Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e10a65d
[fix] UI batch: dropdown width, duplicate tint, modal key gate, windo…
AlexZ005 Aug 1, 2026
9e1cef4
[fix] color picker: revive it after the v4 rewrite + hex/rgb/hsv inputs
AlexZ005 Aug 1, 2026
ec486db
[fix] properties UX, info toasts, GitHub stars, PWA install
AlexZ005 Aug 2, 2026
6140f92
[fix] toast stack: one system, no overlap; spectating becomes a mode …
AlexZ005 Aug 2, 2026
4553c9e
[fix] progress toast lifecycle + share-or-stash waits for the user
AlexZ005 Aug 2, 2026
baece32
[fix] outline follows the selection SET; creation populates it; empty…
AlexZ005 Aug 2, 2026
c3b5954
[docs] CLAUDE.md + skills: roadmap #15 learnings (baseline 419/62)
AlexZ005 Aug 2, 2026
d4a0ef2
[fix] share-or-stash: no dismiss escape - Share or Stash must decide
AlexZ005 Aug 3, 2026
25af1f3
[feat] context menu redesign: header, sections, icons, hints, type-to…
AlexZ005 Aug 3, 2026
dbfe337
[fix] deselect releases the peer lock; Selected menu follows the set
AlexZ005 Aug 4, 2026
d632d83
[feat] context menu: filter hidden until typing, arrow-key navigation
AlexZ005 Aug 4, 2026
26537bb
[feat] node editor: search runs inside the context menu
AlexZ005 Aug 4, 2026
13a661e
[feat] grid + snapping settings; menu marks the active choice
AlexZ005 Aug 4, 2026
591f249
[feat] camera section in Configure Scene; named bookmarks with lenses
AlexZ005 Aug 4, 2026
d7e0fe3
[feat] scene camera objects: frustum viz, preview, control, capture
AlexZ005 Aug 4, 2026
7f29022
[docs] CLAUDE.md: roadmap 16 modules, traps and status
AlexZ005 Aug 4, 2026
cc8771f
[docs] CLAUDE.md: restore backticked terms mangled by shell substitution
AlexZ005 Aug 4, 2026
b0a49f3
[fix] camera control: no view jump, no orbit-controls leak
AlexZ005 Aug 4, 2026
a02eded
[feat] context menu: sticky search, remembered cursor, fixed anchor
AlexZ005 Aug 4, 2026
6d1726d
[feat] panel deep links, grid look-at follow, snap values, themed che…
AlexZ005 Aug 4, 2026
2b4e19f
[feat] one numeric field everywhere: live typing, arrow-key steps
AlexZ005 Aug 4, 2026
3d1be25
[feat] camera preview window (PiP) and a Capture row
AlexZ005 Aug 4, 2026
58a7e97
[docs] CLAUDE.md: roadmap 16 second drop (delegation trap, PiP, numer…
AlexZ005 Aug 4, 2026
d0db189
[fix] gizmo drags no longer orbit the view; menu, grid, deep-link and…
AlexZ005 Aug 4, 2026
6f9f321
[docs] CLAUDE.md: Q5 gotchas (gizmo/orbit suppression, HMR-lying suit…
AlexZ005 Aug 4, 2026
9168540
[fix] look-at survives a camera preview; snap rows, caret, search hei…
AlexZ005 Aug 5, 2026
7db1606
[docs] CLAUDE.md: vacuous-assertion + remount-defaults gotchas (16-Q6)
AlexZ005 Aug 5, 2026
7b340e0
[docs] skills + CLAUDE.md: roadmap 16 learnings
AlexZ005 Aug 5, 2026
d7e6a61
Merge pull request #86 from theprototype-app/fix/roadmap16-menus-cameras
AlexZ005 Aug 5, 2026
e8d038a
Merge pull request #85 from theprototype-app/fix/roadmap15-q-menu
AlexZ005 Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 91 additions & 8 deletions .claude/skills/e2e-verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,30 @@ peer id), `connect(from, to, settleMs=9000)`, `check(ok, label)`,
screen pixel for real clicks), `finish(browser)` (exit code), `run(body)`.

Rules: never run suites in parallel AGAINST THE SAME dev server, never edit sources
while one runs (HMR reloads the pages mid-test).
while one runs (HMR reloads the pages mid-test — see "HMR churn makes runs LIE").

## Assertion discipline (a check that cannot fail is not a check)

The expensive failures in #16 were not broken code — they were assertions that
passed while the user watched the feature misbehave:

