feat: Ghost Log HUD + evil meter + tells (Workstream D)
App-shell HUD (mounted in App.tsx alongside HauntingLayer, visible on every screen) that shows ambient idle status until a séance is active, then streams `tell` WS frames as terminal-style log lines with a "hacker witch" crescent-arc evil-meter gauge (occult sigils/runes fused with Transcript.tsx's monospace log vocabulary). - lib/evilMeter.ts: pure function computing a malevolent<->benevolent belief from the accumulated tell history — starts wide/uncertain, narrows geometrically and shifts per tell (deterministic hash of the tell text, since tells never leak ground truth), and snaps to definitive certainty on a successful ritual_complete's revealed.alignment. Fully unit tested (narrowing, ordering, determinism, ritual override, idle/empty history). - lib/ghostLogBus.ts: tiny typed event bus (mirrors lib/haunting.ts's HauntBus) so the app-shell-level GhostLog can react to live séance frames — SeanceProvider is only mounted inside the séance route, so a shell-level sibling can't read its context directly. - state/seance.tsx: publish entity/tell/ritual_complete onto the bus, and session_end on provider teardown so the HUD falls back to idle when the seeker leaves the séance page. - lib/types.ts: add the `tell` and `ritual_complete` server frames from the Character Depth spec's Contract section (only what this workstream consumes). - components/GhostLog.tsx/.css: the HUD itself, plus i18n keys in en.json/es.json. 168/168 frontend tests pass (137 pre-existing + 20 evilMeter + 11 GhostLog); tsc -b and the i18n coverage pretest are clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
258
frontend/src/components/GhostLog.css
Normal file
258
frontend/src/components/GhostLog.css
Normal file
@@ -0,0 +1,258 @@
|
||||
/* GhostLog — app-shell HUD, mounted once alongside HauntingLayer so it sits
|
||||
above every page. "Hacker witch": Transcript.tsx's terminal vocabulary
|
||||
(monospace log rows, phosphor glow, flicker) fused with occult framing
|
||||
(sigil corners, rune bullets, pentagram divider, crescent evil-meter).
|
||||
Palette borrowed from App.css's --qm-* tokens / SeancePage.css — no new
|
||||
colors introduced. */
|
||||
|
||||
.ghost-log {
|
||||
position: fixed;
|
||||
left: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 500; /* above page content, below the global CRT glass (9999) and haunting layer (9998) */
|
||||
width: 300px;
|
||||
max-width: calc(100vw - 28px);
|
||||
max-height: 46vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--qm-panel, rgba(16, 16, 26, 0.85));
|
||||
border: 1px solid var(--qm-border, rgba(124, 255, 178, 0.18));
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
0 6px 28px rgba(0, 0, 0, 0.55),
|
||||
0 0 18px rgba(178, 107, 255, 0.08),
|
||||
inset 0 0 24px rgba(124, 255, 178, 0.03);
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: var(--qm-font-ui, 'IBM Plex Mono', monospace);
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* candle-flicker glow instead of a flat terminal green */
|
||||
.ghost-log--active {
|
||||
animation: ghostLogCandle 5.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ghost-log-corner {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(178, 107, 255, 0.55);
|
||||
text-shadow: 0 0 8px rgba(178, 107, 255, 0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ghost-log-corner--tl {
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.ghost-log-corner--tr {
|
||||
right: 6px;
|
||||
}
|
||||
|
||||
.ghost-log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 1rem 0.35rem;
|
||||
}
|
||||
|
||||
.ghost-log-glyph {
|
||||
color: var(--qm-violet, #b26bff);
|
||||
text-shadow: 0 0 10px rgba(178, 107, 255, 0.7);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ghost-log-title {
|
||||
font-family: var(--qm-font-display, 'Cinzel', serif);
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--qm-green, #7cffb2);
|
||||
text-shadow: 0 0 10px rgba(124, 255, 178, 0.4);
|
||||
}
|
||||
|
||||
.ghost-log-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0 0.3rem;
|
||||
color: rgba(178, 107, 255, 0.4);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
|
||||
.ghost-log-divider-sigil {
|
||||
color: var(--qm-blood, #ff3b5c);
|
||||
text-shadow: 0 0 8px rgba(255, 59, 92, 0.5);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ghost-log-status {
|
||||
margin: 0;
|
||||
padding: 0.3rem 1rem 0.8rem;
|
||||
font-size: 0.74rem;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(211, 228, 220, 0.55);
|
||||
}
|
||||
|
||||
.ghost-log-status.dim {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ghost-log-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem 1rem 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.32rem;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.4;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(124, 255, 178, 0.3) transparent;
|
||||
}
|
||||
|
||||
.ghost-log-body::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.ghost-log-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(124, 255, 178, 0.25);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ghost-log-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.ghost-log-rune {
|
||||
flex: 0 0 auto;
|
||||
color: var(--qm-violet, #b26bff);
|
||||
text-shadow: 0 0 6px rgba(178, 107, 255, 0.6);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ghost-log-line--system .ghost-log-rune {
|
||||
color: var(--qm-green, #7cffb2);
|
||||
text-shadow: 0 0 6px rgba(124, 255, 178, 0.6);
|
||||
}
|
||||
|
||||
.ghost-log-text {
|
||||
color: rgba(211, 228, 220, 0.9);
|
||||
}
|
||||
|
||||
.ghost-log-entity-name {
|
||||
font-family: var(--qm-font-display, 'Cinzel', serif);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--qm-green, #7cffb2);
|
||||
}
|
||||
|
||||
/* ---------- evil meter gauge ---------- */
|
||||
|
||||
.evil-meter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 0.6rem;
|
||||
}
|
||||
|
||||
.evil-meter-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.evil-meter-track {
|
||||
fill: none;
|
||||
stroke: rgba(124, 255, 178, 0.14);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.evil-meter-band {
|
||||
fill: rgba(178, 107, 255, 0.4);
|
||||
filter: drop-shadow(0 0 6px rgba(178, 107, 255, 0.55));
|
||||
transition: d 200ms ease;
|
||||
}
|
||||
|
||||
.evil-meter-band--certain {
|
||||
fill: rgba(124, 255, 178, 0.5);
|
||||
filter: drop-shadow(0 0 10px rgba(124, 255, 178, 0.7));
|
||||
}
|
||||
|
||||
/* CRT-scanline texture cut through the belief band — the gauge belongs to
|
||||
both worlds at once: occult crescent shape, hacker scanline fill. */
|
||||
.evil-meter-scanline {
|
||||
stroke: rgba(7, 7, 13, 0.55);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.evil-meter-band-texture {
|
||||
opacity: 0.6;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.evil-meter-needle {
|
||||
stroke: #d9f5e4;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 4px rgba(217, 245, 228, 0.8));
|
||||
}
|
||||
|
||||
.evil-meter-hub {
|
||||
fill: #d9f5e4;
|
||||
}
|
||||
|
||||
.evil-meter--certain .evil-meter-needle {
|
||||
stroke: var(--qm-green, #7cffb2);
|
||||
}
|
||||
|
||||
.evil-meter-readout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: -0.35rem;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.evil-meter-glyph {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.evil-meter-glyph--malevolent {
|
||||
color: var(--qm-blood, #ff3b5c);
|
||||
text-shadow: 0 0 6px rgba(255, 59, 92, 0.5);
|
||||
}
|
||||
|
||||
.evil-meter-glyph--benevolent {
|
||||
color: var(--qm-green, #7cffb2);
|
||||
text-shadow: 0 0 6px rgba(124, 255, 178, 0.5);
|
||||
}
|
||||
|
||||
.evil-meter-label {
|
||||
color: rgba(211, 228, 220, 0.85);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes ghostLogCandle {
|
||||
0%, 100% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 18px rgba(178, 107, 255, 0.08), inset 0 0 24px rgba(124, 255, 178, 0.03); }
|
||||
35% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 24px rgba(178, 107, 255, 0.16), inset 0 0 28px rgba(124, 255, 178, 0.05); }
|
||||
60% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 14px rgba(178, 107, 255, 0.06), inset 0 0 20px rgba(124, 255, 178, 0.02); }
|
||||
80% { box-shadow: 0 6px 28px rgba(0, 0, 0, 0.55), 0 0 22px rgba(178, 107, 255, 0.14), inset 0 0 26px rgba(124, 255, 178, 0.04); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ghost-log--active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
139
frontend/src/components/GhostLog.test.tsx
Normal file
139
frontend/src/components/GhostLog.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import '../i18n'
|
||||
import { GhostLog } from './GhostLog'
|
||||
import { ghostLogBus } from '../lib/ghostLogBus'
|
||||
import type { GhostLogEventMap } from '../lib/ghostLogBus'
|
||||
|
||||
// GhostLog subscribes to the shared, module-level ghostLogBus (it's mounted
|
||||
// at the app-shell level with no props — see the component's header
|
||||
// comment for why). Tests drive it the same way <SeanceProvider> does in
|
||||
// production: by emitting frames onto the bus and letting React flush.
|
||||
function emit<K extends keyof GhostLogEventMap>(type: K, detail: GhostLogEventMap[K]) {
|
||||
act(() => {
|
||||
ghostLogBus.emit(type, detail)
|
||||
})
|
||||
}
|
||||
|
||||
describe('GhostLog — idle state', () => {
|
||||
afterEach(() => {
|
||||
emit('session_end', {})
|
||||
})
|
||||
|
||||
it('shows ambient status text with no active entity/session', () => {
|
||||
render(<GhostLog />)
|
||||
expect(
|
||||
screen.getByText('the veil is quiet — no presence detected'),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders no evil-meter gauge while idle', () => {
|
||||
render(<GhostLog />)
|
||||
expect(screen.queryByTestId('evil-meter')).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the idle class on the root element', () => {
|
||||
const { container } = render(<GhostLog />)
|
||||
expect(container.querySelector('.ghost-log--idle')).not.toBeNull()
|
||||
expect(container.querySelector('.ghost-log--active')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GhostLog — active session with tells', () => {
|
||||
afterEach(() => {
|
||||
emit('session_end', {})
|
||||
})
|
||||
|
||||
it('switches out of idle once an entity frame arrives', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
|
||||
expect(screen.queryByText('the veil is quiet — no presence detected')).toBeNull()
|
||||
expect(screen.getByText('The Hollow Choir')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a waiting line before any tells arrive', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
expect(screen.getByText('listening for tells…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('streams tell frames in as log lines, most recent last (Transcript convention)', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('tell', { text: 'the entity avoided a direct question' })
|
||||
emit('tell', { text: 'a cold draft crossed the room' })
|
||||
|
||||
expect(screen.getByText('the entity avoided a direct question')).toBeTruthy()
|
||||
expect(screen.getByText('a cold draft crossed the room')).toBeTruthy()
|
||||
|
||||
const lines = Array.from(document.querySelectorAll('.ghost-log-line .ghost-log-text')).map(
|
||||
(el) => el.textContent,
|
||||
)
|
||||
// entity name row + two tell lines, tells in arrival order
|
||||
expect(lines).toEqual([
|
||||
'The Hollow Choir',
|
||||
'the entity avoided a direct question',
|
||||
'a cold draft crossed the room',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders an evil-meter gauge once active, reflecting the tell history', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('tell', { text: 'the entity avoided a direct question' })
|
||||
|
||||
const gauge = screen.getByTestId('evil-meter')
|
||||
expect(gauge).toBeTruthy()
|
||||
const meterEl = gauge.querySelector('[role="meter"]')
|
||||
expect(meterEl).not.toBeNull()
|
||||
expect(meterEl?.getAttribute('aria-valuemin')).toBe('0')
|
||||
expect(meterEl?.getAttribute('aria-valuemax')).toBe('1')
|
||||
// starts near the uncertain midpoint before any tells narrow it
|
||||
expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeGreaterThanOrEqual(0)
|
||||
expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('snaps the gauge to certainty on a successful ritual_complete', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('tell', { text: 'the entity avoided a direct question' })
|
||||
emit('ritual_complete', { success: true, revealed: { alignment: 0.92 } })
|
||||
|
||||
const gauge = screen.getByTestId('evil-meter')
|
||||
expect(gauge.className).toContain('evil-meter--certain')
|
||||
const meterEl = gauge.querySelector('[role="meter"]')
|
||||
expect(Number(meterEl?.getAttribute('aria-valuenow'))).toBeCloseTo(0.92, 3)
|
||||
})
|
||||
|
||||
it('does not snap to certainty on a failed ritual_complete', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('ritual_complete', { success: false, revealed: null })
|
||||
|
||||
const gauge = screen.getByTestId('evil-meter')
|
||||
expect(gauge.className).not.toContain('evil-meter--certain')
|
||||
})
|
||||
|
||||
it('resets back to idle on session_end (leaving the séance)', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('tell', { text: 'a cold draft crossed the room' })
|
||||
emit('session_end', {})
|
||||
|
||||
expect(
|
||||
screen.getByText('the veil is quiet — no presence detected'),
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByText('a cold draft crossed the room')).toBeNull()
|
||||
})
|
||||
|
||||
it('starts a fresh log when a new entity frame supersedes the old one', () => {
|
||||
render(<GhostLog />)
|
||||
emit('entity', { name: 'The Hollow Choir' })
|
||||
emit('tell', { text: 'a cold draft crossed the room' })
|
||||
emit('entity', { name: 'The Weeping Usher' })
|
||||
|
||||
expect(screen.getByText('The Weeping Usher')).toBeTruthy()
|
||||
expect(screen.queryByText('a cold draft crossed the room')).toBeNull()
|
||||
})
|
||||
})
|
||||
254
frontend/src/components/GhostLog.tsx
Normal file
254
frontend/src/components/GhostLog.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
// GhostLog — the app-shell HUD (mounted once in App.tsx, alongside
|
||||
// HauntingLayer, so it's visible on every screen, not just the séance
|
||||
// page). It has no props: it subscribes directly to `ghostLogBus`
|
||||
// (lib/ghostLogBus.ts), which <SeanceProvider> publishes onto whenever a
|
||||
// live séance is running. That indirection exists because the provider is
|
||||
// only mounted inside the séance route — a shell-level sibling can't read
|
||||
// its context directly — so this component stays fully functional (idle
|
||||
// state) on every page with no active WS connection, and comes alive with
|
||||
// streamed `tell` lines once a séance actually starts.
|
||||
//
|
||||
// Visual language: "hacker witch" — Transcript.tsx's terminal/log
|
||||
// vocabulary (monospace rows, phosphor glow, flicker) fused with occult
|
||||
// framing (sigil corner ornaments, rune bullets instead of dashes, a
|
||||
// pentagram divider, a crescent-arc evil-meter gauge with a CRT scanline
|
||||
// texture standing in for a plain progress bar).
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ghostLogBus } from '../lib/ghostLogBus'
|
||||
import { computeEvilMeter, evilMeterLabel } from '../lib/evilMeter'
|
||||
import type { EvilMeterState, RitualReveal } from '../lib/evilMeter'
|
||||
import './GhostLog.css'
|
||||
|
||||
type TellLine = { id: number; text: string; at: number }
|
||||
|
||||
/** Bounded so a very long séance never grows the HUD's memory unbounded. */
|
||||
const TELL_CAP = 60
|
||||
|
||||
let idSeq = 1
|
||||
const nextId = () => idSeq++
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.min(1, Math.max(0, n))
|
||||
}
|
||||
|
||||
export function GhostLog() {
|
||||
const { t } = useTranslation()
|
||||
const [entityName, setEntityName] = useState<string | null>(null)
|
||||
const [tells, setTells] = useState<TellLine[]>([])
|
||||
const [ritualReveal, setRitualReveal] = useState<RitualReveal | null>(null)
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// A fresh `entity` event means a new (or newly re-summoned) presence —
|
||||
// start this HUD's log over rather than carrying the previous entity's
|
||||
// tells/ritual reveal forward.
|
||||
const offEntity = ghostLogBus.on('entity', ({ name }) => {
|
||||
setEntityName(name)
|
||||
setTells([])
|
||||
setRitualReveal(null)
|
||||
})
|
||||
const offTell = ghostLogBus.on('tell', ({ text }) => {
|
||||
setTells((prev) => {
|
||||
const next = [...prev, { id: nextId(), text, at: Date.now() }]
|
||||
return next.length > TELL_CAP ? next.slice(next.length - TELL_CAP) : next
|
||||
})
|
||||
})
|
||||
const offRitual = ghostLogBus.on('ritual_complete', (detail) => {
|
||||
setRitualReveal(detail)
|
||||
})
|
||||
const offEnd = ghostLogBus.on('session_end', () => {
|
||||
setEntityName(null)
|
||||
setTells([])
|
||||
setRitualReveal(null)
|
||||
})
|
||||
return () => {
|
||||
offEntity()
|
||||
offTell()
|
||||
offRitual()
|
||||
offEnd()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Same scroll convention as Transcript.tsx: newest line appended at the
|
||||
// bottom, view auto-scrolls down to follow it.
|
||||
useEffect(() => {
|
||||
const box = bodyRef.current
|
||||
if (box) box.scrollTop = box.scrollHeight
|
||||
}, [tells.length])
|
||||
|
||||
const meter = useMemo(
|
||||
() => computeEvilMeter(tells, ritualReveal),
|
||||
[tells, ritualReveal],
|
||||
)
|
||||
const active = entityName !== null
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`ghost-log ${active ? 'ghost-log--active' : 'ghost-log--idle'}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="ghost-log-corner ghost-log-corner--tl" aria-hidden="true">
|
||||
☽
|
||||
</span>
|
||||
<span className="ghost-log-corner ghost-log-corner--tr" aria-hidden="true">
|
||||
☾
|
||||
</span>
|
||||
|
||||
<header className="ghost-log-header">
|
||||
<span className="ghost-log-glyph" aria-hidden="true">
|
||||
⟁
|
||||
</span>
|
||||
<span className="ghost-log-title">
|
||||
{t('ghostLog.title', { defaultValue: 'GHOST LOG' })}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{active && <EvilMeterGauge meter={meter} label={evilMeterLabel(meter)} />}
|
||||
|
||||
<div className="ghost-log-divider" aria-hidden="true">
|
||||
<span>·</span>
|
||||
<span className="ghost-log-divider-sigil">⛧</span>
|
||||
<span>·</span>
|
||||
</div>
|
||||
|
||||
{!active ? (
|
||||
<p className="ghost-log-status flicker-soft">
|
||||
{t('ghostLog.idle', { defaultValue: 'the veil is quiet — no presence detected' })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="ghost-log-body" ref={bodyRef}>
|
||||
{entityName && (
|
||||
<div className="ghost-log-line ghost-log-line--system">
|
||||
<span className="ghost-log-rune" aria-hidden="true">
|
||||
⟁
|
||||
</span>
|
||||
<span className="ghost-log-text ghost-log-entity-name">{entityName}</span>
|
||||
</div>
|
||||
)}
|
||||
{tells.length === 0 ? (
|
||||
<p className="ghost-log-status dim">
|
||||
{t('ghostLog.waiting', { defaultValue: 'listening for tells…' })}
|
||||
</p>
|
||||
) : (
|
||||
tells.map((tl) => (
|
||||
<div key={tl.id} className="ghost-log-line">
|
||||
<span className="ghost-log-rune" aria-hidden="true">
|
||||
⚝
|
||||
</span>
|
||||
<span className="ghost-log-text">{tl.text}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default GhostLog
|
||||
|
||||
// ---- evil-meter gauge: a crescent arc, not a boring progress bar ----
|
||||
|
||||
type GaugeProps = { meter: EvilMeterState; label: string }
|
||||
|
||||
const CX = 60
|
||||
const CY = 56
|
||||
const R = 42
|
||||
const BAND_THICKNESS = 12
|
||||
|
||||
function angleForValue(v: number): number {
|
||||
// 0 (malevolent) -> 180deg (left); 1 (benevolent) -> 0deg (right); the
|
||||
// sweep from 180 down to 0 traces the *top* semicircle — the crescent.
|
||||
return 180 - clamp01(v) * 180
|
||||
}
|
||||
|
||||
function polar(radius: number, angleDeg: number): { x: number; y: number } {
|
||||
const rad = (angleDeg * Math.PI) / 180
|
||||
return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) }
|
||||
}
|
||||
|
||||
function EvilMeterGauge({ meter, label }: GaugeProps) {
|
||||
const { t } = useTranslation()
|
||||
const patternId = useId()
|
||||
const { value, spread, certain } = meter
|
||||
|
||||
const outerR = R + BAND_THICKNESS / 2
|
||||
const innerR = R - BAND_THICKNESS / 2
|
||||
const lowV = clamp01(value - spread)
|
||||
const highV = clamp01(value + spread)
|
||||
const lowAngle = angleForValue(lowV) // larger angle (further left)
|
||||
const highAngle = angleForValue(highV) // smaller angle (further right)
|
||||
const largeArc = lowAngle - highAngle > 180 ? 1 : 0
|
||||
|
||||
const outerStart = polar(outerR, lowAngle)
|
||||
const outerEnd = polar(outerR, highAngle)
|
||||
const innerEnd = polar(innerR, highAngle)
|
||||
const innerStart = polar(innerR, lowAngle)
|
||||
|
||||
const bandPath = [
|
||||
`M ${outerStart.x} ${outerStart.y}`,
|
||||
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
|
||||
`L ${innerEnd.x} ${innerEnd.y}`,
|
||||
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
|
||||
const trackStart = polar(R, 180)
|
||||
const trackEnd = polar(R, 0)
|
||||
const trackPath = `M ${trackStart.x} ${trackStart.y} A ${R} ${R} 0 0 1 ${trackEnd.x} ${trackEnd.y}`
|
||||
|
||||
const needle = polar(R + BAND_THICKNESS / 2 + 4, angleForValue(value))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`evil-meter${certain ? ' evil-meter--certain' : ''}`}
|
||||
data-testid="evil-meter"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 120 62"
|
||||
className="evil-meter-svg"
|
||||
role="meter"
|
||||
aria-label={t('ghostLog.meterLabel', { defaultValue: 'evil meter' })}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={1}
|
||||
aria-valuenow={Number(value.toFixed(3))}
|
||||
aria-valuetext={label}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width="4"
|
||||
height="4"
|
||||
patternTransform="rotate(45)"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect width="4" height="4" fill="transparent" />
|
||||
<line x1="0" y1="0" x2="0" y2="4" className="evil-meter-scanline" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<path d={trackPath} className="evil-meter-track" />
|
||||
<path d={bandPath} className={`evil-meter-band${certain ? ' evil-meter-band--certain' : ''}`} />
|
||||
<path d={bandPath} fill={`url(#${patternId})`} className="evil-meter-band-texture" />
|
||||
<line
|
||||
x1={CX}
|
||||
y1={CY}
|
||||
x2={needle.x}
|
||||
y2={needle.y}
|
||||
className="evil-meter-needle"
|
||||
/>
|
||||
<circle cx={CX} cy={CY} r={3} className="evil-meter-hub" />
|
||||
</svg>
|
||||
<div className="evil-meter-readout">
|
||||
<span className="evil-meter-glyph evil-meter-glyph--malevolent" aria-hidden="true">
|
||||
⛧
|
||||
</span>
|
||||
<span className="evil-meter-label">{label}</span>
|
||||
<span className="evil-meter-glyph evil-meter-glyph--benevolent" aria-hidden="true">
|
||||
✧
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user