Skip to content

v1.13.0 β€” Knock it about πŸͺ (roadmap #24 lanes A/B, looks & materials, mesh/windows/flow) - #217

Merged
AlexZ005 merged 23 commits into
mainfrom
release/next
Sep 18, 2026
Merged

AlexZ005 merged 23 commits into
mainfrom
release/next

Conversation

@AlexZ005

Copy link
Copy Markdown
Collaborator

The four v1.13 feature lanes, stacked onto 1.12.0 and integrated in #216 after the hardening batch rewrote the files they shared.

lane what
24-stars the knock, the onhit node and api.onHit, author-script hoist, the Stars Room
looks-materials per-camera looks P2-P6, then shared materials
mesh-windows-flow proportional falloff for rotate/scale, docked edge splits, flow editor mouse bindings
24-football the VR football module (modules repo), 89/89 on node tests and the two-peer flight

Also merged alongside: scenes #3 (the Stars Room artefact) and modules #3 (the football module).

Worth knowing

  • The integration was the work here, not the features. All four lanes forked from 1.11.0, and release/next had since taken roadmaps 25-27, which rewrote peerHandler, commandsHandler, Toasts, App, autosave, physics and sceneStore. v1.13 β€” the three feature lanes, stacked on 1.12.0Β #216 resolved that against both sides' intent.
  • A conflict-resolution trap worth repeating, from the orchestrator handover: git checkout --theirs -- <file> discards the WHOLE ours side of that file. It silently dropped an import('./lib/knock') a hundred lines above the conflict and shifted every later window.__stores binding by one. The three debug-hook tails must count equal β€” 209 on feat/1.13.
  • The CHANGELOG section was renamed from ## Unreleased to ## 1.13.0 before the bump, deliberately: leaving it is what filed the hardening entries under the shipped 1.11.0 heading last time.

Owed, not done

πŸ€– Generated with Claude Code

AlexZ005 and others added 23 commits September 12, 2026 11:47
… body

Core could grab and throw, and a node could push a body, but nothing let a
hand or a walking player HIT an object and have it leave at the speed it was
hit - VR hands were grab-only, On Impact only sees falling contacts, and the
character capsule never touches anything. The knock is that one seam, and both
roadmap 24 games (Stars Room, VR Football) stand on it.

- knockMath.js (a LEAF: THREE + throwVelocity) - the probe ring, the
  sphere-vs-bounding-sphere contact test with the approach speed along the
  normal, the response v' = v_body + n * approach * gain (an infinite-mass
  hand), the spin off the TANGENTIAL slip (a sphere contact is always central,
  so the plan's normal-impulse cross product is zero by construction), one
  clamp (clampThrow, then the scene's maxSpeed below it), and the one-knock-
  per-pass cooldown with a 60 ms hysteresis on the way back in.
- knock.js (the runtime, playInteract's shape) - VR hands through a seam Scene
  passes in (handSnapshot; knock.js never imports vrControls), the desktop
  camera as a 0.35 m head probe, both carried into the objects group's frame so
  a bent VR rig cannot put a hand and a ball in two spaces; candidates from
  listPhysicsObjects (refreshed every 200 ms for spawned bodies) minus what this
  peer carries; body velocity exact off the initiator's rapier body, a pose
  ring off the move stream elsewhere; the `hit` message from the HITTER
  whoever it is; the non-initiator's local PREDICTION behind knock.predict,
  withdrawn after 400 ms if authority never confirms it; the hit log (last per
  body + a ring of 32, runtime state, no history kind, no handshake reply) and
  registerHitListener, the seam A2's onhit and api.onHit hang on; feedProbe,
  the test hook that sweeps a probe on its own clock.
- physics.applyHit - the throw's sibling: initiator-only, clampThrow plus the
  scene's maxSpeed, CCD over 5 m/s, a held body refuses; bodyVelocityOf.
- scenePhysics gains the ADDITIVE `knock` nested block, enabled:false by
  default, so Towers and every saved scene are byte-identical (the suite's
  counterfactual). Zero new singleton, zero handshake work.
- `hit` is CONTENT: gateable by canApply like `throw`, ROOM_SCOPED like
  `move`, `by` stamped from the connection, never the payload.

knock-physics (77 checks, node-pure + one page + two peers): monotonic in
probe speed, gain, maxSpeed, receding/resting/slow do nothing, one per pass,
held bodies skipped, the log, the block-off counterfactual, the play and sim
gates, the wire crossing with the same stamp on both logs, prediction on and
off, the capability gate dropping it and the prediction reverting, the
initiator's own knock reaching the other log. scene-physics-state grows the
block's defaults, clamps, merge and the pre-knock-file restore.

STATUS-24a.md carries the as-built and the one finding worth a follow-up: a
late joiner is never told a sim is running (`simulate` goes out at start/stop,
not in the handshake), so its probes - and play-mode grab - stay down until
the sim restarts. Pre-existing; the suite starts the sim after the join.

svelte-check 362/47 (the pristine baseline, re-measured here first).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H2ike21X4j6x1eKZTXJjia
- a proportional vertex drag ends with ONE whole-geometry meshgeo commit
  (commitMeshGeoSnapshot: applied locally, broadcast, recorded) instead of
  the selection-only verts stream β€” the falloff neighbourhood was never on
  the wire before, so a peer saw the selected vertex move and the bulge
  around it never arrive (19-A P4 gap; plan option A, locked)
- the LOCAL apply is load-bearing: applyMeshGeo rebuilds the receiver
  non-indexed while a /create Plane is indexed, so both sides swap together
  or the sender's next verts indices address a layout the peer no longer
  holds (the undo path's rule)
- the swap rebuilds handles in triangle order and the refresher clamps the
  selection by COUNT only, so the selection is captured as positions before
  the commit and re-found by position after (commitFalloffSnapshot)
- a commit refused by MAX_SNAPSHOT falls back to the old verts + entry path
- suite mesh-falloff-sync (two peers, 24 checks): B's neighbours match A's
  corner for corner, both hold the same layout, a later plain drag lands on
  the same corner for B, one undo flattens the bulge on both
- counterfactual: with the end-of-drag commit replaced by `false`,
  mesh-falloff-sync reads B's halfway neighbour 0.0000 vs A's 0.5000 (red)
- held: mesh-proportional 63, mesh-pivot-gizmo 131, mesh-edge-gizmo 26 (base)
- svelte-check 361/47, list identical to base; build green (server stopped)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm
- decision (plan option A, the interaction itself stays WONTFIX): the slide
  measures from the PROXY and a placed pivot seats the proxy away from the
  vertex, so a slide under a pivot would project from an unrelated point;
  slide constrains ONE vertex to its own edge, a pivot transforms a SET
  about a chosen centre β€” different questions, no evidence anyone wants
  both at once
- the tool silently doing nothing was the only real problem: proxyLocal
  now toasts once per session when the slide is armed, a drag has started
  and a custom pivot is placed (slidePivotTold, reset in exitEditMode)
- no wire, no history, no toolbox change; held mesh suites at base
- svelte-check 361/47, list identical to base; build green (server stopped)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm
…mulate in the handshake

- `onhit` trigger node (Triggers, beside onimpact): minSpeed, who anyone|me|others;
  fired on EVERY peer from the `hit` application (fireObjectHit) as a trigger stamp
  derived from the message's own `at` β€” one message per knock, no nodetrigger, the
  stamp literally equal on every peer; value outputs speed/byMe as a handle map with
  a `__default` pulse on the unnamed dot (unwrapHandle grew `__default`; every earlier
  handle-map producer omits it, so they are byte-unchanged)
- own card OnHitNode.svelte (pulse dot + speed/byMe rows, the MoveInput shape),
  registered in Nodes.svelte's CORE_NODE_TYPES (the two-registry gotcha)
- api.onHit(cb) -> unsubscribe + api.hitLog(); the knock import is PRIMED and kept as a
  promise so a listener registered at module boot is not dropped (the DEVX #8 family)
- a LOCAL VR hit buzzes the hitting hand (min(1, 0.2 + speed/10), 30 ms) through a
  haptic seam Scene passes into startKnock (hapticPulse); the head probe buzzes nothing
- sendHandshake pushes `simulate` when this peer is simulating (A1's late-joiner
  finding: the probes and play-mode grab never armed for a joiner mid-run), held with
  the singletons under the same !holdContent condition; additive, the start message's
  own shape
- Inspector > Physics > Knock: enabled checkbox + Gain / Max speed / Probe radius / Spin
  rows, data-anchor Knock -> openSceneSection('Physics:Knock')
- suite knock-node (50 checks: two peers + a late joiner, PEER_CONFIG, GPU_ARGS): who and
  minSpeed on both peers, the equal stamp, speed/byMe, the per-player touches banked
  once per hit per peer (the 21-F3 double-bank counter-case), the SDK payload agreeing
  with the node's stamp, the handshake simulate, the Inspector rows + deep link, the
  haptic seam
- counterfactuals, each red then restored: fireObjectHit without its applyNodeTrigger
  -> 23 pass / 11 fail (2.2, 2.4, 2.5, 2.7, 2.11, 3.2-3.4: nothing stamped anywhere); the who gate removed -> 44 / 7 (2.3, 2.6, 3.3, 3.4 and the double-bank 2.11/3.6: `me` fired on the other peer); the handshake simulate push
  removed -> 47 / 4 (5.1-5.3: the late joiner's remoteSimulating stays null); unwrapHandle without __default -> 49 / 2 (2.10: the unnamed edge reads undefined, the Math node its 5 fallback)
- held: knock-physics 77/0, scene-physics-state 37/0, play-interact 46/0, throw-peer 28/0,
  flow-unknown-node 24/0 (the new card resolves), game-towers 20/0; flow-spawner 39/0
  39 pass then `SCRIPT FAILED:
  page.waitForSelector('.svelte-flow') Timeout 15000ms` β€” A/B'd AND PRE-EXISTING: pristine
  origin/release/next src on this same box, server and lock fails IDENTICALLY (39 pass, same
  timeout at the same point). It is the suite's own last check in Β§7, which opens the node
  editor by clicking `p[title="Node editor (N)"]`; that string exists only as a menu-item
  PROPERTY in Controls.svelte, so the opener looks stale on this build β€” a separate ticket,
  not this lane's (flow-unknown-node mounts the same editor 24/0 with the new card); palette-groups 10/1 β€” the one red is PRE-EXISTING on release/next
  (the Music group has no NodeWrapper accent; NodeWrapper untouched here)
- svelte-check 361/47 (base 361/47, identical entry list); build green

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013je8UxeJpoMSRcyejTiNFj
The PR #192 follow-up, done before the second game's graph is written.

- graphBuilder() at module scope: N/E hoisted from towersGraph()/beatGraph(); E takes
  both handles (the beat superset β€” a 3-arg call yields the Towers id verbatim), and
  both graphs return g.done()
- remapData walks every OWN string field and string array, replacing a def-local
  object name with its uuid, except HUMAN_TEXT_KEYS (label/format/text/placeholder/
  name); uuid/selected/camera/hash fall out as ordinary cases; the Set crosses into
  the page as a list argument
- scripts/compare-authored.cjs: canonicalises what a build mints afresh (session
  id/createdAt/appVersion/thumbnail, changedAt/startedAt/at stamps, every uuid by
  first appearance) and diffs two authored trees; the ritual is in the script header
- PROVEN byte-identical on Towers: `--only towers --out` before (the pre-A3 script)
  and after -> SAME games/towers/scene.tpscene (76676 canonical chars, the inline
  thumbnail 6607/6607 and thumb.webp 2968/2968 identical), SAME index.json; the collectible
  module loaded in both builds (no SKIP)
- held: game-towers 20/0
- svelte-check 361/47 unchanged (no src change)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013je8UxeJpoMSRcyejTiNFj
- proportional editing was a TRANSLATE tool: applyPivotTransform turned/scaled
  the selected set only and left the falloff neighbourhood exactly where it
  was. It now blends the WHOLE weighted neighbourhood β€” the plan's option 3,
  the weighted transform blend: per vertex the gesture's rotation is slerped
  and its scale lerped toward identity by that vertex's smoothstep weight,
  then applied. For a pure rotation that is the conventional weighted ANGLE
  (a w=0.5 vertex turns half way, so a straight row becomes a spiral, which
  is what Blender does) and it is the only reading that stays defined when
  rotate and scale combine.
- the selection keeps w = 1 by construction (beginFalloff), so a rotate with
  nothing in range is byte-identical to before.
- fixes a pre-existing hole the blend exposed: recaptureVertexFalloff (the
  mid-drag wheel resize) always re-applied as a TRANSLATE, so resizing the
  radius during a rotate/scale displaced the neighbours; it re-applies
  through applyProxyGesture (which dispatches by mode) when a gizmo gesture
  is live, keeping the translate shape for the VR/no-gesture path.
- replication and undo come free: a falloff gesture already commits one
  whole-geometry meshgeo (F3).
- suite mesh-falloff-rotate-scale (22 checks): swept ANGLES about the pivot,
  never distances (every invariant a rotation preserves is preserved by a
  WRONG rotation too) β€” 75.94 / 45.00 / 14.06 degrees at the three weighted
  rings, the rim vertex unturned, a vertex beyond the radius byte-identical,
  one undo exact, the scale factors lerp(1, 2, w), and proportional OFF still
  turning the selection alone.
- counterfactual: with the weight forced to 1 the suite reads 7 FAILURES β€”
  every weighted vertex sweeps 90.00 degrees, the spiral twist is 0.000 and
  every scale factor is 2.
- two fixture traps this suite paid for, both recorded in its header: an
  attribute INDEX recorded before a meshgeo commit addresses a DIFFERENT
  vertex after it (F3's commit rebuilds the mesh index-expanded), so every
  tracked vertex is recorded in both layouts; and a 4-wide plane has no
  vertex beyond a radius of 2, so the grid is 6 wide.
- held: mesh-proportional 63, mesh-pivot-gizmo 131, mesh-falloff-sync 24 (base)
- svelte-check 361/47, list identical to base; build green (server stopped)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm
Watching a peer already adopts their CAMERA; it now adopts the state that decides
what that camera LOOKS like, which is the only way a camera-scoped or locally
overridden look is observable from outside. Presence, never data.

- `lookPresence.js` (a LEAF, the `campreview` shape): a per-peer `lookstate` row
  {camera, mode, overrides:{post,shaders}, look} published on CHANGE (signature
  gated, never on a timer), replied to `getmodulestate` beside
  sendCameraPreviewState, and dropped at both finalizeDisconnect sites. ADDITIVE:
  an older build never sends one, its row stays ABSENT, and the watcher falls back
  to its own state β€” which is exactly what it did before this existed.
- Outline.svelte resolves the chain from the WATCHED peer's row while
  `specatorMode` names them: the camera they look through and its own look, their
  view mode, their local post switch and their Set Look overrides.
  `resolvedDoc(key, overrides?)` takes the override map as an argument so the
  watcher never writes anything of theirs into its own stores β€” leaving the watch
  reverts by construction. `__postDebug().adoptedFrom` names whose state it is.
- The watch banner SAYS when adoption cannot take effect (they shared no row, or
  they have the scene look switched off) β€” the P1 lesson that a viewpoint-scoped
  feature must speak on its own surface, since silence there is indistinguishable
  from a dead wire.
- Suite `watch-look`, 45 checks on two peers: the handshake row both ways, live
  changes (camera, Set Look, the local switch, wireframe), watch adopts, stop
  reverts, an absent row falls back, and A disconnecting mid-watch strands nothing.

Counterfactuals, each proven by breaking the code and watching it go red:
- drop the `adopted` branch in Outline's chain effect -> 10 red (2.3/2.7/2.9/2.10/
  2.13/2.14/2.16/3.1/4.2/4.6): B renders its own chain while watching.
- remove `dropPeerLook` from the two disconnect sites -> 5.1/5.2/5.3 red: B stays
  stranded on a departed peer's look.
- remove the module-level send-on-change subscribes -> 1.5 red (the camera change
  never reaches B's row) and Β§2 follows it down.

Also fixed on the way, both found by those runs:
- a duplicated, mis-indented `dropPeerLook` in leaveSession's loop (idempotent, so
  harmless, but it read as a mistake).
- the suite selected `.peer-watch`, which is ALSO worn by the join-a-peer's-camera
  button rendered beside Watch whenever that peer is previewing β€” the exact fixture
  this suite builds. B joined A's camera instead of watching it, so every reading
  was "fill-blue": the right answer for the wrong reason. It selects by exclusion
  now and asserts the button count. The same trap sits on `.spectator-exit`, which
  the camera-preview banner shares with the watch banner.

Gates: watch-look 45/45 Β· duplicate-parity 28/0 Β· scene-post-effects 41/1 and
shader-editor 64/2 (both pre-existing, reproduced at the base commit) Β·
svelte-check 361 errors / 47 warnings with the message set identical to base Β·
`npm run build` green with the dev server down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
- an edge holds up to TWO windows now, stacked: dropping a second floating
  window onto a docked panel (or onto that edge) splits the column
  vertically, a draggable divider sets the share, and undocking either
  member collapses it back to one full-height panel. The quiz decision
  stands β€” splits are for DOCKED panels and tabbing (83) for floating ones,
  and they cannot compete because headerTargetAt already skips docked nodes.
- the half you release over is the slot you take (upper half = the newcomer
  goes on top), shown before release by #dock-zone[data-split] over that
  half; a THIRD window on a full edge still wiggles the occupant.
- `docked` became {left: string[], right: string[]}; a persisted string from
  before reads as a one-element list, so an existing dock survives untouched.
  Heights are CSS calc against --bottom-inset, so the dock still pushes them
  and a SINGLE panel's style is byte-identical to before. The share persists
  per side (dockSplit:<side>, 0.15..0.85); the width is one column, so the
  side handle writes dockWidth: for every member and either grip resizes it.
- the divider sits INSIDE the top panel: every docked window is
  overflow-hidden, so a handle hung past the edge is clipped away and takes
  no pointer events at all (measured β€” the first one moved nothing and wrote
  no ratio; the width grip only works because half of its 6px is inside).
- a docked window's geometry belongs to docking.js, so the two "reveal"
  clamps that re-assert a window's own floating rect when it becomes visible
  again now stand down for it (dragWindow.js, and the object list's dragMe
  in Controls.svelte) β€” the same rule they already keep for a tab member.
  Without it, reopening a split edge after a reload threw both panels back
  to wherever they last floated: measured 164px -> 250px, the object list's
  default floating height.
- suite dock-splits (17 checks): the affordance, the two-panel geometry,
  the divider drag, the persisted share and stack, a refused third window,
  a reload, and the collapse on undock. docking.test grew the split case
  (8 -> 10) β€” an occupied edge no longer refuses.
- counterfactual: with the split target disabled the drop wiggles and one
  panel remains β€” dock-splits reads 7 FAILURES, starting at the affordance.
- held: docking 10, dock-float-exclusivity 8, dock-tab-drag 57,
  window-bottom-clamp 14, window-size-clamp 36, window-tabs 14,
  window-tabs-move 6 (all at base)
- svelte-check 361/47, identical to base once line numbers are normalised
  (a 4-line insertion in Controls.svelte shifts them); build green

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm
Layer 1 of the look has had a stack, a registry and twelve built-in kinds since
L1-L5; what it has not had is a way to AUTHOR a new one without writing a module.
A post graph compiles to a fragment over SCREEN buffers and enters the ordinary
scene stack as one more entry β€” so it replicates, saves, undoes, reorders and
MERGES with its neighbours with nothing new on the wire and no new history kind.

- `shaderCatalog`: a third stage, `post` (absent `stages` still means every stage,
  so the arithmetic and channel nodes serve both domains). New **Post** group, all
  post-only: Scene colour, Scene sample, Scene depth (linear + raw), Scene normal,
  Resolution, Bayer pattern, Edge detect, Ambient occlusion β€” plus `postOutput`,
  the domain's own terminal, and `outputNodeFor(domain)`. `normal` is now declared
  fragment+vertex: a screen pixel has no surface of its own, and the post domain
  reads the normal BUFFER instead.
- `shaderCompile`: the evaluator core extracted into `createCompiler(graph,
  outputType)` and shared, with a new `compilePostGraphToIR` walking the Post
  output's colour/alpha taps. `STAGE_EQUIVALENT` generalises the vertex-stage
  default translation to post (`vUv` -> the screen `uv`; a surface normal has NO
  equivalent, so a socket defaulting to one is refused BY NAME rather than reading
  a varying that does not exist in an EffectPass). `tpDepthAt` is emitted once
  whenever any node wants depth, so Edge detect and the AO node work in a graph
  that has no Scene depth node in it.
- `postGraphs.js`: the bridge β€” a post effect KIND `graph` whose `params.graph`
  names a `post:`-keyed document (the prefix SH1 reserved). `scenePost` never
  learns what a shader graph is and this module never touches the composer.
- `postGraphPresets.js` (pure data): posterise, ordered dither, depth+normal edge
  detect, graph AO. They PROVE THE SEAM rather than being hardcoded β€” each is an
  ordinary graph you can take apart, and between them they touch every input the
  domain has. Every number in them is a Float NODE: the arithmetic nodes take
  operands from sockets and have no params, so an authored `{b: 0.5}` would be
  silently ignored and the socket's 0.0 used.
- `scenePost`: two OPTIONAL registry members, both absent on every built-in so
  their signatures stay byte-identical β€” `signature(params)` for a kind whose
  output depends on state its params only POINT at, and `tick` for a live uniform.
  A post graph's signature is its compiled FRAGMENT, so a value scrub writes the
  uniform and only a STRUCTURAL edit rebuilds the chain.
- `Outline`: ONE NormalPass, built only while something in the stack reads normals
  (a second scene render is not an ambient cost), and the per-frame `tick` that
  feeds the shared shader clock without a rebuild.
- UI: a `graph` group in the Post-processing add menu offering the presets and the
  scene's existing graphs (its bare leaf would have added a row pointing at
  nothing), a graph picker + Edit on the row, and a Surface|Post switch in the
  shader editor β€” same cards, same palette filtered by domain, with the post half
  getting a scope picker because a post graph belongs to no object.

Counterfactuals, each proven by breaking the code:
- drop the `signature` fold in postStackSignature -> 5.4 red: a structural edit
  leaves the stale pass compiled into the chain.
- remove the NormalPass block in rebuildStack -> 4.2 and 7.3 red: edge detect
  renders with an empty normal buffer.
- remove POST_EQUIVALENT from STAGE_EQUIVALENT -> 1.8 red: a shared node's `vUv`
  default is emitted into a post shader, where it compiles and reads nothing.

Two suites carried an assertion this invalidates, updated here: the post library
is 14 kinds now (`graph:graph`), and `postOutput` joins `surface` as a terminal
that emits nothing.

Gates: shader-post-domain 48/48 NEW Β· the 19-suite shader/post battery at or above
base (camera-looks 43 Β· scene-post 93 Β· scene-post-ui 44 Β· shader-graph 67 Β·
shader-sync 20 Β· shader-persist 19 Β· shader-editor 64/2 and scene-post-effects
42/1, both pre-existing Β· watch-look 45) Β· svelte-check 361/47, message set
identical to base Β· `npm run build` green with the dev server down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
…ms measured

Most of layer 2 shipped with SH6b β€” `graphKeyFor` already resolves own graph ->
scene default -> the object's real material, `defaultTargetsFor('scene')` already
drives every mesh without one, and each object already keeps its own base colour
because the compile clones per object. What was missing was the LOCAL half, and
proof for two things the plan said were free.

- `viewportOverrides.shaders` finally renders something. It was DECLARED in B
  ahead of this phase precisely so layer 2 would add a renderer and not a new
  concept, and until now nothing read it β€” the Inspector even filtered the
  checkbox out of the Overrides list because it would have done nothing.
  `applyShaderLayer` in shaderGraph swaps every shader-driven object to its own
  material and back; the checkbox is in the list now, with a hint that says what
  it does.
- OFF IS A SWAP, NEVER A DETACH: the documents, the compiled materials and the
  base materials all stay, so switching back costs no compile and a peer sees
  nothing at all. A compile that lands WHILE it is off is remembered and not
  installed, which is not hypothetical β€” a peer editing the scene graph
  recompiles on my machine through that same path.
- Deliberately not `scene.overrideMaterial`: that replaces EVERY material in the
  scene, and this layer is only the ones a graph drives. Which is also why
  wireframe and the UV checker suppress layers 2 and 3 for free β€” they own that
  slot β€” and the plan asked for it to be asserted rather than assumed.

Suite `scene-default-material` (33 checks, two peers, pixels): the resolution
order and own-before-scene as two DIFFERENT material instances; one graph over
three objects each keeping its own colour; wireframe and the UV checker taking
overrideMaterial while the graphs stay attached underneath; the local override
including a recompile while it is off; a late joiner inheriting the scene default
through the scene key; and a scene using none of it carrying no documents.

Counterfactuals:
- remove the viewportOverrides subscribe -> 4.2/4.3 red: the switch does nothing.
- remove the install guard in applyMaterial -> 4.6 red: a recompile while the
  layer is off puts the material back on.

A trap worth recording, because the first version of that second counterfactual
PASSED against the bug: the injected material is a CLONE of the base, so both
read `MeshStandardMaterial` and a type check cannot tell "the layer is off" from
"the layer just installed something". The metric is material IDENTITY now
(`isBase`), which is what makes the guard provable.

Gates: scene-default-material 33/33 NEW Β· shader-graph 67 Β· shader-scene-default
16 Β· shader-persist 19 Β· shader-inspector 24 Β· shader-sync 20 Β· shader-post-domain
48 Β· shader-window 40 Β· shader-editor 64/2 (pre-existing) Β· svelte-check 361/47
with the message set identical to base Β· `npm run build` green, server down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
- Settings > Input > Node editor > Mouse bindings, a LOCAL pref in the new
  leaf src/lib/flowPrefs.js (localStorage flow:mouseBindings):
  * Classic (the DEFAULT, and byte-identical to every shipped version): a
    left drag on the pane pans, Shift+drag draws a selection box.
  * Select-first: a left drag rectangle-selects, dragging the selection
    moves the whole set, Shift+click adds to or removes from it, and the
    middle or right button pans β€” while a right click that does not travel
    still opens the pane menu.
- the user's call (2026-07-11) was to keep the current behaviour and make it
  adjustable, so nothing changes for anyone who never opens the setting.
- xyflow is 1.6.5 here and the plan was written for 0.1.x, so every prop was
  re-verified in node_modules: panOnDrag / selectionOnDrag / selectionMode
  survived, but the key prop is `multiSelectionKey`, NOT the documented
  `multiSelectionKeyCode`. Node moves still broadcast per node through the
  existing onnodedragstop, so 114.2 needed nothing on the wire.
- THE PART THAT NEEDED CODE: once panOnDrag includes the right button,
  xyflow's Pane preventDefaults EVERY contextmenu and forwards none. Its
  system layer would re-emit a press that did not travel, but the svelte
  wrapper never passes that callback through β€” so the editor tracks the
  gesture itself and re-emits the pane menu.
- that decision is made on POINTERUP, not on the contextmenu event:
  Chromium fires `contextmenu` on the PRESS, so at that moment a gesture has
  travelled zero pixels whether it is a click or a 200px pan β€” measured, and
  the first version opened the menu on every right drag.
- suite flow-mouse-bindings (23 checks, real mouse): Classic pans and selects
  nothing and still opens its menu; Select-first selects, moves the set by one
  delta, toggles with Shift, pans on the right button without a menu, opens the
  menu on a stationary right click, and survives a reload with the Settings row
  present.
- counterfactual: with the setting ignored (selectFirst pinned false) the
  Select-first section reads 3 FAILURES β€” the left drag pans instead of
  selecting. Classic IS the default, so that half is its own control.
- three geometry traps recorded in the suite header, all found by printing
  document.elementFromPoint rather than by reading handlers: the pane's
  "empty" bottom-right corner is the MINIMAP (a drag there panned 631px for
  an 80px gesture), the docked pane is ~300px tall so two cards 160 units
  apart do not both fit, and a rectangle selection renders an overlay across
  the selected cards that eats a later click aimed at one of them.
- held: flow-nodes-core 15, flow-dock-toggle 19, flow-node-undocked 4,
  node-drag-fields 24, flow-palette 7 (+ its one PRE-EXISTING red, the hover
  delay, identical to base)
- svelte-check 361/47, error and warning lists identical to base; build green

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgtYLde61S8THqad37dDQm
… decisions

The four questions the plan says are only answerable once the three layers meet.

ONE STORY. Configure Scene read as unrelated sections β€” "Post-processing" for
layer 1, nothing at all for layers 2 and 3 β€” which is how "must my peers switch
this on?" became a separate question three times over. There is one **Scene look**
section now: what the look IS (three layers, all scene data, only the right to
switch one off is local), then the stack, then the materials layer as a summary
plus the way in, because its editing surface is a dock tab and belongs there.
  The label moved and the DEEP-LINK NAME did not. `Section` takes `aliases` now,
  so a rename lists what the section used to be called instead of hunting every
  menu, component and suite that wrote the old name down β€” and silently missing
  one. The 21-G1 rule, generalised into the component that enforces it.

THE COST LINE, EXTENDED. L3's "Effects: N, passes: M" was the only place a look's
cost was visible. The materials layer speaks in the same voice now: "A scene
default and 2 objects with their own β€” driving 9 objects, 3 programs." PROGRAMS
rather than objects is the honest number β€” `customProgramCacheKey` hashes the
injected source, so N objects on one graph compile one program (measured 22 -> 23
for 24 objects when SH6b's compile-once optimisation was declined on evidence).

THE SAVE-PATH AUDIT, done once and written where the carriers are (shaderGraph.js).
All three layers are a KEYED DOCUMENT plus a runtime product, and the rule is the
same each time: save the document, never the product. Wire, autosave, sessions and
undo each carry all three; the products are carried by nobody and rebuilt on the
other side. The conclusion worth recording is a NEGATIVE one: `parkShaderMaterials`
exists only because layers 2 and 3 attach their product to the scene TREE, and a
post Effect lives in the composer, which no serializer walks β€” so P4 needed no
fourth park, and a reader looking for one now finds the paragraph saying why.

THE CAPABILITY GATE, DECIDED: they stay separate, reason in viewMode.js beside
`postSupported`. Three properties of the measured ANGLE/D3D11 failure do not
transfer to a material β€” blast radius (a broken pass takes the whole viewport, a
broken material keeps its last good one and reports), where they run (post is
skipped in VR, materials are the only layer that works there, so one gate would
switch off the half that works), and who compiles (a material goes through three's
own program path, so gating it is gating three). Materials get a CHOICE
(`viewportOverrides.shaders`, P5); post keeps its REFUSAL.

Counterfactual: remove `aliases` from Section's deep-link match -> scene-post-ui
1.3 red (gap -3090px: the old name lands nowhere).

Gates: scene-post-ui 50/50 (44 base + 6 new) Β· scene-post 93 Β· panel-deeplinks 23 Β·
shader-inspector 24 Β· camera-looks 43 Β· watch-look 45 Β· shader-post-domain 48 Β·
scene-default-material 33 Β· scene-post-effects 42/1 and shader-editor 64/2, both
pre-existing Β· svelte-check 361/47 message set identical to base Β· build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
…der lane proved

The user's ask from 2026-08-17 β€” "by default copy, add into app settings an option
to share" β€” held since then because sharing is one line LOCALLY and everything hard
about it is replication: every material change is broadcast PER OBJECT, so two
objects sharing an instance would diverge the moment a peer applied one. The plan
said to wait for the shader lane and reuse whatever identity it established.

THE IDENTITY IS `userData.materialId` β€” the carrier `userData.physics`,
`userData.origin`, `userData.camera` and `__uuid` already ride, which is the
four-carrier problem (wire, autosave, sessions, undo) solved once rather than
designed twice. `materialSharing.js` is a leaf holding it and three rules:

1. THE ID IS THE TRUTH; THE INSTANCE IS AN OPTIMISATION. Objects sharing an id
   should share one THREE.Material so a local edit is instant on both, but every
   carrier splits instances somewhere β€” GLTF rebuilds one per mesh, a peer
   receives objects one message at a time, undo re-parses a subtree. So nothing
   depends on the instance: `reconcileSharedMaterials` re-unifies by id whenever
   the scene changes (the shaderGraph reconcile shape).
2. THE SENDER FANS. Rather than mint a material-addressed message β€” a new type, a
   new applier, a capability-gate entry and a story for every older peer β€” the
   sender repeats the per-object messages a receiver ALREADY understands, once per
   sharer. One choke point (`materialsHandler.broadcast`) covers colour, params,
   maps, the slot array and the type switch; the wire is byte-unchanged.
3. COPY REMAINS THE DEFAULT (`shareDuplicatedMaterials`, LOCAL, off). A duplicate
   is a working copy of everything that belongs to the object β€” D1's DCC rule β€”
   and Blender's linked duplicate is its own command, not its default. Geometry is
   always copied: two questions, and only one of them has a setting.

The one op that REPLACES a material rather than writing into it β€” the type switch
β€” relinks the sharers locally, or the next reconcile would put the old material
back on the object that just changed.

UI: the Settings toggle under the other Duplicate options, and a shared-material
notice in the Inspector's Material section with **Unlink** beside it (the
`shader-driven` notice's shape, one concern over), because an edit that quietly
changes another object needs to say so where the edit happens.

Suite `material-sharing` (42 checks, three peers): copy still the default and
PROVEN by editing, sharing on, unlink, the reconcile, the id in a save, two peers
both ways, a first share made while connected, a type switch, and a late joiner.

Counterfactuals β€” and the first two are the reason this suite is bigger than it
looks, because BOTH passed against the first version of it:
- remove the fan -> 6d.2 red. It took a receiver whose instances are still SPLIT
  to isolate: with them already unified, one message reaches both objects for
  free, so the check was passing for the wrong reason. Β§6d stops that peer's
  reconcile and separates its materials by hand, which is the real window (just
  after objects arrive, just after a duplicate, and always for an older peer).
- remove the applier's link -> 6b.3 red. Likewise: the id rides the full-state
  sync, so a peer that connected AFTER the sharing needs no applier at all. The
  case that needs one is a FIRST share made while connected, where the id is
  minted on a source whose userData never gets re-sent β€” Β§6b creates a fresh
  object for exactly that.
- make the reconcile a no-op -> 4.3 red.

Gates: material-sharing 42/42 NEW Β· duplicate-parity 28 Β· material-types 11 Β·
uv-materials 25 Β· mesh-edit-materials 34 Β· shader-inspector 24 Β· object-sync 12 Β·
animation-autokey-material 11, all at base Β· svelte-check 361/47 with the message
set identical to base Β· `npm run build` green with the dev server down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qw58R9VNZNVDGPYsFwfaPR
THE GAME. A zero-gravity room of 24 glowing stars you knock around with your hands in
VR or by walking into them; free play is the default and P opens the round ("light
every star") with a per-player touch leaderboard. The first game that needs NO module
download β€” pure core, so the Games tab shows it with no requirement card.

- `STARS_DEF` in scripts/author-templates.cjs, authored as DATA on A3's graphBuilder:
  a 12x7x12 room (see-through walls you bounce off), 24 stars on a SEEDED jittered
  lattice (an LCG at module scope, so the def is stable data and two builds place every
  star alike), 2 planets for contrast, a template under the floor for the spawner,
  and two physical onclick PADS β€” the DOM HUD is invisible in a headset (F8), so Start
  and More stars have in-scene buttons as well as menu ones.
- physics: gravity 0, ground off, bounds respawn, damping 0.35/0.2, grab + simOnPlay,
  and the A1 `knock` block ON (gain 1, maxSpeed 10, spin 0.6). Look: a LIT preset at
  exposure 0.55 + bloom (F14 β€” never `night` alone), ao/AGX/vignette/smaa.
- the graph: per star an `onhit` scaling a particle burst by its `speed` output and
  ringing a chime, an `onhit who:'me'` banking a per-player `touches`, and a perRound
  latch -> Select -> Set Color that lights it during a round; a 23-node add chain ->
  "Lit: n / 24" -> compare -> allplayers -> `over`. Towers' menu/pause/over shell
  re-skinned; NO shared HUD builder (D1 decides that with three documents on the table).
- the default screen is `input: 'game'`, deliberately: a `menu`-input screen visible
  while playing releases the pointer lock (21-E3), which would make free play unplayable
  on desktop. Every button lives on the P menu or a pad.
- NEW def field `sounds: [{key, name, url, sha256}]` (additive): fetched in node like
  `music`, dropped into the Explorer, addressed from a Sound node as `'$sound:<key>'`
  through A3's widened remap β€” but NOT the scene's music slot, because a chime is an
  asset a node plays, not background music. The chime is audio-essentials'
  impact-glass.ogg (Kenney, CC0), sha256-pinned; its 8400 bytes ride the .tpscene.

TWO CORE BUGS, both found by authoring and both fixed here:

- `peervariable` was MISSING from flowRuntime's `valueTypes`, so `resolveInputs` refused
  it as a wire source and a Player Variable node delivered NOTHING to anything wired to
  it β€” the consumer silently kept its own dialled value. Shipped since 21-G4, and it is
  the exact `peervariable -> hudtext` shape CLAUDE.md prescribes for this game. Measured:
  two readouts rendered 0 while the leaderboard beside them, which reads peerVars
  directly rather than through a wire, read 1. `peer-variables` gains section 1c, which
  wires the node into a Math node and asks the CONSUMER what it resolved β€” the existing
  1b evaluates the node IN ISOLATION, proves only the evaluator, and is why this shipped.
- A SUSPENDED object lost its whole effect list, and `physics.trackBody` suspends every
  dynamic body that is a flow-animation target for the whole run ("dynamic wins over an
  animation"). So a Set Color on a dynamic body applied once in the frames before the
  sim's bodies existed and then STUCK β€” material colour is not base-managed, so nothing
  repainted it. `POSE_FREE_EFFECTS` (setcolor, setuniform, deviceparam, notetrigger) now
  still run for a suspended object, with no base restore and no pose effects; visibility,
  module effects, scripts and custom nodes stay suspended because they may write a pose.
  Diagnosed in this order: the wire resolved correctly under both clocks, the file's
  wiring was right, and then changing the node's DIALLED colour live did nothing β€” which
  is what separates "not applying" from "resolving wrong".

- suite `game-stars-room` (36 checks, skip-never-fail, driving the REAL .tpscene; takes
  `STARS_ROOM_TPSCENE` so a lane can point it at a scratch build): the physics block
  restored from the file, 36 objects / 27 dynamic, the chime's bytes in the Explorer, a
  probe pass leaving Star 1 at ~4 m/s and damping bleeding it by half in 3 s, a 10 m/s
  star still inside the room 2 s later, More stars +3 with the cap holding at 59 (the
  spawner RECYCLES oldest-out, it does not refuse), one touch banked in my own row, the
  round lighting every star and ending in `over`, and a NEW round un-lighting them.
- counterfactuals, each red then restored: reverting POSE_FREE_EFFECTS to the blanket skip turns `...and Star 3 is painted lit` red (the star reads the dialled #3b3f66 again); removing `peervariable` from valueTypes turns `my touch row reads 1` red (the HUD keeps the node's own 0 while the leaderboard reads 1).
- held: peer-variables 73/0 (incl. the new 1c), knock-node 50/0, play-interact 46/0, logic-nodes 77/0,
  animation-node 35/0 in the 9-suite battery. Its four reds A/B'd against base: collectibles-v2 is
  WORSE on base (68/2 vs 69/1); flow-physics-actions' 8.5 fails identically on base and its 2.1 is a
  first-sample timing read (-0.00 -> 1.28, same end state); possess asserts the avatar module, which
  left core in 17-A and has no zip on this box; game-towers' 'Restart cleared the height latches'
  went 19/1 once alone and then 20/0 three times on the branch (20/0 on base src) - a flake (likely a crate
  resting in a height sensor re-lights the 1m latch), and Towers holds none of the four
  POSE_FREE_EFFECTS node types or a peervariable.
- 'all 24 latches read lit' asserts the TRIGGER LOG (every star's hit stamp at or after the
  round's startedAt), not the count node: the instant the last star lights, allplayers flips the
  round to `over`, whose Infinity cutoff reads every perRound latch un-lit again, and flowValues
  publishes only every 150ms - so the count read 24 for at most one publish and a poll lost the
  race (measured `last: 0` with the `over` check right after it green, even on a 25s budget).
  The `over` check still proves the count chain reached 24. The stamp read folds startedAt
  (epoch ms) into the tick clock's seconds-of-day exactly as retiredByRound does - a raw `>=`
  read 0 - and a premise check right after Star 3 lights (the log counts exactly 1, Star 1 having
  been knocked before Start) keeps it from passing vacuously.
- svelte-check 361/47, entry list identical to base; build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ws-flow

The lane forked at 4d7dce2 (1.11.0); release/next has since taken roadmaps 25-27 and
shipped as 1.12.0, which rewrote the files this lane also touches. Resolved to BOTH
sides' intent rather than either wholesale:

- `docking.js` (the one textual conflict, both hunks): the lane's edge-split logic on
  top of 27-H's storage codemod β€” every read and write of `dockedWindows`, `dockWidth:`
  and the new `dockSplit:` share goes through `safeStorage`, so a docked layout cannot
  kill its own subscriber in private mode. The 81.4 sideList/persist shape is unchanged.
- `flowPrefs.js` (114's new leaf, no conflict β€” the gate caught it): same conversion.
  `scripts/check-storage.cjs`, which CI runs, was red on these four call sites and is
  green now; the `typeof localStorage` guard it carried is safeStorage's job (a
  SecurityError walks straight through that guard, which is why the codemod exists).
- `meshEdit.js`, `Controls.svelte`, `Settings.svelte`, `Nodes.svelte`, `dragWindow.js`
  auto-merged; checked by hand that both sides survived β€” the lane's F1/F2/F3 and reveal
  clamps, and hardening's `pokeScene()`, `safeStorage` and VR vertex cap.
- 26-B's invariant holds for free on F3's new path: `commitMeshGeoSnapshot` applies
  through `applyMeshGeo`, which ends in `pokeScene()`.

Gates on the merged tree, all green:
- `node scripts/check-ratchet.cjs` β†’ 341 errors / 47 warnings, exactly the committed
  floor (the lane measured 361/47 on the old base; the floor moved with the batch).
- `npm run build` exit 0 with the dev server stopped; `npx vitest run` 133/133 (11 files
  β€” the unit layer did not exist when this lane forked).
- e2e, one batch on port 5179 under `/tmp/tp-e2e.lock`: **249 PASS, 0 FAIL** across
  dock-splits 17 Β· docking 10 Β· flow-mouse-bindings 23 Β· mesh-falloff-rotate-scale 22 Β·
  mesh-falloff-sync 24 Β· mesh-proportional 63 Β· **scene-poke** Β· **storage-hardening** β€”
  the last two are the hardening suites for the two invariants this merge touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
The lane forked at 4d7dce2 (1.11.0); release/next has since taken roadmaps 25-27 and
shipped as 1.12.0, which rewrote peerHandler, physics, flowRuntime, Scene and App β€” every
file A1-A4 also touches. Resolved to BOTH sides' intent, and in three places the merge is
more than a textual union:

- **`knock.js`'s wire stamps move to 25-E's session clock.** `at` is stamped by the hitter
  and then folded into the trigger log on EVERY peer (A2 keys the `onhit` pulse by it), so
  it is exactly the kind of number sessionClock's contract names: "only a number another
  machine will compare needs to be on the session's clock". It was `Date.now()`, which on a
  peer whose clock runs minutes out would have placed every On Hit pulse somewhere else on
  the shared timeline than every other stamp in the same log.
- **`hit` gets a shape in 27-A's `wireValidate` table.** Absent means ALLOW, so it worked
  without one; but the velocities are applied to a body the moment the message lands, and
  the table is where a malformed one is counted and dropped instead of reaching `applyHit`.
- **App.svelte's three debug tails** are the union: the lane's `knock` beside the batch's
  diagnostics / wireValidate / wireErrors / safeStorage / sceneBudget / overloadGuard /
  qualityGovernor. All three lists count 205 and stay in the same order β€” the one real trap
  here is that `git checkout --theirs` on this file drops the lane's `import('./lib/knock')`
  100 lines above the conflict and silently shifts every later binding by one.

Everything else auto-merged and was checked by hand: A2's `simulate` push still sits in
`sendHandshake` beside the batch's `gettriggers`/`joinresult`, the `hit` case still sits
behind 27-A's shape-then-gate dispatch, A4's `POSE_FREE_EFFECTS` and `peervariable`
valueType survived, and 26-B needs nothing from `applyHit` (it writes rapier velocities,
not the THREE tree).

Gates on the merged tree:
- `node scripts/check-ratchet.cjs` β†’ **341 errors / 47 warnings**, exactly the committed
  floor (the lane measured 361/47 on the old base; the floor moved with the batch).
- `npm run build` exit 0 with the dev server stopped; `npx vitest run` **133/133**.
- e2e, one batch on 5182 under `/tmp/tp-e2e.lock`: **377 PASS** β€”
  game-stars-room 36 Β· knock-node 50 Β· knock-physics 77 Β· peer-variables 73 Β·
  scene-physics-state 37 Β· net-handshake 9 Β· **wire-hardening 15** Β· **session-clock 26** Β·
  **runtime-resilience 15** Β· flow-spawner 39. The last four are the batch's own suites for
  the seams this merge touched.
- `flow-spawner` fails after those 39 checks (`waitForSelector('.svelte-flow')`, Β§7's
  reopen). **A/B'd and PRE-EXISTING**: the same suite on a pristine `origin/main` @3f71477
  worktree fails identically β€” 39 PASS then the same timeout. The lane had recorded it as a
  suspected standing red and could not prove it; this settles it, and it is a red on
  release/next today, not something this lane or this merge introduced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
…rials

The lane forked at 4d7dce2 (1.11.0); release/next has since taken roadmaps 25-27 and
shipped as 1.12.0. Three textual conflicts, each resolved to BOTH sides' intent, plus
three of the batch's invariants applied to this lane's new code:

- **`Outline.svelte`'s post chain** is where P2 and 26-D met on the same call. The
  authored documents and the view mode now come from the WATCHED peer when watching
  (`adopted`), and the quality governor still applies on top of that: the governor is
  this machine giving up post to hold its frames, and watching somebody must not undo it.
  So `mode` degrades `shaded-ao` β†’ `shaded` on the ADOPTED mode, and `localEnabled` is
  the adopted switch AND `!reduced.postOff`.
- **`materialsHandler.js`** β€” D2's fan (the material-TYPE swap is the one op that
  REPLACES the instance, so the sharers must be handed the new one) now ends in 26-B's
  `pokeScene()` instead of the direct `objectsGroup.update`. Both imports kept.
- **App.svelte's three debug tails** are the union: the lane's lookPresence / postGraphs /
  postGraphPresets / materialSharing beside the batch's seven. All three lists count 208,
  same order.
- **`materialSharing.js` moves to `safeStorage`** (27-H): the share pref was a bare
  `localStorage` pair with its own try/catch, which `scripts/check-storage.cjs` β€” a gate
  CI runs β€” was red on. safeStorage keeps a failed write in memory for the session, and
  the `typeof localStorage` guard it carried is exactly the one a SecurityError walks
  through.
- **`materialSharing.js` Γ—2 and `shaderGraph.js` Γ—1 call `pokeScene()`** instead of
  `objectsGroup.update((value) => value)`. Release/next has exactly ONE of those left, in
  `flushScenePoke` itself; every other call site in the tree is the coalesced poke.
- `postGraphs`/`materialSharing` id minting keeps `Date.now()` on purpose: an id built
  from a timestamp is never compared as a time, and 25-E's contract is about numbers
  another machine compares.
- `lookstate` gets a shape in 27-A's `wireValidate` table β€” `overrides` and `look` are
  read as objects the moment the row lands.

Gates on the merged tree:
- `node scripts/check-ratchet.cjs` β†’ **341 errors / 47 warnings**, exactly the floor.
- `npm run build` exit 0 with the dev server stopped; `npx vitest run` **133/133**.
- e2e, one batch on 5178 under `/tmp/tp-e2e.lock`: **591 PASS** β€” watch-look 45 Β·
  shader-post-domain 48 Β· scene-default-material 33 Β· material-sharing 42 Β· scene-post-ui
  50 Β· scene-post 93 Β· shader-compile 85 Β· shader-graph 67 Β· scene-post-effects 42 Β·
  **perf-governor 39** Β· **scene-poke 32** Β· **wire-hardening 15**. The last three are the
  batch's suites for the invariants above.
- `scene-post-effects` Β§4.5 ("assigning a LUT PUSHES its bytes to the mesh") fails.
  **A/B'd and PRE-EXISTING**: the same check fails on a pristine `origin/main` @3f71477
  worktree (41 pass there, 42 here β€” this lane legitimately grew the suite by one when the
  post library became 14 kinds). The lane had already reproduced it at its own base.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
feat/1.13 = origin/release/next (1.12.0) + the three v1.13 feature lanes, each of which
already carries its own merge of the batch. This commit is where the lanes meet each
other, which is the part no individual lane PR can show:

- **`wireValidate.js`** β€” 24-A's `hit` and P2's `lookstate` both landed right after
  `throw:`. Both kept.
- **App.svelte's three debug tails** β€” the union of all five new modules (knock,
  lookPresence, postGraphs, postGraphPresets, materialSharing) on top of the batch's seven.
  All three lists count 209, same order.
- `Inspector.svelte` (Knock rows + the Scene look section), `Settings.svelte` (Node editor
  mouse bindings + Duplicate β–Έ share materials), `Nodes.svelte` (the On Hit card + the
  Select-first pane) and `peerHandler` (the `hit` and `lookstate` cases) auto-merged; each
  was checked by hand for both lanes' symbols.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
…s the lanes paid for

- CHANGELOG: a `## Unreleased` section ABOVE the shipped `## 1.12.0`, in five parts β€” the
  knock and the Stars Room, one scene look and shared materials, modelling/windows/node
  editor, VR Football, and the SDK additions. Written against the CURRENT file on purpose:
  the trap c8258dd had to undo was a lane writing `## Unreleased` while branched off an
  older release, after which the next version bump renamed the heading around it. This
  branch is based on 1.12.0, so the 1.13 bump will rename exactly this heading.
- CLAUDE.md architecture map: `knock.js`/`knockMath.js` (incl. the `hit` message being
  CONTENT and its `at` being `sessionNow()`), `lookPresence.js`, `postGraphs.js` +
  `postGraphPresets.js`, `materialSharing.js`, `flowPrefs.js`, and the docking/meshEdit
  entries for edge splits and the falloff meshgeo commit.
- CLAUDE.md gotchas, each one paid for this round: `git checkout --theirs` dropping the
  whole ours side of a file (it cost a silently shifted debug-hook binding during this
  integration); an attribute index recorded before a meshgeo commit addressing a different
  vertex after it; a handle hung past a docked window's edge being clipped; Chromium firing
  `contextmenu` on the press; the node pane's bottom-right corner being the minimap;
  `.peer-watch` being worn by two buttons; a type check not telling a shader-driven material
  from its base; a guard that is redundant on the happy path not being proven by it; a
  shader-graph constant having to be a Float node; and a perRound latch count reading 0 the
  instant the round ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
The entry claimed Football "joins the Games tab". It does not, and the docs pass found the
same thing independently: `FOOTBALL_DEF` is absent from `scripts/author-templates.cjs`, the
scenes feed's `games` list holds towers and stars-room only, and the football lane's handover
records that the authoring run was never made β€” its `index.json` row already points at
`games/football`, so the gallery card points at a template that is not published.

What ships is the MODULE: install it from Browse, and its toolbox's "Build pitch" recipe lays
out the pitch, the gates and the rules. That is what the entry says now, with the missing
template named rather than implied. Authoring the def into the scenes repo is a small,
separate job (import the modules repo's `football.def.json` into the author script, run
`--only football --out`, review the thumbnail by eye) and it is the only thing between the
module and a Games-tab card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Knf7qCs3U1pQN8T3fCQCGw
v1.13 β€” the three feature lanes, stacked on 1.12.0
The section was still "## Unreleased". Leaving it that way is the exact trap the
hardening batch fell into: a lane writes into "## Unreleased" while branched off an
older release, the next npm version bump renames that heading to ITS number, and the
entries end up filed under a release that already shipped. RELEASING.md now warns
about it; this is the warning being followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AlexZ005
AlexZ005 merged commit b86a69a into main Sep 18, 2026
8 checks passed
@AlexZ005
AlexZ005 deleted the release/next branch September 18, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant