HusseinEid commited on
Commit
e6ec979
·
verified ·
1 Parent(s): 2d15f23

docs: reference Supertone/supertonic-3 in Space model metadata and credits

Browse files
COMPLIANCE.md CHANGED
@@ -1,108 +1,105 @@
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
 
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 | [Supertone/supertonic-3](https://huggingface.co/Supertone/supertonic-3) (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
 
 
 
README.md CHANGED
@@ -8,21 +8,21 @@ app_port: 7860
8
  pinned: true
9
  license: apache-2.0
10
  models:
11
- - Qwen/Qwen2.5-1.5B-Instruct
 
12
  tags:
13
- - build-small-hackathon
14
- - thousand-token-wood
15
- - off-the-grid
16
- - llama-champion
17
- - off-brand
18
- - tiny-titan
19
- - field-notes
20
- - sharing-is-caring
21
- - llama-cpp
22
- - detective-game
23
- - text-generation
24
- - tts
25
- short_description: Gather evidence and solve cases in this pixel noir !
26
  ---
27
 
28
  # 🕵️ Case Zero — the AI *is* the detective game
@@ -108,8 +108,8 @@ bundle and the Python stage compiles llama.cpp and bakes the weights.)
108
  ## 🙏 Credits
109
 
110
  - **LLM:** [Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) (Apache-2.0), via llama.cpp.
111
- - **Voices:** Supertonic on-device TTS.
112
  - **Music:** *"Backbay Lounge"* by Kevin MacLeod (incompetech.com), licensed under
113
  [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/).
114
  - **Fonts:** Silkscreen & Pixelify Sans (SIL Open Font License), self-hosted.
115
- - Pixel art and UI sound effects: procedurally generated.
 
8
  pinned: true
9
  license: apache-2.0
10
  models:
11
+ - Qwen/Qwen2.5-1.5B-Instruct
12
+ - Supertone/supertonic-3
13
  tags:
14
+ - build-small-hackathon
15
+ - thousand-token-wood
16
+ - off-the-grid
17
+ - llama-champion
18
+ - off-brand
19
+ - tiny-titan
20
+ - field-notes
21
+ - sharing-is-caring
22
+ - llama-cpp
23
+ - detective-game
24
+ - text-generation
25
+ - tts
 
26
  ---
27
 
28
  # 🕵️ Case Zero — the AI *is* the detective game
 
108
  ## 🙏 Credits
109
 
110
  - **LLM:** [Qwen2.5-1.5B-Instruct](https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct) (Apache-2.0), via llama.cpp.
111
+ - **Voices:** [Supertonic-3](https://huggingface.co/Supertone/supertonic-3) on-device ONNX TTS.
112
  - **Music:** *"Backbay Lounge"* by Kevin MacLeod (incompetech.com), licensed under
113
  [Creative Commons Attribution 4.0](https://creativecommons.org/licenses/by/4.0/).
114
  - **Fonts:** Silkscreen & Pixelify Sans (SIL Open Font License), self-hosted.
115
+ - Pixel art and UI sound effects: procedurally generated.
src/case_zero/api/case_adapter.py CHANGED
@@ -1,297 +1,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
- )
 
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
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,77 +1,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
- }
 
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
+ }
 
 
 
 
 
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, 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
- }
 
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
+ }
web/src/screens/board.tsx CHANGED
@@ -1,26 +1,19 @@
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,11 +21,10 @@ interface Node {
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,67 +32,16 @@ function boardNodes(c: PublicCase, compact: boolean): Node[] {
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,40 +52,32 @@ function Corkboard({ compact, connect, setConnect }: { compact: boolean; connect
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,153 +97,57 @@ function Corkboard({ compact, connect, setConnect }: { compact: boolean; connect
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,11 +156,11 @@ function WallItem({ x, y, rot, pin = 'amber', children, w }: { x: string; y: str
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,56 +168,48 @@ function BoardDecor({ c, compact }: { c: PublicCase; compact?: boolean }) {
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,8 +243,8 @@ function SuspectRail() {
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,13 +266,13 @@ function SuspectRail() {
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,7 +284,7 @@ function BoardNode({ node, cardW, selected, onSelect }: { node: Node; cardW: num
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,3 +297,46 @@ function BoardNode({ node, cardW, selected, onSelect }: { node: Node; cardW: num
468
  </Panel>
469
  )
470
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  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
 
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
  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
  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
  )
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
  <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
  </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
  )
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
  )
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
  </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
+ }
web/src/screens/cold.tsx CHANGED
@@ -1,368 +1,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
- }
 
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
+ }
 
 
 
 
 
web/src/screens/index.ts CHANGED
@@ -1,27 +1,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
- }
 
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
+ }
 
 
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' ? 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
- }
 
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
+ }
web/src/store.tsx CHANGED
@@ -1,191 +1,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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
web/src/styles/layout.css CHANGED
@@ -1,358 +1,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
- }
 
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
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/styles/pixel.css CHANGED
@@ -1,276 +1,273 @@
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; }
 
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; }
 
 
 
web/src/ui/components.tsx CHANGED
@@ -1,407 +1,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
- }
 
 
 
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
+ }
web/src/ui/tweaks.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }