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

@@ -8,6 +8,7 @@ import { GhostLog } from './components/GhostLog'
import { CodexEntityPage } from './pages/CodexEntityPage'
import { CodexPage } from './pages/CodexPage'
import { EnterPage } from './pages/EnterPage'
import { InventoryPage } from './pages/InventoryPage'
import { LandingPage } from './pages/LandingPage'
import { SeancePage } from './pages/SeancePage'
import { ShopPage } from './pages/ShopPage'
@@ -29,6 +30,7 @@ function App() {
<Route path="/codex" element={<CodexPage />} />
<Route path="/codex/:id" element={<CodexEntityPage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="*" element={<LandingPage />} />
</Routes>
</BrowserRouter>

View File

@@ -1,6 +1,10 @@
export type User = {
id: string
username: string
/** Unlock keys the user has earned (e.g. 'listening_tool'). Contract: GET /auth/me. */
unlocks: string[]
/** Current spendable currency balance. Contract: GET /auth/me. */
essence: number
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {

View File

@@ -0,0 +1,231 @@
/* InventoryPanel — terminal item listing meets occult rarity-glow cards.
Reuses the app-wide palette (App.css): bg #07070d, panel rgba(16,16,26,.85),
phosphor #7cffb2, violet #b26bff, blood #ff3b5c, border rgba(124,255,178,.18).
Rarity glow borders mirror CodexPage.css's `.codex-rarity[data-rarity]`. */
.inv-panel {
display: flex;
flex-direction: column;
gap: 1.6rem;
font-family: var(--qm-font-ui);
color: var(--qm-text);
}
/* ---- essence readout: big terminal stat, front and center ---- */
.inv-essence {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.9rem 1.2rem;
background: var(--qm-panel);
border: 1px solid var(--qm-border);
border-radius: 8px;
box-shadow: 0 0 22px rgba(124, 255, 178, 0.08);
}
.inv-essence-label {
font-size: 0.68rem;
letter-spacing: 0.32em;
text-transform: uppercase;
color: var(--qm-dim);
}
.inv-essence-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--qm-green);
text-shadow: 0 0 14px rgba(124, 255, 178, 0.55);
}
/* ---- sections ---- */
.inv-section-title {
font-family: var(--qm-font-display);
font-size: 1rem;
letter-spacing: 0.1em;
color: #eef7f0;
margin: 0 0 0.8rem;
}
.inv-state {
color: var(--qm-dim);
font-size: 0.85rem;
}
.inv-state-error {
color: var(--qm-blood);
}
/* ---- terminal-style item/unlock listing ---- */
.inv-term-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.inv-term-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
background: rgba(16, 16, 26, 0.6);
border: 1px solid var(--qm-border);
border-radius: 4px;
font-size: 0.82rem;
}
.inv-term-empty {
color: var(--qm-dim);
font-size: 0.82rem;
}
.inv-key {
font-family: var(--qm-font-ui);
color: #eef7f0;
letter-spacing: 0.02em;
}
.inv-tag {
font-size: 0.65rem;
letter-spacing: 0.15em;
padding: 0.12rem 0.4rem;
border-radius: 3px;
border: 1px solid;
}
.inv-tag-owned {
color: var(--qm-green);
border-color: rgba(124, 255, 178, 0.5);
text-shadow: 0 0 8px rgba(124, 255, 178, 0.5);
}
.inv-tag-locked {
color: var(--qm-dim);
border-color: rgba(138, 143, 152, 0.45);
}
/* ---- catalog offer cards ---- */
.inv-offer-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.9rem;
}
.inv-offer-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.9rem 1rem;
background: var(--qm-panel);
border: 1px solid var(--qm-border);
border-radius: 8px;
transition: border-color 200ms ease, box-shadow 200ms ease;
}
.inv-offer-card.is-affordable {
border-color: rgba(178, 107, 255, 0.4);
box-shadow: 0 0 18px rgba(178, 107, 255, 0.14);
}
.inv-offer-card.is-locked {
opacity: 0.72;
}
.inv-price {
font-size: 0.78rem;
color: var(--qm-dim);
}
.inv-price-est {
opacity: 0.7;
font-style: italic;
}
.inv-buy-btn {
margin-top: 0.2rem;
padding: 0.45rem 0.8rem;
font-family: var(--qm-font-ui);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #0a0a12;
background: var(--qm-violet);
border: none;
border-radius: 5px;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
}
.inv-buy-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 16px rgba(178, 107, 255, 0.5);
}
.inv-buy-btn:disabled {
cursor: not-allowed;
opacity: 0.4;
background: #3a3a46;
color: rgba(216, 230, 220, 0.5);
box-shadow: none;
}
.inv-offer-error {
margin: 0;
font-size: 0.7rem;
color: var(--qm-blood);
}
/* ---- item grid: rarity-glow borders, same convention as CodexPage ---- */
.inv-item-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.7rem;
}
.inv-item-card {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.7rem 0.85rem;
background: rgba(16, 16, 26, 0.7);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 6px;
}
.inv-item-type {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--qm-dim);
}
.inv-item-card[data-rarity='common'] {
border-color: rgba(138, 143, 152, 0.45);
}
.inv-item-card[data-rarity='uncommon'] {
border-color: rgba(124, 255, 178, 0.45);
}
.inv-item-card[data-rarity='rare'] {
border-color: rgba(178, 107, 255, 0.5);
box-shadow: 0 0 14px rgba(178, 107, 255, 0.2);
}
.inv-item-card[data-rarity='mythic'] {
border-color: rgba(255, 157, 74, 0.55);
box-shadow: 0 0 16px rgba(255, 157, 74, 0.3), inset 0 0 8px rgba(255, 157, 74, 0.12);
}

View File

@@ -0,0 +1,136 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { InventoryPanel } from './InventoryPanel'
function mockFetch(handlers: {
items?: unknown
itemsStatus?: number
catalog?: unknown
catalogStatus?: number
purchaseStatus?: number
}) {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.includes('/api/inventory/items')) {
return new Response(JSON.stringify(handlers.items ?? { items: [] }), {
status: handlers.itemsStatus ?? 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (url.includes('/api/inventory/catalog')) {
if (handlers.catalogStatus && handlers.catalogStatus !== 200) {
return new Response('not found', { status: handlers.catalogStatus })
}
return new Response(JSON.stringify(handlers.catalog ?? { unlocks: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
if (url.includes('/api/inventory/unlocks/') && init?.method === 'POST') {
return new Response('{}', { status: handlers.purchaseStatus ?? 200 })
}
return new Response('not found', { status: 404 })
}),
)
}
describe('InventoryPanel', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('shows the essence balance prominently', async () => {
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
render(<InventoryPanel unlocks={[]} essence={128} />)
expect(screen.getByTestId('essence-value').textContent).toBe('128')
await screen.findByRole('button', { name: /buy/i }) // let pending fetches settle
})
it('lists owned unlocks with an [OWNED] tag and hides them from the buy catalog', async () => {
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
render(<InventoryPanel unlocks={['listening_tool']} essence={5} />)
expect(await screen.findByText('listening_tool')).toBeTruthy()
expect(screen.getByText('[OWNED]')).toBeTruthy()
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
})
it('disables the buy button when the user cannot afford the unlock', async () => {
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
render(<InventoryPanel unlocks={[]} essence={10} />)
const btn = await screen.findByRole('button', { name: /buy/i })
await waitFor(() => expect(btn).toHaveProperty('disabled', true))
})
it('enables the buy button when the user can afford the unlock', async () => {
mockFetch({ catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] } })
render(<InventoryPanel unlocks={[]} essence={100} />)
const btn = await screen.findByRole('button', { name: /buy/i })
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
})
it('falls back to a flagged estimated price when the catalog endpoint is unavailable', async () => {
mockFetch({ catalogStatus: 404 })
render(<InventoryPanel unlocks={[]} essence={100} />)
await waitFor(() => expect(screen.getByText(/\(est\.\)/)).toBeTruthy())
const btn = screen.getByRole('button', { name: /buy/i })
expect(btn).toHaveProperty('disabled', false) // 100 essence affords the 40 fallback price
})
it('purchasing successfully moves the unlock into the owned list and deducts essence', async () => {
mockFetch({
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
purchaseStatus: 200,
})
const user = userEvent.setup()
render(<InventoryPanel unlocks={[]} essence={100} />)
const btn = await screen.findByRole('button', { name: /buy/i })
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
await user.click(btn)
await waitFor(() => expect(screen.getByTestId('essence-value').textContent).toBe('60'))
expect(screen.getByText('[OWNED]')).toBeTruthy()
expect(screen.queryByRole('button', { name: /buy/i })).toBeNull()
})
it('shows an insufficient-funds message on a 402 without changing essence', async () => {
mockFetch({
catalog: { unlocks: [{ unlock_key: 'listening_tool', price: 40 }] },
purchaseStatus: 402,
})
const user = userEvent.setup()
render(<InventoryPanel unlocks={[]} essence={100} />)
const btn = await screen.findByRole('button', { name: /buy/i })
await waitFor(() => expect(btn).toHaveProperty('disabled', false))
await user.click(btn)
await screen.findByRole('alert')
expect(screen.getByTestId('essence-value').textContent).toBe('100')
})
it('renders items with a rarity data attribute when payload carries a known rarity', async () => {
mockFetch({
items: {
items: [
{
item_type: 'trinket',
item_key: 'silver-locket',
payload: { rarity: 'rare' },
obtained_at: '2026-01-01T00:00:00Z',
},
],
},
})
render(<InventoryPanel unlocks={[]} essence={0} />)
const key = await screen.findByText('silver-locket')
const card = key.closest('[data-rarity]')
expect(card?.getAttribute('data-rarity')).toBe('rare')
})
})

View File

