diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ff5c54..fa76cf5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 255c0a1..ecdfae0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { diff --git a/frontend/src/components/InventoryPanel.css b/frontend/src/components/InventoryPanel.css new file mode 100644 index 0000000..ef4f195 --- /dev/null +++ b/frontend/src/components/InventoryPanel.css @@ -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); +} diff --git a/frontend/src/components/InventoryPanel.test.tsx b/frontend/src/components/InventoryPanel.test.tsx new file mode 100644 index 0000000..07707cf --- /dev/null +++ b/frontend/src/components/InventoryPanel.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + const key = await screen.findByText('silver-locket') + const card = key.closest('[data-rarity]') + expect(card?.getAttribute('data-rarity')).toBe('rare') + }) +}) diff --git a/frontend/src/components/InventoryPanel.tsx b/frontend/src/components/InventoryPanel.tsx new file mode 100644 index 0000000..e6f0d7c --- /dev/null +++ b/frontend/src/components/InventoryPanel.tsx @@ -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 = { 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(unlocks) + const [essence, setEssence] = useState(initialEssence) + const [items, setItems] = useState([]) + const [itemsState, setItemsState] = useState<'loading' | 'ready' | 'error'>('loading') + const [prices, setPrices] = useState>(FALLBACK_PRICES) + const [catalogState, setCatalogState] = useState('loading') + const [purchasing, setPurchasing] = useState(null) + const [purchaseError, setPurchaseError] = useState>({}) + + // 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 = { ...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 ( +
+
+ + {t('inventory.essenceLabel', { defaultValue: 'essence' })} + + + {essence} + +
+ +
+

+ {t('inventory.unlocksTitle', { defaultValue: 'unlocks' })} +

+
    + {ownedUnlocks.length === 0 && ( +
  • + {t('inventory.noUnlocks', { defaultValue: 'nothing unlocked yet' })} +
  • + )} + {ownedUnlocks.map((key) => ( +
  • + + {t('inventory.ownedTag', { defaultValue: '[OWNED]' })} + + {key} +
  • + ))} +
+ + {offers.length > 0 && ( +
    + {offers.map((key) => { + const price = prices[key] + const affordable = typeof price === 'number' && essence >= price + const disabled = purchasing === key || typeof price !== 'number' || !affordable + return ( +
  • + + {t('inventory.lockedTag', { defaultValue: '[LOCKED]' })} + + {key} + + {typeof price === 'number' + ? t('inventory.priceValue', { price, defaultValue: '{{price}} essence' }) + : t('inventory.priceUnknown', { defaultValue: 'price unknown' })} + {catalogState === 'fallback' && typeof price === 'number' && ( + + {' '} + {t('inventory.priceEstimate', { defaultValue: '(est.)' })} + + )} + + + {purchaseError[key] && ( +

    + {purchaseError[key]} +

    + )} +
  • + ) + })} +
+ )} +
+ +
+

+ {t('inventory.itemsTitle', { defaultValue: 'items' })} +

+ {itemsState === 'loading' && ( +

{t('inventory.itemsLoading', { defaultValue: 'reading the shelf…' })}

+ )} + {itemsState === 'error' && ( +

+ {t('common.error')} +

+ )} + {itemsState === 'ready' && items.length === 0 && ( +

{t('inventory.itemsEmpty', { defaultValue: 'nothing collected yet' })}

+ )} + {itemsState === 'ready' && items.length > 0 && ( +
    + {items.map((item, i) => { + const rarity = isRarity(item.payload?.rarity) ? item.payload.rarity : null + return ( +
  • + {item.item_type} + {item.item_key} +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/SigilDesigner.css b/frontend/src/components/SigilDesigner.css new file mode 100644 index 0000000..f648581 --- /dev/null +++ b/frontend/src/components/SigilDesigner.css @@ -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); +} diff --git a/frontend/src/components/SigilDesigner.test.tsx b/frontend/src/components/SigilDesigner.test.tsx new file mode 100644 index 0000000..2051871 --- /dev/null +++ b/frontend/src/components/SigilDesigner.test.tsx @@ -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() + 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() + 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() + 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() + + // 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() + + 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() + await user.click(anchorButtons()[0]) + await user.click(screen.getByRole('button', { name: /inscribe sigil/i })) + await screen.findByRole('alert') + }) +}) diff --git a/frontend/src/components/SigilDesigner.tsx b/frontend/src/components/SigilDesigner.tsx new file mode 100644 index 0000000..f57f2e0 --- /dev/null +++ b/frontend/src/components/SigilDesigner.tsx @@ -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 ). */ +const RUNE_PATHS: Record = { + eye: ( + <> + + + + ), + crescent: , + key: ( + <> + + + + ), + spiral: , + thorn: , +} + +export type SigilDesignerProps = { + /** Called after a successful save. */ + onSaved?: (sigil: SigilRecord) => void +} + +export function SigilDesigner({ onSaved }: SigilDesignerProps) { + const { t } = useTranslation() + const [selectedSlots, setSelectedSlots] = useState([]) + const [rune, setRune] = useState(RUNES[0].id) + const [hoverSlot, setHoverSlot] = useState(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 ( +
+
+ + + + + + + + + + + + {/* ---- technical grid layer: plotting-tool feel ---- */} + + + + + {/* ---- mystical result layer: connecting lines + rune, violet glow ---- */} + + {selectedSlots.slice(1).map((slot, i) => { + const [x1, y1] = toSvg(selectedSlots[i]) + const [x2, y2] = toSvg(slot) + return + })} + + + + {RUNE_PATHS[rune]} + + + {/* ---- clickable anchor slots ---- */} + {Array.from({ length: SIGIL_SLOT_COUNT }, (_, slot) => { + const [x, y] = toSvg(slot) + const active = selectedSlots.includes(slot) + return ( + setHoverSlot(slot)} + onMouseLeave={() => setHoverSlot((s) => (s === slot ? null : s))} + > + toggle(slot)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + toggle(slot) + } + }} + /> + + + ) + })} + + + {/* ---- debug-tool coordinate HUD ---- */} +
+ + {t('inventory.sigil.coordLabel', { defaultValue: 'coords' })} + + {hovered ? ( + + SLOT {String(hoverSlot).padStart(2, '0')} · θ={slotDegrees(hoverSlot ?? 0, SIGIL_SLOT_COUNT)}° + · x={hovered[0].toFixed(3)} y={hovered[1].toFixed(3)} + + ) : ( + + {t('inventory.sigil.hoverHint', { defaultValue: 'hover an anchor point' })} + + )} +
+
+ + {t('inventory.sigil.pointsLabel', { defaultValue: 'points' })} + + + {selectedSlots.length}/{SIGIL_MAX_POINTS} + + {lastSlot !== null && ( + + {t('inventory.sigil.lastLabel', { defaultValue: 'last' })}: {lastSlot} + + )} +
+
+ +
+
+ {RUNES.map((r) => ( + + ))} +
+ + + setName(e.target.value)} + placeholder={t('inventory.sigil.namePlaceholder', { defaultValue: 'untitled sigil' })} + /> + + + {status === 'saved' && ( +

+ {t('inventory.sigil.saved', { defaultValue: 'the sigil is bound' })} +

+ )} + {status === 'error' && ( +

+ {t('inventory.sigil.error', { defaultValue: 'the sigil would not hold' })} +

+ )} +
+
+ ) +} diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 205674e..b125c7d 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index ba51256..0ca1e22 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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", diff --git a/frontend/src/lib/evp.test.ts b/frontend/src/lib/evp.test.ts index 9e928d8..18d045c 100644 --- a/frontend/src/lib/evp.test.ts +++ b/frontend/src/lib/evp.test.ts @@ -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) + }) +}) diff --git a/frontend/src/lib/evp.ts b/frontend/src/lib/evp.ts index 96d1d64..b199fec 100644 --- a/frontend/src/lib/evp.ts +++ b/frontend/src/lib/evp.ts @@ -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 { + async start(cb: EvpListenerCallbacks, opts: EvpListenerStartOptions = {}): Promise { 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 diff --git a/frontend/src/lib/sigil.test.ts b/frontend/src/lib/sigil.test.ts new file mode 100644 index 0000000..51f53a3 --- /dev/null +++ b/frontend/src/lib/sigil.test.ts @@ -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) + }) + }) +}) diff --git a/frontend/src/lib/sigil.ts b/frontend/src/lib/sigil.ts new file mode 100644 index 0000000..ad552ac --- /dev/null +++ b/frontend/src/lib/sigil.ts @@ -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, + } +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c50f356..8075c0f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 + 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 +} diff --git a/frontend/src/pages/InventoryPage.css b/frontend/src/pages/InventoryPage.css new file mode 100644 index 0000000..f49241f --- /dev/null +++ b/frontend/src/pages/InventoryPage.css @@ -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; +} diff --git a/frontend/src/pages/InventoryPage.tsx b/frontend/src/pages/InventoryPage.tsx new file mode 100644 index 0000000..6e18cc2 --- /dev/null +++ b/frontend/src/pages/InventoryPage.tsx @@ -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 ( +
+ {t('common.loading')} +
+ ) + } + if (!user) return + + return ( +
+ + ) +} + +export default InventoryPage diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index ac3d4ab..0280e87 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -256,6 +256,10 @@ function SeanceSession({ username }: { username: string }) { {t('nav.codex')} + + {t('nav.inventory', { defaultValue: 'inventory' })} + + {username} @@ -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