feat(frontend): unlocks/inventory UI, sigil designer, listening-tool EVP threshold
Workstream F of the character-depth/ghost-log spec.
- InventoryPanel.tsx: essence balance, owned unlocks/items (terminal
listing + Codex-style rarity-glow borders on items), and a buy flow
for a small fixed unlock catalog (currently just "listening_tool",
the only key the contract names) with afford/can't-afford button
states. Prices are fetched from a best-guess /api/inventory/catalog
endpoint and fall back to a flagged "(est.)" price sourced from the
spec's own worked example when that endpoint isn't available yet —
the contract doesn't define a price-list REST shape.
- SigilDesigner.tsx + lib/sigil.ts: constrained geometric builder —
points snap to 24 fixed clock-face slots around a circle, capped at
12 to match the backend's payload limit, connected in placement
order. Five hand-drawn stroke-only rune glyphs (eye/crescent/key/
spiral/thorn) overlay the center. Point-cap enforcement, rune
selection and payload-shape building are pure functions in
lib/sigil.ts, unit-tested directly. Saves via POST
/api/inventory/sigils (path inferred; payload shape matches the
contract exactly: {"points": [[x,y],...], "rune": str}). Art
direction: the builder itself reads like a plotting/debug tool
(crosshair cursor, monospace coordinate HUD) while the rendered
lines + rune glow violet, consistent with GhostGlyph's conventions.
- lib/evp.ts: new evpThresholdDb(hasListeningTool) pure function and
EvpListener.start() now accepts { hasListeningTool } to lower the
EVP anomaly threshold (8dB -> 4dB) when the unlock is owned — wired
from useAuth().user.unlocks in SeancePage's EvpPanel, a real
gameplay effect on which faint signals register as anomalies.
- api.ts User type gains unlocks/essence per the contract's /auth/me
extension; new InventoryPage.tsx mounts both components behind auth
at /inventory, linked from the séance nav.
166/166 tests pass (137 pre-existing + 29 new), i18n coverage check
clean, tsc -b clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
80
frontend/src/pages/InventoryPage.css
Normal file
80
frontend/src/pages/InventoryPage.css
Normal file
@@ -0,0 +1,80 @@
|
||||
/* InventoryPage — thin shell around InventoryPanel + SigilDesigner,
|
||||
matching CodexPage.css's page-chrome conventions. */
|
||||
|
||||
.inventory-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(178, 107, 255, 0.08), transparent 70%),
|
||||
var(--qm-bg);
|
||||
color: var(--qm-text);
|
||||
font-family: var(--qm-font-ui);
|
||||
padding: 2rem clamp(1rem, 4vw, 3.5rem) 5rem;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inventory-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(255, 255, 255, 0.022) 0px,
|
||||
rgba(255, 255, 255, 0.022) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
),
|
||||
radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.6) 100%);
|
||||
}
|
||||
|
||||
.inventory-loading {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--qm-green);
|
||||
font-family: var(--qm-font-ui);
|
||||
}
|
||||
|
||||
.inventory-header {
|
||||
text-align: center;
|
||||
margin: 0 auto 2.2rem;
|
||||
max-width: 760px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.inventory-back {
|
||||
color: var(--qm-dim);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.inventory-back:hover {
|
||||
color: var(--qm-green);
|
||||
}
|
||||
|
||||
.inventory-title {
|
||||
font-family: var(--qm-font-display);
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
letter-spacing: 0.14em;
|
||||
margin: 0.6rem 0 0.4rem;
|
||||
color: #eef7f0;
|
||||
}
|
||||
|
||||
.inventory-subtitle {
|
||||
color: var(--qm-dim);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inventory-main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.4rem;
|
||||
}
|
||||
47
frontend/src/pages/InventoryPage.tsx
Normal file
47
frontend/src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// The Reliquary Shelf — the seeker's unlocks/items/essence and the sigil
|
||||
// designer, gated behind auth like SeancePage.
|
||||
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../state/auth'
|
||||
import { InventoryPanel } from '../components/InventoryPanel'
|
||||
import { SigilDesigner } from '../components/SigilDesigner'
|
||||
import './InventoryPage.css'
|
||||
|
||||
export function InventoryPage() {
|
||||
const { user, checking } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="inventory-loading" role="status">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
return (
|
||||
<div className="inventory-page">
|
||||
<div className="inventory-overlay" aria-hidden="true" />
|
||||
<header className="inventory-header">
|
||||
<Link to="/seance" className="inventory-back">
|
||||
{t('inventory.back', { defaultValue: '← back to the séance' })}
|
||||
</Link>
|
||||
<h1 className="inventory-title">{t('inventory.pageTitle', { defaultValue: 'THE SHELF' })}</h1>
|
||||
<p className="inventory-subtitle">
|
||||
{t('inventory.pageSubtitle', {
|
||||
defaultValue: 'what the veil has left you, and what it will still trade for',
|
||||
})}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="inventory-main">
|
||||
<InventoryPanel unlocks={user.unlocks} essence={user.essence} />
|
||||
<SigilDesigner />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InventoryPage
|
||||
@@ -254,6 +254,10 @@ function SeanceSession({ username }: { username: string }) {
|
||||
{t('nav.codex')}
|
||||
</Link>
|
||||
|
||||
<Link to="/inventory" className="seance-codex-link">
|
||||
{t('nav.inventory', { defaultValue: 'inventory' })}
|
||||
</Link>
|
||||
|
||||
<span className="seance-user" title={username}>
|
||||
{username}
|
||||
</span>
|
||||
@@ -464,7 +468,11 @@ function WirePanel() {
|
||||
|
||||
function EvpPanel() {
|
||||
const { sendAnomaly } = useSeance()
|
||||
const { user } = useAuth()
|
||||
const { t } = useTranslation()
|
||||
// "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP
|
||||
// detector's threshold so fainter deviations register — see lib/evp.ts.
|
||||
const hasListeningTool = user?.unlocks?.includes('listening_tool') ?? false
|
||||
const [listening, setListening] = useState(false)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [denied, setDenied] = useState(false)
|
||||
@@ -526,7 +534,7 @@ function EvpPanel() {
|
||||
setBandDb(Number.isFinite(peak) ? peak : null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}, { hasListeningTool })
|
||||
if (listenerRef.current === listener) setListening(true)
|
||||
} catch {
|
||||
if (listenerRef.current === listener) listenerRef.current = null
|
||||
|
||||
Reference in New Issue
Block a user