@@ -0,0 +1,261 @@
// InventoryPanel — "terminal item listing" crossed with occult rarity-glow
// cards (see CodexPage.css's `.codex-rarity[data-rarity]` convention, reused
// here). Shows the seeker's essence balance, their owned unlocks/items, and
// a small fixed catalog of purchasable unlocks with afford/can't-afford
// buy buttons.
//
// Backend-shape note (see report): the contract only names
// GET /auth/me { unlocks: string[], essence: number } and
// POST /api/inventory/unlocks/{unlock_key} explicitly. It does not define a
// REST shape for *listing* a user's items, nor for exposing per-unlock
// prices. This component:
// - fetches GET /api/inventory/items for the item list (reasonable REST
// extension of "list a user's ... items" per Workstream C's brief),
// - optimistically fetches GET /api/inventory/catalog for prices, and
// falls back to FALLBACK_PRICES (sourced directly from the spec's own
// worked example, `{"listening_tool": 40}`) — flagged "(est.)" in the
// UI — when that endpoint isn't available yet.
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { InventoryItem, Rarity } from '../lib/types'
import './InventoryPanel.css'
/**
* The small fixed catalog of unlocks this panel offers. "listening_tool" is
* the only unlock key named anywhere in the contract; more can be appended
* here once other workstreams define further unlock keys.
*/
const OFFERED_UNLOCK_KEYS: readonly string[] = ['listening_tool']
/** Fallback prices, used only if /api/inventory/catalog is unavailable. */
const FALLBACK_PRICES: Record<string, number> = { listening_tool: 40 }
type CatalogState = 'loading' | 'live' | 'fallback'
export type InventoryPanelProps = {
/** Current unlock keys owned by the user (from GET /auth/me). */
unlocks: string[]
/** Current spendable essence balance (from GET /auth/me). */
essence: number
}
function isRarity(x: unknown): x is Rarity {
return x === 'common' || x === 'uncommon' || x === 'rare' || x === 'mythic'
}
export function InventoryPanel({ unlocks, essence: initialEssence }: InventoryPanelProps) {
const { t } = useTranslation()
const [ownedUnlocks, setOwnedUnlocks] = useState<string[]>(unlocks)
const [essence, setEssence] = useState(initialEssence)
const [items, setItems] = useState<InventoryItem[]>([])
const [itemsState, setItemsState] = useState<'loading' | 'ready' | 'error'>('loading')
const [prices, setPrices] = useState<Record<string, number>>(FALLBACK_PRICES)
const [catalogState, setCatalogState] = useState<CatalogState>('loading')
const [purchasing, setPurchasing] = useState<string | null>(null)
const [purchaseError, setPurchaseError] = useState<Record<string, string>>({})
// Keep local state in sync if the parent's auth snapshot changes (e.g.
// after a full /auth/me refresh elsewhere in the app).
useEffect(() => setOwnedUnlocks(unlocks), [unlocks])
useEffect(() => setEssence(initialEssence), [initialEssence])
useEffect(() => {
const controller = new AbortController()
fetch('/api/inventory/items', { credentials: 'include', signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`items fetch failed: ${res.status}`)
return (await res.json()) as { items: InventoryItem[] }
})
.then((data) => {
setItems(data.items ?? [])
setItemsState('ready')
})
.catch(() => {
if (!controller.signal.aborted) setItemsState('error')
})
return () => controller.abort()
}, [])
useEffect(() => {
const controller = new AbortController()
fetch('/api/inventory/catalog', { credentials: 'include', signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error(`catalog fetch failed: ${res.status}`)
return (await res.json()) as { unlocks: { unlock_key: string; price: number }[] }
})
.then((data) => {
const live: Record<string, number> = { ...FALLBACK_PRICES }
for (const entry of data.unlocks ?? []) live[entry.unlock_key] = entry.price
setPrices(live)
setCatalogState('live')
})
.catch(() => {
if (!controller.signal.aborted) {
setPrices(FALLBACK_PRICES)
setCatalogState('fallback')
}
})
return () => controller.abort()
}, [])
const buy = useCallback(
async (unlockKey: string) => {
if (purchasing) return
setPurchasing(unlockKey)
setPurchaseError((prev) => {
const next = { ...prev }
delete next[unlockKey]
return next
})
try {
const res = await fetch(`/api/inventory/unlocks/${encodeURIComponent(unlockKey)}`, {
method: 'POST',
credentials: 'include',
})
if (res.status === 402) {
setPurchaseError((prev) => ({
...prev,
[unlockKey]: t('inventory.buyInsufficient', {
defaultValue: 'not enough essence',
}),
}))
return
}
if (!res.ok) {
setPurchaseError((prev) => ({
...prev,
[unlockKey]: t('inventory.buyError', { defaultValue: 'the purchase failed' }),
}))
return
}
const price = prices[unlockKey] ?? 0
setOwnedUnlocks((prev) => (prev.includes(unlockKey) ? prev : [...prev, unlockKey]))
setEssence((prev) => Math.max(0, prev - price))
} catch {
setPurchaseError((prev) => ({
...prev,
[unlockKey]: t('inventory.buyError', { defaultValue: 'the purchase failed' }),
}))
} finally {
setPurchasing(null)
}
},
[prices, purchasing, t],
)
const offers = OFFERED_UNLOCK_KEYS.filter((key) => !ownedUnlocks.includes(key))
return (
<section className="inv-panel" aria-label={t('inventory.title', { defaultValue: 'inventory' })}>
<div className="inv-essence" role="status">
<span className="inv-essence-label">
{t('inventory.essenceLabel', { defaultValue: 'essence' })}
</span>
<span className="inv-essence-value" data-testid="essence-value">
{essence}
</span>
</div>
<div className="inv-section">
<h3 className="inv-section-title">
{t('inventory.unlocksTitle', { defaultValue: 'unlocks' })}
</h3>
<ul className="inv-term-list">
{ownedUnlocks.length === 0 && (
<li className="inv-term-empty">
{t('inventory.noUnlocks', { defaultValue: 'nothing unlocked yet' })}
</li>
)}
{ownedUnlocks.map((key) => (
<li className="inv-term-row" key={key}>
<span className="inv-tag inv-tag-owned">
{t('inventory.ownedTag', { defaultValue: '[OWNED]' })}
</span>
<span className="inv-key">{key}</span>
</li>
))}
</ul>
{offers.length > 0 && (
<ul className="inv-offer-grid">
{offers.map((key) => {
const price = prices[key]
const affordable = typeof price === 'number' && essence >= price
const disabled = purchasing === key || typeof price !== 'number' || !affordable
return (
<li className={`inv-offer-card${affordable ? ' is-affordable' : ' is-locked'}`} key={key}>
<span className="inv-tag inv-tag-locked">
{t('inventory.lockedTag', { defaultValue: '[LOCKED]' })}
</span>
<span className="inv-key">{key}</span>
<span className="inv-price">
{typeof price === 'number'
? t('inventory.priceValue', { price, defaultValue: '{{price}} essence' })
: t('inventory.priceUnknown', { defaultValue: 'price unknown' })}
{catalogState === 'fallback' && typeof price === 'number' && (
<span className="inv-price-est">
{' '}
{t('inventory.priceEstimate', { defaultValue: '(est.)' })}
</span>
)}
</span>
<button
type="button"
className="inv-buy-btn"
disabled={disabled}
data-affordable={affordable}
onClick={() => void buy(key)}
>
{purchasing === key
? t('inventory.buying', { defaultValue: 'buying…' })
: t('inventory.buy', { defaultValue: 'buy' })}
</button>
{purchaseError[key] && (
<p className="inv-offer-error" role="alert">
{purchaseError[key]}
</p>
)}
</li>
)
})}
</ul>
)}
</div>
<div className="inv-section">
<h3 className="inv-section-title">
{t('inventory.itemsTitle', { defaultValue: 'items' })}
</h3>
{itemsState === 'loading' && (
<p className="inv-state">{t('inventory.itemsLoading', { defaultValue: 'reading the shelf…' })}</p>
)}
{itemsState === 'error' && (
<p className="inv-state inv-state-error" role="alert">
{t('common.error')}
</p>
)}
{itemsState === 'ready' && items.length === 0 && (
<p className="inv-state">{t('inventory.itemsEmpty', { defaultValue: 'nothing collected yet' })}</p>
)}
{itemsState === 'ready' && items.length > 0 && (
<ul className="inv-item-grid">
{items.map((item, i) => {
const rarity = isRarity(item.payload?.rarity) ? item.payload.rarity : null
return (
<li
className="inv-item-card"
data-rarity={rarity ?? undefined}
key={`${item.item_type}-${item.item_key}-${i}`}
>
<span className="inv-item-type">{item.item_type}</span>
<span className="inv-key">{item.item_key}</span>
</li>
)
})}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,242 @@
/* SigilDesigner — "hacker witch" fusion: the builder reads like a plotting/
debug tool (crosshair cursor, monospace coordinate HUD, phosphor grid),
while the constructed result (lines + rune) renders as a soft violet-
bloom occult sigil. Palette matches App.css: bg #07070d, panel
rgba(16,16,26,.85), phosphor #7cffb2, violet #b26bff, border
rgba(124,255,178,.18). */
.sigil-designer {
display: grid;
grid-template-columns: minmax(260px, 340px) minmax(200px, 1fr);
gap: 1.6rem;
font-family: var(--qm-font-ui);
color: var(--qm-text);
}
@media (max-width: 720px) {
.sigil-designer {
grid-template-columns: 1fr;
}
}
/* ---- canvas column ---- */
.sigil-canvas-col {
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.sigil-canvas {
width: 100%;
aspect-ratio: 1 / 1;
background: radial-gradient(ellipse at 50% 40%, rgba(178, 107, 255, 0.07), transparent 65%), var(--qm-bg);
border: 1px solid var(--qm-border);
border-radius: 10px;
cursor: crosshair;
}
.sigil-guide-circle {
stroke: rgba(124, 255, 178, 0.28);
stroke-width: 1;
stroke-dasharray: 2 4;
}
.sigil-guide-axis {
stroke: rgba(124, 255, 178, 0.12);
stroke-width: 1;
}
/* mystical result layer: violet bloom */
.sigil-lines line {
stroke: var(--qm-violet);
stroke-width: 1.4;
stroke-linecap: round;
opacity: 0.85;
}
.sigil-rune {
color: var(--qm-violet);
}
.sigil-rune path,
.sigil-rune circle {
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.92;
}
/* clickable slots: technical/precise plotting-tool feel */
.sigil-slot-hit {
fill: transparent;
stroke: none;
cursor: crosshair;
}
.sigil-slot-dot {
fill: rgba(124, 255, 178, 0.55);
stroke: none;
pointer-events: none;
transition: r 120ms ease, fill 120ms ease;
}
.sigil-slot:hover .sigil-slot-dot {
fill: #eef7f0;
}
.sigil-slot.is-active .sigil-slot-dot {
fill: var(--qm-violet);
filter: drop-shadow(0 0 4px rgba(178, 107, 255, 0.9));
}
/* ---- debug-tool coordinate HUD ---- */
.sigil-hud {
display: flex;
align-items: baseline;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
background: rgba(16, 16, 26, 0.7);
border: 1px solid rgba(124, 255, 178, 0.18);
border-radius: 6px;
font-size: 0.76rem;
}
.sigil-hud-label {
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--qm-dim);
flex-shrink: 0;
}
.sigil-hud-value {
color: var(--qm-green);
letter-spacing: 0.02em;
}
.sigil-hud-value.dim {
color: var(--qm-dim);
}
/* ---- controls column ---- */
.sigil-controls-col {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sigil-rune-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 0.5rem;
}
.sigil-rune-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.6rem 0.4rem;
background: var(--qm-panel);
border: 1px solid var(--qm-border);
border-radius: 8px;
color: var(--qm-dim);
cursor: pointer;
transition: border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
}
.sigil-rune-btn:hover {
border-color: rgba(178, 107, 255, 0.4);
}
.sigil-rune-btn.is-active {
color: var(--qm-violet);
border-color: rgba(178, 107, 255, 0.6);
box-shadow: 0 0 14px rgba(178, 107, 255, 0.28);
}
.sigil-rune-icon {
width: 28px;
height: 28px;
}
.sigil-rune-icon path,
.sigil-rune-icon circle {
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.sigil-rune-name {
font-size: 0.6rem;
letter-spacing: 0.06em;
text-align: center;
}
.sigil-name-label {
font-size: 0.68rem;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--qm-dim);
}
.sigil-name-input {
padding: 0.55rem 0.7rem;
background: rgba(16, 16, 26, 0.7);
border: 1px solid var(--qm-border);
border-radius: 6px;
color: var(--qm-text);
font-family: var(--qm-font-ui);
font-size: 0.85rem;
}
.sigil-name-input:focus {
outline: none;
border-color: rgba(178, 107, 255, 0.5);
}
.sigil-save-btn {
padding: 0.6rem 1rem;
font-family: var(--qm-font-ui);
font-size: 0.8rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #0a0a12;
background: var(--qm-violet);
border: none;
border-radius: 6px;
cursor: pointer;
transition: transform 150ms ease, box-shadow 150ms ease, opacity 150ms ease;
}
.sigil-save-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 0 18px rgba(178, 107, 255, 0.5);
}
.sigil-save-btn:disabled {
cursor: not-allowed;
opacity: 0.4;
background: #3a3a46;
color: rgba(216, 230, 220, 0.5);
box-shadow: none;
}
.sigil-status {
margin: 0;
font-size: 0.78rem;
}
.sigil-status.is-saved {
color: var(--qm-green);
}
.sigil-status.is-error {
color: var(--qm-blood);
}

View File

@@ -0,0 +1,134 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '../i18n'
import { SigilDesigner } from './SigilDesigner'
import { RUNES, SIGIL_MAX_POINTS, SIGIL_SLOT_COUNT } from '../lib/sigil'
function anchorButtons() {
return screen.getAllByRole('button', { name: /anchor point/i })
}
describe('SigilDesigner', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders all 24 clock-face anchor slots and starts with zero points', () => {
render(<SigilDesigner />)
expect(anchorButtons()).toHaveLength(SIGIL_SLOT_COUNT)
expect(screen.getByTestId('sigil-point-count').textContent).toBe(`0/${SIGIL_MAX_POINTS}`)
})
it('adds a point on click, up to the 12-point cap, and refuses a 13th', async () => {
const user = userEvent.setup()
render(<SigilDesigner />)
const anchors = anchorButtons()
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
await user.click(anchors[i])
}
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
)
// clicking a 13th, distinct, previously-unselected slot must be a no-op.
await user.click(anchors[SIGIL_MAX_POINTS])
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
)
expect(anchors[SIGIL_MAX_POINTS]).toHaveAttribute('aria-pressed', 'false')
})
it('toggling a placed point removes it, freeing a slot even while capped', async () => {
const user = userEvent.setup()
render(<SigilDesigner />)
const anchors = anchorButtons()
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
await user.click(anchors[i])
}
await user.click(anchors[0]) // remove the first point
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
`${SIGIL_MAX_POINTS - 1}/${SIGIL_MAX_POINTS}`,
)
expect(anchors[0]).toHaveAttribute('aria-pressed', 'false')
await user.click(anchors[SIGIL_MAX_POINTS]) // now there's room
expect(screen.getByTestId('sigil-point-count').textContent).toBe(
`${SIGIL_MAX_POINTS}/${SIGIL_MAX_POINTS}`,
)
})
it('offers the fixed rune set and lets the seeker pick one', async () => {
const user = userEvent.setup()
render(<SigilDesigner />)
// First rune defaults active.
const firstBtn = screen.getByRole('button', { name: RUNES[0].label })
expect(firstBtn).toHaveAttribute('aria-pressed', 'true')
const secondBtn = screen.getByRole('button', { name: RUNES[1].label })
expect(secondBtn).toHaveAttribute('aria-pressed', 'false')
await user.click(secondBtn)
expect(secondBtn).toHaveAttribute('aria-pressed', 'true')
expect(firstBtn).toHaveAttribute('aria-pressed', 'false')
})
it('save is disabled with zero points, and posts the {points, rune} payload shape', async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(
JSON.stringify({
id: 's1',
name: 'untitled sigil',
design: { points: [], rune: 'eye' },
created_at: '2026-01-01T00:00:00Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
render(<SigilDesigner />)
const saveBtn = screen.getByRole('button', { name: /inscribe sigil/i })
expect(saveBtn).toHaveProperty('disabled', true)
const anchors = anchorButtons()
await user.click(anchors[0])
await user.click(anchors[6])
expect(saveBtn).toHaveProperty('disabled', false)
await user.click(saveBtn)
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toBe('/api/inventory/sigils')
expect(init?.method).toBe('POST')
const body = JSON.parse(String(init?.body))
expect(body.design.rune).toBe(RUNES[0].id)
expect(Array.isArray(body.design.points)).toBe(true)
expect(body.design.points).toHaveLength(2)
for (const p of body.design.points) {
expect(p).toHaveLength(2)
expect(typeof p[0]).toBe('number')
expect(typeof p[1]).toBe('number')
}
await screen.findByText(/bound/i)
})
it('shows an error state when saving fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('nope', { status: 500 })),
)
const user = userEvent.setup()
render(<SigilDesigner />)
await user.click(anchorButtons()[0])
await user.click(screen.getByRole('button', { name: /inscribe sigil/i }))
await screen.findByRole('alert')
})
})

