UI overhaul: mobile corkboard, Connect the Dots, nav and overflow fixes

#3
by aliabdelwahab - opened
COMPLIANCE.md CHANGED
@@ -1,105 +1,108 @@
1
- # Case Zero - Hackathon Compliance
2
-
3
- Built for the **Build Small Hackathon** ("Small models, big adventure").
4
-
5
- Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
6
- "Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
7
- registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
8
- GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
9
- app itself is Gradio, served end to end by `gradio.Server`.
10
-
11
- ## Core requirements
12
-
13
- | Requirement | Status |
14
- |---|---|
15
- | Total model params <= 32B | ✓ ~1.6B (see budget below) |
16
- | Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
17
- | Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
18
- | Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
19
- | Social-media post | ☐ to post |
20
-
21
- ## Parameter budget (<= 32B total)
22
-
23
- Every model is open-weights and self-run. **No third-party AI service is ever called.**
24
-
25
- | Component | Model | Open? | Params | Runs |
26
- |---|---|---|---|---|
27
- | Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
28
- | Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
29
- | Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
30
- | Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
31
- | Embeddings / vector RAG | none | n/a | 0B | - |
32
-
33
- **Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
34
- **Tiny Titan** special award).
35
-
36
- ## Merit badges
37
-
38
- ### Earned by the build (verifiable on the Space)
39
-
40
- - **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
41
- The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
42
- rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
43
- baked into the Docker image at build time, so the running container makes **no AI network
44
- calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
45
- socket guard and asserts **zero non-loopback connections**. ✓
46
- - **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
47
- through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
48
- endpoint. ✓
49
- - **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
50
- end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
51
- TypeScript)** - 12 screens, a custom pixel design system (self-hosted Silkscreen /
52
- Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards, a ruled-paper
53
- dossier with page-flips), a draggable corkboard, a live interrogation stage with a
54
- voiced suspect, procedural canvas art and rain FX, and a full client audio layer. The
55
- built bundle is served as static files by the same `gradio.Server` that exposes the
56
- `/api` routes - one process, no separate frontend host.
57
-
58
- ### Targeted / in progress
59
-
60
- - **Field Notes** - *"Write a blog post or report about your project."*
61
- [`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md), shipped with the Space.
62
- - **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
63
- from."* Real traces (the exact prompts + raw completions of a full case generation, and
64
- a live interrogation playthrough with server-authoritative suspicion) are produced by
65
- `scripts/export_traces.py` and published as a Hub dataset (linked from the README).
66
- - **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
67
- Not claimed - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and
68
- publishing a model; out of scope for this submission.
69
-
70
- ## Content scope
71
-
72
- Cases span **homicide, theft, fraud, blackmail, arson, and missing-person** mysteries.
73
- Generation is structurally constrained (case-file language, physical evidence, no graphic
74
- description) and a deterministic scrubber sanitizes model output. Sexual violence is
75
- deliberately **not** a case type, keeping the Space comfortably inside the
76
- [HF Content Guidelines](https://huggingface.co/content-guidelines) with no NFAA gating.
77
-
78
- ## Zero cloud AI APIs
79
-
80
- - **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
81
- AI API is ever called** - not for text, not for voice, not for images.
82
- - The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
83
- art is procedural canvas. The music is a bundled CC-BY track.
84
- - The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
85
- so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
86
- non-loopback connections during a full playthrough.
87
-
88
- ## Anti-cheat / fairness (why the game is solvable and the win is earned)
89
-
90
- - The sealed solution (killer, true motive, key evidence) is **never sent to the client**
91
- pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
92
- - Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
93
- only displays them.
94
- - Suspects **never confess**: the win is registered only when the player accuses correctly,
95
- so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
96
-
97
- ## Submission checklist
98
-
99
- - [x] Gradio app on a Hugging Face Space (CPU)
100
- - [x] <= 32B total params (~1.6B)
101
- - [x] Open-weights, self-run models only - zero cloud AI APIs
102
- - [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
103
- - [x] Off the Grid proof (`scripts/net_audit.py`)
104
- - [ ] Short demo video
105
- - [ ] Social-media post
 
 
 
 
1
+ # Case Zero - Hackathon Compliance
2
+
3
+ Built for the **Build Small Hackathon** ("Small models, big adventure").
4
+
5
+ Case Zero is a **Gradio application**: the whole app is one `gradio.Server` (Gradio 6
6
+ "Server mode" - a FastAPI subclass launched through Gradio, with Gradio API endpoints
7
+ registered via `@server.api`). It is deployed as a **Hugging Face Space** on **CPU** (no
8
+ GPU). It ships via the Docker SDK purely so llama.cpp compiles on a stable base image - the
9
+ app itself is Gradio, served end to end by `gradio.Server`.
10
+
11
+ ## Core requirements
12
+
13
+ | Requirement | Status |
14
+ |---|---|
15
+ | Total model params <= 32B | ✓ ~1.6B (see budget below) |
16
+ | Built in Gradio | ✓ one `gradio.Server`, with `@server.api` endpoints (`new_case`, `interrogate`) |
17
+ | Hosted as a Hugging Face Space | ✓ `build-small-hackathon/case0` (Docker SDK, `app_port: 7860`) |
18
+ | Demo video | ☐ to record (warmup -> interrogate -> present evidence -> alibi cracks -> accuse -> verdict) |
19
+ | Social-media post | ☐ to post |
20
+
21
+ ## Parameter budget (<= 32B total)
22
+
23
+ Every model is open-weights and self-run. **No third-party AI service is ever called.**
24
+
25
+ | Component | Model | Open? | Params | Runs |
26
+ |---|---|---|---|---|
27
+ | Reasoning + dialogue (the whole game) | Qwen2.5-1.5B-Instruct (Q4_K_M GGUF) | Apache-2.0 | **1.5B** | in-process llama.cpp on CPU |
28
+ | Suspect voices | Supertonic (ONNX) | open | ~0.1B | local ONNX Runtime (CPU) |
29
+ | Portraits / scenes / props | Procedural canvas - no model | n/a | 0B | client-side |
30
+ | Music + SFX | Pre-made / procedural audio - no model | n/a | 0B | playback only |
31
+ | Embeddings / vector RAG | none | n/a | 0B | - |
32
+
33
+ **Total runtime parameters: ~1.6B** - far under 32B (and under 4B, eligible for the
34
+ **Tiny Titan** special award).
35
+
36
+ ## Merit badges
37
+
38
+ ### Earned by the build (verifiable on the Space)
39
+
40
+ - **Off the Grid** - *"No cloud APIs. The whole thing runs on the model in front of you."*
41
+ The LLM is in-process llama.cpp; the voices are a local ONNX model; the pixel art is
42
+ rendered client-side on canvas; the music is a bundled CC-BY track. The open weights are
43
+ baked into the Docker image at build time, so the running container makes **no AI network
44
+ calls at all**. Proof: `python scripts/net_audit.py` runs a full playthrough under a
45
+ socket guard and asserts **zero non-loopback connections**. ✓
46
+ - **Llama Champion** - *"Your model runs through the llama.cpp runtime."* The LLM runs
47
+ through `llama-cpp-python` (in-process, on the CPU) - no server, no GPU, no remote
48
+ endpoint. ✓
49
+ - **Off-Brand** - *"A custom frontend that pushes past the default Gradio look."* The front
50
+ end is **not** stock Gradio. It is a hand-built **pixel-art noir SPA (Preact + Vite,
51
+ TypeScript)** - 13 screens, a custom pixel design system (self-hosted Silkscreen /
52
+ Pixelify Sans fonts, beveled 9-slice panels, inventory-slot evidence cards with 23
53
+ procedural pixel icons keyword-matched to each exhibit, a ruled-paper dossier with
54
+ page-flips), a draggable corkboard with a red-yarn **"Connect the Dots"** mode (tie
55
+ evidence together with sagging SVG threads, on desktop and on the scrollable mobile
56
+ wall), a live interrogation stage with a voiced suspect, procedural canvas art and rain
57
+ FX, and a full client audio layer. The built bundle is served as static files by the
58
+ same `gradio.Server` that exposes the `/api` routes - one process, no separate frontend
59
+ host. ✓
60
+
61
+ ### Targeted / in progress
62
+
63
+ - **Field Notes** - *"Write a blog post or report about your project."*
64
+ [`docs/FIELD_NOTES.md`](docs/FIELD_NOTES.md), shipped with the Space.
65
+ - **Sharing is Caring** - *"You shared your agent trace on the Hub for everyone to learn
66
+ from."* Real traces (the exact prompts + raw completions of a full case generation, and
67
+ a live interrogation playthrough with server-authoritative suspicion) are produced by
68
+ `scripts/export_traces.py` and published as a Hub dataset (linked from the README).
69
+ - **Well-Tuned** - *"Your app uses a fine-tuned model you've published on Hugging Face."*
70
+ Not claimed - the game runs on stock Qwen2.5-1.5B. Would require fine-tuning and
71
+ publishing a model; out of scope for this submission.
72
+
73
+ ## Content scope
74
+
75
+ Cases span **homicide, theft, fraud, blackmail, arson, and missing-person** mysteries.
76
+ Generation is structurally constrained (case-file language, physical evidence, no graphic
77
+ description) and a deterministic scrubber sanitizes model output. Sexual violence is
78
+ deliberately **not** a case type, keeping the Space comfortably inside the
79
+ [HF Content Guidelines](https://huggingface.co/content-guidelines) with no NFAA gating.
80
+
81
+ ## Zero cloud AI APIs
82
+
83
+ - **No OpenAI, Anthropic, Google, ElevenLabs, Higgsfield, Midjourney, or any other hosted
84
+ AI API is ever called** - not for text, not for voice, not for images.
85
+ - The LLM is the in-process llama.cpp runtime. The voices are a local ONNX model. The pixel
86
+ art is procedural canvas. The music is a bundled CC-BY track.
87
+ - The open Qwen GGUF and Supertonic ONNX are **baked into the Docker image at build time**,
88
+ so the running container makes no AI network calls. `scripts/net_audit.py` proves zero
89
+ non-loopback connections during a full playthrough.
90
+
91
+ ## Anti-cheat / fairness (why the game is solvable and the win is earned)
92
+
93
+ - The sealed solution (killer, true motive, key evidence) is **never sent to the client**
94
+ pre-verdict; it is read only inside `/api/run/{runId}/accuse`. Verified by anti-leak tests.
95
+ - Suspicion, evidence reactions, and the verdict are **server-authoritative** - the client
96
+ only displays them.
97
+ - Suspects **never confess**: the win is registered only when the player accuses correctly,
98
+ so the outcome is immune to prose (a jailbroken "just tell me who did it" earns nothing).
99
+
100
+ ## Submission checklist
101
+
102
+ - [x] Gradio app on a Hugging Face Space (CPU)
103
+ - [x] <= 32B total params (~1.6B)
104
+ - [x] Open-weights, self-run models only - zero cloud AI APIs
105
+ - [x] Custom (non-default) UI - pixel-art Preact SPA via `gradio.Server`
106
+ - [x] Off the Grid proof (`scripts/net_audit.py`)
107
+ - [ ] Short demo video
108
+ - [ ] Social-media post
src/case_zero/api/case_adapter.py CHANGED
@@ -1,264 +1,297 @@
1
- """Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
2
- golden case uses). Deterministic: it synthesizes the display surface from the case's
3
- own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
4
- is exposed; the true motive's *text* appears only as one option among the decoys.
5
-
6
- Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
7
- (threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import re
13
-
14
- from ..generator.crime_profiles import CrimeProfile, profile_for
15
- from ..schemas.case import CaseFile
16
- from ..schemas.clue import Clue
17
- from .public_view import (
18
- FlashbackAccount,
19
- PublicCase,
20
- PublicEvidence,
21
- PublicFlashback,
22
- PublicMotive,
23
- PublicSuspect,
24
- PublicVictim,
25
- StoryBeat,
26
- SuggestedQuestion,
27
- TimelineBeat,
28
- )
29
- from .questions import FIXED_QUESTIONS
30
-
31
- _SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
32
- _WEATHER = (
33
- "Rain, and a wind off the water.", "Fog thick enough to lean on.",
34
- "A dry cold that gets into the joints.", "Sleet against every window.",
35
- )
36
- _GREETS = (
37
- "Detective. Let's get this over with.",
38
- "I already told the others everything I know.",
39
- "Ask what you came to ask.",
40
- "I have nothing to hide, if that's what you think.",
41
- )
42
- _APPARENT = (
43
- "Was close enough to the victim to have a reason - and a chance.",
44
- "Had more to lose tonight than they're letting on.",
45
- "Their story has a seam in it, if you pull the right thread.",
46
- "Stood to gain from a death no one saw coming.",
47
- )
48
- _DECOY_MOTIVES = (
49
- "To bury a debt that was about to surface.",
50
- "Jealousy that had festered for years.",
51
- "To keep a buried secret buried.",
52
- "To settle an old score, finally.",
53
- "Fear of being exposed and ruined.",
54
- )
55
- # Keyword-matched icons so a letter looks like paper and a keycard like a key -
56
- # never a phone icon on a bloodied letter. First match wins; unmatched rotate.
57
- _ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
58
- (re.compile(r"photo|polaroid|snapshot|portrait|negative|film", re.I), "photoEv"),
59
- (re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
60
- (re.compile(r"voicemail|recording|tape|audio|cylinder|dictaphone", re.I), "voicemail"),
61
- (re.compile(r"phone|telephone|telegram|message|wire\b", re.I), "phone"),
62
- (re.compile(r"key\b|keys\b|keycard|access|badge|pass\b|lock\b", re.I), "keycard"),
63
- (re.compile(r"receipt|ticket|ledger|letter|note\b|paper|document|contract|deed|cheque|"
64
- r"check\b|bill\b|pawn|invoice|book\b|journal|diary|stub", re.I), "receipt"),
65
- (re.compile(r"map\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
66
- )
67
- _ICONS = ("photoEv", "receipt", "compass")
68
-
69
-
70
- def _icon_for(name: str, reveal: str, idx: int) -> str:
71
- hay = f"{name} {reveal}"
72
- for rule, icon in _ICON_RULES:
73
- if rule.search(hay):
74
- return icon
75
- return _ICONS[idx % len(_ICONS)]
76
- # Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
77
- _CITIES = (
78
- "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
79
- "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
80
- "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
81
- )
82
-
83
-
84
- def _hash(s: str) -> int:
85
- h = 0
86
- for ch in s:
87
- h = (h * 31 + ord(ch)) & 0x7FFFFFFF
88
- return h
89
-
90
-
91
- def _clock(minute: int) -> str:
92
- h = (minute // 60) % 24
93
- m = minute % 60
94
- suffix = "AM" if h < 12 else "PM"
95
- h12 = h % 12 or 12
96
- return f"{h12}:{m:02d} {suffix}"
97
-
98
-
99
- def _loc_name(case: CaseFile, loc_id: str) -> str:
100
- for loc in case.setting.locations:
101
- if loc.loc_id == loc_id:
102
- return loc.name
103
- return loc_id
104
-
105
-
106
- def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
107
- s = case.suspects[idx]
108
- seed = _hash(s.sus_id)
109
- role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
110
- tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
111
- quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
112
- gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
113
- return PublicSuspect(
114
- id=s.sus_id,
115
- name=s.name,
116
- role=s.role,
117
- age=30 + (seed % 35),
118
- sprite=s.sus_id,
119
- gender=gender,
120
- tag=tag[:22],
121
- baseline_suspicion=25 + (seed % 16),
122
- motive=_APPARENT[idx % len(_APPARENT)],
123
- alibi=s.stated_alibi.claim_text,
124
- quote=quote,
125
- greet=_GREETS[idx % len(_GREETS)],
126
- suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
127
- )
128
-
129
-
130
- def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
131
- at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
132
- time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
133
- return PublicEvidence(
134
- id=clue.clue_id,
135
- name=clue.name.upper(),
136
- type=clue.discovery_method.value.upper(),
137
- icon=_icon_for(clue.name, clue.reveal_text, idx),
138
- time=time,
139
- found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
140
- summary=clue.reveal_text,
141
- detail=clue.reveal_text,
142
- )
143
-
144
-
145
- def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
146
- beats: list[TimelineBeat] = []
147
- w = case.setting.murder_window
148
- beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
149
- culprit = case.culprit.sus_id
150
- for i, clue in enumerate(case.clues):
151
- at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
152
- t = at if at is not None else w.start_min + 7 * (i + 1)
153
- conflict = clue.contradicts_alibi_of == culprit
154
- beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
155
- incident = profile.timeline_line.format(
156
- name=case.victim.name, instrument=case.weapon.name,
157
- room=_loc_name(case, case.victim.found_at_loc_id),
158
- )
159
- beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
160
- beats.sort(key=lambda b: b.time)
161
- return tuple(beats)
162
-
163
-
164
- def _flashback(case: CaseFile) -> PublicFlashback:
165
- culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
166
- claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
167
- crime = _loc_name(case, case.victim.found_at_loc_id)
168
- breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
169
- return PublicFlashback(
170
- title="TWO ACCOUNTS",
171
- a=FlashbackAccount(
172
- who=f"{culprit.name} SAYS",
173
- scene=claimed,
174
- lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
175
- flags=(),
176
- ),
177
- b=FlashbackAccount(
178
- who="THE EVIDENCE SAYS",
179
- scene=crime,
180
- lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
181
- flags=tuple(range(len(breakers))),
182
- ),
183
- )
184
-
185
-
186
- def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
187
- true_text = case.culprit.true_motive.summary
188
- decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
189
- chosen = [PublicMotive(id="M1", text=true_text)]
190
- for i in range(3):
191
- chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
192
- # stable shuffle by seed so the true option isn't always first
193
- rot = seed % len(chosen)
194
- return tuple(chosen[rot:] + chosen[:rot])
195
-
196
-
197
- def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
198
- """Each beat's backdrop is the place its text describes: the city for the call, the
199
- REAL crime scene for the incident, another room of the same building for the cast,
200
- and the detective's desk for the hand-off."""
201
- v = case.victim
202
- crime_loc = case.victim.found_at_loc_id
203
- building = case.setting.name
204
- other_room = next(
205
- (loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
206
- _loc_name(case, crime_loc),
207
- )
208
- return (
209
- StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
210
- StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
211
- StoryBeat(scene=f"{building} {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
212
- text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
213
- StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
214
- text="One of them is lying to your face. Find the crack in the account and follow it down."),
215
- )
216
-
217
-
218
- def casefile_to_public(case: CaseFile) -> PublicCase:
219
- seed = case.seed
220
- profile = profile_for(case.crime_kind)
221
- tod = case.victim.time_of_death.start_min
222
- building = case.setting.name
223
- room = _loc_name(case, case.victim.found_at_loc_id)
224
- scene = f"{building} {room}" # building + room; the painter keys off the room
225
- city = _CITIES[seed % len(_CITIES)]
226
- return PublicCase(
227
- id=case.case_id,
228
- city=city,
229
- district=building,
230
- title=case.title,
231
- tagline="Detective, your presence is required.",
232
- weather=_WEATHER[seed % len(_WEATHER)],
233
- victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
234
- scene=scene,
235
- tod=_clock(tod),
236
- found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
237
- cause=case.victim.cause_of_death,
238
- kind=profile.kind.value,
239
- kind_label=profile.kind_label,
240
- division=profile.division,
241
- victim_status=profile.victim_status,
242
- tod_label=profile.tod_label,
243
- verdict=profile.verdict,
244
- facts=(
245
- ("CITY", city),
246
- ("VICTIM", f"{case.victim.name}"),
247
- ("SCENE", scene),
248
- (profile.tod_label, _clock(tod)),
249
- ("WHAT HAPPENED", case.victim.cause_of_death),
250
- ("VERDICT", profile.verdict),
251
- ),
252
- boot_lines=(
253
- "The phone drags you up out of half a sleep.",
254
- f"{case.setting.name}. {profile.boot_line}",
255
- f"{case.victim.name} - {case.victim.role}.",
256
- "They're holding the scene. It's yours now, detective.",
257
- ),
258
- story_beats=_story_beats(case, profile, scene),
259
- suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
260
- evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
261
- timeline=_timeline(case, profile),
262
- flashback=_flashback(case),
263
- motives=_motives(case, seed),
264
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Project a generated ``CaseFile`` into the PUBLIC wire view (the same contract the
2
+ golden case uses). Deterministic: it synthesizes the display surface from the case's
3
+ own fields. Nothing from the sealed solution (culprit, true motive id, breaker chain)
4
+ is exposed; the true motive's *text* appears only as one option among the decoys.
5
+
6
+ Generated evidence renders as paper exhibits (reveal_text); richer per-type payloads
7
+ (threads, keycard tables, voicemail) and authored tags/quotes are a later enhancement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ from ..generator.crime_profiles import CrimeProfile, profile_for
15
+ from ..schemas.case import CaseFile
16
+ from ..schemas.clue import Clue
17
+ from .public_view import (
18
+ FlashbackAccount,
19
+ PublicCase,
20
+ PublicEvidence,
21
+ PublicFlashback,
22
+ PublicMotive,
23
+ PublicSuspect,
24
+ PublicVictim,
25
+ StoryBeat,
26
+ SuggestedQuestion,
27
+ TimelineBeat,
28
+ )
29
+ from .questions import FIXED_QUESTIONS
30
+
31
+ _SCENES = ("skyline", "atrium", "interro", "seawall", "mezzanine", "desk")
32
+ _WEATHER = (
33
+ "Rain, and a wind off the water.", "Fog thick enough to lean on.",
34
+ "A dry cold that gets into the joints.", "Sleet against every window.",
35
+ )
36
+ _GREETS = (
37
+ "Detective. Let's get this over with.",
38
+ "I already told the others everything I know.",
39
+ "Ask what you came to ask.",
40
+ "I have nothing to hide, if that's what you think.",
41
+ )
42
+ _APPARENT = (
43
+ "Was close enough to the victim to have a reason - and a chance.",
44
+ "Had more to lose tonight than they're letting on.",
45
+ "Their story has a seam in it, if you pull the right thread.",
46
+ "Stood to gain from a death no one saw coming.",
47
+ )
48
+ _DECOY_MOTIVES = (
49
+ "To bury a debt that was about to surface.",
50
+ "Jealousy that had festered for years.",
51
+ "To keep a buried secret buried.",
52
+ "To settle an old score, finally.",
53
+ "Fear of being exposed and ruined.",
54
+ )
55
+ # Keyword-matched icons so a letter looks like an envelope, a tumbler like a glass and a
56
+ # bloodied letter opener like a blade - never a phone icon on a fingerprint. First match
57
+ # wins, so the order is deliberate: specific objects (knife before "letter", footprints
58
+ # before "prints", keycard before "key") come before the generic paper/photo buckets.
59
+ _ICON_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
60
+ (re.compile(r"cctv|camera|footage|surveillance|still\b", re.I), "cctv"),
61
+ (re.compile(r"photo|polaroid|snapshot|portrait|negative|film\b", re.I), "photoEv"),
62
+ (re.compile(r"knife|blade|dagger|razor|scissors?|shears|opener|stilett?o", re.I), "knife"),
63
+ (re.compile(r"voicemail|recording|tape\b|audio|cylinder|dictaphone|phonograph", re.I), "voicemail"),
64
+ (re.compile(r"phone|telephone|message|\bwire\b|\bcall\b|\btext\b", re.I), "phone"),
65
+ (re.compile(r"keycard|key card|access|badge|\bpass\b|swipe", re.I), "keycard"),
66
+ (re.compile(r"\bkey\b|\bkeys\b|keychain|keyring|latchkey|\block\b|padlock", re.I), "key"),
67
+ (re.compile(r"footprint|bootprint|\bboot\b|shoe|\bsole\b|tread|scuff|heel", re.I), "bootprint"),
68
+ (re.compile(r"fingerprint|thumbprint|handprint|prints?\b", re.I), "fingerprint"),
69
+ (re.compile(r"tumbler|glass\b|goblet|wine|whisky|whiskey|brandy|champagne|cocktail", re.I), "tumbler"),
70
+ (re.compile(r"bottle|vial|flask|perfume|poison|decanter|tonic|arsenic|cyanide|sedative", re.I), "bottle"),
71
+ (re.compile(r"match(es|box|book)?\b|lighter|accelerant|gasoline|petrol|kerosene|"
72
+ r"\bfuel\b|scorch|charred|\bburn(t|ed)?\b|ember|\bash\b|flame", re.I), "flame"),
73
+ (re.compile(r"cigar|cigarette|tobacco|ashtray", re.I), "cigarette"),
74
+ (re.compile(r"clock|\bwatch\b|timepiece|hourglass|pendulum", re.I), "clock"),
75
+ (re.compile(r"cufflink|jewell?|diamond|brooch|necklace|pendant|earring|locket|"
76
+ r"bracelet|\bgem\b|\bring\b|tiara|pearl|sapphire|ruby|emerald", re.I), "jewel"),
77
+ (re.compile(r"thread|fib(er|re)|cloth\b|fabric|handkerchief|scarf|glove|wool|silk|"
78
+ r"snag|button\b|lace\b", re.I), "thread"),
79
+ (re.compile(r"\bpen\b|\bnib\b|quill|fountain pen|inkwell|\bink\b", re.I), "pen"),
80
+ (re.compile(r"letter\b|envelope|telegram|postcard|\bnote\b|correspondence|\bmemo\b", re.I), "envelope"),
81
+ (re.compile(r"\bbook\b|journal|diary|ledger|notebook|manuscript|novel", re.I), "book"),
82
+ (re.compile(r"\bcash\b|banknote|money|wallet|billfold|currency|\bcoins?\b|bribe|payout", re.I), "cash"),
83
+ (re.compile(r"magnif|\blens\b|loupe", re.I), "magnifier"),
84
+ (re.compile(r"receipt|ticket|\bstub\b|paper|document|contract|deed|cheque|\bcheck\b|"
85
+ r"\bbill\b|pawn|invoice|agreement|statement|certificate|affidavit|\bwill\b|"
86
+ r"\bfile\b|\bform\b|register", re.I), "receipt"),
87
+ (re.compile(r"\bmap\b|compass|route|itinerary|timetable|schedule", re.I), "compass"),
88
+ )
89
+ # No keyword hit: fall back to how the clue is FOUND, which is always known - forensics
90
+ # read as a print, documents as paper, searches as a scene photo, testimony as a voice.
91
+ _METHOD_ICONS = {
92
+ "forensic": "fingerprint",
93
+ "document": "receipt",
94
+ "search": "photoEv",
95
+ "interrogation": "voicemail",
96
+ }
97
+ _ICONS = ("photoEv", "receipt", "compass", "envelope", "fingerprint")
98
+
99
+
100
+ def _icon_for(name: str, reveal: str, idx: int, method: str = "") -> str:
101
+ # The NAME names the object itself, so it decides first; the reveal text only breaks
102
+ # a tie when the name says nothing (it describes context and loves false positives -
103
+ # a fingerprint whose reveal mentions "the blade" is still a fingerprint).
104
+ for hay in (name, reveal):
105
+ for rule, icon in _ICON_RULES:
106
+ if rule.search(hay):
107
+ return icon
108
+ return _METHOD_ICONS.get(method, _ICONS[idx % len(_ICONS)])
109
+ # Noir city names so CITY reads as an actual city, not the venue (the venue goes in SCENE).
110
+ _CITIES = (
111
+ "Graymoor", "Blackport", "Ashmoor", "Harrowgate", "Duskwater", "Coldhaven",
112
+ "Ravenreach", "Mortlake", "Greyharbor", "Thornehaven", "Mistport", "Hollowmere",
113
+ "Saltmarsh", "Ironhaven", "Blackwater", "Fellgate",
114
+ )
115
+
116
+
117
+ def _hash(s: str) -> int:
118
+ h = 0
119
+ for ch in s:
120
+ h = (h * 31 + ord(ch)) & 0x7FFFFFFF
121
+ return h
122
+
123
+
124
+ def _clock(minute: int) -> str:
125
+ h = (minute // 60) % 24
126
+ m = minute % 60
127
+ suffix = "AM" if h < 12 else "PM"
128
+ h12 = h % 12 or 12
129
+ return f"{h12}:{m:02d} {suffix}"
130
+
131
+
132
+ def _loc_name(case: CaseFile, loc_id: str) -> str:
133
+ for loc in case.setting.locations:
134
+ if loc.loc_id == loc_id:
135
+ return loc.name
136
+ return loc_id
137
+
138
+
139
+ def _suspect_public(case: CaseFile, idx: int) -> PublicSuspect:
140
+ s = case.suspects[idx]
141
+ seed = _hash(s.sus_id)
142
+ role_word = (s.role.split()[-1] if s.role else "").strip(".,").upper()
143
+ tag = f"THE {role_word}" if role_word else "PERSON OF INTEREST"
144
+ quote = (s.persona_summary.split(".")[0] + ".") if s.persona_summary else "I've nothing to hide."
145
+ gender = "female" if ((s.visual.gender if s.visual else "") or "").lower().startswith("f") else "male"
146
+ return PublicSuspect(
147
+ id=s.sus_id,
148
+ name=s.name,
149
+ role=s.role,
150
+ age=30 + (seed % 35),
151
+ sprite=s.sus_id,
152
+ gender=gender,
153
+ tag=tag[:22],
154
+ baseline_suspicion=25 + (seed % 16),
155
+ motive=_APPARENT[idx % len(_APPARENT)],
156
+ alibi=s.stated_alibi.claim_text,
157
+ quote=quote,
158
+ greet=_GREETS[idx % len(_GREETS)],
159
+ suggested_questions=tuple(SuggestedQuestion(id=q, q=text) for q, text in FIXED_QUESTIONS),
160
+ )
161
+
162
+
163
+ def _evidence_public(case: CaseFile, clue: Clue, idx: int) -> PublicEvidence:
164
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
165
+ time = _clock(at) if at is not None else _clock(case.setting.murder_window.start_min + 7 * (idx + 1))
166
+ return PublicEvidence(
167
+ id=clue.clue_id,
168
+ name=clue.name.upper(),
169
+ type=clue.discovery_method.value.upper(),
170
+ icon=_icon_for(clue.name, clue.reveal_text, idx, clue.discovery_method.value),
171
+ time=time,
172
+ found=f"Recovered from {_loc_name(case, clue.discoverable_at_loc_id)}.",
173
+ summary=clue.reveal_text,
174
+ detail=clue.reveal_text,
175
+ )
176
+
177
+
178
+ def _timeline(case: CaseFile, profile: CrimeProfile) -> tuple[TimelineBeat, ...]:
179
+ beats: list[TimelineBeat] = []
180
+ w = case.setting.murder_window
181
+ beats.append(TimelineBeat(time=_clock(w.start_min), label="The evening is under way; everyone is in the house.", locked=True))
182
+ culprit = case.culprit.sus_id
183
+ for i, clue in enumerate(case.clues):
184
+ at = next((f.at_min for f in case.facts if f.fact_id == clue.supports_fact_id and f.at_min is not None), None)
185
+ t = at if at is not None else w.start_min + 7 * (i + 1)
186
+ conflict = clue.contradicts_alibi_of == culprit
187
+ beats.append(TimelineBeat(time=_clock(t), label=clue.reveal_text[:80], ev=clue.clue_id, conflict=conflict))
188
+ incident = profile.timeline_line.format(
189
+ name=case.victim.name, instrument=case.weapon.name,
190
+ room=_loc_name(case, case.victim.found_at_loc_id),
191
+ )
192
+ beats.append(TimelineBeat(time=_clock(case.victim.time_of_death.start_min), label=incident, locked=True))
193
+ beats.sort(key=lambda b: b.time)
194
+ return tuple(beats)
195
+
196
+
197
+ def _flashback(case: CaseFile) -> PublicFlashback:
198
+ culprit = next((s for s in case.suspects if s.sus_id == case.culprit.sus_id), case.suspects[0])
199
+ claimed = _loc_name(case, case.culprit.alibi_lie.claimed_loc_id)
200
+ crime = _loc_name(case, case.victim.found_at_loc_id)
201
+ breakers = [c for c in case.clues if c.contradicts_alibi_of == culprit.sus_id][:3]
202
+ return PublicFlashback(
203
+ title="TWO ACCOUNTS",
204
+ a=FlashbackAccount(
205
+ who=f"{culprit.name} SAYS",
206
+ scene=claimed,
207
+ lines=(culprit.stated_alibi.claim_text, f"I was in {claimed} the whole time.", "I never went near it."),
208
+ flags=(),
209
+ ),
210
+ b=FlashbackAccount(
211
+ who="THE EVIDENCE SAYS",
212
+ scene=crime,
213
+ lines=tuple(c.reveal_text[:90] for c in breakers) or ("The evidence places someone at the scene.",),
214
+ flags=tuple(range(len(breakers))),
215
+ ),
216
+ )
217
+
218
+
219
+ def _motives(case: CaseFile, seed: int) -> tuple[PublicMotive, ...]:
220
+ true_text = case.culprit.true_motive.summary
221
+ decoys = [d for d in _DECOY_MOTIVES if d.lower() != true_text.lower()]
222
+ chosen = [PublicMotive(id="M1", text=true_text)]
223
+ for i in range(3):
224
+ chosen.append(PublicMotive(id=f"MD{i + 1}", text=decoys[(seed + i) % len(decoys)]))
225
+ # stable shuffle by seed so the true option isn't always first
226
+ rot = seed % len(chosen)
227
+ return tuple(chosen[rot:] + chosen[:rot])
228
+
229
+
230
+ def _story_beats(case: CaseFile, profile: CrimeProfile, scene: str) -> tuple[StoryBeat, ...]:
231
+ """Each beat's backdrop is the place its text describes: the city for the call, the
232
+ REAL crime scene for the incident, another room of the same building for the cast,
233
+ and the detective's desk for the hand-off."""
234
+ v = case.victim
235
+ crime_loc = case.victim.found_at_loc_id
236
+ building = case.setting.name
237
+ other_room = next(
238
+ (loc.name for loc in case.setting.locations if loc.loc_id != crime_loc),
239
+ _loc_name(case, crime_loc),
240
+ )
241
+ return (
242
+ StoryBeat(scene="skyline", kicker=case.setting.name.upper(), title="The call", text=case.briefing),
243
+ StoryBeat(scene=scene, kicker="THE VICTIM", title=v.name, text=f"{v.name}, {v.role}. {v.cause_of_death}"),
244
+ StoryBeat(scene=f"{building} — {other_room}", kicker="THOSE WHO STAYED", title="Persons of interest",
245
+ text="Each of them had a reason to be here tonight, and a story you'll need to take apart."),
246
+ StoryBeat(scene="desk", kicker="YOUR CASE NOW", title="Detective",
247
+ text="One of them is lying to your face. Find the crack in the account and follow it down."),
248
+ )
249
+
250
+
251
+ def casefile_to_public(case: CaseFile) -> PublicCase:
252
+ seed = case.seed
253
+ profile = profile_for(case.crime_kind)
254
+ tod = case.victim.time_of_death.start_min
255
+ building = case.setting.name
256
+ room = _loc_name(case, case.victim.found_at_loc_id)
257
+ scene = f"{building} — {room}" # building + room; the painter keys off the room
258
+ city = _CITIES[seed % len(_CITIES)]
259
+ return PublicCase(
260
+ id=case.case_id,
261
+ city=city,
262
+ district=building,
263
+ title=case.title,
264
+ tagline="Detective, your presence is required.",
265
+ weather=_WEATHER[seed % len(_WEATHER)],
266
+ victim=PublicVictim(name=case.victim.name, role=case.victim.role, age=40 + (seed % 30), sprite="victim", bio=case.briefing),
267
+ scene=scene,
268
+ tod=_clock(tod),
269
+ found=f"{profile.found_verb} {scene} at {_clock(case.victim.found_at_min)}.",
270
+ cause=case.victim.cause_of_death,
271
+ kind=profile.kind.value,
272
+ kind_label=profile.kind_label,
273
+ division=profile.division,
274
+ victim_status=profile.victim_status,
275
+ tod_label=profile.tod_label,
276
+ verdict=profile.verdict,
277
+ facts=(
278
+ ("CITY", city),
279
+ ("VICTIM", f"{case.victim.name}"),
280
+ ("SCENE", scene),
281
+ (profile.tod_label, _clock(tod)),
282
+ ("WHAT HAPPENED", case.victim.cause_of_death),
283
+ ("VERDICT", profile.verdict),
284
+ ),
285
+ boot_lines=(
286
+ "The phone drags you up out of half a sleep.",
287
+ f"{case.setting.name}. {profile.boot_line}",
288
+ f"{case.victim.name} - {case.victim.role}.",
289
+ "They're holding the scene. It's yours now, detective.",
290
+ ),
291
+ story_beats=_story_beats(case, profile, scene),
292
+ suspects=tuple(_suspect_public(case, i) for i in range(len(case.suspects))),
293
+ evidence=tuple(_evidence_public(case, c, i) for i, c in enumerate(case.clues)),
294
+ timeline=_timeline(case, profile),
295
+ flashback=_flashback(case),
296
+ motives=_motives(case, seed),
297
+ )
web/src/engine/art.ts CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/engine/draw.ts CHANGED
@@ -1,72 +1,77 @@
1
- // Low-level pixel drawing helpers, shared by the canvas components and the procedural
2
- // art. Ported verbatim from the prototype's pixel.jsx so rendering stays pixel-accurate.
3
-
4
- export type Pal = Record<string, string> | null | undefined
5
-
6
- /** Draw a string-grid map: rows of chars, each char a key in `pal`. */
7
- export function drawMap(
8
- ctx: CanvasRenderingContext2D,
9
- map: string[],
10
- pal: Pal,
11
- px: number,
12
- ): void {
13
- for (let y = 0; y < map.length; y++) {
14
- const row = map[y]
15
- for (let x = 0; x < row.length; x++) {
16
- const c = row[x]
17
- if (c === '.' || c === ' ' || c == null) continue
18
- const col = pal ? pal[c] || c : c
19
- if (!col || col === '.') continue
20
- ctx.fillStyle = col
21
- ctx.fillRect(x * px, y * px, px, px)
22
- }
23
- }
24
- }
25
-
26
- /** Ordered 4x4 Bayer matrix for dithering. */
27
- export const BAYER4 = [
28
- [0, 8, 2, 10],
29
- [12, 4, 14, 6],
30
- [3, 11, 1, 9],
31
- [15, 7, 13, 5],
32
- ]
33
-
34
- /** Dither between two colors over a region; `t` in 0..1 is the `near` coverage. */
35
- export function dither(
36
- ctx: CanvasRenderingContext2D,
37
- x0: number,
38
- y0: number,
39
- w: number,
40
- h: number,
41
- near: string,
42
- far: string,
43
- t: number,
44
- ): void {
45
- for (let y = 0; y < h; y++) {
46
- for (let x = 0; x < w; x++) {
47
- const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
48
- ctx.fillStyle = thr < t ? near : far
49
- ctx.fillRect(x0 + x, y0 + y, 1, 1)
50
- }
51
- }
52
- }
53
-
54
- /** Vertical dither gradient from colTop to colBottom across height h. */
55
- export function ditherGrad(
56
- ctx: CanvasRenderingContext2D,
57
- x0: number,
58
- y0: number,
59
- w: number,
60
- h: number,
61
- colTop: string,
62
- colBottom: string,
63
- ): void {
64
- for (let y = 0; y < h; y++) {
65
- const t = 1 - y / h
66
- for (let x = 0; x < w; x++) {
67
- const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
68
- ctx.fillStyle = thr < t ? colTop : colBottom
69
- ctx.fillRect(x0 + x, y0 + y, 1, 1)
70
- }
71
- }
72
- }
 
 
 
 
 
 
1
+ // Low-level pixel drawing helpers, shared by the canvas components and the procedural
2
+ // art. Ported verbatim from the prototype's pixel.jsx so rendering stays pixel-accurate.
3
+
4
+ export type Pal = Record<string, string> | null | undefined
5
+
6
+ /** One sprite row: a string of single-char palette keys, or an array of cell values
7
+ * (full hex colors / multi-char keys). Indexing works the same on both. */
8
+ export type PixelRow = string | string[]
9
+ export type PixelMap = PixelRow[]
10
+
11
+ /** Draw a pixel map: rows of cells, each cell a key in `pal` or a literal color. */
12
+ export function drawMap(
13
+ ctx: CanvasRenderingContext2D,
14
+ map: PixelMap,
15
+ pal: Pal,
16
+ px: number,
17
+ ): void {
18
+ for (let y = 0; y < map.length; y++) {
19
+ const row = map[y]
20
+ for (let x = 0; x < row.length; x++) {
21
+ const c = row[x]
22
+ if (c === '.' || c === ' ' || c == null) continue
23
+ const col = pal ? pal[c] || c : c
24
+ if (!col || col === '.') continue
25
+ ctx.fillStyle = col
26
+ ctx.fillRect(x * px, y * px, px, px)
27
+ }
28
+ }
29
+ }
30
+
31
+ /** Ordered 4x4 Bayer matrix for dithering. */
32
+ export const BAYER4 = [
33
+ [0, 8, 2, 10],
34
+ [12, 4, 14, 6],
35
+ [3, 11, 1, 9],
36
+ [15, 7, 13, 5],
37
+ ]
38
+
39
+ /** Dither between two colors over a region; `t` in 0..1 is the `near` coverage. */
40
+ export function dither(
41
+ ctx: CanvasRenderingContext2D,
42
+ x0: number,
43
+ y0: number,
44
+ w: number,
45
+ h: number,
46
+ near: string,
47
+ far: string,
48
+ t: number,
49
+ ): void {
50
+ for (let y = 0; y < h; y++) {
51
+ for (let x = 0; x < w; x++) {
52
+ const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
53
+ ctx.fillStyle = thr < t ? near : far
54
+ ctx.fillRect(x0 + x, y0 + y, 1, 1)
55
+ }
56
+ }
57
+ }
58
+
59
+ /** Vertical dither gradient from colTop to colBottom across height h. */
60
+ export function ditherGrad(
61
+ ctx: CanvasRenderingContext2D,
62
+ x0: number,
63
+ y0: number,
64
+ w: number,
65
+ h: number,
66
+ colTop: string,
67
+ colBottom: string,
68
+ ): void {
69
+ for (let y = 0; y < h; y++) {
70
+ const t = 1 - y / h
71
+ for (let x = 0; x < w; x++) {
72
+ const thr = (BAYER4[y & 3][x & 3] + 0.5) / 16
73
+ ctx.fillStyle = thr < t ? colTop : colBottom
74
+ ctx.fillRect(x0 + x, y0 + y, 1, 1)
75
+ }
76
+ }
77
+ }
web/src/engine/pixel.tsx CHANGED
@@ -1,253 +1,253 @@
1
- // Pixel engine — crisp nearest-neighbor canvas rendering. Ported from prototype/js/pixel.jsx.
2
- import { useEffect, useRef, useState } from 'preact/hooks'
3
- import type { JSX } from 'preact'
4
-
5
- import { type Pal, drawMap } from './draw'
6
-
7
- export type ScenePainter = (
8
- ctx: CanvasRenderingContext2D,
9
- w: number,
10
- h: number,
11
- t: number,
12
- ) => void
13
-
14
- interface PixelCanvasProps {
15
- frames?: string[][]
16
- map?: string[]
17
- pal?: Pal
18
- px?: number
19
- fps?: number
20
- className?: string
21
- style?: JSX.CSSProperties
22
- playing?: boolean
23
- onClick?: (e: MouseEvent) => void
24
- }
25
-
26
- /** A sprite, optionally animated across frames. */
27
- export function PixelCanvas({
28
- frames,
29
- map,
30
- pal,
31
- px = 4,
32
- fps = 4,
33
- className,
34
- style,
35
- playing = true,
36
- onClick,
37
- }: PixelCanvasProps) {
38
- const ref = useRef<HTMLCanvasElement>(null)
39
- const allFrames = frames || [map as string[]]
40
- const cols = allFrames[0][0].length
41
- const rows = allFrames[0].length
42
- const [f, setF] = useState(0)
43
-
44
- useEffect(() => {
45
- if (!playing || allFrames.length < 2) return
46
- const id = setInterval(() => setF((v) => (v + 1) % allFrames.length), 1000 / fps)
47
- return () => clearInterval(id)
48
- }, [playing, allFrames.length, fps])
49
-
50
- useEffect(() => {
51
- const cv = ref.current
52
- if (!cv) return
53
- const ctx = cv.getContext('2d')!
54
- ctx.imageSmoothingEnabled = false
55
- ctx.clearRect(0, 0, cv.width, cv.height)
56
- drawMap(ctx, allFrames[f], pal, px)
57
- }, [f, px, pal, allFrames])
58
-
59
- return (
60
- <canvas
61
- ref={ref}
62
- width={cols * px}
63
- height={rows * px}
64
- onClick={onClick}
65
- class={className}
66
- style={{ imageRendering: 'pixelated', display: 'block', ...style }}
67
- />
68
- )
69
- }
70
-
71
- interface SceneCanvasProps {
72
- paint: ScenePainter
73
- w?: number
74
- h?: number
75
- className?: string
76
- style?: JSX.CSSProperties
77
- deps?: unknown[]
78
- anim?: boolean
79
- full?: boolean
80
- rain?: boolean
81
- }
82
-
83
- /** Procedural painter at low internal res. Static scenes paint once to an offscreen
84
- * buffer; anim scenes blit the buffer + a cheap rain overlay so we never re-dither
85
- * the whole canvas every frame. `full` forces a true per-frame repaint. */
86
- export function SceneCanvas({
87
- paint,
88
- w = 240,
89
- h = 135,
90
- className,
91
- style,
92
- deps = [],
93
- anim = false,
94
- full = false,
95
- rain = true,
96
- }: SceneCanvasProps) {
97
- const ref = useRef<HTMLCanvasElement>(null)
98
- const bufRef = useRef<HTMLCanvasElement | null>(null)
99
- const tRef = useRef(0)
100
- useEffect(() => {
101
- const cv = ref.current
102
- if (!cv) return
103
- const ctx = cv.getContext('2d')!
104
- ctx.imageSmoothingEnabled = false
105
-
106
- const buf = document.createElement('canvas')
107
- buf.width = w
108
- buf.height = h
109
- const bctx = buf.getContext('2d')!
110
- bctx.imageSmoothingEnabled = false
111
- paint(bctx, w, h, 0)
112
- bufRef.current = buf
113
-
114
- let raf = 0
115
- // always paint a first frame synchronously (rAF is paused in background iframes)
116
- ctx.clearRect(0, 0, w, h)
117
- ctx.drawImage(buf, 0, 0)
118
- if (full) {
119
- let last = 0
120
- const loop = (ts: number) => {
121
- if (ts - last > 110) {
122
- last = ts
123
- tRef.current += 1
124
- ctx.clearRect(0, 0, w, h)
125
- paint(ctx, w, h, tRef.current)
126
- }
127
- raf = requestAnimationFrame(loop)
128
- }
129
- raf = requestAnimationFrame(loop)
130
- } else if (anim && rain) {
131
- let last = 0
132
- const loop = (ts: number) => {
133
- if (ts - last > 70) {
134
- last = ts
135
- tRef.current += 1
136
- ctx.clearRect(0, 0, w, h)
137
- ctx.drawImage(buf, 0, 0)
138
- ctx.fillStyle = 'rgba(176,196,206,0.26)'
139
- const t = tRef.current
140
- for (let i = 0; i < 36; i++) {
141
- const x = (i * 41 + t * 5) % w
142
- const y = (i * 57 + t * 9) % h
143
- ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
144
- }
145
- }
146
- raf = requestAnimationFrame(loop)
147
- }
148
- raf = requestAnimationFrame(loop)
149
- } else {
150
- ctx.clearRect(0, 0, w, h)
151
- ctx.drawImage(buf, 0, 0)
152
- }
153
- return () => cancelAnimationFrame(raf)
154
- // eslint-disable-next-line react-hooks/exhaustive-deps
155
- }, deps)
156
- return (
157
- <canvas
158
- ref={ref}
159
- width={w}
160
- height={h}
161
- class={className}
162
- style={{ imageRendering: 'pixelated', display: 'block', width: '100%', height: '100%', ...style }}
163
- />
164
- )
165
- }
166
-
167
- /** Full-screen pixel rain on a canvas. */
168
- export function RainFX({ density = 90 }: { density?: number }) {
169
- const ref = useRef<HTMLCanvasElement>(null)
170
- useEffect(() => {
171
- const cv = ref.current
172
- if (!cv) return
173
- const ctx = cv.getContext('2d')!
174
- let W = 0
175
- let H = 0
176
- let drops: { x: number; y: number; v: number; len: number }[] = []
177
- let raf = 0
178
- const resize = () => {
179
- W = cv.width = Math.ceil(window.innerWidth / 3)
180
- H = cv.height = Math.ceil(window.innerHeight / 3)
181
- cv.style.width = window.innerWidth + 'px'
182
- cv.style.height = window.innerHeight + 'px'
183
- drops = Array.from({ length: density }, () => ({
184
- x: Math.random() * W,
185
- y: Math.random() * H,
186
- v: 2 + Math.random() * 3,
187
- len: 3 + Math.random() * 5,
188
- }))
189
- }
190
- resize()
191
- window.addEventListener('resize', resize)
192
- let last = 0
193
- const loop = (ts: number) => {
194
- if (ts - last > 33) {
195
- last = ts
196
- ctx.clearRect(0, 0, W, H)
197
- ctx.fillStyle = 'rgba(180,200,210,0.30)'
198
- for (const d of drops) {
199
- ctx.fillRect(Math.floor(d.x), Math.floor(d.y), 1, Math.floor(d.len))
200
- d.y += d.v
201
- d.x += 0.4
202
- if (d.y > H) {
203
- d.y = -d.len
204
- d.x = Math.random() * W
205
- }
206
- }
207
- }
208
- raf = requestAnimationFrame(loop)
209
- }
210
- raf = requestAnimationFrame(loop)
211
- return () => {
212
- cancelAnimationFrame(raf)
213
- window.removeEventListener('resize', resize)
214
- }
215
- }, [density])
216
- return <canvas ref={ref} class="fx-rain" style={{ position: 'fixed', inset: 0, imageRendering: 'pixelated' }} />
217
- }
218
-
219
- /** True if the user asked the OS to reduce motion. */
220
- export function prefersReducedMotion(): boolean {
221
- return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
222
- }
223
-
224
- /** Typewriter hook: returns [visibleText, done]. Honors prefers-reduced-motion (instant). */
225
- export function useTypewriter(text: string, speed = 28, on = true): [string, boolean] {
226
- if (prefersReducedMotion()) on = false
227
- const [out, setOut] = useState(on ? '' : text)
228
- const [done, setDone] = useState(!on)
229
- useEffect(() => {
230
- if (!on) {
231
- setOut(text)
232
- setDone(true)
233
- return
234
- }
235
- setOut('')
236
- setDone(false)
237
- if (!text) {
238
- setDone(true)
239
- return
240
- }
241
- let i = 0
242
- const id = setInterval(() => {
243
- i++
244
- setOut(text.slice(0, i))
245
- if (i >= text.length) {
246
- clearInterval(id)
247
- setDone(true)
248
- }
249
- }, speed)
250
- return () => clearInterval(id)
251
- }, [text, speed, on])
252
- return [out, done]
253
- }
 
1
+ // Pixel engine — crisp nearest-neighbor canvas rendering. Ported from prototype/js/pixel.jsx.
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import type { JSX } from 'preact'
4
+
5
+ import { type Pal, type PixelMap, drawMap } from './draw'
6
+
7
+ export type ScenePainter = (
8
+ ctx: CanvasRenderingContext2D,
9
+ w: number,
10
+ h: number,
11
+ t: number,
12
+ ) => void
13
+
14
+ interface PixelCanvasProps {
15
+ frames?: PixelMap[]
16
+ map?: PixelMap
17
+ pal?: Pal
18
+ px?: number
19
+ fps?: number
20
+ className?: string
21
+ style?: JSX.CSSProperties
22
+ playing?: boolean
23
+ onClick?: (e: MouseEvent) => void
24
+ }
25
+
26
+ /** A sprite, optionally animated across frames. */
27
+ export function PixelCanvas({
28
+ frames,
29
+ map,
30
+ pal,
31
+ px = 4,
32
+ fps = 4,
33
+ className,
34
+ style,
35
+ playing = true,
36
+ onClick,
37
+ }: PixelCanvasProps) {
38
+ const ref = useRef<HTMLCanvasElement>(null)
39
+ const allFrames = frames || [map as PixelMap]
40
+ const cols = allFrames[0][0].length
41
+ const rows = allFrames[0].length
42
+ const [f, setF] = useState(0)
43
+
44
+ useEffect(() => {
45
+ if (!playing || allFrames.length < 2) return
46
+ const id = setInterval(() => setF((v) => (v + 1) % allFrames.length), 1000 / fps)
47
+ return () => clearInterval(id)
48
+ }, [playing, allFrames.length, fps])
49
+
50
+ useEffect(() => {
51
+ const cv = ref.current
52
+ if (!cv) return
53
+ const ctx = cv.getContext('2d')!
54
+ ctx.imageSmoothingEnabled = false
55
+ ctx.clearRect(0, 0, cv.width, cv.height)
56
+ drawMap(ctx, allFrames[f], pal, px)
57
+ }, [f, px, pal, allFrames])
58
+
59
+ return (
60
+ <canvas
61
+ ref={ref}
62
+ width={cols * px}
63
+ height={rows * px}
64
+ onClick={onClick}
65
+ class={className}
66
+ style={{ imageRendering: 'pixelated', display: 'block', ...style }}
67
+ />
68
+ )
69
+ }
70
+
71
+ interface SceneCanvasProps {
72
+ paint: ScenePainter
73
+ w?: number
74
+ h?: number
75
+ className?: string
76
+ style?: JSX.CSSProperties
77
+ deps?: unknown[]
78
+ anim?: boolean
79
+ full?: boolean
80
+ rain?: boolean
81
+ }
82
+
83
+ /** Procedural painter at low internal res. Static scenes paint once to an offscreen
84
+ * buffer; anim scenes blit the buffer + a cheap rain overlay so we never re-dither
85
+ * the whole canvas every frame. `full` forces a true per-frame repaint. */
86
+ export function SceneCanvas({
87
+ paint,
88
+ w = 240,
89
+ h = 135,
90
+ className,
91
+ style,
92
+ deps = [],
93
+ anim = false,
94
+ full = false,
95
+ rain = true,
96
+ }: SceneCanvasProps) {
97
+ const ref = useRef<HTMLCanvasElement>(null)
98
+ const bufRef = useRef<HTMLCanvasElement | null>(null)
99
+ const tRef = useRef(0)
100
+ useEffect(() => {
101
+ const cv = ref.current
102
+ if (!cv) return
103
+ const ctx = cv.getContext('2d')!
104
+ ctx.imageSmoothingEnabled = false
105
+
106
+ const buf = document.createElement('canvas')
107
+ buf.width = w
108
+ buf.height = h
109
+ const bctx = buf.getContext('2d')!
110
+ bctx.imageSmoothingEnabled = false
111
+ paint(bctx, w, h, 0)
112
+ bufRef.current = buf
113
+
114
+ let raf = 0
115
+ // always paint a first frame synchronously (rAF is paused in background iframes)
116
+ ctx.clearRect(0, 0, w, h)
117
+ ctx.drawImage(buf, 0, 0)
118
+ if (full) {
119
+ let last = 0
120
+ const loop = (ts: number) => {
121
+ if (ts - last > 110) {
122
+ last = ts
123
+ tRef.current += 1
124
+ ctx.clearRect(0, 0, w, h)
125
+ paint(ctx, w, h, tRef.current)
126
+ }
127
+ raf = requestAnimationFrame(loop)
128
+ }
129
+ raf = requestAnimationFrame(loop)
130
+ } else if (anim && rain) {
131
+ let last = 0
132
+ const loop = (ts: number) => {
133
+ if (ts - last > 70) {
134
+ last = ts
135
+ tRef.current += 1
136
+ ctx.clearRect(0, 0, w, h)
137
+ ctx.drawImage(buf, 0, 0)
138
+ ctx.fillStyle = 'rgba(176,196,206,0.26)'
139
+ const t = tRef.current
140
+ for (let i = 0; i < 36; i++) {
141
+ const x = (i * 41 + t * 5) % w
142
+ const y = (i * 57 + t * 9) % h
143
+ ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3)
144
+ }
145
+ }
146
+ raf = requestAnimationFrame(loop)
147
+ }
148
+ raf = requestAnimationFrame(loop)
149
+ } else {
150
+ ctx.clearRect(0, 0, w, h)
151
+ ctx.drawImage(buf, 0, 0)
152
+ }
153
+ return () => cancelAnimationFrame(raf)
154
+ // eslint-disable-next-line react-hooks/exhaustive-deps
155
+ }, deps)
156
+ return (
157
+ <canvas
158
+ ref={ref}
159
+ width={w}
160
+ height={h}
161
+ class={className}
162
+ style={{ imageRendering: 'pixelated', display: 'block', width: '100%', height: '100%', ...style }}
163
+ />
164
+ )
165
+ }
166
+
167
+ /** Full-screen pixel rain on a canvas. */
168
+ export function RainFX({ density = 90 }: { density?: number }) {
169
+ const ref = useRef<HTMLCanvasElement>(null)
170
+ useEffect(() => {
171
+ const cv = ref.current
172
+ if (!cv) return
173
+ const ctx = cv.getContext('2d')!
174
+ let W = 0
175
+ let H = 0
176
+ let drops: { x: number; y: number; v: number; len: number }[] = []
177
+ let raf = 0
178
+ const resize = () => {
179
+ W = cv.width = Math.ceil(window.innerWidth / 3)
180
+ H = cv.height = Math.ceil(window.innerHeight / 3)
181
+ cv.style.width = window.innerWidth + 'px'
182
+ cv.style.height = window.innerHeight + 'px'
183
+ drops = Array.from({ length: density }, () => ({
184
+ x: Math.random() * W,
185
+ y: Math.random() * H,
186
+ v: 2 + Math.random() * 3,
187
+ len: 3 + Math.random() * 5,
188
+ }))
189
+ }
190
+ resize()
191
+ window.addEventListener('resize', resize)
192
+ let last = 0
193
+ const loop = (ts: number) => {
194
+ if (ts - last > 33) {
195
+ last = ts
196
+ ctx.clearRect(0, 0, W, H)
197
+ ctx.fillStyle = 'rgba(180,200,210,0.30)'
198
+ for (const d of drops) {
199
+ ctx.fillRect(Math.floor(d.x), Math.floor(d.y), 1, Math.floor(d.len))
200
+ d.y += d.v
201
+ d.x += 0.4
202
+ if (d.y > H) {
203
+ d.y = -d.len
204
+ d.x = Math.random() * W
205
+ }
206
+ }
207
+ }
208
+ raf = requestAnimationFrame(loop)
209
+ }
210
+ raf = requestAnimationFrame(loop)
211
+ return () => {
212
+ cancelAnimationFrame(raf)
213
+ window.removeEventListener('resize', resize)
214
+ }
215
+ }, [density])
216
+ return <canvas ref={ref} class="fx-rain" style={{ position: 'fixed', inset: 0, imageRendering: 'pixelated' }} />
217
+ }
218
+
219
+ /** True if the user asked the OS to reduce motion. */
220
+ export function prefersReducedMotion(): boolean {
221
+ return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
222
+ }
223
+
224
+ /** Typewriter hook: returns [visibleText, done]. Honors prefers-reduced-motion (instant). */
225
+ export function useTypewriter(text: string, speed = 28, on = true): [string, boolean] {
226
+ if (prefersReducedMotion()) on = false
227
+ const [out, setOut] = useState(on ? '' : text)
228
+ const [done, setDone] = useState(!on)
229
+ useEffect(() => {
230
+ if (!on) {
231
+ setOut(text)
232
+ setDone(true)
233
+ return
234
+ }
235
+ setOut('')
236
+ setDone(false)
237
+ if (!text) {
238
+ setDone(true)
239
+ return
240
+ }
241
+ let i = 0
242
+ const id = setInterval(() => {
243
+ i++
244
+ setOut(text.slice(0, i))
245
+ if (i >= text.length) {
246
+ clearInterval(id)
247
+ setDone(true)
248
+ }
249
+ }, speed)
250
+ return () => clearInterval(id)
251
+ }, [text, speed, on])
252
+ return [out, done]
253
+ }
web/src/screens/board-threads.tsx ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Red-yarn threads for the investigation board's "Connect the Dots" mode: pair state
2
+ // persisted per case in localStorage, plus the SVG layer that draws the yarn. (Named
3
+ // ThreadPair, not Thread — Evidence.thread already means phone messages in types.ts.)
4
+ import { useState } from 'preact/hooks'
5
+
6
+ export type ThreadPair = [string, string]
7
+
8
+ const keyFor = (caseId: string) => `cz-threads-${caseId}`
9
+ export const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`)
10
+ const canon = (a: string, b: string): ThreadPair => (a < b ? [a, b] : [b, a])
11
+
12
+ function loadPairs(caseId: string, valid: Set<string>): ThreadPair[] {
13
+ try {
14
+ const raw = localStorage.getItem(keyFor(caseId))
15
+ if (!raw) return []
16
+ const data = JSON.parse(raw) as { v?: number; pairs?: unknown }
17
+ if (data?.v !== 1 || !Array.isArray(data.pairs)) return []
18
+ const seen = new Set<string>()
19
+ const out: ThreadPair[] = []
20
+ for (const p of data.pairs) {
21
+ if (!Array.isArray(p) || typeof p[0] !== 'string' || typeof p[1] !== 'string') continue
22
+ const [a, b] = canon(p[0], p[1])
23
+ if (a === b || !valid.has(a) || !valid.has(b) || seen.has(pairKey(a, b))) continue
24
+ seen.add(pairKey(a, b))
25
+ out.push([a, b])
26
+ }
27
+ return out
28
+ } catch {
29
+ return []
30
+ }
31
+ }
32
+
33
+ function savePairs(caseId: string, pairs: ThreadPair[]): void {
34
+ try {
35
+ localStorage.setItem(keyFor(caseId), JSON.stringify({ v: 1, pairs }))
36
+ } catch {
37
+ /* ignore */
38
+ }
39
+ }
40
+
41
+ export interface Threads {
42
+ pairs: ThreadPair[]
43
+ /** Add the pair — or remove it if it already exists. */
44
+ toggle: (a: string, b: string) => 'added' | 'removed'
45
+ remove: (a: string, b: string) => void
46
+ clear: () => void
47
+ }
48
+
49
+ /** Thread pairs for one case, restored from and written through to localStorage so the
50
+ * yarn survives screen switches (the board unmounts on nav) and full reloads. */
51
+ export function useThreads(caseId: string, validIds: string[]): Threads {
52
+ const [pairs, setPairs] = useState<ThreadPair[]>(() => loadPairs(caseId, new Set(validIds)))
53
+ const commit = (next: ThreadPair[]) => {
54
+ savePairs(caseId, next)
55
+ setPairs(next)
56
+ }
57
+ return {
58
+ pairs,
59
+ toggle: (a, b) => {
60
+ const k = pairKey(a, b)
61
+ const without = pairs.filter(([x, y]) => pairKey(x, y) !== k)
62
+ const removed = without.length < pairs.length
63
+ commit(removed ? without : [...pairs, canon(a, b)])
64
+ return removed ? 'removed' : 'added'
65
+ },
66
+ remove: (a, b) => {
67
+ const k = pairKey(a, b)
68
+ commit(pairs.filter(([x, y]) => pairKey(x, y) !== k))
69
+ },
70
+ clear: () => commit([]),
71
+ }
72
+ }
73
+
74
+ /** Yarn path between two pushpins: a quadratic curve with a little gravity sag. */
75
+ export function threadD(x1: number, y1: number, x2: number, y2: number): string {
76
+ const dist = Math.hypot(x2 - x1, y2 - y1)
77
+ const sag = Math.min(48, 12 + dist * 0.14)
78
+ return `M ${x1} ${y1} Q ${(x1 + x2) / 2} ${(y1 + y2) / 2 + sag} ${x2} ${y2}`
79
+ }
80
+
81
+ interface ThreadLayerProps {
82
+ pairs: ThreadPair[]
83
+ /** Pushpin position for a card id, in board coordinates (null until laid out). */
84
+ anchor: (id: string) => { x: number; y: number } | null
85
+ w: number
86
+ h: number
87
+ /** Connect mode: threads grow an invisible fat hit-stroke so a tap can cut them. */
88
+ connect: boolean
89
+ /** Live dashed thread from the armed pin to the pointer (desktop juice). */
90
+ ghost?: { from: { x: number; y: number }; to: { x: number; y: number } } | null
91
+ onRemove: (a: string, b: string) => void
92
+ }
93
+
94
+ export function ThreadLayer({ pairs, anchor, w, h, connect, ghost, onRemove }: ThreadLayerProps) {
95
+ return (
96
+ <svg class="thread-layer" viewBox={`0 0 ${w} ${h}`} width={w} height={h} aria-hidden="true">
97
+ {pairs.map(([a, b]) => {
98
+ const pa = anchor(a)
99
+ const pb = anchor(b)
100
+ if (!pa || !pb) return null
101
+ const d = threadD(pa.x, pa.y, pb.x, pb.y)
102
+ return (
103
+ // Keyed by pair so the draw-in animation runs once on creation, not on drags.
104
+ <g key={pairKey(a, b)}>
105
+ <path class="thread thread--under" d={d} pathLength={1} />
106
+ <path class="thread thread--draw" d={d} pathLength={1} />
107
+ {connect && (
108
+ <path
109
+ class="thread-hit"
110
+ d={d}
111
+ onClick={(e) => {
112
+ e.stopPropagation()
113
+ onRemove(a, b)
114
+ }}
115
+ />
116
+ )}
117
+ <circle class="thread-knot" cx={pa.x} cy={pa.y} r={2.5} />
118
+ <circle class="thread-knot" cx={pb.x} cy={pb.y} r={2.5} />
119
+ </g>
120
+ )
121
+ })}
122
+ {ghost && <path class="thread thread--ghost" d={threadD(ghost.from.x, ghost.from.y, ghost.to.x, ghost.to.y)} />}
123
+ </svg>
124
+ )
125
+ }
web/src/screens/board.tsx CHANGED
@@ -1,19 +1,26 @@
1
- // Investigation board — corkboard with draggable evidence cards + suspect rail. Mobile
2
- // collapses to stacked lists. Node positions are derived from the case so any evidence
3
- // count lays out cleanly.
 
