Spaces:
Running
Running
| // Flashback (dual reconstruction) · Timeline · Notes. | |
| import { useState } from 'preact/hooks' | |
| import { useGame } from '../store' | |
| import type { Evidence, FlashbackAccount, TimelineBeat } from '../types' | |
| import { BottomNav, Btn, Chip, EvIcon, Hud, Panel, Scene, perpNoun } from '../ui/components' | |
| function Side({ data, hot }: { data: FlashbackAccount; hot?: boolean }) { | |
| return ( | |
| <Panel variant={hot ? 'ox' : undefined} className="col" style={{ gap: 10, padding: 12 }}> | |
| <div class="between"> | |
| <span class="t-display" style={{ fontSize: 11, color: hot ? 'var(--ox-3)' : 'var(--amber-2)' }}>{data.who}</span> | |
| {hot && <Chip variant="ox">CONTESTED</Chip>} | |
| </div> | |
| <div style={{ aspectRatio: '16/9', boxShadow: 'inset 0 0 0 3px var(--ink-0)', position: 'relative' }}> | |
| <Scene name={data.scene} w={256} h={144} anim style={{ width: '100%', height: '100%' }} /> | |
| <div class="fx-scanlines" style={{ position: 'absolute', inset: 0, opacity: 0.35 }} /> | |
| </div> | |
| <div class="col" style={{ gap: 6 }}> | |
| {data.lines.map((l, i) => ( | |
| <div key={i} class="t-body" style={{ fontSize: 14, padding: '7px 9px', background: 'var(--ink-1)', color: data.flags.includes(i) ? 'var(--ox-3)' : 'var(--bone-2)', boxShadow: data.flags.includes(i) ? 'inset 0 0 0 2px var(--ox-2)' : 'inset 0 0 0 2px var(--ink-0)' }}> | |
| {data.flags.includes(i) && '⚑ '} | |
| {l} | |
| </div> | |
| ))} | |
| </div> | |
| </Panel> | |
| ) | |
| } | |
| export function FlashbackScreen() { | |
| const g = useGame() | |
| const F = g.case.flashback | |
| const [ab, setAB] = useState<'a' | 'b'>('a') | |
| return ( | |
| <div class="app__view"> | |
| <Hud title="RECONSTRUCTION" sub={F.title} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} /> | |
| <div class="screen-pad"> | |
| <div class="maxw" style={{ maxWidth: 980 }}> | |
| <p class="t-body" style={{ marginBottom: 14, color: 'var(--bone-2)' }}> | |
| Two accounts of the same minutes. Where they diverge, the truth is hiding. <span class="ox">Oxblood marks a contradiction.</span> | |
| </p> | |
| {g.mode === 'mobile' && ( | |
| <div class="viewbar" style={{ position: 'static', justifyContent: 'center', marginBottom: 12 }}> | |
| <div class="seg"> | |
| <button class={ab === 'a' ? 'on' : ''} onClick={() => setAB('a')}>{F.a.who}</button> | |
| <button class={ab === 'b' ? 'on' : ''} onClick={() => setAB('b')}>{F.b.who}</button> | |
| </div> | |
| </div> | |
| )} | |
| {g.mode === 'mobile' ? ( | |
| ab === 'a' ? <Side data={F.a} /> : <Side data={F.b} hot /> | |
| ) : ( | |
| <div class="flash-grid"><Side data={F.a} /><Side data={F.b} hot /></div> | |
| )} | |
| <div class="center" style={{ marginTop: 18 }}> | |
| <Btn variant="ox" onClick={() => g.nav('accuse')}>This is enough. Name the {perpNoun(g.case.kind)} ▸</Btn> | |
| </div> | |
| </div> | |
| </div> | |
| <BottomNav /> | |
| </div> | |
| ) | |
| } | |
| export function TimelineScreen() { | |
| const g = useGame() | |
| const c = g.case | |
| const [sel, setSel] = useState<TimelineBeat | null>(null) | |
| const conflicts = c.timeline.filter((t) => t.conflict) | |
| const evOf = (id?: string | null): Evidence | undefined => (id ? c.evidence.find((e) => e.id === id) : undefined) | |
| return ( | |
| <div class="app__view"> | |
| <Hud title="TIMELINE" sub={`THE NIGHT · ${conflicts.length} CONFLICTS`} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} /> | |
| <div class="screen-pad"> | |
| <div class="maxw"> | |
| <p class="t-body" style={{ marginBottom: 18, color: 'var(--bone-2)' }}> | |
| The reconstructed night. <span class="ox">Amber pins are fixed facts; red nodes mark where a statement collides with the evidence.</span> | |
| </p> | |
| <div class="tl-track" style={{ overflowX: 'auto' }}> | |
| <div class="tl-line" /> | |
| <div class="row" style={{ gap: g.mode === 'mobile' ? 14 : 22, minWidth: g.mode === 'mobile' ? 880 : 'auto', position: 'relative', zIndex: 2 }}> | |
| {c.timeline.map((t, i) => { | |
| const ev = evOf(t.ev) | |
| return ( | |
| <div key={i} class="tl-stop grow" style={{ minWidth: 110, cursor: ev ? 'pointer' : 'default' }} onClick={() => ev && setSel(t)}> | |
| <div class="t-mono amber" style={{ fontSize: 'calc(16px*var(--mono-scale))' }}>{t.time}</div> | |
| <div class={'tl-node' + (t.locked ? ' tl-node--lock' : t.conflict ? ' tl-node--conflict' : '')} /> | |
| <Panel className="tl-slot" variant={t.conflict ? 'ox' : undefined} style={{ padding: 8, width: '100%' }}> | |
| {ev && <div style={{ background: 'var(--ink-1)', padding: 3, width: 'fit-content', marginBottom: 5 }}><EvIcon icon={ev.icon} px={2} /></div>} | |
| <div class="t-body" style={{ fontSize: 12, lineHeight: 1.3, color: t.conflict ? 'var(--ox-3)' : 'var(--bone-2)' }}>{t.label}</div> | |
| </Panel> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| {conflicts.length > 0 && ( | |
| <div class="grid-3" style={{ marginTop: 22 }}> | |
| {conflicts.map((t, i) => ( | |
| <Panel key={i} tab="CONFLICT" variant="ox"> | |
| <div class="t-body" style={{ fontSize: 14 }}> | |
| <b class="ox">{t.time}.</b> {t.label} | |
| {evOf(t.ev) && <> The {evOf(t.ev)!.name.toLowerCase()} tells a different story.</>} | |
| </div> | |
| </Panel> | |
| ))} | |
| </div> | |
| )} | |
| <div class="center" style={{ marginTop: 20 }}> | |
| <Btn variant="ox" onClick={() => g.nav('accuse')}>Build the accusation ▸</Btn> | |
| </div> | |
| </div> | |
| </div> | |
| {sel && ( | |
| <div class="tray" onClick={() => setSel(null)}> | |
| <div class="tray__sheet" onClick={(e) => e.stopPropagation()}> | |
| <div class="between" style={{ marginBottom: 10 }}> | |
| <span class="t-display amber" style={{ fontSize: 11 }}>{sel.time} · {evOf(sel.ev)?.name}</span> | |
| <Btn sm variant="ghost" onClick={() => setSel(null)}>✕</Btn> | |
| </div> | |
| <p class="t-body">{evOf(sel.ev)?.summary}</p> | |
| </div> | |
| </div> | |
| )} | |
| <BottomNav /> | |
| </div> | |
| ) | |
| } | |
| export function NotesScreen() { | |
| const g = useGame() | |
| const c = g.case | |
| const grilled = c.suspects.filter((s) => (g.state.interrogations[s.id] || []).length > 1) | |
| const pinned = c.evidence.filter((e) => g.state.pinned.includes(e.id)) | |
| const conflicts = c.timeline.filter((t) => t.conflict) | |
| const sections: [string, preact.ComponentChildren][] = [ | |
| ['SUSPECTS GRILLED', grilled.length ? grilled.map((s) => s.name).join(', ') : 'None yet. Start on the board.'], | |
| ['EXHIBITS PINNED', pinned.length ? pinned.map((e) => e.name).join(', ') : 'Pin the exhibits that contradict a statement.'], | |
| ['OPEN CONTRADICTIONS', conflicts.length ? conflicts.map((t) => t.label).join(' · ') : 'Reconstruct the timeline to surface them.'], | |
| ['THE QUESTION', 'Who could be where the evidence says, when it says — and who had the most to lose?'], | |
| ] | |
| return ( | |
| <div class="app__view"> | |
| <Hud title="DETECTIVE'S NOTEBOOK" sub={c.id} right={<Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>} /> | |
| <div class="screen-center"> | |
| <Panel tab="WORKING THEORY" style={{ width: 'min(640px,100%)', background: 'var(--ink-1)' }}> | |
| <div class="col" style={{ gap: 0 }}> | |
| {sections.map(([k, v], i) => ( | |
| <div key={i} style={{ padding: '14px 4px', boxShadow: i ? 'inset 0 2px 0 rgba(45,74,82,.4)' : 'none' }}> | |
| <div class="t-display amber" style={{ fontSize: 10, marginBottom: 6 }}>{i + 1}. {k}</div> | |
| <div class="t-body" style={{ fontSize: 15 }}>{v}</div> | |
| </div> | |
| ))} | |
| </div> | |
| <div class="center" style={{ marginTop: 14 }}> | |
| <Btn variant="ox" onClick={() => g.nav('accuse')}>Name the {perpNoun(g.case.kind)} ▸</Btn> | |
| </div> | |
| </Panel> | |
| </div> | |
| <BottomNav /> | |
| </div> | |
| ) | |
| } | |