- **Position/layout asserts need a TIGHT BAND and a forcing start state.** "the
section label is somewhere below the sticky header" is true when NO scrolling
happened at all (short panel = most sections collapsed), so a deep-link check
green-lit a link that never scrolled. The fix: expand every section, scroll the
panel to the BOTTOM first, then demand `0 <= gap <= 40px` (panel-deeplinks).
- **Isolate a REGRESSION with an A/B, not an absolute.** "dragging the gizmo must
not rotate the view" can pass vacuously (the drag missed the gizmo) or fail
innocently (left-drag on empty space orbits BY DESIGN). Measure the same gesture
before and after the suspect sequence and compare — plus assert the gesture did
its job (the object moved), so a no-op can never look like a pass
(gizmo-orbit-leak).
- **Match the metric to the gesture**: in OrbitControls LEFT-drag rotates and
RIGHT-drag PANS — a right-drag "orbit works" check that compares quaternions
reads 0.0000 forever. Compare `camera.position` for pans, `quaternion` for
rotation.
- When a check reports success but the user reports failure, re-read the check
before re-reading the code: ask what state would make it fail.

**Parallel lanes (multi-session work, 2026-07-21):** each session works in its own
`git worktree` (e.g. `../theprototype-lane-flow`) with its OWN dev server on its own
Expand All @@ -40,13 +63,19 @@ through on this npm version: it parses them as npm config, vite gets `dev 5177`
as a positional and binds a random free port over plain http.)