4
  import { useEffect, useRef, useState } from 'preact/hooks'
5
 
6
  import { useGame } from '../store'
7
- import type { PublicCase, Suspect } from '../types'
8
- import {
9
- BottomNav, Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspectCard, SuspicionBar,
10
- } from '../ui/components'
11
 
12
- const CARD_W = 150
13
  const CARD_H = 92
 
 
14
  const SPOTS: [number, number][] = [
15
  [0.1, 0.3], [0.7, 0.16], [0.42, 0.44], [0.74, 0.52], [0.12, 0.72], [0.52, 0.78], [0.3, 0.28], [0.85, 0.36],
16
  ]
 
 
 
 
 
17
 
18
  interface Node {
19
  id: string
@@ -21,10 +28,11 @@ interface Node {
21
  x: number
22
  y: number
23
  }
24
- function boardNodes(c: PublicCase): Node[] {
25
- const nodes: Node[] = [{ id: 'victim', kind: 'victim', x: 0.44, y: 0.05 }]
 
26
  c.evidence.forEach((e, i) => {
27
- const [x, y] = SPOTS[i % SPOTS.length]
28
  nodes.push({ id: e.id, kind: 'evidence', x, y })
29
  })
30
  return nodes
@@ -32,16 +40,67 @@ function boardNodes(c: PublicCase): Node[] {
32
 
33
  export function Board() {
34
  const g = useGame()
35
- if (g.mode === 'mobile') return <BoardMobile />
36
  const c = g.case
37
- const NODES = boardNodes(c)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  const ref = useRef<HTMLDivElement>(null)
39
- const [size, setSize] = useState({ w: 1100, h: 640 })
40
  const [pos, setPos] = useState<Record<string, { x: number; y: number }>>({})
41
  const [selEv, setSelEv] = useState<string | null>(null)
 
 
 
42
  const drag = useRef<{ id: string; dx: number; dy: number } | null>(null)
43
  const dragCleanup = useRef<(() => void) | null>(null)
 
 
 
 
44
 
 
 
 
 
 
 
 
 
 
45
  useEffect(() => {
46
  const m = () => {
47
  if (ref.current) {
@@ -52,32 +111,40 @@ export function Board() {
52
  m()
53
  window.addEventListener('resize', m)
54
  return () => window.removeEventListener('resize', m)
55
- }, [])
 
 
56
  useEffect(() => {
57
  if (size.w < 40) return
58
  setPos((p) => {
59
  if (Object.keys(p).length) return p
60
  const np: Record<string, { x: number; y: number }> = {}
61
  NODES.forEach((n) => {
62
- np[n.id] = { x: n.x * (size.w - CARD_W), y: n.y * (size.h - CARD_H) }
63
  })
64
  return np
65
  })
66
- }, [size])
67
  useEffect(() => () => dragCleanup.current?.(), [])
68
 
69
  const onDown = (id: string, e: MouseEvent | TouchEvent) => {
 
70
  e.preventDefault()
71
- if (drag.current) return
72
  const pt = 'touches' in e ? e.touches[0] : e
73
- drag.current = { id, dx: pt.clientX - pos[id].x, dy: pt.clientY - pos[id].y }
 
 
 
74
  const move = (ev: MouseEvent | TouchEvent) => {
75
  const d = drag.current
76
- if (!d) return
 
77
  window.__justDragged = true
 
78
  const p = 'touches' in ev ? ev.touches[0] : ev
79
- const nx = Math.max(0, Math.min(size.w - CARD_W, p.clientX - d.dx))
80
- const ny = Math.max(0, Math.min(size.h - CARD_H, p.clientY - d.dy))
81
  setPos((o) => ({ ...o, [d.id]: { x: nx, y: ny } }))
82
  }
83
  const up = () => {
@@ -97,57 +164,153 @@ export function Board() {
97
  window.addEventListener('touchend', up)
98
  }
99
 
100
- return (
101
- <div class="app__view">
102
- <Hud
103
- title="INVESTIGATION BOARD"
104
- sub="THE EVIDENCE WALL · DRAG TO ARRANGE"
105
- right={
106
- <>
107
- <Btn sm variant="ghost" onClick={() => g.nav('notes')}>Notes</Btn>
108
- <Btn sm variant="ghost" onClick={() => g.nav('timeline')}>Timeline</Btn>
109
- <Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse ▸</Btn>
110
- </>
111
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  />
113
- <div class="board-layout">
114
- <div class="board" ref={ref}>
115
- <div class="board__felt" />
116
- <BoardDecor c={c} />
117
- <div class="board__frame" />
118
- <div class="board__filters">
119
- <span class="chip chip--amber">EVIDENCE WALL · {c.evidence.length}</span>
120
- </div>
121
- {NODES.map((n) => {
122
- if (!pos[n.id]) return null
123
- const p = pos[n.id]
124
- const isEv = n.kind === 'evidence'
125
- const open = selEv === n.id
126
- return (
127
- <div key={n.id} class={'pin-card' + (open ? ' pin-card--sel' : '')} style={{ left: p.x, top: p.y, width: CARD_W, zIndex: open ? 7 : 4 }}>
128
- <span class="pin" onMouseDown={(e) => onDown(n.id, e)} onTouchStart={(e) => onDown(n.id, e)} title="Drag to move" />
129
- <div class="card-grip" onMouseDown={(e) => onDown(n.id, e)} onTouchStart={(e) => onDown(n.id, e)} title="Drag to move">
130
- <span class="card-grip__dots">⠿⠿⠿</span>
131
- <span class="card-grip__lbl">MOVE</span>
132
- </div>
133
- <BoardNode node={n} selected={open} onSelect={() => { if (!window.__justDragged && isEv) setSelEv(open ? null : n.id) }} />
134
- {isEv && open && (
135
- <div class="card-actions">
136
- <Btn variant="amber" sm onClick={() => g.nav('evidence', { focus: n.id })}>Examine ▸</Btn>
137
- </div>
138
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  </div>
140
- )
141
- })}
142
- </div>
143
- <SuspectRail />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  </div>
145
- <BottomNav />
146
  </div>
147
  )
148
  }
149
 
150
- function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: string; rot: number; pin?: string; children: preact.ComponentChildren; w?: number }) {
151
  return (
152
  <div class="wall-item" style={{ left: x, top: y, transform: `rotate(${rot}deg)`, width: w }}>
153
  <span class={'pushpin pushpin--' + pin} />
@@ -156,11 +319,11 @@ function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: str
156
  )
157
  }
158
 
159
- function BoardDecor({ c }: { c: PublicCase }) {
160
  const tod = c.facts.find(([k]) => k === 'TIME OF DEATH')?.[1] || c.tod
161
  return (
162
  <div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none' }}>
163
- <WallItem x="0.5%" y="3%" rot={-3} pin="bone" w={186}>
164
  <div class="wall-clip">
165
  <div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
166
  <div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
@@ -168,48 +331,56 @@ function BoardDecor({ c }: { c: PublicCase }) {
168
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
169
  </div>
170
  </WallItem>
171
- <WallItem x="63%" y="1%" rot={3} pin="ox" w={160}>
172
- <div class="wall-photo">
173
- <div style={{ background: '#0d1117', height: 96 }}><Scene name="map" w={148} h={96} full style={{ width: '100%', height: '100%' }} /></div>
174
- <div class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.district.split('·')[0]}</div>
175
- </div>
176
- </WallItem>
177
- <WallItem x="29%" y="11%" rot={4} pin="ox" w={120}>
 
 
178
  <div class="wall-note scrawl" style={{ fontSize: 15 }}>{tod} —<br /><b>pushed?</b></div>
179
  </WallItem>
180
- <WallItem x="2%" y="52%" rot={-5} pin="amber" w={130}>
181
- <div class="wall-note wall-note--bone scrawl" style={{ fontSize: 15 }}>WHO HELD<br />A KEY?</div>
182
- </WallItem>
183
- <WallItem x="56%" y="82%" rot={-3} pin="amber" w={120}>
 
 
184
  <div class="wall-note scrawl" style={{ fontSize: 15 }}>MOTIVE = ?</div>
185
  </WallItem>
186
- <WallItem x="29%" y="60%" rot={2} pin="bone" w={150}>
187
- <div class="wall-redact">
188
- <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150' }}>FORENSIC — SEALED</div>
189
- <div class="bar-blk" style={{ width: '90%' }} />
190
- <div class="bar-blk" style={{ width: '70%' }} />
191
- <div class="bar-blk" style={{ width: '85%' }} />
192
- </div>
193
- </WallItem>
194
- <WallItem x="83%" y="30%" rot={-4} pin="amber" w={150}>
195
- <div class="wall-clip">
196
- <div style={{ fontFamily: 'var(--f-display)', fontSize: 8, color: '#231f16', marginBottom: 5 }}>GUEST LIST</div>
197
- {c.suspects.map((s) => (
198
- <div key={s.id} class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#4a4234', display: 'flex', justifyContent: 'space-between' }}>
199
- <span>{s.name.split(' ').slice(-1)[0]}, {s.name[0]}.</span>
200
- <span style={{ color: '#8a3a2c' }}>✓</span>
201
- </div>
202
- ))}
203
- </div>
204
- </WallItem>
205
- <WallItem x="84%" y="55%" rot={3} pin="ox" w={138}>
 
 
 
 
206
  <div class="wall-photo">
207
  <div style={{ background: '#0d1117', height: 72 }}><Scene name="mezzanine" w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
208
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — RAIL</div>
209
  </div>
210
  </WallItem>
211
- <div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />
212
- <div class="wall-item" style={{ left: '-6%', top: '88%', width: '46%', transform: 'rotate(-7deg)', zIndex: 8 }}>
213
  <div style={{ background: 'var(--amber-2)', color: 'var(--ink-0)', padding: '5px 0', boxShadow: '0 3px 0 rgba(0,0,0,.4)', fontFamily: 'var(--f-display)', fontSize: 10, letterSpacing: '.3em', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
214
  ▪ POLICE LINE ▪ DO NOT CROSS ▪ POLICE LINE ▪
215
  </div>
@@ -243,8 +414,8 @@ function SuspectRail() {
243
  </div>
244
  <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
245
  <div class="between" style={{ gap: 6 }}>
246
- <span class="t-display nowrap" style={{ fontSize: 10, color: 'var(--bone-3)' }}>{s.name}</span>
247
- <span class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: susp >= 65 ? 'var(--ox-3)' : 'var(--bone-1)' }}>{susp}%</span>
248
  </div>
249
  <div class="t-label">{s.tag}</div>
250
  <SuspicionBar value={susp} compact label={null} />
@@ -266,13 +437,13 @@ function SuspectRail() {
266
  )
267
  }
268
 
269
- function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean; onSelect: () => void }) {
270
  const g = useGame()
271
  const c = g.case
272
  if (node.kind === 'evidence') {
273
  const e = c.evidence.find((x) => x.id === node.id)!
274
  return (
275
- <div class="pin-note" style={{ width: CARD_W, cursor: 'pointer', boxShadow: selected ? '0 0 0 3px var(--amber-2), 3px 4px 0 rgba(0,0,0,.4)' : undefined }} onClick={onSelect}>
276
  <div class="row" style={{ gap: 8, alignItems: 'center' }}>
277
  <div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
278
  <div class="col" style={{ gap: 2, minWidth: 0 }}>
@@ -284,7 +455,7 @@ function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean
284
  )
285
  }
286
  return (
287
- <Panel variant="ox" style={{ padding: 7, width: CARD_W }}>
288
  <div class="row" style={{ gap: 8, alignItems: 'center' }}>
289
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 2, flexShrink: 0 }}>
290
  <Portrait id={c.victim.sprite} px={3} />
@@ -297,46 +468,3 @@ function BoardNode({ node, selected, onSelect }: { node: Node; selected: boolean
297
  </Panel>
298
  )
299
  }
300
-
301
- function BoardMobile() {
302
- const g = useGame()
303
- const c = g.case
304
- const [sel, setSel] = useState<string | null>(null)
305
- return (
306
- <div class="app__view">
307
- <Hud title="THE BOARD" sub={c.id} right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>} />
308
- <div class="screen-pad">
309
- <div class="row between" style={{ marginBottom: 8 }}>
310
- <span class="t-label" style={{ color: 'var(--amber-2)' }}>PERSONS OF INTEREST</span>
311
- <span class="t-label dim">tap, then interrogate</span>
312
- </div>
313
- <div class="col" style={{ gap: 10 }}>
314
- {c.suspects.map((s: Suspect) => {
315
- const open = sel === s.id
316
- return (
317
- <div key={s.id} onClick={() => setSel(open ? null : s.id)}>
318
- <SuspectCard s={s} active={open} />
319
- {open && (
320
- <div style={{ marginTop: -2 }}>
321
- <Panel variant="amber" style={{ padding: 10, marginTop: 4 }}>
322
- <div class="t-body" style={{ fontSize: 13, lineHeight: 1.4, marginBottom: 8 }}>{s.alibi}</div>
323
- <Btn variant="ox" className="grow" style={{ width: '100%' }} onClick={(e) => { e.stopPropagation(); g.nav('interro', { suspect: s.id }) }}>▸ Interrogate {s.name.split(' ')[0]}</Btn>
324
- </Panel>
325
- </div>
326
- )}
327
- </div>
328
- )
329
- })}
330
- </div>
331
- <div class="row between" style={{ margin: '18px 0 8px' }}>
332
- <span class="t-label">THE EVIDENCE WALL</span>
333
- <span class="t-label dim">{c.evidence.length}</span>
334
- </div>
335
- <div class="col" style={{ gap: 8 }}>
336
- {c.evidence.map((e) => <EvidenceCard key={e.id} e={e} onClick={() => g.nav('evidence', { focus: e.id })} />)}
337
- </div>
338
- </div>
339
- <BottomNav />
340
- </div>
341
- )
342
- }
 
1
+ // Investigation board — corkboard with draggable evidence cards, a red-yarn "Connect
2
+ // the Dots" mode, and a suspect rail on desktop. Mobile gets the same corkboard on a
3
+ // tall scrollable wall (suspects live in their own bottom-nav tab there). Node positions
4
+ // are derived from the case so any evidence count lays out cleanly.
5
  import { useEffect, useRef, useState } from 'preact/hooks'
6
 
7
  import { useGame } from '../store'
8
+ import type { PublicCase } from '../types'
9
+ import { playSfx } from '../ui/audio'
10
+ import { BottomNav, Btn, EvIcon, Hud, Panel, Portrait, Scene, SuspicionBar } from '../ui/components'
11
+ import { ThreadLayer, useThreads } from './board-threads'
12
 
 
13
  const CARD_H = 92
14
+ const CARD_W = 150 // desktop
15
+ const CARD_W_M = 132 // mobile — two loose columns at 390px
16
  const SPOTS: [number, number][] = [
17
  [0.1, 0.3], [0.7, 0.16], [0.42, 0.44], [0.74, 0.52], [0.12, 0.72], [0.52, 0.78], [0.3, 0.28], [0.85, 0.36],
18
  ]
19
+ // Mobile wall: alternate two columns down a tall scrolling board.
20
+ const SPOTS_M: [number, number][] = [
21
+ [0.06, 0.1], [0.62, 0.17], [0.1, 0.27], [0.6, 0.35],
22
+ [0.08, 0.45], [0.58, 0.53], [0.12, 0.63], [0.6, 0.71],
23
+ ]
24
 
25
  interface Node {
26
  id: string
 
28
  x: number
29
  y: number
30
  }
31
+ function boardNodes(c: PublicCase, compact: boolean): Node[] {
32
+ const nodes: Node[] = [{ id: 'victim', kind: 'victim', x: compact ? 0.3 : 0.44, y: compact ? 0.025 : 0.05 }]
33
+ const spots = compact ? SPOTS_M : SPOTS
34
  c.evidence.forEach((e, i) => {
35
+ const [x, y] = spots[i % spots.length]
36
  nodes.push({ id: e.id, kind: 'evidence', x, y })
37
  })
38
  return nodes
 
40
 
41
  export function Board() {
42
  const g = useGame()
 
43
  const c = g.case
44
+ const compact = g.mode === 'mobile'
45
+ const [connect, setConnect] = useState(false)
46
+ return (
47
+ <div class="app__view">
48
+ <Hud
49
+ title={compact ? 'THE BOARD' : 'INVESTIGATION BOARD'}
50
+ sub={connect ? 'CONNECT THE DOTS · TAP TWO PINS TO TIE A THREAD' : compact ? c.id : 'THE EVIDENCE WALL · DRAG TO ARRANGE'}
51
+ right={
52
+ compact ? (
53
+ <Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>
54
+ ) : (
55
+ <>
56
+ <Btn sm variant="ghost" onClick={() => g.nav('notes')}>Notes</Btn>
57
+ <Btn sm variant="ghost" onClick={() => g.nav('timeline')}>Timeline</Btn>
58
+ <Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse ▸</Btn>
59
+ </>
60
+ )
61
+ }
62
+ />
63
+ {compact ? (
64
+ <Corkboard compact connect={connect} setConnect={setConnect} />
65
+ ) : (
66
+ <div class="board-layout">
67
+ <Corkboard compact={false} connect={connect} setConnect={setConnect} />
68
+ <SuspectRail />
69
+ </div>
70
+ )}
71
+ <BottomNav />
72
+ </div>
73
+ )
74
+ }
75
+
76
+ function Corkboard({ compact, connect, setConnect }: { compact: boolean; connect: boolean; setConnect: (v: boolean) => void }) {
77
+ const g = useGame()
78
+ const c = g.case
79
+ const NODES = boardNodes(c, compact)
80
+ const cardW = compact ? CARD_W_M : CARD_W
81
  const ref = useRef<HTMLDivElement>(null)
82
+ const [size, setSize] = useState({ w: 0, h: 0 })
83
  const [pos, setPos] = useState<Record<string, { x: number; y: number }>>({})
84
  const [selEv, setSelEv] = useState<string | null>(null)
85
+ const [armed, setArmed] = useState<string | null>(null)
86
+ const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null)
87
+ const [confirmClear, setConfirmClear] = useState(false)
88
  const drag = useRef<{ id: string; dx: number; dy: number } | null>(null)
89
  const dragCleanup = useRef<(() => void) | null>(null)
90
+ const threads = useThreads(c.id, ['victim', ...c.evidence.map((e) => e.id)])
91
+
92
+ // Tall virtual wall on mobile so eight cards breathe at ~400px width.
93
+ const mobileBoardH = Math.max(900, 200 + c.evidence.length * 115)
94
 
95
+ // Mode flip: wipe layout (and the measurement that produced it) so cards re-seat
96
+ // on the new wall instead of stranding off-board.
97
+ useEffect(() => {
98
+ setPos({})
99
+ setSize({ w: 0, h: 0 })
100
+ setSelEv(null)
101
+ setArmed(null)
102
+ setGhost(null)
103
+ }, [compact])
104
  useEffect(() => {
105
  const m = () => {
106
  if (ref.current) {
 
111
  m()
112
  window.addEventListener('resize', m)
113
  return () => window.removeEventListener('resize', m)
114
+ }, [compact])
115
+ // Seat the cards once a real measurement exists (size starts at 0×0 so the default
116
+ // never leaks into the layout); manual arrangement survives later resizes.
117
  useEffect(() => {
118
  if (size.w < 40) return
119
  setPos((p) => {
120
  if (Object.keys(p).length) return p
121
  const np: Record<string, { x: number; y: number }> = {}
122
  NODES.forEach((n) => {
123
+ np[n.id] = { x: n.x * (size.w - cardW), y: n.y * (size.h - CARD_H) }
124
  })
125
  return np
126
  })
127
+ }, [size, compact])
128
  useEffect(() => () => dragCleanup.current?.(), [])
129
 
130
  const onDown = (id: string, e: MouseEvent | TouchEvent) => {
131
+ if (connect) return // cards are pinned while connecting; let the tap become a click
132
  e.preventDefault()
133
+ if (drag.current || !ref.current || !pos[id]) return
134
  const pt = 'touches' in e ? e.touches[0] : e
135
+ // Board-local coordinates: the rect tracks container scroll, so dragging works
136
+ // anywhere down the mobile wall (clientX alone assumed an unscrolled board).
137
+ const r = ref.current.getBoundingClientRect()
138
+ drag.current = { id, dx: pt.clientX - r.left - pos[id].x, dy: pt.clientY - r.top - pos[id].y }
139
  const move = (ev: MouseEvent | TouchEvent) => {
140
  const d = drag.current
141
+ if (!d || !ref.current) return
142
+ if ('touches' in ev) ev.preventDefault() // block scroll only while a drag is live
143
  window.__justDragged = true
144
+ const rr = ref.current.getBoundingClientRect()
145
  const p = 'touches' in ev ? ev.touches[0] : ev
146
+ const nx = Math.max(0, Math.min(size.w - cardW, p.clientX - rr.left - d.dx))
147
+ const ny = Math.max(0, Math.min(size.h - CARD_H, p.clientY - rr.top - d.dy))
148
  setPos((o) => ({ ...o, [d.id]: { x: nx, y: ny } }))
149
  }
150
  const up = () => {
 
164
  window.addEventListener('touchend', up)
165
  }
166
 
167
+ // Pushpin position for a card — where threads tie off.
168
+ const anchor = (id: string) => {
169
+ const p = pos[id]
170
+ return p ? { x: p.x + cardW / 2, y: p.y - 3 } : null
171
+ }
172
+
173
+ const tapCard = (id: string) => {
174
+ if (!connect) return
175
+ if (armed === null) {
176
+ setArmed(id)
177
+ return
178
+ }
179
+ if (armed === id) {
180
+ setArmed(null)
181
+ setGhost(null)
182
+ return
183
+ }
184
+ threads.toggle(armed, id)
185
+ playSfx('click')
186
+ setArmed(null)
187
+ setGhost(null)
188
+ }
189
+ const toggleConnect = () => {
190
+ setConnect(!connect)
191
+ setArmed(null)
192
+ setGhost(null)
193
+ setSelEv(null)
194
+ setConfirmClear(false)
195
+ }
196
+ const onBoardMove = (e: MouseEvent) => {
197
+ if (!connect || !armed || compact || !ref.current) return
198
+ const r = ref.current.getBoundingClientRect()
199
+ setGhost({ x: e.clientX - r.left, y: e.clientY - r.top })
200
+ }
201
+ const onBoardClick = (e: MouseEvent) => {
202
+ if (!connect || !armed) return
203
+ if ((e.target as HTMLElement).closest('.pin-card')) return // card taps handle themselves
204
+ setArmed(null)
205
+ setGhost(null)
206
+ }
207
+ const onClear = () => {
208
+ if (confirmClear) {
209
+ threads.clear()
210
+ setConfirmClear(false)
211
+ } else {
212
+ setConfirmClear(true)
213
+ setTimeout(() => setConfirmClear(false), 2500)
214
+ }
215
+ }
216
+
217
+ const armedAnchor = armed ? anchor(armed) : null
218
+ const board = (
219
+ <div
220
+ class={'board' + (compact ? ' board--mobile' : '') + (connect ? ' board--connect' : '')}
221
+ ref={ref}
222
+ style={compact ? { height: mobileBoardH } : undefined}
223
+ onMouseMove={onBoardMove}
224
+ onClick={onBoardClick}
225
+ >
226
+ <div class="board__felt" />
227
+ <BoardDecor c={c} compact={compact} />
228
+ <div class="board__frame" />
229
+ <ThreadLayer
230
+ pairs={threads.pairs}
231
+ anchor={anchor}
232
+ w={size.w}
233
+ h={size.h}
234
+ connect={connect}
235
+ ghost={connect && armedAnchor && ghost ? { from: armedAnchor, to: ghost } : null}
236
+ onRemove={(a, b) => {
237
+ threads.remove(a, b)
238
+ playSfx('click')
239
+ }}
240
  />
241
+ {NODES.map((n) => {
242
+ if (!pos[n.id]) return null
243
+ const p = pos[n.id]
244
+ const isEv = n.kind === 'evidence'
245
+ const open = selEv === n.id
246
+ const isArmed = armed === n.id
247
+ return (
248
+ <div
249
+ key={n.id}
250
+ class={'pin-card' + (open ? ' pin-card--sel' : '') + (isArmed ? ' pin-card--armed' : '')}
251
+ style={{ left: p.x, top: p.y, width: cardW, zIndex: open || isArmed ? 7 : 4 }}
252
+ >
253
+ <span
254
+ class="pin"
255
+ onMouseDown={(e) => onDown(n.id, e)}
256
+ onTouchStart={(e) => onDown(n.id, e)}
257
+ onClick={() => tapCard(n.id)}
258
+ title={connect ? 'Tie a thread' : 'Drag to move'}
259
+ />
260
+ <div
261
+ class={'card-grip' + (connect ? ' card-grip--locked' : '')}
262
+ onMouseDown={(e) => onDown(n.id, e)}
263
+ onTouchStart={(e) => onDown(n.id, e)}
264
+ onClick={() => tapCard(n.id)}
265
+ title={connect ? 'Cards are pinned while connecting' : 'Drag to move'}
266
+ >
267
+ <span class="card-grip__dots">⠿⠿⠿</span>
268
+ <span class="card-grip__lbl">{connect ? 'PINNED' : 'MOVE'}</span>
269
+ </div>
270
+ <BoardNode
271
+ node={n}
272
+ cardW={cardW}
273
+ selected={open}
274
+ onSelect={() => {
275
+ if (connect) {
276
+ tapCard(n.id)
277
+ return
278
+ }
279
+ if (!window.__justDragged && isEv) setSelEv(open ? null : n.id)
280
+ }}
281
+ />
282
+ {isEv && open && !connect && (
283
+ <div class="card-actions">
284
+ <Btn variant="amber" sm onClick={() => g.nav('evidence', { focus: n.id })}>Examine ▸</Btn>
285
  </div>
286
+ )}
287
+ </div>
288
+ )
289
+ })}
290
+ </div>
291
+ )
292
+
293
+ // Toolbar first in the DOM: a static bar above the scrolling wall on mobile,
294
+ // a floating overlay (absolute, top-left) on desktop.
295
+ return (
296
+ <div class="board-wrap">
297
+ <div class="board__filters">
298
+ <span class="chip chip--amber">EVIDENCE WALL · {c.evidence.length}</span>
299
+ {threads.pairs.length > 0 && <span class="chip chip--ox">THREADS · {threads.pairs.length}</span>}
300
+ <Btn sm variant={connect ? 'ox' : 'ghost'} onClick={toggleConnect}>
301
+ {connect ? '▣ CONNECT: ON' : '◈ CONNECT THE DOTS'}
302
+ </Btn>
303
+ {connect && threads.pairs.length > 0 && (
304
+ <Btn sm variant="ghost" onClick={onClear}>{confirmClear ? 'SURE?' : 'CLEAR'}</Btn>
305
+ )}
306
+ {connect && <span class="chip">{armed ? 'PICK A SECOND CARD' : 'TAP TWO CARDS TO LINK'}</span>}
307
  </div>
308
+ {compact ? <div class="board-scroll">{board}</div> : board}
309
  </div>
310
  )
311
  }
312
 
313
+ function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: string; rot: number; pin?: string; children: preact.ComponentChildren; w?: number | string }) {
314
  return (
315
  <div class="wall-item" style={{ left: x, top: y, transform: `rotate(${rot}deg)`, width: w }}>
316
  <span class={'pushpin pushpin--' + pin} />
 
319
  )
320
  }
321
 
322
+ function BoardDecor({ c, compact }: { c: PublicCase; compact?: boolean }) {
323
  const tod = c.facts.find(([k]) => k === 'TIME OF DEATH')?.[1] || c.tod
324
  return (
325
  <div style={{ position: 'absolute', inset: 0, zIndex: 2, pointerEvents: 'none' }}>
326
+ <WallItem x={compact ? '2%' : '0.5%'} y={compact ? '0.5%' : '3%'} rot={-3} pin="bone" w={compact ? 150 : 186}>
327
  <div class="wall-clip">
328
  <div style={{ fontFamily: 'var(--f-display)', fontSize: 9, lineHeight: 1.25, color: '#231f16' }}>{c.city} UNVEILS<br />ITS NEW CROWN</div>
329
  <div style={{ height: 2, background: '#231f1633', margin: '5px 0' }} />
 
331
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#4a4234', lineHeight: 1.2 }}>{c.victim.name} found dead the night the city came to celebrate.</div>
332
  </div>
333
  </WallItem>
334
+ {!compact && (
335
+ <WallItem x="63%" y="1%" rot={3} pin="ox" w={160}>
336
+ <div class="wall-photo">
337
+ <div style={{ background: '#0d1117', height: 96 }}><Scene name="map" w={148} h={96} full style={{ width: '100%', height: '100%' }} /></div>
338
+ <div class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.district.split('·')[0]}</div>
339
+ </div>
340
+ </WallItem>
341
+ )}
342
+ <WallItem x={compact ? '58%' : '29%'} y={compact ? '8%' : '11%'} rot={4} pin="ox" w={compact ? 110 : 120}>
343
  <div class="wall-note scrawl" style={{ fontSize: 15 }}>{tod} —<br /><b>pushed?</b></div>
344
  </WallItem>
345
+ {!compact && (
346
+ <WallItem x="2%" y="52%" rot={-5} pin="amber" w={130}>
347
+ <div class="wall-note wall-note--bone scrawl" style={{ fontSize: 15 }}>WHO HELD<br />A KEY?</div>
348
+ </WallItem>
349
+ )}
350
+ <WallItem x={compact ? '8%' : '56%'} y={compact ? '78%' : '82%'} rot={-3} pin="amber" w={120}>
351
  <div class="wall-note scrawl" style={{ fontSize: 15 }}>MOTIVE = ?</div>
352
  </WallItem>
353
+ {!compact && (
354
+ <WallItem x="29%" y="60%" rot={2} pin="bone" w={150}>
355
+ <div class="wall-redact">
356
+ <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150' }}>FORENSIC — SEALED</div>
357
+ <div class="bar-blk" style={{ width: '90%' }} />
358
+ <div class="bar-blk" style={{ width: '70%' }} />
359
+ <div class="bar-blk" style={{ width: '85%' }} />
360
+ </div>
361
+ </WallItem>
362
+ )}
363
+ {!compact && (
364
+ <WallItem x="83%" y="30%" rot={-4} pin="amber" w={150}>
365
+ <div class="wall-clip">
366
+ <div style={{ fontFamily: 'var(--f-display)', fontSize: 8, color: '#231f16', marginBottom: 5 }}>GUEST LIST</div>
367
+ {c.suspects.map((s) => (
368
+ <div key={s.id} class="t-mono" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#4a4234', display: 'flex', justifyContent: 'space-between' }}>
369
+ <span>{s.name.split(' ').slice(-1)[0]}, {s.name[0]}.</span>
370
+ <span style={{ color: '#8a3a2c' }}>✓</span>
371
+ </div>
372
+ ))}
373
+ </div>
374
+ </WallItem>
375
+ )}
376
+ <WallItem x={compact ? '52%' : '84%'} y={compact ? '82%' : '55%'} rot={3} pin="ox" w={compact ? 130 : 138}>
377
  <div class="wall-photo">
378
  <div style={{ background: '#0d1117', height: 72 }}><Scene name="mezzanine" w={126} h={72} style={{ width: '100%', height: '100%' }} /></div>
379
  <div class="t-mono" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 3 }}>SCENE — RAIL</div>
380
  </div>
381
  </WallItem>
382
+ {!compact && <div class="wall-item" style={{ left: '44%', top: '70%', width: 64, height: 64, border: '5px solid rgba(60,40,20,.35)', borderRadius: '50%', boxShadow: 'inset 0 0 8px rgba(60,40,20,.25)' }} />}
383
+ <div class="wall-item" style={{ left: compact ? '2%' : '-6%', top: compact ? '94%' : '88%', width: compact ? '80%' : '46%', transform: 'rotate(-7deg)', zIndex: 8 }}>
384
  <div style={{ background: 'var(--amber-2)', color: 'var(--ink-0)', padding: '5px 0', boxShadow: '0 3px 0 rgba(0,0,0,.4)', fontFamily: 'var(--f-display)', fontSize: 10, letterSpacing: '.3em', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden' }}>
385
  ▪ POLICE LINE ▪ DO NOT CROSS ▪ POLICE LINE ▪
386
  </div>
 
414
  </div>
415
  <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
416
  <div class="between" style={{ gap: 6 }}>
417
+ <span class="t-display nowrap" style={{ fontSize: 10, color: 'var(--bone-3)', flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.name}</span>
418
+ <span class="t-mono" style={{ flexShrink: 0, fontSize: 'calc(12px*var(--mono-scale))', color: susp >= 65 ? 'var(--ox-3)' : 'var(--bone-1)' }}>{susp}%</span>
419
  </div>
420
  <div class="t-label">{s.tag}</div>
421
  <SuspicionBar value={susp} compact label={null} />
 
437
  )
438
  }
439
 
440
+ function BoardNode({ node, cardW, selected, onSelect }: { node: Node; cardW: number; selected: boolean; onSelect: () => void }) {
441
  const g = useGame()
442
  const c = g.case
443
  if (node.kind === 'evidence') {
444
  const e = c.evidence.find((x) => x.id === node.id)!
445
  return (
446
+ <div class="pin-note" style={{ width: cardW, cursor: 'pointer', boxShadow: selected ? '0 0 0 3px var(--amber-2), 3px 4px 0 rgba(0,0,0,.4)' : undefined }} onClick={onSelect}>
447
  <div class="row" style={{ gap: 8, alignItems: 'center' }}>
448
  <div style={{ background: 'var(--ink-1)', padding: 3 }}><EvIcon icon={e.icon} px={2} /></div>
449
  <div class="col" style={{ gap: 2, minWidth: 0 }}>
 
455
  )
456
  }
457
  return (
458
+ <Panel variant="ox" style={{ padding: 7, width: cardW, cursor: 'pointer' }} onClick={onSelect}>
459
  <div class="row" style={{ gap: 8, alignItems: 'center' }}>
460
  <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 2, flexShrink: 0 }}>
461
  <Portrait id={c.victim.sprite} px={3} />
 
468
  </Panel>
469
  )
470
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/screens/cold.tsx CHANGED
@@ -1,363 +1,368 @@
1
- // Title · Story · Briefing (dossier) · Boot — the "cold" pre-investigation screens.
2
- import { useEffect, useRef, useState } from 'preact/hooks'
3
- import type { ComponentChildren } from 'preact'
4
-
5
- import { useTypewriter } from '../engine/pixel'
6
- import { useGame } from '../store'
7
- import type { PublicCase } from '../types'
8
- import { playSfx } from '../ui/audio'
9
- import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
10
-
11
- export function TitleScreen() {
12
- const g = useGame()
13
- const c = g.case
14
- const [enterId, setEnterId] = useState(false)
15
- const [idVal, setIdVal] = useState(c.id)
16
- const pull = () => {
17
- const id = idVal.trim()
18
- if (id) g.loadCase(id) // load that exact case (fresh run) and jump straight into it
19
- }
20
- return (
21
- <div class="app__view" style={{ position: 'relative' }}>
22
- <Scene name="skyline" w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
23
- <div style={{ position: 'absolute', top: 12, right: 14, zIndex: 5 }}><Controls /></div>
24
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
25
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
26
- <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
27
- <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL {c.kindLabel || 'HOMICIDE'}</div>
28
- <h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
29
- <p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
30
- Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
31
- </p>
32
- <div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>“{c.tagline}”</div>
33
- {!enterId ? (
34
- <div class="col" style={{ gap: 10, width: 'min(320px,80vw)', marginTop: 6 }}>
35
- <Btn variant="amber" onClick={() => g.newCase()}>▸ Begin New Case</Btn>
36
- <Btn variant="ghost" onClick={() => setEnterId(true)}>Enter Case ID</Btn>
37
- <Btn variant="ghost" onClick={() => g.nav('board')}>Continue · {c.id}</Btn>
38
- </div>
39
- ) : (
40
- <Panel className="col" style={{ gap: 10, width: 'min(340px,86vw)', padding: 16 }}>
41
- <span class="t-label">ENTER A CASE FILE NUMBER</span>
42
- <input
43
- class="pinput t-mono"
44
- value={idVal}
45
- onInput={(e) => setIdVal((e.target as HTMLInputElement).value)}
46
- style={{ textAlign: 'center', fontSize: 'calc(17px*var(--mono-scale))' }}
47
- />
48
- <div class="row" style={{ gap: 8 }}>
49
- <Btn variant="ghost" sm onClick={() => setEnterId(false)}>Back</Btn>
50
- <Btn variant="amber" className="grow" onClick={pull}>Pull File ▸</Btn>
51
- </div>
52
- </Panel>
53
- )}
54
- </div>
55
- <div style={{ position: 'absolute', bottom: 12, left: 0, right: 0, textAlign: 'center', zIndex: 2 }}>
56
- <span class="t-label">{c.city} · {c.weather}</span>
57
- </div>
58
- </div>
59
- )
60
- }
61
-
62
- export function StoryScreen() {
63
- const g = useGame()
64
- const beats = g.case.storyBeats
65
- const [i, setI] = useState(0)
66
- const beat = beats[i]
67
- const last = i === beats.length - 1
68
- const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true)
69
- return (
70
- <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
71
- <Scene name={beat.scene} w={320} h={200} cover deps={[i]} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
72
- <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} />
73
- <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}>
74
- <div class="between" style={{ padding: '14px 18px' }}>
75
- <span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span>
76
- <div class="row" style={{ gap: 8 }}>
77
- <HintButton />
78
- <Btn sm variant="ghost" onClick={() => g.nav('briefing')}>Skip ▸</Btn>
79
- </div>
80
- </div>
81
- <div class="grow center" style={{ padding: '12px 22px' }}>
82
- <div style={{ maxWidth: 680, width: '100%' }}>
83
- <h1 class="t-display" style={{ fontSize: 'clamp(22px,4vw,34px)', color: 'var(--bone-3)', marginBottom: 18, lineHeight: 1.1, textShadow: '3px 3px 0 var(--ink-0)' }}>{beat.title}</h1>
84
- <p class="t-body" style={{ fontSize: 'clamp(16px,2.2vw,20px)', lineHeight: 1.65, color: 'var(--bone-2)', minHeight: 130 }}>
85
- {body}
86
- {!done && <span class="cursor" />}
87
- </p>
88
- </div>
89
- </div>
90
- <div class="between" style={{ padding: '16px 22px 22px', gap: 14 }}>
91
- <div class="row" style={{ gap: 6 }}>
92
- {beats.map((_, k) => (
93
- <span key={k} onClick={() => setI(k)} style={{ width: 26, height: 6, cursor: 'pointer', background: k === i ? 'var(--amber-2)' : k < i ? 'var(--slate-2)' : 'var(--ink-3)', boxShadow: 'inset 0 0 0 1px var(--ink-0)' }} />
94
- ))}
95
- </div>
96
- <div class="row" style={{ gap: 10 }}>
97
- {i > 0 && <Btn variant="ghost" onClick={() => setI(i - 1)}>◂ Back</Btn>}
98
- {!last ? (
99
- <Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn>
100
- ) : (
101
- <Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('briefing')}>Open the Case File ▸▸</Btn>
102
- )}
103
- </div>
104
- </div>
105
- </div>
106
- </div>
107
- )
108
- }
109
-
110
- export function BootScreen() {
111
- const g = useGame()
112
- const boot = g.case.bootLines
113
- const [lines, setLines] = useState<string[]>([])
114
- const [cur, setCur] = useState('')
115
- const [stamped, setStamped] = useState(false)
116
- const [done, setDone] = useState(false)
117
- const idx = useRef(0)
118
- const ch = useRef(0)
119
-
120
- useEffect(() => {
121
- const speed = g.state.tweaks.typeSpeed || 16
122
- let timer: ReturnType<typeof setTimeout>
123
- const tick = () => {
124
- const i = idx.current
125
- if (i >= boot.length) {
126
- setStamped(true)
127
- setTimeout(() => setDone(true), 900)
128
- return
129
- }
130
- const line = boot[i]
131
- ch.current++
132
- setCur(line.slice(0, ch.current))
133
- if (ch.current >= line.length) {
134
- setLines((l) => [...l, line])
135
- setCur('')
136
- ch.current = 0
137
- idx.current++
138
- timer = setTimeout(tick, 240)
139
- } else {
140
- timer = setTimeout(tick, speed)
141
- }
142
- }
143
- timer = setTimeout(tick, 300)
144
- return () => clearTimeout(timer)
145
- }, [])
146
-
147
- return (
148
- <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
149
- <Scene name="seawall" w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} />
150
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} />
151
- <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
152
- <div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
153
- <div class="row" style={{ gap: 10, alignItems: 'center' }}>
154
- <span style={{ width: 9, height: 9, background: 'var(--ox-3)', boxShadow: '0 0 10px var(--ox-3)', animation: 'blink 1.4s steps(1) infinite' }} />
155
- <span class="t-label" style={{ letterSpacing: '.22em', color: 'var(--amber-2)' }}>{g.case.city} · {g.case.district}</span>
156
- </div>
157
- <div class="t-mono" style={{ fontSize: 'calc(17px*var(--mono-scale))', lineHeight: 1.85, color: 'var(--bone-2)', minHeight: g.mode === 'mobile' ? 220 : 200 }}>
158
- {lines.map((l, i) => (
159
- <div key={i} style={{ opacity: i === lines.length - 1 && !cur ? 1 : 0.82 }}>{l}</div>
160
- ))}
161
- {cur && <div class="bone">{cur}<span class="cursor" /></div>}
162
- {lines.length === 0 && !cur && <span class="cursor" />}
163
- </div>
164
- <div class="col" style={{ gap: 18, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
165
- <div style={{ position: 'relative', paddingTop: 16 }}>
166
- <span class="t-label" style={{ position: 'absolute', top: 0, left: 0 }}>CASE FILE No.</span>
167
- {stamped ? (
168
- <Stamp slam style={{ fontSize: 'clamp(15px,3.5vw,22px)' }}>{g.case.id}</Stamp>
169
- ) : (
170
- <span class="t-display dim" style={{ fontSize: 'clamp(15px,3.5vw,22px)', opacity: 0.25, letterSpacing: '.08em' }}>— — — — —</span>
171
- )}
172
- </div>
173
- {done && <Btn variant="amber" style={{ fontSize: 12, padding: '15px 24px' }} onClick={() => g.nav('briefing')}>Take the Case ▸</Btn>}
174
- </div>
175
- </div>
176
- </div>
177
- </div>
178
- )
179
- }
180
-
181
- // ---- dossier ----
182
- function PageHead({ tab, idx, c }: { tab: string; idx: string; c: PublicCase }) {
183
- return (
184
- <div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}>
185
- <div class="col" style={{ gap: 4 }}>
186
- <span class="dlabel">{c.city} CO. · {c.division || 'HOMICIDE DIVISION'}</span>
187
- <span class="dh" style={{ fontSize: 13 }}>{tab}</span>
188
- </div>
189
- <span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span>
190
- </div>
191
- )
192
- }
193
-
194
- function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChildren }[] {
195
- return [
196
- {
197
- tab: 'COVER',
198
- render: () => (
199
- <div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}>
200
- <span class="paperclip" />
201
- <span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span>
202
- <div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>{c.kindLabel || 'HOMICIDE'} — CASE FILE</div>
203
- <h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1>
204
- <div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div>
205
- <div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div>
206
- <div style={{ position: 'absolute', bottom: 6, right: 8 }}><span class="dstamp" style={{ fontSize: 11, borderColor: '#1a1610', color: '#1a1610', transform: 'rotate(4deg)' }}>CONFIDENTIAL</span></div>
207
- <div class="dtype" style={{ color: '#6a6150', fontSize: 'calc(14px*var(--mono-scale))', marginTop: 8 }}> flip the page </div>
208
- </div>
209
- ),
210
- },
211
- {
212
- tab: 'THE VICTIM',
213
- render: () => (
214
- <div>
215
- <PageHead tab="THE VICTIM" idx="01" c={c} />
216
- <div class="row" style={{ gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
217
- <div class="photo-paper" style={{ flexShrink: 0 }}>
218
- <div style={{ background: '#0d1117', padding: 4 }}><Portrait id={c.victim.sprite} px={6} /></div>
219
- <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.victim.name.split(' ').slice(-1)[0]}</div>
220
- </div>
221
- <div class="col" style={{ gap: 8, flex: 1, minWidth: 200 }}>
222
- <h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2>
223
- <div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div>
224
- <div class="row" style={{ gap: 10, flexWrap: 'wrap' }}>
225
- <span class="dstamp" style={{ fontSize: 11 }}>{c.victimStatus || 'DECEASED'}</span>
226
- <span class="dtype" style={{ alignSelf: 'center' }}>{c.todLabel || 'T.O.D.'} {c.tod} · AGE {c.victim.age}</span>
227
- </div>
228
- </div>
229
- </div>
230
- <hr style={{ border: 0, borderTop: '2px dashed #211d1533', margin: '16px 0' }} />
231
- <p class="dtype">{c.victim.bio}</p>
232
- </div>
233
- ),
234
- },
235
- {
236
- tab: 'THE SCENE',
237
- render: () => (
238
- <div>
239
- <PageHead tab="THE SCENE" idx="02" c={c} />
240
- <div class="photo-paper" style={{ marginBottom: 16, transform: 'rotate(-1deg)' }}>
241
- <div style={{ aspectRatio: '16/9', background: '#0d1117' }}>
242
- <Scene name={c.scene} w={288} h={162} style={{ width: '100%', height: '100%' }} />
243
- </div>
244
- <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', marginTop: 4 }}>EXHIBIT A — {c.scene}</div>
245
- </div>
246
- <p class="dtype" style={{ marginBottom: 10 }}><b style={{ color: '#8a3a2c' }}>FOUND — </b>{c.found}</p>
247
- <p class="dtype"><b style={{ color: '#8a3a2c' }}>CAUSE — </b>{c.cause}</p>
248
- </div>
249
- ),
250
- },
251
- {
252
- tab: 'THE EXHIBITS',
253
- render: () => (
254
- <div>
255
- <PageHead tab="THE EXHIBITS" idx="03" c={c} />
256
- <p class="dtype" style={{ marginBottom: 12, color: '#4a4234' }}>Photographed and logged at the scene. Examine each on the wall.</p>
257
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
258
- {c.evidence.slice(0, 6).map((e, i) => (
259
- <div key={e.id} class="photo-paper" style={{ transform: `rotate(${(i % 3) - 1}deg)` }}>
260
- <div style={{ background: '#0d1117' }}>
261
- <ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} />
262
- </div>
263
- <div class="between" style={{ marginTop: 4, gap: 6 }}>
264
- <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
265
- <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#8a3a2c', flexShrink: 0 }}>{e.time}</span>
266
- </div>
267
- </div>
268
- ))}
269
- </div>
270
- </div>
271
- ),
272
- },
273
- {
274
- tab: 'KEY FACTS',
275
- render: () => (
276
- <div style={{ position: 'relative' }}>
277
- <PageHead tab="KEY FACTS" idx="04" c={c} />
278
- <div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>{c.kindLabel || 'HOMICIDE'}</span></div>
279
- <div class="col" style={{ gap: 0 }}>
280
- {c.facts.map(([k, v], i) => (
281
- <div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}>
282
- <span class="dlabel" style={{ color: '#6a6150', width: 116, flexShrink: 0 }}>{k}</span>
283
- <span class="dtype" style={{ color: k === 'VERDICT' ? '#8a2a2a' : '#211d15' }}>{v}</span>
284
- </div>
285
- ))}
286
- </div>
287
- </div>
288
- ),
289
- },
290
- {
291
- tab: 'PERSONS OF INTEREST',
292
- render: () => (
293
- <div>
294
- <PageHead tab="PERSONS OF INTEREST" idx="05" c={c} />
295
- <p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p>
296
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
297
- {c.suspects.map((s) => (
298
- <div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}>
299
- <div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} /></div>
300
- <div class="col" style={{ gap: 2, minWidth: 0 }}>
301
- <span class="dh" style={{ fontSize: 10 }}>{s.name}</span>
302
- <span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span>
303
- </div>
304
- </div>
305
- ))}
306
- </div>
307
- <p class="dtype" style={{ marginTop: 16, color: '#8a3a2c', textAlign: 'center' }}>One of them is lying. Find the crack.</p>
308
- </div>
309
- ),
310
- },
311
- ]
312
- }
313
-
314
- export function BriefingScreen() {
315
- const g = useGame()
316
- const c = g.case
317
- const PAGES = dossierPages(c)
318
- const [page, setPage] = useState(0)
319
- const [flip, setFlip] = useState<string | null>(null)
320
- const go = (dir: number) => {
321
- const next = page + dir
322
- if (next < 0 || next >= PAGES.length) return
323
- playSfx('page')
324
- setFlip(dir > 0 ? 'fwd' : 'back')
325
- setPage(next)
326
- setTimeout(() => setFlip(null), 470)
327
- }
328
- const P = PAGES[page]
329
- return (
330
- <div class="app__view">
331
- <Hud title={c.title} sub="CASE DOSSIER" right={<Btn sm variant="ghost" onClick={() => g.nav('title')}>Menu</Btn>} />
332
- <div class="dossier-stage">
333
- <div class="dossier">
334
- <div class="dossier__folder">
335
- <div class="page-wrap">
336
- <div class={'page ' + (page % 2 ? 'page--paper2 ' : '') + (flip ? 'page--flip-' + flip : '')} key={page}>
337
- {P.render()}
338
- <div class="page__curl" />
339
- {page > 0 && <div class="page__edge page__edge--l" onClick={() => go(-1)} title="Previous page" />}
340
- {page < PAGES.length - 1 && <div class="page__edge page__edge--r" onClick={() => go(1)} title="Next page" />}
341
- </div>
342
- </div>
343
- </div>
344
- <div class="dossier__nav">
345
- <Btn sm variant="ghost" sfx={null} disabled={page === 0} onClick={() => go(-1)}>◂ Prev</Btn>
346
- <div class="page-dots">
347
- {PAGES.map((pg, i) => (
348
- <span key={i} class={'page-dot' + (i === page ? ' page-dot--on' : '')} title={pg.tab}
349
- onClick={() => { setFlip(i > page ? 'fwd' : 'back'); setPage(i); setTimeout(() => setFlip(null), 470) }} />
350
- ))}
351
- </div>
352
- {page < PAGES.length - 1 ? (
353
- <Btn sm variant="amber" sfx={null} onClick={() => go(1)}>Next page ▸</Btn>
354
- ) : (
355
- <Btn variant="amber" onClick={() => g.nav('board')}>Open the Wall ▸▸</Btn>
356
- )}
357
- </div>
358
- </div>
359
- </div>
360
- <BottomNav />
361
- </div>
362
- )
363
- }
 
 
 
 
 
 
1
+ // Title · Story · Briefing (dossier) · Boot — the "cold" pre-investigation screens.
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import type { ComponentChildren } from 'preact'
4
+
5
+ import { useTypewriter } from '../engine/pixel'
6
+ import { useGame } from '../store'
7
+ import type { PublicCase } from '../types'
8
+ import { playSfx } from '../ui/audio'
9
+ import { BottomNav, Btn, Controls, ExhibitArt, HintButton, Hud, Panel, Portrait, Scene, Stamp } from '../ui/components'
10
+
11
+ export function TitleScreen() {
12
+ const g = useGame()
13
+ const c = g.case
14
+ const [enterId, setEnterId] = useState(false)
15
+ const [idVal, setIdVal] = useState(c.id)
16
+ const pull = () => {
17
+ const id = idVal.trim()
18
+ if (id) g.loadCase(id) // load that exact case (fresh run) and jump straight into it
19
+ }
20
+ return (
21
+ <div class="app__view" style={{ position: 'relative' }}>
22
+ <Scene name="skyline" w={320} h={200} anim cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
23
+ <div style={{ position: 'absolute', top: 12, right: 14, zIndex: 5 }}><Controls /></div>
24
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 75% at 50% 42%, transparent 45%, rgba(8,11,16,.45) 95%)' }} />
25
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(34% 56% at 50% 46%, rgba(8,11,16,.82) 30%, rgba(8,11,16,.5) 60%, transparent 80%)' }} />
26
+ <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 26, padding: 24, textAlign: 'center' }}>
27
+ <div class="t-label" style={{ letterSpacing: '.34em', color: 'var(--amber-2)' }}>A PROCEDURAL DETECTIVE MYSTERY</div>
28
+ <h1 class="t-display" style={{ fontSize: 'clamp(34px,9vw,82px)', color: 'var(--bone-3)', lineHeight: 0.95, textShadow: '4px 4px 0 var(--ink-0)' }}>CASE<br />ZERO</h1>
29
+ <p class="t-body" style={{ maxWidth: 440, color: 'var(--bone-2)', fontSize: 16 }}>
30
+ Every case is generated. The city, the body, the lies. Solve one no one has ever seen.
31
+ </p>
32
+ <div class="t-mono amber" style={{ fontSize: 'calc(17px*var(--mono-scale))' }}>“{c.tagline}”</div>
33
+ {!enterId ? (
34
+ <div class="col" style={{ gap: 10, width: 'min(320px,80vw)', marginTop: 6 }}>
35
+ <Btn variant="amber" onClick={() => g.newCase()}>▸ Begin New Case</Btn>
36
+ <Btn variant="ghost" onClick={() => setEnterId(true)}>Enter Case ID</Btn>
37
+ <Btn variant="ghost" onClick={() => g.nav('board')}>Continue · {c.id}</Btn>
38
+ </div>
39
+ ) : (
40
+ <Panel className="col" style={{ gap: 10, width: 'min(340px,86vw)', padding: 16 }}>
41
+ <span class="t-label">ENTER A CASE FILE NUMBER</span>
42
+ <input
43
+ class="pinput t-mono"
44
+ value={idVal}
45
+ onInput={(e) => setIdVal((e.target as HTMLInputElement).value)}
46
+ style={{ textAlign: 'center', fontSize: 'calc(17px*var(--mono-scale))' }}
47
+ />
48
+ <div class="row" style={{ gap: 8 }}>
49
+ <Btn variant="ghost" sm onClick={() => setEnterId(false)}>Back</Btn>
50
+ <Btn variant="amber" className="grow" onClick={pull}>Pull File ▸</Btn>
51
+ </div>
52
+ </Panel>
53
+ )}
54
+ </div>
55
+ <div style={{ position: 'absolute', bottom: 12, left: 12, right: 12, display: 'flex', justifyContent: 'center', zIndex: 2 }}>
56
+ <div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', columnGap: 8, rowGap: 3, alignItems: 'baseline', textAlign: 'center', maxWidth: 480, padding: '5px 12px', background: 'rgba(8,11,16,.6)', boxShadow: '0 0 0 2px var(--ink-3), 4px 4px 0 rgba(0,0,0,.35)' }}>
57
+ <span class="t-label nowrap" style={{ color: 'var(--amber-2)', letterSpacing: '.2em' }}>TONIGHT'S WIRE</span>
58
+ <span class="t-label nowrap">{c.city}</span>
59
+ <span class="t-label dim">·</span>
60
+ <span class="t-label">{c.weather}</span>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ )
65
+ }
66
+
67
+ export function StoryScreen() {
68
+ const g = useGame()
69
+ const beats = g.case.storyBeats
70
+ const [i, setI] = useState(0)
71
+ const beat = beats[i]
72
+ const last = i === beats.length - 1
73
+ const [body, done] = useTypewriter(beat.text, (g.state.tweaks.typeSpeed || 18) + 2, true)
74
+ return (
75
+ <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
76
+ <Scene name={beat.scene} w={320} h={200} cover deps={[i]} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.5 }} />
77
+ <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(8,11,16,.7) 0%, rgba(8,11,16,.35) 38%, rgba(8,11,16,.82) 100%)' }} />
78
+ <div style={{ position: 'relative', zIndex: 2, flex: 1, display: 'flex', flexDirection: 'column' }}>
79
+ <div class="between" style={{ padding: '14px 18px' }}>
80
+ <span class="t-label" style={{ letterSpacing: '.2em', color: 'var(--amber-2)' }}>{beat.kicker}</span>
81
+ <div class="row" style={{ gap: 8 }}>
82
+ <HintButton />
83
+ <Btn sm variant="ghost" onClick={() => g.nav('briefing')}>Skip ▸</Btn>
84
+ </div>
85
+ </div>
86
+ <div class="grow center" style={{ padding: '12px 22px' }}>
87
+ <div style={{ maxWidth: 680, width: '100%' }}>
88
+ <h1 class="t-display" style={{ fontSize: 'clamp(22px,4vw,34px)', color: 'var(--bone-3)', marginBottom: 18, lineHeight: 1.1, textShadow: '3px 3px 0 var(--ink-0)' }}>{beat.title}</h1>
89
+ <p class="t-body" style={{ fontSize: 'clamp(16px,2.2vw,20px)', lineHeight: 1.65, color: 'var(--bone-2)', minHeight: 130 }}>
90
+ {body}
91
+ {!done && <span class="cursor" />}
92
+ </p>
93
+ </div>
94
+ </div>
95
+ <div class="between" style={{ padding: '16px 22px 22px', gap: 14 }}>
96
+ <div class="row" style={{ gap: 6 }}>
97
+ {beats.map((_, k) => (
98
+ <span key={k} onClick={() => setI(k)} style={{ width: 26, height: 6, cursor: 'pointer', background: k === i ? 'var(--amber-2)' : k < i ? 'var(--slate-2)' : 'var(--ink-3)', boxShadow: 'inset 0 0 0 1px var(--ink-0)' }} />
99
+ ))}
100
+ </div>
101
+ <div class="row" style={{ gap: 10 }}>
102
+ {i > 0 && <Btn variant="ghost" onClick={() => setI(i - 1)}>◂ Back</Btn>}
103
+ {!last ? (
104
+ <Btn variant="amber" onClick={() => setI(i + 1)}>Next ▸</Btn>
105
+ ) : (
106
+ <Btn variant="amber" style={{ padding: '13px 24px' }} onClick={() => g.nav('briefing')}>Open the Case File ▸▸</Btn>
107
+ )}
108
+ </div>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ export function BootScreen() {
116
+ const g = useGame()
117
+ const boot = g.case.bootLines
118
+ const [lines, setLines] = useState<string[]>([])
119
+ const [cur, setCur] = useState('')
120
+ const [stamped, setStamped] = useState(false)
121
+ const [done, setDone] = useState(false)
122
+ const idx = useRef(0)
123
+ const ch = useRef(0)
124
+
125
+ useEffect(() => {
126
+ const speed = g.state.tweaks.typeSpeed || 16
127
+ let timer: ReturnType<typeof setTimeout>
128
+ const tick = () => {
129
+ const i = idx.current
130
+ if (i >= boot.length) {
131
+ setStamped(true)
132
+ setTimeout(() => setDone(true), 900)
133
+ return
134
+ }
135
+ const line = boot[i]
136
+ ch.current++
137
+ setCur(line.slice(0, ch.current))
138
+ if (ch.current >= line.length) {
139
+ setLines((l) => [...l, line])
140
+ setCur('')
141
+ ch.current = 0
142
+ idx.current++
143
+ timer = setTimeout(tick, 240)
144
+ } else {
145
+ timer = setTimeout(tick, speed)
146
+ }
147
+ }
148
+ timer = setTimeout(tick, 300)
149
+ return () => clearTimeout(timer)
150
+ }, [])
151
+
152
+ return (
153
+ <div class="app__view" style={{ position: 'relative', background: 'var(--ink-0)' }}>
154
+ <Scene name="seawall" w={320} h={200} cover style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.28 }} />
155
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(90% 80% at 30% 40%, transparent 40%, rgba(8,11,16,.7) 100%)' }} />
156
+ <div class="screen-center" style={{ position: 'relative', zIndex: 2 }}>
157
+ <div class="maxw" style={{ maxWidth: 760, display: 'flex', flexDirection: 'column', gap: 22, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
158
+ <div class="row" style={{ gap: 10, alignItems: 'center' }}>
159
+ <span style={{ width: 9, height: 9, background: 'var(--ox-3)', boxShadow: '0 0 10px var(--ox-3)', animation: 'blink 1.4s steps(1) infinite' }} />
160
+ <span class="t-label" style={{ letterSpacing: '.22em', color: 'var(--amber-2)' }}>{g.case.city} · {g.case.district}</span>
161
+ </div>
162
+ <div class="t-mono" style={{ fontSize: 'calc(17px*var(--mono-scale))', lineHeight: 1.85, color: 'var(--bone-2)', minHeight: g.mode === 'mobile' ? 220 : 200 }}>
163
+ {lines.map((l, i) => (
164
+ <div key={i} style={{ opacity: i === lines.length - 1 && !cur ? 1 : 0.82 }}>{l}</div>
165
+ ))}
166
+ {cur && <div class="bone">{cur}<span class="cursor" /></div>}
167
+ {lines.length === 0 && !cur && <span class="cursor" />}
168
+ </div>
169
+ <div class="col" style={{ gap: 18, alignItems: g.mode === 'mobile' ? 'stretch' : 'flex-start' }}>
170
+ <div style={{ position: 'relative', paddingTop: 16 }}>
171
+ <span class="t-label" style={{ position: 'absolute', top: 0, left: 0 }}>CASE FILE No.</span>
172
+ {stamped ? (
173
+ <Stamp slam style={{ fontSize: 'clamp(15px,3.5vw,22px)' }}>{g.case.id}</Stamp>
174
+ ) : (
175
+ <span class="t-display dim" style={{ fontSize: 'clamp(15px,3.5vw,22px)', opacity: 0.25, letterSpacing: '.08em' }}>— — — — —</span>
176
+ )}
177
+ </div>
178
+ {done && <Btn variant="amber" style={{ fontSize: 12, padding: '15px 24px' }} onClick={() => g.nav('briefing')}>Take the Case ▸</Btn>}
179
+ </div>
180
+ </div>
181
+ </div>
182
+ </div>
183
+ )
184
+ }
185
+
186
+ // ---- dossier ----
187
+ function PageHead({ tab, idx, c }: { tab: string; idx: string; c: PublicCase }) {
188
+ return (
189
+ <div class="between" style={{ borderBottom: '2px solid #211d1533', paddingBottom: 10, marginBottom: 16 }}>
190
+ <div class="col" style={{ gap: 4 }}>
191
+ <span class="dlabel">{c.city} CO. · {c.division || 'HOMICIDE DIVISION'}</span>
192
+ <span class="dh" style={{ fontSize: 13 }}>{tab}</span>
193
+ </div>
194
+ <span class="dtype" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: '#6a6150' }}>FILE {c.id} · pg.{idx}</span>
195
+ </div>
196
+ )
197
+ }
198
+
199
+ function dossierPages(c: PublicCase): { tab: string; render: () => ComponentChildren }[] {
200
+ return [
201
+ {
202
+ tab: 'COVER',
203
+ render: () => (
204
+ <div class="center" style={{ flexDirection: 'column', height: '100%', gap: 18, textAlign: 'center', position: 'relative' }}>
205
+ <span class="paperclip" />
206
+ <span class="dlabel" style={{ fontSize: 9, letterSpacing: '.3em' }}>{c.district}</span>
207
+ <div class="dlabel" style={{ color: '#6a6150', letterSpacing: '.2em' }}>{c.kindLabel || 'HOMICIDE'} CASE FILE</div>
208
+ <h1 class="dh" style={{ fontSize: 'clamp(26px,5vw,44px)', lineHeight: 1.05 }}>{c.title}</h1>
209
+ <div style={{ margin: '6px 0' }}><span class="dstamp" style={{ fontSize: 'clamp(14px,3vw,20px)' }}>{c.id}</span></div>
210
+ <div class="dtype" style={{ color: '#4a4234' }}>{c.district}</div>
211
+ <div style={{ position: 'absolute', bottom: 6, right: 8 }}><span class="dstamp" style={{ fontSize: 11, borderColor: '#1a1610', color: '#1a1610', transform: 'rotate(4deg)' }}>CONFIDENTIAL</span></div>
212
+ <div class="dtype" style={{ color: '#6a6150', fontSize: 'calc(14px*var(--mono-scale))', marginTop: 8 }}>▸ flip the page →</div>
213
+ </div>
214
+ ),
215
+ },
216
+ {
217
+ tab: 'THE VICTIM',
218
+ render: () => (
219
+ <div>
220
+ <PageHead tab="THE VICTIM" idx="01" c={c} />
221
+ <div class="row" style={{ gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
222
+ <div class="photo-paper" style={{ flexShrink: 0 }}>
223
+ <div style={{ background: '#0d1117', padding: 4 }}><Portrait id={c.victim.sprite} px={6} /></div>
224
+ <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', textAlign: 'center', marginTop: 4 }}>{c.victim.name.split(' ').slice(-1)[0]}</div>
225
+ </div>
226
+ <div class="col" style={{ gap: 8, flex: 1, minWidth: 200 }}>
227
+ <h2 class="dh" style={{ fontSize: 20 }}>{c.victim.name}</h2>
228
+ <div class="dtype" style={{ color: '#4a4234' }}>{c.victim.role}</div>
229
+ <div class="row" style={{ gap: 10, flexWrap: 'wrap' }}>
230
+ <span class="dstamp" style={{ fontSize: 11 }}>{c.victimStatus || 'DECEASED'}</span>
231
+ <span class="dtype" style={{ alignSelf: 'center' }}>{c.todLabel || 'T.O.D.'} {c.tod} · AGE {c.victim.age}</span>
232
+ </div>
233
+ </div>
234
+ </div>
235
+ <hr style={{ border: 0, borderTop: '2px dashed #211d1533', margin: '16px 0' }} />
236
+ <p class="dtype">{c.victim.bio}</p>
237
+ </div>
238
+ ),
239
+ },
240
+ {
241
+ tab: 'THE SCENE',
242
+ render: () => (
243
+ <div>
244
+ <PageHead tab="THE SCENE" idx="02" c={c} />
245
+ <div class="photo-paper" style={{ marginBottom: 16, transform: 'rotate(-1deg)' }}>
246
+ <div style={{ aspectRatio: '16/9', background: '#0d1117' }}>
247
+ <Scene name={c.scene} w={288} h={162} style={{ width: '100%', height: '100%' }} />
248
+ </div>
249
+ <div class="dtype" style={{ fontSize: 'calc(12px*var(--mono-scale))', color: '#6a6150', marginTop: 4 }}>EXHIBIT A — {c.scene}</div>
250
+ </div>
251
+ <p class="dtype" style={{ marginBottom: 10 }}><b style={{ color: '#8a3a2c' }}>FOUND — </b>{c.found}</p>
252
+ <p class="dtype"><b style={{ color: '#8a3a2c' }}>CAUSE — </b>{c.cause}</p>
253
+ </div>
254
+ ),
255
+ },
256
+ {
257
+ tab: 'THE EXHIBITS',
258
+ render: () => (
259
+ <div>
260
+ <PageHead tab="THE EXHIBITS" idx="03" c={c} />
261
+ <p class="dtype" style={{ marginBottom: 12, color: '#4a4234' }}>Photographed and logged at the scene. Examine each on the wall.</p>
262
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
263
+ {c.evidence.slice(0, 6).map((e, i) => (
264
+ <div key={e.id} class="photo-paper" style={{ transform: `rotate(${(i % 3) - 1}deg)` }}>
265
+ <div style={{ background: '#0d1117' }}>
266
+ <ExhibitArt e={e} style={{ width: '100%', height: 'auto' }} />
267
+ </div>
268
+ <div class="between" style={{ marginTop: 4, gap: 6 }}>
269
+ <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#6a6150', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{e.name}</span>
270
+ <span class="dtype" style={{ fontSize: 'calc(11px*var(--mono-scale))', color: '#8a3a2c', flexShrink: 0 }}>{e.time}</span>
271
+ </div>
272
+ </div>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ ),
277
+ },
278
+ {
279
+ tab: 'KEY FACTS',
280
+ render: () => (
281
+ <div style={{ position: 'relative' }}>
282
+ <PageHead tab="KEY FACTS" idx="04" c={c} />
283
+ <div style={{ position: 'absolute', top: 60, right: 0 }}><span class="dstamp" style={{ fontSize: 13 }}>{c.kindLabel || 'HOMICIDE'}</span></div>
284
+ <div class="col" style={{ gap: 0 }}>
285
+ {c.facts.map(([k, v], i) => (
286
+ <div key={i} class="row" style={{ gap: 14, padding: '11px 0', borderBottom: '1px dotted #211d1540', alignItems: 'baseline' }}>
287
+ <span class="dlabel" style={{ color: '#6a6150', width: 116, flexShrink: 0 }}>{k}</span>
288
+ <span class="dtype" style={{ color: k === 'VERDICT' ? '#8a2a2a' : '#211d15' }}>{v}</span>
289
+ </div>
290
+ ))}
291
+ </div>
292
+ </div>
293
+ ),
294
+ },
295
+ {
296
+ tab: 'PERSONS OF INTEREST',
297
+ render: () => (
298
+ <div>
299
+ <PageHead tab="PERSONS OF INTEREST" idx="05" c={c} />
300
+ <p class="dtype" style={{ marginBottom: 14, color: '#4a4234' }}>Those who stayed when the others fled the sirens. Each had reason to be near.</p>
301
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
302
+ {c.suspects.map((s) => (
303
+ <div key={s.id} class="row" style={{ gap: 10, alignItems: 'center', background: '#00000010', padding: 8, boxShadow: 'inset 0 0 0 1px #211d1522' }}>
304
+ <div style={{ background: '#0d1117', padding: 3, flexShrink: 0 }}><Portrait id={s.sprite} px={3} gender={s.gender} /></div>
305
+ <div class="col" style={{ gap: 2, minWidth: 0 }}>
306
+ <span class="dh" style={{ fontSize: 10 }}>{s.name}</span>
307
+ <span class="dlabel" style={{ color: '#8a3a2c' }}>{s.tag}</span>
308
+ </div>
309
+ </div>
310
+ ))}
311
+ </div>
312
+ <p class="dtype" style={{ marginTop: 16, color: '#8a3a2c', textAlign: 'center' }}>One of them is lying. Find the crack.</p>
313
+ </div>
314
+ ),
315
+ },
316
+ ]
317
+ }
318
+
319
+ export function BriefingScreen() {
320
+ const g = useGame()
321
+ const c = g.case
322
+ const PAGES = dossierPages(c)
323
+ const [page, setPage] = useState(0)
324
+ const [flip, setFlip] = useState<string | null>(null)
325
+ const go = (dir: number) => {
326
+ const next = page + dir
327
+ if (next < 0 || next >= PAGES.length) return
328
+ playSfx('page')
329
+ setFlip(dir > 0 ? 'fwd' : 'back')
330
+ setPage(next)
331
+ setTimeout(() => setFlip(null), 470)
332
+ }
333
+ const P = PAGES[page]
334
+ return (
335
+ <div class="app__view">
336
+ <Hud title={c.title} sub="CASE DOSSIER" />
337
+ <div class="dossier-stage">
338
+ <div class="dossier">
339
+ <div class="dossier__folder">
340
+ <div class="page-wrap">
341
+ <div class={'page ' + (page % 2 ? 'page--paper2 ' : '') + (flip ? 'page--flip-' + flip : '')} key={page}>
342
+ {P.render()}
343
+ <div class="page__curl" />
344
+ {page > 0 && <div class="page__edge page__edge--l" onClick={() => go(-1)} title="Previous page" />}
345
+ {page < PAGES.length - 1 && <div class="page__edge page__edge--r" onClick={() => go(1)} title="Next page" />}
346
+ </div>
347
+ </div>
348
+ </div>
349
+ <div class="dossier__nav">
350
+ <Btn sm variant="ghost" sfx={null} disabled={page === 0} onClick={() => go(-1)}>◂ Prev</Btn>
351
+ <div class="page-dots">
352
+ {PAGES.map((pg, i) => (
353
+ <span key={i} class={'page-dot' + (i === page ? ' page-dot--on' : '')} title={pg.tab}
354
+ onClick={() => { setFlip(i > page ? 'fwd' : 'back'); setPage(i); setTimeout(() => setFlip(null), 470) }} />
355
+ ))}
356
+ </div>
357
+ {page < PAGES.length - 1 ? (
358
+ <Btn sm variant="amber" sfx={null} onClick={() => go(1)}>Next page ▸</Btn>
359
+ ) : (
360
+ <Btn variant="amber" onClick={() => g.nav('board')}>Open the Wall ▸▸</Btn>
361
+ )}
362
+ </div>
363
+ </div>
364
+ </div>
365
+ <BottomNav />
366
+ </div>
367
+ )
368
+ }
web/src/screens/index.ts CHANGED
@@ -1,25 +1,27 @@
1
- import type { FunctionComponent } from 'preact'
2
-
3
- import type { Screen } from '../store'
4
- import { Board } from './board'
5
- import { BootScreen, BriefingScreen, StoryScreen, TitleScreen } from './cold'
6
- import { FlashbackScreen, NotesScreen, TimelineScreen } from './deduce'
7
- import { AccusationScreen, ShareScreen, VerdictScreen } from './endgame'
8
- import { EvidenceScreen } from './evidence'
9
- import { Interrogation } from './interro'
10
-
11
- export const SCREENS: Record<Screen, FunctionComponent> = {
12
- title: TitleScreen,
13
- story: StoryScreen,
14
- briefing: BriefingScreen,
15
- boot: BootScreen,
16
- board: Board,
17
- interro: Interrogation,
18
- evidence: EvidenceScreen,
19
- flashback: FlashbackScreen,
20
- timeline: TimelineScreen,
21
- notes: NotesScreen,
22
- accuse: AccusationScreen,
23
- verdict: VerdictScreen,
24
- share: ShareScreen,
25
- }
 
 
 
1
+ import type { FunctionComponent } from 'preact'
2
+
3
+ import type { Screen } from '../store'
4
+ import { Board } from './board'
5
+ import { BootScreen, BriefingScreen, StoryScreen, TitleScreen } from './cold'
6
+ import { FlashbackScreen, NotesScreen, TimelineScreen } from './deduce'
7
+ import { AccusationScreen, ShareScreen, VerdictScreen } from './endgame'
8
+ import { EvidenceScreen } from './evidence'
9
+ import { Interrogation } from './interro'
10
+ import { SuspectsScreen } from './suspects'
11
+
12
+ export const SCREENS: Record<Screen, FunctionComponent> = {
13
+ title: TitleScreen,
14
+ story: StoryScreen,
15
+ briefing: BriefingScreen,
16
+ boot: BootScreen,
17
+ board: Board,
18
+ suspects: SuspectsScreen,
19
+ interro: Interrogation,
20
+ evidence: EvidenceScreen,
21
+ flashback: FlashbackScreen,
22
+ timeline: TimelineScreen,
23
+ notes: NotesScreen,
24
+ accuse: AccusationScreen,
25
+ verdict: VerdictScreen,
26
+ share: ShareScreen,
27
+ }
web/src/screens/interro.tsx CHANGED
@@ -1,194 +1,194 @@
1
- // Interrogation room — questions, free-text, and present-evidence all go to the server,
2
- // which returns the suspect's reply + the authoritative suspicion. The client only displays.
3
- import { useEffect, useRef, useState } from 'preact/hooks'
4
-
5
- import { interrogate } from '../api'
6
- import { useGame } from '../store'
7
- import type { Evidence, SuggestedQuestion } from '../types'
8
- import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
9
- import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
10
-
11
- interface Pending {
12
- text: string
13
- tag?: string | null
14
- suspicion: number
15
- speed?: number // typewriter ms/char, paced to the voice when one is available
16
- }
17
-
18
- export function Interrogation() {
19
- const g = useGame()
20
- const c = g.case
21
- const sid = (g.state.payload.suspect as string) || c.suspects[0].id
22
- const s = c.suspects.find((x) => x.id === sid)!
23
- const transcript = g.state.interrogations[sid] || []
24
- const susp = g.state.suspicion[sid]
25
- const usedQ = g.state.usedQ[sid] || []
26
- const usedEv = g.state.usedEv[sid] || []
27
- const [pending, setPending] = useState<Pending | null>(null)
28
- const [thinking, setThinking] = useState(false)
29
- const [talking, setTalking] = useState(false)
30
- const [tray, setTray] = useState(false)
31
- const [input, setInput] = useState('')
32
- const scroller = useRef<HTMLDivElement>(null)
33
- const busy = thinking || !!pending
34
-
35
- useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room
36
-
37
- useEffect(() => {
38
- if (!transcript.length) setPending({ text: s.greet, suspicion: susp })
39
- // eslint-disable-next-line react-hooks/exhaustive-deps
40
- }, [sid])
41
- useEffect(() => {
42
- if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
43
- }, [transcript, pending, thinking])
44
-
45
- const commit = () => {
46
- if (!pending) return
47
- g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } })
48
- g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion })
49
- setPending(null)
50
- }
51
-
52
- const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => {
53
- if (busy) return
54
- stopSpeak()
55
- setTalking(false)
56
- g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } })
57
- setThinking(true)
58
- const fallback = g.state.tweaks.typeSpeed || 22
59
- try {
60
- const r = await interrogate(g.runId, sid, body)
61
- const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
62
- // Synthesize the voice during the "thinking" beat so text + speech start together.
63
- const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
64
- // Pace the typewriter to the audio so words land in step with the spoken line.
65
- const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback
66
- setThinking(false)
67
- setPending({ text: r.reply, suspicion: r.suspicion, tag, speed })
68
- if (voice) {
69
- setTalking(true)
70
- voice.play()
71
- }
72
- } catch {
73
- setThinking(false)
74
- setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback })
75
- }
76
- }
77
-
78
- const ask = (q: SuggestedQuestion) => {
79
- if (busy) return
80
- playSfx('select')
81
- g.dispatch({ type: 'USEQ', sid, qid: q.id })
82
- run({ questionId: q.id }, { text: q.q })
83
- }
84
- const present = (e: Evidence) => {
85
- setTray(false)
86
- if (busy) return
87
- playSfx('present')
88
- g.dispatch({ type: 'USEEV', sid, ev: e.id })
89
- run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon })
90
- }
91
- const sendFree = () => {
92
- const txt = input.trim()
93
- if (!txt || busy) return
94
- setInput('')
95
- run({ freeText: txt }, { text: txt })
96
- }
97
-
98
- const px = g.mode === 'mobile' ? 8 : 13
99
- return (
100
- <div class="app__view">
101
- <Hud
102
- title={s.name}
103
- sub={s.role}
104
- right={
105
- <>
106
- <div style={{ width: g.mode === 'mobile' ? 110 : 180 }}><SuspicionBar value={susp} /></div>
107
- <Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
108
- </>
109
- }
110
- />
111
- <div class="interro" style={{ position: 'relative' }}>
112
- <div class="interro__stage">
113
- <Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
114
- <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
115
- <div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={sid} px={px} gender={s.gender} talking={talking} /></div>
116
- <div style={{ position: 'absolute', top: 12, left: 12, zIndex: 4 }}>
117
- <Panel style={{ padding: 8 }}>
118
- <span class="t-label">{s.tag}</span>
119
- <div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div>
120
- </Panel>
121
- </div>
122
- </div>
123
-
124
- <div class="interro__right">
125
- <div class="transcript" ref={scroller}>
126
- {transcript.map((l, i) => (
127
- <div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}>
128
- <div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div>
129
- <div class="tline__b">
130
- {l.ev && (
131
- <span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}>
132
- <EvIcon icon={l.ev} px={2} />
133
- </span>
134
- )}
135
- {l.text}
136
- </div>
137
- </div>
138
- ))}
139
- {thinking && (
140
- <div class="tline tline--sus">
141
- <div class="tline__who">{s.name}</div>
142
- <div class="tline__b"><span class="cursor" /></div>
143
- </div>
144
- )}
145
- {pending && (
146
- <div class="tline tline--sus">
147
- <div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div>
148
- <div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div>
149
- </div>
150
- )}
151
- </div>
152
-
153
- <div class="composer">
154
- <div class="qsuggest">
155
- {s.suggestedQuestions.map((q) => (
156
- <button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}>
157
- {usedQ.includes(q.id) ? '✓ ' : '? '}
158
- {q.q}
159
- </button>
160
- ))}
161
- </div>
162
- <div class="composer__input">
163
- <input
164
- class="pinput"
165
- placeholder="Type your question…"
166
- value={input}
167
- onInput={(e) => setInput((e.target as HTMLInputElement).value)}
168
- onKeyDown={(e) => e.key === 'Enter' && sendFree()}
169
- />
170
- <Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn>
171
- <Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn>
172
- </div>
173
- </div>
174
- </div>
175
-
176
- {tray && (
177
- <div class="tray" onClick={() => setTray(false)}>
178
- <div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
179
- <div class="between" style={{ marginBottom: 12 }}>
180
- <span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span>
181
- <Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn>
182
- </div>
183
- <div class="tray__grid">
184
- {c.evidence.map((e) => (
185
- <EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} />
186
- ))}
187
- </div>
188
- </div>
189
- </div>
190
- )}
191
- </div>
192
- </div>
193
- )
194
- }
 
