From 1e57d57398761e192cade0f34a862921bfaf7a6a Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 10:17:24 -0700 Subject: [PATCH 1/6] =?UTF-8?q?docs(design):=20modules.md=20=E2=80=94=20mo?= =?UTF-8?q?dule/bus/panel=20model,=20draft=20for=20ratification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normative design for the nested-projects redesign: project/module mitosis, binding-is-publicity, panel as recursive view of scope channels, lazy runtime panel writers with throttled persistence. Supersedes the direction of the #218 spike (harvest inventory in §9). Plan: planning/2026-07-31-1002-modules-buses-panels. Also: agent-context.toml gains a planning_root fallback (the env var is unset in non-interactive shells). Co-Authored-By: Claude Fable 5 --- agent-context.toml | 1 + docs/design/modules.md | 491 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 492 insertions(+) create mode 100644 docs/design/modules.md diff --git a/agent-context.toml b/agent-context.toml index f63740eab..0cea9ca20 100644 --- a/agent-context.toml +++ b/agent-context.toml @@ -1,5 +1,6 @@ [agent] repo_slug = "lp2025" +planning_root = "~/.photomancer/planning" planning_root_env = "PHOTOMANCER_PLANNING_ROOT" skills_root_env = "PHOTOMANCER_SKILLS_ROOT" default_skills_subdir = "skills" diff --git a/docs/design/modules.md b/docs/design/modules.md new file mode 100644 index 000000000..003b826f1 --- /dev/null +++ b/docs/design/modules.md @@ -0,0 +1,491 @@ +# Modules, Buses, and Panels + +This document is the single source of truth for the **module container +model**: what a module is, how bus scoping works, how slots become public, +and how the panel — the end-user control surface — derives from the bus. It +supersedes the direction of the composite-effects spike (PR #218) and, at +implementation time, its two ADRs (`2026-07-28-scoped-buses.md`, +`2026-07-28-effects-are-projects.md`, both on the spike branch) — the +spike's writer-shadowing model survives here; its promoted-control aliasing +and transient scope implementation do not. + +> **Status: DRAFT — awaiting ratification** (gate G1 of +> `planning/2026-07-31-1002-modules-buses-panels`). +> +> **Posture.** Alpha: no wire or artifact compatibility obligations; the +> rename below is one-shot with no migration. The architecture is required +> to mirror the *user model* — a module is one idea (a bus holder with a +> public face) presented one way at every nesting depth. Where this +> document and an implementation convenience disagree, this document wins. +> +> **Related:** `docs/adr/2026-07-26-node-card-faces.md` (face grammar), +> `docs/adr/2026-07-09-declarative-default-bindings.md` (default binds), +> `docs/adr/2026-07-27-node-authoring-operations.md` (create/vendor seam). + +## 1. Concepts + +| Term | Is | Is not | +|---|---|---| +| **Project** | The *container*: a folder with identity, provenance, history, and format. Not a node. What you open in Studio. | A node kind; a scope; anything the engine resolves against | +| **Module** | The container *node kind* (replaces the `project` node kind). Owns a bus scope, a panel, and child nodes. The root module is an ordinary module that happens to be at the root. | Special at the root; a separate "composite"/"effect" structure | +| **Effect** | A *category* of module (gallery/UI copy): a module authored to be dropped into other projects. | A distinct node kind or schema | +| **Scope** | The bus namespace a module introduces around its children. | Global; a display prefix | +| **Channel** | A named value stream within a scope. Exists iff some binding names it. | A declared registry entry; a place values are stored | +| **Public slot** | A slot with a bus binding. Its channel appears in its scope. | A slot copied, aliased, or mirrored anywhere | +| **Panel** | A *presentation* of a scope's channel list. The root module's panel is the end-user surface. | A place values live; a dataflow construct | +| **Panel state** | Unauthored runtime writer state (per scope + channel), persisted separately. | Part of the project's authored artifacts; dirty-tracked | + +The project/module split ("mitosis") is deliberate: the project carries the +*workspace* concerns (who made this, when, what version, format), the +module carries the *technical spec* (what nodes exist, how they wire, what +is public). A vendored effect is therefore just a module folder; wrapping +it in a project makes it openable; extracting it from a project is a copy +(see R14 for provenance). + +## 2. Naming and rename plan + +**"Module"** replaces "project" as the node kind. One-shot, no migration, +no format bump semantics to preserve (alpha). Inventory: + +- Kind string `project` → `module` in artifacts; `ProjectDef` → `ModuleDef` + (gains `bindings`/exports and optional provenance; loses nothing). +- Root file split: `project.json` becomes the non-node container manifest; + the root module moves to `module.json` (§6). +- Schemas regenerated; `examples/` rewritten; conformance fixtures updated. +- Wire/engine identifiers follow the concept they name: things about the + *loaded tree* or *container* may keep "project" (e.g. "open project" + flows); things about the *node kind or scope* become "module". The + implementation plan owns the exhaustive sweep; the rule here is only + that no identifier may use "project" to mean the node kind. +- Studio copy: users "open a project", "add a module", browse "Effects". +- "Plugin" is rejected (connotes third-party binaries / dynamic loading). + +## 3. Bus rules (normative) + +### R1 — Modules introduce scopes, structurally + +Every module node introduces a scope enclosing its children. The root +module's scope is the root scope — root is *not otherwise special*. Scope +introduction is a property of the module kind, not the invocation site. + +Scope identity is **structural engine state**: after load, the engine can +answer "which scope contains node X" and "which scope does module M +introduce" (the spike's throwaway loader side-table is explicitly +rejected). The scope is engine-owned runtime structure hung off the module +node; authored artifacts never name scopes. + +### R2 — Sink scopes (isolating invocation sites) + +Isolating invocation sites — today exactly playlist entries — wrap each +owned child in an anonymous **sink scope**, parented into the enclosing +scope. *Sink* is a modeled property with two meanings, and both must be +honored by construction rather than by per-layer filters: + +1. **Inward invisibility:** channels in a sink scope never surface on any + enclosing scope's panel or probe listing. They are reachable only + through the isolating node's own face (the playlist face presents the + *active* entry's panel — see E2). +2. **No demand:** listing a panel or probe must never force resolution of + an inactive sink child's channels (the spike violated this and every + inactive playlist entry rendered every frame; the fix must live in the + model, not in the probe). + +Entries are *alternatives* (isolated from one another); module children +are *collaborators* (share one scope). That is why there are exactly two +scope-introduction primitives and "container ⇒ scope" is wrong. + +### R3 — Binding is publicity + +A slot is **public iff it has a bus binding** (authored, or a declared +`default_bind`). Public means: its channel exists in the slot's scope, +which is what makes it appear on that scope's panel (R8). Private slots +are simply unbound. There is no other promotion mechanism — no promoted +control defs, no alias slots, no module-side declaration required. A novel +shader param becomes a panel knob by being bound to a channel; that's the +whole gesture. + +Publicity carries no value: values live on slots (authored defaults) and +in panel state (R10). Publicity is symmetric — produced slots (a shader's +`visual.out`) and consumed slots (a shader's `speed` uniform) are both +made public by binding. + +### R4 — Produces write locally + +A produced bus endpoint always writes the producer's **own nearest +scope**. Never outward. A module's interior cannot clobber its host by +construction. (A module node itself resides in its *parent's* scope — only +its children are inside the scope it introduces — so a produced endpoint +*on the module node* writes the parent scope. R7's publishes are +instances of this rule, not exceptions.) + +### R5 — Consumes resolve by writer-shadowing + +A consumed bus endpoint resolves to the **nearest enclosing scope that has +at least one writer** for the channel, starting at the consumer's own +scope and walking outward. Writers are: authored produce bindings, +produce-direction declared defaults, module publishes/exports (R7), and +engaged panel writers (R10). + +Walking *out of* a sink scope is permitted — sinks isolate inward +visibility (R2), not outward inheritance; a playlist entry's shader still +inherits host `time`. + +The consume/produce asymmetry is the point: reads inherit (an effect with +no clock animates on host time; an effect *with* a clock shadows time for +its own subtree), writes stay local (encapsulation). + +### R6 — No writer anywhere: authored default, channel still lists + +If no enclosing scope has a writer, the consuming slot uses its **own +authored default value**, and the channel **still lists** in the +consumer's scope. Listing is what makes the channel appear on the panel, +where touching the control materializes a writer (R10) — an unfilled +public input is an *invitation*, not an error. This replaces the spike's +"resolve to root, surface unfilled". + +### R7 — Module output interface: one automatic publish + authored exports + +- Every module node produces an `output` slot mirroring its own scope's + `visual.out` channel (resolved per R5 within the scope). This is what + makes any module playlist-playable with zero playlist changes. A scope + with no visual writer mirrors *cleared* — a module without a visual is a + legitimate shape. +- Every **non-root** module node additionally publishes `visual.out` into + its containing scope at fallback priority — the drop-in rule: an + embedded effect contributes its visual to its host by default. (Root is + excluded only because its containing scope does not exist.) +- Any **other** channel a module provides outward is an **authored + export**: a binding on the module node republishing an inner channel + into the containing scope (per R4, the module node's own produces land + there naturally). Exports are the module author's curation of the + output interface — automatic for the universal visual convention, + deliberate for everything else, because auto-exporting all channels + would make two side-by-side modules collide in the host scope, which is + exactly what scoping exists to prevent. +- **Contention** (e.g. a host shader and an embedded effect both writing + `visual.out` at fallback priority) resolves as *ambiguous until the + author picks*. The pick is authorable — module nodes carry bindings — + and Studio should make it a one-gesture choice at drop time. + +Inputs need no counterpart: consumed inheritance (R5) already lets a host +feed a module's inner consumers with zero authoring (see E6). + +### R8 — The panel is a view of the scope's channels + +The panel of a scope **is its channel list, presented**. No dataflow +construct exists behind a panel; nothing is promoted between levels. + +- A module's face renders its own scope's panel. +- An enclosing module's panel additionally presents each child module's + panel as a **nested group** (presentation recursion — the same shape as + the card grammar). Two embedded instances of the same effect present + two independent groups. +- The **root module's face is the end-user panel**. Play mode is "render + only the root module's face". +- Sink scopes surface only per R2 (through the isolating node's face). +- Presentation is kind-dependent: scalar channels render as knobs/faders, + bools as toggles, visuals as preview tiles, streams as readouts. + Channels driven by authored writers (an LFO, a clock) render as live + readouts that can be *grabbed* (R11). + +### R9 — Control meta follows the binding + +A panel control's display meta (label, unit, min/max, step, widget) +derives from the slot(s) **currently bound** to the channel in that scope +— it is re-derived whenever bindings change. This is what makes E2 work: +when a playlist switches entries, the panel control's range switches with +it, because a different slot is now behind the channel. + +Merge rule when several slots in one scope bind the same channel with +conflicting meta: **numeric ranges union (widest wins)**; on label/unit +conflict the channel name wins. A module-level authored meta override +(label/unit/min/max on the module node, per channel) beats derivation — +this is the curation escape hatch, and the only module-side declaration +in the model. It carries no value (that would recreate the alias problem +the spike had). + +### R10 — Panel state: lazy, stateful, unauthored runtime writers + +Panel controls write through **runtime writer state**, per +`(scope, channel)`: + +- **Unauthored.** Touching a control never edits an artifact and never + dirties the project. Authored artifacts define *defaults and wiring*; + panel state is live performance state. +- **Lazy.** The writer materializes on **first touch**, in the scope + where the control was touched. Untouched channels have no panel writer + — this is load-bearing: if every public slot self-shadowed, an outer + scope could never drive an inner channel (R5 would always stop at the + inner scope) and inheritance would be dead on arrival. +- **Stateful.** A panel writer is a data *source*, not a stored scalar — + it may integrate (the phasor knob: changing speed without phase + discontinuity), which is why it lives engine-side, identically on sim + and device, and Studio talks to it via runtime commands (the + playlist-activate precedent: a poke, nothing staged). + +### R11 — Precedence: an engaged panel writer wins its scope + +Within a scope, an **engaged** panel writer takes precedence over +authored writers for the same channel, until reset — grabbing a knob +overrides the LFO driving it. Across scopes, plain R5 applies: an engaged +writer in an inner scope shadows outer writers for that subtree. +Corollary: **touching a control detaches that scope from outer control** +until reset — the UI must show engaged/overridden state distinctly from +inheriting state. + +### R12 — Reset + +Reset removes panel writers — per control, per module (scope), or whole +panel — restoring authored / inherited / default resolution (R5/R6) and +clearing the corresponding persisted entries (R13). + +### R13 — Persistence + +Panel state persists by default in `state.json` beside the project — +never in authored artifacts. Writes are **throttled (≥ ~10 s apart)** for +flash preservation on device. Auto-save is on by default with a user +toggle; reset clears persisted entries. + +The motivating case (verbatim requirement): *4 a.m., Burning Man, LED +scarf dimmed from a phone. Unplug for a minute, replug — it must come +back dim, not blinding. Next night, brighter conditions — connect, hit +reset.* Persist by default; throttle writes; make reset one obvious +gesture. + +### R14 — Provenance is a node capability; extraction copies it + +Provenance metadata (§8) may sit on **any node**. Projects normally carry +it. When a node leaves a project (vendoring a module out), the copy +receives the project's provenance **unless it already carries its own**. + +## 4. Worked examples + +Notation: `Scope(X)` is the scope module `X` introduces; `sink(e)` is a +playlist entry's sink scope. Channel tables list `channel — writers → +readers` within one scope. + +### E1 — Plain project (single module) + +```text +project/ (project.json + module.json) +└─ M (root module) + ├─ clock seconds → bus:time (produced default) + └─ shader S time ← bus:time (consumed default) + speed ← bus:speed (authored: made public) + color (unbound: private) + visual → bus:visual.out (produced default) +``` + +Scope(M): `time — clock → S` · `speed — ∅ → S` · `visual.out — S → M.output`. + +- `speed` has no writer anywhere → S uses its authored default (R6); the + channel lists, so M's panel shows a `speed` knob with S's meta (R9). + Touching it materializes a panel writer in Scope(M) (R10); the project + stays clean; the value survives restart (R13). +- `color` is private: no binding, no channel, no knob (R3). +- Identical to today's behavior for existing flat projects, modulo the + file split — single scope, same resolutions. + +### E2 — Playlist, two speeds, meta follows the active entry + +```text +M (root module) +└─ playlist P + ├─ entry a: shader A speed ← bus:speed (meta 0–1, "Drift") + └─ entry b: shader B speed ← bus:speed (meta 0–10, "Whirl") +``` + +Scopes: Scope(M) ⊃ sink(a), sink(b). `speed` lists separately in each +sink scope (R2); neither surfaces on M's panel directly. + +- P's face presents the **active** entry's panel (R2.1): with `a` active, + one knob, range 0–1, label "Drift". Switch to `b`: the control + re-derives (R9) — range 0–10, "Whirl". +- Panel state is per `(scope, channel)` (R10): tweak `a`, switch to `b`, + switch back — `a`'s tweak is still there. The two entries never share + or clobber a value. +- Inactive `b` is never demanded (R2.2) — listing M's panel or probing + the bus must not render `b`. +- Both entries still inherit `time` from Scope(M) (R5 — sinks don't block + outward walks). + +### E3 — Drop-in embed (plasma into a host that already has a visual) + +```text +H (root module): clock, shader S_h (visual → bus:visual.out, fallback) +└─ plasma (module) + └─ shader S_p time ← bus:time, speed ← bus:speed (0–1), + visual → bus:visual.out (fallback) +``` + +- `S_p.time` resolves: Scope(plasma) has no `time` writer → walk out → + clock in Scope(H) (R5). **An effect with no clock animates on host + time, automatically.** +- `S_p.visual` writes Scope(plasma) (R4). plasma's node publishes + `visual.out` into Scope(H) at fallback (R7). +- Scope(H) now has **two** fallback `visual.out` writers (S_h, plasma) → + ambiguous until the author picks (R7); Studio offers the pick at drop + time. Had H no visual of its own, plasma's publish would win alone — + true drop-in. +- H's panel shows its own channels plus a **plasma group** containing the + `speed` knob (R8). Turning it writes Scope(plasma) — host channels + untouched. + +### E4 — Shared control (one speed driving two effects) + detach + +```text +H (root module): control K (value → bus:speed, authored) +├─ plasma₁ … S₁.speed ← bus:speed +└─ plasma₂ … S₂.speed ← bus:speed +``` + +- Before K exists: neither `speed` has any writer → each S uses its + authored default (R6); each plasma group shows its own knob. +- With K: untouched inner consumers walk out and inherit Scope(H)'s + `speed` (R5). One host knob drives both effects. **Sharing is + inheritance, not promotion** — no channel moved anywhere. +- Touch plasma₁'s own knob: a lazy writer materializes in Scope(plasma₁) + (R10) and shadows K for that subtree (R11) — plasma₁ detaches; plasma₂ + still follows K. The UI shows plasma₁'s control as *engaged/overridden* + (R11). Reset plasma₁ (R12) → re-inherits K. + +### E5 — Depth 2 (module inside a module) — the spike's latent-bug case + +```text +H (root module): clock, … +└─ M_outer (module) + ├─ M_inner (plasma module) — publishes visual.out into Scope(M_outer) (R7) + └─ compute C vis ← bus:visual.out (e.g. analyzes the visual) +``` + +- `C.vis` resolves: Scope(M_outer) **has** a `visual.out` writer — + M_inner's publish — so it resolves to the **sibling effect's visual**, + never walking to root (R5). The spike got this wrong (its writer table + omitted module publishes; depth 1 worked only because the fallback also + landed on root). **Implementation must pin this exact shape with a + test.** +- M_outer's own `output` mirrors Scope(M_outer)'s `visual.out` = M_inner's + visual (R7), and publishes it up into Scope(H) — nesting composes. +- Feedback loops via the bus are not a supported idiom: a node that both + consumes and produces the *same* channel in one scope is a load error; + chains that need explicit topology use direct `node:` refs. + +### E6 — Audio pipeline module (non-visual; inputs & exports) + +```text +A (module, "audio-analysis"): + in ← bus:audio.in (consumed by FFT compute node) + fft ⇒ energy → bus:audio.energy, beat → bus:audio.beat (in Scope(A)) + exports (authored on A): audio.energy ↑, audio.beat ↑ + +H (root module): audio-input node (samples → bus:audio.in) +├─ A +└─ plasma … S.brightness ← bus:audio.energy +``` + +- **Inputs are free:** A's inner `audio.in` consumer inherits H's + audio-input writer by R5 — zero authoring on either side. +- **Outputs are curated:** `audio.energy`/`audio.beat` write Scope(A) + (R4) and reach Scope(H) only via A's authored exports (R7). plasma's + shader then inherits `audio.energy` from Scope(H) — **modules compose + through the host scope**. Two audio modules exporting the same channel + into H is the same authorable contention as E3. +- Standalone, A has no `audio.in` writer → FFT sees the authored default + (silence, R6); its workbench project supplies a test-tone writer. A + module with no visual is legitimate: its `output` mirrors cleared (R7). + +## 5. UI corollaries (spike M2 owns the shape) + +One face, three zoom levels: the **effect author** works inside the module +(children expanded); the **artist** sees the module face as a card +(preview + panel group); the **end user** sees the root module's face +alone (play mode). Consequences the UX spike must exercise: the root +module returns to the node area as the single top-level card (flat-root +reversal — the root now *does* something); the sidebar bus pane dissolves +into the module face (bus-as-controls) plus a wiring drawer +(bus-as-writers/readers, today's pane content); engaged-vs-inheriting +state and the reset gesture (R11/R12); the drop-time contention pick (E3). + +## 6. File layout + +```text +my-project/ +├─ project.json # container manifest — NOT a node: +│ # format, uid, name, provenance (§8), created +├─ module.json # root module node: nodes{}, bindings, exports, +│ # per-channel meta overrides (R9), optional provenance +├─ shader.json … # child nodes, one file per node (unchanged) +├─ modules/ +│ └─ plasma/ # a vendored module: a module folder, nothing more +│ ├─ module.json # (no project.json, no format — see below) +│ └─ … +└─ state.json # panel state (R13) — unauthored, throttled, + # never dirty-tracked, safe to delete +``` + +- `format` lives **only** in `project.json`. Vendored module folders carry + none — the spike's "format tolerated-but-ignored at non-root" rule is + deleted. A vendored module's format is its host project's; a bare module + folder opened standalone is wrapped in a workbench project + (starter-project seam) and assumed current — cross-version module + *sharing* keeps the alpha posture: version + refuse, never migrate. +- `state.json` shape (proposed, Q3): a versioned map of + `scope-path / channel → { value, engaged }`. Scope paths are node + paths, so vendoring/renames invalidate entries gracefully (unknown + paths are dropped on load). +- Relative `node:` refs and file-relative artifact refs survive vendoring + by construction (unchanged from the spike's analysis). + +## 7. Bus vocabulary — under discovery + +The set of standard channel names (`time`, `visual.out`, `audio.*`, …) +is **an unsolved problem, deliberately**. It cannot be designed ahead of a +real example corpus; it will be discovered by building modules and locked +down gradually **at the module boundary** — a module's public channels and +exports are where conventions become contracts. Until then: names above +are provisional conventions, not schema; new channels cost nothing (R3); +nothing in this document depends on the vocabulary's final shape. + +## 8. Provenance (proposed field set — Q7) + +`author`, `version`, `license`, `created` (ISO date). Optional on any +node and on `project.json`; skip-if-default; no semver semantics yet. +Copy-on-extract per R14. (The spike's `ProjectDef.author/version/license` +survive as this, relocated.) + +## 9. Relationship to the #218 spike + +| Spike piece | Fate | +|---|---| +| Writer-shadowing resolution (rules 3/4 of its ADR) | **Kept** → R4/R5 | +| Anonymous playlist-entry scopes | **Kept, promoted** → sink scopes as modeled property (R2) | +| Output mirror + non-root fallback publish | **Kept, generalized** → R7 (exports added) | +| `ScopedChannel` key through `QueryKey`/index/resolver | **Kept** as mechanism (resolver stays dumb) | +| Effect-face component, knob/panel widget reuse | **Kept** as M2 starting point | +| plasma / meteor content | **Kept** as future example modules | +| `PromotedControlDef` value-less aliasing | **Dropped** → R3 (binding is publicity); meta-override vocabulary survives in R9 | +| Transient `BusScopes` loader side-table | **Dropped** → R1 (structural scope state) | +| Scope flattened to display strings on the wire; entry scopes probe-filtered | **Dropped** → structured scope on the wire; R2 by construction | +| "Resolve to root, surface unfilled" | **Replaced** → R6 | +| Root special-casing (~7 sites, incl. Studio's `"visual.out"` string test) | **Deleted** — root is not special (R1) | + +Defects the implementation must pin with tests: the depth-2 resolution +shape (E5); sink-scope no-demand (R2.2 — the inactive-entry render +regression); and note the spike branch carries a silently-disabled probe +test (duplicated `#[test]`) — do not inherit it. + +## 10. Open questions (G1 redline register) + +- **Q3 (spec proposed):** panel runtime commands `PanelWrite { scope, + channel, value }` / `PanelReset { scope?, channel? }`; "touched" = + first `PanelWrite`; state keyed `(scope-path, channel)`; `engaged` + flag round-trips through `state.json`. Ratify or bend. +- **Q7:** provenance field set (§8) — enough? (`description`/`homepage` + deferred?) +- **Q10:** bare-module-folder open assumes current format (§6) — fine for + alpha? +- **Q11:** R9 tiebreak (channel name wins on label conflict) — fine, or + prefer first-bound-slot-wins? +- **Q12:** grabbing an authored-driven channel (R8/R11 "grab the LFO") + — in scope for the first implementation, or panel-writers-only-on- + otherwise-unwritten-channels initially? From 0010dabc186374add3619c290a33b82c47d580f7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 11:06:03 -0700 Subject: [PATCH 2/6] =?UTF-8?q?docs:=20G1=20first-pass=20revisions=20?= =?UTF-8?q?=E2=80=94=20panel=20formalized,=20ADR=20split,=20glossary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - modules.md R8: panel is a first-class per-node concept (leaf = bound slots, module = scope channels, playlist = active entry), hosted by faces, standalone in play mode; legacy panel flag subsumption raised as Q13. Spike references stripped — the doc now states what is. - New ADR records the moment-in-time context: spike relationship, harvest inventory, defect pins, alternatives. - docs/glossary.md created (sectioned; module-model terms marked). Co-Authored-By: Claude Fable 5 --- ...odel-supersedes-composite-effects-spike.md | 115 ++++++++++++++++ docs/design/modules.md | 127 +++++++++--------- docs/glossary.md | 95 +++++++++++++ 3 files changed, 271 insertions(+), 66 deletions(-) create mode 100644 docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md create mode 100644 docs/glossary.md diff --git a/docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md b/docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md new file mode 100644 index 000000000..269144db8 --- /dev/null +++ b/docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md @@ -0,0 +1,115 @@ +# ADR: Module model supersedes the composite-effects spike direction + +- **Status:** Accepted +- **Date:** 2026-07-31 +- **Deciders:** Photomancer +- **Supersedes:** The composite-effects spike direction of PR #218 + (`claude/composite-effects-planning-f4f51b`, **not merged**) and its two + branch-only ADRs, `2026-07-28-scoped-buses.md` and + `2026-07-28-effects-are-projects.md` — those never landed on main and + are retired with the branch. Builds on node card faces + (`2026-07-26-node-card-faces.md`), declarative default bindings + (`2026-07-09-declarative-default-bindings.md`), and node authoring + operations (`2026-07-27-node-authoring-operations.md`). +- **Superseded by:** None + +## Context + +PR #218 implemented "scoped buses + embeddable projects": writer-shadowing +bus scopes keyed `(scope, name)`, projects embeddable as child nodes, +`PromotedControlDef` control aliasing, and two shipped example effects. +Before merge, Yona's product vision sharpened — "the bus logically lives +in the project node; project nodes are bus holders and the place you +define the public interface" — and asked for a design review of the spike +against it (2026-07-31 session; planning artifact: +`planning/2026-07-31-1002-modules-buses-panels`). + +The review found the spike's *model* essentially matched the vision while +its *implementation* did not: + +- Scope was a transient loader side-table (`BusScopes`), built and dropped + inside one binding pass — post-load, nothing could answer "what scope is + node X in". The bus stayed a flat global index with a compound key. +- The wire had no structured scope — non-root channels reached Studio as + display strings (`"plasma›visual.out"`), and playlist-entry scopes had + to be hand-filtered out of the probe after they caused every inactive + entry to render each refresh. +- Root was special-cased in ~7 sites across three crates, most sharply + Studio's `channel.name == "visual.out"` string test for "the primary + visual". +- A latent depth-2 defect: writer collection skipped project nodes, so + the output mirror's own `visual.out` publish was absent from the + shadowing table — depth 1 resolved correctly only because the no-writer + fallback also landed on root; at depth 2 a consumer beside an embedded + effect resolved to root's visual instead of its sibling's. Untested. +- Control promotion was value-less aliasing resolved twice, in different + languages: structurally in the loader, then again in Studio by + string-matching projected UI DTO rows. +- (Incidental: a duplicated `#[test]` attribute on the branch silently + disabled `binding_graph_probe_reports_bindings_channels_and_values`.) + +The subsequent design discussion (session log in the planning directory's +`notes.md`, D1–D8) converged a stronger model — notably: promotion must be +automatic, control meta must follow whatever is currently bound (the +playlist meta-switching case), and panel edits must not dirty the project +— which the spike's aliasing could not express. + +## Decision + +1. **`docs/design/modules.md` is the normative model** (project/module + mitosis, binding-is-publicity, panel as a first-class per-node view, + lazy runtime panel writers, sink scopes as a modeled property). The + design doc states what is; this ADR records how we got here. +2. **PR #218 is not merged.** The branch is kept as a harvest source and + the PR closed once harvesting (below) is complete. +3. **Harvest inventory** — what carries forward and what dies: + +| Spike piece | Fate | +|---|---| +| Writer-shadowing resolution (produce-local / consume-inherit) | Kept → R4/R5 | +| Anonymous playlist-entry scopes | Kept, promoted → sink scopes as modeled property (R2) | +| Output mirror + non-root fallback publish | Kept, generalized → R7 (authored exports added) | +| `ScopedChannel` key through `QueryKey`/binding index/resolver | Kept as mechanism (resolver stays scope-dumb) | +| Effect-face component + panel widget reuse | Kept as the UX spike's starting point | +| plasma / meteor example content | Kept as future example modules | +| Cross-target uniform-indexing filetest + defect/debt entries | Landed on main independently (side task) | +| `PromotedControlDef` value-less aliasing + DTO string-matching | Dropped → R3 (binding is publicity); only the meta-override vocabulary survives (R9) | +| Transient `BusScopes` side-table | Dropped → R1 (structural scope state) | +| Display-string scope on the wire; probe-side entry filtering | Dropped → structured wire scope; R2 by construction | +| "Resolve to root, surface unfilled" | Replaced → R6 (authored default; channel still lists) | +| Root special-casing (~7 sites) | Deleted — root is not special (R1) | + +4. **Defect classes the implementation must pin with tests:** the depth-2 + resolution shape (design doc E5) and sink-scope no-demand (R2.2). Do + not inherit the branch's disabled probe test. +5. **Naming:** the container node kind is renamed `project` → `module` + (one-shot, alpha, no migration); "project" survives as the container/ + folder concept only. This reverses the spike-era "Module off the + table" kickoff lean — the project/module mitosis (container vs node) + did not exist then and is what makes the name correct now. + +## Consequences + +- The two spike ADRs are not carried to main; their surviving content is + re-derived in `docs/design/modules.md` and future implementation ADRs. +- Implementation planning (the M3 roadmap run in the planning directory) + sequences the rename, engine restructure, wire change, panel runtime, + and Studio rebuild, harvesting per the table above. +- The "effects are projects" insight survives unchanged as "effects are + modules": a category, not a structure. + +## Alternatives considered + +- **Merge #218 and iterate** — rejected: the aliasing layer, transient + scopes, string-typed wire scope, and root special-casing are all things + the converged model deletes; merging would mean building the new model + through partial reverts of the old one, with the depth-2 defect live in + the meantime. +- **Keep value-less control aliasing** (`PromotedControlDef`) — rejected + by the converged requirements: promotion must be automatic (aliases + require authoring), meta must follow the currently-bound slot (aliases + freeze a target), and panel edits must not dirty the project (alias + writes land on authored child slots). +- **Keep "project" as the node kind with UI-copy disambiguation** — + rejected: the copy ambiguity was structural, not cosmetic; the mitosis + gives each concept one name. diff --git a/docs/design/modules.md b/docs/design/modules.md index 003b826f1..88ad5cd1f 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -2,12 +2,11 @@ This document is the single source of truth for the **module container model**: what a module is, how bus scoping works, how slots become public, -and how the panel — the end-user control surface — derives from the bus. It -supersedes the direction of the composite-effects spike (PR #218) and, at -implementation time, its two ADRs (`2026-07-28-scoped-buses.md`, -`2026-07-28-effects-are-projects.md`, both on the spike branch) — the -spike's writer-shadowing model survives here; its promoted-control aliasing -and transient scope implementation do not. +and how the panel — the control surface — derives from the bus. It +describes what *is* (once implemented, what the system does); the decision +context that produced it, including its relationship to the +composite-effects spike, is recorded in +`docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md`. > **Status: DRAFT — awaiting ratification** (gate G1 of > `planning/2026-07-31-1002-modules-buses-panels`). @@ -18,7 +17,8 @@ and transient scope implementation do not. > public face) presented one way at every nesting depth. Where this > document and an implementation convenience disagree, this document wins. > -> **Related:** `docs/adr/2026-07-26-node-card-faces.md` (face grammar), +> **Related:** `docs/glossary.md` (terms), +> `docs/adr/2026-07-26-node-card-faces.md` (face grammar), > `docs/adr/2026-07-09-declarative-default-bindings.md` (default binds), > `docs/adr/2026-07-27-node-authoring-operations.md` (create/vendor seam). @@ -32,7 +32,8 @@ and transient scope implementation do not. | **Scope** | The bus namespace a module introduces around its children. | Global; a display prefix | | **Channel** | A named value stream within a scope. Exists iff some binding names it. | A declared registry entry; a place values are stored | | **Public slot** | A slot with a bus binding. Its channel appears in its scope. | A slot copied, aliased, or mirrored anywhere | -| **Panel** | A *presentation* of a scope's channel list. The root module's panel is the end-user surface. | A place values live; a dataflow construct | +| **Panel** | A node's *control surface*: the presentation of its public surface (R8). Every node has one; the root module's is the end-user surface. | A place values live; a dataflow construct; face-specific | +| **Face** | A node's kind-specific card presentation in the workspace — the authoring/monitoring instrument. A face *hosts* the node's panel plus authoring-only surfaces (drawers, code, entries strip). | The panel itself; anything play mode renders | | **Panel state** | Unauthored runtime writer state (per scope + channel), persisted separately. | Part of the project's authored artifacts; dirty-tracked | The project/module split ("mitosis") is deliberate: the project carries the @@ -70,9 +71,9 @@ introduction is a property of the module kind, not the invocation site. Scope identity is **structural engine state**: after load, the engine can answer "which scope contains node X" and "which scope does module M -introduce" (the spike's throwaway loader side-table is explicitly -rejected). The scope is engine-owned runtime structure hung off the module -node; authored artifacts never name scopes. +introduce" — a transient load-time-only scope table is non-conforming. +The scope is engine-owned runtime structure hung off the module node; +authored artifacts never name scopes. ### R2 — Sink scopes (isolating invocation sites) @@ -86,9 +87,10 @@ honored by construction rather than by per-layer filters: through the isolating node's own face (the playlist face presents the *active* entry's panel — see E2). 2. **No demand:** listing a panel or probe must never force resolution of - an inactive sink child's channels (the spike violated this and every - inactive playlist entry rendered every frame; the fix must live in the - model, not in the probe). + an inactive sink child's channels — otherwise every inactive playlist + entry renders on every listing (an observed failure class; see the + companion ADR). The property lives in the model, never as a per-layer + filter. Entries are *alternatives* (isolated from one another); module children are *collaborators* (share one scope). That is why there are exactly two @@ -140,8 +142,7 @@ If no enclosing scope has a writer, the consuming slot uses its **own authored default value**, and the channel **still lists** in the consumer's scope. Listing is what makes the channel appear on the panel, where touching the control materializes a writer (R10) — an unfilled -public input is an *invitation*, not an error. This replaces the spike's -"resolve to root, surface unfilled". +public input is an *invitation*, not an error. ### R7 — Module output interface: one automatic publish + authored exports @@ -170,19 +171,30 @@ public input is an *invitation*, not an error. This replaces the spike's Inputs need no counterpart: consumed inheritance (R5) already lets a host feed a module's inner consumers with zero authoring (see E6). -### R8 — The panel is a view of the scope's channels - -The panel of a scope **is its channel list, presented**. No dataflow -construct exists behind a panel; nothing is promoted between levels. - -- A module's face renders its own scope's panel. -- An enclosing module's panel additionally presents each child module's - panel as a **nested group** (presentation recursion — the same shape as - the card grammar). Two embedded instances of the same effect present - two independent groups. -- The **root module's face is the end-user panel**. Play mode is "render - only the root module's face". -- Sink scopes surface only per R2 (through the isolating node's face). +### R8 — The panel: one concept, every node, derived from publicity + +The **panel** is a first-class per-node concept, not a feature of +particular faces: every node has a panel, and it presents the node's +*public surface*. No dataflow construct exists behind a panel; nothing is +promoted between levels. + +- A **leaf node's** panel presents its public (bound) slots. Publicity + (R3) is the one gesture that puts a control on a panel — this + **subsumes the legacy `panel: bool` slot flag**: "add to panel" *means* + "bind to a channel". (Widget choice, step, unit remain slot meta.) +- A **module's** panel presents its scope's channel list — which is the + aggregate of its children's publicity — plus each child module's panel + as a **nested group** (presentation recursion). Two embedded instances + of the same effect present two independent groups. +- An **isolating node's** panel (playlist) presents the *active* sink + child's panel (R2); inactive siblings surface nowhere. +- The **root module's panel is the end-user surface**. Play mode renders + panels only — no faces. +- A **face** is a different concept: the kind-specific card presentation + in the workspace (preview hero, code drawers, entries strip — the + authoring instrument). A face *hosts* its node's panel; it is never + itself the panel. Faces appear in the workspace; panels appear on faces + *and* stand alone in play mode. - Presentation is kind-dependent: scalar channels render as knobs/faders, bools as toggles, visuals as preview tiles, streams as readouts. Channels driven by authored writers (an LFO, a clock) render as live @@ -201,8 +213,8 @@ conflicting meta: **numeric ranges union (widest wins)**; on label/unit conflict the channel name wins. A module-level authored meta override (label/unit/min/max on the module node, per channel) beats derivation — this is the curation escape hatch, and the only module-side declaration -in the model. It carries no value (that would recreate the alias problem -the spike had). +in the model. It carries no value — an override that carried one would +create a second source of truth beside the bound slot. ### R10 — Panel state: lazy, stateful, unauthored runtime writers @@ -349,7 +361,7 @@ H (root module): control K (value → bus:speed, authored) still follows K. The UI shows plasma₁'s control as *engaged/overridden* (R11). Reset plasma₁ (R12) → re-inherits K. -### E5 — Depth 2 (module inside a module) — the spike's latent-bug case +### E5 — Depth 2 (module inside a module) ```text H (root module): clock, … @@ -360,10 +372,11 @@ H (root module): clock, … - `C.vis` resolves: Scope(M_outer) **has** a `visual.out` writer — M_inner's publish — so it resolves to the **sibling effect's visual**, - never walking to root (R5). The spike got this wrong (its writer table - omitted module publishes; depth 1 worked only because the fallback also - landed on root). **Implementation must pin this exact shape with a - test.** + never walking to root (R5). Module publishes and exports (R7) count as + writers in resolution exactly like any other producer; an + implementation whose writer accounting omits them fails only at depth + ≥ 2, so **this exact shape must be pinned with a test** (see the + companion ADR for the observed failure). - M_outer's own `output` mirrors Scope(M_outer)'s `visual.out` = M_inner's visual (R7), and publishes it up into Scope(H) — nesting composes. - Feedback loops via the bus are not a supported idiom: a node that both @@ -394,7 +407,7 @@ H (root module): audio-input node (samples → bus:audio.in) (silence, R6); its workbench project supplies a test-tone writer. A module with no visual is legitimate: its `output` mirrors cleared (R7). -## 5. UI corollaries (spike M2 owns the shape) +## 5. UI corollaries (the UX spike owns the shape) One face, three zoom levels: the **effect author** works inside the module (children expanded); the **artist** sees the module face as a card @@ -423,9 +436,8 @@ my-project/ # never dirty-tracked, safe to delete ``` -- `format` lives **only** in `project.json`. Vendored module folders carry - none — the spike's "format tolerated-but-ignored at non-root" rule is - deleted. A vendored module's format is its host project's; a bare module +- `format` lives **only** in `project.json` — module folders never carry + one. A vendored module's format is its host project's; a bare module folder opened standalone is wrapped in a workbench project (starter-project seam) and assumed current — cross-version module *sharing* keeps the alpha posture: version + refuse, never migrate. @@ -434,7 +446,8 @@ my-project/ paths, so vendoring/renames invalidate entries gracefully (unknown paths are dropped on load). - Relative `node:` refs and file-relative artifact refs survive vendoring - by construction (unchanged from the spike's analysis). + by construction — a module folder's internal wiring is + location-independent. ## 7. Bus vocabulary — under discovery @@ -450,31 +463,9 @@ nothing in this document depends on the vocabulary's final shape. `author`, `version`, `license`, `created` (ISO date). Optional on any node and on `project.json`; skip-if-default; no semver semantics yet. -Copy-on-extract per R14. (The spike's `ProjectDef.author/version/license` -survive as this, relocated.) - -## 9. Relationship to the #218 spike - -| Spike piece | Fate | -|---|---| -| Writer-shadowing resolution (rules 3/4 of its ADR) | **Kept** → R4/R5 | -| Anonymous playlist-entry scopes | **Kept, promoted** → sink scopes as modeled property (R2) | -| Output mirror + non-root fallback publish | **Kept, generalized** → R7 (exports added) | -| `ScopedChannel` key through `QueryKey`/index/resolver | **Kept** as mechanism (resolver stays dumb) | -| Effect-face component, knob/panel widget reuse | **Kept** as M2 starting point | -| plasma / meteor content | **Kept** as future example modules | -| `PromotedControlDef` value-less aliasing | **Dropped** → R3 (binding is publicity); meta-override vocabulary survives in R9 | -| Transient `BusScopes` loader side-table | **Dropped** → R1 (structural scope state) | -| Scope flattened to display strings on the wire; entry scopes probe-filtered | **Dropped** → structured scope on the wire; R2 by construction | -| "Resolve to root, surface unfilled" | **Replaced** → R6 | -| Root special-casing (~7 sites, incl. Studio's `"visual.out"` string test) | **Deleted** — root is not special (R1) | - -Defects the implementation must pin with tests: the depth-2 resolution -shape (E5); sink-scope no-demand (R2.2 — the inactive-entry render -regression); and note the spike branch carries a silently-disabled probe -test (duplicated `#[test]`) — do not inherit it. - -## 10. Open questions (G1 redline register) +Copy-on-extract per R14. + +## 9. Open questions (G1 redline register) - **Q3 (spec proposed):** panel runtime commands `PanelWrite { scope, channel, value }` / `PanelReset { scope?, channel? }`; "touched" = @@ -489,3 +480,7 @@ test (duplicated `#[test]`) — do not inherit it. - **Q12:** grabbing an authored-driven channel (R8/R11 "grab the LFO") — in scope for the first implementation, or panel-writers-only-on- otherwise-unwritten-channels initially? +- **Q13:** R8 subsumes the `panel: bool` slot flag under publicity — a + leaf node's knobs are exactly its bound slots, and "add to panel" is + the binding gesture. Confirm the flag (and `ShaderSlotDef.panel`) is + deleted rather than kept as a parallel card-local mechanism. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 000000000..d53cc77c2 --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,95 @@ +# Glossary + +Shared vocabulary for LightPlayer. One line per term; follow the links for +the normative treatment. Terms marked **(module model)** follow +[`docs/design/modules.md`](design/modules.md), which may be ratified ahead +of implementation — code can lag these names during the transition. + +## Containers & artifacts + +- **Project** — the *container* you open in Studio: a folder with + identity, provenance, history, and `format`. Not a node. (module model) +- **Module** — the container *node kind* (formerly the `project` node + kind): owns a bus scope, a panel, and child nodes. The root module is an + ordinary module at the root. (module model) +- **Effect** — a *category* of module authored to be dropped into other + projects; a gallery/UI term, not a structure. (module model) +- **Node** — one unit of the runtime graph (shader, clock, playlist, + module, …), authored as one JSON artifact file per node. +- **Slot** — a named, typed value surface on a node (consumed or + produced); the unit that bindings, edits, and panel controls address. +- **Artifact** — an authored JSON file (node, project manifest); + serialized deterministically, byte-identical for unchanged models. +- **Vendoring** — importing a module by *copying* its folder into the host + project (no links, no update flow). (module model) +- **Provenance** — author/version/license/created metadata; may sit on any + node; copied onto a module when it is extracted from a project. + (module model) +- **Workbench project** — the wrapper project synthesized to open a bare + module standalone (preview fixture, clock, etc.). (module model) + +## Bus & dataflow + +- **Bus** — the "just works" wiring layer: named channels that slots read + and write via bindings instead of point-to-point references. +- **Scope** — the bus namespace a module introduces around its children; + scopes form a tree. (module model) +- **Sink scope** — the anonymous isolating scope wrapped around each + playlist entry: invisible to enclosing panels/probes, never demanded + while inactive, but entries still inherit outward. (module model) +- **Channel** — a named value stream within a scope; exists iff some + binding names it — there is no channel registry. +- **Binding** — an authored or default connection between a slot and a bus + channel (or another node's slot via `node:` ref). +- **Default bind** — a slot-declared binding (`default_bind`) materialized + at load at fallback priority; the mechanism behind zero-wiring behavior + like `time`. +- **Public / private slot** — public = has a bus binding (and therefore a + channel, and therefore a panel presence); private = unbound. Binding + *is* publicity. (module model) +- **Writer-shadowing** — consume resolution: a read resolves to the + nearest enclosing scope with a writer; writes always land in the + producer's own scope. Reads inherit, writes stay local. (module model) +- **Export** — an authored binding on a module node republishing an inner + channel into the containing scope; `visual.out` is the one automatic + publish. (module model) +- **Output mirror** — every module's produced `output` slot, reflecting + its scope's `visual.out`; what makes any module playlist-playable. + (module model) + +## Panel & faces + +- **Panel** — a node's *control surface*: the presentation of its public + surface. Leaf node → its bound slots; module → its scope's channels plus + nested child-module groups; playlist → the active entry's panel. The + root module's panel is the end-user surface. (module model) +- **Face** — a node's kind-specific card presentation in the workspace + (preview hero, entries strip, drawers): the authoring instrument. A face + *hosts* the node's panel; play mode renders panels without faces. +- **Drawer** — a collapsible authoring surface below a face (code, + advanced slots, bus wiring). +- **Panel state** — unauthored runtime writer state per (scope, channel); + persisted to `state.json` with throttled writes; never dirties the + project. (module model) +- **Engaged** — a panel control whose lazy runtime writer has + materialized (it was touched): it overrides authored writers in its + scope and shadows outer control until reset. (module model) +- **Reset** — removing panel writers (per control / module / whole panel), + restoring authored, inherited, or default resolution. (module model) +- **Play mode** — rendering only the root module's panel: the end-user + view. (module model) +- **Bound (violet)** — the UI state family for "this value comes from a + binding/bus"; always violet, never green (green = valid only). +- **Dirty** — an authored value differing from its saved artifact + (overlay-derived); panel state is never dirty. + +## Runtime & tooling + +- **Engine** — the shared runtime (loader, binding index, resolver, + nodes); identical across sim and device via `lpa-server`. +- **Sim / device parity** — the requirement that model semantics live in + the shared load path so browser-sim and firmware behave identically. +- **Probe** — a read-only wire query of runtime state (e.g. the + binding-graph probe feeding bus views). +- **Story** — a captured Studio component state used for visual baselines + (CI-canonical capture). From 6947f71694239106ba916cc8c1463b6de9534b01 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:32:31 -0700 Subject: [PATCH 3/6] =?UTF-8?q?docs(design):=20=C2=A76=20file=20layout=20s?= =?UTF-8?q?ettled=20=E2=80=94=20two=20ownership=20tiers,=20copy-to-own=20i?= =?UTF-8?q?mports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything in the project folder is user-owned content except .lp/, the single framework-owned dir (panel state moves to .lp/state.json; future caches/locks land there). Imported modules stay visible and committed under modules/ — copy-to-own, shadcn-inspired; provenance records origin. Local sub-modules are plain folders with explicit refs. Co-Authored-By: Claude Fable 5 --- docs/design/modules.md | 27 ++++++++++++++++++++++----- docs/glossary.md | 9 +++++++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/docs/design/modules.md b/docs/design/modules.md index 88ad5cd1f..d2f43d020 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -253,8 +253,9 @@ clearing the corresponding persisted entries (R13). ### R13 — Persistence -Panel state persists by default in `state.json` beside the project — -never in authored artifacts. Writes are **throttled (≥ ~10 s apart)** for +Panel state persists by default in `.lp/state.json` in the project +folder — never in authored artifacts. Writes are **throttled (≥ ~10 s +apart)** for flash preservation on device. Auto-save is on by default with a user toggle; reset clears persisted entries. @@ -421,6 +422,9 @@ state and the reset gesture (R11/R12); the drop-time contention pick (E3). ## 6. File layout +Two ownership tiers, and only two: everything in the project folder is +**user-owned content** except the single framework-owned `.lp/` directory. + ```text my-project/ ├─ project.json # container manifest — NOT a node: @@ -428,12 +432,15 @@ my-project/ ├─ module.json # root module node: nodes{}, bindings, exports, │ # per-channel meta overrides (R9), optional provenance ├─ shader.json … # child nodes, one file per node (unchanged) +├─ effect/ # a LOCAL sub-module: any folder with a module.json, +│ # referenced by explicit path — no blessed location ├─ modules/ -│ └─ plasma/ # a vendored module: a module folder, nothing more +│ └─ plasma/ # an IMPORTED module: visible, committed, copy-to-own │ ├─ module.json # (no project.json, no format — see below) │ └─ … -└─ state.json # panel state (R13) — unauthored, throttled, - # never dirty-tracked, safe to delete +└─ .lp/ # the ONE framework-owned dir: never authored, + └─ state.json # always safe to delete (panel state per R13; + # future caches/locks land here, never beside content) ``` - `format` lives **only** in `project.json` — module folders never carry @@ -441,6 +448,16 @@ my-project/ folder opened standalone is wrapped in a workbench project (starter-project seam) and assumed current — cross-version module *sharing* keeps the alpha posture: version + refuse, never migrate. +- **Imported modules are copy-to-own** (the shadcn model): once vendored, + the source is the user's — readable, editable, committed; provenance + (R14), not the directory, records origin. `modules/` is Studio's + default import target and pure convention — refs are explicit paths, so + any folder with a `module.json` is a module wherever it sits. If + import-by-reference ever exists, read-only dependency storage goes + *outside* the project (shared library + cache), not into a hidden + in-tree dir. +- `.lp/` is a project-folder concept; the device keeps its own filesystem + conventions and needs only the panel-state *data*, not the layout. - `state.json` shape (proposed, Q3): a versioned map of `scope-path / channel → { value, engaged }`. Scope paths are node paths, so vendoring/renames invalidate entries gracefully (unknown diff --git a/docs/glossary.md b/docs/glossary.md index d53cc77c2..b446e4ae6 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,12 @@ of implementation — code can lag these names during the transition. - **Artifact** — an authored JSON file (node, project manifest); serialized deterministically, byte-identical for unchanged models. - **Vendoring** — importing a module by *copying* its folder into the host - project (no links, no update flow). (module model) + project: **copy-to-own** (the shadcn model) — the source becomes the + user's; provenance records origin. No links, no update flow. + (module model) +- **`.lp/`** — the single framework-owned directory in a project folder + (panel state, future caches); never authored, always safe to delete. + Everything else in the folder is user-owned content. (module model) - **Provenance** — author/version/license/created metadata; may sit on any node; copied onto a module when it is extracted from a project. (module model) @@ -69,7 +74,7 @@ of implementation — code can lag these names during the transition. - **Drawer** — a collapsible authoring surface below a face (code, advanced slots, bus wiring). - **Panel state** — unauthored runtime writer state per (scope, channel); - persisted to `state.json` with throttled writes; never dirties the + persisted to `.lp/state.json` with throttled writes; never dirties the project. (module model) - **Engaged** — a panel control whose lazy runtime writer has materialized (it was touched): it overrides authored writers in its From ccf99fd954c7bfa0191327de90bb9f29d1fe110d Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 12:53:31 -0700 Subject: [PATCH 4/6] =?UTF-8?q?docs(design):=20panel.md=20=E2=80=94=20cont?= =?UTF-8?q?rols,=20latch=20state=20machine,=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel gets its own normative doc, sibling of modules.md. Two ratified frames anchor it: panel state is lighting-console-programmer / DAW-latch capture (hold until explicit clear), and controls shape but never integrate (slew ok; phasors are nodes + bus vocabulary). P1-P13 cover identity, Read/Latch/Clear, takeover (jump; pickup reserved for hardware), live-value display, raw-preserve/clamp-emission under meta changes, PanelWrite/PanelClear wire ops, engine-authority multi-client, restore-before-first-render, .lp/state.json, play mode, and MIDI/OSC as future panel writers. modules.md R9-R13 slim to summaries; Q3/Q11 move to panel.md's register, Q12 is resolved by the latch model. Co-Authored-By: Claude Fable 5 --- docs/design/modules.md | 122 +++++++++-------------- docs/design/panel.md | 217 +++++++++++++++++++++++++++++++++++++++++ docs/glossary.md | 19 +++- 3 files changed, 279 insertions(+), 79 deletions(-) create mode 100644 docs/design/panel.md diff --git a/docs/design/modules.md b/docs/design/modules.md index d2f43d020..5801820f7 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -17,7 +17,9 @@ composite-effects spike, is recorded in > public face) presented one way at every nesting depth. Where this > document and an implementation convenience disagree, this document wins. > -> **Related:** `docs/glossary.md` (terms), +> **Related:** `docs/design/panel.md` (control behavior — the normative +> panel treatment; R9–R13 below are summaries of its rules), +> `docs/glossary.md` (terms), > `docs/adr/2026-07-26-node-card-faces.md` (face grammar), > `docs/adr/2026-07-09-declarative-default-bindings.md` (default binds), > `docs/adr/2026-07-27-node-authoring-operations.md` (create/vendor seam). @@ -200,70 +202,43 @@ promoted between levels. Channels driven by authored writers (an LFO, a clock) render as live readouts that can be *grabbed* (R11). -### R9 — Control meta follows the binding - -A panel control's display meta (label, unit, min/max, step, widget) -derives from the slot(s) **currently bound** to the channel in that scope -— it is re-derived whenever bindings change. This is what makes E2 work: -when a playlist switches entries, the panel control's range switches with -it, because a different slot is now behind the channel. - -Merge rule when several slots in one scope bind the same channel with -conflicting meta: **numeric ranges union (widest wins)**; on label/unit -conflict the channel name wins. A module-level authored meta override -(label/unit/min/max on the module node, per channel) beats derivation — -this is the curation escape hatch, and the only module-side declaration -in the model. It carries no value — an override that carried one would -create a second source of truth beside the bound slot. - -### R10 — Panel state: lazy, stateful, unauthored runtime writers - -Panel controls write through **runtime writer state**, per -`(scope, channel)`: - -- **Unauthored.** Touching a control never edits an artifact and never - dirties the project. Authored artifacts define *defaults and wiring*; - panel state is live performance state. -- **Lazy.** The writer materializes on **first touch**, in the scope - where the control was touched. Untouched channels have no panel writer - — this is load-bearing: if every public slot self-shadowed, an outer - scope could never drive an inner channel (R5 would always stop at the - inner scope) and inheritance would be dead on arrival. -- **Stateful.** A panel writer is a data *source*, not a stored scalar — - it may integrate (the phasor knob: changing speed without phase - discontinuity), which is why it lives engine-side, identically on sim - and device, and Studio talks to it via runtime commands (the - playlist-activate precedent: a poke, nothing staged). - -### R11 — Precedence: an engaged panel writer wins its scope - -Within a scope, an **engaged** panel writer takes precedence over -authored writers for the same channel, until reset — grabbing a knob -overrides the LFO driving it. Across scopes, plain R5 applies: an engaged -writer in an inner scope shadows outer writers for that subtree. -Corollary: **touching a control detaches that scope from outer control** -until reset — the UI must show engaged/overridden state distinctly from -inheriting state. - -### R12 — Reset - -Reset removes panel writers — per control, per module (scope), or whole -panel — restoring authored / inherited / default resolution (R5/R6) and -clearing the corresponding persisted entries (R13). - -### R13 — Persistence - -Panel state persists by default in `.lp/state.json` in the project -folder — never in authored artifacts. Writes are **throttled (≥ ~10 s -apart)** for -flash preservation on device. Auto-save is on by default with a user -toggle; reset clears persisted entries. - -The motivating case (verbatim requirement): *4 a.m., Burning Man, LED -scarf dimmed from a phone. Unplug for a minute, replug — it must come -back dim, not blinding. Next night, brighter conditions — connect, hit -reset.* Persist by default; throttle writes; make reset one obvious -gesture. +### R9 — Control meta follows the binding *(summary — normative: panel.md P6/P7)* + +A control's display meta derives from the slot(s) **currently bound** to +the channel in that scope, re-derived whenever bindings change — a +playlist switch re-derives the control (E2). Ranges union on conflict; a +module-level authored per-channel override (label/unit/min/max, **no +value**) beats derivation — the curation escape hatch, and the only +module-side declaration in the model. + +### R10 — Panel state: lazy, unauthored runtime writers *(summary — normative: panel.md P1–P3)* + +Panel controls write through runtime writer state per `(scope, channel)`: +**unauthored** (never dirties the project — authored artifacts are +defaults and wiring; panel state is performance state) and **lazy** (the +writer materializes on first touch, in the scope where touched). Laziness +is load-bearing for R5: if every public slot self-shadowed, an outer +scope could never drive an inner channel. Writers hold values — they +shape, never integrate (panel.md P3). + +### R11 — Precedence: an engaged panel writer wins its scope *(summary — normative: panel.md P2/P4/P5)* + +Within a scope, an engaged panel writer outranks authored writers for the +same channel until cleared; across scopes, plain R5 applies — an engaged +inner writer shadows outer control for that subtree (touching detaches, +clearing re-attaches). The panel is latch-mode capture: values hold until +an explicit clear. + +### R12 — Reset *(summary — normative: panel.md P2)* + +Clear removes panel writers — per control, per module, or whole panel — +restoring R5/R6 resolution and dropping the persisted entries. + +### R13 — Persistence *(summary — normative: panel.md P10/P11)* + +Panel state persists by default to `.lp/state.json` (§6) with throttled +writes (≥ ~10 s, flash preservation), auto-save on by default, and +restore **before first render** on boot. Never in authored artifacts. ### R14 — Provenance is a node capability; extraction copies it @@ -484,20 +459,19 @@ Copy-on-extract per R14. ## 9. Open questions (G1 redline register) -- **Q3 (spec proposed):** panel runtime commands `PanelWrite { scope, - channel, value }` / `PanelReset { scope?, channel? }`; "touched" = - first `PanelWrite`; state keyed `(scope-path, channel)`; `engaged` - flag round-trips through `state.json`. Ratify or bend. +- **Q3:** → specced as `panel.md` P8/P11 (wire ops, state file shape); + ratify there. - **Q7:** provenance field set (§8) — enough? (`description`/`homepage` deferred?) - **Q10:** bare-module-folder open assumes current format (§6) — fine for alpha? -- **Q11:** R9 tiebreak (channel name wins on label conflict) — fine, or - prefer first-bound-slot-wins? -- **Q12:** grabbing an authored-driven channel (R8/R11 "grab the LFO") - — in scope for the first implementation, or panel-writers-only-on- - otherwise-unwritten-channels initially? +- **Q11:** → moved to `panel.md` P6 (merge/tiebreak rules); ratify there. +- **Q12:** → resolved by the latch model: grabbing authored-driven + channels is core behavior (`panel.md` P2/P5), not an increment. - **Q13:** R8 subsumes the `panel: bool` slot flag under publicity — a leaf node's knobs are exactly its bound slots, and "add to panel" is the binding gesture. Confirm the flag (and `ShaderSlotDef.panel`) is deleted rather than kept as a parallel card-local mechanism. +- `panel.md` carries its own register (P-Q1–P-Q4: slew defaults, + three-state affordance requirement, state-file versioning/flush, + clear-all vs sink scopes). diff --git a/docs/design/panel.md b/docs/design/panel.md new file mode 100644 index 000000000..14299b4ed --- /dev/null +++ b/docs/design/panel.md @@ -0,0 +1,217 @@ +# Panel — Controls, State, and Persistence + +This document is the single source of truth for the **panel**: the control +surface every node presents, and the runtime state behind it. It is the +sibling of [`modules.md`](./modules.md), which defines where panels *come +from* (publicity, channels, scopes); this document defines how controls +*behave*. The seam between the two is deliberately narrow (§4). + +> **Status: DRAFT — awaiting ratification** (gate G1 of +> `planning/2026-07-31-1002-modules-buses-panels`). +> +> **Posture.** The panel is the product surface — persona 3 ("end users +> control the art") lives entirely here. Two commitments shape every rule +> below: +> +> 1. **The panel is a lighting-console programmer.** Authored dataflow +> plays; touching a control *captures* its channel and holds it until +> an explicit clear — DAW **Latch** automation, not Touch. Chosen +> because the canonical use is unattended hardware (see P10's scenario): +> a value set from a phone must survive the phone leaving. +> 2. **Controls shape, they never integrate.** A panel writer holds a +> value and may smooth its trajectory toward it; it never accumulates +> state that *is* the signal. Anything that integrates (a phasor +> turning `speed` into continuous `phase`) is a **node** inside the +> module — ordinary authored dataflow and bus vocabulary, not a panel +> mechanism. +> +> **Related:** `docs/design/modules.md` (publicity, scopes, resolution), +> `docs/glossary.md` (terms), +> `docs/adr/2026-07-26-node-card-faces.md` (widget grammar the panel +> renders with). + +## 1. Concepts + +| Term | Is | Is not | +|---|---|---| +| **Control** | The presentation of one channel in one scope, on a panel. | A value holder; a slot; per-widget state | +| **Panel writer** | The runtime writer behind an engaged control: per `(scope, channel)`, unauthored, engine-side. | Authored; a node; UI-side state | +| **Read** | Control state while no panel writer exists: displays the channel's resolved live value (authored / inherited / default). | "Disabled" | +| **Engaged (Latch)** | Control state after first touch: the panel writer exists and holds, shadowing other writers in its scope until cleared. | Released by letting go (that would be Touch mode) | +| **Clear (reset)** | Removing panel writers — per control, per module, or whole panel — restoring Read. | An edit; an undo of authored values | +| **Slew** | Optional shaping of a writer's output *toward* its held value (anti-zipper). | Integration; a signal generator | +| **Takeover** | The policy for how a grab of a moving (automated) channel acquires it. | Needed for Read channels at rest (grab is trivial) | + +## 2. Control rules (normative) + +### P1 — Identity + +A control is identified by `(scope, channel)`. All state — engagement, +held value, persistence — keys off that pair. Widgets, faces, and clients +are views; two phones showing the same control show the same state (P9). + +### P2 — The state machine is Read → Latch → Clear + +- **Read**: no panel writer. The control displays the channel's resolved + live value and whose it is (inherited / authored / default — the UI + distinguishes these; an unwritten channel shows the consumer's default + per modules.md R6). +- **First touch** materializes the panel writer *in the scope where the + control lives* (lazy — modules.md R10) and captures the channel: + **Latch**. Releasing the knob changes nothing; the value holds across + frames, disconnects, and reboots (P10/P11). +- **Clear** removes the writer(s) and returns the control(s) to Read. + Granularity: one control, one module's panel, or the whole panel. Clear + is always one obvious gesture on every panel surface. + +There is no Touch mode (release-returns-to-automation) and no Write mode +(recording into authored artifacts). Panel gestures never author. + +### P3 — Shape, don't integrate + +A panel writer's entire state is: **held value** (+ optional slew toward +it). Slew is presentation-grade smoothing (anti-zipper on brightness +grabs), defined per widget kind with a project-wide default; it never +changes where a value *ends up*, only how it settles. Writers that +accumulate (`phase += speed·dt`), oscillate, or generate are forbidden — +that behavior is a node's. The supported idiom for phase-continuous speed +is: `speed` channel → phasor **node** → `phase` channel → consumer, with +`phase` as the bus-vocabulary convention example modules establish. + +### P4 — Precedence (restates modules.md R11) + +Within its scope, an engaged panel writer outranks authored writers for +the same channel until cleared. Across scopes, ordinary writer-shadowing +applies: an engaged writer in an inner scope shadows outer writers for +that subtree — touching detaches, clearing re-attaches. + +### P5 — Takeover: jump, by default + +Grabbing a channel that authored dataflow is actively moving acquires it +**immediately at the gesture's value** (jump). Rationale: on-screen +touch/drag controls carry the user's absolute intent, and performance +response must be instant. *Pickup* (control is inert until it crosses the +current value) and *scaled* takeover exist for future absolute hardware +inputs (MIDI faders — P13) and are a per-input-binding policy, not a +panel-wide mode. + +### P6 — Display: live value always; meta from the binding + +- A control always displays the channel's **resolved live value** — in + Read that's whatever writer wins (watch the LFO move the knob); in + Latch that's the held/slewing value. +- Engaged controls carry a distinct affordance (not the bound-violet + family — bound means "wired", engaged means "captured"; the UX spike + owns the treatment). +- Display meta (label, unit, range, step, widget kind) derives from the + slot(s) currently bound to the channel in that scope, re-derived on + binding change. Merge rule: numeric ranges union (widest wins); on + label/unit conflict the channel name wins; a module-level authored + per-channel override (modules.md R9) beats derivation. + +### P7 — Meta changes under a held value: preserve raw, clamp emission + +When derivation changes a control's range (a playlist switches entries; +a binding is edited) and a held value falls outside the new range: the +**raw held value is preserved**, the **emitted value clamps** to the +current range, and the control renders pinned at the edge. Switching back +restores the raw value exactly. (Consumers declared the range; feeding +them out-of-range values is meaningless — but the user's setting is never +silently destroyed.) + +### P8 — Wire: runtime commands, never authored ops + +- `PanelWrite { scope, channel, value }` — first write materializes the + writer (P2). Drag streams write at input rate, coalesced per + `(scope, channel)` (the slot-edit coalescing precedent); the engine + applies the latest value per tick. +- `PanelClear { scope?, channel? }` — absent fields widen the clear + (channel-level → scope-level → everything). +- Both are runtime pokes on the playlist-activate pattern: nothing + staged, nothing dirty, no overlay interaction. Studio, play mode, + phones, and future hardware inputs all speak exactly these two ops. + +### P9 — Multi-client: the engine is the authority + +Panel state lives in the engine (sim or device), never in a client. All +connected clients render the same resolved values and engagement flags; +concurrent writes to one control are last-writer-wins at the engine (a +knob fight converges on whoever moved last — acceptable; no locks, no +ownership). Clients learn state changes through the normal probe/refresh +path — two phones on one device stay in agreement. + +### P10 — Restore-on-boot: before first render + +Persisted panel state loads and rematerializes engaged writers **before +the first frame renders**. The defining scenario (verbatim requirement): +*4 a.m., Burning Man, LED scarf dimmed from a phone; unplug, replug — it +must come back dim, with not one bright frame; next night, connect and +reset.* A boot that renders even one frame at authored brightness before +applying restored panel state is non-conforming. + +### P11 — Persistence + +- Panel state persists to **`.lp/state.json`** in the project folder + (the framework-owned tier — modules.md §6); on device, to the + device's own filesystem. Never in authored artifacts. +- Contents: a versioned map `scope-path / channel → { value }` — raw + held values (P7); engagement is implied by presence. Unknown scope + paths are dropped on load (vendoring/renames degrade gracefully). +- Writes are **throttled (≥ ~10 s apart)** for flash preservation, with + a flush on clean shutdown/disconnect where the platform allows. +- **Auto-save is on by default** with a user toggle; Clear (P2) removes + the corresponding persisted entries immediately. + +### P12 — Play mode + +Play mode renders **panels only** — the root module's panel, which +recursively presents nested module groups (modules.md R8) — no faces, no +authoring surfaces. It speaks only P8's two ops plus reads. Anything +play mode can do, an end user is allowed to do. + +### P13 — External inputs (future, seam only) + +MIDI, OSC, and hardware encoders enter as **panel writers through P8's +ops** — same identity, same latch semantics, same persistence — with +per-input takeover policy (P5). No second control path will be added for +them. + +## 3. Worked walkthroughs + +- **The scarf**: boot → P10 restores `brightness` writer before frame 1 + (dim, no flash) → next night, Clear-all from the phone → P2 returns + brightness to Read (authored default) → P11 drops the entry. +- **Grab the LFO**: an LFO node drives `hue`; in Read the knob visibly + follows it (P6). Grab: jump-takeover (P5), writer materializes (P2), + LFO is shadowed in this scope (P4) — the ride continues elsewhere if + outer scopes consume it separately. Clear: the knob falls back into + the LFO's motion. +- **Playlist switch under a held value**: entry A's `speed` (0–10) held + at 7; switch to entry B (0–1): B's *own* control state is untouched + (different sink scope — different identity, P1). Switch back to A: + still 7. If instead one control's *meta* shrinks under a held value + (binding edit), P7: emit 1.0 (clamped), remember 7. + +## 4. The seam with modules.md + +The bus provides the panel: **channels per scope, writer sets, and +resolution** (publicity R3, locality R4, shadowing R5, listing R6). +The panel provides the bus: **exactly one new writer kind** — the panel +writer (unauthored, lazy, latching, persistable). Neither document +reaches deeper than that: modules.md never specifies control behavior; +this document never specifies resolution. + +## 5. Open questions (G1 redline register) + +- **P-Q1:** slew defaults — which widget kinds slew at all (brightness + fader: yes; stepped knob: no?), and is the time constant per-widget + meta or one project default? +- **P-Q2:** engaged-affordance treatment (distinct from bound-violet) — + UX spike owns the visual; confirm the *requirement* that Read-following + -automation, Read-at-default, and Latch are three visibly distinct + states. +- **P-Q3:** `state.json` schema version field name/shape, and whether a + clean-shutdown flush is feasible on device (or throttle-only). +- **P-Q4:** does Clear-all also clear *sink-scope* (playlist entry) + state, or only visible panels? Lean: everything under the cleared + scope, sinks included — "reset means reset". diff --git a/docs/glossary.md b/docs/glossary.md index b446e4ae6..802c18ddf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -76,11 +76,20 @@ of implementation — code can lag these names during the transition. - **Panel state** — unauthored runtime writer state per (scope, channel); persisted to `.lp/state.json` with throttled writes; never dirties the project. (module model) -- **Engaged** — a panel control whose lazy runtime writer has - materialized (it was touched): it overrides authored writers in its - scope and shadows outer control until reset. (module model) -- **Reset** — removing panel writers (per control / module / whole panel), - restoring authored, inherited, or default resolution. (module model) +- **Engaged (Latch)** — a panel control whose lazy runtime writer has + materialized (it was touched): it captures the channel, overriding + authored writers in its scope and shadowing outer control until + cleared — lighting-console programmer / DAW latch semantics. + (module model) +- **Reset (Clear)** — removing panel writers (per control / module / + whole panel), restoring authored, inherited, or default resolution. + (module model) +- **Slew** — optional shaping of a panel writer's output toward its held + value (anti-zipper); controls shape, they never integrate — anything + accumulating state is a node. (module model) +- **Takeover** — the policy for grabbing a channel authored dataflow is + moving: jump (default for touch controls), pickup/scaled reserved for + absolute hardware inputs. (module model) - **Play mode** — rendering only the root module's panel: the end-user view. (module model) - **Bound (violet)** — the UI state family for "this value comes from a From f489f9cfbc06411dec781d0dbbba918edeeecb48 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 13:02:43 -0700 Subject: [PATCH 5/6] docs(design): momentary controls + fluid-drawing example (E7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old-lightPlayer fluid drawing use case pressure-tests the model and exposes one real gap: gesture channels (multi-touch pads, buttons) cannot latch. panel.md P14 adds the momentary control class — writes while the gesture is active, despawns on release, and the despawn IS the fallback mechanism (resolution falls through to a host camera or an idle-generator node). Idle detection stays a node concern (a gate with a public timeout param) because 'no data' is semantic: a connected camera seeing nobody is a live writer producing an empty set, invisible to any writer-priority scheme. modules.md gains E7 (fluid-draw module walk) and a vocabulary-pressure note (touch sets, phase); P-Q5 tracks the touch-set shape with MultiTouchInput.Touch as prior art. Co-Authored-By: Claude Fable 5 --- docs/design/modules.md | 39 +++++++++++++++++++++++++++++++++++++++ docs/design/panel.md | 40 ++++++++++++++++++++++++++++++++++++---- docs/glossary.md | 4 ++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/docs/design/modules.md b/docs/design/modules.md index 5801820f7..827998026 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -383,6 +383,41 @@ H (root module): audio-input node (samples → bus:audio.in) (silence, R6); its workbench project supplies a test-tone writer. A module with no visual is legitimate: its `output` mirrors cleared (R7). +### E7 — Fluid drawing module (gesture input + idle fallback) + +The interactive-installation shape: a fluid sim driven by touches — from +an XY pad on a phone, an IR camera, or, when nobody is playing, a +generated idle animation of wandering synthetic touches. + +```text +F (module, "fluid-draw"): + gate node in ← bus:touches.in (public gesture input; timeout + idle ← bus:touches.idle param `idle_after`, public) + out → bus:touches + friends node (synthetic wandering touches) → bus:touches.idle + emitter touches → forces/dye fluid sim → sim state + visual shader sim state → bus:visual.out +``` + +- `touches.in` has **no internal writer** — it is the module's inheritable + input, exactly E6's pattern. Its panel control is a **multi-XY pad**, a + *momentary* control (panel.md P14): touching writes Scope(F), releasing + despawns the writer. +- Resolution while playing: pad engaged → your touches (nearest scope). + Pad released → writer gone → walk outward → a host camera node writing + `touches.in` in Scope(H), if present (R5). No camera → unwritten → + the gate reads an empty set (R6). +- **Idle is a node, not a resolution feature**: the gate crossfades to + the friends-generator's touches after `idle_after` seconds of + empty-or-absent input. "Empty" matters — a connected camera seeing + nobody is a live writer producing an empty set, which no + writer-priority scheme can distinguish from activity; only domain + logic can (which is why the timeout is a param, on the panel). Both + silence cases collapse into the gate's one code path. +- In play mode on a phone, F's panel — the pad plus `idle_after`, + friend-count, and the sim's public params — *is* the installation + controller. + ## 5. UI corollaries (the UX spike owns the shape) One face, three zoom levels: the **effect author** works inside the module @@ -451,6 +486,10 @@ exports are where conventions become contracts. Until then: names above are provisional conventions, not schema; new channels cost nothing (R3); nothing in this document depends on the vocabulary's final shape. +Known vocabulary pressure beyond scalars: **touch/gesture sets** (E7 — +multi-point, per-touch identity; shape question tracked as panel.md +P-Q5) and the `phase` convention (panel.md P3). + ## 8. Provenance (proposed field set — Q7) `author`, `version`, `license`, `created` (ISO date). Optional on any diff --git a/docs/design/panel.md b/docs/design/panel.md index 14299b4ed..ba50c8476 100644 --- a/docs/design/panel.md +++ b/docs/design/panel.md @@ -41,6 +41,7 @@ from* (publicity, channels, scopes); this document defines how controls | **Clear (reset)** | Removing panel writers — per control, per module, or whole panel — restoring Read. | An edit; an undo of authored values | | **Slew** | Optional shaping of a writer's output *toward* its held value (anti-zipper). | Integration; a signal generator | | **Takeover** | The policy for how a grab of a moving (automated) channel acquires it. | Needed for Read channels at rest (grab is trivial) | +| **Momentary** | The control class for gesture channels (touch sets, buttons): writes while active, despawns on release, never latches or persists. | A user-selectable mode; Touch automation | ## 2. Control rules (normative) @@ -65,7 +66,9 @@ are views; two phones showing the same control show the same state (P9). is always one obvious gesture on every panel surface. There is no Touch mode (release-returns-to-automation) and no Write mode -(recording into authored artifacts). Panel gestures never author. +(recording into authored artifacts). Panel gestures never author. The +Read → Latch → Clear machinery governs **value controls**; gesture-natured +controls are **momentary** and follow P14 instead. ### P3 — Shape, don't integrate @@ -172,9 +175,33 @@ play mode can do, an end user is allowed to do. ### P13 — External inputs (future, seam only) MIDI, OSC, and hardware encoders enter as **panel writers through P8's -ops** — same identity, same latch semantics, same persistence — with -per-input takeover policy (P5). No second control path will be added for -them. +ops** — same identity, same latch or momentary semantics, same +persistence rules — with per-input takeover policy (P5). No second +control path will be added for them. + +### P14 — Momentary controls: the gesture class + +Some channels carry **live gestures**, not settings: multi-touch sets +(an XY pad), momentary buttons. Their controls are **momentary**, and +the class is intrinsic to the channel/widget kind — never a user-facing +mode switch. + +- A momentary writer streams values while the gesture is active and + **despawns on gesture end**. There is no held value, no Latch, and + Clear is a no-op on them. +- **The despawn is the fallback mechanism.** Releasing the pad removes + the writer, and resolution immediately falls through (modules.md + R5/R6) to whatever is next — an inherited input in an outer scope, or + nothing, letting an idle-generator *node* take over (see modules.md + E7). No arbitration machinery exists beyond the writer lifecycle + value controls already use. +- Momentary state is **never persisted** — P11 does not apply. +- Takeover is trivially jump (P5): a gesture *is* the value. +- Semantic emptiness is not the panel's problem: a connected input + producing an *empty* gesture set (a camera seeing nobody) is a live + writer writing "empty". Deciding that empty-for-N-seconds means idle + is domain logic and belongs to a node (a gate with a timeout param), + never to writer resolution. ## 3. Worked walkthroughs @@ -215,3 +242,8 @@ this document never specifies resolution. - **P-Q4:** does Clear-all also clear *sink-scope* (playlist entry) state, or only visible panels? Lean: everything under the cleared scope, sinks included — "reset means reset". +- **P-Q5:** the touch-set value shape (per-touch id, position, + pressure/z, velocity — carried or derived?) and the multi-XY pad + widget spec. Prior art: old lightPlayer's `MultiTouchInput.Touch` + (id, initial/current x·y, z, velocity, interpolation). Vocabulary + item — modules.md §7. diff --git a/docs/glossary.md b/docs/glossary.md index 802c18ddf..2d92914c3 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -90,6 +90,10 @@ of implementation — code can lag these names during the transition. - **Takeover** — the policy for grabbing a channel authored dataflow is moving: jump (default for touch controls), pickup/scaled reserved for absolute hardware inputs. (module model) +- **Momentary** — the control class for gesture channels (touch sets, + buttons): writes while the gesture is active, despawns on release — + which is itself the fallback mechanism — never latches, never + persists. (module model) - **Play mode** — rendering only the root module's panel: the end-user view. (module model) - **Bound (violet)** — the UI state family for "this value comes from a From dc83594aa9d87fe950d7b3852d4e56ac588738ee Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:15:39 -0700 Subject: [PATCH 6/6] docs(design): flip modules.md + panel.md to ratified (G1 passed) Co-Authored-By: Claude Fable 5 --- docs/design/modules.md | 6 ++++-- docs/design/panel.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/design/modules.md b/docs/design/modules.md index 827998026..07e83b4bf 100644 --- a/docs/design/modules.md +++ b/docs/design/modules.md @@ -8,8 +8,10 @@ context that produced it, including its relationship to the composite-effects spike, is recorded in `docs/adr/2026-07-31-module-model-supersedes-composite-effects-spike.md`. -> **Status: DRAFT — awaiting ratification** (gate G1 of -> `planning/2026-07-31-1002-modules-buses-panels`). +> **Status: Ratified 2026-07-31** (gate G1 of +> `planning/2026-07-31-1002-modules-buses-panels`). The open-question +> register (§9) records accepted leans — revisit at implementation, not +> before. > > **Posture.** Alpha: no wire or artifact compatibility obligations; the > rename below is one-shot with no migration. The architecture is required diff --git a/docs/design/panel.md b/docs/design/panel.md index ba50c8476..2bc504604 100644 --- a/docs/design/panel.md +++ b/docs/design/panel.md @@ -6,8 +6,10 @@ sibling of [`modules.md`](./modules.md), which defines where panels *come from* (publicity, channels, scopes); this document defines how controls *behave*. The seam between the two is deliberately narrow (§4). -> **Status: DRAFT — awaiting ratification** (gate G1 of -> `planning/2026-07-31-1002-modules-buses-panels`). +> **Status: Ratified 2026-07-31** (gate G1 of +> `planning/2026-07-31-1002-modules-buses-panels`). The open-question +> register (§5) records accepted leans — revisit at implementation, not +> before. > > **Posture.** The panel is the product surface — persona 3 ("end users > control the art") lives entirely here. Two commitments shape every rule