View File

@@ -0,0 +1,271 @@
// SigilDesigner — a constrained geometric builder, not freeform drawing.
// The seeker clicks fixed clock-face slots around a circle (cap 12 points,
// matching Workstream C's payload limit — see lib/sigil.ts), the tool
// connects them in placement order, and a rune overlay is chosen from a
// small fixed set.
//
// Art direction: the *process* reads like a debugging/plotting tool —
// crosshair cursor over the circle, a monospace coordinate readout HUD,
// slot numbers — while the *result* (lines + rune) renders as a soft
// violet-bloom occult sigil, consistent with GhostGlyph.tsx's glow
// convention (dark ground, radial glow, hsl violet).
//
// Saves via Workstream C's POST endpoint (path inferred — see report) with
// the contract payload shape `{"points": [[x,y],...], "rune": str}`.
import { useState } from 'react'
import type { ReactElement } from 'react'
import { useTranslation } from 'react-i18next'
import {
RUNES,
SIGIL_MAX_POINTS,
SIGIL_SLOT_COUNT,
buildDesign,
slotDegrees,
slotPoint,
} from '../lib/sigil'
import type { RuneId } from '../lib/sigil'
import type { SigilRecord } from '../lib/types'
import './SigilDesigner.css'
const VIEWBOX = 240
const CENTER = VIEWBOX / 2
const RADIUS = 92
const SLOT_HIT_R = 10
/** Simple stroke-only rune glyphs, drawn in a -12..12 local box (see <g transform>). */
const RUNE_PATHS: Record<RuneId, ReactElement> = {
eye: (
<>
<path d="M-11 0 C-6 -8 6 -8 11 0 C6 8 -6 8 -11 0 Z" />
<circle cx="0" cy="0" r="2.6" fill="currentColor" stroke="none" />
</>
),
crescent: <path d="M4 -11 A11 11 0 1 0 4 11 A8 8 0 1 1 4 -11 Z" />,
key: (
<>
<circle cx="-6" cy="0" r="5" />
<path d="M-1 0 H11 M6 0 V4 M9 0 V3" />
</>
),
spiral: <path d="M0 0 C0 -6 6 -6 6 0 C6 5 -3 6 -3 0 C-3 -3 1 -3.5 1 0" />,
thorn: <path d="M0 -12 V12 M0 -6 L7 -11 M0 -1 L-7 -6 M0 4 L7 -1 M0 9 L-7 4" />,
}
export type SigilDesignerProps = {
/** Called after a successful save. */
onSaved?: (sigil: SigilRecord) => void
}
export function SigilDesigner({ onSaved }: SigilDesignerProps) {
const { t } = useTranslation()
const [selectedSlots, setSelectedSlots] = useState<number[]>([])
const [rune, setRune] = useState<RuneId>(RUNES[0].id)
const [hoverSlot, setHoverSlot] = useState<number | null>(null)
const [name, setName] = useState('')
const [status, setStatus] = useState<'idle' | 'pending' | 'saved' | 'error'>('idle')
const toSvg = (slot: number): [number, number] => {
const [ux, uy] = slotPoint(slot, SIGIL_SLOT_COUNT)
return [CENTER + ux * RADIUS, CENTER + uy * RADIUS]
}
const toggle = (slot: number) => {
setSelectedSlots((prev) => {
if (prev.includes(slot)) return prev.filter((s) => s !== slot)
if (prev.length >= SIGIL_MAX_POINTS) return prev
return [...prev, slot]
})
setStatus('idle')
}
const save = async () => {
if (status === 'pending') return
setStatus('pending')
try {
const design = buildDesign(selectedSlots, rune)
const res = await fetch('/api/inventory/sigils', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() || 'untitled sigil', design }),
})
if (!res.ok) throw new Error(`save failed: ${res.status}`)
const saved = (await res.json()) as SigilRecord
setStatus('saved')
onSaved?.(saved)
} catch {
setStatus('error')
}
}
const hovered = hoverSlot !== null ? slotPoint(hoverSlot, SIGIL_SLOT_COUNT) : null
const lastSlot = selectedSlots.length > 0 ? selectedSlots[selectedSlots.length - 1] : null
return (
<section className="sigil-designer" aria-label={t('inventory.sigil.title', { defaultValue: 'sigil designer' })}>
<div className="sigil-canvas-col">
<svg
className="sigil-canvas"
viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}
role="group"
aria-label={t('inventory.sigil.canvasLabel', { defaultValue: 'circle of anchor points' })}
>
<defs>
<filter id="sigil-glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="3.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* ---- technical grid layer: plotting-tool feel ---- */}
<circle
className="sigil-guide-circle"
cx={CENTER}
cy={CENTER}
r={RADIUS}
fill="none"
/>
<line className="sigil-guide-axis" x1={CENTER} y1={CENTER - RADIUS - 8} x2={CENTER} y2={CENTER + RADIUS + 8} />
<line className="sigil-guide-axis" x1={CENTER - RADIUS - 8} y1={CENTER} x2={CENTER + RADIUS + 8} y2={CENTER} />
{/* ---- mystical result layer: connecting lines + rune, violet glow ---- */}
<g className="sigil-lines" filter="url(#sigil-glow)">
{selectedSlots.slice(1).map((slot, i) => {
const [x1, y1] = toSvg(selectedSlots[i])
const [x2, y2] = toSvg(slot)
return <line key={`${selectedSlots[i]}-${slot}`} x1={x1} y1={y1} x2={x2} y2={y2} />
})}
</g>
<g className="sigil-rune" filter="url(#sigil-glow)" transform={`translate(${CENTER} ${CENTER})`}>
{RUNE_PATHS[rune]}
</g>
{/* ---- clickable anchor slots ---- */}
{Array.from({ length: SIGIL_SLOT_COUNT }, (_, slot) => {
const [x, y] = toSvg(slot)
const active = selectedSlots.includes(slot)
return (
<g
key={slot}
className={`sigil-slot${active ? ' is-active' : ''}`}
onMouseEnter={() => setHoverSlot(slot)}
onMouseLeave={() => setHoverSlot((s) => (s === slot ? null : s))}
>
<circle
cx={x}
cy={y}
r={SLOT_HIT_R}
className="sigil-slot-hit"
role="button"
tabIndex={0}
aria-pressed={active}
aria-label={t('inventory.sigil.slotLabel', {
slot,
defaultValue: 'anchor point {{slot}}',
})}
onClick={() => toggle(slot)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(slot)
}
}}
/>
<circle cx={x} cy={y} r={active ? 4.5 : 2.5} className="sigil-slot-dot" />
</g>
)
})}
</svg>
{/* ---- debug-tool coordinate HUD ---- */}
<div className="sigil-hud" role="status">
<span className="sigil-hud-label">
{t('inventory.sigil.coordLabel', { defaultValue: 'coords' })}
</span>
{hovered ? (
<span className="sigil-hud-value">
SLOT {String(hoverSlot).padStart(2, '0')} · θ={slotDegrees(hoverSlot ?? 0, SIGIL_SLOT_COUNT)}°
· x={hovered[0].toFixed(3)} y={hovered[1].toFixed(3)}
</span>
) : (
<span className="sigil-hud-value dim">
{t('inventory.sigil.hoverHint', { defaultValue: 'hover an anchor point' })}
</span>
)}
</div>
<div className="sigil-hud">
<span className="sigil-hud-label">
{t('inventory.sigil.pointsLabel', { defaultValue: 'points' })}
</span>
<span className="sigil-hud-value" data-testid="sigil-point-count">
{selectedSlots.length}/{SIGIL_MAX_POINTS}
</span>
{lastSlot !== null && (
<span className="sigil-hud-value dim">
{t('inventory.sigil.lastLabel', { defaultValue: 'last' })}: {lastSlot}
</span>
)}
</div>
</div>
<div className="sigil-controls-col">
<div className="sigil-rune-picker" role="group" aria-label={t('inventory.sigil.runeLabel', { defaultValue: 'rune' })}>
{RUNES.map((r) => (
<button
key={r.id}
type="button"
className={`sigil-rune-btn${rune === r.id ? ' is-active' : ''}`}
aria-pressed={rune === r.id}
onClick={() => setRune(r.id)}
title={r.label}
>
<svg viewBox="-12 -12 24 24" className="sigil-rune-icon" aria-hidden="true">
{RUNE_PATHS[r.id]}
</svg>
<span className="sigil-rune-name">{r.label}</span>
</button>
))}
</div>
<label className="sigil-name-label" htmlFor="sigil-name-input">
{t('inventory.sigil.nameLabel', { defaultValue: 'name' })}
</label>
<input
id="sigil-name-input"
className="sigil-name-input"
type="text"
value={name}
maxLength={60}
onChange={(e) => setName(e.target.value)}
placeholder={t('inventory.sigil.namePlaceholder', { defaultValue: 'untitled sigil' })}
/>
<button
type="button"
className="sigil-save-btn"
disabled={status === 'pending' || selectedSlots.length === 0}
onClick={() => void save()}
>
{status === 'pending'
? t('inventory.sigil.saving', { defaultValue: 'inscribing…' })
: t('inventory.sigil.save', { defaultValue: 'inscribe sigil' })}
</button>
{status === 'saved' && (
<p className="sigil-status is-saved" role="status">
{t('inventory.sigil.saved', { defaultValue: 'the sigil is bound' })}
</p>
)}
{status === 'error' && (
<p className="sigil-status is-error" role="alert">
{t('inventory.sigil.error', { defaultValue: 'the sigil would not hold' })}
</p>
)}
</div>
</section>
)
}

