diff --git a/docs/scene-7-todo.md b/docs/scene-7-todo.md
index 850d93f..2d1beaa 100644
--- a/docs/scene-7-todo.md
+++ b/docs/scene-7-todo.md
@@ -1,10 +1,18 @@
-# Scene 7: prove Barricelli was an inventor
+Scene 7 teaches one idea: a screenshot found during an OSINT search can become
+source evidence.
-Status: **implemented and verified** on `scene-7-evidence-goal`
-Demo: **GUPI Demo 1 — The Barricelli Files**
+The player opens an otherwise minimal OSINT board, reads the assignment
+**“Demonstrate OSINT skill: prove Nils Aall Barricelli was an inventor,”** finds
+the relevant Google Patents result, and pastes or uploads one screenshot. The
+board creates a document, extracts its text, recognizes the source, and clears
+the level. A URL and a written report are not required.
-## Outcome
+The normal path must feel like one continuous action:
+```text
+paste screenshot -> document appears -> scanning feedback -> source verified
+ -> Scene 7 complete -> continue to Scene 8
+=======
Scene 7 teaches two linked ideas: a screenshot found during an OSINT search can
become source evidence, and a finding is only as useful as the report that cites
and explains that evidence.
@@ -18,10 +26,123 @@ Claim, completes the evidentiary statement, and files the generated Case Report.
```text
paste screenshot -> document appears -> source is verified -> connect to Claim
-> submit thin report -> provenance feedback -> accepted -> Scene 8
-```
## Product decisions
+These are decisions for this slice, not open design questions:
+
+- One suitable Google Patents screenshot is sufficient evidence.
+- Pasting and file upload are equivalent inputs and use the same server path.
+- The uploaded image and extracted text are retained as a real document on the
+ player's level.
+- The known patent source is recognized with deterministic OCR/fuzzy matching.
+ This is the fast, cheap, reproducible victory path.
+- A small LLM judge is a semantic fallback for other credible evidence and for
+ distinguishing Nils from his father. It must not overrule a trusted known-source
+ match.
+- The player does not have to provide a URL when the screenshot text itself
+ establishes provenance.
+- Evidence about Barricelli's father may unlock an optional discovery, but it
+ must not clear the assignment unless the evidence also supports the claim
+ about **Nils Aall Barricelli**.
+- The boarding-house fire article belongs to the later age/rescue assignment,
+ not to Scene 7's inventor victory condition.
+- Scene 7 records completion; Scene 8 owns the merit ceremony and awards
+ `barricelli_luggage`.
+- A failed or inconclusive evaluation never deletes the uploaded document and
+ never penalizes the player.
+
+## Existing foundation — reuse it
+
+Do not build a second upload, OCR, flag, or story system for this scene.
+
+- Migration `025_level_document_flags.sql` provides clonable document gates,
+ level flags, and reveal state.
+- Migration `026_achievements.sql` provides playthrough achievements.
+- Migration `027_evidence_text_matching.sql` provides immutable asset text
+ extractions, board-owned match rules/anchors, level-owned evaluations, and
+ auditable flag awards.
+- `server/ocr.ts` extracts plain text and runs Tesseract for images.
+- `server/evidenceMatching.ts` implements normalized fuzzy anchor matching.
+- `POST /api/levels/:id/documents` already persists the document, OCR result,
+ deterministic evaluations, and newly awarded level flags.
+- Screenshot paste already routes through document upload in the board UI.
+- The story graph already has level nodes and playthroughs with
+ `current_node_id` and `current_level_id`.
+
+The deterministic matcher has already handled noisy historic OCR, including a
+hyphenated `Bar- ricelli`, at useful confidence. Scene 7 should add authored
+patent anchors and a completion contract, not replace that matcher.
+
+## Proposed flags and identifiers
+
+Keep all identifiers authored in template data; these names are the recommended
+contract between independently developed branches.
+
+| Purpose | Key |
+|---|---|
+| Level goal | `barricelli.inventor-proof` |
+| Scene 7 completion flag | `scene7.nils_inventor_proved` |
+| Optional father discovery | `scene7.father_inventor_discovered` |
+| Scene 8 reward | `barricelli_luggage` |
+
+The first three are Scene 7 state. The last is a playthrough achievement awarded
+by Scene 8, never by the document upload endpoint.
+
+## Architecture
+
+### 1. Separate recognition from completion
+
+Recognition answers **“what does this document support?”** Completion answers
+**“are this level's authored requirements now satisfied?”** Do not hide level
+completion in a React conditional or special-case `Barricelli` in server code.
+
+Add a small, generic, board-owned goal model in the next migration (currently
+expected to be `028`; verify the migration number immediately before creating
+it):
+
+- `level_goals`
+ - belongs to a board and clones with a template;
+ - has a stable `goal_key`, player-facing title/instructions, enabled state, and
+ optional completion message;
+ - uses the existing `origin_*` pattern for cloned authoring objects.
+- `level_goal_flag_requirements`
+ - maps a goal to one or more required level `flag_key` values;
+ - Scene 7 has one requirement: `scene7.nils_inventor_proved`;
+ - all requirements are required for the first implementation. Add `any/all`
+ policy only when a real authored level needs it.
+
+Goal state is derived from level flags; do not add a second mutable `completed`
+boolean that can drift out of sync. If completion needs a timestamp, record a
+single idempotent goal-completion event with provenance.
+
+### 2. Known-source fast path
+
+Author one enabled `evidence_match_rule` on the Scene 7 template board. Its
+anchors should be distinctive passages visible in the actual Google Patents
+screenshot, such as a combination of patent number/title, inventor name, and
+invention language. Do not rely on the name alone.
+
+When enough anchors pass their authored thresholds, the existing evaluation
+awards `scene7.nils_inventor_proved`. The goal requirement consequently becomes
+satisfied in the same upload transaction.
+
+The reference OCR, anchor phrases, thresholds, canonical source metadata, and
+player-facing copy are template data. None belong in TypeScript constants or
+React branches. Expected/reference text must not be returned in play-mode API
+responses.
+
+### 3. Semantic fallback, not a free-form LLM gate
+
+Add a provider-independent `EvidenceJudge` interface in a new server module. It
+receives only allowlisted goal data and extracted OCR text and returns validated
+structured data, for example:
+
+```ts
+type EvidenceVerdict = {
+ subject: 'nils' | 'father' | 'ambiguous' | 'neither'
+ supportsInventorClaim: boolean
+=======
- One suitable Google Patents screenshot is sufficient evidence, but not by itself
a complete investigation.
- Clipboard paste and file upload use the same server path.
@@ -113,124 +234,264 @@ type EvidenceVerdict = {
}
```
-OCR is untrusted quoted material. The judge prompt explicitly ignores instructions
-inside it. Provider/model, timeout, input limit, and confidence threshold are
-environment configuration. Semantic configuration clones with the board;
-evaluation history belongs to the level/document/extraction and records evaluator
-version, provider/model, verdict, excerpt, confidence, timestamps, and sanitized
-failure state.
+The provider/model name comes from environment configuration. Do not hard-code a
+Claude model identifier into level content or business logic. Treat OCR as
+untrusted quoted material: the prompt must explicitly ignore instructions found
+inside it, and the response must pass a strict schema before it can award a flag.
-| Verdict | Mutation |
+Persist semantic rule configuration with the board and clone it with the
+template. Persist each evaluation against the level, document, extraction,
+rule/evaluator version, model/provider, verdict, excerpt, confidence, timestamps,
+and sanitized failure state. Add semantic-evaluation provenance to any level flag
+it awards. Never put provider credentials in PostgreSQL.
+
+Verdict routing for this scene:
+
+| Verdict | Result |
|---|---|
-| Target + assertion supported at threshold | authored success flag (`scene7.nils_inventor_proved`) |
-| Related subject only + assertion supported | authored related flag (`scene7.father_inventor_discovered`) |
-| Ambiguous, unsupported, or below threshold | none |
-| Provider failure | none; retryable |
+| Nils + inventor claim supported, high confidence | award `scene7.nils_inventor_proved` |
+| Father only + inventor claim supported | award `scene7.father_inventor_discovered`; do not complete |
+| Ambiguous, neither, unsupported, or below threshold | retain document; award nothing |
+| Provider unavailable/invalid response | retain document; mark evaluation retryable |
-The known-source pass avoids an LLM call. After a deterministic miss, the client
-automatically calls an idempotent semantic endpoint. This keeps document upload
-durable even if an external provider times out, without requiring a demo job queue.
+Keep this evaluator narrower than the generic story-graph `llm_gate`. Scene 7 is
+judging a single uploaded source, not a report or arbitrary player state.
-### Story progression
+### 4. Two-step server flow
-Advancing from a level is server-authoritative. The server verifies the JWT user,
-active playthrough/current level, and enabled goal state. It promotes the Scene 7
-completion fact idempotently and only then follows the level terminal. The browser
-cannot grant its own achievement.
+The primary Google Patents path remains synchronous and deterministic:
-The player must see the verification result before navigation. Success exposes a
-single Continue action to Scene 8. Scene 6 only wires into the Scene 7 level node;
-Scene 8 owns the luggage reward.
+1. Upload/paste persists the asset, document, OCR extraction, fuzzy evaluation,
+ flags, and current goal state in one transaction.
+2. If the trusted rule clears the goal, return success immediately and do not
+ spend an LLM call.
+3. If OCR succeeded but no trusted rule clears the goal, the client automatically
+ calls an idempotent semantic-judge endpoint for that document.
+4. The semantic endpoint uses a strict timeout, persists its result, and returns
+ refreshed goal state. A timeout is retryable and cannot roll back the upload.
+
+This avoids coupling document durability to an external provider without
+requiring a job queue for the demo. Make semantic evaluation idempotent for the
+same `(level, document, goal/rule, evaluator_version)`.
+
+### 5. Story progression contract
+
+Completing a board goal must be a server-authoritative transition:
+
+- verify that the JWT user owns the active playthrough;
+- verify that its `current_level_id` is the level being evaluated;
+- observe the derived completed goal;
+- idempotently record/promote `scene7.nils_inventor_proved` into the playthrough
+ state needed by the story runtime;
+- expose Scene 7's successful terminal so the player can continue to Scene 8.
+
+Do not let the browser award achievements through the current development-only
+achievement route. Do not make upload silently navigate before the player sees
+what was learned. Show the verification result, then expose a single **Continue**
+action (or a short authored transition that ends in the same action).
+
+The Scene 6 branch only needs to route its successful terminal to the Scene 7
+level node. The Scene 8 branch may depend on the completion state above and owns
+the `barricelli_luggage` award.
## Work packages
-### S7-A — Goal model and cloning
+The packages are ordered for integration, but most implementation can happen on
+separate branches after the contracts above are agreed.
-- [x] Add normalized board goals and flag requirements in migration `028`.
-- [x] Clone goals during freeze, instantiation, and reset.
-- [x] Derive pending/complete state from level flags.
-- [x] Hide goal requirements from play payloads.
-- [x] Return newly completed goals from document upload.
-- [x] Add admin CRUD repository/API contracts.
-- [x] Test migration, cloning, deterministic completion, and isolation.
-- [x] Clear level flags/reveal state when resetting from a template.
+### S7-A — Goal model and template cloning
-### S7-B — Barricelli content and trusted recognition
+- [ ] Confirm the next free migration number; never edit applied migrations
+ `025`–`027`.
+- [ ] Add `level_goals` and `level_goal_flag_requirements` with board-scoped
+ foreign keys, uniqueness, indexes, and comments.
+- [ ] Extend template freeze/clone/instantiate so goals and requirements are
+ copied and retain origin provenance.
+- [ ] Derive `pending | complete` goal state from the level's current flags.
+- [ ] Add repository tests for cloning, isolation between two playthroughs, and
+ idempotent completion.
+- [ ] Keep the schema generic; there must be no Barricelli-specific column or
+ table.
-- [x] Add goals and evidence-match rules to the normal mystery manifest importer.
-- [x] Create the Scene 7 template with its assignment and no solution-bearing
- starting document.
-- [x] Add a reproducible screenshot-paste acceptance path that runs through local OCR.
-- [x] Author three distinctive patent anchors.
-- [x] Tune against the target and unrelated/father-only negative fixtures.
-- [x] Award `scene7.nils_inventor_proved` and require it for the level goal.
-- [x] Store canonical source metadata for administrators; keep player URL optional.
+### S7-B — Scene content and deterministic recognition
+
+- [ ] Create/import the Scene 7 template and its brief as data.
+- [ ] Start the board without any solution-bearing document.
+- [ ] Obtain the exact target Google Patents screenshot used for acceptance and
+ run it through the local OCR service.
+- [ ] Author two or more distinctive match anchors from that extraction; avoid a
+ generic `Nils Barricelli`-only rule.
+- [ ] Tune thresholds against the target screenshot plus negative fixtures.
+- [ ] Configure the rule to award `scene7.nils_inventor_proved`.
+- [ ] Configure the goal requirement to consume that flag.
+- [ ] Store canonical patent/source metadata for administrators, while keeping a
+ pasted URL optional for players.
+- [ ] Add the content to the normal manifest/import path rather than SQL seed
+ literals or frontend code.
### S7-C — Semantic judge
-- [x] Add provider-neutral interface and strict verdict validation.
-- [x] Add clonable semantic rule configuration and level-owned evaluations.
-- [x] Add semantic evaluation provenance to level flags.
-- [x] Add provider/model/timeout/input/confidence environment configuration.
-- [x] Send OCR text rather than raw image bytes.
-- [x] Add ownership-checked, idempotent judge endpoint.
-- [x] Route target and father verdicts to their authored flags.
-- [x] Make disabled provider, timeout, quota, and malformed output safe/retryable.
-- [x] Do not log evidence text or secrets.
+- [ ] Add the provider-neutral `EvidenceJudge` interface and strict verdict
+ schema.
+- [ ] Add board-owned semantic rule configuration and level-owned evaluation
+ history with clone support and flag provenance.
+- [ ] Add environment variables for provider, model, timeout, maximum OCR
+ characters, and confidence threshold; document safe defaults in
+ `.env.example` without overwriting concurrent OCR configuration work.
+- [ ] Send extracted text, not raw image bytes, unless a later explicit design
+ requires a vision model.
+- [ ] Delimit and escape untrusted OCR content in the prompt.
+- [ ] Add an authenticated, ownership-checked, idempotent document-judge endpoint.
+- [ ] Award the completion or father-discovery flag only from validated persisted
+ verdicts.
+- [ ] Make timeouts, malformed responses, quota failures, and disabled provider
+ safe and retryable.
+- [ ] Do not log full evidence text or provider credentials.
-### S7-D — Story bridge
+### S7-D — API and story bridge
-- [x] Require completed enabled goals before a playthrough can leave a level.
-- [x] Verify the current playthrough belongs to the JWT user and owns the level.
-- [x] Promote required completion facts exactly once.
-- [x] Return a useful pending-goal error rather than advancing early.
-- [x] Return to the generic story runtime after success; the Scene 6/8 graph owner
- wires the actual neighboring nodes.
-- [x] Keep the arbitrary achievement-grant route development-only.
+- [ ] Return compact goal state from the level response and document-upload
+ response: goal key, status, newly completed state, and player-facing message.
+- [ ] Never return reference anchors, expected text, private evaluator prompts,
+ or unpublished author data in play mode.
+- [ ] Add the semantic fallback endpoint/result to the typed client API.
+- [ ] Resolve the active playthrough for the level and enforce user ownership.
+- [ ] Promote completion server-side exactly once.
+- [ ] Make Scene 7's success terminal available only after the required goal is
+ complete.
+- [ ] Route that terminal to the Scene 8 node without implementing Scene 8's
+ ceremony in this branch.
+- [ ] Remove or fence the player-facing development route that can arbitrarily
+ grant achievements before production deployment.
### S7-E — Board experience
-- [x] Present the active goal prominently on Scene 7.
-- [x] Preserve clipboard paste, drag/drop, and file picker equivalence.
-- [x] Show source import/OCR and semantic checking stages.
-- [x] Highlight the accepted document and show **SOURCE VERIFIED — NILS AALL
- BARRICELLI: INVENTOR**.
-- [x] Show Continue only after server-confirmed completion.
-- [x] Acknowledge father-only discovery while asking for evidence about Nils.
-- [x] Keep inconclusive evidence and offer neutral guidance.
-- [x] Respect reduced-motion and mobile layouts.
+- [ ] Show the exact assignment prominently when Scene 7 opens.
+- [ ] Preserve both clipboard paste and drag/file upload; both call the same API.
+- [ ] Place the pasted screenshot as a new image document using the normal board
+ placement rules.
+- [ ] Show restrained stages such as **Saving source**, **Reading text**, and
+ **Checking evidence** without blocking board interaction unnecessarily.
+- [ ] On success, visually identify the accepted document and show:
+ **SOURCE VERIFIED — NILS AALL BARRICELLI: INVENTOR**.
+- [ ] After the player sees the result, expose one **Continue** action to Scene 8.
+- [ ] On father-only evidence, acknowledge the useful discovery and make clear
+ that evidence about Nils is still required.
+- [ ] On inconclusive evidence, keep the document and provide neutral guidance;
+ do not say that the player is wrong.
+- [ ] Respect reduced-motion settings and provide readable mobile feedback.
+- [ ] Do not introduce Scene 7 checks into generic exhibit components.
-### S7-F — Verification
+### S7-F — Tests and acceptance fixtures
-- [x] Add a legally safe derived OCR fixture.
-- [x] Test line breaks, punctuation, cropping, name hyphenation, unrelated patent,
- father-only text, empty OCR, and prompt-injection-like text.
-- [x] Contract-test with a fake semantic provider; CI never calls a paid model.
-- [x] Integration-test target upload -> document -> flag -> completed goal.
-- [x] Test duplicate evaluation, provider failure/retry, two-user isolation, and
- story progression before/after completion.
-- [x] Browser-test clipboard image paste through OCR and verification. Continue
- is covered by the generic story-gate integration until the Scene 8 node lands.
-- [x] Run migrations on an empty database and one currently at `027`.
-- [x] Run unit, integration, build, and Docker smoke tests.
+- [ ] Add the actual Google Patents screenshot as a legally appropriate test
+ fixture, or store a compact derived OCR fixture if redistributing the image is
+ undesirable.
+- [ ] Unit-test OCR normalization and fuzzy matching for realistic line breaks,
+ punctuation, cropping, and name hyphenation.
+- [ ] Add negative fixtures: unrelated patent, father-only evidence, a generic
+ Barricelli biography, low-quality/empty OCR, and prompt-injection-like text.
+- [ ] Contract-test the semantic judge with a fake provider; CI must not call a
+ paid external model.
+- [ ] Integration-test target upload -> one document -> completion flag -> goal
+ complete, including a repeat upload/evaluation.
+- [ ] Integration-test father-only -> discovery flag -> goal still pending.
+- [ ] Integration-test provider failure -> document retained -> retry succeeds.
+- [ ] Integration-test two users/playthroughs so one player's evidence cannot
+ complete another player's level.
+- [ ] Browser-test clipboard paste through the success state and Continue action.
+- [ ] Run migrations against an empty database and an existing database at
+ migration `027`.
+- [ ] Run the full unit/integration suite, production build, and Docker smoke test.
-## API shape
+## API shape to converge on
+
+Exact route naming may follow the repository's conventions, but the frontend and
+backend branches should agree on a compact result like this before coding:
```ts
-type LevelGoal = {
+type LevelGoalState = {
key: string
title: string
- instructions: string
- completionMessage: string
status: 'pending' | 'complete'
- completedAt?: string
newlyCompleted: boolean
+ message?: string
}
type DocumentAnalysis = {
extractionStatus: 'succeeded' | 'unsupported' | 'failed'
matchedFlags: string[]
awardedFlags: string[]
+ semanticStatus: 'not_needed' | 'available' | 'pending' | 'succeeded' | 'failed'
+ goals: LevelGoalState[]
+}
+```
+
+`newlyCompleted` describes this mutation's effect and is not persisted as goal
+state. Re-fetching a completed level returns `status: 'complete'` and
+`newlyCompleted: false`.
+
+## Security, privacy, and cost limits
+
+- Player endpoints require the same JWT identity and level ownership checks as
+ playthrough progression; admin authoring remains admin-only.
+- Limit upload bytes, OCR text sent to the model, model output tokens, request
+ duration, and retries.
+- Do not expose answer anchors or semantic judging instructions to the browser.
+- Do not trust filenames, MIME declarations, OCR text, or model output.
+- Use schema validation and a confidence threshold before mutating flags.
+- Store enough provenance to explain why a level cleared without retaining
+ unnecessary provider request/response payloads.
+- A deterministic trusted-source match saves cost and is authoritative. The LLM
+ is never called merely to reconfirm it.
+
+## Explicitly out of scope
+
+- Terminal game, Glitch University signup, Dobby, and Glitch Hunter scenes
+ (Scenes 1–6).
+- Scene 8's ceremony/3D luggage implementation and Scene 9's fire mystery.
+- A general knowledge graph, Case Report, claims, red-thread reasoning, or
+ multi-document synthesis.
+- Crawling the web, fetching a pasted URL, or validating a URL as a victory
+ requirement.
+- Training a custom OCR or language model.
+- Generalizing the story graph's future `llm_gate`; this slice may share a
+ provider adapter later, but does not depend on that larger feature.
+- Automatic rejection or deletion of irrelevant player evidence.
+
+## Merge guidance for independent branches
+
+Prefer new modules and narrow glue commits. Current high-conflict files include
+`server/index.ts`, `server/narrativeRepository.ts`, `src/App.tsx`, `src/main.tsx`,
+and the play entrypoint. Assign one integrator to make the final small changes in
+those files after the isolated work lands.
+
+Suggested merge order:
+
+1. S7-A schema/repository and clone support.
+2. S7-B authored content and deterministic fixtures.
+3. S7-C judge service/evaluation persistence.
+4. S7-D story/API glue.
+5. S7-E UI.
+6. S7-F acceptance hardening.
+
+Each branch should state its migration dependency and avoid renumbering an
+already-shared migration silently. If two branches need schema changes, reserve
+migration numbers before implementation or keep one branch schema-free.
+
+## Definition of done
+
+From a fresh playthrough, a player reaches Scene 7 and sees the inventor
+assignment. They paste one accepted Google Patents screenshot. One source
+document appears on their board, the server persists the asset and OCR, the
+authored match rule records an auditable evaluation, and the level obtains
+`scene7.nils_inventor_proved`. The UI clearly confirms what the evidence proved
+and offers Continue; the story then enters Scene 8. Reloading preserves the
+document and completed state, repeating the evaluation grants nothing twice,
+another player's level is unaffected, no URL was required, and
+`barricelli_luggage` has not yet been awarded.
+
+=======
goals: LevelGoal[]
}
```
@@ -273,6 +534,4 @@ They paste one accepted Google Patents screenshot, connect Exhibit 1 to the Clai
and see **“Proof that…”** appear in the typewriter report. The first thin submission
passes the evidence but is returned for provenance; adding the date, source citation,
and a proper evidentiary statement produces an accepted report and enables Continue.
-MinIO asset, OCR, match provenance, stable exhibit number, connection, report
-submissions, and `scene7.nils_inventor_proved` persist. A URL improves the report
-but remains optional, and `barricelli_luggage` is not awarded until Scene 8.
+inIO asset, OCR, match provenance, stable exhibit number, connection, report
diff --git a/src/inventory.tsx b/src/inventory.tsx
new file mode 100644
index 0000000..3c77ddf
--- /dev/null
+++ b/src/inventory.tsx
@@ -0,0 +1,120 @@
+import { useEffect, useRef, useState } from 'react'
+import * as THREE from 'three'
+import { buildPhone, CLOSED_ANGLE, PhonePreview } from './phone'
+
+// A reusable "tools" inventory: a three.js rack you cycle through, one 3D tool at a
+// time, then USE to open it. Tool-driven (see TOOLS below), so it isn't tied to any
+// level type — new tools (map, visual novel, …) just register a model + a component.
+// Rendered as a dev overlay at /?inventory=1 for now; drop into the
+// real navbar later.
+
+function buildPhoneModel() {
+ const { group, hinge } = buildPhone()
+ hinge.rotation.x = CLOSED_ANGLE // sit closed on the rack
+ group.scale.setScalar(1.15)
+ group.position.y = -0.05
+ return group
+}
+
+function buildNotebook() {
+ const g = new THREE.Group()
+ const coverMat = new THREE.MeshStandardMaterial({ color: 0x7c3a2b, roughness: 0.85, metalness: 0.05, flatShading: true })
+ const pageMat = new THREE.MeshStandardMaterial({ color: 0xe7dcbf, roughness: 0.95, flatShading: true })
+ const wireMat = new THREE.MeshStandardMaterial({ color: 0xcaa74a, roughness: 0.5, metalness: 0.5, flatShading: true })
+ const back = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); back.position.z = -0.08; g.add(back)
+ const pages = new THREE.Mesh(new THREE.BoxGeometry(0.8, 1.08, 0.12), pageMat); g.add(pages)
+ const front = new THREE.Mesh(new THREE.BoxGeometry(0.86, 1.16, 0.05), coverMat); front.position.z = 0.09; g.add(front)
+ const strap = new THREE.Mesh(new THREE.BoxGeometry(0.06, 1.2, 0.02), new THREE.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.8, flatShading: true }))
+ strap.position.set(0.3, 0, 0.12); g.add(strap)
+ for (let i = 0; i < 8; i++) { // spiral binding down the spine
+ const ring = new THREE.Mesh(new THREE.TorusGeometry(0.035, 0.012, 6, 10), wireMat)
+ ring.position.set(-0.43, 0.49 - i * 0.14, 0); ring.rotation.y = Math.PI / 2; g.add(ring)
+ }
+ g.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true })
+ return g
+}
+
+const TOOLS: { key: string; name: string; build: () => THREE.Group }[] = [
+ { key: 'notebook', name: 'Notebook', build: buildNotebook },
+ { key: 'phone', name: 'Phone', build: buildPhoneModel },
+]
+
+function ToolRack({ index }: { index: number }) {
+ const stageRef = useRef(null)
+ const holderRef = useRef(null)
+
+ useEffect(() => {
+ const stage = stageRef.current
+ if (!stage) return
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ stage.appendChild(renderer.domElement)
+ renderer.domElement.style.cssText = 'position:absolute;inset:0;width:100%;height:100%'
+
+ const scene = new THREE.Scene()
+ const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100)
+ camera.position.set(0.5, 0.45, 3.3)
+ camera.lookAt(0, 0, 0)
+ scene.add(new THREE.AmbientLight(0x40483a, 0.9))
+ const key = new THREE.DirectionalLight(0xfff4e0, 1.1); key.position.set(2, 3, 3); scene.add(key)
+ const rim = new THREE.DirectionalLight(0x9fd020, 0.4); rim.position.set(-2, 1, -2); scene.add(rim)
+ const holder = new THREE.Group(); scene.add(holder); holderRef.current = holder
+
+ const resize = () => { const w = stage.clientWidth, h = stage.clientHeight; if (w && h) { camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false) } }
+ resize(); const ro = new ResizeObserver(resize); ro.observe(stage)
+
+ let raf = 0
+ const tick = () => { holder.rotation.y += 0.008; renderer.render(scene, camera); raf = requestAnimationFrame(tick) }
+ tick()
+ return () => {
+ cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); renderer.domElement.remove()
+ scene.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } })
+ holderRef.current = null
+ }
+ }, [])
+
+ // Swap the model when the selected tool changes.
+ useEffect(() => {
+ const holder = holderRef.current
+ if (!holder) return
+ while (holder.children.length) { const child = holder.children[0]; holder.remove(child); child.traverse(o => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); (o.material as THREE.Material).dispose() } }) }
+ holder.rotation.set(0, 0, 0)
+ holder.add(TOOLS[index].build())
+ }, [index])
+
+ return
+}
+
+export function Inventory({ onClose }: { onClose?: () => void }) {
+ const [index, setIndex] = useState(0)
+ const [active, setActive] = useState(null)
+
+ if (active) return
+
+ {active === 'phone' ? : }
+
+
+ return
+
+
+
+
+
{TOOLS[index].name}
+
{TOOLS.map((tool, i) => )}
+
+
+ {onClose && }
+
INVENTORY · dev preview
+
+}
+
+// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
+function NotebookTool() {
+ const [text, setText] = useState('')
+ return
+
+
FIELD NOTEBOOK
+
+
+}
diff --git a/src/main.tsx b/src/main.tsx
index 6644b9d..f0f678e 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -6,13 +6,16 @@ import './styles.css'
// three.js is lazy-loaded so the board never pays for it up front.
const PhonePreview = lazy(() => import('./phone').then(m => ({ default: m.PhonePreview })))
+const Inventory = lazy(() => import('./inventory').then(m => ({ default: m.Inventory })))
// Routing: the bare root is the game's front door (splash + campaign); /level/:id,
-// /admin, and the legacy ?level= deep link open the board; ?phone=1 is the spike.
+// /admin, and the legacy ?level= deep link open the board; ?phone / ?inventory spikes.
const path = window.location.pathname
const search = new URLSearchParams(window.location.search)
const isBoard = path.startsWith('/level/') || path === '/admin' || search.has('level')
const root = search.has('phone')
?
- : isBoard ? :
+ : search.has('inventory')
+ ?
+ : isBoard ? :
createRoot(document.getElementById('root')!).render({root})
diff --git a/src/phone.tsx b/src/phone.tsx
index 9013233..a3b1f29 100644
--- a/src/phone.tsx
+++ b/src/phone.tsx
@@ -11,7 +11,7 @@ import * as THREE from 'three'
// flag requirements come from the story graph (see the "mobile" gate discussion).
const SCREEN_RECT = { top: 9, left: 25, width: 50, height: 30 } // % of the stage
-const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
+export const CLOSED_ANGLE = 3.12 // hinge rotation.x when shut (~179°: lid folds over the keypad)
const OPEN_ANGLE = 0 // lid stands up, coplanar with the keypad, facing camera
// ---- placeholder telephony audio (to be replaced by recorded assets) ----------
@@ -46,7 +46,7 @@ const sfx = {
type Built = { group: THREE.Group; hinge: THREE.Group; keys: THREE.Mesh[] }
-function buildPhone(): Built {
+export function buildPhone(): Built {
const group = new THREE.Group()
const keys: THREE.Mesh[] = []
diff --git a/src/styles.css b/src/styles.css
index 1490a85..232801b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -687,3 +687,29 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
.splash-case:disabled { opacity: .5; cursor: default; }
.splash-case-title { font-weight: 600; letter-spacing: .5px; }
.splash-case-action { color: #d58a46; font-size: 12px; letter-spacing: 1px; white-space: nowrap; }
+
+/* === Inventory (tools rack) — src/inventory.tsx ===================== */
+.inv-backdrop { position: fixed; inset: 0; z-index: 500; display: flex; flex-direction: column; align-items: center; justify-content: center;
+ background: radial-gradient(120% 90% at 50% 35%, #14201c 0%, #070d0b 70%, #030605 100%); }
+.inv-stage { position: absolute; inset: 0; }
+.inv-arrow { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 46px; height: 46px; border: 1px solid #3c5a52;
+ background: #0a211de0; color: #cdea6a; font-size: 16px; cursor: pointer; }
+.inv-arrow.left { left: 6vw; } .inv-arrow.right { right: 6vw; }
+.inv-arrow:hover { border-color: #cdea6a; }
+.inv-plate { position: absolute; bottom: 8vh; z-index: 2; display: flex; flex-direction: column; align-items: center; gap: 10px; }
+.inv-name { font: 600 15px IBM Plex Mono, ui-monospace, monospace; letter-spacing: 3px; color: #eafaa0; text-transform: uppercase; }
+.inv-dots { display: flex; gap: 7px; }
+.inv-dots span { width: 7px; height: 7px; border: 1px solid #5f7b73; border-radius: 50%; }
+.inv-dots span.on { background: #cdea6a; border-color: #cdea6a; }
+.inv-use { border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 2px; padding: 9px 26px; cursor: pointer; }
+.inv-use:hover { border-color: #cdea6a; color: #eafaa0; }
+.inv-close { position: absolute; top: 16px; right: 18px; z-index: 2; width: 34px; height: 34px; border: 1px solid #3c5a52; background: #0a211de0; color: #cfe8df; cursor: pointer; }
+.inv-hint { position: absolute; bottom: 14px; z-index: 2; margin: 0; color: #5f7b73; font: 11px ui-monospace, monospace; letter-spacing: 2px; }
+.inv-tool { position: fixed; inset: 0; z-index: 500; }
+.inv-back { position: absolute; top: 16px; left: 18px; z-index: 520; border: 1px solid #6f8f85; background: #0a211de6; color: #cdea6a; font-family: ui-monospace, monospace; letter-spacing: 1px; padding: 6px 14px; cursor: pointer; }
+.inv-back:hover { border-color: #cdea6a; }
+/* notebook tool (placeholder) */
+.notebook { position: fixed; inset: 0; z-index: 500; display: grid; place-items: center; background: radial-gradient(120% 90% at 50% 30%, #1c1a12 0%, #0a0906 75%); }
+.notebook-page { width: min(560px, 88vw); height: min(72vh, 720px); background: repeating-linear-gradient(#f4ecd6 0 30px, #e3d8b8 30px 31px); box-shadow: 0 20px 60px #0009; padding: 26px 30px; display: flex; flex-direction: column; gap: 14px; }
+.notebook-head { font: 700 13px IBM Plex Mono, monospace; letter-spacing: 3px; color: #6a5a3a; }
+.notebook-page textarea { flex: 1; background: transparent; border: none; outline: none; resize: none; font: 16px/31px "Courier New", monospace; color: #3a3020; }