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

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

View File

@@ -0,0 +1,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>
)
}