View File

@@ -13,6 +13,7 @@
"home": "the threshold",
"codex": "codex",
"shop": "shop",
"inventory": "inventory",
"seance": "séance",
"enter": "enter",
"logout": "depart"
@@ -405,6 +406,43 @@
},
"honesty": "the Ultimate Quantum Box is in active development — this is a reservation manifest, no payment is taken. the website works fully without it; hardware only knocks louder."
},
"inventory": {
"back": "← back to the séance",
"pageTitle": "THE SHELF",
"pageSubtitle": "what the veil has left you, and what it will still trade for",
"title": "inventory",
"essenceLabel": "essence",
"unlocksTitle": "unlocks",
"noUnlocks": "nothing unlocked yet",
"ownedTag": "[OWNED]",
"lockedTag": "[LOCKED]",
"priceValue": "{{price}} essence",
"priceUnknown": "price unknown",
"priceEstimate": "(est.)",
"buy": "buy",
"buying": "buying…",
"buyInsufficient": "not enough essence",
"buyError": "the purchase failed",
"itemsTitle": "items",
"itemsLoading": "reading the shelf…",
"itemsEmpty": "nothing collected yet",
"sigil": {
"title": "sigil designer",
"canvasLabel": "circle of anchor points",
"slotLabel": "anchor point {{slot}}",
"coordLabel": "coords",
"hoverHint": "hover an anchor point",
"pointsLabel": "points",
"lastLabel": "last",
"runeLabel": "rune",
"nameLabel": "name",
"namePlaceholder": "untitled sigil",
"save": "inscribe sigil",
"saving": "inscribing…",
"saved": "the sigil is bound",
"error": "the sigil would not hold"
}
},
"haunting": {
"whisper": {
"behindYou": "behind you",

View File

@@ -13,6 +13,7 @@
"home": "el umbral",
"codex": "códice",
"shop": "relicario",
"inventory": "inventario",
"seance": "sesión",
"enter": "entrar",
"logout": "partir"
@@ -405,6 +406,43 @@
},
"honesty": "la Ultimate Quantum Box está en desarrollo activo — esto es un manifiesto de reserva, no se cobra nada. el sitio funciona por completo sin ella; el hardware solo golpea más fuerte."
},
"inventory": {
"back": "← volver a la sesión",
"pageTitle": "EL ESTANTE",
"pageSubtitle": "lo que el velo te ha dejado, y lo que aún cambiará por más",
"title": "inventario",
"essenceLabel": "esencia",
"unlocksTitle": "desbloqueos",
"noUnlocks": "nada desbloqueado todavía",
"ownedTag": "[POSEÍDO]",
"lockedTag": "[BLOQUEADO]",
"priceValue": "{{price}} esencia",
"priceUnknown": "precio desconocido",
"priceEstimate": "(est.)",
"buy": "comprar",
"buying": "comprando…",
"buyInsufficient": "no hay esencia suficiente",
"buyError": "la compra falló",
"itemsTitle": "objetos",
"itemsLoading": "leyendo el estante…",
"itemsEmpty": "nada recolectado todavía",
"sigil": {
"title": "diseñador de sigilos",
"canvasLabel": "círculo de puntos de anclaje",
"slotLabel": "punto de anclaje {{slot}}",
"coordLabel": "coords",
"hoverHint": "pasa el cursor sobre un punto de anclaje",
"pointsLabel": "puntos",
"lastLabel": "último",
"runeLabel": "runa",
"nameLabel": "nombre",
"namePlaceholder": "sigilo sin título",
"save": "inscribir sigilo",
"saving": "inscribiendo…",
"saved": "el sigilo quedó ligado",
"error": "el sigilo no se sostuvo"
}
},
"haunting": {
"whisper": {
"behindYou": "detrás de ti",

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { EvpDetectorCore } from './evp'
import {
EVP_BASE_THRESHOLD_DB,
EVP_LISTENING_TOOL_THRESHOLD_DB,
EvpDetectorCore,
evpThresholdDb,
} from './evp'
import type { EvpAnomaly } from './evp'
const SAMPLE_RATE = 48000
@@ -156,3 +161,46 @@ describe('EvpDetectorCore', () => {
expect(core.getFloor()![50]).toBeCloseTo(-100, 9)
})
})
describe('evpThresholdDb ("listening_tool" unlock)', () => {
it('returns the base threshold when the unlock is absent', () => {
expect(evpThresholdDb(false)).toBe(EVP_BASE_THRESHOLD_DB)
})
it('returns a lower (more sensitive) threshold when the unlock is present', () => {
expect(evpThresholdDb(true)).toBe(EVP_LISTENING_TOOL_THRESHOLD_DB)
expect(EVP_LISTENING_TOOL_THRESHOLD_DB).toBeLessThan(EVP_BASE_THRESHOLD_DB)
})
})
describe('listening_tool threshold changes real detector behavior', () => {
// Test the pure detector logic directly (EvpDetectorCore constructed with
// the resolved threshold), not through any UI — a real gameplay effect,
// not cosmetic: a faint spike that never registers at the base threshold
// must register once the unlock's lower threshold is wired in.
it('a faint spike is missed at the base threshold but caught with the unlock', () => {
const bin = 50
const faintSpikeDb = -94 // 6 dB above the -100 dB floor: below base (8), above unlocked (4)
const withoutUnlock = new EvpDetectorCore({
sampleRate: SAMPLE_RATE,
fftSize: FFT_SIZE,
thresholdDb: evpThresholdDb(false),
})
withoutUnlock.process(quietFrame(), 0)
for (let t = 1; t <= 20; t++) withoutUnlock.process(quietFrame(), t * 100)
expect(withoutUnlock.process(spikedFrame(bin, faintSpikeDb), 5000)).toBeNull()
const withUnlock = new EvpDetectorCore({
sampleRate: SAMPLE_RATE,
fftSize: FFT_SIZE,
thresholdDb: evpThresholdDb(true),
})
withUnlock.process(quietFrame(), 0)
for (let t = 1; t <= 20; t++) withUnlock.process(quietFrame(), t * 100)
const anomaly = withUnlock.process(spikedFrame(bin, faintSpikeDb), 5000)
expect(anomaly).not.toBeNull()
expect(anomaly!.magnitude).toBeCloseTo(6, 6)
})
})

