# Inventory Search Redesign Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace `static/inventory.html`/`inventory.js` with a dark-themed React full-page inventory search at `/?view=inventory` (facet sidebar, chips, instant search, detail panel), per the approved spec + mockup. **Architecture:** New self-contained component tree under `frontend/src/components/inventory/` with a single `useInventorySearch` hook owning filter state, debounce, aborting fetch, and URL serialization. Zero backend changes — everything talks to the existing `GET /api/inv/search/items` (plus `/api/inv/characters/list`, `/api/inv/sets/list`, `/api/live`). Spec: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-design.md`; visual ground truth: `docs/superpowers/specs/2026-07-15-inventory-search-redesign-mockup.html` (styling/spacing/colors MUST follow it). **Tech Stack:** React 19 + TypeScript + Vite (existing app, NO new npm deps). No test framework exists — per-task verification is `npm run build` (runs `tsc -b`) green; live verification is the final task. npm exists on this Windows machine; run builds via the Bash tool. **Working directory for git:** `C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord` **Build command (every task):** ```bash cd "C:/Users/erikn/source/repos/dereth-workspace/MosswartOverlord/frontend" && npm run build ``` (If `node_modules` is missing, run `npm install` once first.) **API cheat-sheet (verified live):** - `GET /api/inv/search/items?...` → `{items:[...], total_count, page, limit, has_next, has_previous}`. Item fields used here: `name, character_name, slot_name, value, burden, wield_level, workmanship, spell_names (string[]), is_equipped, is_bonded, is_attuned, is_rare, item_set_name, armor_level, max_damage, condition_percent, object_class_name, last_updated`. - Params: `text, character, characters, include_all_characters, armor_only, jewelry_only, weapon_only, weapon_type, clothing_only, shirt_only, pants_only, slot_names, legendary_cantrips (CSV, AND), spell_contains, equipment_status (equipped|unequipped), bonded, attuned, is_rare, item_set, min_* rating params, max_level, min_value, min_workmanship, max_burden, sort_by, sort_dir, page, limit`. - `GET /api/inv/characters/list` → `{characters:[{character_name, item_count, last_updated}]}` - `GET /api/inv/sets/list` → `{sets:[{id, item_count, name}]}` — display `id` (e.g. "Defender's Set"); the `name` field has a broken "Unknown Set " prefix, ignore it. Send `item_set=`. - `GET /api/live` → `{players:[{character_name, ...}]}` — online characters. --- ### Task 1: Types + constants module **Files:** - Create: `frontend/src/components/inventory/types.ts` - Create: `frontend/src/components/inventory/constants.ts` - [ ] **Step 1: Create `types.ts`:** ```ts // Item shape returned by /api/inv/search/items (subset we render). export interface InvItem { name: string; character_name: string; slot_name: string | null; value: number | null; burden: number | null; wield_level: number | null; workmanship: number | null; spell_names?: string[]; is_equipped: boolean; is_bonded: boolean; is_attuned: boolean; is_rare: boolean; item_set_name?: string; armor_level: number | null; max_damage: number | null; condition_percent: number | null; object_class_name?: string; material_name?: string; last_updated?: string; } export interface SearchResponse { items: InvItem[]; total_count: number; page: number; limit: number; has_next: boolean; has_previous: boolean; error?: string; } export type ItemType = 'all' | 'armor' | 'jewelry' | 'weapon' | 'clothing' | 'shirt' | 'pants'; export interface SearchFilters { text: string; /** 'all' → include_all_characters=true; otherwise explicit list. */ characters: 'all' | string[]; itemType: ItemType; weaponType: string; // '' = all weapon types slots: string[]; cantrips: string[]; // full "Legendary X" value strings spellContains: string; /** min-rating param name -> value ('' = unset). */ ratings: Record; itemSet: string; // set id, '' = none equippedOnly: boolean; bonded: boolean; attuned: boolean; rare: boolean; maxLevel: number | ''; minValue: number | ''; minWorkmanship: number | ''; maxBurden: number | ''; sortBy: string; sortDir: 'asc' | 'desc'; page: number; } export const DEFAULT_FILTERS: SearchFilters = { text: '', characters: 'all', itemType: 'all', weaponType: '', slots: [], cantrips: [], spellContains: '', ratings: {}, itemSet: '', equippedOnly: false, bonded: false, attuned: false, rare: false, maxLevel: '', minValue: '', minWorkmanship: '', maxBurden: '', sortBy: 'name', sortDir: 'asc', page: 1, }; ``` - [ ] **Step 2: Create `constants.ts`** — the 47 cantrip values are lifted verbatim from the old page's checkboxes (they are what the backend's `legendary_cantrips` matcher expects); labels drop the "Legendary " prefix: ```ts export interface CantripDef { value: string; label: string; } export interface CantripGroup { group: string; items: CantripDef[]; } const c = (value: string): CantripDef => ({ value, label: value.replace(/^Legendary /, '') }); export const CANTRIP_GROUPS: CantripGroup[] = [ { group: 'Attributes', items: [ c('Legendary Strength'), c('Legendary Endurance'), c('Legendary Quickness'), c('Legendary Coordination'), c('Legendary Willpower'), c('Legendary Focus'), ]}, { group: 'Weapon skills', items: [ c('Legendary Heavy Weapon Aptitude'), c('Legendary Light Weapon Aptitude'), c('Legendary Finesse Weapon Aptitude'), c('Legendary Missile Weapon Aptitude'), c('Legendary Two Handed Combat Aptitude'), c('Legendary Dual Wield Aptitude'), c('Legendary Shield Aptitude'), c('Legendary Sneak Attack Prowess'), c('Legendary Dirty Fighting Prowess'), c('Legendary Recklessness Prowess'), c('Legendary Defender'), c('Legendary Blood Thirst'), ]}, { group: 'Magic', items: [ c('Legendary War Magic Aptitude'), c('Legendary Void Magic Aptitude'), c('Legendary Creature Enchantment Aptitude'), c('Legendary Item Enchantment Aptitude'), c('Legendary Life Magic Aptitude'), c('Legendary Mana Conversion Prowess'), c('Legendary Arcane Prowess'), c('Legendary Hermetic Link'), c('Legendary Spirit Thirst'), c('Legendary Magic Resistance'), ]}, { group: 'Utility', items: [ c('Legendary Summoning Prowess'), c('Legendary Healing Prowess'), c('Legendary Leadership'), c('Legendary Deception Prowess'), c('Legendary Person Attunement'), c('Legendary Magic Item Tinkering Expertise'), ]}, { group: 'Defense', items: [ c('Legendary Invulnerability'), c('Legendary Impenetrability'), c('Legendary Impregnability'), c('Legendary Armor'), ]}, { group: 'Wards & Banes', items: [ c('Legendary Flame Ward'), c('Legendary Frost Ward'), c('Legendary Acid Ward'), c('Legendary Storm Ward'), c('Legendary Slashing Ward'), c('Legendary Piercing Ward'), c('Legendary Bludgeoning Ward'), c('Legendary Piercing Bane'), c('Legendary Storm Bane'), ]}, ]; export const JEWELRY_SLOTS = ['Ring', 'Bracelet', 'Neck', 'Trinket', 'Cloak']; export const ARMOR_SLOTS = ['Head', 'Chest', 'Abdomen', 'Upper Arms', 'Lower Arms', 'Hands', 'Upper Legs', 'Lower Legs', 'Feet', 'Shield']; export const WEAPON_TYPES: Array<{ value: string; label: string }> = [ { value: '', label: 'All weapons' }, { value: 'heavy', label: 'Heavy' }, { value: 'light', label: 'Light' }, { value: 'finesse', label: 'Finesse' }, { value: 'two_handed', label: 'Two-handed' }, { value: 'bow', label: 'Bow' }, { value: 'crossbow', label: 'Crossbow' }, { value: 'thrown', label: 'Thrown' }, { value: 'caster', label: 'Wand/Staff/Orb' }, ]; export interface RatingDef { param: string; label: string; common?: boolean; } export const RATING_DEFS: RatingDef[] = [ { param: 'min_damage_rating', label: 'Damage rating', common: true }, { param: 'min_crit_damage_rating', label: 'Crit damage', common: true }, { param: 'min_heal_boost_rating', label: 'Heal boost', common: true }, { param: 'min_vitality_rating', label: 'Vitality', common: true }, { param: 'min_armor', label: 'Armor level' }, { param: 'min_damage_resist_rating', label: 'Damage resist' }, { param: 'min_crit_resist_rating', label: 'Crit resist' }, { param: 'min_crit_damage_resist_rating', label: 'Crit dmg resist' }, { param: 'min_healing_resist_rating', label: 'Healing resist' }, { param: 'min_nether_resist_rating', label: 'Nether resist' }, { param: 'min_healing_rating', label: 'Healing rating' }, { param: 'min_dot_resist_rating', label: 'DoT resist' }, { param: 'min_life_resist_rating', label: 'Life resist' }, { param: 'min_sneak_attack_rating', label: 'Sneak attack' }, { param: 'min_recklessness_rating', label: 'Recklessness' }, { param: 'min_deception_rating', label: 'Deception' }, { param: 'min_pk_damage_rating', label: 'PK damage' }, { param: 'min_pk_damage_resist_rating', label: 'PK dmg resist' }, { param: 'min_gear_pk_damage_rating', label: 'Gear PK dmg' }, { param: 'min_gear_pk_damage_resist_rating', label: 'Gear PK resist' }, { param: 'min_tinks', label: 'Tinks' }, ]; export interface ColumnDef { key: string; label: string; sortKey?: string; defaultVisible: boolean; } // sortKey values must exist in the backend's sortMapping (search.go). export const COLUMNS: ColumnDef[] = [ { key: 'name', label: 'Item', sortKey: 'name', defaultVisible: true }, { key: 'character_name', label: 'Character', sortKey: 'character_name', defaultVisible: true }, { key: 'slot_name', label: 'Slot', defaultVisible: true }, { key: 'value', label: 'Value', sortKey: 'value', defaultVisible: true }, { key: 'wield_level', label: 'Wield', sortKey: 'level', defaultVisible: true }, { key: 'spell_names', label: 'Spells / Cantrips', sortKey: 'spell_names', defaultVisible: true }, { key: 'armor_level', label: 'Armor', sortKey: 'armor', defaultVisible: false }, { key: 'max_damage', label: 'Max Dmg', sortKey: 'damage', defaultVisible: false }, { key: 'workmanship', label: 'Work', sortKey: 'workmanship', defaultVisible: false }, { key: 'item_set_name', label: 'Set', sortKey: 'item_set', defaultVisible: false }, { key: 'object_class_name', label: 'Type', sortKey: 'item_type_name', defaultVisible: false }, { key: 'burden', label: 'Burden', defaultVisible: false }, { key: 'condition_percent', label: 'Cond %', defaultVisible: false }, { key: 'last_updated', label: 'Updated', sortKey: 'last_updated', defaultVisible: false }, ]; export const PAGE_SIZE = 200; export const COLUMNS_LS_KEY = 'inv.visibleColumns'; ``` - [ ] **Step 3: Build** — expected green (files compile standalone). - [ ] **Step 4: Commit** ```bash git add frontend/src/components/inventory/ git commit -m "feat(frontend): inventory search types + filter/column constants" ``` --- ### Task 2: useInventorySearch hook **Files:** - Create: `frontend/src/components/inventory/useInventorySearch.ts` - [ ] **Step 1: Create the hook** — owns filters, URL (de)serialization (single `q` JSON param alongside `view=inventory`), 400 ms debounce, AbortController fetch: ```ts import { useCallback, useEffect, useRef, useState } from 'react'; import { DEFAULT_FILTERS, type SearchFilters, type SearchResponse } from './types'; import { PAGE_SIZE } from './constants'; /** Serialize only the keys that differ from defaults, as a compact q= JSON param. */ function filtersToUrl(f: SearchFilters): void { const diff: Partial = {}; for (const k of Object.keys(DEFAULT_FILTERS) as Array) { if (JSON.stringify(f[k]) !== JSON.stringify(DEFAULT_FILTERS[k])) (diff as any)[k] = f[k]; } const url = new URL(window.location.href); url.searchParams.set('view', 'inventory'); if (Object.keys(diff).length) url.searchParams.set('q', JSON.stringify(diff)); else url.searchParams.delete('q'); window.history.replaceState(null, '', url); } function filtersFromUrl(): SearchFilters { try { const q = new URLSearchParams(window.location.search).get('q'); if (!q) return DEFAULT_FILTERS; return { ...DEFAULT_FILTERS, ...JSON.parse(q) }; } catch { return DEFAULT_FILTERS; } } export function buildParams(f: SearchFilters): URLSearchParams { const p = new URLSearchParams(); if (f.characters === 'all') p.set('include_all_characters', 'true'); else if (f.characters.length === 1) p.set('character', f.characters[0]); else p.set('characters', f.characters.join(',')); if (f.text) p.set('text', f.text); switch (f.itemType) { case 'armor': p.set('armor_only', 'true'); break; case 'jewelry': p.set('jewelry_only', 'true'); break; case 'clothing': p.set('clothing_only', 'true'); break; case 'shirt': p.set('shirt_only', 'true'); break; case 'pants': p.set('pants_only', 'true'); break; case 'weapon': p.set('weapon_only', 'true'); if (f.weaponType) p.set('weapon_type', f.weaponType); break; } if (f.slots.length) p.set('slot_names', f.slots.join(',')); if (f.cantrips.length) p.set('legendary_cantrips', f.cantrips.join(',')); if (f.spellContains) p.set('spell_contains', f.spellContains); for (const [param, v] of Object.entries(f.ratings)) if (v !== '') p.set(param, String(v)); if (f.itemSet) p.set('item_set', f.itemSet); if (f.equippedOnly) p.set('equipment_status', 'equipped'); if (f.bonded) p.set('bonded', 'true'); if (f.attuned) p.set('attuned', 'true'); if (f.rare) p.set('is_rare', 'true'); if (f.maxLevel !== '') p.set('max_level', String(f.maxLevel)); if (f.minValue !== '') p.set('min_value', String(f.minValue)); if (f.minWorkmanship !== '') p.set('min_workmanship', String(f.minWorkmanship)); if (f.maxBurden !== '') p.set('max_burden', String(f.maxBurden)); p.set('sort_by', f.sortBy); p.set('sort_dir', f.sortDir); p.set('page', String(f.page)); p.set('limit', String(PAGE_SIZE)); return p; } export interface InventorySearch { filters: SearchFilters; /** Patch filters; resets page to 1 unless the patch itself sets page. */ update: (patch: Partial) => void; reset: () => void; result: SearchResponse | null; loading: boolean; error: string | null; queryMs: number | null; } export function useInventorySearch(): InventorySearch { const [filters, setFilters] = useState(filtersFromUrl); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [queryMs, setQueryMs] = useState(null); const abortRef = useRef(null); const update = useCallback((patch: Partial) => { setFilters(prev => ({ ...prev, page: 'page' in patch ? prev.page : 1, ...patch })); }, []); const reset = useCallback(() => setFilters(DEFAULT_FILTERS), []); useEffect(() => { filtersToUrl(filters); const timer = setTimeout(async () => { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; setLoading(true); const t0 = performance.now(); try { const res = await fetch(`/api/inv/search/items?${buildParams(filters)}`, { credentials: 'include', signal: ctrl.signal, }); if (!res.ok) throw new Error(`search: HTTP ${res.status}`); const data: SearchResponse = await res.json(); if (data.error) throw new Error(data.error); setResult(data); setError(null); setQueryMs(Math.round(performance.now() - t0)); } catch (e: any) { if (e?.name !== 'AbortError') setError(String(e?.message ?? e)); } finally { if (abortRef.current === ctrl) setLoading(false); } }, 400); return () => clearTimeout(timer); }, [filters]); return { filters, update, reset, result, loading, error, queryMs }; } ``` - [ ] **Step 2: Build** — green. - [ ] **Step 3: Commit** — `feat(frontend): useInventorySearch hook (debounced abortable search, URL state)` --- ### Task 3: Routing, page scaffold, CSS **Files:** - Modify: `frontend/src/App.tsx` - Create: `frontend/src/components/inventory/InventorySearchPage.tsx` - Create: `frontend/src/styles/inventory.css` - [ ] **Step 1: Route.** In `App.tsx`, add the import and branch (current file branches only on `'dashboard'`): ```tsx import { InventorySearchPage } from './components/inventory/InventorySearchPage'; import './styles/inventory.css'; ``` and change the ternary to: ```tsx {view === 'dashboard' ? : view === 'inventory' ? : } ``` Also update the header comment to document `/?view=inventory`. - [ ] **Step 2: Page scaffold** — `InventorySearchPage.tsx`. Full layout with top bar wired to the hook; sidebar/table/detail rendered from components built in Tasks 4-6 (create with placeholder stubs now so this task builds; the stubs are replaced by the real components in their tasks): ```tsx import { useState } from 'react'; import { useInventorySearch } from './useInventorySearch'; import { FilterSidebar } from './FilterSidebar'; import { ActiveChips } from './ActiveChips'; import { ResultsTable } from './ResultsTable'; import { DetailPanel } from './DetailPanel'; import type { InvItem } from './types'; export function InventorySearchPage() { const search = useInventorySearch(); const [selected, setSelected] = useState(null); return (
⚔ Inventory Search search.update({ text: e.target.value })} /> {search.error ? {search.error} : search.result ? <>{search.result.total_count.toLocaleString()} items {search.queryMs != null && <> · {search.queryMs} ms} {search.loading && ' · …'} : 'loading…'}
{selected && setSelected(null)} />}
); } ``` For this task only, create minimal stub files so the build passes (each replaced by its real task): ```tsx // FilterSidebar.tsx (stub — replaced in Task 4) import type { InventorySearch } from './useInventorySearch'; export function FilterSidebar(_: { search: InventorySearch }) { return
; } ``` ```tsx // ActiveChips.tsx (stub — replaced in Task 5) import type { InventorySearch } from './useInventorySearch'; export function ActiveChips(_: { search: InventorySearch }) { return null; } ``` ```tsx // ResultsTable.tsx (stub — replaced in Task 5) import type { InventorySearch } from './useInventorySearch'; import type { InvItem } from './types'; export function ResultsTable(_: { search: InventorySearch; selected: InvItem | null; onSelect: (i: InvItem | null) => void }) { return
; } ``` ```tsx // DetailPanel.tsx (stub — replaced in Task 6) import type { InvItem } from './types'; export function DetailPanel(_: { item: InvItem; onClose: () => void }) { return null; } ``` - [ ] **Step 3: CSS** — `frontend/src/styles/inventory.css`. Port the approved mockup's `