docs(spec): inventory search redesign — React full-page view + approved mockup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-15 05:57:10 +02:00
parent 7096f30566
commit edf76f50db
3 changed files with 410 additions and 0 deletions

1
.gitignore vendored
View file

@ -15,3 +15,4 @@ frontend/node_modules/
# permission-neutral so devs can `claude` interactively here without
# inheriting production-agent restrictions.
.claude/
.superpowers/

View file

@ -0,0 +1,115 @@
# Inventory Search redesign — React full-page view
**Date:** 2026-07-15
**Status:** Approved (layout + behaviors validated interactively in visual-companion mockup)
**Mockup:** `2026-07-15-inventory-search-redesign-mockup.html` (same directory — the approved clickable reference)
## Problem
The inventory search UI (`static/inventory.html` + `inventory.js`, ~2100 lines of
2010s-era dense gray vanilla HTML) is functionally complete (all filters work
against the Go `inventory-go` service, including the July 2026 spell filters)
but visually dated and inconsistent with the React dashboard. Full redesign
requested.
## Decisions (user-validated)
1. **Platform:** new full-page view in the existing React app (`frontend/`),
reached via `/?view=inventory` — same routing pattern as `?view=dashboard`
in `App.tsx`.
2. **Layout:** facet sidebar (direction A of three mocked options) + active-
filter chips row borrowed from direction B.
3. **Search trigger:** instant auto-search, debounced ~400 ms, no Search
button; in-flight requests aborted via `AbortController`.
4. **Item details:** right-side detail panel opened by row click; ↑/↓ moves
selection, Esc closes.
5. **Set analysis:** dropped entirely. The new page fully replaces
`inventory.html`/`inventory.js` — both deleted at cutover; the "Analyze
Sets" feature ceases to exist.
6. **Shareable URLs:** filter state serialized to the query string.
## Layout (per approved mockup)
- **Top bar:** title, name/material search box (`text` param), Reset (clears
all filters), column picker (⚙), result count + query time.
- **Chips row:** one removable chip per active filter; legendary-cantrip chips
styled gold (#fc6 on #2a2418).
- **Facet sidebar** (~210 px, collapsible groups with active-count badges):
- **Characters** — searchable checkbox list, All/None/Online links, green
online dots (from the `/live` data the app already polls).
- **Item type** — radios: All / Armor / Jewelry / Weapons (+subtype) /
Clothing (maps to `armor_only`/`jewelry_only`/`weapon_only`+`weapon_type`/
`clothing_only`; shirts/pants reachable as clothing subtypes).
- **Slots** — jewelry slots (Ring/Bracelet/Neck/Trinket/Cloak) up front,
armor slots behind a "show armor slots" expander (`slot_names` CSV).
- **Cantrips** — find-as-you-type filter over the legendary cantrip list,
grouped (Attributes / Skills / Defense / Banes / Other); checked items pin
to a "Selected" section on top; gold styling; plus an "any-tier spell
search" text input (`spell_contains`). Checkbox values = the same display
names the old page sent (`legendary_cantrips` CSV, AND semantics).
- **Ratings** (collapsed) — min inputs for the 4 common ratings, "all 20
ratings" expander for the rest (`min_*` params).
- **Equipment sets** (collapsed) — searchable set list (`item_set`/`item_sets`).
- **Item state** (collapsed) — equipped-only, bonded, attuned, rare
(`equipment_status`, `bonded`, `attuned`, `is_rare`).
- **Reqs & value** (collapsed) — wield level ≤, value ≥, workmanship ≥,
burden ≤ (`max_level`, `min_value`, `min_workmanship`, `max_burden`).
- **Results table:** server-side sort via `sort_by`/`sort_dir` (click headers);
sticky header; equipped marker (green ⚔); Spells column with legendary names
gold; pagination footer (200/page default, `page`/`limit`). Column set
togglable via the ⚙ picker, persisted to `localStorage`.
- **Detail panel** (~250 px): item name, slot, equipped state, character,
value/burden/wield/workmanship/mana, full ordered spell list (legendary
gold). Keyboard: ↑/↓/Esc.
## Implementation shape
- **No backend changes.** The existing `GET /api/inv/search/items` supports
every filter above (validated; the spell filters shipped 2026-07-14).
- New directory `frontend/src/components/inventory/`:
- `InventorySearchPage.tsx` — page layout, keyboard handling.
- `FilterSidebar.tsx` + one small component per filter group.
- `ActiveChips.tsx`, `ResultsTable.tsx`, `DetailPanel.tsx`.
- `useInventorySearch.ts` — single-reducer filter state, debounce,
AbortController fetch, URL (de)serialization.
- Filter state ↔ URL: serialize non-default filters into query params on
change (replaceState), parse on mount — bookmarkable searches.
- Styling: `frontend/src/styles/inventory.css`, `.inv-*` class prefix,
dashboard dark tokens (#111 bg, #1a1a1a panels, #333 borders, #88f accent,
#fc6 legendary gold, Segoe UI) — mirrors `map-layout.css` conventions.
- **No new npm dependencies.**
- Cantrip list + grouping: lift the value strings from the old
`inventory.html` checkbox grid (they are the strings the backend matches);
store as a typed constant module.
- Character list: reuse the app's existing live-players data source for names
+ online status; fall back to `/api/inv/characters/list` for characters that
are offline/never online.
## Cutover
Same commit series: add the new view → repoint the dashboard tool-link that
currently opens `/inventory.html` to `/?view=inventory` → delete
`static/inventory.html` and `static/inventory.js`. `/inventory.html` then 404s
to the SPA fallback; acceptable (single-user tool, no external links).
## Testing & verification
The frontend has no test framework (build = `tsc -b && vite build`).
Verification per task: TypeScript build green + live checks in the Vite dev
server against the production API, then post-deploy spot checks of the same
scenarios used for the spell-filter validation (Invuln+Summon → 2 items;
Ring+Invuln → 56; filterless jewelry → ~4.8k). Adding a test harness is out
of scope.
## Deploy
`bash deploy-frontend.sh` (builds + copies into `static/`), commit `static/`
+ `frontend/` + deletions, push; server picks it up via bind mount, no
restart.
## Out of scope
- Set analysis (dropped per decision 5).
- Suitbuilder page (untouched).
- React-window (`InventoryWindow.tsx`) per-character inventory — unrelated.
- Mobile layout (desktop tool); no test framework introduction.

View file

@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Inventory Search — mockup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body { background:#111; color:#eee; font-family:"Segoe UI",sans-serif; font-size:13px; display:flex; flex-direction:column; }
/* ── Top bar ── */
.topbar { display:flex; align-items:center; gap:10px; padding:10px 14px; background:#1a1a1a; border-bottom:2px solid #333; }
.topbar .title { color:#88f; font-weight:600; font-size:15px; white-space:nowrap; }
.searchbox { flex:1; max-width:520px; background:#222; border:1px solid #444; border-radius:4px; padding:7px 12px; color:#eee; font-size:13px; outline:none; }
.searchbox:focus { border-color:#88f; }
.searchbox::placeholder { color:#666; }
.btn { background:#333; border:1px solid #555; color:#ccc; border-radius:4px; padding:6px 14px; font-size:12px; cursor:pointer; }
.btn:hover { background:#444; color:#fff; }
.count { margin-left:auto; color:#888; font-size:12px; white-space:nowrap; }
.count b { color:#eee; }
/* ── Chips ── */
.chipsrow { display:flex; align-items:center; flex-wrap:wrap; gap:6px; padding:8px 14px; background:#151515; border-bottom:1px solid #2a2a2a; min-height:37px; }
.chipsrow .lbl { color:#666; font-size:10px; letter-spacing:1px; }
.chip { display:inline-flex; align-items:center; gap:6px; background:#23233a; border:1px solid #55f; border-radius:12px; padding:2px 10px; font-size:11px; color:#bbd; cursor:default; }
.chip .x { color:#c66; cursor:pointer; font-weight:bold; }
.chip .x:hover { color:#f88; }
.chip.gold { border-color:#a80; color:#fc6; background:#2a2418; }
/* ── Main ── */
.main { display:flex; flex:1; overflow:hidden; }
/* ── Sidebar ── */
.sidebar { width:210px; min-width:210px; background:#1a1a1a; border-right:2px solid #333; overflow-y:auto; padding:8px 10px; }
.grp { border-bottom:1px solid #262626; padding:6px 0; }
.grp-head { display:flex; align-items:center; gap:6px; cursor:pointer; color:#999; text-transform:uppercase; font-size:10px; letter-spacing:1px; padding:3px 0; user-select:none; }
.grp-head:hover { color:#ccc; }
.grp-head .arrow { font-size:9px; transition:transform .15s; }
.grp.open .arrow { transform:rotate(90deg); }
.grp-head .badge { margin-left:auto; background:#3a3a6e; color:#cce; border-radius:8px; padding:0 7px; font-size:9px; }
.grp-body { display:none; padding:5px 0 3px; }
.grp.open .grp-body { display:block; }
.minisearch { width:100%; background:#222; border:1px solid #3a3a3a; border-radius:3px; padding:4px 8px; color:#ccc; font-size:11px; outline:none; margin-bottom:5px; }
.minisearch::placeholder { color:#555; }
.fitem { display:flex; align-items:center; gap:6px; padding:2px 0; font-size:12px; color:#bbb; cursor:pointer; }
.fitem:hover { color:#fff; }
.fitem input { accent-color:#88f; }
.fitem .online { width:6px; height:6px; border-radius:50%; background:#4c4; }
.fitem.gold { color:#fc6; }
.subhead { color:#666; font-size:9px; text-transform:uppercase; letter-spacing:1px; margin:6px 0 2px; }
.linky { color:#88f; font-size:10px; cursor:pointer; }
.linky:hover { text-decoration:underline; }
.range { display:flex; gap:4px; align-items:center; margin:2px 0; }
.range label { flex:1; color:#999; font-size:11px; }
.range input { width:52px; background:#222; border:1px solid #3a3a3a; border-radius:3px; color:#ccc; padding:2px 5px; font-size:11px; }
/* ── Results ── */
.results { flex:1; overflow-y:auto; display:flex; flex-direction:column; }
table { width:100%; border-collapse:collapse; }
thead { position:sticky; top:0; background:#191919; z-index:1; }
th { text-align:left; color:#88f; font-size:11px; font-weight:600; padding:8px 10px; border-bottom:2px solid #333; cursor:pointer; white-space:nowrap; user-select:none; }
th:hover { color:#aaf; }
th.sorted::after { content:" ▼"; font-size:9px; }
td { padding:6px 10px; border-bottom:1px solid #1e1e1e; font-size:12px; color:#bbb; vertical-align:top; }
tbody tr { cursor:pointer; }
tbody tr:hover td { background:#191922; }
tbody tr.sel td { background:#20203a; }
td .leg { color:#fc6; }
td .spells { color:#889; font-size:11px; }
td.num { text-align:right; font-variant-numeric:tabular-nums; }
.equipped { color:#4c4; font-size:10px; }
.pager { display:flex; align-items:center; gap:8px; padding:8px 14px; color:#888; font-size:12px; border-top:1px solid #262626; background:#151515; margin-top:auto; }
.pager .pg { padding:2px 8px; border:1px solid #333; border-radius:3px; cursor:pointer; }
.pager .pg.cur { background:#3a3a6e; border-color:#88f; color:#fff; }
/* ── Detail panel ── */
.detail { width:250px; min-width:250px; background:#161620; border-left:2px solid #333; overflow-y:auto; padding:12px; }
.detail h3 { color:#88f; font-size:14px; margin-bottom:2px; }
.detail .sub { color:#777; font-size:11px; margin-bottom:10px; }
.kv { display:flex; justify-content:space-between; padding:2px 0; font-size:12px; color:#999; }
.kv b { color:#ddd; font-weight:normal; text-align:right; }
.detail hr { border:none; border-top:1px solid #2a2a35; margin:9px 0; }
.detail .sphead { color:#666; font-size:10px; text-transform:uppercase; letter-spacing:1px; margin-bottom:4px; }
.sp { padding:2px 0; font-size:12px; color:#bbb; }
.sp.leg { color:#fc6; }
.detail .hint { color:#555; font-size:10px; margin-top:12px; }
.closex { float:right; color:#666; cursor:pointer; font-size:14px; }
.closex:hover { color:#f88; }
</style>
</head>
<body>
<div class="topbar">
<span class="title">⚔ Inventory Search</span>
<input class="searchbox" placeholder="Search name or material… (e.g. 'sapphire ring')">
<button class="btn" onclick="document.querySelectorAll('.chip').forEach(c=>c.remove())">Reset</button>
<button class="btn">⚙ Columns</button>
<span class="count"><b>56</b> items · 24 ms</span>
</div>
<div class="chipsrow">
<span class="lbl">ACTIVE</span>
<span class="chip">Jewelry <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip">Slot: Ring <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip gold">Legendary Invulnerability <span class="x" onclick="this.parentNode.remove()">×</span></span>
<span class="chip gold">Legendary Summoning <span class="x" onclick="this.parentNode.remove()">×</span></span>
</div>
<div class="main">
<div class="sidebar">
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Characters <span class="badge">All</span></div>
<div class="grp-body">
<input class="minisearch" placeholder="filter characters…">
<div style="margin-bottom:4px"><span class="linky">All</span> · <span class="linky">None</span> · <span class="linky">Online</span></div>
<div class="fitem"><input type="checkbox" checked> Bank of Sawato</div>
<div class="fitem"><input type="checkbox" checked> Bowgod <span class="online"></span></div>
<div class="fitem"><input type="checkbox" checked> Larsson <span class="online"></span></div>
<div class="fitem"><input type="checkbox" checked> Mrbow</div>
<div class="fitem"><input type="checkbox" checked> Kosmo Kramer <span class="online"></span></div>
<div class="fitem" style="color:#555">… 33 more</div>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Item type <span class="badge">1</span></div>
<div class="grp-body">
<div class="fitem"><input type="radio" name="t"> All items</div>
<div class="fitem"><input type="radio" name="t"> Armor</div>
<div class="fitem"><input type="radio" name="t" checked> Jewelry</div>
<div class="fitem"><input type="radio" name="t"> Weapons</div>
<div class="fitem"><input type="radio" name="t"> Clothing</div>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Slots <span class="badge">1</span></div>
<div class="grp-body">
<div class="fitem"><input type="checkbox" checked> Ring</div>
<div class="fitem"><input type="checkbox"> Bracelet</div>
<div class="fitem"><input type="checkbox"> Neck</div>
<div class="fitem"><input type="checkbox"> Trinket</div>
<div class="fitem"><input type="checkbox"> Cloak</div>
<span class="linky">show armor slots ▾</span>
</div>
</div>
<div class="grp open">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Cantrips <span class="badge">2</span></div>
<div class="grp-body">
<input class="minisearch" id="cantripSearch" placeholder="find cantrip… e.g. invuln" oninput="filterCantrips(this.value)">
<div class="subhead">Selected</div>
<div class="fitem gold"><input type="checkbox" checked> Invulnerability</div>
<div class="fitem gold"><input type="checkbox" checked> Summoning</div>
<div class="subhead">Attributes</div>
<div class="fitem cf"><input type="checkbox"> Strength</div>
<div class="fitem cf"><input type="checkbox"> Endurance</div>
<div class="fitem cf"><input type="checkbox"> Quickness</div>
<div class="fitem cf"><input type="checkbox"> Focus</div>
<div class="subhead">Skills</div>
<div class="fitem cf"><input type="checkbox"> Heavy Weapon</div>
<div class="fitem cf"><input type="checkbox"> War Magic</div>
<div class="fitem cf"><input type="checkbox"> Defender</div>
<div class="subhead">Defense</div>
<div class="fitem cf"><input type="checkbox"> Impenetrability</div>
<div class="fitem cf"><input type="checkbox"> Magic Resistance</div>
<div class="fitem" style="color:#555">… all 45 in real page</div>
<div class="subhead">Any-tier spell search</div>
<input class="minisearch" placeholder="spell name contains… e.g. Epic Invuln">
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Ratings</div>
<div class="grp-body">
<div class="range"><label>Dmg rating ≥</label><input placeholder="min"></div>
<div class="range"><label>Crit dmg ≥</label><input placeholder="min"></div>
<div class="range"><label>Heal boost ≥</label><input placeholder="min"></div>
<div class="range"><label>Vitality ≥</label><input placeholder="min"></div>
<span class="linky">all 20 ratings ▾</span>
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Equipment sets</div>
<div class="grp-body"><input class="minisearch" placeholder="find set…"></div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Item state</div>
<div class="grp-body">
<div class="fitem"><input type="checkbox"> Equipped only</div>
<div class="fitem"><input type="checkbox"> Bonded</div>
<div class="fitem"><input type="checkbox"> Rare</div>
</div>
</div>
<div class="grp">
<div class="grp-head" onclick="this.parentNode.classList.toggle('open')"><span class="arrow"></span> Reqs & value</div>
<div class="grp-body">
<div class="range"><label>Wield lvl ≤</label><input placeholder="max"></div>
<div class="range"><label>Value ≥</label><input placeholder="min"></div>
<div class="range"><label>Workmanship ≥</label><input placeholder="min"></div>
</div>
</div>
</div>
<div class="results">
<table>
<thead>
<tr><th>Item</th><th>Character</th><th>Slot</th><th class="num sorted">Value</th><th>Wield</th><th>Spells / Cantrips</th></tr>
</thead>
<tbody id="rows"></tbody>
</table>
<div class="pager">
<span class="pg"></span><span class="pg cur">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg"></span>
<span style="margin-left:auto">200 / page</span>
</div>
</div>
<div class="detail" id="detail"></div>
</div>
<script>
const items = [
{ name:"Gold Ring", char:"Bowgod", slot:"Ring", value:24510, wield:"180", equipped:false, burden:50, work:"—", mana:"142/200",
spells:[["Legendary Invulnerability",1],["Legendary Summoning Prowess",1],["Major Heavy Weapon Aptitude",0],["Blessing of the Arrow Turner",0]] },
{ name:"Sapphire Ring", char:"Larsson", slot:"Ring", value:18200, wield:"150", equipped:true, burden:50, work:"—", mana:"98/180",
spells:[["Legendary Invulnerability",1],["Epic Focus",0],["Minor Impenetrability",0]] },
{ name:"Bronze Ring", char:"Mrbow", slot:"Ring", value:9340, wield:"125", equipped:false, burden:50, work:"—", mana:"77/120",
spells:[["Legendary Invulnerability",1],["Epic Willpower",0]] },
{ name:"Diamond Ring", char:"Kosmo Kramer", slot:"Ring", value:31000, wield:"200", equipped:false, burden:50, work:"—", mana:"200/200",
spells:[["Legendary Invulnerability",1],["Legendary Willpower",1],["Epic Mana Conversion Prowess",0]] },
{ name:"Ivory Ring", char:"Bank of Sawato", slot:"Ring", value:12750, wield:"150", equipped:false, burden:50, work:"—", mana:"64/150",
spells:[["Legendary Invulnerability",1],["Major Void Magic Aptitude",0]] },
{ name:"Black Opal Ring", char:"Bowgod", slot:"Ring", value:27890, wield:"180", equipped:true, burden:50, work:"—", mana:"180/220",
spells:[["Legendary Invulnerability",1],["Legendary Critical Damage",1]] },
];
function render() {
const tb = document.getElementById('rows');
tb.innerHTML = items.map((it,i) => `
<tr onclick="sel(${i})" id="r${i}">
<td>${it.name}${it.equipped ? ' <span class="equipped"></span>' : ''}</td>
<td>${it.char}</td><td>${it.slot}</td>
<td class="num">${it.value.toLocaleString()}</td><td>${it.wield}</td>
<td class="spells">${it.spells.map(([s,l]) => l ? `<span class="leg">${s}</span>` : s).join(', ')}</td>
</tr>`).join('');
}
function sel(i) {
document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel'));
document.getElementById('r'+i).classList.add('sel');
const it = items[i];
document.getElementById('detail').innerHTML = `
<span class="closex" onclick="document.getElementById('detail').innerHTML=hint">×</span>
<h3>${it.name}</h3>
<div class="sub">${it.slot} · ${it.equipped ? '⚔ Equipped' : '📦 Inventory'}</div>
<div class="kv"><span>Character</span><b>${it.char}</b></div>
<div class="kv"><span>Value</span><b>${it.value.toLocaleString()}</b></div>
<div class="kv"><span>Burden</span><b>${it.burden}</b></div>
<div class="kv"><span>Wield req</span><b>Level ${it.wield}</b></div>
<div class="kv"><span>Workmanship</span><b>${it.work}</b></div>
<div class="kv"><span>Mana</span><b>${it.mana}</b></div>
<hr>
<div class="sphead">Spells (${it.spells.length})</div>
${it.spells.map(([s,l]) => `<div class="sp${l ? ' leg' : ''}">${s}</div>`).join('')}
<div class="hint">↑/↓ next item · Esc close</div>`;
}
function filterCantrips(q) {
q = q.toLowerCase();
document.querySelectorAll('.cf').forEach(el => {
el.style.display = el.textContent.toLowerCase().includes(q) ? '' : 'none';
});
}
const hint = '<div class="hint">Click a row to inspect an item.</div>';
document.getElementById('detail').innerHTML = hint;
render();
sel(0);
document.addEventListener('keydown', e => {
const cur = document.querySelector('tbody tr.sel');
if (!cur) return;
const i = +cur.id.slice(1);
if (e.key === 'ArrowDown' && i < items.length-1) { sel(i+1); e.preventDefault(); }
if (e.key === 'ArrowUp' && i > 0) { sel(i-1); e.preventDefault(); }
if (e.key === 'Escape') { document.querySelectorAll('tbody tr').forEach(r=>r.classList.remove('sel')); document.getElementById('detail').innerHTML = hint; }
});
</script>
</body>
</html>