View File

@@ -16,6 +16,23 @@ export type EvpAnomaly = {
magnitude: number
}
// ---- "listening_tool" unlock: lowers the detection threshold ----
//
// Purchased via POST /api/inventory/unlocks/listening_tool (see
// InventoryPanel.tsx) and reported back on the `unlocks` list in
// GET /auth/me. Once owned, fainter voice-band deviations register as
// anomalies — a real gameplay effect, not cosmetic.
/** Default deviation threshold (dB) required to register an anomaly. */
export const EVP_BASE_THRESHOLD_DB = 8
/** Threshold once the seeker owns the "listening_tool" unlock. */
export const EVP_LISTENING_TOOL_THRESHOLD_DB = 4
/** Pure: resolve the detection threshold from unlock ownership. */
export function evpThresholdDb(hasListeningTool: boolean): number {
return hasListeningTool ? EVP_LISTENING_TOOL_THRESHOLD_DB : EVP_BASE_THRESHOLD_DB
}
export type EvpDetectorOptions = {
sampleRate: number
fftSize: number
@@ -51,7 +68,7 @@ export class EvpDetectorCore {
const bandHigh = opts.bandHighHz ?? 3400
this.loBin = Math.max(0, Math.floor(bandLow / this.binHz))
this.hiBin = Math.min(bins - 1, Math.ceil(bandHigh / this.binHz))
this.thresholdDb = opts.thresholdDb ?? 8
this.thresholdDb = opts.thresholdDb ?? EVP_BASE_THRESHOLD_DB
this.throttleMs = opts.throttleMs ?? 2000
this.floorAlpha = opts.floorAlpha ?? 0.05
this.quietBandDb = opts.quietBandDb ?? 3
@@ -144,6 +161,11 @@ export type EvpListenerCallbacks = {
onError?: (err: Error) => void
}
export type EvpListenerStartOptions = {
/** Whether the seeker owns the "listening_tool" unlock (from /auth/me's `unlocks`). */
hasListeningTool?: boolean
}
export class EvpListener {
private ctx: AudioContext | null = null
private stream: MediaStream | null = null
@@ -156,7 +178,7 @@ export class EvpListener {
return this.running
}
async start(cb: EvpListenerCallbacks): Promise<void> {
async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise<void> {
if (this.running) return
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const ctx = new AudioContext()
@@ -172,6 +194,7 @@ export class EvpListener {
this.core = new EvpDetectorCore({
sampleRate: ctx.sampleRate,
fftSize: analyser.fftSize,
thresholdDb: evpThresholdDb(opts.hasListeningTool ?? false),
})
this.running = true

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
RUNES,
SIGIL_MAX_POINTS,
buildDesign,
isValidRune,
slotDegrees,
slotPoint,
toggleSlot,
} from './sigil'
describe('sigil geometry + selection', () => {
it('places slot 0 at the top of the circle and proceeds clockwise', () => {
const [x0, y0] = slotPoint(0, 24)
expect(x0).toBeCloseTo(0, 3)
expect(y0).toBeCloseTo(-1, 3)
// quarter turn clockwise (slot 6 of 24) lands on the right.
const [x6, y6] = slotPoint(6, 24)
expect(x6).toBeCloseTo(1, 3)
expect(y6).toBeCloseTo(0, 3)
})
it('slotPoint always lies on the unit circle', () => {
for (let s = 0; s < 24; s++) {
const [x, y] = slotPoint(s, 24)
expect(x * x + y * y).toBeCloseTo(1, 3)
}
})
it('slotDegrees reports clockwise degrees from the top', () => {
expect(slotDegrees(0, 24)).toBe(0)
expect(slotDegrees(6, 24)).toBe(90)
expect(slotDegrees(12, 24)).toBe(180)
})
it('toggleSlot appends new slots up to the cap', () => {
let selected: number[] = []
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
selected = toggleSlot(selected, i)
}
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
expect(selected).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
})
it('refuses to add a 13th point once at the cap', () => {
let selected: number[] = []
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
selected = toggleSlot(selected, i)
}
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
const withThirteenth = toggleSlot(selected, 20)
expect(withThirteenth).toHaveLength(SIGIL_MAX_POINTS)
expect(withThirteenth).not.toContain(20)
expect(withThirteenth).toEqual(selected)
})
it('toggling an already-selected slot removes it, even while at the cap', () => {
let selected: number[] = []
for (let i = 0; i < SIGIL_MAX_POINTS; i++) {
selected = toggleSlot(selected, i)
}
const removed = toggleSlot(selected, 5)
expect(removed).toHaveLength(SIGIL_MAX_POINTS - 1)
expect(removed).not.toContain(5)
// and now there's room again for a fresh point.
const readded = toggleSlot(removed, 20)
expect(readded).toHaveLength(SIGIL_MAX_POINTS)
expect(readded).toContain(20)
})
it('honors a custom max for edge-case testing', () => {
let selected: number[] = []
selected = toggleSlot(selected, 0, 2)
selected = toggleSlot(selected, 1, 2)
selected = toggleSlot(selected, 2, 2) // refused: cap of 2
expect(selected).toEqual([0, 1])
})
describe('rune selection', () => {
it('exposes a small fixed set of runes', () => {
expect(RUNES.length).toBeGreaterThanOrEqual(3)
expect(RUNES.length).toBeLessThanOrEqual(8)
const ids = RUNES.map((r) => r.id)
expect(new Set(ids).size).toBe(ids.length) // all unique
})
it('validates known rune ids and rejects unknown ones', () => {
for (const r of RUNES) {
expect(isValidRune(r.id)).toBe(true)
}
expect(isValidRune('not-a-rune')).toBe(false)
expect(isValidRune('')).toBe(false)
})
})
describe('buildDesign', () => {
it('produces the exact backend payload shape: {points, rune}', () => {
const design = buildDesign([0, 6, 12], 'eye', 24)
expect(design.rune).toBe('eye')
expect(design.points).toEqual([slotPoint(0, 24), slotPoint(6, 24), slotPoint(12, 24)])
})
it('preserves point placement order (not slot-index order)', () => {
const design = buildDesign([12, 0, 6], 'thorn', 24)
expect(design.points).toEqual([slotPoint(12, 24), slotPoint(0, 24), slotPoint(6, 24)])
})
it('never exceeds the backend payload cap when fed a fully-capped selection', () => {
let selected: number[] = []
for (let i = 0; i < 24; i++) selected = toggleSlot(selected, i)
expect(selected).toHaveLength(SIGIL_MAX_POINTS)
const design = buildDesign(selected, 'spiral')
expect(design.points.length).toBeLessThanOrEqual(12)
})
})
})

82
frontend/src/lib/sigil.ts Normal file
View File

@@ -0,0 +1,82 @@
// Sigil design: pure geometry + rune-selection logic for SigilDesigner.tsx.
//
// A sigil is a constrained geometric construction, not freeform drawing: the
// seeker toggles points on/off at fixed slots evenly spaced around a unit
// circle (clock-face style), capped at SIGIL_MAX_POINTS to match the
// backend's payload limit (Workstream C caps `points` length at 12). The
// component connects the chosen points in placement order and overlays a
// rune chosen from a small fixed set.
//
// Kept UI-free and pure so point-cap enforcement and rune selection are
// directly unit-testable, mirroring evp.ts's EvpDetectorCore split.
/** Number of clock-face anchor slots placed evenly around the circle. */
export const SIGIL_SLOT_COUNT = 24
/** Hard cap on active points — mirrors the backend's `points` payload cap. */
export const SIGIL_MAX_POINTS = 12
export type RuneId = 'eye' | 'crescent' | 'key' | 'spiral' | 'thorn'
/** Fixed small set of rune overlays a seeker can choose from. */
export const RUNES: readonly { id: RuneId; label: string }[] = [
{ id: 'eye', label: 'watching eye' },
{ id: 'crescent', label: 'waning crescent' },
{ id: 'key', label: 'binding key' },
{ id: 'spiral', label: 'drawing spiral' },
{ id: 'thorn', label: 'thorn ward' },
]
export function isValidRune(id: string): id is RuneId {
return RUNES.some((r) => r.id === id)
}
/**
* Coordinates of a clock-face slot on the unit circle, slot 0 at the top,
* proceeding clockwise. Rounded to 3 decimals to keep the saved payload
* compact.
*/
export function slotPoint(slot: number, slotCount: number = SIGIL_SLOT_COUNT): [number, number] {
const angle = (slot / slotCount) * Math.PI * 2 - Math.PI / 2
const round = (v: number) => Math.round(v * 1000) / 1000
return [round(Math.cos(angle)), round(Math.sin(angle))]
}
/** Slot angle in whole degrees, clockwise from the top (0..360). */
export function slotDegrees(slot: number, slotCount: number = SIGIL_SLOT_COUNT): number {
return Math.round((slot / slotCount) * 360)
}
/**
* Pure reducer for clicking a slot: toggles it out if already selected,
* otherwise appends it — but only while under SIGIL_MAX_POINTS. Once at the
* cap, clicking an unselected slot is a no-op (the 13th point never gets
* added); toggling an already-selected slot off still works at the cap.
*/
export function toggleSlot(
selected: readonly number[],
slot: number,
max: number = SIGIL_MAX_POINTS,
): number[] {
if (selected.includes(slot)) {
return selected.filter((s) => s !== slot)
}
if (selected.length >= max) {
return [...selected]
}
return [...selected, slot]
}
export type SigilDesign = { points: [number, number][]; rune: RuneId }
/** Build the exact payload shape Workstream C's save endpoint expects. */
export function buildDesign(
selectedSlots: readonly number[],
rune: RuneId,
slotCount: number = SIGIL_SLOT_COUNT,
): SigilDesign {
return {
points: selectedSlots.map((s) => slotPoint(s, slotCount)),
rune,
}
}

View File

@@ -136,3 +136,27 @@ export type ServerFrame =
// REST helpers
export type SortOrder = 'recent' | 'contacted'
// ---- Workstream C inventory/unlocks (consumed by InventoryPanel/SigilDesigner) ----
/** One row from the `unlocks` table, as listed by Workstream C's inventory routes. */
export type UnlockRecord = {
unlock_key: string
unlocked_at: string
}
/** One row from the `inventory_items` table. */
export type InventoryItem = {
item_type: string
item_key: string
payload: Record<string, unknown>
obtained_at: string
}
/** One row from the `sigils` table. */
export type SigilRecord = {
id: string
name: string
design: { points: [number, number][]; rune: string }
created_at: string
}

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