Assigned ports: main checkout 5173 (the user's), lane-c 5174, lane-vr 5175,
lane-ui 5176, lane-flow 5177, lane-aiphys 5178, lane-editmesh 5183. Two-peer suites still meet on the signaling server
lane-ui 5176 (SHADOWED 2026-08-02 — moved to 5186), lane-flow 5177, lane-aiphys 5178, lane-editmesh 5183. Two-peer suites still meet on the signaling server
(now the self-hosted peerjs.theprototype.app box), so concurrent lanes' test peers
never collide (random ids). PORT-SHADOW TRAP: another process holding only
`[::1]:PORT` does NOT trip `--strictPort` (vite binds 0.0.0.0) — but
curl/playwright resolve localhost to ::1 and hit the STALE server (symptom: new
modules 404 to index.html, `__stores` missing new keys). `netstat -ano` and
check BOTH stacks before blaming your build.
modules 404 to index.html, `__stores` missing new keys, or your edits "not
applying" while the suite runs old code). `netstat -ano` and check BOTH stacks
before blaming your build — and before trusting ANY lane server, prove it
serves YOUR code: `curl -sk https://localhost:PORT/src/lib/<file>.js | grep
<your-new-symbol>` (a stale pre-PATH-flip node-20 vite on [::1] burned a full
debug cycle in #15). Remember two-peer runs ALWAYS need the PEER_CONFIG env on
a localhost APP_URL — `helpers.connect` times out on the Approve button
otherwise (the app dials the local :9001 server that isn't running).

## The debugStores hook — the ONLY sanctioned test API

Expand All @@ -61,7 +90,9 @@ palette, viewModeCtl, inputRuntime, shortcutsRegistry, themes, vrRadialMenu,
vrPalette, vrWindowPoses, vrKeyboard, faceEdit, avatarModel, explorer, bottomDock,
explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets, THREE,
GLTFExporterModule, snapping, flowSockets, networkQuality, packs, customNodes,
nodesHandler, nodeCatalog, objectMenu, flowGraphsCtl, objectFlow, vrSleeve` (+ from the
nodesHandler, nodeCatalog, objectMenu, flowGraphsCtl, objectFlow, vrSleeve,
gridSettings, cameraBookmarks, cameraObjects, cameraHelpers, cameraPreview,
cameraPip, addObjects` (+ from the
flowStore spread: `flowGraphs, activeGraphId, setActiveGraph, allNodes, allEdges,
findNodeAnyGraph, SCENE_GRAPH`; `moduleSDK.pointerRayNow()` = the api.pointerRay
internals, `moduleSDK.applyModuleMessage(msg)` = simulate a PEER's module message
Expand Down Expand Up @@ -94,6 +125,29 @@ const value = await page.evaluate(() =>
modules manager via `#open-modules-manager` (drawer: `closeMenu.set(false)` first)
or `modulesOpen.set(true)`; module cards `#module-card-<id>`; draw `#draw-toolbar`;
dungeon `#dungeon-panel`; script editor close `#script-panel-close`.
- **Real-mouse GIZMO drags**: never guess a pixel offset from the object — find the
actual picker and project it. `const helper = controls.getHelper?.() ?? controls;`
then `helper.traverse(n => { if (n.isMesh && n.name === 'X') pick = n })`,
`pick.getWorldPosition(v).project(cam)` → screen px (gizmo-orbit-leak). three keeps
the gizmo VISUALS in that helper object, so `controls.visible = false` hides
nothing — hide the helper.
- **Panels scroll**: a field can be off-screen (`y: -664`) after an earlier
deep-link/scroll in the same suite — `await locator.scrollIntoViewIfNeeded()`
before `boundingBox()`/mouse work, or the events land nowhere and the failure
looks like broken behaviour.
- Inspector section HEADERS are `<button class="ui-section-label">` containing the
label AND a `−`/`+` glyph — match with `startsWith`, never `===`. A collapsed
section renders no children, so query its contents only after expanding
(`localStorage["inspector:sec:<label>"] = "open"` before load, or the deep-link
store `inspectorScrollTo`).
- The numeric field is `.dn-wrap` (wrapper, carries `.dn-scrub`/`.dn-focus`) with
`.dn-input` inside — the old `.drag-number` button is gone. It is always a real
input: typing applies LIVE, ↑/↓ step one minor unit (Ctrl ×10, Shift ×100), a
drag scrubs, Esc reverts.
- Context menus: `.ctx-filter-input` (always mounted, focused, collapsed until you
type), `.ctx-match` rows in search mode, `[data-ctx-active="true"]` = the keyboard
cursor, `.ctx-grip` = the search-list resize handle, and a SUBMENU is a fixed div
with NO role attribute (several suites locate them that way — do not add one).
- Programmatic scene setup: `__stores.commandsHandler.sceneCommand('/create box')`
(geometry names are capitalized THREE types — box/sphere/Button…, NOT "cube").
- Icons are `@lucide/svelte` `<svg>` components (Font Awesome removed): select
Expand All @@ -108,6 +162,20 @@ const value = await page.evaluate(() =>
- Context menus render `role="menuitem"`; group items CONTAIN submenu text — anchored
regex `/^Exact label$/` + `.last()` if needed.
- Action toasts have buttons now — `getByRole('button', { name: 'Approve' })` etc.
Toast entries may be STICKY (`{id, sticky:true, kind:'info'}` — restore-session,
first-run notice, share-or-stash): they never auto-expire and never fold into
"+N more", so don't wait them out — click an action or `dismissToastById(id)`.
State that lives INSIDE a component (not a store) needs its own opt-in hook,
gated on `debugStores` — the pattern is one `$effect` publishing a getter:
`window.__outlineDebug()` → `{selected, locked}` mesh counts,
`window.__cameraPreviewDebug()` → `{preview, hasObject, cameraMounted,
controlsMounted, defaultCamera, defaultIsMine}` (this is what settled the "is the
camera swap broken?" question: the component said `defaultIsMine: true` while a
stale page said otherwise). Module-level probes are plain exports:
`cameraHelpers.cameraHelpersDebug()`, `cameraPip.pipDebug()`,
`colliderHelpers.colliderHelpersDebug()`.
The outline effect isn't in `__stores` (it lives in Outline.svelte) — read it via
`window.__outlineDebug()` → `{selected, locked}` mesh counts (debugStores-gated).

## Repo-external modules (theprototype-app/modules)

Expand Down Expand Up @@ -156,6 +224,14 @@ drops the P2P session.
is flaky to open headlessly — assert profile mounts at the STORE level, not by
clicking `#avatar-menu`. A `transition:slide` element stays in the DOM through the
~200ms out-transition — poll with `eventually`, don't assert `count===0` immediately.
- **HMR churn makes runs LIE** (cost ~4 cycles in #16-Q5): a page that loads while
vite is still re-transforming just-edited modules gets a half-mounted app —
components that exist in the source simply are not there, so a WORKING feature
reports broken (three runs "proved" the camera preview dead; a fresh run passed
untouched). Let the server settle a couple of seconds after your last edit, never
edit during a run, and treat a red run that started right after a save as
unproven. When store reads disagree with what you see, add a COMPONENT-side debug
hook and compare the two (below).
- First run after adding a dependency: vite re-optimizes and reloads mid-test — rerun.
Lazy wasm (rapier) needs a throwaway prewarm page first (see physics.test.cjs).
Physics sims run REAL-time since #12 (fixed-timestep accumulator) — falls/settles
Expand All @@ -179,10 +255,16 @@ drops the P2P session.
full old-deps/new-deps baseline comparison — treat as the dirty baseline, not
regressions): the drag-drop-SIMULATION cluster (explorer-drop, explorer,
packs-drop) + user-modules (setup crash), open-core-m1 (1 drawer check),
dock-sidebar-inset, layout, node-search, panels, script-nodes, and a few
dock-sidebar-inset, layout, panels, script-nodes, and a few
two-peer timing suites (module-sdk, scene-music, physics-kinematic,
physics-discoverability, roadmap-13-notifications-notes, scene-assets,
view-mode, vr-passthrough).
view-mode, vr-passthrough). `node-search` came OFF this list in #16-P2 — two of
its assertions were stale (they demanded menus never scroll and are never
height-capped, which a later change deliberately reversed). When a "known
failing" suite blocks you, check whether it is asserting the OLD contract.
- `add-menu` documents its own flake in a comment at the failing line (a right-tap
that does not open the viewport menu) — the fastest proof that a failure is not
yours is still `git stash push -u` → run → `git stash pop`.
- Long full-suite runs: the Bash tool caps at 10 min — launch the runner DETACHED
(PowerShell `Start-Process node -ArgumentList 'tests\e2e\run.cjs ...'` with
output redirects) and poll/Monitor the log. A dev server started via the Bash
Expand Down Expand Up @@ -270,7 +352,8 @@ drops the P2P session.
(the runner just `node`s each file; see net-backoff.test.cjs). Track PASS/FAIL locally
and `process.exit(1)` on failure (helpers.finish needs a browser).
- svelte-check delta hunting: `npx svelte-check --output machine | grep <yourfile>`;
baseline 2026-08-01 = **435 errors / 62 warnings** (node 24, all A-D migrations in) (drifts down as flowbite/typed
baseline 2026-08-02 = **419 errors / 62 warnings** (node 24; #15-C's one-way
pickers dropped 14, #15-K's outline rework 2 more) (drifts down as flowbite/typed
code is removed — hold whatever it currently is; add no NEW; the release.yml gate
hardcodes the numbers — update it when the baseline moves). Note: in the big
JS-mode `.svelte` files (Scene.svelte) `@param {T}` JSDoc on a function is NOT honored —
Expand Down
57 changes: 54 additions & 3 deletions .claude/skills/peer-feature/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,41 @@ polled locally), the Explorer **pack library** (imported packs stay local until
explicit future "Share"; only a *placed* object replicates through the normal import
path), input claims (`inputRuntime.claimInput` — a claim only pauses THIS peer's own
input consumers, nothing on the wire), view mode/shadow quality/sculpt brush prefs,
and the LOCAL-prefs modules (themes, cameraClip, WindowShell `ws:*`). Rule of thumb:
the LOCAL-prefs modules (themes, cameraClip + orbit feel, gridSettings, WindowShell
`ws:*`, remembered menu sizes `ctx:searchHeight:<kind>`), and the whole camera
PREVIEW/PiP layer (#16: the camera OBJECT is shared scene content, but looking
through it, the picture-in-picture window and the frustum wireframes are yours
alone — only "X is previewing camera Y" goes on the wire, as presence). Rule of thumb:
if two peers would independently compute the same value, or it's a personal setting,
keep it off the wire.

## The cheapest replicated feature: put it on `userData`

Before designing messages, ask whether the feature is really "extra settings on an
object". If so, store them on `userData.<feature>` and you inherit replication,
sessions, autosave, prefabs and GLTF extras for FREE — `userData.physics`,
`userData.particles`, `userData.terrain`, `userData.camera` (#16-P5 scene cameras)
and the `__localOnly`/`__uuid` markers all ride this. The recipe:

1. Creation stamps deterministic defaults in the same place the object is built
(`geometries.svelte.js` — every peer runs the same `/create`, so no message is
needed for the initial state). Keep that literal INLINE if the settings module
would close an import cycle (cameraObjects reaches history; geometries sits in
history's subtree — the camera defaults are duplicated on purpose, commented).
2. ONE write path — `setPhysicsFor(uuid, patch)` / `setCameraFor(uuid, patch)`:
mutate userData, `recordEntry({kind:'props', before, after})`, send
`{type:'objectParameters', parameter:'<feature>', uuid, <feature>}`, then poke
`objectsGroup.update(v => v)` (THREE trees are not reactive — the poke is what
the UI, the viz and any derived store actually see).
3. Add the `parameter` case to `commandsHandler.objectParameters` AND the key to the
`'props'` history applier in objectActions, so remote writes and undo/redo both
land. **Verify the applier with a two-peer suite**: a missing case is invisible
locally (a #16 edit to that file silently never hit disk — the peer test caught
it when the message provably arrived but nothing changed).
4. Anything VISUAL built from that data (frustum wireframes, collider proxies)
belongs at the SCENE ROOT keyed by uuid, rebuilt from the userData and following
the object per frame — never as a child of the object (rule 5 below).

## Checklist for a new replicated feature

1. **State** in a store (`src/stores/*`) or module-level writable; uuid/id-keyed,
Expand Down Expand Up @@ -69,7 +100,23 @@ keep it off the wire.
helpers, env rig, module content; rebuild them from state; they need
`registerInteractiveGroup(name)` to receive viewport clicks. Content that can't
round-trip (skinned rigs) syncs as original file bytes (`animatedImports`).
6. **Cleanup** on peer loss in `commandsHandler.handleDisconnected`.
6. **Cleanup** on peer loss in `commandsHandler.handleDisconnected` — and if you keep
a per-peer map of your own, clear it in BOTH teardown paths in
`peerHandler`: `onConnClose` AND `leaveSession` (they are separate call sites;
`cameraPreviews` needed the entry removed in both, #16-P5).
**PRESENCE-style state** (who is previewing which camera, who is watching whom)
is the lightest replicated shape there is: one message on change
(`{type:'campreview', peerId, uuid|null}`), a `Record<peerId, value>` store,
cleanup on disconnect, and for late joiners piggyback an EXISTING handshake
request instead of inventing a `get*` round trip (`sendCameraPreviewState()`
rides the `getmodulestate` reply). No history, no undo — presence is not scene
content.
**Selection LOCKS need an explicit RELEASE**: a `{type:'lock', uuids}` message
only ever REPLACES the sender's set (`lockGeometry` ignores an empty list), so
dropping a selection must send one `{type:'unlock', peerId, uuid}` per released
uuid — otherwise peers keep the object highlighted and "locked by X" forever
(#16-P6; `broadcastSelectionRelease` covers deselect, `applySelectionSet([])`
and switching to a locked-VIEW).
7. **Undo** (if it mutates the scene): `history.registerHistoryKind(kind, apply)` —
the applier replays the normal replicated action; `recordEntry` is auto-muted while
history applies, so replays can't re-record. Object presence uses
Expand All @@ -84,7 +131,11 @@ keep it off the wire.
menu (`Controls.svelte objectMenuItems`), shortcuts registry (`shortcuts.js` — one
registry drives bindings AND the Settings list), VR quick-menu
(`vrControls.executeVRMenuAction` + `VRMenu.svelte` tiles), action toasts
(`showToast(message, [{label, action}])`).
(`showToast(message, [{label, action}])` — 15s, auto-expires; a decision the
user MUST answer takes `showInfoToast(id, text, actions, onDismiss)` instead:
sticky, never folded by the "+N more" cap, removed via `dismissToastById(id)`;
make the ✕/onDismiss path take the SAFE default — the share-or-stash gate in
sessions.js is the reference, #15-P2).
9. **Verify two-peer** per `.claude/skills/e2e-verify/SKILL.md`; add a suite in
`tests/e2e/`; expose new singletons via the App.svelte `__stores` hook; one
`[feat] ...` commit.
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ jobs:
- run: npm ci
- run: npm run build
# svelte-check exits non-zero at the legacy baseline; fail ONLY when the
# counts grow past it (baseline 435 errors / 62 warnings, 2026-08-01, node 24)
# counts grow past it (baseline 419 errors / 62 warnings, 2026-08-02 — the
# #15 C one-way pickers dropped it from 435, K's outline rework from 421, node 24)
- name: svelte-check baseline gate
run: |
npm run check 2>&1 | tee check.log || true
Expand All @@ -34,9 +35,9 @@ jobs:
ERRORS=$(echo "$LINE" | awk '{print $2}')
WARNINGS=$(echo "$LINE" | awk '{print $5}')
fi
echo "svelte-check: $ERRORS errors / $WARNINGS warnings (baseline 435/62)"
echo "svelte-check: $ERRORS errors / $WARNINGS warnings (baseline 419/62)"
if [ -z "$ERRORS" ]; then echo "could not parse svelte-check output"; exit 1; fi
if [ "$ERRORS" -gt 435 ] || [ "$WARNINGS" -gt 62 ]; then
if [ "$ERRORS" -gt 419 ] || [ "$WARNINGS" -gt 62 ]; then
echo "baseline exceeded"; exit 1
fi
- name: zip the build
Expand Down
Loading