1
+ // Interrogation room — questions, free-text, and present-evidence all go to the server,
2
+ // which returns the suspect's reply + the authoritative suspicion. The client only displays.
3
+ import { useEffect, useRef, useState } from 'preact/hooks'
4
+
5
+ import { interrogate } from '../api'
6
+ import { useGame } from '../store'
7
+ import type { Evidence, SuggestedQuestion } from '../types'
8
+ import { playSfx, prepareSpeak, stopSpeak } from '../ui/audio'
9
+ import { Btn, EvIcon, EvidenceCard, Hud, Panel, Portrait, Scene, SuspicionBar, TypeOnce } from '../ui/components'
10
+
11
+ interface Pending {
12
+ text: string
13
+ tag?: string | null
14
+ suspicion: number
15
+ speed?: number // typewriter ms/char, paced to the voice when one is available
16
+ }
17
+
18
+ export function Interrogation() {
19
+ const g = useGame()
20
+ const c = g.case
21
+ const sid = (g.state.payload.suspect as string) || c.suspects[0].id
22
+ const s = c.suspects.find((x) => x.id === sid)!
23
+ const transcript = g.state.interrogations[sid] || []
24
+ const susp = g.state.suspicion[sid]
25
+ const usedQ = g.state.usedQ[sid] || []
26
+ const usedEv = g.state.usedEv[sid] || []
27
+ const [pending, setPending] = useState<Pending | null>(null)
28
+ const [thinking, setThinking] = useState(false)
29
+ const [talking, setTalking] = useState(false)
30
+ const [tray, setTray] = useState(false)
31
+ const [input, setInput] = useState('')
32
+ const scroller = useRef<HTMLDivElement>(null)
33
+ const busy = thinking || !!pending
34
+
35
+ useEffect(() => () => stopSpeak(), []) // stop any voice when leaving the room
36
+
37
+ useEffect(() => {
38
+ if (!transcript.length) setPending({ text: s.greet, suspicion: susp })
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps
40
+ }, [sid])
41
+ useEffect(() => {
42
+ if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
43
+ }, [transcript, pending, thinking])
44
+
45
+ const commit = () => {
46
+ if (!pending) return
47
+ g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'sus', text: pending.text } })
48
+ g.dispatch({ type: 'SUSP_SET', sid, value: pending.suspicion })
49
+ setPending(null)
50
+ }
51
+
52
+ const run = async (body: Parameters<typeof interrogate>[2], detLine: { text: string; ev?: string }) => {
53
+ if (busy) return
54
+ stopSpeak()
55
+ setTalking(false)
56
+ g.dispatch({ type: 'ADD_LINE', sid, line: { role: 'det', text: detLine.text, ev: detLine.ev } })
57
+ setThinking(true)
58
+ const fallback = g.state.tweaks.typeSpeed || 22
59
+ try {
60
+ const r = await interrogate(g.runId, sid, body)
61
+ const tag = r.flags?.rattled ? 'RATTLED' : r.flags?.cornered ? 'CRACKING' : null
62
+ // Synthesize the voice during the "thinking" beat so text + speech start together.
63
+ const voice = await prepareSpeak(g.runId, sid, r.reply, () => setTalking(false))
64
+ // Pace the typewriter to the audio so words land in step with the spoken line.
65
+ const speed = voice ? Math.max(14, Math.min(90, Math.round(voice.durationMs / Math.max(1, r.reply.length)))) : fallback
66
+ setThinking(false)
67
+ setPending({ text: r.reply, suspicion: r.suspicion, tag, speed })
68
+ if (voice) {
69
+ setTalking(true)
70
+ voice.play()
71
+ }
72
+ } catch {
73
+ setThinking(false)
74
+ setPending({ text: '…I have nothing more to say to that.', suspicion: susp, speed: fallback })
75
+ }
76
+ }
77
+
78
+ const ask = (q: SuggestedQuestion) => {
79
+ if (busy) return
80
+ playSfx('select')
81
+ g.dispatch({ type: 'USEQ', sid, qid: q.id })
82
+ run({ questionId: q.id }, { text: q.q })
83
+ }
84
+ const present = (e: Evidence) => {
85
+ setTray(false)
86
+ if (busy) return
87
+ playSfx('present')
88
+ g.dispatch({ type: 'USEEV', sid, ev: e.id })
89
+ run({ presentEvidenceId: e.id }, { text: `Look at this. ${e.name}.`, ev: e.icon })
90
+ }
91
+ const sendFree = () => {
92
+ const txt = input.trim()
93
+ if (!txt || busy) return
94
+ setInput('')
95
+ run({ freeText: txt }, { text: txt })
96
+ }
97
+
98
+ const px = g.mode === 'mobile' ? 8 : 13
99
+ return (
100
+ <div class="app__view">
101
+ <Hud
102
+ title={s.name}
103
+ sub={s.role}
104
+ right={
105
+ <>
106
+ <div style={{ width: g.mode === 'mobile' ? 96 : 180 }}><SuspicionBar value={susp} /></div>
107
+ <Btn sm variant="ghost" onClick={() => g.nav('board')}>Board</Btn>
108
+ </>
109
+ }
110
+ />
111
+ <div class="interro" style={{ position: 'relative' }}>
112
+ <div class="interro__stage">
113
+ <Scene name="interro" w={220} h={380} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }} />
114
+ <div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(55% 45% at 50% 58%, rgba(224,164,76,.12), transparent 72%)' }} />
115
+ <div class="interro__sprite" style={{ bottom: '7%' }}><Portrait id={sid} px={px} gender={s.gender} talking={talking} /></div>
116
+ <div style={{ position: 'absolute', top: 12, left: 12, right: 12, zIndex: 4 }}>
117
+ <Panel style={{ padding: 8, width: 'fit-content', maxWidth: '100%' }}>
118
+ <span class="t-label">{s.tag}</span>
119
+ <div class="t-mono dim" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>ALIBI · {s.alibi}</div>
120
+ </Panel>
121
+ </div>
122
+ </div>
123
+
124
+ <div class="interro__right">
125
+ <div class="transcript" ref={scroller}>
126
+ {transcript.map((l, i) => (
127
+ <div key={i} class={'tline tline--' + (l.role === 'det' ? 'det' : 'sus')}>
128
+ <div class="tline__who">{l.role === 'det' ? 'YOU' : s.name}</div>
129
+ <div class="tline__b">
130
+ {l.ev && (
131
+ <span style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6, background: 'var(--ink-1)', padding: 2 }}>
132
+ <EvIcon icon={l.ev} px={2} />
133
+ </span>
134
+ )}
135
+ {l.text}
136
+ </div>
137
+ </div>
138
+ ))}
139
+ {thinking && (
140
+ <div class="tline tline--sus">
141
+ <div class="tline__who">{s.name}</div>
142
+ <div class="tline__b"><span class="cursor" /></div>
143
+ </div>
144
+ )}
145
+ {pending && (
146
+ <div class="tline tline--sus">
147
+ <div class="tline__who">{s.name}{pending.tag && <span class="ox"> · {pending.tag}</span>}</div>
148
+ <div class="tline__b"><TypeOnce text={pending.text} speed={pending.speed ?? (g.state.tweaks.typeSpeed || 22)} onDone={commit} /></div>
149
+ </div>
150
+ )}
151
+ </div>
152
+
153
+ <div class="composer">
154
+ <div class="qsuggest">
155
+ {s.suggestedQuestions.map((q) => (
156
+ <button key={q.id} class="qchip" disabled={usedQ.includes(q.id) || busy} onClick={() => ask(q)}>
157
+ {usedQ.includes(q.id) ? '✓ ' : '? '}
158
+ {q.q}
159
+ </button>
160
+ ))}
161
+ </div>
162
+ <div class="composer__input">
163
+ <input
164
+ class="pinput"
165
+ placeholder="Type your question…"
166
+ value={input}
167
+ onInput={(e) => setInput((e.target as HTMLInputElement).value)}
168
+ onKeyDown={(e) => e.key === 'Enter' && sendFree()}
169
+ />
170
+ <Btn variant="amber" sm onClick={sendFree} disabled={busy}>Ask</Btn>
171
+ <Btn variant="ox" sm onClick={() => setTray(true)} disabled={busy}>Evidence ▴</Btn>
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ {tray && (
177
+ <div class="tray" onClick={() => setTray(false)}>
178
+ <div class="tray__sheet" onClick={(e) => e.stopPropagation()}>
179
+ <div class="between" style={{ marginBottom: 12 }}>
180
+ <span class="t-display amber" style={{ fontSize: 12 }}>PRESENT EVIDENCE</span>
181
+ <Btn sm variant="ghost" onClick={() => setTray(false)}>✕ Close</Btn>
182
+ </div>
183
+ <div class="tray__grid">
184
+ {c.evidence.map((e) => (
185
+ <EvidenceCard key={e.id} e={e} active={usedEv.includes(e.id)} onClick={() => present(e)} />
186
+ ))}
187
+ </div>
188
+ </div>
189
+ </div>
190
+ )}
191
+ </div>
192
+ </div>
193
+ )
194
+ }
web/src/screens/suspects.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Persons of interest — the mobile suspects list (own bottom-nav tab). Desktop shows
2
+ // the suspect rail on the board instead, so this screen falls through to the Board there.
3
+ import { useState } from 'preact/hooks'
4
+
5
+ import { useGame } from '../store'
6
+ import type { Suspect } from '../types'
7
+ import { BottomNav, Btn, Hud, Panel, SuspectCard } from '../ui/components'
8
+ import { Board } from './board'
9
+
10
+ export function SuspectsScreen() {
11
+ const g = useGame()
12
+ const [sel, setSel] = useState<string | null>(null) // before the early return: hook order survives a live resize
13
+ if (g.mode === 'desktop') return <Board />
14
+ const c = g.case
15
+ return (
16
+ <div class="app__view">
17
+ <Hud
18
+ title="PERSONS OF INTEREST"
19
+ sub={`${c.suspects.length} SUSPECTS · TAP, THEN INTERROGATE`}
20
+ right={<Btn sm variant="ghost" onClick={() => g.nav('accuse')}>Accuse</Btn>}
21
+ />
22
+ <div class="screen-pad">
23
+ <div class="col" style={{ gap: 10 }}>
24
+ {c.suspects.map((s: Suspect) => {
25
+ const open = sel === s.id
26
+ return (
27
+ <div key={s.id} onClick={() => setSel(open ? null : s.id)}>
28
+ <SuspectCard s={s} active={open} />
29
+ {open && (
30
+ <div style={{ marginTop: -2 }}>
31
+ <Panel variant="amber" style={{ padding: 10, marginTop: 4 }}>
32
+ <div class="t-body" style={{ fontSize: 13, lineHeight: 1.4, marginBottom: 8 }}>{s.alibi}</div>
33
+ <Btn variant="ox" className="grow" style={{ width: '100%' }} onClick={(e) => { e.stopPropagation(); g.nav('interro', { suspect: s.id }) }}>
34
+ ▸ Interrogate {s.name.split(' ')[0]}
35
+ </Btn>
36
+ </Panel>
37
+ </div>
38
+ )}
39
+ </div>
40
+ )
41
+ })}
42
+ </div>
43
+ </div>
44
+ <BottomNav />
45
+ </div>
46
+ )
47
+ }
web/src/store.tsx CHANGED
@@ -1,206 +1,191 @@
1
- // Game store: reducer, context, responsive mode, and localStorage-backed tweaks.
2
- // Mirrors the prototype's app.jsx store, but the case comes from the server and
3
- // suspicion/verdict are server-authoritative (the client only displays them).
4
- import { createContext } from 'preact'
5
- import type { ComponentChildren } from 'preact'
6
- import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks'
7
-
8
- import type { PublicCase } from './types'
9
-
10
- export type Screen =
11
- | 'title' | 'story' | 'briefing' | 'board' | 'interro' | 'evidence'
12
- | 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot'
13
-
14
- export interface Line {
15
- role: 'det' | 'sus'
16
- text: string
17
- ev?: string
18
- }
19
-
20
- export interface AccuseState {
21
- suspect: string | null
22
- motive: string | null
23
- evidence: string[]
24
- }
25
-
26
- export interface GameState {
27
- screen: Screen
28
- payload: Record<string, unknown>
29
- suspicion: Record<string, number>
30
- interrogations: Record<string, Line[]>
31
- usedQ: Record<string, string[]>
32
- usedEv: Record<string, string[]>
33
- pinned: string[]
34
- accuse: AccuseState
35
- startedAt: number
36
- }
37
-
38
- type Action =
39
- | { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
40
- | { type: 'SUSP_SET'; sid: string; value: number }
41
- | { type: 'ADD_LINE'; sid: string; line: Line }
42
- | { type: 'USEQ'; sid: string; qid: string }
43
- | { type: 'USEEV'; sid: string; ev: string }
44
- | { type: 'PIN'; ev: string }
45
- | { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
46
-
47
- function reducer(s: GameState, a: Action): GameState {
48
- switch (a.type) {
49
- case 'NAV':
50
- return { ...s, screen: a.screen, payload: a.payload || {} }
51
- case 'SUSP_SET':
52
- return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } }
53
- case 'ADD_LINE':
54
- return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } }
55
- case 'USEQ':
56
- return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } }
57
- case 'USEEV':
58
- return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
59
- case 'PIN':
60
- return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
61
- case 'ACCUSE':
62
- return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
63
- default:
64
- return s
65
- }
66
- }
67
-
68
- function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
69
- const suspicion: Record<string, number> = {}
70
- c.suspects.forEach((s) => {
71
- suspicion[s.id] = s.baselineSuspicion
72
- })
73
- return {
74
- screen,
75
- payload: {},
76
- suspicion,
77
- interrogations: {},
78
- usedQ: {},
79
- usedEv: {},
80
- pinned: [],
81
- accuse: { suspect: null, motive: null, evidence: [] },
82
- startedAt: Date.now(),
83
- }
84
- }
85
-
86
- // ---- tweaks ----
87
- export interface Tweaks {
88
- palette: 'sodium' | 'harbor' | 'violet'
89
- fonts: 'crisp' | 'terminal' | 'stamp'
90
- fx: 'low' | 'med' | 'high'
91
- mood: 'night' | 'day'
92
- pixelScale: number
93
- typeSpeed: number
94
- rain: boolean
95
- }
96
-
97
- export const TWEAK_DEFAULTS: Tweaks = {
98
- palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night',
99
- pixelScale: 1, typeSpeed: 18, rain: true,
100
- }
101
-
102
- const TWEAK_KEY = 'cz-tweaks'
103
-
104
- export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
105
- const [t, setT] = useState<Tweaks>(() => {
106
- try {
107
- const raw = localStorage.getItem(TWEAK_KEY)
108
- return raw ? { ...TWEAK_DEFAULTS, ...JSON.parse(raw) } : TWEAK_DEFAULTS
109
- } catch {
110
- return TWEAK_DEFAULTS
111
- }
112
- })
113
- const setTweak = useCallback(<K extends keyof Tweaks>(k: K, v: Tweaks[K]) => {
114
- setT((prev) => {
115
- const next = { ...prev, [k]: v }
116
- try {
117
- localStorage.setItem(TWEAK_KEY, JSON.stringify(next))
118
- } catch {
119
- /* ignore */
120
- }
121
- return next
122
- })
123
- }, [])
124
- return [t, setTweak]
125
- }
126
-
127
- // ---- responsive mode ----
128
- export type Device = 'auto' | 'desktop' | 'mobile'
129
- export function useMode(device: Device): 'desktop' | 'mobile' {
130
- const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
131
- useEffect(() => {
132
- const f = () => setW(window.innerWidth)
133
- window.addEventListener('resize', f)
134
- return () => window.removeEventListener('resize', f)
135
- }, [])
136
- if (device === 'desktop') return 'desktop'
137
- if (device === 'mobile') return 'mobile'
138
- return w < 820 ? 'mobile' : 'desktop'
139
- }
140
-
141
- // ---- context ----
142
- export interface Game {
143
- state: GameState & { tweaks: Tweaks }
144
- dispatch: (a: Action) => void
145
- nav: (screen: Screen, payload?: Record<string, unknown>) => void
146
- mode: 'desktop' | 'mobile'
147
- runStats: () => [string, string][]
148
- case: PublicCase
149
- runId: string
150
- setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
151
- newCase: () => void // fetch a fresh case from the server and start playing it
152
- loadCase: (id: string) => void // load a specific case by ID and jump straight into it
153
- }
154
-
155
- const GameCtx = createContext<Game | null>(null)
156
- export const useGame = (): Game => useContext(GameCtx)!
157
-
158
- interface ProviderProps {
159
- case: PublicCase
160
- runId: string
161
- mode: 'desktop' | 'mobile'
162
- tweaks: Tweaks
163
- setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
164
- initialScreen?: Screen
165
- newCase: () => void
166
- loadCase: (id: string) => void
167
- children: ComponentChildren
168
- }
169
-
170
- export function GameProvider({ case: c, runId, mode, tweaks, setTweak, initialScreen = 'title', newCase, loadCase, children }: ProviderProps) {
171
- const [state, dispatch] = useReducer(reducer, c, (cc) => initialState(cc, initialScreen))
172
-
173
- const nav = useCallback((screen: Screen, payload?: Record<string, unknown>) => {
174
- dispatch({ type: 'NAV', screen, payload })
175
- const stage = document.querySelector('.app__view')
176
- if (stage) stage.scrollTop = 0
177
- }, [])
178
-
179
- const runStats = useCallback((): [string, string][] => {
180
- const ms = Date.now() - state.startedAt
181
- const mm = String(Math.floor(ms / 60000)).padStart(2, '0')
182
- const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')
183
- const grilled = Object.values(state.interrogations).filter((x) => x.length > 1).length
184
- return [
185
- ['TIME', `${mm}:${ss}`],
186
- ['GRILLED', `${grilled}/${c.suspects.length}`],
187
- ['EXHIBITS', `${state.pinned.length}/${c.evidence.length}`],
188
- ]
189
- }, [state, c])
190
-
191
- const game: Game = {
192
- state: { ...state, tweaks },
193
- dispatch,
194
- nav,
195
- mode,
196
- runStats,
197
- case: c,
198
- runId,
199
- setTweak,
200
- newCase,
201
- loadCase,
202
- }
203
- window.__game = game
204
-
205
- return <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
206
- }
 
