Merge Workstream F: unlocks/inventory UI + sigil designer + listening tool

This commit is contained in:
Indiana
2026-07-24 09:39:19 +00:00
18 changed files with 1792 additions and 4 deletions

View 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;
}

View 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

View File

@@ -256,6 +256,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>
@@ -469,7 +473,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)
@@ -531,7 +539,7 @@ function EvpPanel() {
setBandDb(Number.isFinite(peak) ? peak : null)
}
},
})
}, { hasListeningTool })
if (listenerRef.current === listener) setListening(true)
} catch {
if (listenerRef.current === listener) listenerRef.current = null