1
+ // Game store: reducer, context, responsive mode, and localStorage-backed tweaks.
2
+ // Mirrors the prototype's app.jsx store, but the case comes from the server and
3
+ // suspicion/verdict are server-authoritative (the client only displays them).
4
+ import { createContext } from 'preact'
5
+ import type { ComponentChildren } from 'preact'
6
+ import { useCallback, useContext, useEffect, useReducer, useState } from 'preact/hooks'
7
+
8
+ import type { PublicCase } from './types'
9
+
10
+ export type Screen =
11
+ | 'title' | 'story' | 'briefing' | 'board' | 'suspects' | 'interro' | 'evidence'
12
+ | 'flashback' | 'timeline' | 'notes' | 'accuse' | 'verdict' | 'share' | 'boot'
13
+
14
+ export interface Line {
15
+ role: 'det' | 'sus'
16
+ text: string
17
+ ev?: string
18
+ }
19
+
20
+ export interface AccuseState {
21
+ suspect: string | null
22
+ motive: string | null
23
+ evidence: string[]
24
+ }
25
+
26
+ export interface GameState {
27
+ screen: Screen
28
+ payload: Record<string, unknown>
29
+ suspicion: Record<string, number>
30
+ interrogations: Record<string, Line[]>
31
+ usedQ: Record<string, string[]>
32
+ usedEv: Record<string, string[]>
33
+ pinned: string[]
34
+ accuse: AccuseState
35
+ startedAt: number
36
+ }
37
+
38
+ type Action =
39
+ | { type: 'NAV'; screen: Screen; payload?: Record<string, unknown> }
40
+ | { type: 'SUSP_SET'; sid: string; value: number }
41
+ | { type: 'ADD_LINE'; sid: string; line: Line }
42
+ | { type: 'USEQ'; sid: string; qid: string }
43
+ | { type: 'USEEV'; sid: string; ev: string }
44
+ | { type: 'PIN'; ev: string }
45
+ | { type: 'ACCUSE'; field: keyof AccuseState; value: unknown }
46
+
47
+ function reducer(s: GameState, a: Action): GameState {
48
+ switch (a.type) {
49
+ case 'NAV':
50
+ return { ...s, screen: a.screen, payload: a.payload || {} }
51
+ case 'SUSP_SET':
52
+ return { ...s, suspicion: { ...s.suspicion, [a.sid]: Math.max(0, Math.min(100, Math.round(a.value))) } }
53
+ case 'ADD_LINE':
54
+ return { ...s, interrogations: { ...s.interrogations, [a.sid]: [...(s.interrogations[a.sid] || []), a.line] } }
55
+ case 'USEQ':
56
+ return { ...s, usedQ: { ...s.usedQ, [a.sid]: [...(s.usedQ[a.sid] || []), a.qid] } }
57
+ case 'USEEV':
58
+ return { ...s, usedEv: { ...s.usedEv, [a.sid]: [...(s.usedEv[a.sid] || []), a.ev] } }
59
+ case 'PIN':
60
+ return { ...s, pinned: s.pinned.includes(a.ev) ? s.pinned : [...s.pinned, a.ev] }
61
+ case 'ACCUSE':
62
+ return { ...s, accuse: { ...s.accuse, [a.field]: a.value } }
63
+ default:
64
+ return s
65
+ }
66
+ }
67
+
68
+ function initialState(c: PublicCase, screen: Screen = 'title'): GameState {
69
+ const suspicion: Record<string, number> = {}
70
+ c.suspects.forEach((s) => {
71
+ suspicion[s.id] = s.baselineSuspicion
72
+ })
73
+ return {
74
+ screen,
75
+ payload: {},
76
+ suspicion,
77
+ interrogations: {},
78
+ usedQ: {},
79
+ usedEv: {},
80
+ pinned: [],
81
+ accuse: { suspect: null, motive: null, evidence: [] },
82
+ startedAt: Date.now(),
83
+ }
84
+ }
85
+
86
+ // ---- tweaks ----
87
+ export interface Tweaks {
88
+ palette: 'sodium' | 'harbor' | 'violet'
89
+ fonts: 'crisp' | 'terminal' | 'stamp'
90
+ fx: 'low' | 'med' | 'high'
91
+ mood: 'night' | 'day'
92
+ pixelScale: number
93
+ typeSpeed: number
94
+ rain: boolean
95
+ }
96
+
97
+ export const TWEAK_DEFAULTS: Tweaks = {
98
+ palette: 'sodium', fonts: 'crisp', fx: 'med', mood: 'night',
99
+ pixelScale: 1, typeSpeed: 18, rain: true,
100
+ }
101
+
102
+ // In-memory only: the tweaks sheet UI is gone, so persisted values would be
103
+ // unrevertable. Defaults are deterministic; setTweak stays as a debug hook.
104
+ export function useTweaks(): [Tweaks, <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void] {
105
+ const [t, setT] = useState<Tweaks>(TWEAK_DEFAULTS)
106
+ const setTweak = useCallback(<K extends keyof Tweaks>(k: K, v: Tweaks[K]) => {
107
+ setT((prev) => ({ ...prev, [k]: v }))
108
+ }, [])
109
+ return [t, setTweak]
110
+ }
111
+
112
+ // ---- responsive mode ----
113
+ export type Device = 'auto' | 'desktop' | 'mobile'
114
+ export function useMode(device: Device): 'desktop' | 'mobile' {
115
+ const [w, setW] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
116
+ useEffect(() => {
117
+ const f = () => setW(window.innerWidth)
118
+ window.addEventListener('resize', f)
119
+ return () => window.removeEventListener('resize', f)
120
+ }, [])
121
+ if (device === 'desktop') return 'desktop'
122
+ if (device === 'mobile') return 'mobile'
123
+ return w < 820 ? 'mobile' : 'desktop'
124
+ }
125
+
126
+ // ---- context ----
127
+ export interface Game {
128
+ state: GameState & { tweaks: Tweaks }
129
+ dispatch: (a: Action) => void
130
+ nav: (screen: Screen, payload?: Record<string, unknown>) => void
131
+ mode: 'desktop' | 'mobile'
132
+ runStats: () => [string, string][]
133
+ case: PublicCase
134
+ runId: string
135
+ setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
136
+ newCase: () => void // fetch a fresh case from the server and start playing it
137
+ loadCase: (id: string) => void // load a specific case by ID and jump straight into it
138
+ }
139
+
140
+ const GameCtx = createContext<Game | null>(null)
141
+ export const useGame = (): Game => useContext(GameCtx)!
142
+
143
+ interface ProviderProps {
144
+ case: PublicCase
145
+ runId: string
146
+ mode: 'desktop' | 'mobile'
147
+ tweaks: Tweaks
148
+ setTweak: <K extends keyof Tweaks>(k: K, v: Tweaks[K]) => void
149
+ initialScreen?: Screen
150
+ newCase: () => void
151
+ loadCase: (id: string) => void
152
+ children: ComponentChildren
153
+ }
154
+
155
+ export function GameProvider({ case: c, runId, mode, tweaks, setTweak, initialScreen = 'title', newCase, loadCase, children }: ProviderProps) {
156
+ const [state, dispatch] = useReducer(reducer, c, (cc) => initialState(cc, initialScreen))
157
+
158
+ const nav = useCallback((screen: Screen, payload?: Record<string, unknown>) => {
159
+ dispatch({ type: 'NAV', screen, payload })
160
+ const stage = document.querySelector('.app__view')
161
+ if (stage) stage.scrollTop = 0
162
+ }, [])
163
+
164
+ const runStats = useCallback((): [string, string][] => {
165
+ const ms = Date.now() - state.startedAt
166
+ const mm = String(Math.floor(ms / 60000)).padStart(2, '0')
167
+ const ss = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')
168
+ const grilled = Object.values(state.interrogations).filter((x) => x.length > 1).length
169
+ return [
170
+ ['TIME', `${mm}:${ss}`],
171
+ ['GRILLED', `${grilled}/${c.suspects.length}`],
172
+ ['EXHIBITS', `${state.pinned.length}/${c.evidence.length}`],
173
+ ]
174
+ }, [state, c])
175
+
176
+ const game: Game = {
177
+ state: { ...state, tweaks },
178
+ dispatch,
179
+ nav,
180
+ mode,
181
+ runStats,
182
+ case: c,
183
+ runId,
184
+ setTweak,
185
+ newCase,
186
+ loadCase,
187
+ }
188
+ window.__game = game
189
+
190
+ return <GameCtx.Provider value={game}>{children}</GameCtx.Provider>
191
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/styles/layout.css CHANGED
@@ -1,307 +1,358 @@
1
- /* ============================================================
2
- LAYOUT & SCREEN CHROME (ported from prototype/css/layout.css)
3
- ============================================================ */
4
-
5
- .app{ position:fixed; inset:0; display:flex; flex-direction:column; background:var(--ink-0); }
6
- .app__view{ position:absolute; inset:0; display:flex; flex-direction:column; flex:1; min-height:0; }
7
-
8
- /* device simulation: mobile = centered phone column */
9
- .app[data-mode="mobile"] .app__frame{
10
- width:min(430px,100vw); height:min(932px,100vh); margin:auto;
11
- position:relative; box-shadow:0 0 0 6px var(--ink-2), 0 0 0 9px var(--ink-0), 0 0 60px -10px #000;
12
- overflow:hidden; display:flex; flex-direction:column;
13
- }
14
- .app[data-mode="mobile"] .app__stage{ background:var(--ink-1); }
15
- .app[data-mode="desktop"] .app__frame{ position:absolute; inset:0; display:flex; flex-direction:column; }
16
- .app__stage{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
17
-
18
- /* ---------- view toggle (top, outside content) ---------- */
19
- .viewbar{
20
- position:fixed; top:10px; right:14px; z-index:80;
21
- display:flex; gap:6px; align-items:center;
22
- }
23
- .viewbar .seg{ display:flex; box-shadow:0 0 0 3px var(--ink-0); }
24
- .viewbar .seg button{
25
- font-family:var(--f-display); font-size:8px; letter-spacing:.08em;
26
- padding:6px 9px; background:var(--ink-2); color:var(--bone-1); border:0;
27
- }
28
- .viewbar .seg button.on{ background:var(--amber-2); color:var(--ink-0); }
29
-
30
- /* ============================================================
31
- HUD (top chrome of in-game screens)
32
- ============================================================ */
33
- .hud{
34
- display:flex; align-items:center; justify-content:space-between; gap:12px;
35
- padding:10px 14px; background:var(--ink-2);
36
- box-shadow:inset 0 -3px 0 var(--ink-0), 0 3px 0 var(--ink-0);
37
- z-index:20; flex-shrink:0;
38
- }
39
- .hud-badge{
40
- display:flex; flex-direction:column; align-items:flex-start; gap:1px;
41
- background:var(--amber-2); border:0; padding:5px 9px; line-height:1.1; flex-shrink:0;
42
- box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
43
- }
44
- .hud-badge span{ white-space:nowrap; }
45
- .hud-badge:hover{ background:var(--amber-3); }
46
- /* Title clamps to one line with an ellipsis on desktop (plenty of width there). */
47
- .hud__title{
48
- font-size:12px; color:var(--bone-3); line-height:1.15;
49
- white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
50
- }
51
- /* On narrow screens, wrap to two lines at a smaller size instead of cropping mid-word. */
52
- [data-mode="mobile"] .hud__title{
53
- font-size:10px; white-space:normal; overflow:hidden;
54
- display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
55
- }
56
-
57
- /* ============================================================
58
- BOTTOM NAV (mobile)
59
- ============================================================ */
60
- .bottom-nav{ display:none; }
61
- [data-mode="mobile"] .bottom-nav{
62
- display:flex; justify-content:space-around; align-items:center;
63
- background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0);
64
- padding:7px 4px 9px; flex-shrink:0; z-index:20;
65
- }
66
- .nav-btn{
67
- display:flex; flex-direction:column; align-items:center; gap:4px;
68
- background:transparent; border:0; color:var(--bone-1); padding:4px 8px; min-width:56px; min-height:44px;
69
- position:relative;
70
- }
71
- .nav-btn--on{ color:var(--ink-0); }
72
- .nav-btn--on::before{
73
- content:''; position:absolute; margin-top:-9px; width:34px; height:34px;
74
- background:var(--amber-2); z-index:-1; box-shadow:0 0 0 2px var(--ink-0);
75
- }
76
-
77
- /* ============================================================
78
- "DEVELOPS IN" reveal scanline/dither wipe
79
- ============================================================ */
80
- .develop-veil{ position:absolute; inset:0; background:var(--ink-1); z-index:3;
81
- background-image:repeating-linear-gradient(0deg, rgba(0,0,0,.5) 0 2px, transparent 2px 4px); }
82
- .develop-veil--anim{ animation:develop .7s steps(10) forwards; }
83
- @keyframes develop{
84
- 0%{ clip-path:inset(0 0 0 0); opacity:1; }
85
- 99%{ clip-path:inset(0 0 100% 0); opacity:1; }
86
- 100%{ clip-path:inset(0 0 100% 0); opacity:0; }
87
- }
88
-
89
- /* ============================================================
90
- DOSSIER — physical case file you flip through
91
- ============================================================ */
92
- .dossier-stage{ flex:1; display:flex; align-items:center; justify-content:center; padding:20px; perspective:2200px; overflow:hidden; }
93
- .dossier{ width:min(820px,96vw); max-height:100%; display:flex; flex-direction:column; gap:14px; }
94
- .dossier__folder{
95
- position:relative; background:linear-gradient(160deg,#2a2417,#1c1810);
96
- box-shadow:0 0 0 3px var(--ink-0), 0 18px 40px -12px #000, inset 0 2px 0 rgba(224,164,76,.12);
97
- padding:14px;
98
- }
99
- .dossier__folder::before{
100
- content:''; position:absolute; top:-14px; left:30px; width:120px; height:16px;
101
- background:linear-gradient(160deg,#2a2417,#1c1810); box-shadow:0 0 0 3px var(--ink-0);
102
- clip-path:polygon(8% 0,92% 0,100% 100%,0 100%);
103
- }
104
- .page-wrap{ position:relative; height:min(560px,68vh); transform-style:preserve-3d; }
105
- .page{
106
- position:absolute; inset:0; background:#e7e0cc; color:#211d15;
107
- padding:26px 30px; overflow:auto;
108
- box-shadow:inset 0 0 0 1px #b9b09433, 0 4px 0 rgba(0,0,0,.35), 6px 8px 18px -6px rgba(0,0,0,.5);
109
- background-image:repeating-linear-gradient(0deg, transparent 0 27px, rgba(33,29,21,.06) 27px 28px);
110
- font-family:var(--f-mono);
111
- }
112
- .page--paper2{ background:#ece6d4; }
113
- .page--flip-fwd{ animation:flipFwd .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:left center; }
114
- .page--flip-back{ animation:flipBack .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:right center; }
115
- @keyframes flipFwd{ from{ transform:rotateY(82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
116
- @keyframes flipBack{ from{ transform:rotateY(-82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
117
- .page__edge{ position:absolute; top:0; bottom:0; width:34px; cursor:pointer; z-index:5; }
118
- .page__edge--r{ right:0; background:linear-gradient(270deg, rgba(0,0,0,.14), transparent); }
119
- .page__edge--l{ left:0; background:linear-gradient(90deg, rgba(0,0,0,.14), transparent); }
120
- .page__edge:hover{ filter:brightness(1.1); }
121
- .page__curl{ position:absolute; bottom:0; right:0; width:0; height:0;
122
- border-style:solid; border-width:0 0 26px 26px; border-color:transparent transparent #cfc6ad transparent;
123
- box-shadow:-2px -2px 4px rgba(0,0,0,.2); pointer-events:none; }
124
- .dh{ font-family:var(--f-display); text-transform:uppercase; color:#1a1610; letter-spacing:.02em; }
125
- .dlabel{ font-family:var(--f-display); font-size:8px; letter-spacing:.18em; color:#8a3a2c; text-transform:uppercase; }
126
- .dtype{ font-family:var(--f-mono); font-size:calc(17px*var(--mono-scale)); line-height:1.55; color:#2a251b; }
127
- .dstamp{ font-family:var(--f-display); text-transform:uppercase; color:#8a2a2a;
128
- border:3px solid #8a2a2a; padding:5px 10px; letter-spacing:.05em; transform:rotate(-7deg);
129
- opacity:.82; box-shadow:0 0 0 1px #8a2a2a33; display:inline-block; }
130
- .paperclip{ position:absolute; top:-8px; left:18px; width:14px; height:34px;
131
- border:3px solid #9aa0a6; border-radius:8px; border-bottom-color:transparent; transform:rotate(8deg); }
132
- .photo-paper{ background:#fff; padding:6px 6px 22px; box-shadow:3px 5px 0 rgba(0,0,0,.3); transform:rotate(-2deg); position:relative; }
133
- .dossier__nav{ display:flex; align-items:center; justify-content:space-between; gap:12px; }
134
- .page-dots{ display:flex; gap:6px; }
135
- .page-dot{ width:24px; height:6px; background:#3a3324; box-shadow:inset 0 0 0 1px var(--ink-0); cursor:pointer; }
136
- .page-dot--on{ background:var(--amber-2); }
137
- [data-mode="mobile"] .page{ padding:20px 18px; }
138
- [data-mode="mobile"] .page-wrap{ height:62vh; }
139
- /* let the dossier nav wrap so long labels ("Open the Wall") never crop on narrow phones */
140
- [data-mode="mobile"] .dossier__nav{ flex-wrap:wrap; row-gap:8px; justify-content:center; }
141
- [data-mode="mobile"] .dossier__nav .pbtn{ flex-shrink:0; }
142
-
143
- /* ============================================================
144
- ASSISTANT partner-on-the-wire hint dock
145
- ============================================================ */
146
- .assistant{ position:fixed; left:14px; bottom:14px; z-index:70; display:flex; flex-direction:column; align-items:flex-start; gap:10px; }
147
- [data-mode="mobile"] .assistant{ bottom:78px; left:10px; right:10px; }
148
- .assistant__panel{
149
- width:min(330px,92vw); background:var(--ink-2); padding:12px;
150
- box-shadow:0 0 0 3px var(--ink-0), inset 0 0 0 2px var(--amber-1), 0 0 26px -8px var(--glow);
151
- animation:slideup .2s steps(5);
152
- }
153
- .assistant__x{ background:transparent; border:0; color:var(--bone-1); font-family:var(--f-mono); font-size:16px; line-height:1; padding:2px 4px; }
154
- .assistant__x:hover{ color:var(--ox-3); }
155
-
156
- .hint-btn{
157
- display:inline-flex; align-items:center; gap:6px;
158
- font-family:var(--f-display); font-size:9px; letter-spacing:.1em;
159
- color:var(--ink-0); background:var(--amber-2); border:0; padding:8px 11px;
160
- box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
161
- }
162
- .hint-btn:hover{ background:var(--amber-3); }
163
- .hint-btn__dot{ width:6px; height:6px; background:var(--ink-0); animation:blink 1.3s steps(1) infinite; }
164
-
165
- /* ============================================================
166
- INVESTIGATION BOARD corkboard
167
- ============================================================ */
168
- .board{ position:relative; flex:1; overflow:hidden;
169
- background:
170
- radial-gradient(rgba(0,0,0,.22) 1px, transparent 1px),
171
- radial-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
172
- repeating-linear-gradient(26deg, rgba(0,0,0,.05) 0 2px, transparent 2px 5px),
173
- linear-gradient(160deg, #5a4326, #3a2c18 55%, #2a2012);
174
- background-size:5px 5px, 7px 7px, 9px 9px, 100% 100%;
175
- background-position:0 0, 3px 4px, 0 0, 0 0;
176
- }
177
- .board__felt{ position:absolute; inset:0; pointer-events:none;
178
- background:
179
- radial-gradient(70% 50% at 50% 0%, rgba(245,208,138,.16), transparent 60%),
180
- radial-gradient(120% 90% at 50% 45%, transparent 45%, rgba(0,0,0,.5) 100%);
181
- }
182
- .board__frame{ position:absolute; inset:6px; pointer-events:none; z-index:9;
183
- box-shadow:inset 0 0 0 5px #241a0e, inset 0 0 0 8px #120d07, inset 0 0 40px 6px rgba(0,0,0,.5); }
184
-
185
- .wall-item{ position:absolute; z-index:2; }
186
- .pushpin{ position:absolute; top:-7px; left:50%; transform:translateX(-50%); width:11px; height:11px; z-index:3;
187
- background:var(--amber-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--amber-1); }
188
- .pushpin--ox{ background:var(--ox-3); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1); }
189
- .pushpin--bone{ background:var(--bone-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--bone-1); }
190
- .wall-photo{ background:#efe9d6; padding:6px 6px 16px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
191
- .wall-clip{ background:#e3ddc8; color:#231f16; padding:9px 11px; box-shadow:4px 6px 0 rgba(0,0,0,.45);
192
- background-image:repeating-linear-gradient(0deg, transparent 0 9px, rgba(0,0,0,.05) 9px 10px); }
193
- .wall-note{ background:var(--amber-2); color:#2a1f0c; padding:11px 12px; box-shadow:4px 6px 0 rgba(0,0,0,.4);
194
- font-family:var(--f-body); }
195
- .wall-note--bone{ background:#e7e0cc; color:#231f16; }
196
- .wall-redact{ background:#1a1610; padding:10px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
197
- .wall-redact .bar-blk{ height:7px; background:#0a0805; margin:4px 0; }
198
- .scrawl{ font-family:var(--f-body); line-height:1.2; }
199
- .pin-card{ position:absolute; cursor:default; user-select:none; z-index:4; transition:transform .05s; }
200
- .pin-card .pin{
201
- position:absolute; top:-9px; left:50%; transform:translateX(-50%);
202
- width:12px; height:12px; background:var(--ox-3); z-index:6; cursor:grab;
203
- box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1);
204
- }
205
- .pin-card .pin:active{ cursor:grabbing; }
206
- .card-grip{ display:flex; align-items:center; justify-content:space-between; gap:6px;
207
- background:var(--ink-3); color:var(--bone-1); padding:2px 6px; cursor:grab;
208
- box-shadow:inset 0 0 0 2px var(--ink-0); }
209
- .card-grip:active{ cursor:grabbing; background:var(--slate-1); }
210
- .card-grip__dots{ font-size:11px; line-height:1; letter-spacing:-1px; color:var(--amber-2); }
211
- .card-grip__lbl{ font-family:var(--f-display); font-size:7px; letter-spacing:.12em; }
212
- .card-actions{ margin-top:6px; display:flex; justify-content:center; }
213
- .pin-card--sel{ filter:drop-shadow(0 0 14px rgba(224,164,76,.35)); }
214
- .pin-note{ background:var(--bone-2); color:var(--ink-1); padding:8px 10px;
215
- box-shadow:3px 4px 0 rgba(0,0,0,.4); font-family:var(--f-body); }
216
- .board__filters{ position:absolute; top:12px; left:12px; z-index:8; display:flex; gap:6px; flex-wrap:wrap; }
217
-
218
- .board-layout{ flex:1; display:flex; min-height:0; }
219
- .board-layout .board{ flex:1; }
220
- .suspect-rail{ width:316px; flex-shrink:0; background:var(--ink-2); box-shadow:inset 4px 0 0 var(--ink-0);
221
- display:flex; flex-direction:column; min-height:0; }
222
- .suspect-rail__head{ padding:14px 14px 10px; box-shadow:inset 0 -3px 0 var(--ink-0); }
223
- .suspect-rail__list{ flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
224
- .rail-card{ cursor:pointer; }
225
- .rail-card__reveal{ overflow:hidden; max-height:0; transition:max-height .25s steps(6); }
226
- .rail-card__reveal--open{ max-height:200px; }
227
-
228
- /* ============================================================
229
- INTERROGATION
230
- ============================================================ */
231
- .interro{ flex:1; display:grid; grid-template-columns:minmax(300px,440px) 1fr; gap:0; overflow:hidden; }
232
- .interro__stage{ position:relative; overflow:hidden; background:var(--ink-0); display:flex; flex-direction:column; }
233
- .interro__sprite{ position:absolute; left:50%; bottom:0; transform:translateX(-50%); z-index:2; }
234
- .interro__right{ display:flex; flex-direction:column; min-width:0; background:var(--ink-1);
235
- box-shadow:inset 4px 0 0 var(--ink-0); }
236
- .transcript{ flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; }
237
- .tline{ max-width:88%; }
238
- .tline--det{ align-self:flex-end; }
239
- .tline--det .tline__b{ background:var(--slate-1); color:var(--bone-3); box-shadow:inset -2px -2px 0 rgba(0,0,0,.4); }
240
- .tline--sus .tline__b{ background:var(--ink-3); color:var(--bone-2); box-shadow:inset 0 0 0 2px var(--ink-0); }
241
- .tline__b{ padding:10px 12px; font-family:var(--f-body); font-size:15px; line-height:1.45; }
242
- .tline__who{ font-family:var(--f-display); font-size:8px; letter-spacing:.1em; color:var(--bone-1); margin-bottom:4px; }
243
- .tline--det .tline__who{ text-align:right; color:var(--amber-2); }
244
- .composer{ flex-shrink:0; padding:12px; background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0); }
245
- .qsuggest{ display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px; }
246
- .qchip{ font-family:var(--f-body); font-size:13px; line-height:1.25; padding:7px 10px; background:var(--ink-1);
247
- color:var(--bone-2); border:0; box-shadow:inset 0 0 0 2px var(--slate-1); text-align:left; flex:1 1 auto; min-width:0; }
248
- .qchip:hover{ box-shadow:inset 0 0 0 2px var(--amber-2); color:var(--bone-3); }
249
- .qchip:disabled{ opacity:.35; }
250
- .composer__input{ display:flex; gap:8px; }
251
- .pinput{ flex:1; background:var(--ink-1); border:0; color:var(--bone-3); padding:11px 12px;
252
- font-size:15px; box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 rgba(0,0,0,.4); outline:none; }
253
- .pinput::placeholder{ color:var(--bone-1); }
254
- .pinput:focus{ box-shadow:inset 0 0 0 2px var(--amber-1); }
255
-
256
- .tray{ position:absolute; inset:0; z-index:30; background:rgba(8,11,16,.82);
257
- display:flex; flex-direction:column; justify-content:flex-end; }
258
- .tray__sheet{ background:var(--ink-2); box-shadow:0 -4px 0 var(--ink-0), inset 0 4px 0 var(--amber-1);
259
- padding:16px; max-height:70%; overflow-y:auto; animation:slideup .25s steps(6); }
260
- @keyframes slideup{ from{ transform:translateY(100%);} to{ transform:translateY(0);} }
261
- .tray__grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:10px; }
262
-
263
- /* ============================================================
264
- GENERIC SCREEN SCAFFOLD
265
- ============================================================ */
266
- .screen-pad{ flex:1; overflow-y:auto; padding:22px; }
267
- .screen-center{ flex:1; display:flex; align-items:center; justify-content:center; padding:22px; overflow-y:auto; }
268
- .maxw{ width:100%; max-width:1180px; margin:0 auto; }
269
- .grid-2{ display:grid; grid-template-columns:1fr 1fr; gap:18px; }
270
- .grid-3{ display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
271
- .grid-4{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
272
-
273
- /* ============================================================
274
- TIMELINE
275
- ============================================================ */
276
- .tl-track{ position:relative; padding:30px 10px; }
277
- .tl-line{ position:absolute; left:0; right:0; top:50%; height:4px;
278
- background:repeating-linear-gradient(90deg,var(--slate-1) 0 6px, transparent 6px 12px); }
279
- .tl-stop{ position:relative; z-index:2; display:flex; flex-direction:column; align-items:center; gap:6px; }
280
- .tl-node{ width:14px; height:14px; background:var(--slate-2); box-shadow:0 0 0 3px var(--ink-0); }
281
- .tl-node--lock{ background:var(--amber-2); }
282
- .tl-node--conflict{ background:var(--ox-3); }
283
- .tl-slot{ min-height:62px; }
284
-
285
- /* ============================================================
286
- FLASHBACK compare
287
- ============================================================ */
288
- .flash-grid{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
289
- .flash-flag{ box-shadow:inset 0 0 0 2px var(--ox-2); }
290
-
291
- /* ============================================================
292
- RESPONSIVE
293
- ============================================================ */
294
- [data-mode="mobile"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
295
- [data-mode="mobile"] .interro__stage{ height:32vh; min-height:200px; }
296
- [data-mode="mobile"] .grid-2,[data-mode="mobile"] .grid-3,[data-mode="mobile"] .grid-4,[data-mode="mobile"] .flash-grid{ grid-template-columns:1fr; }
297
- [data-mode="mobile"] .screen-pad{ padding:14px; }
298
- [data-mode="mobile"] .hud{ padding:9px 11px; }
299
-
300
- @media (max-width:760px){
301
- .app[data-mode="auto"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
302
- .app[data-mode="auto"] .grid-2,.app[data-mode="auto"] .grid-3,.app[data-mode="auto"] .grid-4{ grid-template-columns:1fr; }
303
- .app[data-mode="auto"] .hud__title{
304
- font-size:10px; white-space:normal; overflow:hidden;
305
- display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
306
- }
307
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ LAYOUT & SCREEN CHROME (ported from prototype/css/layout.css)
3
+ ============================================================ */
4
+
5
+ .app{ position:fixed; inset:0; display:flex; flex-direction:column; background:var(--ink-0); }
6
+ .app__view{ position:absolute; inset:0; display:flex; flex-direction:column; flex:1; min-height:0; }
7
+
8
+ /* device simulation: mobile = centered phone column */
9
+ .app[data-mode="mobile"] .app__frame{
10
+ width:min(430px,100vw); height:min(932px,100vh); margin:auto;
11
+ position:relative; box-shadow:0 0 0 6px var(--ink-2), 0 0 0 9px var(--ink-0), 0 0 60px -10px #000;
12
+ overflow:hidden; display:flex; flex-direction:column;
13
+ }
14
+ .app[data-mode="mobile"] .app__stage{ background:var(--ink-1); }
15
+ .app[data-mode="desktop"] .app__frame{ position:absolute; inset:0; display:flex; flex-direction:column; }
16
+ .app__stage{ position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
17
+
18
+ /* ---------- view toggle (top, outside content) ---------- */
19
+ .viewbar{
20
+ position:fixed; top:10px; right:14px; z-index:80;
21
+ display:flex; gap:6px; align-items:center;
22
+ }
23
+ .viewbar .seg{ display:flex; box-shadow:0 0 0 3px var(--ink-0); }
24
+ .viewbar .seg button{
25
+ font-family:var(--f-display); font-size:8px; letter-spacing:.08em;
26
+ padding:6px 9px; background:var(--ink-2); color:var(--bone-1); border:0;
27
+ }
28
+ .viewbar .seg button.on{ background:var(--amber-2); color:var(--ink-0); }
29
+
30
+ /* ============================================================
31
+ HUD (top chrome of in-game screens)
32
+ ============================================================ */
33
+ .hud{
34
+ display:flex; align-items:center; justify-content:space-between; gap:12px;
35
+ padding:10px 14px; background:var(--ink-2);
36
+ box-shadow:inset 0 -3px 0 var(--ink-0), 0 3px 0 var(--ink-0);
37
+ z-index:20; flex-shrink:0;
38
+ }
39
+ /* Left cluster shrinks (title ellipsizes / clamps); right cluster never shrinks, so
40
+ buttons can't slide over the badge or title. On phones the right cluster wraps to
41
+ its own row instead of overlapping. */
42
+ .hud__left{ display:flex; align-items:center; gap:12px; min-width:0; flex:1 1 auto; }
43
+ .hud__right{ display:flex; align-items:center; gap:6px; flex-shrink:0; }
44
+ .hud__sub{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
45
+ [data-mode="mobile"] .hud{ flex-wrap:wrap; row-gap:7px; padding:8px 10px; }
46
+ [data-mode="mobile"] .hud__right{
47
+ margin-left:auto; max-width:100%; flex-wrap:wrap; justify-content:flex-end; row-gap:6px;
48
+ }
49
+ .hud-badge{
50
+ display:flex; flex-direction:column; align-items:flex-start; gap:1px;
51
+ background:var(--amber-2); border:0; padding:5px 9px; line-height:1.1; flex-shrink:0;
52
+ box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
53
+ }
54
+ .hud-badge span{ white-space:nowrap; }
55
+ .hud-badge:hover{ background:var(--amber-3); }
56
+ /* Title clamps to one line with an ellipsis on desktop (plenty of width there). */
57
+ .hud__title{
58
+ font-size:12px; color:var(--bone-3); line-height:1.15;
59
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
60
+ }
61
+ /* On narrow screens, wrap to two lines at a smaller size instead of cropping mid-word. */
62
+ [data-mode="mobile"] .hud__title{
63
+ font-size:10px; white-space:normal; overflow:hidden;
64
+ display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
65
+ }
66
+
67
+ /* ============================================================
68
+ BOTTOM NAV (mobile)
69
+ ============================================================ */
70
+ .bottom-nav{ display:none; }
71
+ [data-mode="mobile"] .bottom-nav{
72
+ display:flex; align-items:stretch;
73
+ background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0);
74
+ padding:6px 4px 8px; flex-shrink:0; z-index:20;
75
+ }
76
+ .nav-btn{
77
+ flex:1 1 0; min-width:0; min-height:48px;
78
+ display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px;
79
+ background:transparent; border:0; color:var(--bone-1); padding:3px 2px; position:relative;
80
+ }
81
+ .nav-btn__glyph{ width:30px; height:24px; display:flex; align-items:center; justify-content:center; }
82
+ .nav-btn--on .nav-btn__glyph{
83
+ background:var(--amber-2);
84
+ box-shadow:0 0 0 2px var(--ink-0), 2px 2px 0 var(--ink-0);
85
+ }
86
+ .nav-btn__lbl{ font-size:9px; letter-spacing:.04em; white-space:nowrap; }
87
+ .nav-btn--on .nav-btn__lbl{ color:var(--amber-2); }
88
+ @media (max-width:359px){ .nav-btn__lbl{ font-size:8px; letter-spacing:.02em; } }
89
+
90
+ /* ============================================================
91
+ "DEVELOPS IN" reveal — scanline/dither wipe
92
+ ============================================================ */
93
+ .develop-veil{ position:absolute; inset:0; background:var(--ink-1); z-index:3;
94
+ background-image:repeating-linear-gradient(0deg, rgba(0,0,0,.5) 0 2px, transparent 2px 4px); }
95
+ .develop-veil--anim{ animation:develop .7s steps(10) forwards; }
96
+ @keyframes develop{
97
+ 0%{ clip-path:inset(0 0 0 0); opacity:1; }
98
+ 99%{ clip-path:inset(0 0 100% 0); opacity:1; }
99
+ 100%{ clip-path:inset(0 0 100% 0); opacity:0; }
100
+ }
101
+
102
+ /* ============================================================
103
+ DOSSIER — physical case file you flip through
104
+ ============================================================ */
105
+ .dossier-stage{ flex:1; display:flex; align-items:center; justify-content:center; padding:20px; perspective:2200px; overflow:hidden; }
106
+ .dossier{ width:min(820px,96vw); max-height:100%; display:flex; flex-direction:column; gap:14px; }
107
+ .dossier__folder{
108
+ position:relative; background:linear-gradient(160deg,#2a2417,#1c1810);
109
+ box-shadow:0 0 0 3px var(--ink-0), 0 18px 40px -12px #000, inset 0 2px 0 rgba(224,164,76,.12);
110
+ padding:14px;
111
+ }
112
+ .dossier__folder::before{
113
+ content:''; position:absolute; top:-14px; left:30px; width:120px; height:16px;
114
+ background:linear-gradient(160deg,#2a2417,#1c1810); box-shadow:0 0 0 3px var(--ink-0);
115
+ clip-path:polygon(8% 0,92% 0,100% 100%,0 100%);
116
+ }
117
+ .page-wrap{ position:relative; height:min(560px,68vh); transform-style:preserve-3d; }
118
+ .page{
119
+ position:absolute; inset:0; background:#e7e0cc; color:#211d15;
120
+ padding:26px 30px; overflow:auto;
121
+ box-shadow:inset 0 0 0 1px #b9b09433, 0 4px 0 rgba(0,0,0,.35), 6px 8px 18px -6px rgba(0,0,0,.5);
122
+ background-image:repeating-linear-gradient(0deg, transparent 0 27px, rgba(33,29,21,.06) 27px 28px);
123
+ font-family:var(--f-mono);
124
+ }
125
+ .page--paper2{ background:#ece6d4; }
126
+ .page--flip-fwd{ animation:flipFwd .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:left center; }
127
+ .page--flip-back{ animation:flipBack .46s cubic-bezier(.2,.6,.2,1) both; transform-origin:right center; }
128
+ @keyframes flipFwd{ from{ transform:rotateY(82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
129
+ @keyframes flipBack{ from{ transform:rotateY(-82deg); opacity:.2; } to{ transform:rotateY(0); opacity:1; } }
130
+ .page__edge{ position:absolute; top:0; bottom:0; width:34px; cursor:pointer; z-index:5; }
131
+ .page__edge--r{ right:0; background:linear-gradient(270deg, rgba(0,0,0,.14), transparent); }
132
+ .page__edge--l{ left:0; background:linear-gradient(90deg, rgba(0,0,0,.14), transparent); }
133
+ .page__edge:hover{ filter:brightness(1.1); }
134
+ .page__curl{ position:absolute; bottom:0; right:0; width:0; height:0;
135
+ border-style:solid; border-width:0 0 26px 26px; border-color:transparent transparent #cfc6ad transparent;
136
+ box-shadow:-2px -2px 4px rgba(0,0,0,.2); pointer-events:none; }
137
+ .dh{ font-family:var(--f-display); text-transform:uppercase; color:#1a1610; letter-spacing:.02em; }
138
+ .dlabel{ font-family:var(--f-display); font-size:8px; letter-spacing:.18em; color:#8a3a2c; text-transform:uppercase; }
139
+ .dtype{ font-family:var(--f-mono); font-size:calc(17px*var(--mono-scale)); line-height:1.55; color:#2a251b; }
140
+ .dstamp{ font-family:var(--f-display); text-transform:uppercase; color:#8a2a2a;
141
+ border:3px solid #8a2a2a; padding:5px 10px; letter-spacing:.05em; transform:rotate(-7deg);
142
+ opacity:.82; box-shadow:0 0 0 1px #8a2a2a33; display:inline-block; }
143
+ .paperclip{ position:absolute; top:-8px; left:18px; width:14px; height:34px;
144
+ border:3px solid #9aa0a6; border-radius:8px; border-bottom-color:transparent; transform:rotate(8deg); }
145
+ .photo-paper{ background:#fff; padding:6px 6px 22px; box-shadow:3px 5px 0 rgba(0,0,0,.3); transform:rotate(-2deg); position:relative; }
146
+ .dossier__nav{ display:flex; align-items:center; justify-content:space-between; gap:12px; }
147
+ .page-dots{ display:flex; gap:6px; }
148
+ .page-dot{ width:24px; height:6px; background:#3a3324; box-shadow:inset 0 0 0 1px var(--ink-0); cursor:pointer; }
149
+ .page-dot--on{ background:var(--amber-2); }
150
+ [data-mode="mobile"] .page{ padding:20px 18px; }
151
+ [data-mode="mobile"] .page-wrap{ height:62vh; }
152
+ /* let the dossier nav wrap so long labels ("Open the Wall") never crop on narrow phones */
153
+ [data-mode="mobile"] .dossier__nav{ flex-wrap:wrap; row-gap:8px; justify-content:center; }
154
+ [data-mode="mobile"] .dossier__nav .pbtn{ flex-shrink:0; }
155
+
156
+ /* ============================================================
157
+ ASSISTANT — partner-on-the-wire hint dock
158
+ ============================================================ */
159
+ .assistant{ position:fixed; left:14px; bottom:14px; z-index:70; display:flex; flex-direction:column; align-items:flex-start; gap:10px; }
160
+ [data-mode="mobile"] .assistant{ bottom:78px; left:10px; right:10px; }
161
+ .assistant__panel{
162
+ width:min(330px,92vw); background:var(--ink-2); padding:12px;
163
+ box-shadow:0 0 0 3px var(--ink-0), inset 0 0 0 2px var(--amber-1), 0 0 26px -8px var(--glow);
164
+ animation:slideup .2s steps(5);
165
+ }
166
+ .assistant__x{ background:transparent; border:0; color:var(--bone-1); font-family:var(--f-mono); font-size:16px; line-height:1; padding:2px 4px; }
167
+ .assistant__x:hover{ color:var(--ox-3); }
168
+
169
+ .hint-btn{
170
+ display:inline-flex; align-items:center; gap:6px;
171
+ font-family:var(--f-display); font-size:9px; letter-spacing:.1em;
172
+ color:var(--ink-0); background:var(--amber-2); border:0; padding:8px 11px;
173
+ box-shadow:2px 2px 0 var(--ink-0), inset -2px -2px 0 rgba(0,0,0,.25);
174
+ }
175
+ .hint-btn:hover{ background:var(--amber-3); }
176
+ .hint-btn__dot{ width:6px; height:6px; background:var(--ink-0); animation:blink 1.3s steps(1) infinite; }
177
+
178
+ /* ============================================================
179
+ INVESTIGATION BOARD corkboard
180
+ ============================================================ */
181
+ .board{ position:relative; flex:1; overflow:hidden;
182
+ background:
183
+ radial-gradient(rgba(0,0,0,.22) 1px, transparent 1px),
184
+ radial-gradient(rgba(255,255,255,.035) 1px, transparent 1px),
185
+ repeating-linear-gradient(26deg, rgba(0,0,0,.05) 0 2px, transparent 2px 5px),
186
+ linear-gradient(160deg, #5a4326, #3a2c18 55%, #2a2012);
187
+ background-size:5px 5px, 7px 7px, 9px 9px, 100% 100%;
188
+ background-position:0 0, 3px 4px, 0 0, 0 0;
189
+ }
190
+ .board__felt{ position:absolute; inset:0; pointer-events:none;
191
+ background:
192
+ radial-gradient(70% 50% at 50% 0%, rgba(245,208,138,.16), transparent 60%),
193
+ radial-gradient(120% 90% at 50% 45%, transparent 45%, rgba(0,0,0,.5) 100%);
194
+ }
195
+ .board__frame{ position:absolute; inset:6px; pointer-events:none; z-index:9;
196
+ box-shadow:inset 0 0 0 5px #241a0e, inset 0 0 0 8px #120d07, inset 0 0 40px 6px rgba(0,0,0,.5); }
197
+
198
+ .wall-item{ position:absolute; z-index:2; }
199
+ .pushpin{ position:absolute; top:-7px; left:50%; transform:translateX(-50%); width:11px; height:11px; z-index:3;
200
+ background:var(--amber-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--amber-1); }
201
+ .pushpin--ox{ background:var(--ox-3); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1); }
202
+ .pushpin--bone{ background:var(--bone-2); box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--bone-1); }
203
+ .wall-photo{ background:#efe9d6; padding:6px 6px 16px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
204
+ .wall-clip{ background:#e3ddc8; color:#231f16; padding:9px 11px; box-shadow:4px 6px 0 rgba(0,0,0,.45);
205
+ background-image:repeating-linear-gradient(0deg, transparent 0 9px, rgba(0,0,0,.05) 9px 10px); }
206
+ .wall-note{ background:var(--amber-2); color:#2a1f0c; padding:11px 12px; box-shadow:4px 6px 0 rgba(0,0,0,.4);
207
+ font-family:var(--f-body); }
208
+ .wall-note--bone{ background:#e7e0cc; color:#231f16; }
209
+ .wall-redact{ background:#1a1610; padding:10px; box-shadow:4px 6px 0 rgba(0,0,0,.45); }
210
+ .wall-redact .bar-blk{ height:7px; background:#0a0805; margin:4px 0; }
211
+ .scrawl{ font-family:var(--f-body); line-height:1.2; }
212
+ .pin-card{ position:absolute; cursor:default; user-select:none; z-index:4; transition:transform .05s; }
213
+ .pin-card .pin{
214
+ position:absolute; top:-9px; left:50%; transform:translateX(-50%);
215
+ width:12px; height:12px; background:var(--ox-3); z-index:6; cursor:grab;
216
+ box-shadow:0 0 0 2px var(--ink-0), inset -2px -2px 0 var(--ox-1);
217
+ }
218
+ .pin-card .pin:active{ cursor:grabbing; }
219
+ .card-grip{ display:flex; align-items:center; justify-content:space-between; gap:6px;
220
+ background:var(--ink-3); color:var(--bone-1); padding:2px 6px; cursor:grab;
221
+ box-shadow:inset 0 0 0 2px var(--ink-0); }
222
+ .card-grip:active{ cursor:grabbing; background:var(--slate-1); }
223
+ .card-grip__dots{ font-size:11px; line-height:1; letter-spacing:-1px; color:var(--amber-2); }
224
+ .card-grip__lbl{ font-family:var(--f-display); font-size:7px; letter-spacing:.12em; }
225
+ .card-actions{ margin-top:6px; display:flex; justify-content:center; }
226
+ .pin-card--sel{ filter:drop-shadow(0 0 14px rgba(224,164,76,.35)); }
227
+ .pin-note{ background:var(--bone-2); color:var(--ink-1); padding:8px 10px;
228
+ box-shadow:3px 4px 0 rgba(0,0,0,.4); font-family:var(--f-body); }
229
+ .board__filters{
230
+ position:absolute; top:12px; left:12px; right:12px; z-index:8;
231
+ display:flex; gap:6px; flex-wrap:wrap; align-items:center; pointer-events:none;
232
+ }
233
+ .board__filters > *{ pointer-events:auto; }
234
+
235
+ .board-layout{ flex:1; display:flex; min-height:0; }
236
+ .board-layout .board{ flex:1; }
237
+
238
+ /* board-wrap anchors the floating toolbar; board-scroll is the mobile pan viewport
239
+ over a tall virtual wall. Drags start only on the pin/grip handles, so touches on
240
+ the felt fall through to native scroll. */
241
+ .board-wrap{ position:relative; flex:1; display:flex; min-height:0; }
242
+ .board-scroll{ flex:1; overflow-y:auto; overflow-x:hidden;
243
+ overscroll-behavior:contain; -webkit-overflow-scrolling:touch; }
244
+ .board--mobile{ flex:none; width:100%; }
245
+ /* mobile: toolbar becomes a static bar above the scrolling wall (an overlay would
246
+ cover the topmost cards), so the wrap stacks vertically */
247
+ [data-mode="mobile"] .board-wrap{ flex-direction:column; }
248
+ [data-mode="mobile"] .board__filters{
249
+ position:static; padding:8px 10px; background:var(--ink-2);
250
+ box-shadow:inset 0 -3px 0 var(--ink-0); flex-shrink:0;
251
+ }
252
+ .pin-card .pin, .card-grip{ touch-action:none; }
253
+
254
+ /* ---------- connect-the-dots red yarn ---------- */
255
+ .thread-layer{ position:absolute; inset:0; z-index:5; pointer-events:none; }
256
+ .thread{ fill:none; stroke:var(--thread); stroke-width:2.5; stroke-linecap:round; }
257
+ .thread--under{ stroke:var(--thread-dk); stroke-width:4.5; opacity:.55; transform:translateY(2px); }
258
+ .thread--draw{ stroke-dasharray:1; stroke-dashoffset:1; animation:threadDraw .25s steps(8) forwards; }
259
+ @keyframes threadDraw{ to{ stroke-dashoffset:0; } }
260
+ .thread--ghost{ stroke-dasharray:5 4; opacity:.75; }
261
+ .thread-hit{ fill:none; stroke:#000; stroke-opacity:0; stroke-width:14; pointer-events:stroke; cursor:pointer; }
262
+ .thread-knot{ fill:var(--thread-dk); stroke:var(--ink-0); stroke-width:1.5; }
263
+ .pin-card--armed{ outline:3px solid var(--thread); outline-offset:2px;
264
+ filter:drop-shadow(0 0 12px rgba(194,59,59,.5)); }
265
+ .pin-card--armed .pin{ animation:pinPulse .7s steps(2) infinite; }
266
+ @keyframes pinPulse{ 50%{ transform:translateX(-50%) scale(1.6); } }
267
+ .board--connect .pin{ cursor:pointer; }
268
+ .board--connect .pin-note{ cursor:pointer; }
269
+ .card-grip--locked{ cursor:pointer; opacity:.55; }
270
+ .card-grip--locked:active{ background:var(--ink-3); }
271
+ .suspect-rail{ width:316px; flex-shrink:0; background:var(--ink-2); box-shadow:inset 4px 0 0 var(--ink-0);
272
+ display:flex; flex-direction:column; min-height:0; }
273
+ .suspect-rail__head{ padding:14px 14px 10px; box-shadow:inset 0 -3px 0 var(--ink-0); }
274
+ .suspect-rail__list{ flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
275
+ .rail-card{ cursor:pointer; }
276
+ .rail-card__reveal{ overflow:hidden; max-height:0; transition:max-height .25s steps(6); }
277
+ .rail-card__reveal--open{ max-height:200px; }
278
+
279
+ /* ============================================================
280
+ INTERROGATION
281
+ ============================================================ */
282
+ .interro{ flex:1; display:grid; grid-template-columns:minmax(300px,440px) 1fr; gap:0; overflow:hidden; }
283
+ .interro__stage{ position:relative; overflow:hidden; background:var(--ink-0); display:flex; flex-direction:column; }
284
+ .interro__sprite{ position:absolute; left:50%; bottom:0; transform:translateX(-50%); z-index:2; }
285
+ .interro__right{ display:flex; flex-direction:column; min-width:0; background:var(--ink-1);
286
+ box-shadow:inset 4px 0 0 var(--ink-0); }
287
+ .transcript{ flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; }
288
+ .tline{ max-width:88%; }
289
+ .tline--det{ align-self:flex-end; }
290
+ .tline--det .tline__b{ background:var(--slate-1); color:var(--bone-3); box-shadow:inset -2px -2px 0 rgba(0,0,0,.4); }
291
+ .tline--sus .tline__b{ background:var(--ink-3); color:var(--bone-2); box-shadow:inset 0 0 0 2px var(--ink-0); }
292
+ .tline__b{ padding:10px 12px; font-family:var(--f-body); font-size:15px; line-height:1.45; }
293
+ .tline__who{ font-family:var(--f-display); font-size:8px; letter-spacing:.1em; color:var(--bone-1); margin-bottom:4px; }
294
+ .tline--det .tline__who{ text-align:right; color:var(--amber-2); }
295
+ .composer{ flex-shrink:0; padding:12px; background:var(--ink-2); box-shadow:inset 0 3px 0 var(--ink-0); }
296
+ .qsuggest{ display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px; }
297
+ .qchip{ font-family:var(--f-body); font-size:13px; line-height:1.25; padding:7px 10px; background:var(--ink-1);
298
+ color:var(--bone-2); border:0; box-shadow:inset 0 0 0 2px var(--slate-1); text-align:left; flex:1 1 auto; min-width:0; }
299
+ .qchip:hover{ box-shadow:inset 0 0 0 2px var(--amber-2); color:var(--bone-3); }
300
+ .qchip:disabled{ opacity:.35; }
301
+ .composer__input{ display:flex; gap:8px; }
302
+ .pinput{ flex:1; background:var(--ink-1); border:0; color:var(--bone-3); padding:11px 12px;
303
+ font-size:15px; box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 rgba(0,0,0,.4); outline:none; }
304
+ .pinput::placeholder{ color:var(--bone-1); }
305
+ .pinput:focus{ box-shadow:inset 0 0 0 2px var(--amber-1); }
306
+
307
+ .tray{ position:absolute; inset:0; z-index:30; background:rgba(8,11,16,.82);
308
+ display:flex; flex-direction:column; justify-content:flex-end; }
309
+ .tray__sheet{ background:var(--ink-2); box-shadow:0 -4px 0 var(--ink-0), inset 0 4px 0 var(--amber-1);
310
+ padding:16px; max-height:70%; overflow-y:auto; animation:slideup .25s steps(6); }
311
+ @keyframes slideup{ from{ transform:translateY(100%);} to{ transform:translateY(0);} }
312
+ .tray__grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:10px; }
313
+
314
+ /* ============================================================
315
+ GENERIC SCREEN SCAFFOLD
316
+ ============================================================ */
317
+ .screen-pad{ flex:1; overflow-y:auto; padding:22px; }
318
+ .screen-center{ flex:1; display:flex; align-items:center; justify-content:center; padding:22px; overflow-y:auto; }
319
+ .maxw{ width:100%; max-width:1180px; margin:0 auto; }
320
+ .grid-2{ display:grid; grid-template-columns:1fr 1fr; gap:18px; }
321
+ .grid-3{ display:grid; grid-template-columns:repeat(3,1fr); gap:14px; }
322
+ .grid-4{ display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
323
+
324
+ /* ============================================================
325
+ TIMELINE
326
+ ============================================================ */
327
+ .tl-track{ position:relative; padding:30px 10px; }
328
+ .tl-line{ position:absolute; left:0; right:0; top:50%; height:4px;
329
+ background:repeating-linear-gradient(90deg,var(--slate-1) 0 6px, transparent 6px 12px); }
330
+ .tl-stop{ position:relative; z-index:2; display:flex; flex-direction:column; align-items:center; gap:6px; }
331
+ .tl-node{ width:14px; height:14px; background:var(--slate-2); box-shadow:0 0 0 3px var(--ink-0); }
332
+ .tl-node--lock{ background:var(--amber-2); }
333
+ .tl-node--conflict{ background:var(--ox-3); }
334
+ .tl-slot{ min-height:62px; }
335
+
336
+ /* ============================================================
337
+ FLASHBACK compare
338
+ ============================================================ */
339
+ .flash-grid{ display:grid; grid-template-columns:1fr 1fr; gap:16px; }
340
+ .flash-flag{ box-shadow:inset 0 0 0 2px var(--ox-2); }
341
+
342
+ /* ============================================================
343
+ RESPONSIVE
344
+ ============================================================ */
345
+ [data-mode="mobile"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
346
+ [data-mode="mobile"] .interro__stage{ height:32vh; min-height:200px; }
347
+ [data-mode="mobile"] .grid-2,[data-mode="mobile"] .grid-3,[data-mode="mobile"] .grid-4,[data-mode="mobile"] .flash-grid{ grid-template-columns:1fr; }
348
+ [data-mode="mobile"] .screen-pad{ padding:14px; }
349
+ [data-mode="mobile"] .hud{ padding:9px 11px; }
350
+
351
+ @media (max-width:760px){
352
+ .app[data-mode="auto"] .interro{ grid-template-columns:1fr; grid-template-rows:auto 1fr; }
353
+ .app[data-mode="auto"] .grid-2,.app[data-mode="auto"] .grid-3,.app[data-mode="auto"] .grid-4{ grid-template-columns:1fr; }
354
+ .app[data-mode="auto"] .hud__title{
355
+ font-size:10px; white-space:normal; overflow:hidden;
356
+ display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:2;
357
+ }
358
+ }
web/src/styles/pixel.css CHANGED
@@ -1,273 +1,276 @@
1
- /* ============================================================
2
- CASE ZERO — pixel-art noir design system
3
- Palette: blue-black shadows · desaturated teal/slate mids ·
4
- warm sodium-amber (single light source) · oxblood (danger) ·
5
- bone-white (key text)
6
- Fonts are self-hosted via @fontsource (imported in main.tsx) — no network at runtime.
7
- ============================================================ */
8
-
9
- /* ---------- palette variants ---------- */
10
- :root,
11
- [data-palette="sodium"]{
12
- --ink-0:#080b10; --ink-1:#0d1117; --ink-2:#11202a; --ink-3:#1b2d38;
13
- --slate-1:#2d4a52; --slate-2:#3a6b6b; --slate-3:#5d8a8a;
14
- --amber-1:#b9772f; --amber-2:#e0a44c; --amber-3:#f5d08a;
15
- --ox-1:#5e1c1c; --ox-2:#8a2a2a; --ox-3:#c23b3b;
16
- --bone-1:#9a937e; --bone-2:#e0d9c4; --bone-3:#f5f1e6;
17
- --glow:#e0a44c;
18
- }
19
- [data-palette="harbor"]{
20
- --ink-0:#060a0f; --ink-1:#0a0e14; --ink-2:#0f1d26; --ink-3:#16242e;
21
- --slate-1:#2c5151; --slate-2:#3a6b6b; --slate-3:#62989a;
22
- --amber-1:#c2842f; --amber-2:#f0b860; --amber-3:#ffd98f;
23
- --ox-1:#661f1f; --ox-2:#a33636; --ox-3:#d24a4a;
24
- --bone-1:#a39d88; --bone-2:#eae3cf; --bone-3:#f7f3e8;
25
- --glow:#f0b860;
26
- }
27
- [data-palette="violet"]{
28
- --ink-0:#08060e; --ink-1:#0e0c14; --ink-2:#171425; --ink-3:#221d33;
29
- --slate-1:#33344e; --slate-2:#46506b; --slate-3:#6b7396;
30
- --amber-1:#b4802f; --amber-2:#d99a3c; --amber-3:#f0c071;
31
- --ox-1:#5e1d2a; --ox-2:#922d3a; --ox-3:#c4485a;
32
- --bone-1:#9690a0; --bone-2:#e6e0d4; --bone-3:#f4f0ea;
33
- --glow:#d99a3c;
34
- }
35
-
36
- /* ---------- light precinct mood (daytime / bright bullpen) ---------- */
37
- [data-mood="day"]{
38
- --ink-0:#cdc6b2; --ink-1:#d8d2bf; --ink-2:#c6c0ac; --ink-3:#b8b29c;
39
- --slate-1:#8a9a96; --slate-2:#6f8a88; --slate-3:#577070;
40
- --bone-1:#5a5544; --bone-2:#2a2820; --bone-3:#15140f;
41
- --glow:#b9772f;
42
- }
43
-
44
- /* ---------- font pairing variants ---------- */
45
- :root,
46
- [data-fonts="crisp"]{
47
- --f-display:'Silkscreen', monospace;
48
- --f-mono:'VT323', monospace;
49
- --f-body:'Pixelify Sans', monospace;
50
- --mono-scale:1.25;
51
- }
52
- [data-fonts="terminal"]{
53
- --f-display:'VT323', monospace;
54
- --f-mono:'VT323', monospace;
55
- --f-body:'VT323', monospace;
56
- --mono-scale:1.35;
57
- }
58
- [data-fonts="stamp"]{
59
- --f-display:'Silkscreen', monospace;
60
- --f-mono:'Silkscreen', monospace;
61
- --f-body:'Pixelify Sans', monospace;
62
- --mono-scale:1.0;
63
- }
64
-
65
- /* ---------- reset ---------- */
66
- *{ box-sizing:border-box; margin:0; padding:0; }
67
- html,body{ height:100%; }
68
- body{
69
- background:var(--ink-0);
70
- color:var(--bone-2);
71
- font-family:var(--f-body);
72
- -webkit-font-smoothing:none;
73
- font-smooth:never;
74
- overflow:hidden;
75
- }
76
- img,canvas{ image-rendering:pixelated; image-rendering:crisp-edges; }
77
- button{ font-family:inherit; color:inherit; cursor:pointer; }
78
- input,textarea{ font-family:var(--f-body); }
79
- ::selection{ background:var(--amber-2); color:var(--ink-0); }
80
-
81
- /* ---------- type scale ---------- */
82
- .t-display{ font-family:var(--f-display); letter-spacing:.04em; line-height:1.05; text-transform:uppercase; }
83
- .t-mono{ font-family:var(--f-mono); letter-spacing:.02em; }
84
- .t-mono-lg{ font-family:var(--f-mono); font-size:calc(1.5rem * var(--mono-scale)); letter-spacing:.04em; }
85
- .t-label{ font-family:var(--f-display); font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--bone-1); }
86
- .t-body{ font-family:var(--f-body); font-size:17px; line-height:1.5; }
87
-
88
- .amber{ color:var(--amber-2); }
89
- .ox{ color:var(--ox-3); }
90
- .bone{ color:var(--bone-3); }
91
- .dim{ color:var(--bone-1); }
92
-
93
- /* ============================================================
94
- 9-SLICE PIXEL PANELS — beveled, no border-radius ever
95
- ============================================================ */
96
- .panel{
97
- position:relative;
98
- background:var(--ink-2);
99
- border:3px solid var(--ink-0);
100
- box-shadow:
101
- inset 0 0 0 3px var(--ink-3),
102
- inset 3px 3px 0 3px rgba(93,138,138,.12),
103
- inset -3px -3px 0 3px rgba(0,0,0,.45),
104
- 0 0 0 1px var(--ink-0);
105
- padding:18px;
106
- }
107
- .panel--raised{ background:var(--ink-3); }
108
- .panel--inset{
109
- background:var(--ink-1);
110
- box-shadow:
111
- inset 0 0 0 3px var(--ink-0),
112
- inset 3px 3px 0 3px rgba(0,0,0,.5),
113
- inset -3px -3px 0 3px rgba(93,138,138,.06);
114
- }
115
- .panel--amber{ border-color:var(--amber-1);
116
- box-shadow:inset 0 0 0 2px var(--amber-1), 0 0 24px -6px var(--glow); }
117
- .panel--ox{ border-color:var(--ox-1);
118
- box-shadow:inset 0 0 0 2px var(--ox-1), 0 0 22px -8px var(--ox-3); }
119
-
120
- .panel__tab{
121
- position:absolute; top:-11px; left:14px;
122
- background:var(--amber-2); color:var(--ink-0);
123
- font-family:var(--f-display); font-size:9px; letter-spacing:.12em;
124
- padding:4px 8px; text-transform:uppercase;
125
- box-shadow:2px 2px 0 var(--ink-0);
126
- }
127
-
128
- /* ---------- pixel buttons ---------- */
129
- .pbtn{
130
- font-family:var(--f-display); font-size:11px; letter-spacing:.08em;
131
- text-transform:uppercase; white-space:nowrap;
132
- color:var(--bone-3); background:var(--slate-1);
133
- border:0; padding:13px 18px;
134
- box-shadow:
135
- inset -3px -3px 0 0 rgba(0,0,0,.45),
136
- inset 3px 3px 0 0 rgba(255,255,255,.12),
137
- 0 0 0 3px var(--ink-0);
138
- transition:transform .04s steps(1);
139
- position:relative;
140
- }
141
- .pbtn:hover{ background:var(--slate-2); color:var(--ink-0); }
142
- .pbtn:active{
143
- transform:translate(2px,2px);
144
- box-shadow:
145
- inset 3px 3px 0 0 rgba(0,0,0,.4),
146
- 0 0 0 3px var(--ink-0);
147
- }
148
- .pbtn--amber{ background:var(--amber-2); color:var(--ink-0); }
149
- .pbtn--amber:hover{ background:var(--amber-3); }
150
- .pbtn--ox{ background:var(--ox-2); color:var(--bone-3); }
151
- .pbtn--ox:hover{ background:var(--ox-3); }
152
- .pbtn--ghost{ background:rgba(13,17,23,.66); color:var(--bone-1);
153
- box-shadow:inset 0 0 0 2px var(--slate-1); }
154
- .pbtn--ghost:hover{ color:var(--bone-3); background:rgba(13,17,23,.85); box-shadow:inset 0 0 0 2px var(--slate-3); }
155
- .pbtn:disabled{ opacity:.4; cursor:not-allowed; }
156
- .pbtn--sm{ font-size:9px; padding:8px 11px; }
157
-
158
- /* ---------- chips / tags ---------- */
159
- .chip{
160
- display:inline-flex; align-items:center; gap:6px; white-space:nowrap;
161
- font-family:var(--f-mono); font-size:calc(15px * var(--mono-scale));
162
- line-height:1; padding:4px 8px;
163
- background:var(--ink-1); color:var(--bone-1);
164
- box-shadow:inset 0 0 0 2px var(--slate-1);
165
- }
166
- .chip--amber{ color:var(--amber-2); box-shadow:inset 0 0 0 2px var(--amber-1); }
167
- .chip--ox{ color:var(--ox-3); box-shadow:inset 0 0 0 2px var(--ox-1); }
168
-
169
- /* ============================================================
170
- SUSPICION / COMPOSURE BAR
171
- ============================================================ */
172
- .bar{
173
- height:16px; background:var(--ink-1);
174
- box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 2px rgba(0,0,0,.5);
175
- position:relative; overflow:hidden;
176
- }
177
- .bar__fill{
178
- height:100%;
179
- background:
180
- repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
181
- linear-gradient(var(--ox-3), var(--ox-2));
182
- transition:width .5s steps(12);
183
- box-shadow:inset 0 2px 0 rgba(255,255,255,.18);
184
- }
185
- .bar__fill--calm{ background:
186
- repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
187
- linear-gradient(var(--slate-3), var(--slate-1)); }
188
-
189
- /* ============================================================
190
- ATMOSPHERE — scanlines, vignette, lamp glow, rain
191
- ============================================================ */
192
- .fx-layer{ position:fixed; inset:0; pointer-events:none; z-index:60; }
193
-
194
- .fx-scanlines{
195
- background:repeating-linear-gradient(
196
- to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
197
- rgba(0,0,0,.16) 3px, rgba(0,0,0,.16) 3px);
198
- mix-blend-mode:multiply;
199
- opacity:var(--fx-scan, .6);
200
- }
201
- .fx-vignette{
202
- background:radial-gradient(120% 90% at 50% 38%,
203
- transparent 40%, rgba(0,0,0,.4) 78%, rgba(0,0,0,.72) 100%);
204
- opacity:var(--fx-vig, 1);
205
- }
206
- .fx-flicker{ animation:flicker 6s steps(1) infinite; background:var(--glow); opacity:0; mix-blend-mode:overlay; }
207
- @keyframes flicker{
208
- 0%,97%,100%{ opacity:0; } 97.5%{ opacity:.05; } 98%{ opacity:.02; } 98.5%{ opacity:.06; }
209
- }
210
- [data-fx="low"]{ --fx-scan:.25; --fx-vig:.6; }
211
- [data-fx="med"]{ --fx-scan:.55; --fx-vig:1; }
212
- [data-fx="high"]{ --fx-scan:.85; --fx-vig:1.25; }
213
- [data-fx="low"] .fx-rain, [data-fx="low"] .fx-flicker{ display:none; }
214
-
215
- .fx-rain{ opacity:var(--fx-rain,.5); }
216
-
217
- @media (prefers-reduced-motion: reduce){
218
- .fx-flicker{ animation:none; }
219
- }
220
-
221
- /* ---------- blinking cursor ---------- */
222
- .cursor::after{
223
- content:'▮'; color:var(--amber-2);
224
- animation:blink 1s steps(1) infinite; margin-left:2px;
225
- }
226
- @keyframes blink{ 50%{ opacity:0; } }
227
-
228
- /* ---------- scrollbars ---------- */
229
- *::-webkit-scrollbar{ width:10px; height:10px; }
230
- *::-webkit-scrollbar-track{ background:var(--ink-1); }
231
- *::-webkit-scrollbar-thumb{ background:var(--slate-1); border:2px solid var(--ink-1); }
232
- *::-webkit-scrollbar-thumb:hover{ background:var(--slate-2); }
233
-
234
- /* ---------- pixel divider ---------- */
235
- .hr-pixel{
236
- height:3px; background:repeating-linear-gradient(90deg,
237
- var(--slate-1) 0 4px, transparent 4px 8px);
238
- border:0;
239
- }
240
-
241
- /* ---------- dithered fill helper ---------- */
242
- .dither-amber{
243
- background-image:
244
- radial-gradient(rgba(224,164,76,.5) 1px, transparent 1px);
245
- background-size:4px 4px;
246
- }
247
-
248
- /* ---------- stamp ---------- */
249
- .stamp{
250
- font-family:var(--f-display); text-transform:uppercase;
251
- color:var(--ox-3);
252
- border:4px solid var(--ox-3);
253
- padding:8px 14px; letter-spacing:.06em;
254
- box-shadow:0 0 0 2px var(--ink-0);
255
- transform:rotate(-6deg);
256
- mix-blend-mode:screen;
257
- }
258
- .stamp--slam{ animation:slam .45s cubic-bezier(.2,1.4,.3,1) both; }
259
- @keyframes slam{
260
- 0%{ transform:rotate(-6deg) scale(3); opacity:0; }
261
- 60%{ transform:rotate(-6deg) scale(.92); opacity:1; }
262
- 100%{ transform:rotate(-6deg) scale(1); opacity:1; }
263
- }
264
-
265
- /* utility */
266
- .row{ display:flex; gap:12px; }
267
- .col{ display:flex; flex-direction:column; gap:12px; }
268
- .center{ display:flex; align-items:center; justify-content:center; }
269
- .between{ display:flex; align-items:center; justify-content:space-between; }
270
- .wrap{ flex-wrap:wrap; }
271
- .grow{ flex:1; }
272
- .scroll-y{ overflow-y:auto; }
273
- .nowrap{ white-space:nowrap; }
 
 
 
 
1
+ /* ============================================================
2
+ CASE ZERO — pixel-art noir design system
3
+ Palette: blue-black shadows · desaturated teal/slate mids ·
4
+ warm sodium-amber (single light source) · oxblood (danger) ·
5
+ bone-white (key text)
6
+ Fonts are self-hosted via @fontsource (imported in main.tsx) — no network at runtime.
7
+ ============================================================ */
8
+
9
+ /* ---------- palette variants ---------- */
10
+ :root,
11
+ [data-palette="sodium"]{
12
+ --ink-0:#080b10; --ink-1:#0d1117; --ink-2:#11202a; --ink-3:#1b2d38;
13
+ --slate-1:#2d4a52; --slate-2:#3a6b6b; --slate-3:#5d8a8a;
14
+ --amber-1:#b9772f; --amber-2:#e0a44c; --amber-3:#f5d08a;
15
+ --ox-1:#5e1c1c; --ox-2:#8a2a2a; --ox-3:#c23b3b;
16
+ --bone-1:#9a937e; --bone-2:#e0d9c4; --bone-3:#f5f1e6;
17
+ --glow:#e0a44c;
18
+ --thread:#d24034; --thread-dk:#5e1c1c;
19
+ }
20
+ [data-palette="harbor"]{
21
+ --ink-0:#060a0f; --ink-1:#0a0e14; --ink-2:#0f1d26; --ink-3:#16242e;
22
+ --slate-1:#2c5151; --slate-2:#3a6b6b; --slate-3:#62989a;
23
+ --amber-1:#c2842f; --amber-2:#f0b860; --amber-3:#ffd98f;
24
+ --ox-1:#661f1f; --ox-2:#a33636; --ox-3:#d24a4a;
25
+ --bone-1:#a39d88; --bone-2:#eae3cf; --bone-3:#f7f3e8;
26
+ --glow:#f0b860;
27
+ --thread:#e04a3c; --thread-dk:#661f1f;
28
+ }
29
+ [data-palette="violet"]{
30
+ --ink-0:#08060e; --ink-1:#0e0c14; --ink-2:#171425; --ink-3:#221d33;
31
+ --slate-1:#33344e; --slate-2:#46506b; --slate-3:#6b7396;
32
+ --amber-1:#b4802f; --amber-2:#d99a3c; --amber-3:#f0c071;
33
+ --ox-1:#5e1d2a; --ox-2:#922d3a; --ox-3:#c4485a;
34
+ --bone-1:#9690a0; --bone-2:#e6e0d4; --bone-3:#f4f0ea;
35
+ --glow:#d99a3c;
36
+ --thread:#d24458; --thread-dk:#5e1d2a;
37
+ }
38
+
39
+ /* ---------- light precinct mood (daytime / bright bullpen) ---------- */
40
+ [data-mood="day"]{
41
+ --ink-0:#cdc6b2; --ink-1:#d8d2bf; --ink-2:#c6c0ac; --ink-3:#b8b29c;
42
+ --slate-1:#8a9a96; --slate-2:#6f8a88; --slate-3:#577070;
43
+ --bone-1:#5a5544; --bone-2:#2a2820; --bone-3:#15140f;
44
+ --glow:#b9772f;
45
+ }
46
+
47
+ /* ---------- font pairing variants ---------- */
48
+ :root,
49
+ [data-fonts="crisp"]{
50
+ --f-display:'Silkscreen', monospace;
51
+ --f-mono:'VT323', monospace;
52
+ --f-body:'Pixelify Sans', monospace;
53
+ --mono-scale:1.25;
54
+ }
55
+ [data-fonts="terminal"]{
56
+ --f-display:'VT323', monospace;
57
+ --f-mono:'VT323', monospace;
58
+ --f-body:'VT323', monospace;
59
+ --mono-scale:1.35;
60
+ }
61
+ [data-fonts="stamp"]{
62
+ --f-display:'Silkscreen', monospace;
63
+ --f-mono:'Silkscreen', monospace;
64
+ --f-body:'Pixelify Sans', monospace;
65
+ --mono-scale:1.0;
66
+ }
67
+
68
+ /* ---------- reset ---------- */
69
+ *{ box-sizing:border-box; margin:0; padding:0; }
70
+ html,body{ height:100%; }
71
+ body{
72
+ background:var(--ink-0);
73
+ color:var(--bone-2);
74
+ font-family:var(--f-body);
75
+ -webkit-font-smoothing:none;
76
+ font-smooth:never;
77
+ overflow:hidden;
78
+ }
79
+ img,canvas{ image-rendering:pixelated; image-rendering:crisp-edges; }
80
+ button{ font-family:inherit; color:inherit; cursor:pointer; }
81
+ input,textarea{ font-family:var(--f-body); }
82
+ ::selection{ background:var(--amber-2); color:var(--ink-0); }
83
+
84
+ /* ---------- type scale ---------- */
85
+ .t-display{ font-family:var(--f-display); letter-spacing:.04em; line-height:1.05; text-transform:uppercase; }
86
+ .t-mono{ font-family:var(--f-mono); letter-spacing:.02em; }
87
+ .t-mono-lg{ font-family:var(--f-mono); font-size:calc(1.5rem * var(--mono-scale)); letter-spacing:.04em; }
88
+ .t-label{ font-family:var(--f-display); font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--bone-1); }
89
+ .t-body{ font-family:var(--f-body); font-size:17px; line-height:1.5; }
90
+
91
+ .amber{ color:var(--amber-2); }
92
+ .ox{ color:var(--ox-3); }
93
+ .bone{ color:var(--bone-3); }
94
+ .dim{ color:var(--bone-1); }
95
+
96
+ /* ============================================================
97
+ 9-SLICE PIXEL PANELS — beveled, no border-radius ever
98
+ ============================================================ */
99
+ .panel{
100
+ position:relative;
101
+ background:var(--ink-2);
102
+ border:3px solid var(--ink-0);
103
+ box-shadow:
104
+ inset 0 0 0 3px var(--ink-3),
105
+ inset 3px 3px 0 3px rgba(93,138,138,.12),
106
+ inset -3px -3px 0 3px rgba(0,0,0,.45),
107
+ 0 0 0 1px var(--ink-0);
108
+ padding:18px;
109
+ }
110
+ .panel--raised{ background:var(--ink-3); }
111
+ .panel--inset{
112
+ background:var(--ink-1);
113
+ box-shadow:
114
+ inset 0 0 0 3px var(--ink-0),
115
+ inset 3px 3px 0 3px rgba(0,0,0,.5),
116
+ inset -3px -3px 0 3px rgba(93,138,138,.06);
117
+ }
118
+ .panel--amber{ border-color:var(--amber-1);
119
+ box-shadow:inset 0 0 0 2px var(--amber-1), 0 0 24px -6px var(--glow); }
120
+ .panel--ox{ border-color:var(--ox-1);
121
+ box-shadow:inset 0 0 0 2px var(--ox-1), 0 0 22px -8px var(--ox-3); }
122
+
123
+ .panel__tab{
124
+ position:absolute; top:-11px; left:14px;
125
+ background:var(--amber-2); color:var(--ink-0);
126
+ font-family:var(--f-display); font-size:9px; letter-spacing:.12em;
127
+ padding:4px 8px; text-transform:uppercase;
128
+ box-shadow:2px 2px 0 var(--ink-0);
129
+ }
130
+
131
+ /* ---------- pixel buttons ---------- */
132
+ .pbtn{
133
+ font-family:var(--f-display); font-size:11px; letter-spacing:.08em;
134
+ text-transform:uppercase; white-space:nowrap;
135
+ color:var(--bone-3); background:var(--slate-1);
136
+ border:0; padding:13px 18px;
137
+ box-shadow:
138
+ inset -3px -3px 0 0 rgba(0,0,0,.45),
139
+ inset 3px 3px 0 0 rgba(255,255,255,.12),
140
+ 0 0 0 3px var(--ink-0);
141
+ transition:transform .04s steps(1);
142
+ position:relative;
143
+ }
144
+ .pbtn:hover{ background:var(--slate-2); color:var(--ink-0); }
145
+ .pbtn:active{
146
+ transform:translate(2px,2px);
147
+ box-shadow:
148
+ inset 3px 3px 0 0 rgba(0,0,0,.4),
149
+ 0 0 0 3px var(--ink-0);
150
+ }
151
+ .pbtn--amber{ background:var(--amber-2); color:var(--ink-0); }
152
+ .pbtn--amber:hover{ background:var(--amber-3); }
153
+ .pbtn--ox{ background:var(--ox-2); color:var(--bone-3); }
154
+ .pbtn--ox:hover{ background:var(--ox-3); }
155
+ .pbtn--ghost{ background:rgba(13,17,23,.66); color:var(--bone-1);
156
+ box-shadow:inset 0 0 0 2px var(--slate-1); }
157
+ .pbtn--ghost:hover{ color:var(--bone-3); background:rgba(13,17,23,.85); box-shadow:inset 0 0 0 2px var(--slate-3); }
158
+ .pbtn:disabled{ opacity:.4; cursor:not-allowed; }
159
+ .pbtn--sm{ font-size:9px; padding:8px 11px; }
160
+
161
+ /* ---------- chips / tags ---------- */
162
+ .chip{
163
+ display:inline-flex; align-items:center; gap:6px; white-space:nowrap;
164
+ font-family:var(--f-mono); font-size:calc(15px * var(--mono-scale));
165
+ line-height:1; padding:4px 8px;
166
+ background:var(--ink-1); color:var(--bone-1);
167
+ box-shadow:inset 0 0 0 2px var(--slate-1);
168
+ }
169
+ .chip--amber{ color:var(--amber-2); box-shadow:inset 0 0 0 2px var(--amber-1); }
170
+ .chip--ox{ color:var(--ox-3); box-shadow:inset 0 0 0 2px var(--ox-1); }
171
+
172
+ /* ============================================================
173
+ SUSPICION / COMPOSURE BAR
174
+ ============================================================ */
175
+ .bar{
176
+ height:16px; background:var(--ink-1);
177
+ box-shadow:inset 0 0 0 2px var(--ink-0), inset 2px 2px 0 2px rgba(0,0,0,.5);
178
+ position:relative; overflow:hidden;
179
+ }
180
+ .bar__fill{
181
+ height:100%;
182
+ background:
183
+ repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
184
+ linear-gradient(var(--ox-3), var(--ox-2));
185
+ transition:width .5s steps(12);
186
+ box-shadow:inset 0 2px 0 rgba(255,255,255,.18);
187
+ }
188
+ .bar__fill--calm{ background:
189
+ repeating-linear-gradient(90deg, transparent 0 6px, rgba(0,0,0,.18) 6px 8px),
190
+ linear-gradient(var(--slate-3), var(--slate-1)); }
191
+
192
+ /* ============================================================
193
+ ATMOSPHERE — scanlines, vignette, lamp glow, rain
194
+ ============================================================ */
195
+ .fx-layer{ position:fixed; inset:0; pointer-events:none; z-index:60; }
196
+
197
+ .fx-scanlines{
198
+ background:repeating-linear-gradient(
199
+ to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px,
200
+ rgba(0,0,0,.16) 3px, rgba(0,0,0,.16) 3px);
201
+ mix-blend-mode:multiply;
202
+ opacity:var(--fx-scan, .6);
203
+ }
204
+ .fx-vignette{
205
+ background:radial-gradient(120% 90% at 50% 38%,
206
+ transparent 40%, rgba(0,0,0,.4) 78%, rgba(0,0,0,.72) 100%);
207
+ opacity:var(--fx-vig, 1);
208
+ }
209
+ .fx-flicker{ animation:flicker 6s steps(1) infinite; background:var(--glow); opacity:0; mix-blend-mode:overlay; }
210
+ @keyframes flicker{
211
+ 0%,97%,100%{ opacity:0; } 97.5%{ opacity:.05; } 98%{ opacity:.02; } 98.5%{ opacity:.06; }
212
+ }
213
+ [data-fx="low"]{ --fx-scan:.25; --fx-vig:.6; }
214
+ [data-fx="med"]{ --fx-scan:.55; --fx-vig:1; }
215
+ [data-fx="high"]{ --fx-scan:.85; --fx-vig:1.25; }
216
+ [data-fx="low"] .fx-rain, [data-fx="low"] .fx-flicker{ display:none; }
217
+
218
+ .fx-rain{ opacity:var(--fx-rain,.5); }
219
+
220
+ @media (prefers-reduced-motion: reduce){
221
+ .fx-flicker{ animation:none; }
222
+ }
223
+
224
+ /* ---------- blinking cursor ---------- */
225
+ .cursor::after{
226
+ content:'▮'; color:var(--amber-2);
227
+ animation:blink 1s steps(1) infinite; margin-left:2px;
228
+ }
229
+ @keyframes blink{ 50%{ opacity:0; } }
230
+
231
+ /* ---------- scrollbars ---------- */
232
+ *::-webkit-scrollbar{ width:10px; height:10px; }
233
+ *::-webkit-scrollbar-track{ background:var(--ink-1); }
234
+ *::-webkit-scrollbar-thumb{ background:var(--slate-1); border:2px solid var(--ink-1); }
235
+ *::-webkit-scrollbar-thumb:hover{ background:var(--slate-2); }
236
+
237
+ /* ---------- pixel divider ---------- */
238
+ .hr-pixel{
239
+ height:3px; background:repeating-linear-gradient(90deg,
240
+ var(--slate-1) 0 4px, transparent 4px 8px);
241
+ border:0;
242
+ }
243
+
244
+ /* ---------- dithered fill helper ---------- */
245
+ .dither-amber{
246
+ background-image:
247
+ radial-gradient(rgba(224,164,76,.5) 1px, transparent 1px);
248
+ background-size:4px 4px;
249
+ }
250
+
251
+ /* ---------- stamp ---------- */
252
+ .stamp{
253
+ font-family:var(--f-display); text-transform:uppercase;
254
+ color:var(--ox-3);
255
+ border:4px solid var(--ox-3);
256
+ padding:8px 14px; letter-spacing:.06em;
257
+ box-shadow:0 0 0 2px var(--ink-0);
258
+ transform:rotate(-6deg);
259
+ mix-blend-mode:screen;
260
+ }
261
+ .stamp--slam{ animation:slam .45s cubic-bezier(.2,1.4,.3,1) both; }
262
+ @keyframes slam{
263
+ 0%{ transform:rotate(-6deg) scale(3); opacity:0; }
264
+ 60%{ transform:rotate(-6deg) scale(.92); opacity:1; }
265
+ 100%{ transform:rotate(-6deg) scale(1); opacity:1; }
266
+ }
267
+
268
+ /* utility */
269
+ .row{ display:flex; gap:12px; }
270
+ .col{ display:flex; flex-direction:column; gap:12px; }
271
+ .center{ display:flex; align-items:center; justify-content:center; }
272
+ .between{ display:flex; align-items:center; justify-content:space-between; }
273
+ .wrap{ flex-wrap:wrap; }
274
+ .grow{ flex:1; }
275
+ .scroll-y{ overflow-y:auto; }
276
+ .nowrap{ white-space:nowrap; }
web/src/ui/components.tsx CHANGED
@@ -1,409 +1,407 @@
1
- // UI kit — reusable pixel components. Ported from prototype/js/ui.jsx, wired to the
2
- // store + procedural art modules instead of globals.
3
- import type { ComponentChildren, JSX } from 'preact'
4
- import { useEffect, useState } from 'preact/hooks'
5
-
6
- import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
7
- import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
8
- import { useGame } from '../store'
9
- import type { Evidence, Suspect } from '../types'
10
- import { type Sfx, musicIsPlaying, playSfx, toggleMusic } from './audio'
11
- import { TweaksSheet } from './tweaks'
12
-
13
- const pxScaled = (px: number, floor = 2) =>
14
- Math.max(floor, Math.round(px * (window.__pxScale || 1)))
15
-
16
- // What the culprit is called in this kind of case ("name the killer / thief / arsonist").
17
- const PERP: Record<string, string> = {
18
- homicide: 'killer', theft: 'thief', fraud: 'fraudster',
19
- blackmail: 'blackmailer', arson: 'arsonist', missing_person: 'abductor',
20
- }
21
- export const perpNoun = (kind?: string): string => PERP[kind || 'homicide'] || 'culprit'
22
-
23
- // ---- sprite wrappers ----
24
- export function useBlink(): boolean {
25
- const [b, setB] = useState(false)
26
- useEffect(() => {
27
- let t1: ReturnType<typeof setTimeout>
28
- let t2: ReturnType<typeof setTimeout>
29
- let alive = true
30
- const loop = () => {
31
- const wait = 2400 + Math.random() * 3200
32
- t1 = setTimeout(() => {
33
- if (!alive) return
34
- setB(true)
35
- t2 = setTimeout(() => {
36
- if (alive) {
37
- setB(false)
38
- loop()
39
- }
40
- }, 120)
41
- }, wait)
42
- }
43
- loop()
44
- return () => {
45
- alive = false
46
- clearTimeout(t1)
47
- clearTimeout(t2)
48
- }
49
- }, [])
50
- return b
51
- }
52
-
53
- interface SpriteProps {
54
- id: string
55
- px?: number
56
- style?: JSX.CSSProperties
57
- className?: string
58
- }
59
- // Toggle the mouth open/closed at a speech-like cadence while the suspect's voice plays.
60
- function useMouth(active: boolean): boolean {
61
- const [open, setOpen] = useState(false)
62
- useEffect(() => {
63
- if (!active) {
64
- setOpen(false)
65
- return
66
- }
67
- const id = setInterval(() => setOpen((o) => !o), 135)
68
- return () => clearInterval(id)
69
- }, [active])
70
- return open
71
- }
72
- export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string }) {
73
- const p = portraitFor(id, gender)
74
- const b = useBlink()
75
- const mouth = useMouth(talking)
76
- let frame = p.frames[0]
77
- if (talking && p.frames[2]) frame = mouth ? p.frames[2] : p.frames[0]
78
- else if (blink && b) frame = p.frames[1]
79
- return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
80
- }
81
- export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) {
82
- const p = bodyFor(id, gender)
83
- return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
84
- }
85
- export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
86
- const m = EV_ICONS[icon] || EV_ICONS.photoEv
87
- return <PixelCanvas map={m} pal={IPAL} px={pxScaled(px, 1)} style={style} />
88
- }
89
- interface SceneProps {
90
- name: string
91
- w?: number
92
- h?: number
93
- anim?: boolean
94
- full?: boolean
95
- cover?: boolean
96
- style?: JSX.CSSProperties
97
- className?: string
98
- deps?: unknown[]
99
- }
100
- export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, style, className, deps = [] }: SceneProps) {
101
- const st = cover ? { objectFit: 'cover' as const, ...style } : style
102
- return (
103
- <SceneCanvas
104
- paint={sceneFor(name)}
105
- w={w}
106
- h={h}
107
- anim={anim}
108
- full={full}
109
- style={st}
110
- className={className}
111
- deps={[name, anim, full, ...deps]}
112
- />
113
- )
114
- }
115
-
116
- interface ExhibitArtProps {
117
- e: Pick<Evidence, 'id' | 'name' | 'summary'>
118
- w?: number
119
- h?: number
120
- style?: JSX.CSSProperties
121
- className?: string
122
- }
123
- /** Procedural "evidence photo" of one exhibit - the object itself, on the forensic
124
- * table, classified from the exhibit's own words and seeded by its id. */
125
- export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtProps) {
126
- return (
127
- <SceneCanvas
128
- paint={exhibitPainter(e.name, e.summary || '', e.id)}
129
- w={w}
130
- h={h}
131
- style={style}
132
- className={className}
133
- deps={[e.id, e.name]}
134
- />
135
- )
136
- }
137
-
138
- // ---- primitives ----
139
- interface PanelProps {
140
- tab?: string
141
- variant?: 'amber' | 'ox' | 'raised' | 'inset'
142
- className?: string
143
- style?: JSX.CSSProperties
144
- children?: ComponentChildren
145
- onClick?: (e: MouseEvent) => void
146
- }
147
- export function Panel({ tab, variant, className = '', style, children, onClick }: PanelProps) {
148
- const v = variant ? ` panel--${variant}` : ''
149
- return (
150
- <div class={`panel${v} ${className}`} style={style} onClick={onClick}>
151
- {tab && <span class="panel__tab">{tab}</span>}
152
- {children}
153
- </div>
154
- )
155
- }
156
-
157
- type BtnProps = {
158
- variant?: 'amber' | 'ox' | 'ghost'
159
- sm?: boolean
160
- className?: string
161
- children?: ComponentChildren
162
- /** SFX on click: a specific cue, or null to silence. Defaults to "click". */
163
- sfx?: Sfx | null
164
- } & JSX.HTMLAttributes<HTMLButtonElement>
165
- export function Btn({ variant, sm, className = '', children, sfx, onClick, ...rest }: BtnProps) {
166
- const v = variant ? ` pbtn--${variant}` : ''
167
- const handle = (e: MouseEvent) => {
168
- if (sfx !== null) playSfx(sfx || 'click')
169
- ;(onClick as ((ev: MouseEvent) => void) | undefined)?.(e)
170
- }
171
- return (
172
- <button class={`pbtn${v}${sm ? ' pbtn--sm' : ''} ${className}`} onClick={handle} {...rest}>
173
- {children}
174
- </button>
175
- )
176
- }
177
-
178
- export function Chip({ variant, children, style }: { variant?: 'amber' | 'ox'; children?: ComponentChildren; style?: JSX.CSSProperties }) {
179
- const v = variant ? ` chip--${variant}` : ''
180
- return <span class={`chip${v}`} style={style}>{children}</span>
181
- }
182
-
183
- export function Stamp({ children, slam, style }: { children?: ComponentChildren; slam?: boolean; style?: JSX.CSSProperties }) {
184
- return <span class={`stamp${slam ? ' stamp--slam' : ''}`} style={style}>{children}</span>
185
- }
186
-
187
- // ---- suspicion / composure bar ----
188
- export function SuspicionBar({ value, label = 'SUSPICION', compact }: { value: number; label?: string | null; compact?: boolean }) {
189
- const v = Math.max(0, Math.min(100, Math.round(value)))
190
- const calm = v < 42
191
- return (
192
- <div class="col" style={{ gap: compact ? 3 : 5 }}>
193
- {!compact && label && (
194
- <div class="between">
195
- <span class="t-label">{label}</span>
196
- <span class="t-mono" style={{ color: calm ? 'var(--slate-3)' : 'var(--ox-3)', fontSize: 'calc(14px*var(--mono-scale))' }}>{v}%</span>
197
- </div>
198
- )}
199
- <div class="bar" style={compact ? { height: 10 } : undefined}>
200
- <div class={'bar__fill' + (calm ? ' bar__fill--calm' : '')} style={{ width: v + '%' }} />
201
- </div>
202
- </div>
203
- )
204
- }
205
-
206
- // ---- suspect card ----
207
- export function SuspectCard({ s, onClick, active, mini }: { s: Suspect; onClick?: (e: MouseEvent) => void; active?: boolean; mini?: boolean }) {
208
- const g = useGame()
209
- const susp = g.state.suspicion[s.id]
210
- return (
211
- <Panel
212
- className="suspect-card"
213
- variant={active ? 'amber' : undefined}
214
- style={{ padding: mini ? 8 : 12, cursor: onClick ? 'pointer' : 'default', minWidth: mini ? 0 : 168 }}
215
- onClick={onClick}
216
- >
217
- <div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
218
- <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
219
- <Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} />
220
- </div>
221
- <div class="col grow" style={{ gap: 5, minWidth: 0 }}>
222
- <div>
223
- <div class="t-display" style={{ fontSize: mini ? 9 : 11, color: 'var(--bone-3)' }}>{s.name}</div>
224
- <div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
225
- </div>
226
- {!mini && <div class="t-body dim" style={{ fontSize: 12, lineHeight: 1.35 }}>{s.role}</div>}
227
- <SuspicionBar value={susp} compact />
228
- </div>
229
- </div>
230
- </Panel>
231
- )
232
- }
233
-
234
- // ---- evidence card with "develops in" reveal ----
235
- export function EvidenceCard({ e, onClick, active, develop, small }: { e: Evidence; onClick?: (ev: MouseEvent) => void; active?: boolean; develop?: boolean; small?: boolean }) {
236
- const [revealed, setRevealed] = useState(!develop)
237
- useEffect(() => {
238
- if (develop) {
239
- const t = setTimeout(() => setRevealed(true), 60)
240
- return () => clearTimeout(t)
241
- }
242
- }, [develop])
243
- return (
244
- <Panel
245
- variant={active ? 'amber' : undefined}
246
- className="ev-card"
247
- style={{ padding: small ? 8 : 11, cursor: onClick ? 'pointer' : 'default', position: 'relative', overflow: 'hidden' }}
248
- onClick={onClick}
249
- >
250
- <div class="row" style={{ gap: 10, alignItems: 'center' }}>
251
- <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 4, flexShrink: 0 }}>
252
- <EvIcon icon={e.icon} px={small ? 2 : 3} />
253
- </div>
254
- <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
255
- <div class="t-display" style={{ fontSize: small ? 8 : 10, color: 'var(--bone-3)' }}>{e.name}</div>
256
- <div class="row" style={{ gap: 6, alignItems: 'center' }}>
257
- <Chip variant="amber" style={{ fontSize: 'calc(12px*var(--mono-scale))', padding: '2px 5px' }}>{e.type}</Chip>
258
- <span class="t-mono dim nowrap" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.time}</span>
259
- </div>
260
- </div>
261
- </div>
262
- {develop && !revealed && <span class="develop-veil" />}
263
- {develop && <span class="develop-veil develop-veil--anim" />}
264
- </Panel>
265
- )
266
- }
267
-
268
- // ---- dialogue / speech panel with typewriter ----
269
- export function DialoguePanel({ who, text, speed = 26, onDone, instant, tag }: { who: string; text: string; speed?: number; onDone?: () => void; instant?: boolean; tag?: string | null }) {
270
- const [out, done] = useTypewriter(text, speed, !instant)
271
- useEffect(() => {
272
- if (done && onDone) onDone()
273
- }, [done])
274
- return (
275
- <Panel className="dialogue" style={{ padding: 16 }}>
276
- <div class="between" style={{ marginBottom: 8 }}>
277
- <span class="t-display amber" style={{ fontSize: 11 }}>{who}</span>
278
- {tag && <Chip variant="ox">{tag}</Chip>}
279
- </div>
280
- <div class="t-body" style={{ minHeight: 56, color: 'var(--bone-2)' }}>
281
- {out}
282
- {!done && <span class="cursor" />}
283
- </div>
284
- </Panel>
285
- )
286
- }
287
-
288
- // ---- type a string once, fire onDone ----
289
- export function TypeOnce({ text, speed, onDone }: { text: string; speed: number; onDone?: () => void }) {
290
- const [out, done] = useTypewriter(text, speed, true)
291
- const [fired, setFired] = useState(false)
292
- useEffect(() => {
293
- if (done && !fired) {
294
- setFired(true)
295
- onDone?.()
296
- }
297
- }, [done])
298
- return (
299
- <>
300
- {out}
301
- {!done && <span class="cursor" />}
302
- </>
303
- )
304
- }
305
-
306
- // ---- HINT trigger button ----
307
- export function HintButton() {
308
- return (
309
- <button class="hint-btn" onClick={() => window.dispatchEvent(new Event('toggle-hint'))} title="Ask your partner">
310
- <span class="hint-btn__dot" /> HINT
311
- </button>
312
- )
313
- }
314
-
315
- // ---- top HUD bar ----
316
- // Navbar controls: optional Main-menu, Music toggle, Settings sheet. Compact icon buttons so
317
- // they sit gracefully in the HUD bar (and on mobile) instead of floating mid-screen.
318
- export function Controls({ menu = false }: { menu?: boolean }) {
319
- const g = useGame()
320
- const [musicOn, setMusicOn] = useState(musicIsPlaying())
321
- const [settings, setSettings] = useState(false)
322
- const icon = (label: string, title: string, on: boolean, onClick: () => void) => (
323
- <button
324
- class="hint-btn"
325
- title={title}
326
- aria-label={title}
327
- onClick={onClick}
328
- style={{ width: 30, height: 28, padding: 0, justifyContent: 'center', fontSize: '0.95rem', opacity: on ? 1 : 0.5 }}
329
- >
330
- {label}
331
- </button>
332
- )
333
- return (
334
- <div class="row" style={{ gap: 5, alignItems: 'center' }}>
335
- {menu && <Btn sm variant="ghost" onClick={() => g.nav('title')}>Menu</Btn>}
336
- {icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
337
- {icon('⚙', 'Settings', true, () => setSettings(true))}
338
- {settings && <TweaksSheet onClose={() => setSettings(false)} />}
339
- </div>
340
- )
341
- }
342
-
343
- export function Hud({ title, sub, right }: { title: string; sub?: string; right?: ComponentChildren }) {
344
- const g = useGame()
345
- return (
346
- <div class="hud">
347
- <div class="row" style={{ gap: 12, alignItems: 'center', minWidth: 0 }}>
348
- <button class="hud-badge" onClick={() => g.nav('board')} title="Investigation Board">
349
- <span class="t-display" style={{ fontSize: 9, color: 'var(--ink-0)' }}>CASE</span>
350
- <span class="t-mono" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: 'var(--ink-0)' }}>{g.case.id}</span>
351
- </button>
352
- <div class="col" style={{ gap: 2, minWidth: 0 }}>
353
- <div class="t-display hud__title">{title}</div>
354
- {sub && <div class="t-label nowrap">{sub}</div>}
355
- </div>
356
- </div>
357
- <div class="row" style={{ gap: 6, alignItems: 'center' }}>
358
- {right}
359
- <Controls menu />
360
- <HintButton />
361
- </div>
362
- </div>
363
- )
364
- }
365
-
366
- // ---- bottom nav (mobile) ----
367
- const NAV_ITEMS = [
368
- { id: 'briefing', label: 'CASE', icon: 'file' },
369
- { id: 'board', label: 'BOARD', icon: 'board' },
370
- { id: 'suspects', label: 'SUSPECTS', icon: 'people' },
371
- { id: 'evidence', label: 'EVIDENCE', icon: 'box' },
372
- { id: 'timeline', label: 'TIME', icon: 'clock' },
373
- ] as const
374
-
375
- function NavGlyph({ icon, on }: { icon: string; on: boolean }) {
376
- const c = on ? 'var(--ink-0)' : 'var(--bone-1)'
377
- const P = (d: JSX.CSSProperties) => <span style={{ position: 'absolute', ...d }} />
378
- return (
379
- <span style={{ position: 'relative', width: 18, height: 18, display: 'inline-block' }}>
380
- {icon === 'file' && (<>{P({ left: 4, top: 2, width: 10, height: 14, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 6, width: 6, height: 2, background: c })}{P({ left: 6, top: 10, width: 6, height: 2, background: c })}</>)}
381
- {icon === 'board' && (<>{P({ left: 2, top: 3, width: 14, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 7, width: 3, height: 3, background: c })}{P({ left: 11, top: 9, width: 3, height: 3, background: c })}</>)}
382
- {icon === 'people' && (<>{P({ left: 3, top: 3, width: 5, height: 5, background: c })}{P({ left: 3, top: 9, width: 7, height: 6, background: c })}{P({ left: 11, top: 4, width: 4, height: 4, background: c })}{P({ left: 10, top: 9, width: 6, height: 6, background: c })}</>)}
383
- {icon === 'box' && (<>{P({ left: 3, top: 4, width: 12, height: 11, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 3, top: 4, width: 12, height: 2, background: c })}{P({ left: 8, top: 8, width: 2, height: 4, background: c })}</>)}
384
- {icon === 'clock' && (<>{P({ left: 3, top: 3, width: 12, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 8, top: 6, width: 2, height: 4, background: c })}{P({ left: 8, top: 9, width: 4, height: 2, background: c })}</>)}
385
- </span>
386
- )
387
- }
388
-
389
- export function BottomNav() {
390
- const g = useGame()
391
- const cur = g.state.screen
392
- return (
393
- <nav class="bottom-nav">
394
- {NAV_ITEMS.map((it) => {
395
- const on = cur === it.id || (it.id === 'suspects' && cur === 'interro')
396
- return (
397
- <button
398
- key={it.id}
399
- class={'nav-btn' + (on ? ' nav-btn--on' : '')}
400
- onClick={() => g.nav((it.id === 'suspects' ? 'board' : it.id) as Parameters<typeof g.nav>[0])}
401
- >
402
- <NavGlyph icon={it.icon} on={on} />
403
- <span class="t-display" style={{ fontSize: 7, letterSpacing: '.06em' }}>{it.label}</span>
404
- </button>
405
- )
406
- })}
407
- </nav>
408
- )
409
- }
 
1
+ // UI kit — reusable pixel components. Ported from prototype/js/ui.jsx, wired to the
2
+ // store + procedural art modules instead of globals.
3
+ import type { ComponentChildren, JSX } from 'preact'
4
+ import { useEffect, useState } from 'preact/hooks'
5
+
6
+ import { EV_ICONS, IPAL, bodyFor, exhibitPainter, portraitFor, sceneFor } from '../engine/art'
7
+ import { PixelCanvas, SceneCanvas, useTypewriter } from '../engine/pixel'
8
+ import { useGame } from '../store'
9
+ import type { Evidence, Suspect } from '../types'
10
+ import { type Sfx, musicIsPlaying, playSfx, toggleMusic } from './audio'
11
+
12
+ const pxScaled = (px: number, floor = 2) =>
13
+ Math.max(floor, Math.round(px * (window.__pxScale || 1)))
14
+
15
+ // What the culprit is called in this kind of case ("name the killer / thief / arsonist").
16
+ const PERP: Record<string, string> = {
17
+ homicide: 'killer', theft: 'thief', fraud: 'fraudster',
18
+ blackmail: 'blackmailer', arson: 'arsonist', missing_person: 'abductor',
19
+ }
20
+ export const perpNoun = (kind?: string): string => PERP[kind || 'homicide'] || 'culprit'
21
+
22
+ // ---- sprite wrappers ----
23
+ export function useBlink(): boolean {
24
+ const [b, setB] = useState(false)
25
+ useEffect(() => {
26
+ let t1: ReturnType<typeof setTimeout>
27
+ let t2: ReturnType<typeof setTimeout>
28
+ let alive = true
29
+ const loop = () => {
30
+ const wait = 2400 + Math.random() * 3200
31
+ t1 = setTimeout(() => {
32
+ if (!alive) return
33
+ setB(true)
34
+ t2 = setTimeout(() => {
35
+ if (alive) {
36
+ setB(false)
37
+ loop()
38
+ }
39
+ }, 120)
40
+ }, wait)
41
+ }
42
+ loop()
43
+ return () => {
44
+ alive = false
45
+ clearTimeout(t1)
46
+ clearTimeout(t2)
47
+ }
48
+ }, [])
49
+ return b
50
+ }
51
+
52
+ interface SpriteProps {
53
+ id: string
54
+ px?: number
55
+ style?: JSX.CSSProperties
56
+ className?: string
57
+ }
58
+ // Toggle the mouth open/closed at a speech-like cadence while the suspect's voice plays.
59
+ function useMouth(active: boolean): boolean {
60
+ const [open, setOpen] = useState(false)
61
+ useEffect(() => {
62
+ if (!active) {
63
+ setOpen(false)
64
+ return
65
+ }
66
+ const id = setInterval(() => setOpen((o) => !o), 135)
67
+ return () => clearInterval(id)
68
+ }, [active])
69
+ return open
70
+ }
71
+ export function Portrait({ id, px = 6, blink = true, talking = false, style, className, gender }: SpriteProps & { blink?: boolean; talking?: boolean; gender?: string }) {
72
+ const p = portraitFor(id, gender)
73
+ const b = useBlink()
74
+ const mouth = useMouth(talking)
75
+ let frame = p.frames[0]
76
+ if (talking && p.frames[2]) frame = mouth ? p.frames[2] : p.frames[0]
77
+ else if (blink && b) frame = p.frames[1]
78
+ return <PixelCanvas map={frame} pal={p.pal} px={pxScaled(px)} style={style} className={className} />
79
+ }
80
+ export function Body({ id, px = 6, playing = true, style, className, gender }: SpriteProps & { playing?: boolean; gender?: string }) {
81
+ const p = bodyFor(id, gender)
82
+ return <PixelCanvas frames={p.frames} pal={p.pal} px={pxScaled(px)} fps={1.1} playing={playing} style={style} className={className} />
83
+ }
84
+ export function EvIcon({ icon, px = 4, style }: { icon: string; px?: number; style?: JSX.CSSProperties }) {
85
+ const m = EV_ICONS[icon] || EV_ICONS.photoEv
86
+ return <PixelCanvas map={m} pal={IPAL} px={pxScaled(px, 1)} style={style} />
87
+ }
88
+ interface SceneProps {
89
+ name: string
90
+ w?: number
91
+ h?: number
92
+ anim?: boolean
93
+ full?: boolean
94
+ cover?: boolean
95
+ style?: JSX.CSSProperties
96
+ className?: string
97
+ deps?: unknown[]
98
+ }
99
+ export function Scene({ name, w = 240, h = 135, anim = false, full = false, cover = false, style, className, deps = [] }: SceneProps) {
100
+ const st = cover ? { objectFit: 'cover' as const, ...style } : style
101
+ return (
102
+ <SceneCanvas
103
+ paint={sceneFor(name)}
104
+ w={w}
105
+ h={h}
106
+ anim={anim}
107
+ full={full}
108
+ style={st}
109
+ className={className}
110
+ deps={[name, anim, full, ...deps]}
111
+ />
112
+ )
113
+ }
114
+
115
+ interface ExhibitArtProps {
116
+ e: Pick<Evidence, 'id' | 'name' | 'summary'>
117
+ w?: number
118
+ h?: number
119
+ style?: JSX.CSSProperties
120
+ className?: string
121
+ }
122
+ /** Procedural "evidence photo" of one exhibit - the object itself, on the forensic
123
+ * table, classified from the exhibit's own words and seeded by its id. */
124
+ export function ExhibitArt({ e, w = 96, h = 72, style, className }: ExhibitArtProps) {
125
+ return (
126
+ <SceneCanvas
127
+ paint={exhibitPainter(e.name, e.summary || '', e.id)}
128
+ w={w}
129
+ h={h}
130
+ style={style}
131
+ className={className}
132
+ deps={[e.id, e.name]}
133
+ />
134
+ )
135
+ }
136
+
137
+ // ---- primitives ----
138
+ interface PanelProps {
139
+ tab?: string
140
+ variant?: 'amber' | 'ox' | 'raised' | 'inset'
141
+ className?: string
142
+ style?: JSX.CSSProperties
143
+ children?: ComponentChildren
144
+ onClick?: (e: MouseEvent) => void
145
+ }
146
+ export function Panel({ tab, variant, className = '', style, children, onClick }: PanelProps) {
147
+ const v = variant ? ` panel--${variant}` : ''
148
+ return (
149
+ <div class={`panel${v} ${className}`} style={style} onClick={onClick}>
150
+ {tab && <span class="panel__tab">{tab}</span>}
151
+ {children}
152
+ </div>
153
+ )
154
+ }
155
+
156
+ type BtnProps = {
157
+ variant?: 'amber' | 'ox' | 'ghost'
158
+ sm?: boolean
159
+ className?: string
160
+ children?: ComponentChildren
161
+ /** SFX on click: a specific cue, or null to silence. Defaults to "click". */
162
+ sfx?: Sfx | null
163
+ } & JSX.ButtonHTMLAttributes<HTMLButtonElement>
164
+ export function Btn({ variant, sm, className = '', children, sfx, onClick, ...rest }: BtnProps) {
165
+ const v = variant ? ` pbtn--${variant}` : ''
166
+ const handle = (e: MouseEvent) => {
167
+ if (sfx !== null) playSfx(sfx || 'click')
168
+ ;(onClick as ((ev: MouseEvent) => void) | undefined)?.(e)
169
+ }
170
+ return (
171
+ <button class={`pbtn${v}${sm ? ' pbtn--sm' : ''} ${className}`} onClick={handle} {...rest}>
172
+ {children}
173
+ </button>
174
+ )
175
+ }
176
+
177
+ export function Chip({ variant, children, style }: { variant?: 'amber' | 'ox'; children?: ComponentChildren; style?: JSX.CSSProperties }) {
178
+ const v = variant ? ` chip--${variant}` : ''
179
+ return <span class={`chip${v}`} style={style}>{children}</span>
180
+ }
181
+
182
+ export function Stamp({ children, slam, style }: { children?: ComponentChildren; slam?: boolean; style?: JSX.CSSProperties }) {
183
+ return <span class={`stamp${slam ? ' stamp--slam' : ''}`} style={style}>{children}</span>
184
+ }
185
+
186
+ // ---- suspicion / composure bar ----
187
+ export function SuspicionBar({ value, label = 'SUSPICION', compact }: { value: number; label?: string | null; compact?: boolean }) {
188
+ const v = Math.max(0, Math.min(100, Math.round(value)))
189
+ const calm = v < 42
190
+ return (
191
+ <div class="col" style={{ gap: compact ? 3 : 5 }}>
192
+ {!compact && label && (
193
+ <div class="between" style={{ gap: 6 }}>
194
+ <span class="t-label" style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
195
+ <span class="t-mono" style={{ flexShrink: 0, color: calm ? 'var(--slate-3)' : 'var(--ox-3)', fontSize: 'calc(14px*var(--mono-scale))' }}>{v}%</span>
196
+ </div>
197
+ )}
198
+ <div class="bar" style={compact ? { height: 10 } : undefined}>
199
+ <div class={'bar__fill' + (calm ? ' bar__fill--calm' : '')} style={{ width: v + '%' }} />
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ // ---- suspect card ----
206
+ export function SuspectCard({ s, onClick, active, mini }: { s: Suspect; onClick?: (e: MouseEvent) => void; active?: boolean; mini?: boolean }) {
207
+ const g = useGame()
208
+ const susp = g.state.suspicion[s.id]
209
+ return (
210
+ <Panel
211
+ className="suspect-card"
212
+ variant={active ? 'amber' : undefined}
213
+ style={{ padding: mini ? 8 : 12, cursor: onClick ? 'pointer' : 'default', minWidth: mini ? 0 : 168 }}
214
+ onClick={onClick}
215
+ >
216
+ <div class="row" style={{ gap: 10, alignItems: 'flex-start' }}>
217
+ <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 3, flexShrink: 0 }}>
218
+ <Portrait id={s.sprite} px={mini ? 3 : 4} gender={s.gender} />
219
+ </div>
220
+ <div class="col grow" style={{ gap: 5, minWidth: 0 }}>
221
+ <div>
222
+ <div class="t-display" style={{ fontSize: mini ? 9 : 11, color: 'var(--bone-3)' }}>{s.name}</div>
223
+ <div class="t-label" style={{ marginTop: 3 }}>{s.tag}</div>
224
+ </div>
225
+ {!mini && <div class="t-body dim" style={{ fontSize: 12, lineHeight: 1.35 }}>{s.role}</div>}
226
+ <SuspicionBar value={susp} compact />
227
+ </div>
228
+ </div>
229
+ </Panel>
230
+ )
231
+ }
232
+
233
+ // ---- evidence card with "develops in" reveal ----
234
+ export function EvidenceCard({ e, onClick, active, develop, small }: { e: Evidence; onClick?: (ev: MouseEvent) => void; active?: boolean; develop?: boolean; small?: boolean }) {
235
+ const [revealed, setRevealed] = useState(!develop)
236
+ useEffect(() => {
237
+ if (develop) {
238
+ const t = setTimeout(() => setRevealed(true), 60)
239
+ return () => clearTimeout(t)
240
+ }
241
+ }, [develop])
242
+ return (
243
+ <Panel
244
+ variant={active ? 'amber' : undefined}
245
+ className="ev-card"
246
+ style={{ padding: small ? 8 : 11, cursor: onClick ? 'pointer' : 'default', position: 'relative', overflow: 'hidden' }}
247
+ onClick={onClick}
248
+ >
249
+ <div class="row" style={{ gap: 10, alignItems: 'center' }}>
250
+ <div style={{ background: 'var(--ink-1)', boxShadow: 'inset 0 0 0 2px var(--ink-0)', padding: 4, flexShrink: 0 }}>
251
+ <EvIcon icon={e.icon} px={small ? 2 : 3} />
252
+ </div>
253
+ <div class="col grow" style={{ gap: 4, minWidth: 0 }}>
254
+ <div class="t-display" style={{ fontSize: small ? 8 : 10, color: 'var(--bone-3)' }}>{e.name}</div>
255
+ <div class="row" style={{ gap: 6, alignItems: 'center' }}>
256
+ <Chip variant="amber" style={{ fontSize: 'calc(12px*var(--mono-scale))', padding: '2px 5px' }}>{e.type}</Chip>
257
+ <span class="t-mono dim nowrap" style={{ fontSize: 'calc(13px*var(--mono-scale))' }}>{e.time}</span>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ {develop && !revealed && <span class="develop-veil" />}
262
+ {develop && <span class="develop-veil develop-veil--anim" />}
263
+ </Panel>
264
+ )
265
+ }
266
+
267
+ // ---- dialogue / speech panel with typewriter ----
268
+ export function DialoguePanel({ who, text, speed = 26, onDone, instant, tag }: { who: string; text: string; speed?: number; onDone?: () => void; instant?: boolean; tag?: string | null }) {
269
+ const [out, done] = useTypewriter(text, speed, !instant)
270
+ useEffect(() => {
271
+ if (done && onDone) onDone()
272
+ }, [done])
273
+ return (
274
+ <Panel className="dialogue" style={{ padding: 16 }}>
275
+ <div class="between" style={{ marginBottom: 8 }}>
276
+ <span class="t-display amber" style={{ fontSize: 11 }}>{who}</span>
277
+ {tag && <Chip variant="ox">{tag}</Chip>}
278
+ </div>
279
+ <div class="t-body" style={{ minHeight: 56, color: 'var(--bone-2)' }}>
280
+ {out}
281
+ {!done && <span class="cursor" />}
282
+ </div>
283
+ </Panel>
284
+ )
285
+ }
286
+
287
+ // ---- type a string once, fire onDone ----
288
+ export function TypeOnce({ text, speed, onDone }: { text: string; speed: number; onDone?: () => void }) {
289
+ const [out, done] = useTypewriter(text, speed, true)
290
+ const [fired, setFired] = useState(false)
291
+ useEffect(() => {
292
+ if (done && !fired) {
293
+ setFired(true)
294
+ onDone?.()
295
+ }
296
+ }, [done])
297
+ return (
298
+ <>
299
+ {out}
300
+ {!done && <span class="cursor" />}
301
+ </>
302
+ )
303
+ }
304
+
305
+ // ---- HINT trigger button ----
306
+ export function HintButton() {
307
+ return (
308
+ <button class="hint-btn" onClick={() => window.dispatchEvent(new Event('toggle-hint'))} title="Ask your partner">
309
+ <span class="hint-btn__dot" /> HINT
310
+ </button>
311
+ )
312
+ }
313
+
314
+ // ---- top HUD bar ----
315
+ // Navbar controls: optional Main-menu and Music toggle. Compact icon buttons so
316
+ // they sit gracefully in the HUD bar (and on mobile) instead of floating mid-screen.
317
+ export function Controls({ menu = false }: { menu?: boolean }) {
318
+ const g = useGame()
319
+ const [musicOn, setMusicOn] = useState(musicIsPlaying())
320
+ const icon = (label: string, title: string, on: boolean, onClick: () => void) => (
321
+ <button
322
+ class="hint-btn"
323
+ title={title}
324
+ aria-label={title}
325
+ onClick={onClick}
326
+ style={{ width: 30, height: 28, padding: 0, justifyContent: 'center', fontSize: '0.95rem', opacity: on ? 1 : 0.5 }}
327
+ >
328
+ {label}
329
+ </button>
330
+ )
331
+ return (
332
+ <div class="row" style={{ gap: 5, alignItems: 'center' }}>
333
+ {menu && (g.mode === 'mobile'
334
+ ? icon('≡', 'Main menu', true, () => g.nav('title'))
335
+ : <Btn sm variant="ghost" onClick={() => g.nav('title')}>Menu</Btn>)}
336
+ {icon('♪', musicOn ? 'Music: on' : 'Music: off', musicOn, () => setMusicOn(toggleMusic()))}
337
+ </div>
338
+ )
339
+ }
340
+
341
+ export function Hud({ title, sub, right }: { title: string; sub?: string; right?: ComponentChildren }) {
342
+ const g = useGame()
343
+ return (
344
+ <div class="hud">
345
+ <div class="hud__left">
346
+ <button class="hud-badge" onClick={() => g.nav('board')} title="Investigation Board">
347
+ <span class="t-display" style={{ fontSize: 9, color: 'var(--ink-0)' }}>CASE</span>
348
+ <span class="t-mono" style={{ fontSize: 'calc(13px*var(--mono-scale))', color: 'var(--ink-0)' }}>{g.case.id}</span>
349
+ </button>
350
+ <div class="col" style={{ gap: 2, minWidth: 0 }}>
351
+ <div class="t-display hud__title">{title}</div>
352
+ {sub && <div class="t-label hud__sub">{sub}</div>}
353
+ </div>
354
+ </div>
355
+ <div class="hud__right">
356
+ {right}
357
+ <Controls menu />
358
+ <HintButton />
359
+ </div>
360
+ </div>
361
+ )
362
+ }
363
+
364
+ // ---- bottom nav (mobile) ----
365
+ const NAV_ITEMS = [
366
+ { id: 'briefing', label: 'CASE', icon: 'file' },
367
+ { id: 'board', label: 'BOARD', icon: 'board' },
368
+ { id: 'suspects', label: 'SUSPECTS', icon: 'people' },
369
+ { id: 'evidence', label: 'EVIDENCE', icon: 'box' },
370
+ { id: 'timeline', label: 'TIME', icon: 'clock' },
371
+ ] as const
372
+
373
+ function NavGlyph({ icon, on }: { icon: string; on: boolean }) {
374
+ const c = on ? 'var(--ink-0)' : 'var(--bone-1)'
375
+ const P = (d: JSX.CSSProperties) => <span style={{ position: 'absolute', ...d }} />
376
+ return (
377
+ <span style={{ position: 'relative', width: 18, height: 18, display: 'inline-block' }}>
378
+ {icon === 'file' && (<>{P({ left: 4, top: 2, width: 10, height: 14, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 6, width: 6, height: 2, background: c })}{P({ left: 6, top: 10, width: 6, height: 2, background: c })}</>)}
379
+ {icon === 'board' && (<>{P({ left: 2, top: 3, width: 14, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 6, top: 7, width: 3, height: 3, background: c })}{P({ left: 11, top: 9, width: 3, height: 3, background: c })}</>)}
380
+ {icon === 'people' && (<>{P({ left: 3, top: 3, width: 5, height: 5, background: c })}{P({ left: 3, top: 9, width: 7, height: 6, background: c })}{P({ left: 11, top: 4, width: 4, height: 4, background: c })}{P({ left: 10, top: 9, width: 6, height: 6, background: c })}</>)}
381
+ {icon === 'box' && (<>{P({ left: 3, top: 4, width: 12, height: 11, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 3, top: 4, width: 12, height: 2, background: c })}{P({ left: 8, top: 8, width: 2, height: 4, background: c })}</>)}
382
+ {icon === 'clock' && (<>{P({ left: 3, top: 3, width: 12, height: 12, background: 'transparent', boxShadow: `inset 0 0 0 2px ${c}` })}{P({ left: 8, top: 6, width: 2, height: 4, background: c })}{P({ left: 8, top: 9, width: 4, height: 2, background: c })}</>)}
383
+ </span>
384
+ )
385
+ }
386
+
387
+ export function BottomNav() {
388
+ const g = useGame()
389
+ const cur = g.state.screen
390
+ return (
391
+ <nav class="bottom-nav">
392
+ {NAV_ITEMS.map((it) => {
393
+ const on = cur === it.id || (it.id === 'suspects' && cur === 'interro')
394
+ return (
395
+ <button
396
+ key={it.id}
397
+ class={'nav-btn' + (on ? ' nav-btn--on' : '')}
398
+ onClick={() => g.nav(it.id)}
399
+ >
400
+ <span class="nav-btn__glyph"><NavGlyph icon={it.icon} on={on} /></span>
401
+ <span class="t-display nav-btn__lbl">{it.label}</span>
402
+ </button>
403
+ )
404
+ })}
405
+ </nav>
406
+ )
407
+ }
 
 
web/src/ui/tweaks.tsx DELETED
@@ -1,76 +0,0 @@
1
- // Slim in-app tweaks sheet (palette / FX / pixel scale / mood / fonts / typewriter / rain),
2
- // styled in the noir system and persisted to localStorage via the store. Opened from the
3
- // navbar gear (Controls). No host/sandbox protocol — production-local only.
4
- import { useGame } from '../store'
5
- import type { Tweaks } from '../store'
6
-
7
- const PALETTES: [Tweaks['palette'], string[]][] = [
8
- ['sodium', ['#0d1117', '#2d4a52', '#e0a44c', '#8a2a2a']],
9
- ['harbor', ['#0a0e14', '#3a6b6b', '#f0b860', '#a33636']],
10
- ['violet', ['#0e0c14', '#46506b', '#d99a3c', '#922d3a']],
11
- ]
12
-
13
- function Seg<T extends string>({ value, options, onChange }: { value: T; options: T[]; onChange: (v: T) => void }) {
14
- return (
15
- <div class="viewbar" style={{ position: 'static' }}>
16
- <div class="seg">
17
- {options.map((o) => (
18
- <button key={o} class={value === o ? 'on' : ''} onClick={() => onChange(o)}>{o}</button>
19
- ))}
20
- </div>
21
- </div>
22
- )
23
- }
24
-
25
- function Row({ label, children }: { label: string; children: preact.ComponentChildren }) {
26
- return (
27
- <div class="col" style={{ gap: 6 }}>
28
- <span class="t-label">{label}</span>
29
- {children}
30
- </div>
31
- )
32
- }
33
-
34
- // The settings sheet, opened from the navbar gear (Controls). Caller owns the open state.
35
- export function TweaksSheet({ onClose }: { onClose: () => void }) {
36
- const g = useGame()
37
- const t = g.state.tweaks
38
-
39
- return (
40
- <div
41
- class="panel panel--amber"
42
- style={{ position: 'fixed', right: 14, top: 56, zIndex: 90, width: 248, maxHeight: '80vh', overflowY: 'auto' }}
43
- >
44
- <div class="between" style={{ marginBottom: 12 }}>
45
- <span class="t-display amber" style={{ fontSize: 11 }}>TWEAKS</span>
46
- <button class="assistant__x" onClick={onClose}>✕</button>
47
- </div>
48
- <div class="col" style={{ gap: 14 }}>
49
- <Row label="Palette">
50
- <div style={{ display: 'flex', gap: 8 }}>
51
- {PALETTES.map(([id, cols]) => (
52
- <button key={id} onClick={() => g.setTweak('palette', id)} style={{ flex: 1, border: 0, padding: 0, cursor: 'pointer', background: 'transparent' }} title={id}>
53
- <div style={{ display: 'flex', height: 24, boxShadow: t.palette === id ? '0 0 0 2px var(--amber-2)' : '0 0 0 2px var(--ink-0)' }}>
54
- {cols.map((c, i) => <span key={i} style={{ flex: 1, background: c }} />)}
55
- </div>
56
- </button>
57
- ))}
58
- </div>
59
- </Row>
60
- <Row label="Precinct mood"><Seg value={t.mood} options={['night', 'day']} onChange={(v) => g.setTweak('mood', v)} /></Row>
61
- <Row label="FX intensity"><Seg value={t.fx} options={['low', 'med', 'high']} onChange={(v) => g.setTweak('fx', v)} /></Row>
62
- <Row label="Bitmap fonts"><Seg value={t.fonts} options={['crisp', 'terminal', 'stamp']} onChange={(v) => g.setTweak('fonts', v)} /></Row>
63
- <Row label={`Pixel scale · ${t.pixelScale}x`}>
64
- <input type="range" min={0.75} max={1.5} step={0.25} value={t.pixelScale} onInput={(e) => g.setTweak('pixelScale', Number((e.target as HTMLInputElement).value))} style={{ width: '100%' }} />
65
- </Row>
66
- <Row label={`Typewriter · ${t.typeSpeed}ms`}>
67
- <input type="range" min={6} max={44} step={2} value={t.typeSpeed} onInput={(e) => g.setTweak('typeSpeed', Number((e.target as HTMLInputElement).value))} style={{ width: '100%' }} />
68
- </Row>
69
- <div class="between">
70
- <span class="t-label">Pixel rain</span>
71
- <button class={t.rain ? 'pbtn pbtn--amber pbtn--sm' : 'pbtn pbtn--ghost pbtn--sm'} onClick={() => g.setTweak('rain', !t.rain)}>{t.rain ? 'ON' : 'OFF'}</button>
72
- </div>
73
- </div>
74
- </div>
75
- )
76
- }