diff --git a/Cargo.lock b/Cargo.lock index 9c200c9d3..7d9a2ebc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5895,6 +5895,7 @@ dependencies = [ "hashbrown 0.15.5", "jsonschema", "libm", + "log", "lp-collection 1.0.0", "lpc-slot-codegen", "lpc-slot-macros", diff --git a/docs/adr/2026-07-05-studio-pane-grammar.md b/docs/adr/2026-07-05-studio-pane-grammar.md index f0e0645a8..0e88a2023 100644 --- a/docs/adr/2026-07-05-studio-pane-grammar.md +++ b/docs/adr/2026-07-05-studio-pane-grammar.md @@ -150,6 +150,33 @@ hierarchy type is deliberately separate because the two levels announce different things, but both follow the same grammar: one priority-merged affordance on the detail trigger, text in the popup. +### The debug family (added 2026-08-01) + +The colour language above assigns one meaning per family: **amber/yellow = +unsaved**, **blue = live**, **violet = bound** (never green — green is valid +only), **red = failed**, and **flat orange = device/roster health**. Debug +(`2026-08-01-debug-slots-taxonomy.md`) needed a treatment that reuses none of +them and reads as "you have put the system in a debug state", so it takes the +attention (orange) family in a **striped** form: diagonal hazard stripes. +Flat orange stays device health; the stripes are what distinguish the two, in +the repo's form-not-just-colour tradition (the stepped knob's gaps *are* the +scale). + +The split the grammar cares about is where the mapping lives: core carries a +distinct **semantic** variant — `UiAffordance::Debug`, in the same +priority-merged vocabulary as `Unsaved`/`Error` — and the web layer holds the +single orange-plus-stripes mapping (`lpa-studio-web/src/app/affordance.rs` +plus one `style.css` block of `.lp-debug-*` tokens). Re-skinning debug later +is one mapping edit, never a call-site hunt. Debug is also unlike the other +families in that it announces **territory, not an event**: the node card's +Debug section is striped whenever it exists, idle or not, because knowing a +value will never be saved *before* touching it is the whole point. + +Two consequences for the chrome described above: the `DirtySummary` that +feeds it no longer has a transient bucket (Debug carries no dirty weight — +the struct is `{ persisted, failed }`), and the affordance vocabulary's +`Live` variant was re-homed to `Debug` rather than gaining a sixth family. + ### Consumers Adopted by the node pane (P3: selection toggle in `primary`, tabs in diff --git a/docs/adr/2026-08-01-debug-slots-taxonomy.md b/docs/adr/2026-08-01-debug-slots-taxonomy.md new file mode 100644 index 000000000..d83944a6a --- /dev/null +++ b/docs/adr/2026-08-01-debug-slots-taxonomy.md @@ -0,0 +1,301 @@ +# ADR: Slot taxonomy — Settings, Panel, Debug, State + +- **Status:** Accepted +- **Date:** 2026-08-01 +- **Deciders:** Photomancer +- **Supersedes:** None +- **Superseded by:** None +- **Relates:** `2026-07-04-studio-editing-model.md` (D2 introduced writable + transient slots; this ADR names and bounds them); + `2026-07-27-runtime-node-command-channel.md` (reconciled below — + unchanged); `2026-07-05-studio-pane-grammar.md` (gains the debug colour + family); `2026-07-09-declarative-default-bindings.md` (retrofitted the + `read_only_transient` state records this ADR retires); + `2026-07-27-node-authoring-operations.md` (its operation contract is + unchanged; its `#[slot(policy = "read_only_persisted")]` spelling is + superseded by `#[slot(role = "fixed")]`); `docs/design/panel.md` + + `docs/glossary.md` (the panel vocabulary this taxonomy must not collide + with) + +## Context + +`SlotPersistence { Persisted, Transient }` shipped 2026-05-12 with the clock +node as a pure, unconsumed hint; the plan that added it explicitly deferred +"the config/params/controls/state taxonomy" as future work. It stayed inert +for two months. The 2026-07-04 studio editing milestone then activated half +of it (D2: transient edits are staged, run live, and are retained across +save), and a later guardrail retrofitted `read_only_transient` onto seven +produced-state records to stop them presenting as editable. One variant +ended up carrying three unrelated meanings: + +1. **writable session controls** — exactly one shape, the clock's + `controls.{running, rate, scrub_offset_seconds}`; +2. **produced runtime state** — seven state records marked read-only + transient, which is a fact about their *direction*, not a policy; +3. **studio-synthesized** — the view builder rewrote every produced-direction + slot to `read_only_transient` at DTO-build time, papering over records + nobody had marked (`TextureState` was safe only because of this patch). + +The user-facing story was thinner still: a clean transient slot was +pixel-identical to a persisted one, so you could not know a value would never +be saved until after you edited it; transient edits appeared in the Save +panel as a "Live" bucket; and `node.schema.json` advertised fields the writer +refuses to ever write. + +Meanwhile the nested-modules line landed **panel state** on main +(`docs/design/panel.md`, PR #230): unauthored per-`(scope, channel)` writer +state, persisted to `.lp/state.json`, never dirty. Two mechanisms now +answered the same user question — *does a live control value survive a +restart?* — in opposite ways. Nailing down the product language became the +point of the work, not a side effect. + +## Decision + +### The language + +| Category | Meaning | Persistence | +|---|---|---| +| **Settings** | Authored node config | In the artifact; Save/dirty | +| **Panel** | End-user control surface; fronts any slot (usually a Setting). Authored value = default, panel writer = live override | `.lp/state.json`; never dirty | +| **Debug** | Transient BY NATURE — diagnostics/authoring overrides, no durable value underneath | Session only; dies on unload/reboot | +| **State / Outputs** | What the runtime produces | Never authored; declared `State` role, cross-checked against `direction = Produced` | + +**G2 amendment (2026-08-02).** The fourth category was ratified at D1 as +*direction-implied* — produced state needed no role, because +`direction = Produced` already made a field read-only and never-serialized. +Yona rejected that at the final gate: "we don't have an Output/Produced +policy… my instinct is that it should be explicit." Declaration beats +inference. `SlotRole::State` now exists and every produced field declares it, +with `role_matches_direction` rejecting **both** halves of a mismatch — a +`State` field that is not produced, and (the case that let `TextureState` +sit unmarked for two months) a produced field that is not `State`. The +classification is unchanged: `State` + `Produced` resolves exactly as an +unmarked produced field did. What changed is that the marking is now +mandatory and machine-checked instead of merely implied. + +### The taxonomy line + +**Events → command channel; ephemeral state → Debug slots; produced state → +the `State` role (direction-checked).** + +This is the sentence that makes the three mechanisms one system instead of +three accidents. Each ephemeral thing gets exactly one home, chosen by what +it *is*, not by which machinery is convenient: + +- an **event** has no value to hold (activate this entry, press this button) + — it is a poke on the runtime command channel; +- **ephemeral state** has a value that must persist for the session and reach + the engine every frame — it is a Debug slot riding the overlay; +- **produced state** is written by the runtime and read by everyone else — it + needs no marking at all, because `direction = Produced` already says + read-only and never-serialized. + +### Panel is not Debug (the boundary) + +Panel and Debug both look like "a live value a user manipulates that is not +in the artifact", and they are not the same thing: + +- **Panel is an *exposure* mechanism over any slot.** Anything can be exposed + as a panel control — usually a Setting — and exposure IS binding: a control + fronts a slot **via its bound channel** (`modules.md` R3, binding = + publicity; control identity is `(scope, channel)`, never the slot itself). + The authored value is the **default**; the panel writer is a **live + override** on top of it. That is precisely why *latching* panel state + persists to `.lp/state.json` and why `panel.md` says a Control "is NOT a + slot": it *fronts* one. A show that was tuned on the panel must come back + tuned. (Momentary panel controls — `panel.md` P14 — are session gestures + that never persist and are still Panel, not Debug: their fallback is bus + resolution, not a shape default.) +- **Debug is *transient by nature*.** There is no durable value underneath — + nothing authored to override, nothing to come back to. It is session-only + and dies on unload/reboot, **deliberately**: a rebooted installation must + not come up in test-pattern mode. + +So there is no retention *axis* to name. Panel state persists because it +overrides durable settings; Debug does not persist because there is nothing +durable to override. The two vocabularies coexist: a slot may be a Setting +exposed on a panel, or a Debug slot, and the Clear verb means the same thing +in both worlds (drop the override, fall back to what is underneath — for +Debug, the shape default). + +### `SlotRole` replaces the policy axes + +`SlotPolicy { writable, persistence }` is gone. Its replacement is +**`SlotRole::{ Setting, Fixed, Debug }`**, with writability implied +(Setting/Debug writable, Fixed read-only) and direction supplying the rest: + +| Former policy | Now | +|---|---| +| `writable_persisted` (the default) | `Setting` | +| `read_only_persisted` (3 `ProjectDef` fields) | `Fixed` | +| `writable_transient` (clock ×3, `output.test_pattern`) | `Debug` | +| `read_only_transient` (7 state records) | *nothing* — `direction = Produced` implies it | + +Declaration sites read `#[slot(role = "debug")]`. Persistence survives only +as a **derived** classification (`effective_persistence(role, direction)`) — +never a stored axis, never declarable, and the single function both the +studio (display, dirty accounting) and the registry (commit-time retention) +must consult, so the two sides cannot disagree about what an edit is. Paths +that resolve in no shape take one shared fallback, +`SlotPersistence::for_unresolved_edit()` — **Setting** — on both sides. + +Retiring `read_only_transient` also retired the studio's view-build synthesis +patch: `TextureState` is now safe by construction (it declares its direction) +rather than by a rewrite in the DTO builder. + +### Nothing authored is Debug + +Debug values are session-only, which means they never appear in files: +schema-gen omits Debug fields from the authoring schema, the example projects +were scrubbed of their `controls.*` stanzas, the reader **warns and skips** an +authored Debug value rather than adopting it as a base, and the reset target +is the **shape default**, not whatever the file happens to hold. That last +one matters more than it looks: tying a Debug value's base to on-disk bytes +meant a commit could shift the base under the user and silently change what +Reset means. If shipping a preset ever matters, it is an explicit persisted +field feeding the control — never authored magic bytes in a Debug slot. + +### Debug leaves dirty and save entirely; its verb is Clear + +A Debug value is not an edit, so it carries no dirty weight: it counts in no +`DirtySummary` bucket, appears in no Save-panel section, and never arms the +unload gate. Warning about a knob turn would train users to dismiss the +dialog. Its verb is **Clear**, offered at three scopes (value, node, +project), matching the panel model's ratified Clear vocabulary. The mechanism +is unchanged — Debug values still ride the overlay to reach the engine, and +still survive a client disconnect because the overlay lives device-side. +Only the accounting and the presentation became honest. (A *failed* write to +a Debug slot still counts as failed: that needs attention.) + +### Debug state must be unmissable, and the mapping lives in one place + +A system in a debug state must announce it. Three tiers: a global header chip +whenever any override is active anywhere ("Debug active · N · Clear all"), +a marking on the node card that carries one, and the Debug **section** styled +as debug territory **always — even idle**, which is what structurally fixes +the clean-transient invisibility problem (you know before you touch it). The +section is policy-derived and **flattened**: any Debug field lands in it +regardless of which record declared it, so a node author gets correct UI +purely by marking a field. + +Architecturally the treatment is split: `lpa-studio-core` carries a distinct +**semantic** variant (`UiAffordance::Debug`), and the attention-orange + +hazard-stripe rendering lives only in the web presentation layer — one +mapping seam. Changing the visual later is one edit, not a call-site hunt. +The colour family itself is recorded in `2026-07-05-studio-pane-grammar.md`. + +### Naming: provisional but ratified + +`Debug` is not a perfect word, and it is the ratified one: every example in +the corpus is authoring/diagnostic rather than performance — drive the clock +by hand to inspect a show, force a wiring test pattern, simulate a press. +Refine later if a better word emerges. The known tension is the clock's +`rate`/`scrub_offset_seconds`, which read as transport rather than +diagnostics; the expectation is that they migrate to a transport surface, +leaving Debug holding exactly the diagnostics it describes. + +### Reconciliation with the runtime command-channel ADR + +`2026-07-27-runtime-node-command-channel.md` **stays valid and is not +amended.** It rejected modeling an ephemeral poke as an overlay edit — +"it pollutes the Save panel with a row for something that is not an edit … +its semantics are dishonest". That verdict was about an **event** (activate +this playlist entry): an event has no value to hold, so an overlay edit would +have had to be staged and immediately un-staged, claiming an authored trigger +the user never wrote. The taxonomy line above says exactly that: events go to +the command channel. + +Debug slots are the other branch. Ephemeral *state* does have a value, the +engine must see it on every frame, and the overlay is the mechanism that +already delivers exactly that. The Save-panel objection does not transfer, +because D7 removed Debug from dirty/save accounting entirely — the pollution +the command-channel ADR refused is now structurally impossible. + +One follow-up of that ADR is **obsolete**: *"Sim button press and debug pokes +adopt the channel as new `WireNodeCommand` variants."* Button input is punted +to the input initiative — record/replay requires injecting at the input +*source* layer, so a per-node `ButtonEvent` command was the wrong injection +point — and "debug pokes" as a category is answered here by Debug slots, with +no wire change at all (`WIRE_PROTO_VERSION` stays 4). The channel itself +remains the right home for any future genuine event. + +## Consequences + +- The three mechanisms are now separable by a question the author can answer + without reading code: does it have a value? does something durable sit + underneath it? who writes it? +- `SlotPersistence::Transient` means exactly one thing — a writable live + control — and produced-state protection no longer depends on anyone + remembering to mark a record. A new state record is safe the moment it + declares its direction. +- Debug values are absent from schemas, example projects, and save + accounting, so "the file is the project" holds again: nothing on disk can + be a Debug value, and nothing a user turns in a Debug section can dirty a + project. +- Node authors get the Debug section, the hazard treatment, the global chip, + and the Clear verbs for free by marking one field — `OutputDef.test_pattern` + proved this end-to-end with zero output-specific UI code. +- The cost is a hard rename across four crates plus regenerated shape dumps + (`"policy"` → `"role"`). It was paid now because there were only four + declaration sites; it would not have stayed that cheap. +- Two vocabularies (Panel and Debug) now share the word "Clear" and the idea + of an override. That is intended — they are the same gesture over different + substrates — but it does mean the glossary, not the code, is where the + distinction is taught. + +## Alternatives Considered + +- **Keep `SlotPolicy { writable, persistence }` and just document it.** + Rejected: the axes cross-multiplied into combinations that could not be + declared (an explicit `writable_persisted` inside a transient container is + unrepresentable) and one of the four combinations was standing in for + direction. A role enum covers everything that survives the taxonomy with no + unreachable states. +- **Model the ephemeral cases as runtime commands** (the shape PR #233 built: + wire proto 5, `ButtonEvent`, `OutputTestPattern`, per-node runtime state, + TTL leases and renewal loops). Rejected for *state with a durable home*: + the leases only existed because a command gave this state no home, and a + Debug slot provides one. (Liveness leases stay legitimate where no durable + home exists — e.g. momentary panel gestures over the wire, the modules + roadmap's P9 — that is an event-shaped problem, not this one.) The collapse was + enormous — no wire variant, no ops, no renewal — and the device-side + lifetime (survives client death, dies on unload/reboot) is exactly the + wiring-test semantic we wanted. PR #233 was closed unmerged after its + engine bypass logic was harvested. +- **One "live values" concept covering panel state and Debug**, distinguished + by a retention axis. Rejected (D5): they differ in *what is underneath*, not + in how long they last. Panel state persists because it overrides an + authored default; Debug has no default to override. A single concept with a + retention flag would have made "does this survive a reboot?" a per-slot + configuration question instead of a consequence of what the value is. +- **Persistent per-row tinting for Debug values** (mirroring the bound-violet + convention). Rejected in favour of a separate section: location is a + categorical signal that is present *before* the user touches anything, and + it leaves the dirty chrome doing only its own job. +- **Record-shaped grouping for the Debug section** (a section per declaring + record). Rejected: it breaks the moment one record mixes Setting and Debug + fields, which nothing forbids, and it made the clock's section look + correct only because that record happens to be named `controls`. +- **A new colour family for debug** (magenta/pink — the only genuinely unused + hue). Rejected in favour of attention-orange plus hazard striping: form, + not just hue, following the repo's stepped-knob precedent, and it keeps the + palette from growing a family per state. + +## Follow-ups + +Per the deferred-decision convention, these are indexed in +`docs/adr/README.md`. + +- **(a) `Debug` naming re-check.** Ratified as provisional. **Revisit when** + the clock's transport controls (`rate`, `scrub_offset_seconds`) move to a + transport surface and Debug holds only diagnostics — if the word still fits + then, it is permanent. +- **(b) Debug indication on preview/play surfaces.** D8 covered the workspace + (chip, card, section) only; a running installation showing a test pattern + has no indication outside the editor. **Revisit when** the panels/play-mode + work defines its own chrome — the indication belongs there, not bolted onto + the node card. +- **(c) `test_pattern` colour.** `TEST_PATTERN_RGB` is full white (the + max-current case on long strips), deliberately chosen for pin discovery. + **Revisit when** someone runs it on a long strip and wants it dimmer; it is + a one-constant change. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6f1453074..98231fc0e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -56,7 +56,7 @@ holds the full context. | Host-process `lpa-server` stdout capture into the Studio console (terminal-only today) | `2026-07-05-studio-logging-model` | Host-process workflow needs in-console server logs | | Console filter persistence and text search (session-only, no search today) | `2026-07-05-studio-logging-model` | Console usage patterns make refiltering per session annoying | | Per-item overlay gating (fetch-full-on-change assumes small overlays) | `2026-07-04-studio-editing-model` (a) | Measured overlay fetch cost matters | -| Singular `ProjectRegistry::mutate` bypasses policy/type validation (only `mutate_batch` enforces) | `2026-07-04-studio-editing-model` (d) | Any new caller of `mutate` | +| ~~Singular `ProjectRegistry::mutate` bypasses policy/type validation (only `mutate_batch` enforces)~~ — **closed 2026-08-01**: `mutate` now runs the same `validate_mutation`; the bypass survives only as the crate-private `stage_dedicated_op` the node-authoring ops use to write `Fixed` containers | `2026-07-04-studio-editing-model` (d) | Closed | | Alternative dirty modes (touched-mode / deliberate value pinning) — minimal-diff normalization fixed dirty to "differs from saved" | `2026-07-04-studio-editing-model` (f) | A concrete pinning/touched-mode use case appears | | Device-pane adoption of the pane grammar (`StudioPane`/`DetailPopover`/`UiPaneAction`) | `2026-07-05-studio-pane-grammar` (a) | Next device-pane UX work | | Save visibility while scrolling (project header scrolls with the sidebar; the strip was always visible) | `2026-07-05-studio-pane-grammar` (b) | The M2a UX gate or later use flags losing always-visible Save | @@ -106,7 +106,7 @@ holds the full context. | Playlist strip evolutions: timeline view, cue-trigger UI, autoplay-to-cue controls | `2026-07-26-node-card-faces` | Playlist interaction work resumes | | Fixture mapping editor drawer (face's planned custom drawer; fixture has only advanced today) | `2026-07-26-node-card-faces` | Fixture mapping UX work begins | | Hardware placard-follow walk under a live trigger (the rest of the refinement round — live knob values, friendly titles, entry-thumb warming, knob keyboard a11y, activate-by-click, drawer-state re-home — landed 2026-07-27, PR #158) | `2026-07-26-node-card-faces` | The next hardware walk | -| Sim button press / debug pokes adopt the runtime command channel as new `WireNodeCommand` variants | `2026-07-27-runtime-node-command-channel` | Sim-button UX or runtime debug tooling work begins | +| ~~Sim button press / debug pokes adopt the runtime command channel as new `WireNodeCommand` variants~~ — **obsolete 2026-08-01** (`2026-08-01-debug-slots-taxonomy`): button input is punted to the input initiative (record/replay needs source-level injection), and debug pokes are Debug slots, not commands. The channel stays the right home for genuine events | `2026-07-27-runtime-node-command-channel` | Obsolete | | Cache-friendly prompt shape: the per-turn system prompt embeds the current shader source ahead of the cache prefix, so staged edits invalidate the cache (measured; live sessions read ~0 cached tokens) | `2026-07-25-studio-shader-agent-architecture` (2026-07-27 addendum) | Next agent round — P0; redesign + eval re-measure, not a blind fix | | Pre-4.6 Anthropic thinking shape (`enabled` + `budget_tokens`; current `adaptive` shape 400s on Sonnet/Haiku 4.5) | `2026-07-25-studio-shader-agent-architecture` (2026-07-27 addendum) | An older Anthropic model is configured deliberately | | OpenRouter reasoning opt-in (`reasoning: {}` request field, provider-gated) | `2026-07-25-studio-shader-agent-architecture` (2026-07-27 addendum) | OpenRouter thinking visibility is asked for | @@ -134,6 +134,9 @@ holds the full context. | Float→int through the soft-float ABI (`__fixsfsi`/`__fixunssfsi` are skipped because the ABI leaves out-of-range and NaN undefined) | `2026-07-31-soft-float-via-compiler-builtins` | The C6 harness's probe data shows the ROM matching `compiler_builtins` at those edges | | Xtensa `F32Lowering` arm (`Unsupported` today — the S3 has an FPU, so soft float would be the wrong default) | `2026-07-31-soft-float-via-compiler-builtins` | The Xtensa FPU emulator and emitter land (roadmap M6/M7) | | Soft-float performance measurement on the C6 (nothing here says how slow Float mode is) | `2026-07-31-soft-float-via-compiler-builtins` | A perf surface exists to show it (roadmap D3) | +| `Debug` naming re-check (ratified as provisional — the corpus is all diagnostics, but the clock's `rate`/`scrub_offset_seconds` read as transport) | `2026-08-01-debug-slots-taxonomy` (a) | The clock's transport controls move to a transport surface | +| Debug indication on preview/play surfaces (D8 covered the workspace only; a running installation in test-pattern mode shows nothing outside the editor) | `2026-08-01-debug-slots-taxonomy` (b) | The panels/play-mode work defines its own chrome | +| `TEST_PATTERN_RGB` is full white — the max-current case on long strips, deliberate for pin discovery | `2026-08-01-debug-slots-taxonomy` (c) | Someone runs it on a long strip and wants it dimmer | ## Relationship To Shared Planning diff --git a/docs/debt/README.md b/docs/debt/README.md index 909fa7e69..480fa21cf 100644 --- a/docs/debt/README.md +++ b/docs/debt/README.md @@ -66,5 +66,9 @@ stay in place when retired; the log is the history). | [s3-frame-cost-scales-per-fixture](s3-frame-cost-scales-per-fixture.md) | carried | 2026-07-31 | lpc-engine resolver + lpc-hardware registry | Frame cost is flat ~8.4 ms/fixture: per-frame dataflow re-resolution + per-frame endpoint-status recomputation; the shader JIT is ~1%, sends 11% | | [c6-on-legacy-ws281x-driver](c6-on-legacy-ws281x-driver.md) | retired | 2026-07-31 | lp-fw/fw-esp32c6/src/output | C6 ran its own single-channel WS281x driver, not `lp-ws281x`; retired 2026-08-01 when the C6 moved onto the shared core and gained its second channel | | [example-shaders-not-compile-gated](example-shaders-not-compile-gated.md) | carried | 2026-07-29 | examples GLSL + CI + lps-filetests | an example shader can compile on the host yet fail on 4 of 5 targets; the break surfaces only when a human opens it in Studio | +| [clock-transport-has-no-transport-ui](clock-transport-has-no-transport-ui.md) | carried | 2026-05-12 | clock node + studio faces | scrubbing a show means typing seconds into a generic slider; the misfit also keeps the `Debug` name provisional | +| [project-reload-drops-debug-silently](project-reload-drops-debug-silently.md) | carried | 2026-07-04 | lpa-server project lifecycle | the documented recovery path discards every pending edit and Debug override with no return value, event, or notice | +| [registry-apis-without-production-callers](registry-apis-without-production-callers.md) | carried | 2026-07-04 | lpc-registry project APIs | `discard_overlay` is public, test-only API that duplicates the `MutationOp::Clear` path and silently drifts from it | +| [save-notice-assumes-header-dispatch](save-notice-assumes-header-dispatch.md) | carried | 2026-07-04 | lpa-studio-core save flow | "no persisted edits to write" is phrased for the gated header path but the asset editors dispatch the same op ungated | | [safe-mode-dim-boot-unproven](safe-mode-dim-boot-unproven.md) | retired | 2026-08-01 | fw-esp32c6/bootctl + lpc-engine safe clamp | RETIRED 2026-08-01: dim boot seen on silicon (serial: record found/consumed/clamped 26/255; heartbeat outputClamp; eyes: dim) | | [studio-no-reconnect-after-replug](studio-no-reconnect-after-replug.md) | carried | 2026-07-31 | lpa-link/browser-serial + studio device cards | every bootloader-mode op ends on a replug Studio cannot see; the op card waits forever and the user reloads the tab | diff --git a/docs/debt/clock-transport-has-no-transport-ui.md b/docs/debt/clock-transport-has-no-transport-ui.md new file mode 100644 index 000000000..03d5a5d6a --- /dev/null +++ b/docs/debt/clock-transport-has-no-transport-ui.md @@ -0,0 +1,54 @@ +--- +status: carried +since: 2026-05-12 +logged: 2026-08-01 +area: clock node / studio node faces +related: + - "../adr/2026-08-01-debug-slots-taxonomy.md" + - "../adr/2026-07-26-node-card-faces.md" + - "plan notes: ~/.photomancer/planning/lp2025/2026-07-31-1736-ephemeral-slots/notes.md (S9, D6)" +--- +# The clock's transport controls have no transport UI + +**Shape** — `ClockControls` +(`lp-core/lpc-model/src/nodes/clock/clock_controls.rs`) exposes +`running`, `rate`, and `scrub_offset_seconds` as Debug-role slots, and +the engine consumes all three every frame. The UI for them is the +generic slot renderer: a toggle, a number, and — for the scrub offset — +a plain slider whose unit is "seconds added to the clock". There is no +transport surface: no timeline, no scrub bar, no jog, no +return-to-zero, no readout of where the show currently is. Scrubbing a +show by typing an offset into a numeric field is the tell. + +It is structural rather than a missing widget because of *where the +controls live*. Debug is defined as diagnostics/authoring overrides with +no durable value underneath, and `running`/`rate`/`scrub_offset_seconds` +only half fit: driving time by hand to inspect a show is diagnostics, +but transport is a first-class performance concept that wants its own +home (project-level, not buried in one node's card). The taxonomy ADR +records this as the known tension in the `Debug` name, and expects it to +resolve by those controls moving to a transport surface — at which point +Debug holds exactly the diagnostics it describes. Building a scrub UI +inside the clock's Debug section now would cement the wrong home. + +**Carrying cost** — Inspecting a show at a chosen time is clumsy enough +that it mostly is not done; the scrub slot reads as unfinished; and the +`Debug` category carries an example that undercuts its own definition, +which costs explanation every time the taxonomy is taught. + +**Workarounds** — Set `controls.scrub_offset_seconds` in the clock +card's Debug section and read the resulting time from the clock's +produced state; Clear (per value or per node) returns to live time. + +**Incident log** +- 2026-08-01 — catalogued as S9 in the Debug-slots discovery sweep and + cited in D6 as the reason `Debug` is ratified as provisional. The + Debug section (P3) at least makes the controls findable and marks the + system as debug-driven while an offset is held; the transport gap is + untouched. + +**Exit criteria** — A transport surface exists (scrub/rate/run at the +project level, with a position readout), the clock's three controls move +onto it, and the `Debug` naming re-check in +`../adr/2026-08-01-debug-slots-taxonomy.md` (follow-up (a)) can be +answered against a Debug category that holds only diagnostics. diff --git a/docs/debt/project-reload-drops-debug-silently.md b/docs/debt/project-reload-drops-debug-silently.md new file mode 100644 index 000000000..f8a94d983 --- /dev/null +++ b/docs/debt/project-reload-drops-debug-silently.md @@ -0,0 +1,58 @@ +--- +status: carried +since: 2026-07-04 +logged: 2026-08-01 +area: lpa-server project lifecycle +related: + - "../adr/2026-08-01-debug-slots-taxonomy.md" + - "../adr/2026-07-04-studio-editing-model.md" + - "registry-apis-without-production-callers.md" + - "plan notes: ~/.photomancer/planning/lp2025/2026-07-31-1736-ephemeral-slots/notes.md (S8)" +--- +# `Project::reload()` discards the whole overlay with no signal + +**Shape** — `Project::reload()` +(`lp-app/lpa-server/src/project.rs:397`) is documented as a recovery +path: "discard live runtime state and rebuild from the committed +filesystem". It drops the runtime and rebuilds registry and engine from +`ProjectLoader::load_from_root`, which means the device-side overlay +goes with it — every pending edit, and every **Debug** override +(`clock.controls.*`, `output.test_pattern`). Persisted edits are +recoverable in principle (the client mirror can re-stage them); Debug +overrides are not authored anywhere, so they are simply gone. + +Dying on reload is the *correct* lifetime for a Debug value — the +taxonomy ADR makes it a deliberate property ("a rebooted installation +must not come up in test-pattern mode"), and overlay persistence across +restarts was explicitly rejected ("a device-crashing edit must not +crash-loop"). The debt is that the drop is **silent and unaccounted**: +no return value, no event, no notice mentions that pending state +vanished, and the client would discover it only through the next +overlay read. Today that costs nothing, because `reload()` has no +production caller (it is the documented future recovery path). It +becomes a real defect the moment recovery is wired up — the flow that +calls it is exactly the flow where a user has a reason to be confused +about what they just lost. + +**Carrying cost** — Zero today, deferred and concentrated: the eventual +recovery flow has to (re)discover the semantics and design the +messaging, and the current signature gives it nothing to report. +Catalogued as S8 during the Debug-slots discovery sweep. + +**Workarounds** — Do not call `reload()` from a user-facing flow without +first deciding what the user is told. The information needed for a good +notice is available before the drop: `registry.overlay()` can be +counted (and classified — `SlotRoleResolution::persistence`) at the top +of the function. + +**Incident log** +- 2026-08-01 — re-verified during the Debug-slots plan (P6): still no + production caller, and the D7 change makes the loss *more* invisible + than before (Debug edits no longer appear in the Save panel, so + nothing on screen hints they existed). Logged, not fixed. + +**Exit criteria** — The first production caller lands together with a +decision about the drop: either `reload()` reports what it discarded +(counts, split persisted vs Debug) so the flow can say so, or the +recovery flow re-stages the client mirror's persisted edits and tells +the user Debug overrides were cleared. diff --git a/docs/debt/registry-apis-without-production-callers.md b/docs/debt/registry-apis-without-production-callers.md new file mode 100644 index 000000000..2ebad0984 --- /dev/null +++ b/docs/debt/registry-apis-without-production-callers.md @@ -0,0 +1,56 @@ +--- +status: carried +since: 2026-07-04 +logged: 2026-08-01 +area: lpc-registry / lpa-server project APIs +related: + - "../adr/2026-07-04-studio-editing-model.md" + - "../adr/2026-08-01-debug-slots-taxonomy.md" + - "plan notes: ~/.photomancer/planning/lp2025/2026-07-31-1736-ephemeral-slots/notes.md (S7)" +--- +# `ProjectRegistry::discard_overlay` is public API with no production caller + +**Shape** — `ProjectRegistry::discard_overlay` +(`lp-core/lpc-registry/src/registry/project_registry.rs:444`) clears the +whole overlay and re-derives the inventory. Nothing in production calls +it: the studio's "Revert to saved" goes through `MutationOp::Clear` on +the ordinary mutation path, and the only caller is +`lp-core/lpc-registry/tests/apply.rs:188`. So the method is exercised +exclusively by the test that exists to exercise it — a shape that reads +as coverage but proves nothing about a path anyone takes. + +This is a condition rather than a one-off deletion because the registry +has more than one of these: a public surface accretes recovery-shaped +entry points ("discard everything", "reload from disk") that seem +obviously useful, are cheap to keep compiling, and quietly diverge from +the paths that actually run. Each one is a second implementation of a +behaviour the real path already has, and the divergence surfaces only +when someone finally wires it up. `Project::reload()` is the sibling +case (see `project-reload-drops-debug-silently.md`). + +The Debug-slots work is a concrete example of the divergence risk: the +mutation path grew role-aware retention and validation, and a bypassing +"clear it all" entry point is exactly the kind of thing that would not +have been updated with it. + +**Carrying cost** — Low but real: dead API widens the crate's contract, +its test consumes gate time while asserting nothing about production, +and every refactor of overlay semantics has to reason about a caller +that does not exist. It cost one triage pass during the Debug-slots plan +(catalogued as S7). + +**Workarounds** — When changing overlay semantics, treat `discard_overlay` +as a mirror of the `MutationOp::Clear` path and update both, or the next +person to wire it up inherits a stale behaviour. + +**Incident log** +- 2026-08-01 — re-confirmed during the Debug-slots plan (P6 paydown + sweep): still no production caller. Logged rather than deleted; the + paydown list for that plan was closed at three fixes, and deleting a + public API deserves its own look at whether the recovery story wants + it. + +**Exit criteria** — Either `discard_overlay` gains a real caller (a +recovery flow that needs a non-mutation clear) with a test that goes +through that caller, or it is deleted along with its test and +"revert everything" stays the single `MutationOp::Clear` path. diff --git a/docs/debt/save-notice-assumes-header-dispatch.md b/docs/debt/save-notice-assumes-header-dispatch.md new file mode 100644 index 000000000..076607ace --- /dev/null +++ b/docs/debt/save-notice-assumes-header-dispatch.md @@ -0,0 +1,55 @@ +--- +status: carried +since: 2026-07-04 +logged: 2026-08-01 +area: lpa-studio-core/project save flow +related: + - "../adr/2026-08-01-debug-slots-taxonomy.md" + - "plan notes: ~/.photomancer/planning/lp2025/2026-07-31-1736-ephemeral-slots/notes.md (S6)" +--- +# Save's zero-write notice assumes the project header dispatched it + +**Shape** — `ProjectController::save_overlay` +(`lp-app/lpa-studio-core/src/app/project/project_controller.rs:2665`) +reports `"Save found no persisted edits to write"` when a commit writes +zero files. The wording is written for a caller that could only have +reached Save with something to save: the project header offers Save +**only** while `dirty.persisted > 0` (`project_header_actions`), so from +there a zero-write commit really is a surprise worth naming. + +`ProjectOp::SaveOverlay` is not header-only, though. The asset editors — +`lpa-studio-web/src/app/node/asset_editor.rs:791` and +`mapping_asset_editor.rs:267` — mount their own Save button on the same +project-level op, ungated by the project's dirty state. Press Save there +with an applied-but-already-committed body and the notice announces an +anomaly that is simply "nothing to do". The condition is structural +rather than a one-line copy fix: the notice is phrased from the +*caller's* expectation, but the op is project-level and has several +callers with different expectations, and only the dispatcher knows which +reading is right. + +D7 (Debug leaves save accounting) narrowed this but did not close it: a +Debug-only overlay no longer counts as pending work anywhere, so the +header correctly offers no Save at all — the asset-editor path is what +keeps the notice reachable. + +**Carrying cost** — Small and recurring: a confusing notice on a benign +action, and a trap for anyone who "fixes" the wording without noticing +the header path, where the current phrasing is the correct one. It cost +one investigation during the Debug-slots plan (catalogued as S6) and +survived the P2 sweep for exactly this reason. + +**Workarounds** — None needed operationally; the save itself is correct. +When touching this code, remember the two dispatch paths: the header +(gated, "no persisted edits" is meaningful) and the asset editors +(ungated, it is not). + +**Incident log** +- 2026-08-01 — re-verified during the Debug-slots plan (P2/P6). Still + reachable via the asset-editor dispatch; logged rather than fixed + because the honest fix is per-caller notice context, not new copy. + +**Exit criteria** — Either the notice's text is chosen by the dispatch +context (the op carries, or the controller knows, why Save was pressed), +or the asset editors' Save becomes gated the way the header is so the +zero-write case is unreachable and the wording stays true. diff --git a/docs/glossary.md b/docs/glossary.md index 2d92914c3..0eb317d00 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -96,6 +96,17 @@ of implementation — code can lag these names during the transition. persists. (module model) - **Play mode** — rendering only the root module's panel: the end-user view. (module model) +- **Debug** — a slot that is *transient by nature*: a diagnostic or authoring + override with **no durable value underneath** (clock `rate`, output + `test_pattern`). Session-only — it dies on project unload or reboot, so a + restarted installation never comes up in a debug state. Not a Panel: a + panel control *exposes a bound slot via its channel* (authored value = + default), which is why latching panel state persists and Debug does not + (momentary panel gestures don't persist either, but their fallback is bus + resolution, not a shape default). Never dirty, never saved; + its verb is **Clear**. Declared `#[slot(role = "debug")]`, rendered in the + node card's own hazard-striped Debug section + ([ADR](adr/2026-08-01-debug-slots-taxonomy.md)). - **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 diff --git a/examples/basic/clock.json b/examples/basic/clock.json index 79834c140..253ac2aae 100644 --- a/examples/basic/clock.json +++ b/examples/basic/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/basic2/clock.json b/examples/basic2/clock.json index 79834c140..253ac2aae 100644 --- a/examples/basic2/clock.json +++ b/examples/basic2/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/button-playlist/clock.json b/examples/button-playlist/clock.json index 79834c140..253ac2aae 100644 --- a/examples/button-playlist/clock.json +++ b/examples/button-playlist/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/button-sign/clock.json b/examples/button-sign/clock.json index 79834c140..253ac2aae 100644 --- a/examples/button-sign/clock.json +++ b/examples/button-sign/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/events/clock.json b/examples/events/clock.json index 79834c140..253ac2aae 100644 --- a/examples/events/clock.json +++ b/examples/events/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/fast/clock.json b/examples/fast/clock.json index 79834c140..253ac2aae 100644 --- a/examples/fast/clock.json +++ b/examples/fast/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/fiber-headband/clock.json b/examples/fiber-headband/clock.json index 79834c140..253ac2aae 100644 --- a/examples/fiber-headband/clock.json +++ b/examples/fiber-headband/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/fluid/clock.json b/examples/fluid/clock.json index 79834c140..253ac2aae 100644 --- a/examples/fluid/clock.json +++ b/examples/fluid/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/fyeah-button/clock.json b/examples/fyeah-button/clock.json index 79834c140..253ac2aae 100644 --- a/examples/fyeah-button/clock.json +++ b/examples/fyeah-button/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/fyeah-sign/clock.json b/examples/fyeah-sign/clock.json index 79834c140..253ac2aae 100644 --- a/examples/fyeah-sign/clock.json +++ b/examples/fyeah-sign/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/perf/baseline/clock.json b/examples/perf/baseline/clock.json index 79834c140..253ac2aae 100644 --- a/examples/perf/baseline/clock.json +++ b/examples/perf/baseline/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/perf/fastmath/clock.json b/examples/perf/fastmath/clock.json index 79834c140..253ac2aae 100644 --- a/examples/perf/fastmath/clock.json +++ b/examples/perf/fastmath/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/examples/rocaille/clock.json b/examples/rocaille/clock.json index 79834c140..253ac2aae 100644 --- a/examples/rocaille/clock.json +++ b/examples/rocaille/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/lp-app/lpa-server/tests/project_fs_refresh.rs b/lp-app/lpa-server/tests/project_fs_refresh.rs index 403008243..bc659afa5 100644 --- a/lp-app/lpa-server/tests/project_fs_refresh.rs +++ b/lp-app/lpa-server/tests/project_fs_refresh.rs @@ -21,18 +21,32 @@ fn server_tick_refreshes_referenced_artifact_without_recreating_runtime_node() { let handle = server.load_project(project_path.as_path()).expect("load"); let before_id = clock_runtime_node_id(project(&server, handle)); + // `controls.rate` is Debug-role (D2): an external file edit to it is + // ignored by the loader (warn-and-skip), so it cannot prove a body + // actually re-parsed. A Setting-role field (a binding) is the probe + // here instead. server .base_fs_mut() .write_file( project_file("fs-refresh", "clock.json").as_path(), - clock_json_with_rate(2.0).as_bytes(), + br#" +{ + "kind": "Clock", + "bindings": { + "seconds": { "target": "bus:custom" } + } +} +"#, ) .expect("write clock"); server.advance_frame(16).expect("tick"); let project = project(&server, handle); - assert_eq!(clock_rate(project), 2.0); + assert!( + clock_has_seconds_binding(project), + "refreshed body should carry the new binding" + ); assert_eq!(clock_runtime_node_id(project), before_id); } @@ -155,6 +169,21 @@ fn clock_rate(project: &Project) -> f32 { *def.controls.rate.value() } +fn clock_has_seconds_binding(project: &Project) -> bool { + let entry = project + .registry() + .def(&NodeDefLocation::artifact_root(ArtifactLocation::file( + "/clock.json", + ))) + .expect("clock definition"); + let NodeDef::Clock(def) = entry.state.loaded_def().expect("loaded clock") else { + panic!("expected clock definition"); + }; + def.bindings + .entries() + .contains_key(&alloc::string::String::from("seconds")) +} + fn clock_use() -> NodeUseLocation { NodeUseLocation::root().child(SlotPath::parse("nodes[clock]").expect("clock use path")) } diff --git a/lp-app/lpa-studio-core/README.md b/lp-app/lpa-studio-core/README.md index bbe6f69f0..b9c172086 100644 --- a/lp-app/lpa-studio-core/README.md +++ b/lp-app/lpa-studio-core/README.md @@ -259,9 +259,16 @@ stateless views that dispatch ops and render DTOs. The model (recorded in its path — no client-local dirty tracking, so dirty state is cross-client-correct and survives reconnects. The DTO join (`slot/slot_edit_join.rs`): buffer entries map to `Saving`/`Error`, - overlay-mirror entries to `Dirty`; `UiSlotFieldState.live` distinguishes - transient ("live") from persisted ("unsaved") edits. The same join feeds - `DirtySummary { persisted, transient, failed }` (`project/dirty_summary.rs`), + overlay-mirror entries to `Dirty`; `UiSlotFieldState.debug` marks a + **Debug** override (transient by nature, D7) as against a persisted + ("unsaved") edit. Debug overrides carry no dirty weight at all — they + count in no bucket, list in no save-panel section, and never gate an + unload; their verb is **Clear** + (`SlotEditOp::Clear` / `NodeClearDebugOp` / `ProjectOp::ClearDebugEdits`), + and the join counts them on their own channel + (`debug_overrides_for_node` / `debug_override_count`) for the node-card + marking and the global "Debug active · N · Clear all" chip. The same join + feeds `DirtySummary { persisted, failed }` (`project/dirty_summary.rs`), aggregated slot → node → project during the DTO build: node headers, child entries, sidebar tree items, and `ProjectEditorView.dirty` all carry it, and the project header's contextual Save/Revert actions surface as @@ -270,8 +277,10 @@ stateless views that dispatch ops and render DTOs. The model (recorded in (`NodeRevertOp`) on `UiNodeView.header_actions` / `UiNodeChild.header_actions`. Each hierarchy DTO also projects status + dirty into its one chrome `UiAffordance` (`project/ui_affordance.rs`, priority merge - Error > Unsaved > Live > Busy > Info) — the glyph/tone every detail - trigger and tree-row indicator renders. + Error > Unsaved > Debug > Busy > Info) — the glyph/tone every detail + trigger and tree-row indicator renders. `Debug` is the **semantic** + variant only: the attention-orange + hazard-stripe rendering lives in + `lpa-studio-web` (one mapping seam), per the taxonomy ADR. `UiConfigSlot` carries its `ProjectSlotAddress` so fields can dispatch edits without extra lookup, plus `edit_entry_address` — the row's **own** edit entry when one exists (the row-level Revert/Reset target; a @@ -280,8 +289,9 @@ stateless views that dispatch ops and render DTOs. The model (recorded in the variant-switch gesture's storage path). The project popup's save panel renders `ProjectEditorView.pending_edits`: one `UiPendingEdit` per edit entry (node label, slot path, op/value display string, phase - persisted/live/failed, per-entry revert action), built from the same join - enumeration the counts sum — list and counts cannot drift. Each + `Persisted`/`Failed`, per-entry revert action), built from the same join + enumeration the counts sum — list and counts cannot drift. There is no + live/transient section: Debug overrides are not edits (D7). Each `UiPendingEdit` also carries `old_value` (the saved base as a display string) so popups and the panel render `old → new`; base values are server-derived and mirrored beside the overlay (`ProjectSync:: diff --git a/lp-app/lpa-studio-core/src/app/agent/agent_controller.rs b/lp-app/lpa-studio-core/src/app/agent/agent_controller.rs index 5d21c8924..28867bad3 100644 --- a/lp-app/lpa-studio-core/src/app/agent/agent_controller.rs +++ b/lp-app/lpa-studio-core/src/app/agent/agent_controller.rs @@ -495,7 +495,9 @@ impl AgentController { ) { for section in sections { match section { - UiNodeSection::AssetSlots(slots) | UiNodeSection::ConfigSlots(slots) => { + UiNodeSection::AssetSlots(slots) + | UiNodeSection::ConfigSlots(slots) + | UiNodeSection::DebugSlots(slots) => { self.decorate_slots(slots, runtime, ctx); } _ => {} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs index 791a25516..596c7cf51 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs @@ -41,6 +41,9 @@ pub struct UiNodeChild { /// Aggregate dirty-edit summary for this child's subtree (own slots plus /// nested children), matching the per-field affordances. pub dirty: DirtySummary, + /// Active Debug overrides in this child's subtree — the nested card's + /// marking (D8 tier b), separate from [`Self::dirty`] (D7). + pub debug_overrides: usize, /// Contextual header actions for the nested pane this child becomes: /// controller-produced, currently the node-subtree batch revert while /// [`Self::dirty`] announces pending edits. @@ -68,6 +71,7 @@ impl UiNodeChild { sections: Vec::new(), children: Vec::new(), dirty: DirtySummary::clean(), + debug_overrides: 0, header_actions: Vec::new(), } } @@ -98,4 +102,10 @@ impl UiNodeChild { pub fn affordance(&self) -> UiAffordance { UiAffordance::merged(self.status.kind, &self.dirty) } + + /// The child's DEBUG channel, mirroring + /// [`crate::UiNodeHeader::debug_affordance`]. + pub fn debug_affordance(&self) -> UiAffordance { + UiAffordance::from_debug_overrides(self.debug_overrides) + } } diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs index 7ce34cc22..fa427497d 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs @@ -22,6 +22,11 @@ pub struct UiNodeHeader { /// Aggregate dirty-edit summary for this node's subtree (own slots plus /// descendant nodes), matching the per-field affordances. pub dirty: DirtySummary, + /// Active **Debug** overrides in this node's subtree (D8 tier b: the + /// node-card marking). Deliberately NOT part of [`Self::dirty`] — a debug + /// override is not pending work (D7) — and deliberately not merged into + /// [`Self::affordance`], so it can never mask an unsaved or failed edit. + pub debug_overrides: usize, /// The node exists in the project but has NO RUNTIME on the device /// running it (its kind is not in that firmware build). The pane /// replaces its whole body with the hazard-striped error treatment: @@ -44,6 +49,7 @@ impl UiNodeHeader { summary: None, detail: None, dirty: DirtySummary::clean(), + debug_overrides: 0, unsupported: false, } } @@ -60,6 +66,12 @@ impl UiNodeHeader { self } + /// Set the count of active Debug overrides in the node's subtree. + pub fn with_debug_overrides(mut self, debug_overrides: usize) -> Self { + self.debug_overrides = debug_overrides; + self + } + /// Mark the node as having no runtime here (see [`Self::unsupported`]). pub fn with_unsupported(mut self, unsupported: bool) -> Self { self.unsupported = unsupported; @@ -89,4 +101,11 @@ impl UiNodeHeader { pub fn affordance(&self) -> UiAffordance { UiAffordance::merged(self.status.kind, &self.dirty) } + + /// The node's DEBUG channel (D8 tier b), separate from + /// [`Self::affordance`]: [`UiAffordance::Debug`] while the subtree carries + /// an active override, else the silent [`UiAffordance::Info`]. + pub fn debug_affordance(&self) -> UiAffordance { + UiAffordance::from_debug_overrides(self.debug_overrides) + } } diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs index 2b33416b1..df3c6347c 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs @@ -9,8 +9,19 @@ pub enum UiNodeSection { ProducedProducts(Vec), /// Non-product outputs, such as time or progress values. ProducedValues(Vec), - /// Normal configurable input slots. + /// Normal configurable input slots — the **Settings** section (D6): the + /// authored config that Save writes back. ConfigSlots(Vec), + /// **Debug** slots (D3/D4): every `SlotRole::Debug` field of the node, + /// **flattened** — the section comes from the ROLE, never from a record + /// being named `controls`, so a clock's three `controls.*` fields render + /// directly here with no nesting. Transient by nature: never dirty, never + /// saved, cleared rather than reverted (D7). + /// + /// The section is rendered as debug territory *even when empty of + /// overrides* — knowing a control is transient BEFORE touching it is the + /// whole point (D8 tier c). + DebugSlots(Vec), /// Asset slots promoted to editor-level treatment. AssetSlots(Vec), /// Children shown inline for small compositions or story isolation. @@ -24,6 +35,7 @@ impl UiNodeSection { Self::ProducedProducts(items) => items.is_empty(), Self::ProducedValues(items) => items.is_empty(), Self::ConfigSlots(items) => items.is_empty(), + Self::DebugSlots(items) => items.is_empty(), Self::AssetSlots(items) => items.is_empty(), Self::Children(items) => items.is_empty(), } diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs index 238b446ff..18ec44be7 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs @@ -11,10 +11,13 @@ pub struct UiSlotFieldState { pub dirty: UiNodeDirtyState, /// Validation error shown near the field when present. pub invalid: Option, - /// True when the slot's policy persistence is transient: edits apply - /// live to the running project and are **not** written back by save. - /// M2 styles transient (`live`) dirty differently from persisted dirty. - pub live: bool, + /// True when the slot is a **Debug** control (D6): transient by nature — + /// diagnostics/authoring overrides with no durable value underneath. + /// Edits apply live to the running project and are **not** written back + /// by save; such a slot is not "dirty" in the `DirtySummary` sense at all + /// (D7) and its verb is Clear. The web layer maps this onto the debug + /// (hazard) row treatment. + pub debug: bool, } impl UiSlotFieldState { @@ -24,7 +27,7 @@ impl UiSlotFieldState { editable: true, dirty: UiNodeDirtyState::Clean, invalid: None, - live: false, + debug: false, } } @@ -34,7 +37,7 @@ impl UiSlotFieldState { editable: false, dirty: UiNodeDirtyState::Clean, invalid: None, - live: false, + debug: false, } } @@ -50,9 +53,9 @@ impl UiSlotFieldState { self } - /// Mark whether the field is a live (transient-persistence) control. - pub fn with_live(mut self, live: bool) -> Self { - self.live = live; + /// Mark whether the field is a Debug (transient-by-nature) control. + pub fn with_debug(mut self, debug: bool) -> Self { + self.debug = debug; self } diff --git a/lp-app/lpa-studio-core/src/app/project/dirty_summary.rs b/lp-app/lpa-studio-core/src/app/project/dirty_summary.rs index fb2ba230c..8d58671b5 100644 --- a/lp-app/lpa-studio-core/src/app/project/dirty_summary.rs +++ b/lp-app/lpa-studio-core/src/app/project/dirty_summary.rs @@ -15,22 +15,24 @@ use crate::{PendingAssetEdit, PendingEdit, PendingEditPhase}; /// counting rule — so an edit at a path with no surviving row (a removed map /// entry) still counts exactly once, and the prefix-dirty display state on /// ancestor composites never double-counts. Each entry, classified by -/// [`DirtySummary::for_slot`], lands in exactly one bucket: +/// [`DirtySummary::for_slot`], lands in at most one bucket: /// /// - a buffered `Failed` edit → [`failed`](Self::failed) (the overlay may not /// hold the edit, but the slot still needs attention); -/// - any other buffered edit, or an overlay-mirror edit → persistence bucket -/// ([`persisted`](Self::persisted) / [`transient`](Self::transient), from -/// the shape-resolved persistence governing the entry's path). +/// - any other buffered edit, or an overlay-mirror edit, at a **persisted** +/// path → [`persisted`](Self::persisted); +/// - anything else — a Debug (live-only) override, or any produced field — +/// counts in **no** bucket (D7). Debug values are transient by nature: no +/// durable value sits underneath them, so they are never "dirty", never +/// gate an unload, and never appear in the Save panel. Their verb is +/// **Clear**, not Revert (`SlotEditOp::Clear`, `NodeClearDebugOp`, +/// `ProjectOp::ClearDebugEdits`). /// /// Summaries merge upward: node (own edits + child nodes) → project. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct DirtySummary { /// Dirty slots whose edits are written back to def artifacts on save. pub persisted: usize, - /// Dirty slots whose edits are live-only (transient persistence) and - /// survive save as pending overlay entries. - pub transient: usize, /// Slots whose buffered edit failed (rejected or transport error); they /// need attention even though the overlay may not hold them. pub failed: usize, @@ -44,7 +46,7 @@ impl DirtySummary { /// Total slots needing a dirty affordance, regardless of bucket. pub fn total(&self) -> usize { - self.persisted + self.transient + self.failed + self.persisted + self.failed } /// True when nothing is dirty or failed. @@ -56,7 +58,6 @@ impl DirtySummary { pub fn merge(self, other: Self) -> Self { Self { persisted: self.persisted + other.persisted, - transient: self.transient + other.transient, failed: self.failed + other.failed, } } @@ -82,8 +83,7 @@ impl DirtySummary { /// Classify one asset body edit entry's join state (same order as /// [`Self::for_slot`]: buffered edit first, then the overlay mirror, else /// clean). Asset body edits are always **persisted**-class — they are - /// written to their artifact files on save — so there is no transient - /// bucket for them. + /// written to their artifact files on save. pub(in crate::app::project) fn for_asset( pending: Option<&PendingAssetEdit>, overlay_dirty: bool, @@ -99,17 +99,16 @@ impl DirtySummary { } } - /// One dirty slot in the bucket named by its persistence policy. + /// One edit entry classified by the persistence governing its path: a + /// persisted path is one dirty slot; a transient one (a Debug override or + /// a produced field) is not dirty at all and counts nothing (D7). fn for_persistence(persistence: SlotPersistence) -> Self { match persistence { SlotPersistence::Persisted => Self { persisted: 1, ..Self::default() }, - SlotPersistence::Transient => Self { - transient: 1, - ..Self::default() - }, + SlotPersistence::Transient => Self::default(), } } } @@ -141,24 +140,21 @@ mod tests { use super::*; #[test] - fn buffered_edit_counts_by_persistence() { + fn buffered_persisted_edit_counts_and_debug_counts_nothing() { let edit = PendingEdit::pending(LpValue::F32(1.0)); assert_eq!( DirtySummary::for_slot(Some(&edit), false, SlotPersistence::Persisted), DirtySummary { persisted: 1, - transient: 0, failed: 0, } ); - assert_eq!( - DirtySummary::for_slot(Some(&edit), false, SlotPersistence::Transient), - DirtySummary { - persisted: 0, - transient: 1, - failed: 0, - } + // D7: a Debug (live-only) override is never dirty — no bucket holds + // it, so it cannot gate an unload or tint a header. + assert!( + DirtySummary::for_slot(Some(&edit), false, SlotPersistence::Transient).is_clean(), + "a Debug edit contributes to no bucket" ); } @@ -174,24 +170,29 @@ mod tests { }; for overlay_dirty in [false, true] { - assert_eq!( - DirtySummary::for_slot(Some(&edit), overlay_dirty, SlotPersistence::Persisted), - DirtySummary { - persisted: 0, - transient: 0, - failed: 1, - } - ); + for persistence in [SlotPersistence::Persisted, SlotPersistence::Transient] { + assert_eq!( + DirtySummary::for_slot(Some(&edit), overlay_dirty, persistence), + DirtySummary { + persisted: 0, + failed: 1, + }, + "a rejected write needs attention whatever it addresses" + ); + } } } #[test] fn overlay_edit_counts_by_persistence_and_clean_counts_nothing() { + assert!( + DirtySummary::for_slot(None, true, SlotPersistence::Transient).is_clean(), + "an acked Debug override is not dirty either" + ); assert_eq!( - DirtySummary::for_slot(None, true, SlotPersistence::Transient), + DirtySummary::for_slot(None, true, SlotPersistence::Persisted), DirtySummary { - persisted: 0, - transient: 1, + persisted: 1, failed: 0, } ); @@ -204,12 +205,10 @@ mod tests { let failed = PendingAssetEdit::failed(b"body".to_vec(), "too large"); let one_persisted = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; let one_failed = DirtySummary { persisted: 0, - transient: 0, failed: 1, }; @@ -226,38 +225,28 @@ mod tests { fn merge_add_and_sum_combine_bucket_wise() { let persisted = DirtySummary { persisted: 1, - transient: 0, - failed: 0, - }; - let transient = DirtySummary { - persisted: 0, - transient: 2, failed: 0, }; let failed = DirtySummary { persisted: 0, - transient: 0, failed: 1, }; let expected = DirtySummary { persisted: 1, - transient: 2, failed: 1, }; - assert_eq!(persisted.merge(transient).merge(failed), expected); - assert_eq!(persisted + transient + failed, expected); + assert_eq!(persisted.merge(failed), expected); + assert_eq!(persisted + failed, expected); assert_eq!( - [persisted, transient, failed] - .into_iter() - .sum::(), + [persisted, failed].into_iter().sum::(), expected ); let mut accumulated = DirtySummary::clean(); accumulated += expected; assert_eq!(accumulated, expected); - assert_eq!(expected.total(), 4); + assert_eq!(expected.total(), 2); assert!(!expected.is_clean()); } } diff --git a/lp-app/lpa-studio-core/src/app/project/mod.rs b/lp-app/lpa-studio-core/src/app/project/mod.rs index e14cf6907..f60f29727 100644 --- a/lp-app/lpa-studio-core/src/app/project/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -57,8 +57,8 @@ pub use asset::{ pub use dirty_summary::DirtySummary; pub use loaded_project_choice::LoadedProjectChoice; pub use node::{ - NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, - NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget, + NodeClearDebugOp, NodeController, NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, + NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, ProjectNodeAddress, ProjectNodeTarget, ProjectProductSubscriptionIntent, UiAddNodeMenu, UiAddNodeMenuEntry, UiAttachTarget, UiNodeRemovePreflight, }; diff --git a/lp-app/lpa-studio-core/src/app/project/node/mod.rs b/lp-app/lpa-studio-core/src/app/project/node/mod.rs index cc8307368..43c801b71 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/mod.rs @@ -5,6 +5,7 @@ //! [`ProjectNodeTarget`] adds the current runtime `NodeId` for actions that //! need to talk back to the server. +pub mod node_clear_debug_op; pub mod node_controller; pub mod node_create_op; pub(in crate::app::project) mod node_face_builder; @@ -18,6 +19,7 @@ pub mod project_node_address; pub mod project_node_target; pub mod ui_add_node_menu; +pub use node_clear_debug_op::NodeClearDebugOp; pub(in crate::app::project) use node_controller::root_slot_key; pub use node_controller::{NodeController, NodeControllerState, ProjectProductSubscriptionIntent}; pub use node_create_op::{NodeCreateOp, UiAttachTarget}; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs b/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs new file mode 100644 index 000000000..ef3efaa69 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs @@ -0,0 +1,84 @@ +//! Node-level Clear operation for Debug overrides. + +use core::any::Any; + +use crate::{ + ActionClass, ActionMeta, ActionPriority, ControllerOp, PROJECT_EDITOR_ACTION_DEADLINE, + ProjectNodeAddress, +}; + +/// **Clear** every Debug override under one node's subtree (D7, the per-node +/// scope of the Clear verb): the node's own Debug edit entries plus its +/// descendant nodes'. Persisted edits are untouched — they are the Save +/// panel's business, and their verb stays Revert. +/// +/// Dispatched to `ProjectController::NODE_ID` like [`crate::NodeRevertOp`]; +/// the controller enumerates the Debug entries through the edit join and +/// expands the op into per-entry `RemoveSlotEdit` wire mutations sent as +/// **one** batch. A Debug slot has no durable authored value underneath, so +/// removing its overlay entry returns it to the shape default. Like +/// `NodeRevertOp` it never coalesces in the studio actor queue and acts as a +/// coalescing barrier. +#[derive(Clone, Debug, PartialEq)] +pub struct NodeClearDebugOp { + /// Address of the node whose subtree Debug overrides are cleared. + pub node: ProjectNodeAddress, +} + +impl ControllerOp for NodeClearDebugOp { + fn default_action_meta(&self) -> ActionMeta { + ActionMeta::new( + "Clear debug", + "Clear every debug override under this node.", + ActionPriority::Secondary, + ) + } + + fn action_class(&self) -> ActionClass { + // Same editor foreground class as the slot-level edit ops. + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_clear_debug_is_editor_foreground_class_with_clear_meta() { + let op = NodeClearDebugOp { + node: ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(), + }; + + assert_eq!( + op.action_class(), + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + ); + // D7 vocabulary: Clear, never Revert/Reset. + let meta = op.default_action_meta(); + assert_eq!(meta.label, "Clear debug"); + assert!(!meta.summary.contains("Revert")); + assert!(!meta.summary.contains("Reset")); + assert_eq!(meta.priority, ActionPriority::Secondary); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs index bc679ed94..b6875b67a 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs @@ -229,6 +229,13 @@ impl NodeController { .iter() .map(|child| child.dirty) .sum::(); + // Debug overrides aggregate over the same full child list on their + // own channel (D8 tier b) — never merged into `dirty` (D7). + let debug_overrides = self.own_slots_debug_overrides(edits) + + children + .iter() + .map(|child| child.debug_overrides) + .sum::(); let mut header = UiNodeHeader::new( self.label.clone(), self.kind.clone(), @@ -236,6 +243,7 @@ impl NodeController { ) .with_status(self.ui_status()) .with_dirty(dirty) + .with_debug_overrides(debug_overrides) .with_unsupported(self.status.tone.is_unsupported()); // Status detail (error/warning/failure text) rides the header so the // node detail popup can answer "why" — the compact status alone read @@ -517,6 +525,7 @@ impl NodeController { let mut produced_values = Vec::new(); let mut config_slots = Vec::new(); let mut asset_slots = Vec::new(); + let mut debug_slots = Vec::new(); for slot in &self.slots { match slot.address().root { @@ -524,7 +533,12 @@ impl NodeController { slot.collect_produced(&mut products, &mut produced_values); } ProjectSlotRoot::Def | ProjectSlotRoot::Other(_) => { - slot.collect_config(edits, &mut config_slots, &mut asset_slots); + slot.collect_config( + edits, + &mut config_slots, + &mut asset_slots, + &mut debug_slots, + ); } } } @@ -567,6 +581,14 @@ impl NodeController { if !config_slots.is_empty() { sections.push(UiNodeSection::ConfigSlots(config_slots)); } + // The Debug section always comes last and is rendered as debug + // territory even when no override is active (D8 tier c) — the + // treatment says "transient" BEFORE the control is touched, which is + // what fixes clean-transient invisibility. It is still omitted when + // the node declares no Debug field at all. + if !debug_slots.is_empty() { + sections.push(UiNodeSection::DebugSlots(debug_slots)); + } sections } @@ -580,15 +602,26 @@ impl NodeController { ) -> Vec { let mut config_slots = Vec::new(); let mut asset_slots = Vec::new(); + // The flat root renders no node card, so it has no Debug section to + // route Debug rows into; they stay in the one flat list rather than + // vanishing. `ProjectDef` declares no Debug field today, so this is + // empty in practice. + let mut debug_slots = Vec::new(); for slot in &self.slots { match slot.address().root { ProjectSlotRoot::State => {} ProjectSlotRoot::Def | ProjectSlotRoot::Other(_) => { - slot.collect_config(edits, &mut config_slots, &mut asset_slots); + slot.collect_config( + edits, + &mut config_slots, + &mut asset_slots, + &mut debug_slots, + ); } } } config_slots.extend(asset_slots); + config_slots.extend(debug_slots); config_slots } @@ -640,6 +673,14 @@ impl NodeController { .iter() .map(|nested| nested.dirty) .sum::(); + // Debug overrides roll up the same full list, on their own + // channel (D8 tier b): active, never dirty. + view.debug_overrides = child.own_slots_debug_overrides(edits) + + view + .children + .iter() + .map(|nested| nested.debug_overrides) + .sum::(); view.face = child.kind_face(&view.sections, &mut view.children); view.header_actions = node_header_actions(&child.address, &view.dirty, remove_action(&child.address)); @@ -672,6 +713,18 @@ impl NodeController { edits.dirty_summary_for_node(&self.address) } + /// Active Debug overrides addressed to this node (child nodes excluded) + /// — the node-card marking's input (D8 tier b), merged bottom-up by the + /// DTO walk exactly like [`Self::own_slots_dirty_summary`]. Kept a + /// separate channel from the dirty summary: a debug override is active, + /// not dirty (D7). + pub(in crate::app::project) fn own_slots_debug_overrides( + &self, + edits: &SlotEditJoin<'_>, + ) -> usize { + edits.debug_overrides_for_node(&self.address) + } + fn ui_status(&self) -> UiStatus { UiStatus::new(self.status.label.clone(), self.status.tone.ui_status_kind()) } @@ -705,7 +758,9 @@ impl NodeController { let resolve = |asset: &UiSlotAsset| asset_editor(self, asset); for section in sections { match section { - UiNodeSection::AssetSlots(slots) | UiNodeSection::ConfigSlots(slots) => { + UiNodeSection::AssetSlots(slots) + | UiNodeSection::ConfigSlots(slots) + | UiNodeSection::DebugSlots(slots) => { embed_asset_editors_in_slots(slots, &resolve); } _ => {} diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs index 522cc5caf..f97f1c1cc 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_face_builder.rs @@ -190,9 +190,9 @@ fn inline_editor_of_kind( }) } sections.iter().find_map(|section| match section { - UiNodeSection::AssetSlots(slots) | UiNodeSection::ConfigSlots(slots) => { - in_slots(slots, kind) - } + UiNodeSection::AssetSlots(slots) + | UiNodeSection::ConfigSlots(slots) + | UiNodeSection::DebugSlots(slots) => in_slots(slots, kind), _ => None, }) } diff --git a/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs b/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs index a0b58ca53..60bea44f5 100644 --- a/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs +++ b/lp-app/lpa-studio-core/src/app/project/node_card_ui_state.rs @@ -3,7 +3,7 @@ //! `docs/adr/2026-07-26-card-view-state-ownership.md`, explicitly deferred //! this slice). //! -//! What a node card's face is disclosing: whether the code/advanced +//! What a node card's face is disclosing: whether the code/advanced/debug //! drawers are expanded, whether the agent section is collapsed, and the //! last MIRRORED composer draft. This used to live in the web renderer's //! `use_signal`s (`NodeCardDrawers`, `AgentChatPane`), which meant it died @@ -27,13 +27,20 @@ //! and a full card remount. /// One node card's UI view-state. `Default` is a fresh card: drawers -/// closed, agent section expanded, no mirrored draft. +/// closed (the Debug section included — most of the time those controls are +/// not wanted, so it opens on demand), agent section expanded, no mirrored +/// draft. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct NodeCardUiState { /// Whether the code drawer (inline GLSL editor) is expanded. pub code_open: bool, /// Whether the advanced drawer (generic slot rows) is expanded. pub advanced_open: bool, + /// Whether the **Debug** section's rows are expanded. Default `false`: + /// the section is collapsed to its (always striped) header, which keeps + /// carrying the DEBUG label, the active-override count, and Clear — so + /// debug territory stays announced and clearable without expanding. + pub debug_open: bool, /// Whether the shader face's agent section is collapsed to its /// summary row. pub agent_collapsed: bool, @@ -50,6 +57,7 @@ impl NodeCardUiState { NodeUiOp::SetDrawer { drawer, open, .. } => match drawer { NodeCardDrawer::Code => self.code_open = *open, NodeCardDrawer::Advanced => self.advanced_open = *open, + NodeCardDrawer::Debug => self.debug_open = *open, }, NodeUiOp::SetAgentCollapsed { collapsed, .. } => { self.agent_collapsed = *collapsed; @@ -61,13 +69,16 @@ impl NodeCardUiState { } } -/// The two expandable drawers under a node card's face. +/// The expandable drawers under a node card's face. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum NodeCardDrawer { /// The inline GLSL editor drawer (shader faces). Code, /// The generic slot-row drawer (every face). Advanced, + /// The **Debug** section's rows (any node declaring a `SlotRole::Debug` + /// field). Its header is never hidden — only the rows disclose. + Debug, } /// Mutations to a node card's UI view-state, dispatched by the card @@ -130,6 +141,10 @@ mod tests { let node = "/demo.project/orbit.shader".to_string(); let mut state = NodeCardUiState::default(); assert!(!state.code_open && !state.advanced_open && !state.agent_collapsed); + assert!( + !state.debug_open, + "the Debug section defaults to collapsed (G1 feedback)" + ); assert!(state.composer_draft.is_empty()); state.apply(&NodeUiOp::SetDrawer { @@ -142,6 +157,11 @@ mod tests { drawer: NodeCardDrawer::Advanced, open: true, }); + state.apply(&NodeUiOp::SetDrawer { + node: node.clone(), + drawer: NodeCardDrawer::Debug, + open: true, + }); state.apply(&NodeUiOp::SetDraft { node: node.clone(), draft: "make it pulse".to_string(), @@ -155,6 +175,7 @@ mod tests { NodeCardUiState { code_open: true, advanced_open: true, + debug_open: true, agent_collapsed: true, composer_draft: "make it pulse".to_string(), } diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 753877e09..1a88faa20 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -27,14 +27,13 @@ use crate::{ UiPaneView, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiProductRef, UiResult, UiShaderError, UiShaderUniform, UiSlotAsset, UiStatus, UiViewContent, UxUpdateSink, }; -use lpc_model::slot::{SlotPersistence, effective_persistence}; +use lpc_model::slot::SlotPersistence; use lpc_model::{ ArtifactLocation, ArtifactSpec, AssetBodyOverlay, FromLpValue, MutationCmd, MutationCmdBatch, MutationCmdId, MutationCmdStatus, MutationEffect, MutationOp, MutationRejection, - NodeAttachSite, NodeId, NodeKind, NodeStarter, ShaderValueShapeRef, SlotDirection, SlotEdit, - SlotMapKey, SlotPath, SlotPathSegment, SlotRole, SlotShapeId, SlotShapeLookup, - SlotShapeRegistry, TreePath, glsl_type_for_lp_type, resolve_artifact_specifier, - resolve_slot_role, starter_for_kind, + NodeAttachSite, NodeId, NodeKind, NodeStarter, ShaderValueShapeRef, SlotEdit, SlotMapKey, + SlotPath, SlotPathSegment, SlotShapeId, SlotShapeLookup, SlotShapeRegistry, TreePath, + glsl_type_for_lp_type, resolve_artifact_specifier, resolve_slot_role, starter_for_kind, }; use lpc_view::ProjectView; use lpc_wire::{ @@ -965,7 +964,9 @@ impl ProjectController { /// The save panel's labeled change list (D5): one [`UiPendingEdit`] per /// edit entry of the same join [`DirtySummary`] counting uses /// (`SlotEditJoin::entries`), so the list length per phase equals the - /// summary's bucket counts by construction. Stable order: by node + /// summary's bucket counts by construction — Debug overrides count in no + /// bucket (D7) and are therefore listed in no section either; their verb + /// is Clear, not Revert. Stable order: by node /// address, then slot path. Overlay entries whose artifact no longer /// reverse-maps to a synced node are appended with the artifact path as /// their label rather than being dropped (no revert — there is no node @@ -979,6 +980,11 @@ impl ProjectController { let mut edits: Vec = join .entries() .into_iter() + // D7: a Debug override carries no dirty weight, so it belongs in + // no save-panel section — the summary-clean filter keeps the list + // and the counts equal by construction. (A *failed* write to a + // Debug slot still needs attention and stays listed.) + .filter(|entry| !entry.summary.is_clean()) .map(|entry| { let old_value = join.base_display(entry.address).map(str::to_string); self.ui_pending_edit(&entry, old_value) @@ -1045,7 +1051,10 @@ impl ProjectController { /// Project one join entry into its change-list DTO. The phase derives /// from the entry's own [`DirtySummary`] classification — the same value - /// the counts sum — so list and counts cannot drift. `old_value` is the + /// the counts sum — so list and counts cannot drift. Only entries that + /// carry dirty weight get here (Debug overrides are filtered upstream in + /// [`Self::pending_edits`]), so the phase is Failed or Persisted. + /// `old_value` is the /// join's base display for the entry's address /// ([`SlotEditJoin::base_display`]), threaded by the caller. fn ui_pending_edit( @@ -1090,8 +1099,6 @@ impl ProjectController { .unwrap_or_default() .to_string(), } - } else if entry.summary.transient > 0 { - UiPendingEditPhase::Live } else { UiPendingEditPhase::Persisted }; @@ -1116,13 +1123,35 @@ impl ProjectController { /// entries). Rendered with the artifact path as the label so a stale /// pending edit stays visible; save still writes it, so it lists as /// persisted. + /// + /// Classification is role-aware (S4): an artifact that left the node tree + /// may still be reachable through the connect-time def-artifact map (an + /// unmounted node's def is the common case), and a **Debug** override + /// there is not authored work — it belongs in no save-panel section, + /// exactly like a Debug entry the join classified (D7). Listing it would + /// amber-tint a value that Save will never write. Entries that classify + /// nowhere take the shared unresolvable rule (Setting) and list. fn stale_pending_edits(&self) -> Vec { let Some(sync) = &self.sync else { return Vec::new(); }; let nodes_by_artifact = self.nodes_by_def_artifact(); + let node_ids_by_artifact: BTreeMap<&ArtifactLocation, NodeId> = self + .def_artifacts + .iter() + .map(|(node_id, artifact)| (artifact, *node_id)) + .collect(); sync.overlay_slot_edits() .filter(|(artifact, _, _)| !nodes_by_artifact.contains_key(artifact)) + .filter(|(artifact, path, _)| { + node_ids_by_artifact + .get(artifact) + .map(|node_id| { + self.persistence_at(*node_id, ProjectSlotRoot::def().name(), path) + }) + .unwrap_or_else(SlotPersistence::for_unresolved_edit) + .is_persisted() + }) .map(|(artifact, path, op)| UiPendingEdit { node_label: artifact.file_path().as_str().to_string(), node_path: artifact.file_path().as_str().to_string(), @@ -1197,21 +1226,28 @@ impl ProjectController { /// removed map entries — exactly like paths that still have data. A /// produced field (e.g. under the `State` root) always classifies as /// transient regardless of its role (D1). Unresolvable entries (unknown - /// node/shape/path) classify as the default role's bucket (persisted). + /// node/shape/path) take the shared unresolvable rule + /// ([`SlotPersistence::for_unresolved_edit`] — Setting), the same + /// fallback the server's commit-time retention uses, so the two sides + /// cannot disagree about what an edit is. fn resolve_edit_persistence(&self, address: &ProjectSlotAddress) -> SlotPersistence { self.node(&address.node) - .and_then(|node| { - let key = root_slot_key(node.target().node_id, address.root.name()); - let shape = self - .slot_shapes - .get_shape(*self.root_shape_ids.get(&key)?)?; - let resolution = resolve_slot_role(shape, &self.slot_shapes, &address.path)?; - Some(effective_persistence(resolution.role, resolution.direction)) - }) - .unwrap_or(effective_persistence( - SlotRole::default(), - SlotDirection::default(), - )) + .map(|node| node.target().node_id) + .map(|node_id| self.persistence_at(node_id, address.root.name(), &address.path)) + .unwrap_or_else(SlotPersistence::for_unresolved_edit) + } + + /// The persistence governing `path` under one node's slot root — the + /// shape-only classifier behind [`Self::resolve_edit_persistence`] and + /// the stale-entry classification in [`Self::stale_pending_edits`], which + /// has an artifact and a node id but no slot address. + fn persistence_at(&self, node_id: NodeId, root_name: &str, path: &SlotPath) -> SlotPersistence { + self.root_shape_ids + .get(&root_slot_key(node_id, root_name)) + .and_then(|shape_id| self.slot_shapes.get_shape(*shape_id)) + .and_then(|shape| resolve_slot_role(shape, &self.slot_shapes, path)) + .map(|resolution| resolution.persistence()) + .unwrap_or_else(SlotPersistence::for_unresolved_edit) } /// Entry keys of `artifact`'s `entries` map that carry pending overlay @@ -1594,6 +1630,7 @@ impl ProjectController { .with_root_slots(root_slots) .with_library_identity(self.active_library_uid().zip(self.active_library_slug())) .with_dirty(dirty) + .with_debug_overrides(edits.debug_override_count()) .with_pending_edits(self.pending_edits()) .with_header_actions(project_header_actions(&dirty)) .with_add_node_menu(root_add_node_menu) @@ -2571,7 +2608,16 @@ impl ProjectController { ) .await } - SlotEditOp::Revert { address } => self.apply_revert(server, handle_id, address).await, + SlotEditOp::Revert { address } => { + self.apply_revert(server, handle_id, address, "Revert") + .await + } + // Same mechanism, different verb: a Debug slot has nothing + // durable underneath, so dropping the overlay entry clears the + // override back to the shape default (D7). + SlotEditOp::Clear { address } => { + self.apply_revert(server, handle_id, address, "Clear").await + } } } @@ -2621,12 +2667,12 @@ impl ProjectController { } /// Commit the pending-edit overlay (persisted edits are written back to - /// def artifacts; transient edits stay pending) and re-sync the overlay + /// def artifacts; Debug overrides stay pending) and re-sync the overlay /// mirror from a follow-up read. /// /// The full read (rather than trusting the commit response's revision /// alone) is deliberate: commit drops persisted entries but retains - /// transient ones (P2), and an only-transient commit does not bump the + /// Debug ones (P2), and an only-Debug commit does not bump the /// overlay revision, so a wholesale re-read is the reliable way for the /// mirror to converge immediately instead of waiting for the next tick's /// fetch-on-advance. @@ -2805,6 +2851,125 @@ impl ProjectController { }) } + // --- The Clear verb: Debug overrides only (D7) --------------------------- + + /// **Clear** every Debug override under `node`'s subtree + /// ([`crate::NodeClearDebugOp`], the per-node scope of the Clear verb). + /// Persisted edits under the same node are untouched — their verb is + /// Revert and their home is the Save panel. + pub async fn clear_node_debug_edits( + &mut self, + server: &mut StudioServerClient, + node: &ProjectNodeAddress, + ) -> Result { + let addresses = self.debug_edit_addresses(Some(node)); + self.clear_debug_edits_at(server, addresses, &format!("under {node}")) + .await + } + + /// **Clear** every Debug override in the project + /// ([`ProjectOp::ClearDebugEdits`], the project scope of the Clear verb — + /// P3's global debug chip dispatches it). Unlike + /// [`Self::revert_all_edits`] this leaves persisted edits pending: Debug + /// values were never part of Save, so clearing them is not a discard of + /// authored work. + pub async fn clear_debug_edits( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + let addresses = self.debug_edit_addresses(None); + self.clear_debug_edits_at(server, addresses, "in this project") + .await + } + + /// Addresses of the join's Debug (transient-persistence) edit entries, + /// optionally restricted to one node's subtree. This is the same + /// enumeration `DirtySummary` counting walks — the entries it deliberately + /// counts as nothing. + fn debug_edit_addresses(&self, under: Option<&ProjectNodeAddress>) -> Vec { + self.slot_edit_join() + .entries() + .into_iter() + .filter(|entry| entry.persistence == SlotPersistence::Transient) + .filter(|entry| under.is_none_or(|node| entry.address.node.is_self_or_under(node))) + .map(|entry| entry.address.clone()) + .collect() + } + + /// Drop the named Debug overlay entries in ONE batch of `RemoveSlotEdit` + /// mutations — the shared body of the node and project Clear scopes. + /// `scope` only phrases the notices. + async fn clear_debug_edits_at( + &mut self, + server: &mut StudioServerClient, + addresses: Vec, + scope: &str, + ) -> Result { + let handle_id = self.ready_handle_id()?; + if addresses.is_empty() { + return Ok(ProjectEditRun::notice(UiNotice::info(format!( + "No debug overrides {scope}" + )))); + } + // Every entry clears locally regardless of whether its artifact still + // resolves (matching `apply_revert`); an artifact shared by several + // node uses yields one wire removal per distinct `(artifact, path)`. + // Debug entries are never staged node removals (those are persisted), + // so no `ClearArtifact` companions are needed here. + let mut notices = UiNotices::new(); + let mut wire_targets = BTreeSet::new(); + for address in addresses { + self.edit_buffer.remove(&address); + match self.resolve_def_artifact(&address) { + Ok(artifact) => { + wire_targets.insert((artifact, address.path.clone())); + } + Err(reason) => { + notices = notices.with_notice(UiNotice::warning(format!( + "Clear on {} could not reach the server overlay: {reason}", + address.path + ))); + } + } + } + if wire_targets.is_empty() { + return Ok(ProjectEditRun { + notices, + logs: Vec::new(), + }); + } + let batch = MutationCmdBatch::new( + wire_targets + .into_iter() + .map(|(artifact, path)| MutationCmd { + id: self.allocate_mutation_cmd_id(), + mutation: MutationOp::RemoveSlotEdit { artifact, path }, + }) + .collect(), + ); + let cleared = batch.commands.len(); + let mutation = server + .project_overlay_mutate(handle_id, batch.clone()) + .await?; + let rejections = self.apply_mutation_acks(&batch, &mutation, &[]); + notices = if rejections.is_empty() { + notices.with_notice(UiNotice::info(format!( + "Cleared {cleared} debug override(s) {scope}" + ))) + } else { + rejections.iter().fold(notices, |notices, rejection| { + notices.with_notice(UiNotice::warning(format!( + "Clear rejected: {}", + rejection_text(rejection) + ))) + }) + }; + Ok(ProjectEditRun { + notices, + logs: mutation.logs, + }) + } + // --- Dedicated node create/remove ops (authoring P4) --------------------- /// Create one blank node of `kind` at `attach` ([`crate::NodeCreateOp`]): @@ -3686,11 +3851,18 @@ impl ProjectController { }) } + /// Drop one edit entry: the shared mechanism behind both per-value verbs + /// ([`SlotEditOp::Revert`] and, for Debug slots, [`SlotEditOp::Clear`] — + /// D7). `verb` only names the gesture in the unreachable-overlay notice; + /// the mutation is one `RemoveSlotEdit` either way, and for a Debug slot + /// removing the overlay entry IS the return to the shape default (no + /// durable authored value sits underneath). async fn apply_revert( &mut self, server: &mut StudioServerClient, handle_id: u32, address: ProjectSlotAddress, + verb: &str, ) -> Result { // A revert at a staged node-removal site expands into the inverse // composed batch (site RemoveSlotEdit + ClearArtifact per staged @@ -3707,7 +3879,7 @@ impl ProjectController { Ok(artifact) => artifact, Err(reason) => { return Ok(ProjectEditRun::notice(UiNotice::warning(format!( - "Revert on {} could not reach the server overlay: {reason}", + "{verb} on {} could not reach the server overlay: {reason}", address.path )))); } @@ -6952,6 +7124,16 @@ mod tests { .unwrap_or(&[]) } + fn section_debug_slots(sections: &[UiNodeSection]) -> &[crate::UiConfigSlot] { + sections + .iter() + .find_map(|section| match section { + UiNodeSection::DebugSlots(items) => Some(items.as_slice()), + _ => None, + }) + .unwrap_or(&[]) + } + fn tree_view() -> ProjectView { let mut view = ProjectView::new(); let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); @@ -8453,6 +8635,15 @@ mod tests { .unwrap_or_else(|| panic!("config slot {label} should exist")) } + /// A row of the node's **Debug** section (D3/D4) — the partition means a + /// `SlotRole::Debug` field is deliberately NOT in `config_slot`'s bucket. + fn debug_slot<'a>(nodes: &'a [crate::UiNodeView], label: &str) -> &'a crate::UiConfigSlot { + section_debug_slots(node_sections(&nodes[0])) + .iter() + .find(|slot| slot.label == label) + .unwrap_or_else(|| panic!("debug slot {label} should exist")) + } + fn slot_display(slot: &crate::UiConfigSlot) -> &str { let UiConfigSlotBody::Value(value) = &slot.body else { panic!("expected value body"); @@ -8577,14 +8768,13 @@ mod tests { let nodes = project.ui_nodes(); let slot = config_slot(&nodes, "Brightness"); assert_eq!(slot.state.dirty, UiNodeDirtyState::Dirty); - assert!(!slot.state.live); + assert!(!slot.state.debug); assert_eq!(slot_display(slot), "0.9"); assert_eq!(slot.address, Some(brightness_address())); assert_eq!( project.dirty_summary(), DirtySummary { persisted: 1, - transient: 0, failed: 0, } ); @@ -9001,7 +9191,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 1, - transient: 0, failed: 0, } ); @@ -9051,7 +9240,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -9203,6 +9391,244 @@ mod tests { ); } + // --- The Clear verb at three scopes (D7) -------------------------------- + + /// Seed the editable fixture with one persisted edit (`brightness`) and + /// one Debug override (`rate`), both acked into the overlay mirror. + fn project_with_one_persisted_and_one_debug_edit( + responses: Vec, + ) -> ( + ProjectController, + StudioServerClient, + Rc>>, + ) { + let (mut project, client, sent) = editable_project_with_scripted_client(responses); + project.sync_mut().unwrap().apply_acked_edits( + &[ + ( + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: edit_artifact(), + edit: SlotEdit::assign_value( + SlotPath::parse("brightness").unwrap(), + LpValue::F32(0.9), + ), + }, + }, + MutationEffect::overlay_changed(true), + ), + ( + MutationCmd { + id: MutationCmdId::new(2), + mutation: MutationOp::PutSlotEdit { + artifact: edit_artifact(), + edit: SlotEdit::assign_value( + SlotPath::parse("rate").unwrap(), + LpValue::F32(2.0), + ), + }, + }, + MutationEffect::overlay_changed(true), + ), + ], + Revision::new(3), + ); + (project, client, sent) + } + + /// Per-value Clear: `SlotEditOp::Clear` is the Revert mechanism under the + /// Debug verb — one `RemoveSlotEdit` at the address. + #[test] + fn per_value_clear_removes_only_that_debug_overlay_entry() { + let (mut project, mut client, sent) = + project_with_one_persisted_and_one_debug_edit(vec![mutation_response( + 1, + vec![accepted(1)], + 5, + )]); + + block_on_ready(project.apply_slot_edit( + &mut client, + crate::SlotEditOp::Clear { + address: rate_address(), + }, + )) + .unwrap(); + + let sent = sent.borrow(); + let ClientRequest::ProjectCommand { + command: WireProjectCommand::MutateOverlay { request }, + .. + } = &sent[0].msg + else { + panic!("expected an overlay mutation"); + }; + assert_eq!(request.batch.commands.len(), 1); + assert!(matches!( + &request.batch.commands[0].mutation, + MutationOp::RemoveSlotEdit { path, .. } if path.to_string() == "rate" + )); + drop(sent); + + let sync = project.sync.as_ref().unwrap(); + assert_eq!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("rate").unwrap()), + None, + "the debug override is cleared" + ); + assert!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("brightness").unwrap()) + .is_some(), + "the persisted edit beside it is untouched" + ); + } + + /// Per-node Clear: only the subtree's Debug overrides go; persisted edits + /// under the same node stay pending (their verb is Revert). + #[test] + fn node_clear_removes_debug_overrides_and_keeps_persisted_edits() { + let (mut project, mut client, sent) = + project_with_one_persisted_and_one_debug_edit(vec![mutation_response( + 1, + vec![accepted(1)], + 5, + )]); + let dirty_before = project.dirty_summary(); + + let run = block_on_ready( + project + .clear_node_debug_edits(&mut client, &node_address("/demo.project/orbit.shader")), + ) + .unwrap(); + + // ONE batch carrying exactly the debug entry. + let sent = sent.borrow(); + assert_eq!(sent.len(), 1, "one batch, one round trip"); + let ClientRequest::ProjectCommand { + command: WireProjectCommand::MutateOverlay { request }, + .. + } = &sent[0].msg + else { + panic!("expected an overlay mutation"); + }; + let paths: Vec = request + .batch + .commands + .iter() + .map(|command| match &command.mutation { + MutationOp::RemoveSlotEdit { path, .. } => path.to_string(), + other => panic!("expected RemoveSlotEdit, got {other:?}"), + }) + .collect(); + assert_eq!(paths, ["rate"]); + drop(sent); + + let sync = project.sync.as_ref().unwrap(); + assert_eq!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("rate").unwrap()), + None + ); + assert_eq!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("brightness").unwrap()), + Some(&SlotEditOp::AssignValue(LpValue::F32(0.9))), + "Clear is not Revert: the persisted edit survives" + ); + assert_eq!( + project.dirty_summary(), + dirty_before, + "clearing debug overrides changes nothing about dirtiness" + ); + assert!( + run.notices.notices[0] + .message + .contains("Cleared 1 debug override(s)") + ); + } + + /// Per-node Clear on a subtree with no Debug overrides is a no-op that + /// never reaches the wire. + #[test] + fn node_clear_with_no_debug_overrides_sends_nothing() { + let (mut project, mut client, sent) = editable_project_with_scripted_client(Vec::new()); + project.insert_pending_edit_for_test( + brightness_address(), + PendingEdit::pending(LpValue::F32(0.9)), + ); + + let run = block_on_ready( + project + .clear_node_debug_edits(&mut client, &node_address("/demo.project/orbit.shader")), + ) + .unwrap(); + + assert!(sent.borrow().is_empty(), "no wire traffic"); + assert_eq!( + project.edit_buffer_for_test().len(), + 1, + "the persisted buffer entry is untouched" + ); + assert!( + run.notices.notices[0] + .message + .contains("No debug overrides under") + ); + } + + /// Project-wide Clear (the op P3's global chip dispatches): every Debug + /// override goes, nothing persisted does. + #[test] + fn project_clear_removes_every_debug_override_and_nothing_persisted() { + let (mut project, mut client, sent) = + project_with_one_persisted_and_one_debug_edit(vec![mutation_response( + 1, + vec![accepted(1)], + 5, + )]); + // A buffered (un-acked) debug edit joins the acked one in the sweep. + project.insert_pending_edit_for_test( + crate::ProjectSlotAddress::new( + node_address("/demo.project/orbit.shader"), + ProjectSlotRoot::def(), + SlotPath::parse("rate").unwrap(), + ), + PendingEdit::pending(LpValue::F32(3.0)), + ); + + block_on_ready(project.clear_debug_edits(&mut client)).unwrap(); + + let sent = sent.borrow(); + assert_eq!(sent.len(), 1, "one batch, one round trip"); + let ClientRequest::ProjectCommand { + command: WireProjectCommand::MutateOverlay { request }, + .. + } = &sent[0].msg + else { + panic!("expected an overlay mutation"); + }; + assert_eq!( + request.batch.commands.len(), + 1, + "the buffered and acked debug entries share one (artifact, path)" + ); + drop(sent); + + assert!( + project.edit_buffer_for_test().is_empty(), + "the buffered debug edit clears locally too" + ); + let sync = project.sync.as_ref().unwrap(); + assert_eq!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("rate").unwrap()), + None + ); + assert_eq!( + sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("brightness").unwrap()), + Some(&SlotEditOp::AssignValue(LpValue::F32(0.9))), + "project-wide Clear is not Revert-all" + ); + } + #[test] fn accepted_move_entry_sends_the_move_op_and_mirrors_the_materialized_effect() { // The map's `entries` values are leaves, so a realistic materialized @@ -9291,7 +9717,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 2, - transient: 0, failed: 0, } ); @@ -9355,7 +9780,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -9412,7 +9836,6 @@ mod tests { ); let expected = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!( @@ -9578,10 +10001,6 @@ mod tests { persisted: 1, ..DirtySummary::default() }, - crate::UiPendingEditPhase::Live => DirtySummary { - transient: 1, - ..DirtySummary::default() - }, crate::UiPendingEditPhase::Failed { .. } => DirtySummary { failed: 1, ..DirtySummary::default() @@ -9649,7 +10068,6 @@ mod tests { editor.dirty, DirtySummary { persisted: 2, - transient: 0, failed: 1, } ); @@ -9702,8 +10120,11 @@ mod tests { ); } + /// D7: a Debug override is not dirty, so it neither counts in the + /// summary nor lists in the save panel — while the persisted edit beside + /// it does both. #[test] - fn transient_edits_list_in_the_live_phase() { + fn debug_edits_are_absent_from_the_summary_and_the_save_panel() { let (mut project, _client, _sent) = editable_project_with_scripted_client(Vec::new()); project.sync_mut().unwrap().apply_acked_edits( &[ @@ -9743,9 +10164,9 @@ mod tests { editor.dirty, DirtySummary { persisted: 1, - transient: 1, failed: 0, - } + }, + "only the persisted brightness edit is dirty" ); assert_eq!(pending_edits_by_phase(&editor.pending_edits), editor.dirty); let phases: Vec<(&str, &crate::UiPendingEditPhase)> = editor @@ -9755,11 +10176,15 @@ mod tests { .collect(); assert_eq!( phases, - vec![ - ("brightness", &crate::UiPendingEditPhase::Persisted), - ("rate", &crate::UiPendingEditPhase::Live), - ] + vec![("brightness", &crate::UiPendingEditPhase::Persisted)], + "the debug `rate` override lists in no save-panel section" ); + // The override is still LIVE on the project — it is simply not + // save/dirty business; its verb is Clear. + let nodes = project.ui_nodes(); + let rate = debug_slot(&nodes, "Rate"); + assert!(rate.state.debug); + assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); } /// Overlay entries whose artifact no longer reverse-maps to a synced node @@ -9807,9 +10232,114 @@ mod tests { assert!(stale.revert.is_none()); } + /// S4: the stale-entry path classifies by role like every other entry. + /// A node whose def artifact left the tree (unmounted, retired) can still + /// be classified through the connect-time def-artifact map and the + /// retained shapes — and a Debug override there is not authored work, so + /// it must not list as a persisted pending edit (which would amber-tint + /// it and present Save as having something to write). + #[test] + fn stale_overlay_entries_are_classified_by_role_not_hard_coded_persisted() { + let (mut project, _client, _sent) = editable_project_with_scripted_client(Vec::new()); + project.sync_mut().unwrap().apply_acked_edits( + &[ + ( + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: edit_artifact(), + edit: SlotEdit::assign_value( + SlotPath::parse("brightness").unwrap(), + LpValue::F32(0.9), + ), + }, + }, + MutationEffect::overlay_changed(true), + ), + ( + MutationCmd { + id: MutationCmdId::new(2), + mutation: MutationOp::PutSlotEdit { + artifact: edit_artifact(), + edit: SlotEdit::assign_value( + SlotPath::parse("rate").unwrap(), + LpValue::F32(2.0), + ), + }, + }, + MutationEffect::overlay_changed(true), + ), + ], + Revision::new(3), + ); + + // The node leaves the tree while its shapes (and the def-artifact + // map) survive — the unmounted-def case. Both edits are now stale. + let mut unmounted = ProjectView::new(); + install_mixed_policy_slots(&mut unmounted, 1, Revision::new(2)); + project.apply_project_view(&unmounted).unwrap(); + + let editor = project.editor_view("loaded-project", 7, &ProjectInventorySummary::default()); + let listed: Vec<&str> = editor + .pending_edits + .iter() + .map(|edit| edit.slot_path_display.as_str()) + .collect(); + assert_eq!( + listed, + vec!["brightness"], + "the stale Debug override lists in no save-panel section (D7); \ + the stale Setting still does" + ); + assert_eq!( + editor.pending_edits[0].phase, + crate::UiPendingEditPhase::Persisted + ); + } + + /// The other half of S5, client side: an entry the shapes cannot + /// classify falls back to Setting — the same verdict the server's + /// commit-time retention reaches — so it lists as authored work rather + /// than becoming an override nothing accounts for. #[test] - fn save_overlay_commits_persisted_edits_and_keeps_transient_dirty() { - // Post-commit overlay retains only the transient rate edit (P2). + fn unclassifiable_stale_entries_fall_back_to_setting() { + let (mut project, _client, _sent) = editable_project_with_scripted_client(Vec::new()); + project.sync_mut().unwrap().apply_acked_edits( + &[( + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + // No node maps to this artifact at all: nothing can + // resolve its role. + artifact: ArtifactLocation::file("/retired.shader.json"), + edit: SlotEdit::assign_value( + SlotPath::parse("rate").unwrap(), + LpValue::F32(2.0), + ), + }, + }, + MutationEffect::overlay_changed(true), + )], + Revision::new(3), + ); + + let editor = project.editor_view("loaded-project", 7, &ProjectInventorySummary::default()); + assert_eq!(editor.pending_edits.len(), 1); + assert_eq!( + editor.pending_edits[0].phase, + crate::UiPendingEditPhase::Persisted, + "unresolvable entries are Settings on both sides, so they stay \ + visible as authored work" + ); + assert_eq!( + editor.pending_edits[0].node_label, "/retired.shader.json", + "and keep the artifact label — there is no node to name them" + ); + } + + #[test] + fn save_overlay_commits_persisted_edits_and_keeps_debug_overrides() { + // Post-commit overlay retains only the debug rate override (P2). let mut post_commit_overlay = ProjectOverlay::new(); post_commit_overlay.put_slot_edit( edit_artifact(), @@ -9819,7 +10349,7 @@ mod tests { commit_response(1, vec![edit_artifact()], 5), overlay_read_response(2, post_commit_overlay, 5), ]); - // Mirror holds one persisted (brightness) and one transient (rate) + // Mirror holds one persisted (brightness) and one debug (rate) // acked edit before the save. project.sync_mut().unwrap().apply_acked_edits( &[ @@ -9856,9 +10386,9 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 1, - transient: 1, failed: 0, - } + }, + "the debug rate override is not dirty (D7)" ); let run = block_on_ready(project.save_overlay(&mut client)).unwrap(); @@ -9880,20 +10410,19 @@ mod tests { assert_eq!( sync.overlay_edit_at(&edit_artifact(), &SlotPath::parse("rate").unwrap()), Some(&SlotEditOp::AssignValue(LpValue::F32(2.0))), - "transient edit stays pending (dirty-live)" + "the debug override survives the commit, live on the project" ); - assert_eq!( - project.dirty_summary(), - DirtySummary { - persisted: 0, - transient: 1, - failed: 0, - } + assert!( + project.dirty_summary().is_clean(), + "with the persisted edit written, only the debug override remains — and it is not dirty" ); let nodes = project.ui_nodes(); - let rate = config_slot(&nodes, "Rate"); + let rate = debug_slot(&nodes, "Rate"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); - assert!(rate.state.live, "transient dirty is distinguishable"); + assert!( + rate.state.debug, + "the live override is still distinguishable" + ); assert_eq!( config_slot(&nodes, "Brightness").state.dirty, UiNodeDirtyState::Clean @@ -9947,7 +10476,7 @@ mod tests { UiNodeDirtyState::Clean ); assert_eq!( - config_slot(&nodes, "Rate").state.dirty, + debug_slot(&nodes, "Rate").state.dirty, UiNodeDirtyState::Clean ); } @@ -10045,7 +10574,6 @@ mod tests { // and must count toward Save at the project level. let expected = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!(project.dirty_summary(), expected); @@ -10068,7 +10596,6 @@ mod tests { let expected = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!(project.dirty_summary(), expected); @@ -10125,7 +10652,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -10175,7 +10701,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -10419,9 +10944,9 @@ mod tests { }) } sections.iter().find_map(|section| match section { - crate::UiNodeSection::AssetSlots(slots) | crate::UiNodeSection::ConfigSlots(slots) => { - in_slots(slots) - } + crate::UiNodeSection::AssetSlots(slots) + | crate::UiNodeSection::ConfigSlots(slots) + | crate::UiNodeSection::DebugSlots(slots) => in_slots(slots), _ => None, }) } @@ -10551,7 +11076,6 @@ mod tests { ); let one_persisted = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; @@ -10605,7 +11129,6 @@ mod tests { let expected = DirtySummary { persisted: 0, - transient: 0, failed: 1, }; assert_eq!(project.dirty_summary(), expected); @@ -10696,20 +11219,37 @@ mod tests { } #[test] - fn transient_only_dirty_shows_no_header_actions() { + fn debug_only_dirty_is_clean_and_shows_no_header_actions() { let (mut project, _client, _sent) = editable_project_with_scripted_client(Vec::new()); - project - .insert_pending_edit_for_test(rate_address(), PendingEdit::pending(LpValue::F32(2.0))); + // An ACKED debug override (nothing in flight, so the only thing that + // could announce is the dirty projection). + project.sync_mut().unwrap().apply_acked_edits( + &[( + MutationCmd { + id: MutationCmdId::new(1), + mutation: MutationOp::PutSlotEdit { + artifact: edit_artifact(), + edit: SlotEdit::assign_value( + SlotPath::parse("rate").unwrap(), + LpValue::F32(2.0), + ), + }, + }, + MutationEffect::overlay_changed(true), + )], + Revision::new(3), + ); let editor = project.editor_view("loaded-project", 7, &ProjectInventorySummary::default()); + // D7: the summary never learns about the debug override, so the + // project header stays untinted and offers no Save/Revert. + assert!(editor.dirty.is_clean()); + assert!(editor.pending_edits.is_empty()); assert_eq!( - editor.dirty, - DirtySummary { - persisted: 0, - transient: 1, - failed: 0, - } + editor.affordance(crate::UiStatusKind::Good), + crate::UiAffordance::Info, + "a debug-only project does not tint its header" ); assert_eq!( editor @@ -10718,7 +11258,7 @@ mod tests { .map(|action| action.icon.as_str()) .collect::>(), Vec::<&str>::new(), - "live-only edits do not surface Save/Revert" + "debug overrides do not surface Save/Revert" ); } @@ -10740,9 +11280,10 @@ mod tests { let editor = project.editor_view("loaded-project", 7, &ProjectInventorySummary::default()); + // Only the persisted brightness edit counts; the debug rate override + // is absent from every aggregation (D7). let expected = DirtySummary { persisted: 1, - transient: 1, failed: 0, }; // editor.dirty, the standalone walk, and dirty_summary agree — one diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs index 6d8d78c5a..9224f19a5 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs @@ -33,9 +33,16 @@ pub struct ProjectEditorView { /// rather than a broken one. pub library_identity: Option<(String, String)>, /// Project-level aggregate of the per-node dirty summaries (persisted / - /// transient / failed) driving the save affordances; derived from the - /// same edit-state join as the per-field dirty affordances. + /// failed) driving the save affordances; derived from the same + /// edit-state join as the per-field dirty affordances. Debug overrides + /// are absent by construction (D7). pub dirty: DirtySummary, + /// Active **Debug** overrides anywhere in the project (D8 tier a): the + /// count the global "Debug active · N · Clear all" chip shows. Its own + /// channel — a debug override is not dirty (D7), so it is absent from + /// [`Self::dirty`], from [`Self::pending_edits`], and from + /// [`Self::affordance`]; the chip is the only place it announces. + pub debug_overrides: usize, /// The save panel's labeled change list: one entry per pending edit, /// built from the same edit-state join as [`Self::dirty`], so the list /// length per phase equals the summary's bucket counts by construction. @@ -79,6 +86,7 @@ impl ProjectEditorView { root_slots: Vec::new(), library_identity: None, dirty: DirtySummary::clean(), + debug_overrides: 0, pending_edits: Vec::new(), header_actions: Vec::new(), add_node_menu: None, @@ -117,6 +125,12 @@ impl ProjectEditorView { self } + /// Attach the project-wide count of active Debug overrides. + pub fn with_debug_overrides(mut self, debug_overrides: usize) -> Self { + self.debug_overrides = debug_overrides; + self + } + /// Attach the save panel's labeled change list. pub fn with_pending_edits(mut self, pending_edits: Vec) -> Self { self.pending_edits = pending_edits; @@ -156,6 +170,13 @@ impl ProjectEditorView { }; UiAffordance::merged(status, &self.dirty).merge(busy) } + + /// The project's DEBUG channel (D8 tier a), separate from + /// [`Self::affordance`]: [`UiAffordance::Debug`] while any override is + /// active anywhere, else the silent [`UiAffordance::Info`]. + pub fn debug_affordance(&self) -> UiAffordance { + UiAffordance::from_debug_overrides(self.debug_overrides) + } } #[cfg(test)] diff --git a/lp-app/lpa-studio-core/src/app/project/project_op.rs b/lp-app/lpa-studio-core/src/app/project/project_op.rs index 5c2a7e743..2ec2f55fa 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_op.rs @@ -49,11 +49,18 @@ pub enum ProjectOp { /// quiesces first and that session stays in the pool. OpenSimProject, /// Commit the pending-edit overlay: persisted edits are written back to - /// def artifacts; transient edits stay pending (live-only). + /// def artifacts; Debug overrides stay pending (live-only). SaveOverlay, /// Discard every pending edit — the local edit buffer and the server /// overlay both clear. RevertAllEdits, + /// **Clear** every Debug override in the project (D7, the project scope + /// of the Clear verb). Persisted edits survive untouched: this is not + /// Revert-all, and Debug values were never part of Save. + /// + /// P3's global "Debug active · N · Clear all" chip dispatches this; the + /// op is exposed here so the chip is pure presentation. + ClearDebugEdits, } impl ControllerOp for ProjectOp { @@ -109,6 +116,11 @@ impl ControllerOp for ProjectOp { "Discard every pending edit on this project.", ActionPriority::Secondary, ), + Self::ClearDebugEdits => ActionMeta::new( + "Clear all", + "Clear every debug override in this project.", + ActionPriority::Secondary, + ), } } @@ -144,9 +156,11 @@ impl ControllerOp for ProjectOp { }, // Editing ops share the project-editor quiet-gap budget (D5: // all edit ops are Foreground/6 s). - Self::SaveOverlay | Self::RevertAllEdits => ActionClass::Foreground { - deadline: PROJECT_EDITOR_ACTION_DEADLINE, - }, + Self::SaveOverlay | Self::RevertAllEdits | Self::ClearDebugEdits => { + ActionClass::Foreground { + deadline: PROJECT_EDITOR_ACTION_DEADLINE, + } + } } } @@ -216,7 +230,11 @@ mod tests { #[test] fn overlay_edit_ops_use_the_editor_deadline() { - for op in [ProjectOp::SaveOverlay, ProjectOp::RevertAllEdits] { + for op in [ + ProjectOp::SaveOverlay, + ProjectOp::RevertAllEdits, + ProjectOp::ClearDebugEdits, + ] { assert_eq!( op.action_class(), ActionClass::Foreground { @@ -226,4 +244,13 @@ mod tests { ); } } + + #[test] + fn the_project_scope_of_the_clear_verb_says_clear() { + // D7 vocabulary: Debug values are cleared, never reverted or reset. + let meta = ProjectOp::ClearDebugEdits.default_action_meta(); + assert_eq!(meta.label, "Clear all"); + assert!(!meta.summary.contains("Revert")); + assert!(!meta.summary.contains("Reset")); + } } diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs index 166b2d214..3873fe094 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs @@ -579,27 +579,86 @@ impl SlotController { } } - /// Collect config and asset rows under this slot. + /// Collect config, asset, and **Debug** rows under this slot. + /// + /// The three buckets are the node card's three slot sections, and the + /// split is by [`SlotRole`] (D3/D4): a `Debug` row leaves the settings + /// bucket entirely and lands **flat** in `debug_slots`, whatever record + /// declared it — see [`Self::partition_debug`]. pub(in crate::app::project) fn collect_config( &self, edits: &SlotEditJoin<'_>, config_slots: &mut Vec, asset_slots: &mut Vec, + debug_slots: &mut Vec, ) { if self.is_internal_config_slot() { return; } + if self.role == SlotRole::Debug { + debug_slots.push(self.ui_config_slot(edits)); + return; + } if let Some(asset) = self.ui_asset_slot(edits) { asset_slots.push(asset); return; } if self.address.is_root() && self.children_are_top_level_rows() { for child in &self.children { - child.collect_config(edits, config_slots, asset_slots); + child.collect_config(edits, config_slots, asset_slots, debug_slots); } return; } - config_slots.push(self.ui_config_slot(edits)); + if let Some(row) = self.partition_debug(edits, debug_slots) { + config_slots.push(row); + } + } + + /// Split one settings row into its Debug part — appended **flat** to + /// `debug_slots`, so a Debug field never renders nested under the record + /// that declared it (D4: the clock's `controls.running/rate/ + /// scrub_offset_seconds` become three top-level Debug rows, not a + /// "Controls › Controls" group) — and the Setting remainder, returned for + /// the settings section. `None` when nothing but Debug fields remained. + /// + /// A row with no Debug descendant takes the fast path and is built exactly + /// as before, so the common case is unchanged. + fn partition_debug( + &self, + edits: &SlotEditJoin<'_>, + debug_slots: &mut Vec, + ) -> Option { + if self.role == SlotRole::Debug { + debug_slots.push(self.ui_config_slot(edits)); + return None; + } + if !self.has_debug_descendant() { + return Some(self.ui_config_slot(edits)); + } + let mut row = self.ui_config_slot(edits); + // Only plain records are re-partitioned: map entries, enum variants, + // and options carry structure a role split must not rewrite. + if matches!(self.body, SlotControllerBody::Record) + && let UiConfigSlotBody::Record(record) = &mut row.body + { + record.fields = self + .children + .iter() + .filter(|child| !child.is_internal_config_slot()) + .filter_map(|child| child.partition_debug(edits, debug_slots)) + .collect(); + if record.fields.is_empty() { + return None; + } + } + Some(row) + } + + /// True when any field beneath this slot declares [`SlotRole::Debug`]. + fn has_debug_descendant(&self) -> bool { + self.children + .iter() + .any(|child| child.role == SlotRole::Debug || child.has_debug_descendant()) } /// Find a mutable descendant slot controller by address. @@ -947,11 +1006,20 @@ impl SlotController { effective_writable(self.role, self.semantics.direction) } - /// Whether this slot is "live": a runtime/session value that ordinary - /// save/writeback never persists (a `Debug`-role field, or any produced - /// field regardless of role). - fn is_live(&self) -> bool { - effective_persistence(self.role, self.semantics.direction) == SlotPersistence::Transient + /// Whether this slot is a **Debug** control (D6): transient by nature, + /// with no durable value underneath, so save never writes it and its verb + /// is Clear. Keyed on the ROLE alone — a produced field is transient too + /// ([`effective_persistence`]) but is read-only by direction and therefore + /// never wears edit chrome, and it belongs to the produced sections, not + /// the Debug one. + fn is_debug(&self) -> bool { + debug_assert!( + self.role != SlotRole::Debug + || effective_persistence(self.role, self.semantics.direction) + == SlotPersistence::Transient, + "a Debug-role slot is transient whatever its direction" + ); + self.role == SlotRole::Debug } /// Context for one record field, inheriting from the parent's ambient @@ -959,7 +1027,7 @@ impl SlotController { /// semantics nor its own role, else taking the field's own declared /// values directly. Either way the result is safe by construction: a /// produced field is always effectively read-only and live regardless of - /// its role (D1, [`Self::is_writable`]/[`Self::is_live`]), so no field + /// its role (D1, [`Self::is_writable`]/[`effective_persistence`]), so no field /// ever needs a role override to compensate for its direction — a state /// field that leaves everything default inherits `Produced` from the /// `State` root's ambient context ([`SlotApplyContext::for_root`]) and a @@ -1195,7 +1263,7 @@ impl SlotController { } else { UiSlotFieldState::readonly() }; - state = state.with_live(self.is_live()); + state = state.with_debug(self.is_debug()); // Join order: edit buffer (Saving/Error + invalid reason), then the // overlay mirror (Dirty), then — for composite slots only — the diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs index d46098444..8e955a43e 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs @@ -43,9 +43,9 @@ pub(in crate::app::project) struct SlotEditJoin<'a> { /// shadow; the structural ops shadow nothing). overlay: BTreeMap, /// Persistence classification for every buffer/overlay address, resolved - /// by `ProjectController` through the shape-only policy walk - /// (`lpc_model::resolve_slot_policy`), which works on data-less paths — - /// so a removed entry with no surviving row still classifies correctly. + /// by `ProjectController` through the shape-only role walk + /// (`lpc_model::resolve_slot_role`), which works on data-less paths — so + /// a removed entry with no surviving row still classifies correctly. persistence: BTreeMap, /// Asset body edits (buffer + overlay `ArtifactOverlay::Asset` mirror), /// keyed per owning node so [`Self::dirty_summary_for_node`] counts them @@ -137,7 +137,13 @@ pub(in crate::app::project) struct SlotEditEntry<'a> { pub pending: Option<&'a PendingEdit>, /// The entry's op for display, from the source that classifies it. pub op: SlotEditEntrySource<'a>, - /// The entry's [`DirtySummary`] classification (exactly one bucket). + /// The persistence governing the entry's path. + /// [`SlotPersistence::Transient`] marks a **Debug** (live-only) override: + /// not dirty (D7), listed nowhere, and cleared by the Clear verb rather + /// than reverted. + pub persistence: SlotPersistence, + /// The entry's [`DirtySummary`] classification (at most one bucket — + /// a non-failed Debug entry counts in none). pub summary: DirtySummary, } @@ -303,14 +309,16 @@ impl<'a> SlotEditJoin<'a> { .expect("entry addresses come from the buffer or the overlay"), ), }; + let persistence = self.entry_persistence(address); SlotEditEntry { address, pending, op, + persistence, summary: DirtySummary::for_slot( pending, self.overlay_dirty(address), - self.entry_persistence(address), + persistence, ), } }) @@ -323,8 +331,9 @@ impl<'a> SlotEditJoin<'a> { /// /// Counts are per edit entry ([`Self::entries`]), classified by /// [`DirtySummary::for_slot`] exactly like the per-field affordances: a - /// failed buffer entry → `failed`, anything else → its resolved - /// persistence bucket. Each entry counts **once** regardless of whether + /// failed buffer entry → `failed`, a persisted one → `persisted`, and a + /// Debug (transient) override → nothing at all (D7). Each entry counts + /// **at most once** regardless of whether /// a slot row survives at its path (a removed map entry still counts) — /// prefix-dirty on ancestor composites is display state, never an /// additional count. @@ -347,6 +356,36 @@ impl<'a> SlotEditJoin<'a> { slots + assets } + /// Active **Debug** overrides addressed to `node` — the count D8's + /// node-card marking reads (tier b). + /// + /// The same entries [`crate::ProjectController::clear_node_debug_edits`] + /// sweeps and [`Self::dirty_summary_for_node`] deliberately counts as + /// nothing: transient by nature, so never dirty (D7), but very much + /// *active*, which is exactly what the marking announces. + pub(in crate::app::project) fn debug_overrides_for_node( + &self, + node: &ProjectNodeAddress, + ) -> usize { + self.debug_entries() + .filter(|entry| entry.address.node == *node) + .count() + } + + /// Active Debug overrides anywhere in the project — the count the global + /// "Debug active · N · Clear all" chip shows (D8 tier a). + pub(in crate::app::project) fn debug_override_count(&self) -> usize { + self.debug_entries().count() + } + + /// The join's Debug (transient-persistence) entries — one enumeration + /// behind both counts and the Clear sweep. + fn debug_entries(&self) -> impl Iterator> { + self.entries() + .into_iter() + .filter(|entry| entry.persistence == SlotPersistence::Transient) + } + /// Enumerate every asset body edit entry in the join — the single /// enumeration asset [`DirtySummary`] counting and the save panel's /// asset rows consume, mirroring [`Self::entries`] for slots. @@ -492,7 +531,8 @@ mod tests { fn dirty_summary_counts_entries_once_including_rowless_removals() { // One overlay removal at a path with no surviving row, one buffered // failed edit, one address present in both buffer and overlay: three - // entries, three counts — the buffer classification wins on overlap. + // entries — the buffer classification wins on overlap, and the Debug + // (transient) one counts in no bucket (D7). let buffer = BTreeMap::from([ ( at("entries[b]"), @@ -514,10 +554,19 @@ mod tests { join.dirty_summary_for_node(&node()), DirtySummary { persisted: 1, - transient: 1, failed: 1, } ); + // The Debug entry is still an ENTRY (Clear enumerates it) — it just + // carries no dirty weight. + let entries = join.entries(); + assert_eq!(entries.len(), 3); + let debug_entry = entries + .iter() + .find(|entry| *entry.address == at("brightness")) + .expect("the transient address is an entry"); + assert_eq!(debug_entry.persistence, SlotPersistence::Transient); + assert!(debug_entry.summary.is_clean()); assert!( join.dirty_summary_for_node( &ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap() @@ -527,6 +576,59 @@ mod tests { ); } + #[test] + fn debug_overrides_count_on_their_own_channel() { + // Two Debug overrides on this node, one persisted edit, one Debug + // override on ANOTHER node: the debug channel counts three overall + // and two here, while the dirty summary sees only the persisted one. + let other = ProjectNodeAddress::parse("/demo.project/clock.clock").unwrap(); + let buffer = BTreeMap::from([(at("brightness"), PendingEdit::pending(LpValue::F32(0.9)))]); + let overlay = BTreeMap::from([ + ( + at("controls.rate"), + SlotEditOp::AssignValue(LpValue::F32(2.0)), + ), + ( + at("controls.running"), + SlotEditOp::AssignValue(LpValue::Bool(false)), + ), + ( + ProjectSlotAddress::new( + other.clone(), + ProjectSlotRoot::def(), + SlotPath::parse("controls.rate").unwrap(), + ), + SlotEditOp::AssignValue(LpValue::F32(0.5)), + ), + ]); + let persistence = BTreeMap::from([ + (at("brightness"), SlotPersistence::Persisted), + (at("controls.rate"), SlotPersistence::Transient), + (at("controls.running"), SlotPersistence::Transient), + ( + ProjectSlotAddress::new( + other.clone(), + ProjectSlotRoot::def(), + SlotPath::parse("controls.rate").unwrap(), + ), + SlotPersistence::Transient, + ), + ]); + let join = SlotEditJoin::new(&buffer, overlay, persistence); + + assert_eq!(join.debug_override_count(), 3); + assert_eq!(join.debug_overrides_for_node(&node()), 2); + assert_eq!(join.debug_overrides_for_node(&other), 1); + assert_eq!( + join.dirty_summary_for_node(&node()), + DirtySummary { + persisted: 1, + failed: 0, + }, + "the debug channel never leaks into the dirty summary (D7)" + ); + } + #[test] fn empty_join_reads_clean_everywhere() { let join = SlotEditJoin::empty(); @@ -535,6 +637,8 @@ mod tests { assert!(!join.overlay_dirty(&at("entries[a]"))); assert_eq!(join.state_under(&at("entries")), None); assert!(join.dirty_summary_for_node(&node()).is_clean()); + assert_eq!(join.debug_override_count(), 0); + assert_eq!(join.debug_overrides_for_node(&node()), 0); assert!(join.asset_entries().is_empty()); assert!(join.unmapped_asset_dirty_summary().is_clean()); } @@ -566,7 +670,6 @@ mod tests { let one_persisted = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!(join.dirty_summary_for_node(&node()), one_persisted); @@ -609,7 +712,6 @@ mod tests { join.dirty_summary_for_node(&node()), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs index 765094982..298629630 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_op.rs @@ -55,6 +55,14 @@ pub enum SlotEditOp { /// Discard the pending edit for the slot at `address`, locally and on /// the server overlay. Revert { address: ProjectSlotAddress }, + /// **Clear** the Debug override at `address` — the per-value scope of the + /// Clear verb (D7). Mechanically identical to [`Self::Revert`] (one + /// `RemoveSlotEdit` at the address): a Debug slot has no durable authored + /// value underneath, so removing the overlay entry returns the slot to its + /// shape default. It exists as its own variant because the *verb* differs + /// — Debug values are cleared, never "reverted" or "reset" — and the + /// action's label/summary follow the op. + Clear { address: ProjectSlotAddress }, } impl SlotEditOp { @@ -65,7 +73,8 @@ impl SlotEditOp { | Self::EnsurePresent { address } | Self::RemoveValue { address } | Self::MoveEntry { address, .. } - | Self::Revert { address } => address, + | Self::Revert { address } + | Self::Clear { address } => address, } } } @@ -98,6 +107,11 @@ impl ControllerOp for SlotEditOp { "Discard the pending edit for this slot.", ActionPriority::Secondary, ), + Self::Clear { .. } => ActionMeta::new( + "Clear", + "Clear this debug override; the slot returns to its default.", + ActionPriority::Secondary, + ), } } @@ -163,6 +177,9 @@ mod tests { SlotEditOp::Revert { address: test_address(), }, + SlotEditOp::Clear { + address: test_address(), + }, ]; for op in ops { @@ -176,4 +193,17 @@ mod tests { assert_eq!(op.address(), &test_address()); } } + + #[test] + fn debug_slots_are_cleared_never_reverted_or_reset() { + // D7 vocabulary: the per-value Clear scope says "Clear", and no + // Debug-facing wording leaks "Revert"/"Reset". + let meta = SlotEditOp::Clear { + address: test_address(), + } + .default_action_meta(); + assert_eq!(meta.label, "Clear"); + assert!(!meta.summary.contains("Revert")); + assert!(!meta.summary.contains("Reset")); + } } diff --git a/lp-app/lpa-studio-core/src/app/project/ui_affordance.rs b/lp-app/lpa-studio-core/src/app/project/ui_affordance.rs index f91d8ed29..91fce1783 100644 --- a/lp-app/lpa-studio-core/src/app/project/ui_affordance.rs +++ b/lp-app/lpa-studio-core/src/app/project/ui_affordance.rs @@ -27,9 +27,17 @@ pub enum UiAffordance { /// Genuine in-flight activity (sync, save, provision, an edit awaiting /// its ack). Steady-state "running" is `Good` status and never Busy. Busy, - /// Live-only (transient) edits in the subtree; blue, never written by - /// Save. - Live, + /// **Debug** overrides are active on the surface (D8/D9): transient + /// diagnostics/authoring values with no durable value underneath. + /// + /// [`Self::from_dirty`] never produces this — since D7 a Debug value is + /// not dirty and has no bucket in [`DirtySummary`], so it must not tint a + /// header wash or announce as pending work. Its source is the separate + /// debug-override count ([`Self::from_debug_overrides`]), which the debug + /// section, the node-card marking, and the global "Debug active" chip + /// read. The web layer is the ONE place that turns this variant into + /// pixels (attention-orange + hazard stripes) — change the look there. + Debug, /// Unsaved persisted edits in the subtree; yellow edit glyph. Unsaved, /// Needs attention: the surface's own status is failing (error or @@ -62,19 +70,27 @@ impl UiAffordance { } /// Project the subtree dirty summary onto the affordance vocabulary - /// (failed > unsaved > live, the established dirty precedence). + /// (failed > unsaved, the established dirty precedence). Debug overrides + /// are absent from the summary (D7), so they never announce here. pub fn from_dirty(dirty: &DirtySummary) -> Self { if dirty.failed > 0 { Self::Error } else if dirty.persisted > 0 { Self::Unsaved - } else if dirty.transient > 0 { - Self::Live } else { Self::Info } } + /// Project a debug-override count onto the affordance vocabulary — the + /// separate channel D8 needs, deliberately NOT folded into + /// [`Self::merged`]: a debug override is not pending work, so it never + /// masks (or is masked by) the dirty/status rollup. Surfaces that mark + /// debug territory ask for this explicitly. + pub fn from_debug_overrides(count: usize) -> Self { + if count > 0 { Self::Debug } else { Self::Info } + } + /// Priority merge: the more important affordance wins. pub fn merge(self, other: Self) -> Self { self.max(other) @@ -95,8 +111,8 @@ mod tests { #[test] fn enum_order_is_the_confirmed_priority() { assert!(UiAffordance::Error > UiAffordance::Unsaved); - assert!(UiAffordance::Unsaved > UiAffordance::Live); - assert!(UiAffordance::Live > UiAffordance::Busy); + assert!(UiAffordance::Unsaved > UiAffordance::Debug); + assert!(UiAffordance::Debug > UiAffordance::Busy); assert!(UiAffordance::Busy > UiAffordance::Info); } @@ -137,29 +153,43 @@ mod tests { UiAffordance::Info ); assert_eq!( - UiAffordance::from_dirty(&dirty(0, 2, 0)), - UiAffordance::Live - ); - assert_eq!( - UiAffordance::from_dirty(&dirty(1, 2, 0)), + UiAffordance::from_dirty(&dirty(1, 0)), UiAffordance::Unsaved ); + assert_eq!(UiAffordance::from_dirty(&dirty(1, 1)), UiAffordance::Error); + } + + #[test] + fn the_debug_channel_is_separate_from_the_dirty_rollup() { + // D8: the debug count has its own projection; it never enters + // `merged`, so a debug-only surface still reads Info there. + assert_eq!(UiAffordance::from_debug_overrides(0), UiAffordance::Info); + assert_eq!(UiAffordance::from_debug_overrides(3), UiAffordance::Debug); assert_eq!( - UiAffordance::from_dirty(&dirty(1, 2, 1)), - UiAffordance::Error + UiAffordance::merged(UiStatusKind::Good, &DirtySummary::clean()), + UiAffordance::Info ); } + #[test] + fn a_debug_only_project_never_tints_a_header() { + // D7: debug overrides leave the summary clean, so every hierarchy + // surface stays quiet — no wash, no announced trigger. + let debug_only = DirtySummary::clean(); + assert_eq!(UiAffordance::from_dirty(&debug_only), UiAffordance::Info); + assert!(!UiAffordance::merged(UiStatusKind::Good, &debug_only).is_announced()); + } + #[test] fn merged_takes_the_max_of_status_and_edits() { // Unsaved edits outrank an in-flight status… assert_eq!( - UiAffordance::merged(UiStatusKind::Working, &dirty(1, 0, 0)), + UiAffordance::merged(UiStatusKind::Working, &dirty(1, 0)), UiAffordance::Unsaved ); // …but an error status is never masked by a dirty wash. assert_eq!( - UiAffordance::merged(UiStatusKind::Error, &dirty(1, 1, 0)), + UiAffordance::merged(UiStatusKind::Error, &dirty(1, 0)), UiAffordance::Error ); // A clean, healthy surface stays silent. @@ -169,11 +199,7 @@ mod tests { ); } - fn dirty(persisted: usize, transient: usize, failed: usize) -> DirtySummary { - DirtySummary { - persisted, - transient, - failed, - } + fn dirty(persisted: usize, failed: usize) -> DirtySummary { + DirtySummary { persisted, failed } } } diff --git a/lp-app/lpa-studio-core/src/app/project/ui_pending_edit.rs b/lp-app/lpa-studio-core/src/app/project/ui_pending_edit.rs index 69722adbc..461af42af 100644 --- a/lp-app/lpa-studio-core/src/app/project/ui_pending_edit.rs +++ b/lp-app/lpa-studio-core/src/app/project/ui_pending_edit.rs @@ -9,7 +9,8 @@ use crate::UiAction; /// edit-state join `DirtySummary` counting uses, so the list length per /// [`UiPendingEditPhase`] equals the summary's bucket counts by construction /// (one entry per buffer/overlay address, never per slot row — a removed map -/// entry with no surviving row is still listed). Entries carry the saved +/// entry with no surviving row is still listed; a Debug override counts in no +/// bucket and is likewise listed nowhere). Entries carry the saved /// (base) value they replace where the mirror knows it ([`Self::old_value`] /// — editing-model ADR follow-up (b), display half). #[derive(Clone, Debug, PartialEq)] @@ -93,12 +94,14 @@ pub enum UiPendingEditKind { /// Save-panel section for a pending edit — the entry-level mirror of the /// [`crate::DirtySummary`] buckets. +/// +/// There is no live/transient section (D7): a Debug override is not dirty, so +/// it never reaches the change list at all — the controller drops entries +/// whose summary is clean. #[derive(Clone, Debug, PartialEq)] pub enum UiPendingEditPhase { /// Written to project files on save. Persisted, - /// Live-only (transient persistence); survives save as a pending edit. - Live, /// The buffered edit failed (rejected or transport error). Failed { /// Human-readable rejection or transport reason. diff --git a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs index e6fcf6cf6..38da0ca30 100644 --- a/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs +++ b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs @@ -619,7 +619,7 @@ impl StudioServerClient { Ok((node_def_artifacts(&inventory.value), logs)) } - /// Commit the pending-edit overlay to artifact storage. Post-P2, transient + /// Commit the pending-edit overlay to artifact storage. Post-P2, Debug /// entries survive the commit as pending overlay edits. pub async fn project_overlay_commit( &mut self, diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs index 51e101fb8..a4650ce0c 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_actor_tests.rs @@ -727,6 +727,9 @@ fn planned_slot_ops(plan: &CommandPlan) -> Vec<(String, Option)> { Some(crate::SlotEditOp::Revert { address }) => { (format!("revert:{}", address.path), None) } + Some(crate::SlotEditOp::Clear { address }) => { + (format!("clear:{}", address.path), None) + } None => ("other".to_string(), None), }) .collect() diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_agent_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_agent_e2e_tests.rs index 5dbd5a05c..c1d07586e 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_agent_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_agent_e2e_tests.rs @@ -588,7 +588,7 @@ fn declared_orphan_is_repaired_by_upsert_param_end_to_end() { assert_eq!(content["engine"]["status"], "ok", "{content}"); // Everything is STAGED, not saved: the save panel counts pending edits. - let (persisted, _transient) = editor_dirty(&snapshot); + let (persisted, _failed) = editor_dirty(&snapshot); assert!(persisted > 0, "the upsert rides the Save-gated overlay"); // The knob appears on the shader face via the EXISTING panel diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index 7eaacd5a2..a9cf202ae 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -22,13 +22,13 @@ use crate::core::log::{LogClock, LogFilter, LogRing}; use crate::core::notice::UiNotices; use crate::{ AssetContentFetchOp, AssetEditOp, ConnectFlowState, Controller, ControllerContext, - DeviceController, DeviceOp, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, - PlaylistActivateOp, ProjectConnectResult, ProjectController, ProjectEditRun, ProjectOp, - ProjectRefreshOutcome, ProjectState, ProjectSyncRun, RuntimePayload, RuntimePool, - ServerFailureKind, ServerSnapshot, ServerState, SlotEditOp, StudioSnapshot, UiAction, - UiActions, UiActivityView, UiError, UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, UiNotice, - UiPaneView, UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, UxUpdate, - UxUpdateSink, + DeviceController, DeviceOp, NodeClearDebugOp, NodeCopyOp, NodeCreateOp, NodePasteOp, + NodeRemoveOp, NodeRevertOp, PlaylistActivateOp, ProjectConnectResult, ProjectController, + ProjectEditRun, ProjectOp, ProjectRefreshOutcome, ProjectState, ProjectSyncRun, RuntimePayload, + RuntimePool, ServerFailureKind, ServerSnapshot, ServerState, SlotEditOp, StudioSnapshot, + UiAction, UiActions, UiActivityView, UiError, UiLogDraft, UiLogEntry, UiLogLevel, UiLogOrigin, + UiNotice, UiPaneView, UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, + UxUpdate, UxUpdateSink, }; /// How often the quiet PortHeld retry re-attempts the granted attach @@ -2025,6 +2025,10 @@ impl StudioController { let op = action.into_op::()?; return self.execute_node_revert_op(op).await; } + if action.op_as::().is_some() { + let op = action.into_op::()?; + return self.execute_node_clear_debug_op(op).await; + } if action.op_as::().is_some() { let op = action.into_op::()?; return self.execute_playlist_activate_op(op).await; @@ -2897,6 +2901,13 @@ impl StudioController { }; self.record_project_edit_run(run) } + ProjectOp::ClearDebugEdits => { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.clear_debug_edits(server).await + }; + self.record_project_edit_run(run) + } } } @@ -3152,6 +3163,16 @@ impl StudioController { self.record_project_edit_run(run) } + /// The per-node scope of the Clear verb (D7): only this subtree's Debug + /// overrides go, persisted edits stay. + async fn execute_node_clear_debug_op(&mut self, op: NodeClearDebugOp) -> UiResult { + let run = { + let server = self.pool.lens_session_mut()?.client_mut()?; + self.project.clear_node_debug_edits(server, &op.node).await + }; + self.record_project_edit_run(run) + } + /// Playlist entry-strip click: dispatch the activate-entry runtime /// command (the non-overlay command channel). Quiet on acceptance — /// the ACTIVE placard follows via the tightened refresh ticks; a diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs index b34367332..4c0d32499 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs @@ -76,13 +76,34 @@ fn simulator_session_edit_save_and_revert_end_to_end() { assert!(!root_slot("nodes").state.editable); assert!(root_slot("name").state.editable); + // D3/D4 over the real wire: the clock's three `controls.*` Debug fields + // land FLAT in the node's Debug section — no "Controls" record row in + // the settings section, no nesting inside the Debug one — while the + // clock's persisted settings stay where they were. + let clock_sections = node_sections(&snapshot, "/edit_e2e.show/clock.clock"); + let debug_labels = section_slot_labels(&clock_sections, |section| { + matches!(section, UiNodeSection::DebugSlots(_)) + }); + assert_eq!( + debug_labels, + vec!["Running", "Rate", "Scrub offset seconds"], + "every Debug field renders directly in the Debug section (D4 flattening)" + ); + let settings_labels = section_slot_labels(&clock_sections, |section| { + matches!(section, UiNodeSection::ConfigSlots(_)) + }); + assert!( + !settings_labels.iter().any(|label| label == "Controls"), + "the Debug section replaces the old `controls` record row, never duplicates it: {settings_labels:?}" + ); + let rate = find_slot(&snapshot, "controls.rate"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Clean); - assert!(rate.state.live, "clock rate is a transient (live) control"); + assert!(rate.state.debug, "clock rate is a Debug control"); let rate_address = rate.address.clone().expect("rate slot carries an address"); let color_order = find_slot(&snapshot, "color_order"); assert_eq!(color_order.state.dirty, UiNodeDirtyState::Clean); - assert!(!color_order.state.live, "color order is a persisted slot"); + assert!(!color_order.state.debug, "color order is a persisted slot"); let color_order_address = color_order .address .clone() @@ -111,20 +132,20 @@ fn simulator_session_edit_save_and_revert_end_to_end() { ); let rate = find_slot(&snapshot, "controls.rate"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); - assert!(rate.state.live); + assert!(rate.state.debug); assert_eq!(slot_value_display(rate), "2"); let color_order = find_slot(&snapshot, "color_order"); assert_eq!(color_order.state.dirty, UiNodeDirtyState::Dirty); - assert!(!color_order.state.live); + assert!(!color_order.state.debug); assert_eq!(slot_value_display(color_order), "rgb"); assert_eq!( editor_dirty(&snapshot), - (1, 1), - "one persisted and one transient slot are dirty" + (1, 0), + "only the persisted slot is dirty; the debug rate override is not (D7)" ); // Save: the persisted color-order edit commits to fixture.json; the - // transient rate edit stays pending (dirty-live), clock.json untouched. + // debug rate override stays pending (live), clock.json untouched. handle.tx.send(project_action(ProjectOp::SaveOverlay)); drive(actor.run_one_batch_for_test()); // Pull a refresh so the synced view reflects the committed def. @@ -140,13 +161,13 @@ fn simulator_session_edit_save_and_revert_end_to_end() { let clock_json = read_project_file(&server, "clock.json"); assert!( !clock_json.contains("\"rate\":2"), - "clock.json must not gain the transient rate edit: {clock_json}" + "clock.json must not gain the debug rate override: {clock_json}" ); let rate = find_slot(&snapshot, "controls.rate"); assert_eq!( rate.state.dirty, UiNodeDirtyState::Dirty, - "transient edit survives the save as dirty-live" + "the debug override survives the save, live on the project" ); assert_eq!(slot_value_display(rate), "2"); let color_order = find_slot(&snapshot, "color_order"); @@ -156,7 +177,11 @@ fn simulator_session_edit_save_and_revert_end_to_end() { "rgb", "committed value synced back" ); - assert_eq!(editor_dirty(&snapshot), (0, 1)); + assert_eq!( + editor_dirty(&snapshot), + (0, 0), + "with the persisted edit written the project reads clean — the surviving debug override is not dirty" + ); // Revert all: the overlay clears, every slot returns to Clean, and the // *gated* refresh (since = last known revision) delivers the reverted @@ -993,10 +1018,10 @@ fn save_after_home_open_pulls_the_edit_into_the_library() { } #[test] -fn per_slot_transient_reset_reverts_value_through_gated_refresh() { - // The per-slot Reset affordance on a transient control (the clock `rate` - // slider): SetValue then `SlotEditOp::Revert` must bring the DTO back to - // the authored default through a *gated* refresh, without a reconnect. +fn per_slot_clear_restores_the_debug_default_through_gated_refresh() { + // The per-slot Clear affordance on a debug control (the clock `rate` + // slider): SetValue then `SlotEditOp::Clear` must bring the DTO back to + // the default through a *gated* refresh, without a reconnect. // The intermediate refresh below syncs the mutated def into the view // first, so the final assertion can only pass if the refresh after the // revert delivers the *reverted* def root (monotonic revisions, studio @@ -1022,7 +1047,7 @@ fn per_slot_transient_reset_reverts_value_through_gated_refresh() { assert_eq!(slot_value_display(rate), "1"); let rate_address = rate.address.clone().expect("rate slot carries an address"); - // Edit the transient control, then pull a gated refresh so the synced + // Edit the debug control, then pull a gated refresh so the synced // view itself holds the edited value. handle .tx @@ -1035,20 +1060,21 @@ fn per_slot_transient_reset_reverts_value_through_gated_refresh() { assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); assert_eq!(slot_value_display(rate), "2"); - // Per-slot reset: revert the rate edit, then a gated refresh must show - // the authored default again. - handle.tx.send(revert_action(rate_address)); + // Per-value Clear: drop the debug override, then a gated refresh must + // show the default again. For a Debug slot the authored default IS the + // shape default, so Clear and reset-to-authored coincide. + handle.tx.send(clear_action(rate_address)); drive(actor.run_one_batch_for_test()); handle.tx.send(project_action(ProjectOp::RefreshProject)); drive(actor.run_one_batch_for_test()); - let snapshot = view.try_recv().expect("revert + refresh emit a snapshot"); + let snapshot = view.try_recv().expect("clear + refresh emit a snapshot"); let rate = find_slot(&snapshot, "controls.rate"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Clean); assert_eq!( slot_value_display(rate), "1", - "per-slot reset restores the authored default through the gated refresh" + "per-value Clear restores the default through the gated refresh" ); } @@ -1709,7 +1735,7 @@ fn special_editor_values_round_trip_save_and_revert() { let render_size = find_slot(&snapshot, "render_size"); assert_eq!(render_size.state.dirty, UiNodeDirtyState::Dirty); - assert!(!render_size.state.live, "render_size is a persisted slot"); + assert!(!render_size.state.debug, "render_size is a persisted slot"); let transform = find_slot(&snapshot, "transform"); assert_eq!(transform.state.dirty, UiNodeDirtyState::Dirty); assert_eq!(editor_dirty(&snapshot), (2, 0)); @@ -1970,6 +1996,62 @@ fn shader_asset_editor_fetch_apply_save_and_revert_end_to_end() { ); } +#[test] +fn the_output_card_gets_a_debug_section_for_test_pattern() { + // P5, over the real wire: `OutputDef.test_pattern` is `SlotRole::Debug`, + // and NOTHING output-specific exists in the UI layer — the same + // role-keyed partition that gives the Clock its Debug section (P3) gives + // the output card one, with the toggle in it. `endpoint` stays a Setting. + let server = Rc::new(RefCell::new(asset_e2e_server())); + let io = InProcessServerIo { + server: Rc::clone(&server), + inbox: Rc::new(RefCell::new(VecDeque::new())), + sent: Rc::new(RefCell::new(Vec::new())), + }; + let client = StudioServerClient::from_io_for_test("in-process", Box::new(io)); + let controller = StudioController::connected_with_client_for_test(client); + let (mut actor, handle) = StudioActor::new(controller, |_| core::future::ready(())); + let mut view = handle.view; + + handle + .tx + .send(project_action(ProjectOp::ConnectRunningProject)); + drive(actor.run_one_batch_for_test()); + let snapshot = view.try_recv().expect("connect emits a snapshot"); + + let sections = node_sections(&snapshot, "/edit_e2e.show/output.output"); + assert_eq!( + section_slot_labels(§ions, |section| matches!( + section, + UiNodeSection::DebugSlots(_) + )), + vec!["Test pattern"], + "the output's one Debug field renders in the Debug section" + ); + assert!( + !section_slot_labels(§ions, |section| matches!( + section, + UiNodeSection::ConfigSlots(_) + )) + .iter() + .any(|label| label == "Test pattern"), + "a Debug field is never also a Setting row" + ); + + let test_pattern = find_slot(&snapshot, "test_pattern"); + assert!(test_pattern.state.debug, "test_pattern is a Debug slot"); + assert!( + test_pattern.state.editable, + "a Debug slot is writable — that is the whole point of the toggle" + ); + assert_eq!(test_pattern.state.dirty, UiNodeDirtyState::Clean); + let endpoint = find_slot(&snapshot, "endpoint"); + assert!( + !endpoint.state.debug, + "the endpoint is authored config, not debug" + ); +} + #[test] fn successive_shader_applies_each_reach_the_engine() { // Regression: an overlay→overlay body change (second Apply before any @@ -2088,7 +2170,9 @@ pub(crate) fn find_asset_editor(view: &UiStudioView) -> crate::UiAssetEditor { } fn in_sections(sections: &[UiNodeSection]) -> Option { sections.iter().find_map(|section| match section { - UiNodeSection::AssetSlots(slots) | UiNodeSection::ConfigSlots(slots) => in_slots(slots), + UiNodeSection::AssetSlots(slots) + | UiNodeSection::ConfigSlots(slots) + | UiNodeSection::DebugSlots(slots) => in_slots(slots), _ => None, }) } @@ -2311,6 +2395,15 @@ fn revert_action(address: crate::ProjectSlotAddress) -> StudioCommand { )) } +/// The per-value scope of the Clear verb (D7) — same mechanism as +/// `revert_action`, the vocabulary debug slots use. +fn clear_action(address: crate::ProjectSlotAddress) -> StudioCommand { + StudioCommand::Action(UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + SlotEditOp::Clear { address }, + )) +} + fn ensure_present_action(address: crate::ProjectSlotAddress) -> StudioCommand { StudioCommand::Action(UiAction::from_op( ControllerId::new(ProjectController::NODE_ID), @@ -2375,9 +2468,45 @@ fn project_editor(view: &UiStudioView) -> &crate::ProjectEditorView { .expect("project editor pane") } +/// The editor DTO's dirty counts as `(persisted, failed)`. There is no debug +/// bucket (D7): a debug override never enters the summary at all. pub(crate) fn editor_dirty(view: &UiStudioView) -> (usize, usize) { let editor = project_editor(view); - (editor.dirty.persisted, editor.dirty.transient) + (editor.dirty.persisted, editor.dirty.failed) +} + +/// The main-tab sections of one workspace card, by node address. +pub(crate) fn node_sections(view: &UiStudioView, node_id: &str) -> Vec { + let editor = project_editor(view); + let node = editor + .nodes + .iter() + .find(|node| node.node_id == node_id) + .unwrap_or_else(|| panic!("workspace card {node_id} should exist")); + match &node.tabs[0].body { + UiNodeTabBody::Sections(sections) => sections.clone(), + UiNodeTabBody::Text { .. } => panic!("expected node sections"), + } +} + +/// Top-level row labels of the first section matching `pick` (empty when the +/// node renders no such section). +pub(crate) fn section_slot_labels( + sections: &[UiNodeSection], + pick: impl Fn(&UiNodeSection) -> bool, +) -> Vec { + sections + .iter() + .find(|section| pick(section)) + .map(|section| match section { + UiNodeSection::ConfigSlots(slots) + | UiNodeSection::DebugSlots(slots) + | UiNodeSection::AssetSlots(slots) => { + slots.iter().map(|slot| slot.label.clone()).collect() + } + _ => Vec::new(), + }) + .unwrap_or_default() } /// Find a config slot anywhere in the editor DTO tree by its address path. @@ -2412,9 +2541,9 @@ fn try_find_slot<'a>(view: &'a UiStudioView, path: &str) -> Option<&'a UiConfigS fn in_sections<'a>(sections: &'a [UiNodeSection], path: &str) -> Option<&'a UiConfigSlot> { sections.iter().find_map(|section| match section { - UiNodeSection::ConfigSlots(slots) | UiNodeSection::AssetSlots(slots) => { - in_slots(slots, path) - } + UiNodeSection::ConfigSlots(slots) + | UiNodeSection::AssetSlots(slots) + | UiNodeSection::DebugSlots(slots) => in_slots(slots, path), _ => None, }) } diff --git a/lp-app/lpa-studio-core/src/app/studio/unsaved_changes.rs b/lp-app/lpa-studio-core/src/app/studio/unsaved_changes.rs index 9f05a7cc8..aaabd33a0 100644 --- a/lp-app/lpa-studio-core/src/app/studio/unsaved_changes.rs +++ b/lp-app/lpa-studio-core/src/app/studio/unsaved_changes.rs @@ -14,10 +14,11 @@ //! `Home` while the editor is open detaches the lens and every runtime //! session survives, edits included. //! -//! Only **persisted** edits count. Live/transient edits apply to the -//! running project and are explicitly never written by Save, so warning -//! about them would train users to dismiss the dialog. Failed edits are -//! not pending work either — they never reached the overlay. +//! Only **persisted** edits count — and since D7 that is structural, not a +//! filter applied here: Debug overrides are transient by nature, so they +//! never enter the [`DirtySummary`] at all. Warning about them would train +//! users to dismiss the dialog. Failed edits are counted but are not pending +//! work either — they never reached the overlay. //! //! The browser plumbing lives in the web edge //! (`lpa-studio-web/src/unsaved_gate.rs`); core stays sans-IO and only @@ -39,8 +40,8 @@ mod tests { #[test] fn only_persisted_edits_gate() { - assert!(has_unsaved_work(&dirty(1, 0, 0))); - assert!(has_unsaved_work(&dirty(3, 2, 1))); + assert!(has_unsaved_work(&dirty(1, 0))); + assert!(has_unsaved_work(&dirty(3, 1))); } #[test] @@ -49,24 +50,25 @@ mod tests { } #[test] - fn live_only_edits_never_gate() { - // Live controls apply to the running project and are never written - // by Save — warning about them would cry wolf on every knob turn. - assert!(!has_unsaved_work(&dirty(0, 5, 0))); + fn debug_only_edits_are_absent_from_the_summary_and_never_gate() { + // D7 made this structural rather than a filter applied here: a + // project whose ONLY pending edits are debug overrides produces the + // clean summary — there is no live bucket left for the gate to + // ignore. (`DirtySummary::for_slot` proves the classification side; + // this asserts the gate's half: clean means no warning.) + let debug_only = DirtySummary::clean(); + assert!(debug_only.is_clean()); + assert!(!has_unsaved_work(&debug_only)); } #[test] fn failed_only_edits_never_gate() { // A rejected edit is not pending work: it never reached the // overlay, so there is nothing for Save to write. - assert!(!has_unsaved_work(&dirty(0, 0, 2))); + assert!(!has_unsaved_work(&dirty(0, 2))); } - fn dirty(persisted: usize, transient: usize, failed: usize) -> DirtySummary { - DirtySummary { - persisted, - transient, - failed, - } + fn dirty(persisted: usize, failed: usize) -> DirtySummary { + DirtySummary { persisted, failed } } } diff --git a/lp-app/lpa-studio-core/src/core/view/view_content.rs b/lp-app/lpa-studio-core/src/core/view/view_content.rs index a992b31a9..0b5a6b553 100644 --- a/lp-app/lpa-studio-core/src/core/view/view_content.rs +++ b/lp-app/lpa-studio-core/src/core/view/view_content.rs @@ -102,6 +102,9 @@ impl UiViewContent { crate::UiNodeSection::ConfigSlots(items) => { format!("config slots: {}", items.len()) } + crate::UiNodeSection::DebugSlots(items) => { + format!("debug slots: {}", items.len()) + } crate::UiNodeSection::AssetSlots(items) => { format!("asset slots: {}", items.len()) } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 0eae0f890..8bb69246e 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -65,18 +65,18 @@ pub use app::preview_host::{ }; pub use app::project::{ AgentEngineStatus, AssetContentFetchOp, AssetEditOp, DirtySummary, LoadedProjectChoice, - MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeController, NodeControllerState, - NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, NodeUiOp, PendingAssetEdit, - PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, ProjectAssetContentRun, - ProjectConnectResult, ProjectController, ProjectEditRun, ProjectEditorOp, ProjectEditorTarget, - ProjectEditorView, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, - ProjectNodeStatusView, ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, - ProjectProductSubscriptionIntent, ProjectRefreshOutcome, ProjectRuntimeSummary, - ProjectSlotAddress, ProjectSlotRoot, ProjectSnapshot, ProjectState, ProjectSync, - ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, SlotController, SlotControllerState, - SlotEditOp, SlotKind, UiAddNodeMenu, UiAddNodeMenuEntry, UiAffordance, UiAssetContent, - UiAssetContentBody, UiAttachTarget, UiNodeRemovePreflight, UiPendingEdit, UiPendingEditKind, - UiPendingEditPhase, UiShaderError, + MAX_ASSET_BODY_BYTES, NodeCardDrawer, NodeCardUiState, NodeClearDebugOp, NodeController, + NodeControllerState, NodeCopyOp, NodeCreateOp, NodePasteOp, NodeRemoveOp, NodeRevertOp, + NodeUiOp, PendingAssetEdit, PendingEdit, PendingEditOp, PendingEditPhase, PlaylistActivateOp, + ProjectAssetContentRun, ProjectConnectResult, ProjectController, ProjectEditRun, + ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, ProjectInventorySummary, + ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTarget, + ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectProductSubscriptionIntent, + ProjectRefreshOutcome, ProjectRuntimeSummary, ProjectSlotAddress, ProjectSlotRoot, + ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, + ProjectSyncSummary, SlotController, SlotControllerState, SlotEditOp, SlotKind, UiAddNodeMenu, + UiAddNodeMenuEntry, UiAffordance, UiAssetContent, UiAssetContentBody, UiAttachTarget, + UiNodeRemovePreflight, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiShaderError, }; pub use app::rich_object::{ RichChip, RichLine, RichObjectView, RichRollup, RichSection, RichWeight, diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 070a04c30..d41c824a0 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -1283,9 +1283,6 @@ .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-error-bg\)_0\%\,var\(--studio-status-error-bg\)_34\%\,transparent_100\%\)\] { background-image: linear-gradient(270deg,var(--studio-status-error-bg) 0%,var(--studio-status-error-bg) 34%,transparent 100%); } - .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-live-bg\)_0\%\,var\(--studio-status-live-bg\)_34\%\,transparent_100\%\)\] { - background-image: linear-gradient(270deg,var(--studio-status-live-bg) 0%,var(--studio-status-live-bg) 34%,transparent 100%); - } .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-warning-bg\)_0\%\,var\(--studio-status-warning-bg\)_34\%\,transparent_100\%\)\] { background-image: linear-gradient(270deg,var(--studio-status-warning-bg) 0%,var(--studio-status-warning-bg) 34%,transparent 100%); } @@ -1852,9 +1849,6 @@ .tw\:\[--studio-tree-dirty-bg\:var\(--studio-status-error-bg\)\] { --studio-tree-dirty-bg: var(--studio-status-error-bg); } - .tw\:\[--studio-tree-dirty-bg\:var\(--studio-status-live-bg\)\] { - --studio-tree-dirty-bg: var(--studio-status-live-bg); - } .tw\:\[--studio-tree-dirty-bg\:var\(--studio-status-warning-bg\)\] { --studio-tree-dirty-bg: var(--studio-status-warning-bg); } @@ -1864,12 +1858,6 @@ --tw-color-card-muted: color-mix(in oklab,var(--studio-status-error-bg) 55%,var(--studio-color-surface-muted)); } } - .tw\:\[--tw-color-card-muted\:color-mix\(in_oklab\,var\(--studio-status-live-bg\)_55\%\,var\(--studio-color-surface-muted\)\)\] { - --tw-color-card-muted: var(--studio-status-live-bg); - @supports (color: color-mix(in lab, red, red)) { - --tw-color-card-muted: color-mix(in oklab,var(--studio-status-live-bg) 55%,var(--studio-color-surface-muted)); - } - } .tw\:\[--tw-color-card-muted\:color-mix\(in_oklab\,var\(--studio-status-warning-bg\)_55\%\,var\(--studio-color-surface-muted\)\)\] { --tw-color-card-muted: var(--studio-status-warning-bg); @supports (color: color-mix(in lab, red, red)) { @@ -1885,12 +1873,6 @@ --tw-color-card-subtle: color-mix(in oklab,var(--studio-status-error-bg) 55%,var(--studio-color-surface-subtle)); } } - .tw\:\[--tw-color-card-subtle\:color-mix\(in_oklab\,var\(--studio-status-live-bg\)_55\%\,var\(--studio-color-surface-subtle\)\)\] { - --tw-color-card-subtle: var(--studio-status-live-bg); - @supports (color: color-mix(in lab, red, red)) { - --tw-color-card-subtle: color-mix(in oklab,var(--studio-status-live-bg) 55%,var(--studio-color-surface-subtle)); - } - } .tw\:\[--tw-color-card-subtle\:color-mix\(in_oklab\,var\(--studio-status-warning-bg\)_55\%\,var\(--studio-color-surface-subtle\)\)\] { --tw-color-card-subtle: var(--studio-status-warning-bg); @supports (color: color-mix(in lab, red, red)) { @@ -1906,12 +1888,6 @@ --tw-color-card: color-mix(in oklab,var(--studio-status-error-bg) 55%,var(--studio-color-surface)); } } - .tw\:\[--tw-color-card\:color-mix\(in_oklab\,var\(--studio-status-live-bg\)_55\%\,var\(--studio-color-surface\)\)\] { - --tw-color-card: var(--studio-status-live-bg); - @supports (color: color-mix(in lab, red, red)) { - --tw-color-card: color-mix(in oklab,var(--studio-status-live-bg) 55%,var(--studio-color-surface)); - } - } .tw\:\[--tw-color-card\:color-mix\(in_oklab\,var\(--studio-status-warning-bg\)_55\%\,var\(--studio-color-surface\)\)\] { --tw-color-card: var(--studio-status-warning-bg); @supports (color: color-mix(in lab, red, red)) { diff --git a/lp-app/lpa-studio-web/src/app/affordance.rs b/lp-app/lpa-studio-web/src/app/affordance.rs index dc9f44fb5..4a72d616c 100644 --- a/lp-app/lpa-studio-web/src/app/affordance.rs +++ b/lp-app/lpa-studio-web/src/app/affordance.rs @@ -1,11 +1,20 @@ //! Consumer-side rendering of the core [`UiAffordance`] vocabulary. //! //! Core computes one affordance per hierarchy surface -//! (`UiAffordance::merged`, priority Error > Unsaved > Live > Busy > Info); +//! (`UiAffordance::merged`, priority Error > Unsaved > Debug > Busy > Info); //! this module is the ONE place that turns it into chrome — the detail //! trigger's glyph + tone (node header, project pane) and the sidebar tree //! row's small indicator. Status words and dirty counts never render here; //! they live in the popups. +//! +//! **The debug mapping seam (D9).** `UiAffordance::Debug` is a SEMANTIC +//! variant in `lpa-studio-core` — core never says "orange" or "stripes". +//! Every consumer-side translation of it happens in the match arms below +//! (`PaneTone::Debug`, `IconMenuTone::Debug`, `.lp-debug-indicator`), and the +//! pixels themselves live in one CSS block (`src/style.css`, the +//! `--studio-status-debug-*` tokens and the `.lp-debug-*` classes). Changing +//! how debug territory looks is an edit to that block; changing what it maps +//! to is an edit to these arms. use lpa_studio_core::{UiAffordance, UiStatusKind}; @@ -21,8 +30,8 @@ pub(crate) struct AffordanceStyle { /// The detail-trigger treatment for an affordance: a quiet "i" when there is /// nothing to announce (OK is not announced — no checkmark, no status -/// coloring), the edit pencil for unsaved (yellow) and live (blue) edits, -/// and the red warning glyph for the attention class. +/// coloring), the edit pencil for unsaved (yellow) and debug (hazard-orange) +/// edits, and the red warning glyph for the attention class. pub(crate) fn affordance_trigger_style(affordance: UiAffordance) -> AffordanceStyle { match affordance { UiAffordance::Info => AffordanceStyle { @@ -33,9 +42,9 @@ pub(crate) fn affordance_trigger_style(affordance: UiAffordance) -> AffordanceSt icon: StudioIconName::InfoBare, tone: IconMenuTone::Working, }, - UiAffordance::Live => AffordanceStyle { + UiAffordance::Debug => AffordanceStyle { icon: StudioIconName::Edited, - tone: IconMenuTone::Live, + tone: IconMenuTone::Debug, }, UiAffordance::Unsaved => AffordanceStyle { icon: StudioIconName::Edited, @@ -55,7 +64,7 @@ pub(crate) fn affordance_pane_tone(affordance: UiAffordance, status: UiStatusKin match affordance { UiAffordance::Info => status_pane_tone(status), UiAffordance::Busy => PaneTone::Working, - UiAffordance::Live => PaneTone::Live, + UiAffordance::Debug => PaneTone::Debug, UiAffordance::Unsaved => PaneTone::Warning, UiAffordance::Error => PaneTone::Error, } @@ -69,9 +78,7 @@ pub(crate) fn affordance_indicator_class(affordance: UiAffordance) -> Option<&'s UiAffordance::Busy => Some( "tw:inline-flex tw:h-4 tw:items-center tw:justify-center tw:text-status-working-foreground", ), - UiAffordance::Live => Some( - "tw:inline-flex tw:h-4 tw:items-center tw:justify-center tw:text-status-live-foreground", - ), + UiAffordance::Debug => Some("lp-debug-indicator"), UiAffordance::Unsaved => Some( "tw:inline-flex tw:h-4 tw:items-center tw:justify-center tw:text-status-warning-foreground", ), @@ -110,13 +117,13 @@ mod tests { assert_eq!(busy.icon, StudioIconName::InfoBare); assert_eq!(busy.tone, IconMenuTone::Working); - // Edits wear the pencil: yellow for unsaved, blue for live. + // Edits wear the pencil: yellow for unsaved, hazard for debug. let unsaved = affordance_trigger_style(UiAffordance::Unsaved); assert_eq!(unsaved.icon, StudioIconName::Edited); assert_eq!(unsaved.tone, IconMenuTone::Warning); - let live = affordance_trigger_style(UiAffordance::Live); - assert_eq!(live.icon, StudioIconName::Edited); - assert_eq!(live.tone, IconMenuTone::Live); + let debug = affordance_trigger_style(UiAffordance::Debug); + assert_eq!(debug.icon, StudioIconName::Edited); + assert_eq!(debug.tone, IconMenuTone::Debug); // Attention: the red warning glyph. let error = affordance_trigger_style(UiAffordance::Error); @@ -131,8 +138,8 @@ mod tests { PaneTone::Warning ); assert_eq!( - affordance_pane_tone(UiAffordance::Live, UiStatusKind::Good), - PaneTone::Live + affordance_pane_tone(UiAffordance::Debug, UiStatusKind::Good), + PaneTone::Debug ); assert_eq!( affordance_pane_tone(UiAffordance::Error, UiStatusKind::Good), @@ -158,7 +165,7 @@ mod tests { assert!(affordance_indicator_class(UiAffordance::Info).is_none()); for affordance in [ UiAffordance::Busy, - UiAffordance::Live, + UiAffordance::Debug, UiAffordance::Unsaved, UiAffordance::Error, ] { diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_pane.rs b/lp-app/lpa-studio-web/src/app/layout/studio_pane.rs index 731fbf070..b96293aa3 100644 --- a/lp-app/lpa-studio-web/src/app/layout/studio_pane.rs +++ b/lp-app/lpa-studio-web/src/app/layout/studio_pane.rs @@ -157,6 +157,10 @@ pub enum PaneTone { Good, /// Live-only (transient) state, blue. Live, + /// **Debug** territory (D9): attention-orange + hazard stripes. Distinct + /// from [`Self::Attention`] (flat orange = device health) and from + /// [`Self::Live`] (blue). The look is defined in `style.css`. + Debug, /// Unsaved/edited, yellow (node edit vocabulary). Warning, /// Health needs a look, orange (device/roster attention family). @@ -310,6 +314,7 @@ fn pane_header_tint_class(tone: PaneTone) -> &'static str { PaneTone::Live => { "tw:bg-[linear-gradient(90deg,var(--studio-status-live-bg),transparent_62%)]" } + PaneTone::Debug => "lp-debug-pane-tint", PaneTone::Warning => { "tw:bg-[linear-gradient(90deg,var(--studio-status-warning-bg),transparent_62%)]" } @@ -336,6 +341,7 @@ fn pane_chip_class(tone: PaneTone) -> &'static str { PaneTone::Live => { "tw:shrink-0 tw:whitespace-nowrap tw:rounded-pill tw:border tw:border-status-live-border tw:bg-status-live-bg tw:px-2 tw:py-0.5 tw:text-xs tw:font-bold tw:leading-none tw:text-status-live-foreground" } + PaneTone::Debug => "tw:shrink-0 lp-debug-chip", PaneTone::Warning => { "tw:shrink-0 tw:whitespace-nowrap tw:rounded-pill tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:px-2 tw:py-0.5 tw:text-xs tw:font-bold tw:leading-none tw:text-status-warning-foreground" } diff --git a/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs index b89cc048d..f4d2f9f64 100644 --- a/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs +++ b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs @@ -6,7 +6,7 @@ use lpa_studio_core::{ UiSlotComposite, UiSlotFieldState, UiSlotMapKeyKind, UiSlotSourceState, }; -use crate::app::node::slot_edit_actions::slot_revert_action; +use crate::app::node::slot_edit_actions::{slot_clear_action, slot_revert_action}; use crate::app::node::slot_option_presence::{ OptionPresenceWidth, option_presence_child_slot, option_presence_chip, }; @@ -19,16 +19,17 @@ use crate::app::node::{ use crate::base::{StudioIcon, StudioIconName}; /// Edit chrome for a touched slot row: persisted edits wear the warning -/// (amber) tint and count toward Save, transient edits the live (blue) tint -/// (applied to the running project and never written by Save). The tint plus -/// the affordance icon carry the whole row treatment — no text chips (M3 UX -/// gate); text stays in the popups and the save panel. Rows with an own edit -/// entry additionally get the inline revert icon; the detail popup keeps its -/// revert footer as the second access point. +/// (amber) tint and count toward Save; **Debug** overrides wear the hazard +/// (attention-orange + diagonal stripes) tint — applied to the running +/// project, never written by Save, and cleared rather than reverted (D7/D9). +/// The tint plus the affordance icon carry the whole row treatment — no text +/// chips (M3 UX gate); text stays in the popups and the save panel. Rows with +/// an own edit entry additionally get the inline verb icon; the detail popup +/// keeps its footer as the second access point. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SlotEditChrome { Unsaved, - Live, + Debug, } #[component] @@ -70,9 +71,16 @@ pub fn ConfigSlotRow( let aspects = slot.visible_aspects(); let primary = primary_affordance(&aspects); let chrome = slot_edit_chrome(&slot.state); + // A Debug row must be the SAME BOX touched and untouched (G1 feedback: + // the Clear button appearing reflowed the card). Both states therefore + // carry the shared `lp-debug-row-floor` geometry, and the trailing verb + // is reserved below — only the colour differs. let row_class = match chrome { - Some(SlotEditChrome::Live) if slot.state.invalid.is_none() => live_row_class(), - _ => slot_row_class(primary, index), + Some(SlotEditChrome::Debug) if slot.state.invalid.is_none() => { + debug_row_class().to_string() + } + _ if slot.state.debug => format!("{} lp-debug-row-floor", slot_row_class(primary, index)), + _ => slot_row_class(primary, index).to_string(), }; let indent = depth * 14; // Value edits on a present option row target the interior `some` slot; @@ -185,6 +193,14 @@ pub fn ConfigSlotRow( } if let Some(revert) = row_revert { SlotRowRevertButton { revert } + } else if slot.state.debug { + // Debug controls exist to be poked, so their rows + // must not move when they are: the verb's footprint + // is RESERVED, and the untouched row is the same box + // as the touched one (G1 feedback — the Clear button + // appearing reflowed the card). Persisted rows keep + // today's appear-on-edit behaviour. + span { class: "lp-debug-row-verb-reserve" } } if let Some(optionality) = presence { // Option-ness as presence (P5 live default): the @@ -269,29 +285,37 @@ pub fn ConfigSlotRow( } } -/// The one revert verb vocabulary (M3 UX gate): "Revert" for unsaved -/// (persisted) edits, "Reset" for live (transient) controls — shared by the -/// inline row icon and the detail-popup footer so the two access points can -/// never diverge. +/// The one verb vocabulary (M3 UX gate, D7): "Revert" for unsaved +/// (persisted) edits, **"Clear"** for Debug overrides — never "Reset" — +/// shared by the inline row icon and the detail-popup footer so the two +/// access points can never diverge. fn chrome_revert_labels(chrome: SlotEditChrome) -> (&'static str, &'static str) { match chrome { SlotEditChrome::Unsaved => ("Revert", "Discard this pending edit"), - SlotEditChrome::Live => ("Reset", "Reset this live control to its authored value"), + SlotEditChrome::Debug => ("Clear", "Clear this debug override"), + } +} + +/// The op the row's verb dispatches: a persisted edit is reverted, a Debug +/// override is **cleared** (same `RemoveSlotEdit` mechanism, the vocabulary +/// debug values use). +fn chrome_revert_action(chrome: SlotEditChrome, address: ProjectSlotAddress) -> UiAction { + match chrome { + SlotEditChrome::Unsaved => slot_revert_action(address), + SlotEditChrome::Debug => slot_clear_action(address), } } -/// Tone for the inline revert button, from the same status token families as -/// the row tint and the edited affordance icon: warning (amber) for unsaved -/// persisted edits, live (blue) for transient controls — so the button reads -/// as part of the row's unsaved/live chrome rather than a neutral control. +/// Tone for the inline verb button, from the same family as the row tint and +/// the edited affordance icon: warning (amber) for unsaved persisted edits, +/// the debug hazard family for Debug overrides — so the button reads as part +/// of the row's chrome rather than a neutral control. fn chrome_revert_button_class(chrome: SlotEditChrome) -> &'static str { match chrome { SlotEditChrome::Unsaved => { "tw:inline-flex tw:h-6 tw:w-6 tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground tw:transition-colors tw:hover:border-status-warning-foreground" } - SlotEditChrome::Live => { - "tw:inline-flex tw:h-6 tw:w-6 tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-live-border tw:bg-status-live-bg tw:p-0 tw:text-status-live-foreground tw:transition-colors tw:hover:border-status-live-foreground" - } + SlotEditChrome::Debug => "lp-debug-row-button", } } @@ -327,7 +351,7 @@ fn SlotRowRevertButton(revert: RowRevert) -> Element { title: "{label}: {title}", onclick: move |event| { event.stop_propagation(); - on_action.call(slot_revert_action(address.clone())); + on_action.call(chrome_revert_action(chrome, address.clone())); }, StudioIcon { name: StudioIconName::Revert, @@ -347,11 +371,12 @@ fn slot_detail_revert( address: Option, on_action: Option>, ) -> Option { - let (label, title) = chrome_revert_labels(chrome?); + let chrome = chrome?; + let (label, title) = chrome_revert_labels(chrome); Some(SlotDetailRevert { label, title, - address: address?, + action: chrome_revert_action(chrome, address?), on_action: on_action?, }) } @@ -466,18 +491,20 @@ fn slot_edit_chrome(state: &UiSlotFieldState) -> Option { if state.dirty == UiNodeDirtyState::Clean { return None; } - Some(if state.live { - SlotEditChrome::Live + Some(if state.debug { + SlotEditChrome::Debug } else { SlotEditChrome::Unsaved }) } -/// Row treatment for live-dirty rows: the dedicated live (blue) tint keeps a -/// touched runtime control distinct from both the warning-tinted unsaved -/// (persisted) rows and the good/success (green) treatments. -fn live_row_class() -> &'static str { - "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_32px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-status-live-bg)_0%,var(--studio-status-live-bg)_34%,transparent_100%)] tw:px-2 tw:py-1.5" +/// Row treatment for a touched **Debug** row (D9): the hazard tint keeps an +/// active debug override distinct from the warning-tinted unsaved (persisted) +/// rows, from flat-orange device health, and from the good/success (green) +/// treatments. Geometry and colors both live in `style.css` so the whole +/// debug look stays in one block. +fn debug_row_class() -> &'static str { + "lp-debug-row lp-debug-row-floor" } fn record_summary_class(expanded: bool) -> &'static str { @@ -509,16 +536,34 @@ mod tests { assert!(unsaved.contains("tw:bg-status-warning-bg")); assert!(unsaved.contains("tw:text-status-warning-foreground")); - // Live: the live (blue) family, same position and shape. - let live = chrome_revert_button_class(SlotEditChrome::Live); - assert!(live.contains("tw:border-status-live-border")); - assert!(live.contains("tw:bg-status-live-bg")); - assert!(live.contains("tw:text-status-live-foreground")); + // Debug: the hazard family, defined in one CSS block rather than + // spelled out in utilities (D9 — one place to change the look). + assert_eq!( + chrome_revert_button_class(SlotEditChrome::Debug), + "lp-debug-row-button" + ); + } + + #[test] + fn a_debug_row_is_the_same_box_touched_and_untouched() { + // G1 feedback: the inline Clear appearing must not reflow the card. + // Both debug row states name the shared geometry floor, and the + // untouched state reserves the verb's footprint (`row_class` and the + // `lp-debug-row-verb-reserve` span above) — so the two states differ + // in colour only. `.lp-debug-row-floor` and + // `.lp-debug-row-verb-reserve` carry the sizes, in `style.css`. + assert!( + debug_row_class().contains("lp-debug-row-floor"), + "the touched debug row must carry the shared floor: {}", + debug_row_class() + ); + assert!(debug_row_class().contains("lp-debug-row")); } #[test] fn revert_verbs_stay_per_chrome() { + // D7 vocabulary: persisted edits are reverted, debug values cleared. assert_eq!(chrome_revert_labels(SlotEditChrome::Unsaved).0, "Revert"); - assert_eq!(chrome_revert_labels(SlotEditChrome::Live).0, "Reset"); + assert_eq!(chrome_revert_labels(SlotEditChrome::Debug).0, "Clear"); } } diff --git a/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs index 7092bb12d..3ce5b825e 100644 --- a/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs @@ -172,10 +172,10 @@ pub(crate) fn write_failed() -> Element { } #[story( - label = "Live Chrome", - description = "Touched transient controls: the live (blue) row tint, the detail icon, and the inline Reset icon on rows with an own edit entry — no text chips." + label = "Debug Chrome", + description = "Touched Debug controls (D9): the hazard row tint — attention-orange under diagonal stripes, never the amber a persisted edit wears — plus the detail icon and the inline Clear icon on rows with an own edit entry. No text chips." )] -pub(crate) fn live_chrome() -> Element { +pub(crate) fn debug_chrome() -> Element { rsx! { div { class: "tw:grid tw:min-w-0 tw:overflow-hidden tw:divide-y tw:divide-border-muted", ConfigSlotRow { @@ -185,7 +185,7 @@ pub(crate) fn live_chrome() -> Element { .with_state( UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), ), depth: 0, index: 0, @@ -206,7 +206,7 @@ pub(crate) fn live_chrome() -> Element { .with_state( UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), ), depth: 0, index: 1, @@ -217,10 +217,10 @@ pub(crate) fn live_chrome() -> Element { } #[story( - label = "Live Detail Popup", - description = "The detail popup for a touched live control: the edited section hosts the Reset button; no saved value is known for a transient control, so no Was row (degraded state)." + label = "Debug Detail Popup", + description = "The detail popup for a touched Debug control: the edited section hosts the Clear button; no saved value is known for a debug override, so no Was row (degraded state)." )] -pub(crate) fn live_detail_popup() -> Element { +pub(crate) fn debug_detail_popup() -> Element { rsx! { div { class: "tw:min-h-72", ConfigSlotRow { @@ -230,7 +230,7 @@ pub(crate) fn live_detail_popup() -> Element { .with_state( UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), ), depth: 0, index: 0, @@ -307,7 +307,7 @@ pub(crate) fn editable_clean_controls() -> Element { ConfigSlotRow { slot: UiConfigSlot::value("controls.running", "Running", UiSlotValue::bool(true)) .with_address(story_slot_address("controls.running")) - .with_state(UiSlotFieldState::editable().with_live(true)), + .with_state(UiSlotFieldState::editable().with_debug(true)), depth: 0, index: 0, on_action: move |_| {}, @@ -323,7 +323,7 @@ pub(crate) fn editable_clean_controls() -> Element { }), ) .with_address(story_slot_address("controls.rate")) - .with_state(UiSlotFieldState::editable().with_live(true)), + .with_state(UiSlotFieldState::editable().with_debug(true)), depth: 0, index: 1, on_action: move |_| {}, @@ -579,7 +579,7 @@ pub(crate) fn rejected_edit() -> Element { UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Error) .with_invalid("target slot is not writable") - .with_live(true), + .with_debug(true), ), depth: 0, index: 0, diff --git a/lp-app/lpa-studio-web/src/app/node/face/node_card_drawers.rs b/lp-app/lpa-studio-web/src/app/node/face/node_card_drawers.rs index a0cb0c434..4c2030d1e 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/node_card_drawers.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/node_card_drawers.rs @@ -13,7 +13,10 @@ //! //! Drawers used today: shader = `code` (the existing [`AssetEditor`]) + //! `advanced`; fixture/playlist = `advanced` only. The advanced drawer -//! hosts today's generic slot-row sections unchanged. +//! hosts today's generic slot-row sections unchanged — with ONE exception: +//! the **Debug** section is lifted out and rendered permanently above the +//! drawers. D8 tier (c) requires debug territory to be visible even when +//! idle, and a section behind a closed lid is not visible. use dioxus::prelude::*; use lpa_studio_core::{ @@ -44,6 +47,9 @@ pub fn NodeCardDrawers( /// Whether the advanced drawer is expanded (core-owned state). #[props(default = false)] advanced_open: bool, + /// Whether the Debug section's rows are expanded (core-owned state). + #[props(default = false)] + debug_open: bool, /// Platform for the code editor's shortcut hints; stories pin it for /// deterministic captures. #[props(default = None)] @@ -52,9 +58,14 @@ pub fn NodeCardDrawers( #[props(default)] dirty_tint: NodeDirtyTint, #[props(default)] on_action: Option>, ) -> Element { - let has_advanced = !sections.is_empty(); + // The Debug section never hides behind the advanced lid (D8 tier c). + let (debug_sections, drawer_sections): (Vec<_>, Vec<_>) = sections + .into_iter() + .partition(|section| matches!(section, UiNodeSection::DebugSlots(_))); + let has_advanced = !drawer_sections.is_empty(); let code_summary = code.as_ref().map(|editor| editor.source.clone()); let code_node = node.clone(); + let section_node = Some(node.clone()); let advanced_node = node; rsx! { @@ -72,6 +83,16 @@ pub fn NodeCardDrawers( AssetEditor { editor, on_action, platform } } } + for section in debug_sections { + NodeSection { + section, + node: section_node.clone(), + debug_open, + on_action, + pending_edits: pending_edits.clone(), + dirty_tint, + } + } if has_advanced { NodeCardSection { label: "advanced", @@ -83,10 +104,11 @@ pub fn NodeCardDrawers( NodeCardDrawer::Advanced, !advanced_open, ), - for (index, section) in sections.clone().into_iter().enumerate() { + for (index, section) in drawer_sections.clone().into_iter().enumerate() { NodeSection { section, first: index == 0, + node: section_node.clone(), on_action, pending_edits: pending_edits.clone(), dirty_tint, diff --git a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs index 9223779d4..6cd65f7d0 100644 --- a/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs +++ b/lp-app/lpa-studio-web/src/app/node/face/node_face_body.rs @@ -71,6 +71,7 @@ pub fn NodeFaceBody( sections, code_open: card_ui.code_open, advanced_open: card_ui.advanced_open, + debug_open: card_ui.debug_open, platform, pending_edits, dirty_tint, @@ -87,6 +88,7 @@ pub fn NodeFaceBody( node, sections, advanced_open: card_ui.advanced_open, + debug_open: card_ui.debug_open, platform, pending_edits, dirty_tint, @@ -99,6 +101,7 @@ pub fn NodeFaceBody( node, sections, advanced_open: card_ui.advanced_open, + debug_open: card_ui.debug_open, platform, pending_edits, dirty_tint, diff --git a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs index 70cd3baff..a757c4ece 100644 --- a/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/face_story_fixtures.rs @@ -169,7 +169,7 @@ pub(crate) fn shader_controls(speed_bound: bool) -> Vec { 4.0, UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), UiSlotSourceState::Unset, ), toggle_control( diff --git a/lp-app/lpa-studio-web/src/app/node/h_fader_field_stories.rs b/lp-app/lpa-studio-web/src/app/node/h_fader_field_stories.rs index 2b89ee55d..1871687c7 100644 --- a/lp-app/lpa-studio-web/src/app/node/h_fader_field_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/h_fader_field_stories.rs @@ -84,7 +84,7 @@ fn live() -> Element { 212.0, UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), UiSlotSourceState::Unset, ), on_action: move |_| {}, diff --git a/lp-app/lpa-studio-web/src/app/node/knob_field_stories.rs b/lp-app/lpa-studio-web/src/app/node/knob_field_stories.rs index 5a2c13103..49569091e 100644 --- a/lp-app/lpa-studio-web/src/app/node/knob_field_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/knob_field_stories.rs @@ -140,7 +140,7 @@ fn live() -> Element { 4.0, UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), UiSlotSourceState::Unset, ), on_action: move |_| {}, @@ -157,7 +157,7 @@ fn label_states() -> Element { let dirty = UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty); let live = UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true); + .with_debug(true); let failed = UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Error); rsx! { KnobStoryCard { diff --git a/lp-app/lpa-studio-web/src/app/node/node_children.rs b/lp-app/lpa-studio-web/src/app/node/node_children.rs index 38403d373..167efcb1c 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_children.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_children.rs @@ -39,7 +39,10 @@ fn child_node_view(child: UiNodeChild) -> UiNodeView { child.detail.clone(), ) .with_status(child.status.clone()) - .with_dirty(child.dirty); + .with_dirty(child.dirty) + // The debug channel promotes with the rest: a nested card marks its own + // active overrides (D8 tier b) exactly like a top-level one. + .with_debug_overrides(child.debug_overrides); let header = if let Some(summary) = child.summary { header.with_summary(summary) } else { diff --git a/lp-app/lpa-studio-web/src/app/node/node_detail_popover.rs b/lp-app/lpa-studio-web/src/app/node/node_detail_popover.rs index fa0dbdb62..fb109512e 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_detail_popover.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_detail_popover.rs @@ -43,7 +43,6 @@ pub(crate) fn NodeDetailPopover( .filter(|edit| edit.node_path == header.path) .collect(); let unsaved_entries = entries_in(&own_edits, PendingEditBucket::Persisted); - let live_entries = entries_in(&own_edits, PendingEditBucket::Live); let failed_entries = entries_in(&own_edits, PendingEditBucket::Failed); // The header path is the node address the copy op needs; a header // whose path does not parse (never in production) simply offers no @@ -95,14 +94,6 @@ pub(crate) fn NodeDetailPopover( PendingEditList { entries: unsaved_entries, on_action: forward } } } - if dirty.transient > 0 { - DetailSection { - title: "Live (transient)", - meta: dirty.transient.to_string(), - tint: bucket_section_tint(PendingEditBucket::Live, dirty.transient), - PendingEditList { entries: live_entries, on_action: forward } - } - } if dirty.failed > 0 { DetailSection { title: "Failed edits", diff --git a/lp-app/lpa-studio-web/src/app/node/node_pane.rs b/lp-app/lpa-studio-web/src/app/node/node_pane.rs index f3d54c6cf..17389a25b 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_pane.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_pane.rs @@ -1,15 +1,18 @@ use dioxus::prelude::*; use lpa_studio_core::{ - DirtySummary, UiAction, UiNodeSection, UiNodeTabBody, UiNodeView, UiPendingEdit, UiSlotRecord, + DirtySummary, NodeCardDrawer, NodeUiOp, UiAction, UiConfigSlot, UiNodeDirtyState, + UiNodeSection, UiNodeTabBody, UiNodeView, UiPendingEdit, UiSlotRecord, }; use crate::app::affordance::affordance_pane_tone; use crate::app::layout::{PaneCollapse, RichObjectPane}; +use crate::app::node::face::node_ui_action; +use crate::app::node::slot_edit_actions::node_clear_debug_action; use crate::app::node::{ NodeChildren, NodeDetailPopover, NodeFaceBody, ProducedProducts, ProducedValues, SlotRecordEditor, }; -use crate::base::{Platform, StudioIcon, node_kind_icon}; +use crate::base::{Platform, StudioIcon, StudioIconName, node_kind_icon}; /// Which surface treatment a dirty node pane wears — the D7 tint experiment, /// story-selectable pending the user's P5 pick. @@ -48,6 +51,9 @@ pub fn NodePane( let active_index = active_tab().min(view.tabs.len().saturating_sub(1)); let active_body = view.tabs.get(active_index).map(|tab| tab.body.clone()); let dirty = view.header.dirty; + // The debug channel is separate from the dirty rollup (D7/D8): it marks + // the card, it never washes the header. + let debug_overrides = view.header.debug_overrides; // The rollup: the merged affordance tones the header (P6 — no count // chips; the detail trigger is the whole announcement). RichObjectPane // pins that composition. @@ -81,6 +87,10 @@ pub fn NodePane( // node's address path — the header path carries it for panes and // nested child cards alike. let face_node = view.header.path.clone(); + // The node address the Debug section's per-node Clear targets, and its + // core-owned disclosure bit (collapsed on a fresh card). + let section_node = Some(view.header.path.clone()); + let debug_open = view.card_ui.debug_open; let face_card_ui = view.card_ui.clone(); let add_node_menu = view.add_node_menu.clone(); @@ -111,6 +121,7 @@ pub fn NodePane( actions: header_actions, on_action, trailing: rsx! { + NodeDebugMarker { count: debug_overrides } if !kind_label.is_empty() { span { class: "tw:self-center tw:whitespace-nowrap tw:pl-2 tw:pr-1 tw:text-[11px] tw:font-bold tw:lowercase tw:tracking-wide tw:text-dim-foreground", "{kind_label}" @@ -166,6 +177,8 @@ pub fn NodePane( section, first: index == 0, focus_action: focus_action.clone(), + node: section_node.clone(), + debug_open, on_action, pending_edits: pending_edits.clone(), dirty_tint, @@ -304,6 +317,15 @@ pub fn NodeSection( #[props(default = false)] first: bool, #[props(default)] focus_action: Option, #[props(default)] on_action: Option>, + /// The owning node's address path — the Debug section's per-node Clear + /// dispatches `NodeClearDebugOp` against it, and its disclosure toggle + /// keys `NodeCardUiState` on it. + #[props(default = None)] + node: Option, + /// Core-owned disclosure state for the Debug section + /// (`NodeCardUiState::debug_open`); every other section ignores it. + #[props(default = false)] + debug_open: bool, /// The editor-level pending-edit list, threaded through extracted child /// sections into their nested panes' detail popovers. #[props(default)] @@ -323,12 +345,18 @@ pub fn NodeSection( }, UiNodeSection::ConfigSlots(slots) => rsx! { section { class: section_class("tw:bg-card tw:p-0", first), + div { class: "lp-settings-section-header", + span { class: "lp-settings-section-label", "Settings" } + } SlotRecordEditor { record: UiSlotRecord::new(slots), on_action, } } }, + UiNodeSection::DebugSlots(slots) => rsx! { + DebugSlotsSection { slots, node, open: debug_open, on_action } + }, UiNodeSection::AssetSlots(assets) => rsx! { section { class: section_class("tw:bg-card tw:p-0", first), SlotRecordEditor { @@ -350,6 +378,163 @@ pub fn NodeSection( } } +/// The node card's **Debug** section (D3/D4/D8 tier c): the node's +/// `SlotRole::Debug` rows, flattened by core, behind a **collapsed-by-default +/// disclosure** whose HEADER is the debug territory — hazard-striped and +/// labelled DEBUG whether or not anything is overridden, so a transient +/// control announces itself before it is touched (clean-transient +/// invisibility). Most of the time those controls are not wanted, so the rows +/// open on demand (G1 feedback); unmissability rides the always-visible +/// header, the card marker, and the global chip instead. +/// +/// While collapsed the header still carries "N active · session only" and the +/// per-node **Clear** ([`lpa_studio_core::NodeClearDebugOp`]) — clearing never +/// requires expanding. Nothing here ever says "Revert" or "Reset" (D7). +/// +/// Open state is CORE-OWNED, on the same path as the code/advanced drawers: +/// `NodeCardUiState::debug_open`, keyed by the node's address, mutated with +/// `NodeUiOp::SetDrawer { drawer: NodeCardDrawer::Debug, .. }` — so disclosure +/// survives re-renders and is e2e-drivable. The chevron follows the section +/// grammar's rotation (right = closed, down = open) even though the striped +/// header replaces `NodeCardSection`'s rail/collapsed-row pair. +/// +/// **No state transition here may change a height.** The header reserves the +/// Clear button's box (`min-height` in the CSS), so the count/Clear appearing +/// does not reflow the card. +/// +/// Every pixel of the treatment is the `.lp-debug-*` block in `style.css` — +/// the semantic side is `UiNodeSection::DebugSlots` in core. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn DebugSlotsSection( + slots: Vec, + /// The owning node's address path — both the Clear target and the + /// disclosure's `NodeCardUiState` key. `None` (a caller with no address + /// to give) renders the header as a plain label: no Clear, and no + /// toggle, since there would be nowhere to store the bit. + #[props(default = None)] + node: Option, + /// Core-owned disclosure state (`NodeCardUiState::debug_open`). + #[props(default = false)] + open: bool, + #[props(default)] on_action: Option>, +) -> Element { + let active = slots + .iter() + .filter(|slot| slot.state.dirty != UiNodeDirtyState::Clean) + .count(); + let clear = node + .as_deref() + .filter(|_| active > 0) + .and_then(node_clear_debug_action); + let mut class = String::from("lp-debug-section"); + if active > 0 { + class.push_str(" lp-debug-section--active"); + } + if open { + class.push_str(" lp-debug-section--open"); + } + let toggle_node = node.clone(); + let toggle_title = if open { + "Collapse debug controls" + } else { + "Expand debug controls" + }; + + rsx! { + section { class, + div { class: "lp-debug-section-header", + if let Some(node) = toggle_node { + button { + class: "lp-debug-section-toggle", + r#type: "button", + aria_expanded: "{open}", + aria_label: "{toggle_title}", + title: "{toggle_title}", + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_action { + handler.call(node_ui_action(NodeUiOp::SetDrawer { + node: node.clone(), + drawer: NodeCardDrawer::Debug, + open: !open, + })); + } + }, + span { class: debug_chevron_class(open), + StudioIcon { + name: if open { StudioIconName::Expanded } else { StudioIconName::Collapsed }, + size: 12, + } + } + span { class: "lp-debug-section-label", "Debug" } + span { class: "lp-debug-section-note", + if active > 0 { "{active} active · session only" } else { "session only" } + } + } + } else { + span { class: "lp-debug-section-toggle", + span { class: "lp-debug-section-label", "Debug" } + span { class: "lp-debug-section-note", + if active > 0 { "{active} active · session only" } else { "session only" } + } + } + } + if let Some(action) = clear { + button { + class: "lp-debug-button", + r#type: "button", + title: "Clear every debug override on this node", + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_action { + handler.call(action.clone()); + } + }, + "Clear" + } + } + } + if open { + SlotRecordEditor { + record: UiSlotRecord::new(slots), + on_action, + } + } + } + } +} + +/// Chevron rotation for the Debug disclosure — the section grammar's +/// convention: the glyph previews the state the press leads to. +fn debug_chevron_class(open: bool) -> &'static str { + if open { + "lp-debug-section-chevron lp-debug-section-chevron--open" + } else { + "lp-debug-section-chevron" + } +} + +/// The node card's debug marking (D8 tier b): a hazard pill in the header's +/// trailing slot while the node's subtree carries an active override. Not a +/// `PaneChrome` chip — the rich-object pane renders none by convention — and +/// deliberately not the header wash, which stays the dirty/status rollup's +/// (D7: a debug override is not pending work). +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeDebugMarker(count: usize) -> Element { + if count == 0 { + return rsx! {}; + } + rsx! { + span { + class: "lp-debug-marker", + title: "This node carries {count} active debug override(s) — session only, never saved", + "debug {count}" + } + } +} + /// The main tab's sections — the face's advanced drawer hosts them /// unchanged (today's slot-row view behind the last lid). fn main_tab_sections(view: &UiNodeView) -> Vec { @@ -390,8 +575,6 @@ fn pane_surface_tint_class(variant: NodeDirtyTint, dirty: DirtySummary) -> &'sta "tw:contents tw:[--tw-color-card:color-mix(in_oklab,var(--studio-status-error-bg)_55%,var(--studio-color-surface))] tw:[--tw-color-card-subtle:color-mix(in_oklab,var(--studio-status-error-bg)_55%,var(--studio-color-surface-subtle))] tw:[--tw-color-card-muted:color-mix(in_oklab,var(--studio-status-error-bg)_55%,var(--studio-color-surface-muted))]" } else if dirty.persisted > 0 { "tw:contents tw:[--tw-color-card:color-mix(in_oklab,var(--studio-status-warning-bg)_55%,var(--studio-color-surface))] tw:[--tw-color-card-subtle:color-mix(in_oklab,var(--studio-status-warning-bg)_55%,var(--studio-color-surface-subtle))] tw:[--tw-color-card-muted:color-mix(in_oklab,var(--studio-status-warning-bg)_55%,var(--studio-color-surface-muted))]" - } else if dirty.transient > 0 { - "tw:contents tw:[--tw-color-card:color-mix(in_oklab,var(--studio-status-live-bg)_55%,var(--studio-color-surface))] tw:[--tw-color-card-subtle:color-mix(in_oklab,var(--studio-status-live-bg)_55%,var(--studio-color-surface-subtle))] tw:[--tw-color-card-muted:color-mix(in_oklab,var(--studio-status-live-bg)_55%,var(--studio-color-surface-muted))]" } else { "tw:contents tw:[--tw-color-card:var(--studio-color-surface)] tw:[--tw-color-card-subtle:var(--studio-color-surface-subtle)] tw:[--tw-color-card-muted:var(--studio-color-surface-muted)]" } @@ -433,12 +616,8 @@ fn NodeTabs( mod tests { use super::*; - fn dirty(persisted: usize, transient: usize, failed: usize) -> DirtySummary { - DirtySummary { - persisted, - transient, - failed, - } + fn dirty(persisted: usize, failed: usize) -> DirtySummary { + DirtySummary { persisted, failed } } #[test] @@ -459,22 +638,24 @@ mod tests { tone(UiStatus::good("Running"), DirtySummary::clean()), PaneTone::Good ); - // Dirty precedence: failed > unsaved > live. + // Dirty precedence: failed > unsaved. assert_eq!( - tone(UiStatus::good("Running"), dirty(2, 1, 1)), + tone(UiStatus::good("Running"), dirty(2, 1)), PaneTone::Error ); assert_eq!( - tone(UiStatus::good("Running"), dirty(2, 1, 0)), + tone(UiStatus::good("Running"), dirty(2, 0)), PaneTone::Warning ); + // D7: debug overrides never enter the summary, so a debug-only node + // keeps its runtime tone — no wash at all. assert_eq!( - tone(UiStatus::good("Running"), dirty(0, 1, 0)), - PaneTone::Live + tone(UiStatus::good("Running"), DirtySummary::clean()), + PaneTone::Good ); // An error status is never masked by a dirty wash. assert_eq!( - tone(UiStatus::error("Failed"), dirty(0, 1, 0)), + tone(UiStatus::error("Failed"), dirty(1, 0)), PaneTone::Error ); } @@ -482,15 +663,13 @@ mod tests { #[test] fn surface_tint_applies_only_in_full_surface_variant_on_dirty_panes() { assert_eq!( - pane_surface_tint_class(NodeDirtyTint::HeaderOnly, dirty(2, 0, 0)), + pane_surface_tint_class(NodeDirtyTint::HeaderOnly, dirty(2, 0)), "tw:contents" ); - let unsaved = pane_surface_tint_class(NodeDirtyTint::FullSurface, dirty(2, 0, 0)); + let unsaved = pane_surface_tint_class(NodeDirtyTint::FullSurface, dirty(2, 0)); assert!(unsaved.contains("--studio-status-warning-bg")); - let live = pane_surface_tint_class(NodeDirtyTint::FullSurface, dirty(0, 1, 0)); - assert!(live.contains("--studio-status-live-bg")); - let failed = pane_surface_tint_class(NodeDirtyTint::FullSurface, dirty(1, 1, 1)); + let failed = pane_surface_tint_class(NodeDirtyTint::FullSurface, dirty(1, 1)); assert!(failed.contains("--studio-status-error-bg")); let clean = pane_surface_tint_class(NodeDirtyTint::FullSurface, DirtySummary::clean()); diff --git a/lp-app/lpa-studio-web/src/app/node/node_stories.rs b/lp-app/lpa-studio-web/src/app/node/node_stories.rs index 20cde67dd..74589d260 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_stories.rs @@ -3,9 +3,9 @@ use lpa_studio_core::{ControllerId, ProjectEditorOp, UiAction}; use lpa_studio_web_story_macros::story; use crate::app::node::node_story_fixtures::{ - error_node_view, failed_dirty_node_view, live_dirty_node_view, nested_dirty_node_view, - node_delete_pane_action, playlist_node_view, playlist_pending_edits, unsaved_dirty_node_view, - unsupported_node_view, + clock_node_view, error_node_view, failed_dirty_node_view, nested_dirty_node_view, + node_delete_pane_action, output_node_view, playlist_node_view, playlist_pending_edits, + unsaved_dirty_node_view, unsupported_node_view, }; use crate::app::node::{NodeDetailPopover, NodeDirtyTint, NodePane}; @@ -92,10 +92,10 @@ pub(crate) fn dirty_unsaved_surface_tint() -> Element { } #[story( - description = "D7 variant (a), live-only: header-only blue tint with the blue (live) pencil detail trigger (the live default)." + description = "D7 variant (a), failed: the error wash dominates the header and the detail trigger wears the red warning glyph (the live default)." )] -pub(crate) fn dirty_live_header_tint() -> Element { - let mut view = live_dirty_node_view(); +pub(crate) fn dirty_failed_header_tint() -> Element { + let mut view = failed_dirty_node_view(); view.action = Some(story_focus_action()); rsx! { @@ -108,10 +108,10 @@ pub(crate) fn dirty_live_header_tint() -> Element { } #[story( - description = "D7 variant (b), live-only: the blue tint re-mixed into the whole pane surface." + description = "D7 variant (b), failed: the error tint re-mixed into the whole pane surface." )] -pub(crate) fn dirty_live_surface_tint() -> Element { - let mut view = live_dirty_node_view(); +pub(crate) fn dirty_failed_surface_tint() -> Element { + let mut view = failed_dirty_node_view(); view.action = Some(story_focus_action()); rsx! { @@ -124,46 +124,85 @@ pub(crate) fn dirty_live_surface_tint() -> Element { } #[story( - description = "D7 variant (a), failed: the error wash dominates the header and the detail trigger wears the red warning glyph (the live default)." + description = "Dirty bubbling: a dirty grandchild's affordance shows on its own detail trigger and on both ancestors' triggers (with the header tint), so a collapsed parent still reveals a dirty descendant; the clean sibling stays silent." )] -pub(crate) fn dirty_failed_header_tint() -> Element { - let mut view = failed_dirty_node_view(); +pub(crate) fn nested_dirty_children() -> Element { + let mut view = nested_dirty_node_view(); view.action = Some(story_focus_action()); rsx! { - NodePane { - view, - on_action: move |_| {}, - dirty_tint: NodeDirtyTint::HeaderOnly, - } + NodePane { view, on_action: move |_| {} } } } #[story( - description = "D7 variant (b), failed: the error tint re-mixed into the whole pane surface." + description = "The live default: a Clock card whose three `controls.*` fields are Debug-role. The section is COLLAPSED — most of the time those controls are not wanted — but its header is always debug territory: hazard-striped, labelled DEBUG, reading \"session only\". Nothing is overridden, so there is no count, no Clear, and no card marking. The persisted rows sit above under `Settings`." )] -pub(crate) fn dirty_failed_surface_tint() -> Element { - let mut view = failed_dirty_node_view(); - view.action = Some(story_focus_action()); +pub(crate) fn debug_section_idle() -> Element { + rsx! { + NodePane { view: clock_node_view(0, false), on_action: move |_| {} } + } +} +#[story( + description = "Collapsed with two active overrides: the header reads \"2 active · session only\" and offers Clear WITHOUT expanding, and the card header carries the `debug 2` marking. The header box is the same height as the idle story's — the count and the Clear button are reserved space, so touching a control never reflows the card." +)] +pub(crate) fn debug_section_collapsed_active() -> Element { rsx! { - NodePane { - view, - on_action: move |_| {}, - dirty_tint: NodeDirtyTint::FullSurface, + NodePane { view: clock_node_view(2, false), on_action: move |_| {} } + } +} + +#[story( + description = "Expanded with two active overrides: the flattened Debug rows (Running / Rate / Scrub offset seconds — no nested `Controls` group), the touched ones wearing the hazard row tint with the inline Clear verb. The header wash stays neutral on purpose — a debug override is NOT unsaved work (D7), so it never borrows the amber dirty treatment." +)] +pub(crate) fn debug_section_active() -> Element { + rsx! { + NodePane { view: clock_node_view(2, true), on_action: move |_| {} } + } +} + +#[story( + description = "Expanded and idle: what the disclosure reveals before anything is touched — three transient controls, each already reading as debug territory. This is the clean-transient case D8c exists for." +)] +pub(crate) fn debug_section_expanded_idle() -> Element { + rsx! { + NodePane { view: clock_node_view(0, true), on_action: move |_| {} } + } +} + +#[story( + description = "The hazard family beside its neighbours, for the G1 distinctness question: collapsed-idle, collapsed-active, and expanded-active Debug sections next to an amber-unsaved card. Debug = attention-orange + diagonal stripes; flat orange stays device health; amber stays unsaved. The three debug cards also show the no-reflow contract — every header strip is the same height." +)] +pub(crate) fn debug_section_vs_unsaved() -> Element { + let mut unsaved = unsaved_dirty_node_view(); + unsaved.action = Some(story_focus_action()); + + rsx! { + div { class: "tw:grid tw:gap-4", + NodePane { view: clock_node_view(0, false), on_action: move |_| {} } + NodePane { view: clock_node_view(2, false), on_action: move |_| {} } + NodePane { view: clock_node_view(2, true), on_action: move |_| {} } + NodePane { view: unsaved, on_action: move |_| {} } } } } #[story( - description = "Dirty bubbling: a dirty grandchild's affordance shows on its own detail trigger and on both ancestors' triggers (with the header tint), so a collapsed parent still reveals a dirty descendant; the clean sibling stays silent." + description = "The P5 proof case, hardware mode: an Output card whose one Debug field is `test_pattern`. Expanded with the override ACTIVE — the strip on `ws281x:rmt:D10` is solid white and the engine skips the graph resolve entirely for this output. The card wears the `debug 1` marking, the striped header offers Clear, and the row carries the hazard tint; endpoint and driver options stay above under `Settings`. Nothing here is output-specific UI: the section is derived from `SlotRole::Debug` (P1) by the same partition that produces the Clock's (P3)." )] -pub(crate) fn nested_dirty_children() -> Element { - let mut view = nested_dirty_node_view(); - view.action = Some(story_focus_action()); +pub(crate) fn output_debug_test_pattern_active() -> Element { + rsx! { + NodePane { view: output_node_view(true, true), on_action: move |_| {} } + } +} +#[story( + description = "The same Output card at rest, collapsed: one Debug field, nothing overridden, so no count, no Clear, no card marking — but the header still reads as debug territory. The live default for a hardware output nobody is probing." +)] +pub(crate) fn output_debug_test_pattern_idle() -> Element { rsx! { - NodePane { view, on_action: move |_| {} } + NodePane { view: output_node_view(false, false), on_action: move |_| {} } } } @@ -216,8 +255,7 @@ pub(crate) fn unsupported_detail_popup() -> Element { description = "The merged node detail popup open: status content plus the per-bucket dirty sections as tinted-title change lists — the node's OWN pending edits with per-entry reverts (subtree counts ride the title rows; the other node's edit in the threaded list is filtered out)." )] pub(crate) fn dirty_detail_popup() -> Element { - let mut view = unsaved_dirty_node_view(); - view.header.dirty.transient = 1; + let view = unsaved_dirty_node_view(); rsx! { div { class: "tw:flex tw:min-h-[620px] tw:justify-end", diff --git a/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs index 706f14e5a..1d15c0274 100644 --- a/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs @@ -3,14 +3,14 @@ use lpa_studio_core::{ ColorOrder, ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, ControllerId, DirtySummary, - NodeRemoveOp, NodeRevertOp, ProjectNodeAddress, ProjectSlotAddress, ProjectSlotRoot, Revision, - SlotEditOp, SlotPath, UiAction, UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, - UiControlProductPreview, UiControlSampleFormat, UiNodeChild, UiNodeDirtyState, UiNodeHeader, - UiNodeRemovePreflight, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPaneAction, - UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, UiProducedBinding, UiProducedBindings, - UiProducedProduct, UiProducedValue, UiProductPreview, UiProductTrackingState, UiSlotAsset, - UiSlotEditorHint, UiSlotFieldState, UiSlotOptionality, UiSlotRecord, UiSlotSourceState, - UiSlotUnit, UiSlotValue, UiStatus, + NodeCardUiState, NodeRemoveOp, NodeRevertOp, ProjectNodeAddress, ProjectSlotAddress, + ProjectSlotRoot, Revision, SlotEditOp, SlotPath, UiAction, UiAssetEditorKind, + UiBindingEndpoint, UiConfigSlot, UiControlProductPreview, UiControlSampleFormat, UiNodeChild, + UiNodeDirtyState, UiNodeHeader, UiNodeRemovePreflight, UiNodeSection, UiNodeTab, UiNodeTabBody, + UiNodeView, UiPaneAction, UiPendingEdit, UiPendingEditKind, UiPendingEditPhase, + UiProducedBinding, UiProducedBindings, UiProducedProduct, UiProducedValue, UiProductPreview, + UiProductTrackingState, UiSlotAsset, UiSlotEditorHint, UiSlotFieldState, UiSlotOptionality, + UiSlotRecord, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiStatus, }; const IDLE_GLSL: &str = r#"vec3 palette(float t) { @@ -152,6 +152,173 @@ pub(crate) fn node_delete_pane_action() -> UiPaneAction { ) } +/// A Clock node card: one persisted **Settings** section plus the **Debug** +/// section the D3/D4 partition produces. The clock's three `controls.*` +/// fields are `SlotRole::Debug`, so core lifts them FLAT into +/// `UiNodeSection::DebugSlots` — the card shows "Running / Rate / Scrub +/// offset seconds" as top-level rows, never a nested "Controls" group. +/// +/// `overrides` seeds how many of them carry an active override: `0` is the +/// idle case (the section header is still debug territory — D8 tier c), and a +/// non-zero count also lights the card's header marking (tier b). +/// +/// `debug_open` seeds the core-owned disclosure (`NodeCardUiState:: +/// debug_open`). The live default is `false` — the rows are collapsed behind +/// the always-visible striped header. +pub(crate) fn clock_node_view(overrides: usize, debug_open: bool) -> UiNodeView { + let debug_row = |key: &str, label: &str, value: UiSlotValue, index: usize| { + let state = if index < overrides { + UiSlotFieldState::editable() + .with_debug(true) + .with_dirty(UiNodeDirtyState::Dirty) + } else { + UiSlotFieldState::editable().with_debug(true) + }; + let mut row = UiConfigSlot::value(key, label, value) + .with_address(clock_slot_address(&format!("controls.{key}"))) + .with_state(state); + if index < overrides { + // An active override owns its overlay entry, which is what puts + // the inline Clear verb on the row (untouched rows reserve its + // footprint instead, so the two are the same box). + row = row.with_edit_entry_address(clock_slot_address(&format!("controls.{key}"))); + } + row + }; + + let mut view = UiNodeView::new( + UiNodeHeader::new("clock", "Clock", CLOCK_NODE) + .with_status(UiStatus::good("Running")) + .with_debug_overrides(overrides), + vec![UiNodeTab::main(vec![ + UiNodeSection::ProducedValues(vec![UiProducedValue::new("Time", "12.480")]), + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "epoch_offset_seconds", + "Epoch offset seconds", + UiSlotValue::f32(0.0).with_unit(UiSlotUnit::seconds()), + ) + .with_address(clock_slot_address("epoch_offset_seconds")), + ]), + UiNodeSection::DebugSlots(vec![ + debug_row("running", "Running", UiSlotValue::bool(true), 0), + debug_row("rate", "Rate", UiSlotValue::f32(2.0), 1), + debug_row( + "scrub_offset_seconds", + "Scrub offset seconds", + UiSlotValue::f32(0.0).with_unit(UiSlotUnit::seconds()), + 2, + ), + ]), + ])], + ) + .with_node_id(CLOCK_NODE); + view.card_ui = NodeCardUiState { + debug_open, + ..NodeCardUiState::default() + }; + view.action = Some(UiAction::from_op( + ControllerId::new("story.project"), + SlotEditOp::Revert { + address: clock_slot_address("epoch_offset_seconds"), + }, + )); + view +} + +/// An Output node card: the hardware-mode Debug case (P5). +/// +/// `OutputDef.test_pattern` is the first `SlotRole::Debug` **bool**, and the +/// output card gets its Debug section for free — core partitions by role, so +/// no output-specific UI exists. The persisted rows (endpoint, driver +/// options) stay in `Settings`; the one Debug row sits below. +/// +/// `active` seeds the override: `true` is the pattern lit — the card wears the +/// `debug 1` marking, the section header offers Clear, and the row carries the +/// hazard tint. This is the state where the strip on that pin is solid white +/// and the graph is bypassed entirely. +pub(crate) fn output_node_view(active: bool, debug_open: bool) -> UiNodeView { + let state = if active { + UiSlotFieldState::editable() + .with_debug(true) + .with_dirty(UiNodeDirtyState::Dirty) + } else { + UiSlotFieldState::editable().with_debug(true) + }; + let mut test_pattern = + UiConfigSlot::value("test_pattern", "Test pattern", UiSlotValue::bool(active)) + .with_address(output_slot_address("test_pattern")) + .with_state(state) + .with_detail("solid white on every channel"); + if active { + test_pattern = test_pattern.with_edit_entry_address(output_slot_address("test_pattern")); + } + + let mut view = UiNodeView::new( + UiNodeHeader::new("output", "Output", OUTPUT_NODE) + .with_source("output.json") + .with_status(UiStatus::good("Running")) + .with_summary("ws281x:rmt:D10") + .with_debug_overrides(usize::from(active)), + vec![UiNodeTab::main(vec![ + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("ws281x:rmt:D10"), + ) + .with_address(output_slot_address("endpoint")), + UiConfigSlot::value("input", "Input", UiSlotValue::unset()).with_source( + UiSlotSourceState::Bound(UiBindingEndpoint::new("bus:control.out")), + ), + UiConfigSlot::record( + "options", + "Options", + vec![ + UiConfigSlot::value( + "interpolation_enabled", + "Interpolation enabled", + UiSlotValue::bool(true), + ), + UiConfigSlot::value( + "dithering_enabled", + "Dithering enabled", + UiSlotValue::bool(true), + ), + ], + ), + ]), + UiNodeSection::DebugSlots(vec![test_pattern]), + ])], + ) + .with_node_id(OUTPUT_NODE); + view.card_ui = NodeCardUiState { + debug_open, + ..NodeCardUiState::default() + }; + view +} + +const OUTPUT_NODE: &str = "/fyeah_sign.show/output.output"; + +fn output_slot_address(path: &str) -> ProjectSlotAddress { + ProjectSlotAddress::new( + ProjectNodeAddress::parse(OUTPUT_NODE).expect("valid story node address"), + ProjectSlotRoot::def(), + SlotPath::parse(path).expect("valid story slot path"), + ) +} + +const CLOCK_NODE: &str = "/fyeah_sign.show/clock.clock"; + +fn clock_slot_address(path: &str) -> ProjectSlotAddress { + ProjectSlotAddress::new( + ProjectNodeAddress::parse(CLOCK_NODE).expect("valid story node address"), + ProjectSlotRoot::def(), + SlotPath::parse(path).expect("valid story slot path"), + ) +} + /// Playlist node whose subtree carries unsaved (persisted) edits — drives the /// yellow pencil affordance, the header batch-revert action, and D7 tint /// variants. @@ -159,21 +326,6 @@ pub(crate) fn unsaved_dirty_node_view() -> UiNodeView { let mut view = playlist_node_view(); view.header.dirty = DirtySummary { persisted: 2, - transient: 0, - failed: 0, - }; - view.header_actions = vec![node_revert_pane_action()]; - view -} - -/// Playlist node whose subtree carries live-only (transient) edits — drives -/// the blue (live) pencil affordance, the header batch-revert action, and D7 -/// tint variants. -pub(crate) fn live_dirty_node_view() -> UiNodeView { - let mut view = playlist_node_view(); - view.header.dirty = DirtySummary { - persisted: 0, - transient: 1, failed: 0, }; view.header_actions = vec![node_revert_pane_action()]; @@ -181,9 +333,10 @@ pub(crate) fn live_dirty_node_view() -> UiNodeView { } /// The editor-level change list the dirty playlist popup stories thread in: -/// the playlist's OWN edits (two persisted plus one live control, matching -/// the dirty-fixture counts) and one edit addressed to ANOTHER node that the -/// popover must filter out of its list. +/// the playlist's OWN edits (two persisted, matching the dirty-fixture +/// counts) and one edit addressed to ANOTHER node that the popover must +/// filter out of its list. There is no debug row: debug overrides are not +/// dirty (D7), so the controller never lists them. pub(crate) fn playlist_pending_edits() -> Vec { let mut time_edit = story_pending_edit( "/fyeah_sign.show/playlist.playlist", @@ -205,15 +358,6 @@ pub(crate) fn playlist_pending_edits() -> Vec { UiPendingEditKind::Added, UiPendingEditPhase::Persisted, ), - story_pending_edit( - "/fyeah_sign.show/playlist.playlist", - "Playlist", - "controls.rate", - UiPendingEditKind::Assign { - value_display: "2.0".to_string(), - }, - UiPendingEditPhase::Live, - ), story_pending_edit( "/fyeah_sign.show/other.shader", "Other shader", @@ -261,7 +405,6 @@ pub(crate) fn failed_dirty_node_view() -> UiNodeView { let mut view = playlist_node_view(); view.header.dirty = DirtySummary { persisted: 1, - transient: 0, failed: 1, }; view.header_actions = vec![node_revert_pane_action()]; @@ -270,12 +413,11 @@ pub(crate) fn failed_dirty_node_view() -> UiNodeView { /// Three-level bubbling fixture: the grandchild carries the edits and every /// ancestor's summary includes them, exactly as the controller's aggregation -/// walk produces (grandchild {1p,1t} → child {1p,1t} → parent adds one -/// persisted edit of its own → {2p,1t}). +/// walk produces (grandchild {1p} → child {1p} → parent adds one persisted +/// edit of its own → {2p}). pub(crate) fn nested_dirty_node_view() -> UiNodeView { let bubbled = DirtySummary { persisted: 1, - transient: 1, failed: 0, }; @@ -317,7 +459,6 @@ pub(crate) fn nested_dirty_node_view() -> UiNodeView { ]); view.header.dirty = bubbled.merge(DirtySummary { persisted: 1, - transient: 0, failed: 0, }); view.header_actions = vec![node_revert_pane_action()]; diff --git a/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs b/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs index f5c8d9ef5..1f11b823d 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/panel_control.rs @@ -239,7 +239,10 @@ fn panel_label_visual(label: &str, color_class: &'static str) -> Element { fn panel_label_class(control: &UiPanelControlData) -> &'static str { match primary_affordance(&control.aspects) { UiSlotAffordance::Error | UiSlotAffordance::Invalid => "tw:text-status-error-foreground", - UiSlotAffordance::Edited if control.state.live => "tw:text-status-live-foreground", + // A Debug control fronted on a panel keeps the panel surface's own + // live-value blue: preview/play surfaces are out of the debug + // treatment's scope (D8) — the panels line owns their indication. + UiSlotAffordance::Edited if control.state.debug => "tw:text-status-live-foreground", UiSlotAffordance::Edited => "tw:text-status-warning-foreground", UiSlotAffordance::Saving => "tw:text-status-working-foreground", UiSlotAffordance::Bound => "tw:text-status-bound-foreground", @@ -399,13 +402,13 @@ mod tests { ); assert!(panel_label_class(&unsaved).contains("warning")); - let live = control( + let debug = control( UiSlotValue::f32(1.0), UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), ); - assert!(panel_label_class(&live).contains("live")); + assert!(panel_label_class(&debug).contains("live")); let failed = control( UiSlotValue::f32(1.0), diff --git a/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs b/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs index 1ee49f016..f8ef551bd 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs @@ -2,10 +2,9 @@ use dioxus::prelude::*; use lpa_studio_core::{ - ProjectSlotAddress, UiAction, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, + UiAction, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, }; -use crate::app::node::slot_edit_actions::slot_revert_action; use crate::app::node::{ SlotShapeDisplay, SlotShapeDisplayMode, SlotUnitDisplay, SlotUnitDisplayMode, legacy_shape_from_parts, @@ -15,19 +14,19 @@ use crate::base::{ detail_popover_section_class, }; -/// Revert/reset affordance rendered INSIDE the slot detail popup's edited +/// Revert/clear affordance rendered INSIDE the slot detail popup's edited /// (edit-state) section for a touched editable slot — beside the state and /// old-value rows it acts on, like the save panel's per-entry revert rows /// (the row's inline icon stays the quick path). #[derive(Clone, PartialEq)] pub struct SlotDetailRevert { - /// Button label: "Revert" for unsaved persisted edits, "Reset" for live - /// (transient) controls. + /// Button label: "Revert" for unsaved persisted edits, "Clear" for + /// live/debug overrides (D7 — never "Reset"). pub label: &'static str, - /// Tooltip explaining what dispatching the revert discards. + /// Tooltip explaining what dispatching the button discards. pub title: &'static str, - /// Slot address the revert op targets. - pub address: ProjectSlotAddress, + /// The already-built op action (revert or clear) this button dispatches. + pub action: UiAction, /// Shared action conduit. pub on_action: EventHandler, } @@ -161,7 +160,7 @@ fn SlotDetailRevertButton(revert: SlotDetailRevert) -> Element { let SlotDetailRevert { label, title, - address, + action, on_action, } = revert; @@ -173,7 +172,7 @@ fn SlotDetailRevertButton(revert: SlotDetailRevert) -> Element { title, onclick: move |event| { event.stop_propagation(); - on_action.call(slot_revert_action(address.clone())); + on_action.call(action.clone()); }, StudioIcon { name: StudioIconName::Revert, diff --git a/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs b/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs index f06978820..b56385f2e 100644 --- a/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs +++ b/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs @@ -6,7 +6,8 @@ //! plus overlay mirror keep the DTO value stable until the server acks. use lpa_studio_core::{ - ControllerId, LpValue, ProjectController, ProjectSlotAddress, SlotEditOp, SlotMapKey, UiAction, + ControllerId, LpValue, NodeClearDebugOp, ProjectController, ProjectNodeAddress, + ProjectSlotAddress, SlotEditOp, SlotMapKey, UiAction, }; /// Build the `SetValue` action a field dispatches on input. @@ -17,7 +18,7 @@ pub(crate) fn slot_set_value_action(address: ProjectSlotAddress, value: LpValue) ) } -/// Build the per-slot revert action (labelled "Reset" on live rows). +/// Build the per-slot revert action for a persisted (unsaved) edit. pub(crate) fn slot_revert_action(address: ProjectSlotAddress) -> UiAction { UiAction::from_op( ControllerId::new(ProjectController::NODE_ID), @@ -25,6 +26,31 @@ pub(crate) fn slot_revert_action(address: ProjectSlotAddress) -> UiAction { ) } +/// Build the per-value **Clear** action for a debug (live-only) override +/// (D7): the same `RemoveSlotEdit` mechanism as revert, under the verb debug +/// values use — for a Debug slot the authored default IS the shape default. +pub(crate) fn slot_clear_action(address: ProjectSlotAddress) -> UiAction { + UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + SlotEditOp::Clear { address }, + ) +} + +/// Build the **per-node** Clear action ([`NodeClearDebugOp`], D7's node +/// scope): every Debug override under one node's subtree goes, persisted +/// edits stay. The Debug section header is its only entry point — a node's +/// debug territory owns its own reset. +/// +/// `None` when the card's path is not a parsable node address (story +/// fixtures with stand-in paths), so the header simply renders no Clear. +pub(crate) fn node_clear_debug_action(node: &str) -> Option { + let node = ProjectNodeAddress::parse(node).ok()?; + Some(UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + NodeClearDebugOp { node }, + )) +} + /// Build the structural add gesture (map entry add, option on, enum variant /// switch): the server constructs the defaults at `address` (M3 D1). pub(crate) fn slot_ensure_present_action(address: ProjectSlotAddress) -> UiAction { diff --git a/lp-app/lpa-studio-web/src/app/node/toggle_field_stories.rs b/lp-app/lpa-studio-web/src/app/node/toggle_field_stories.rs index 004d65ff8..75ce66cd9 100644 --- a/lp-app/lpa-studio-web/src/app/node/toggle_field_stories.rs +++ b/lp-app/lpa-studio-web/src/app/node/toggle_field_stories.rs @@ -104,7 +104,7 @@ fn live() -> Element { true, UiSlotFieldState::editable() .with_dirty(UiNodeDirtyState::Dirty) - .with_live(true), + .with_debug(true), UiSlotSourceState::Unset, ), on_action: move |_| {}, diff --git a/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs b/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs index d4aef2375..3c87be9e2 100644 --- a/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs +++ b/lp-app/lpa-studio-web/src/app/project/pending_edit_section.rs @@ -1,7 +1,7 @@ //! Save-panel change list: dense pending-edit rows with per-entry revert. //! //! The pending-edit surfaces share this module: the project detail popup's -//! per-bucket sections (unsaved / live / failed) render the full editor list +//! per-bucket sections (unsaved / failed) render the full editor list //! bucketed, and the node detail popup renders the node's own entries the //! same way. Sections are `DetailSection`s titled with the bucket name (the //! count rides the title row's meta cell) and tinted via @@ -15,12 +15,14 @@ use crate::base::DetailSectionTint; /// The save-panel buckets, mirroring `UiPendingEditPhase` for filtering /// entries into their popup sections. +/// +/// There is no live/debug bucket (D7): a debug override is not dirty, so the +/// controller never lists it — the save panel is about work Save would write +/// and work that failed, nothing else. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PendingEditBucket { /// Written to project files on save (the "Unsaved" section). Persisted, - /// Live-only transient controls (the "Live" section). - Live, /// Failed edits needing attention (the "Failed" section). Failed, } @@ -37,13 +39,12 @@ pub(crate) fn entries_in(edits: &[UiPendingEdit], bucket: PendingEditBucket) -> fn edit_bucket(edit: &UiPendingEdit) -> PendingEditBucket { match edit.phase { UiPendingEditPhase::Persisted => PendingEditBucket::Persisted, - UiPendingEditPhase::Live => PendingEditBucket::Live, UiPendingEditPhase::Failed { .. } => PendingEditBucket::Failed, } } /// The [`DetailSectionTint`] a pending-edit bucket section wears while it -/// holds entries — the same treatment as edited/live/failed slot rows, worn +/// holds entries — the same treatment as edited/failed slot rows, worn /// on the section TITLE per the `DetailSection` convention. A bucket at zero /// stays untinted (its section reads as plain information). /// @@ -55,7 +56,6 @@ pub(crate) fn bucket_section_tint(bucket: PendingEditBucket, count: usize) -> De } match bucket { PendingEditBucket::Persisted => DetailSectionTint::Warning, - PendingEditBucket::Live => DetailSectionTint::Live, PendingEditBucket::Failed => DetailSectionTint::Error, } } @@ -97,9 +97,9 @@ fn kind_display(kind: &UiPendingEditKind, old_value: Option<&str>) -> String { /// Revert-button wording matching the per-slot detail popups: "Revert" for /// unsaved persisted edits (and failed entries, where it clears the parked -/// error), "Reset" for live controls — except the staged node removal, -/// whose revert restores the node (and cancels its staged file deletions), -/// so it reads "Restore". +/// error) — except the staged node removal, whose revert restores the node +/// (and cancels its staged file deletions), so it reads "Restore". Debug +/// overrides never reach this list (D7); their verb is Clear, on the row. fn revert_label(edit: &UiPendingEdit) -> (&'static str, &'static str) { if matches!(edit.kind, UiPendingEditKind::NodeRemoved) { return ( @@ -109,8 +109,7 @@ fn revert_label(edit: &UiPendingEdit) -> (&'static str, &'static str) { } match edit.phase { UiPendingEditPhase::Persisted => ("Revert", "Discard this pending edit"), - UiPendingEditPhase::Live => ("Reset", "Reset this live control to its authored value"), - UiPendingEditPhase::Failed { .. } => ("Revert", "Clear this failed edit"), + UiPendingEditPhase::Failed { .. } => ("Revert", "Discard this failed edit"), } } @@ -192,7 +191,6 @@ mod tests { fn entries_filter_into_their_bucket_preserving_order() { let edits = vec![ edit("entries[a]", UiPendingEditPhase::Persisted), - edit("rate", UiPendingEditPhase::Live), edit( "entries[c]", UiPendingEditPhase::Failed { @@ -212,7 +210,6 @@ mod tests { paths(PendingEditBucket::Persisted), vec!["entries[a]", "entries[b]"] ); - assert_eq!(paths(PendingEditBucket::Live), vec!["rate"]); assert_eq!(paths(PendingEditBucket::Failed), vec!["entries[c]"]); } @@ -280,19 +277,11 @@ mod tests { bucket_section_tint(PendingEditBucket::Persisted, 2), DetailSectionTint::Warning ); - assert_eq!( - bucket_section_tint(PendingEditBucket::Live, 1), - DetailSectionTint::Live - ); assert_eq!( bucket_section_tint(PendingEditBucket::Failed, 1), DetailSectionTint::Error ); - for bucket in [ - PendingEditBucket::Persisted, - PendingEditBucket::Live, - PendingEditBucket::Failed, - ] { + for bucket in [PendingEditBucket::Persisted, PendingEditBucket::Failed] { assert_eq!(bucket_section_tint(bucket, 0), DetailSectionTint::None); } } @@ -347,10 +336,6 @@ mod tests { revert_label(&edit("a", UiPendingEditPhase::Persisted)).0, "Revert" ); - assert_eq!( - revert_label(&edit("a", UiPendingEditPhase::Live)).0, - "Reset" - ); assert_eq!( revert_label(&edit( "a", diff --git a/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs b/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs index 0f70c64d4..0cc49dbb0 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_node_tree.rs @@ -196,8 +196,6 @@ fn tree_item_dirty_var_class(dirty: DirtySummary) -> &'static str { "tw:[--studio-tree-dirty-bg:var(--studio-status-error-bg)]" } else if dirty.persisted > 0 { "tw:[--studio-tree-dirty-bg:var(--studio-status-warning-bg)]" - } else if dirty.transient > 0 { - "tw:[--studio-tree-dirty-bg:var(--studio-status-live-bg)]" } else { "" } @@ -218,9 +216,6 @@ fn tree_item_title(kind: &str, status: &ProjectNodeStatusView, dirty: DirtySumma if dirty.persisted > 0 { parts.push(format!("{} unsaved", dirty.persisted)); } - if dirty.transient > 0 { - parts.push(format!("{} live", dirty.transient)); - } if dirty.failed > 0 { parts.push(format!("{} failed", dirty.failed)); } @@ -231,12 +226,8 @@ fn tree_item_title(kind: &str, status: &ProjectNodeStatusView, dirty: DirtySumma mod tests { use super::*; - fn dirty(persisted: usize, transient: usize, failed: usize) -> DirtySummary { - DirtySummary { - persisted, - transient, - failed, - } + fn dirty(persisted: usize, failed: usize) -> DirtySummary { + DirtySummary { persisted, failed } } #[test] @@ -249,7 +240,7 @@ mod tests { #[test] fn dirty_row_wears_the_node_header_tint_in_the_dominant_bucket_color() { - let unsaved = tree_item_row_class(false, dirty(2, 0, 0)); + let unsaved = tree_item_row_class(false, dirty(2, 0)); assert!(unsaved.contains("tw:[--studio-tree-dirty-bg:var(--studio-status-warning-bg)]")); assert!(unsaved.contains( "tw:bg-[linear-gradient(90deg,var(--studio-tree-dirty-bg),transparent_62%)]" @@ -258,18 +249,18 @@ mod tests { assert!(unsaved.contains("tw:bg-card-subtle")); assert!( - tree_item_row_class(false, dirty(0, 1, 0)) - .contains("--studio-tree-dirty-bg:var(--studio-status-live-bg)") - ); - assert!( - tree_item_row_class(false, dirty(1, 1, 1)) + tree_item_row_class(false, dirty(1, 1)) .contains("--studio-tree-dirty-bg:var(--studio-status-error-bg)") ); + // D7: a subtree carrying only debug overrides reads clean — the + // summary never learns about them, so no wash and no variable. + let debug_only = tree_item_row_class(false, DirtySummary::clean()); + assert!(!debug_only.contains("--studio-tree-dirty-bg:")); } #[test] fn focused_dirty_row_mixes_the_dirty_color_into_the_selection_highlight() { - let class = tree_item_row_class(true, dirty(2, 0, 0)); + let class = tree_item_row_class(true, dirty(2, 0)); assert!(class.contains("tw:border-selection-border")); assert!(class.contains("--studio-tree-dirty-bg:var(--studio-status-warning-bg)")); assert!(class.contains( @@ -307,11 +298,11 @@ mod tests { "Visual — Warning: using fallback palette" ); assert_eq!( - tree_item_title("Shader", &running, dirty(2, 1, 0)), - "Shader — Running — edits in this subtree: 2 unsaved, 1 live" + tree_item_title("Shader", &running, dirty(2, 0)), + "Shader — Running — edits in this subtree: 2 unsaved" ); assert_eq!( - tree_item_title("Output", &running, dirty(0, 0, 3)), + tree_item_title("Output", &running, dirty(0, 3)), "Output — Running — edits in this subtree: 3 failed" ); } @@ -345,7 +336,7 @@ mod tests { assert!(affordance_indicator_class(clean.affordance()).is_none()); // Dirty and failing rows announce with the affordance glyph. - let unsaved = item(ProjectNodeStatusTone::Good, dirty(1, 0, 0)); + let unsaved = item(ProjectNodeStatusTone::Good, dirty(1, 0)); assert_eq!(unsaved.affordance(), UiAffordance::Unsaved); let warn = item(ProjectNodeStatusTone::Warning, DirtySummary::clean()); assert_eq!(warn.affordance(), UiAffordance::Error); diff --git a/lp-app/lpa-studio-web/src/app/project/project_pane.rs b/lp-app/lpa-studio-web/src/app/project/project_pane.rs index edf45ce10..a7b1827f0 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_pane.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_pane.rs @@ -9,7 +9,9 @@ //! intercepting the P4 add action's default create), and a `DetailPopover` //! at the right edge whose trigger renders the pane's one core-computed //! `UiAffordance` -//! (P6 affordance model). No status chip and no count chips in the header: +//! (P6 affordance model), plus — and ONLY when the project carries active +//! debug overrides — the global "Debug active · N · Clear all" chip (D8 tier +//! a). No status chip and no count chips in the header: //! the status word ("Ready", "Syncing", …), the per-bucket dirty counts, and //! the project stats all live in the detail popup. //! @@ -21,8 +23,8 @@ use dioxus::prelude::*; use lpa_studio_core::{ - DirtySummary, ProjectEditorView, ProjectSyncPhase, UiAction, UiAffordance, UiConfigSlot, - UiMetric, UiPendingEdit, UiStatus, + ControllerId, DirtySummary, ProjectController, ProjectEditorView, ProjectOp, ProjectSyncPhase, + UiAction, UiAffordance, UiConfigSlot, UiMetric, UiPendingEdit, UiStatus, }; use crate::app::affordance::{affordance_pane_tone, affordance_trigger_style}; @@ -73,6 +75,9 @@ pub fn ProjectPane( let pending_edits = view.pending_edits.clone(); let root_slots = view.root_slots.clone(); let library_identity = view.library_identity.clone(); + // D8 tier (a): the project-wide debug channel, deliberately outside the + // dirty rollup that drives `chrome`/`affordance` (D7). + let debug_overrides = view.debug_overrides; rsx! { StudioPane { @@ -81,6 +86,9 @@ pub fn ProjectPane( chrome, actions: header_actions, on_action, + trailing: rsx! { + DebugActiveChip { count: debug_overrides, on_action } + }, detail: rsx! { ProjectDetailPopover { affordance, @@ -121,6 +129,44 @@ pub fn ProjectPane( } } +/// The global **"Debug active · N · Clear all"** chip (D8 tier a): present +/// whenever ANY debug override is active anywhere in the project, absent +/// otherwise. It is the only project-level announcement debug overrides get — +/// they are not dirty (D7), so they never reach the header wash, the Save +/// affordances, or the save panel's change list. +/// +/// Pressing it dispatches [`ProjectOp::ClearDebugEdits`] (label "Clear all" +/// already lives on the op's `ActionMeta`, so the chip stays pure +/// presentation); persisted edits survive untouched — this is not Revert-all. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn DebugActiveChip(count: usize, on_action: EventHandler) -> Element { + if count == 0 { + return rsx! {}; + } + let action = UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + ProjectOp::ClearDebugEdits, + ); + let summary = action.meta().summary.clone(); + + rsx! { + div { class: "tw:flex tw:items-center tw:pr-1", + button { + class: "lp-debug-global-chip", + r#type: "button", + title: "{summary}", + aria_label: "Clear all debug overrides", + onclick: move |event| { + event.stop_propagation(); + on_action.call(action.clone()); + }, + "Debug active · {count} · Clear all" + } + } + } +} + /// The detail popup on the shared [`DetailPopover`] base — the save panel: /// project identity with the status word (its only home — headers no longer /// carry a status chip), the root's "Project settings" identity rows (P6 @@ -128,8 +174,8 @@ pub fn ProjectPane( /// and the read-only `format`/`uid`/`nodes` rows — live here, as /// purpose-built controls rather than generic slot editors; see /// [`ProjectSettingsSection`]), the pending-edit state, -/// overlay revision, the per-bucket [`DetailSection`]s (unsaved / live / -/// failed) as titled change lists with per-entry revert (a populated bucket +/// overlay revision, the per-bucket [`DetailSection`]s (unsaved / failed — +/// there is no debug bucket, D7) as titled change lists with per-entry revert (a populated bucket /// wears its affordance tint on the title; the count rides the title row's /// meta cell), and the project stats (moved here from the old sidebar /// MetricGrid card). @@ -155,7 +201,6 @@ fn ProjectDetailPopover( let label = trigger_label(affordance); let status_class = node_status_label_class(status.kind); let unsaved_entries = entries_in(&pending_edits, PendingEditBucket::Persisted); - let live_entries = entries_in(&pending_edits, PendingEditBucket::Live); let failed_entries = entries_in(&pending_edits, PendingEditBucket::Failed); rsx! { @@ -206,15 +251,6 @@ fn ProjectDetailPopover( tint: bucket_section_tint(PendingEditBucket::Persisted, dirty.persisted), PendingEditList { entries: unsaved_entries, on_action } } - DetailSection { - title: "Live (transient)", - meta: dirty.transient.to_string(), - tint: bucket_section_tint(PendingEditBucket::Live, dirty.transient), - PendingEditList { entries: live_entries, on_action } - p { class: "tw:m-0 tw:pt-1 tw:text-[0.68rem] tw:leading-snug tw:text-subtle-foreground", - "Live controls apply to the running project and are never written by Save." - } - } if dirty.failed > 0 || !failed_entries.is_empty() { DetailSection { title: "Failed edits", @@ -250,7 +286,7 @@ fn trigger_label(affordance: UiAffordance) -> &'static str { match affordance { UiAffordance::Info => "Project details — no unsaved changes", UiAffordance::Busy => "Project activity in progress", - UiAffordance::Live => "Project has live-only edits", + UiAffordance::Debug => "Project has debug overrides", UiAffordance::Unsaved => "Project has unsaved changes", UiAffordance::Error => "Project needs attention", } @@ -261,7 +297,7 @@ fn state_label(affordance: UiAffordance) -> &'static str { match affordance { UiAffordance::Info => "unchanged", UiAffordance::Busy => "in progress", - UiAffordance::Live => "live edits only", + UiAffordance::Debug => "debug overrides only", UiAffordance::Unsaved => "uncommitted", UiAffordance::Error => "needs attention", } @@ -274,12 +310,8 @@ mod tests { use super::*; - fn dirty(persisted: usize, transient: usize, failed: usize) -> DirtySummary { - DirtySummary { - persisted, - transient, - failed, - } + fn dirty(persisted: usize, failed: usize) -> DirtySummary { + DirtySummary { persisted, failed } } fn editor_view(dirty: DirtySummary, edits_in_flight: usize) -> ProjectEditorView { @@ -305,18 +337,19 @@ mod tests { // Persisted edits: the edited pencil, even while an ack is pending // (Unsaved outranks Busy in the shared priority). - let uncommitted = editor_view(dirty(1, 0, 0), 1).affordance(UiStatusKind::Good); + let uncommitted = editor_view(dirty(1, 0), 1).affordance(UiStatusKind::Good); assert_eq!(uncommitted, UiAffordance::Unsaved); assert_eq!(state_label(uncommitted), "uncommitted"); // In-flight only: genuine activity. - let busy = editor_view(dirty(0, 0, 0), 1).affordance(UiStatusKind::Good); + let busy = editor_view(dirty(0, 0), 1).affordance(UiStatusKind::Good); assert_eq!(busy, UiAffordance::Busy); assert_eq!(state_label(busy), "in progress"); - // Live-only edits stay distinct from unsaved. - let live = editor_view(dirty(0, 2, 0), 0).affordance(UiStatusKind::Good); - assert_eq!(live, UiAffordance::Live); + // D7: a project whose only pending edits are debug overrides reads + // clean — they never enter the summary, so the trigger stays quiet. + let debug_only = editor_view(DirtySummary::clean(), 0).affordance(UiStatusKind::Good); + assert_eq!(debug_only, UiAffordance::Info); } #[test] @@ -330,20 +363,11 @@ mod tests { tone(DirtySummary::clean(), 0, UiStatusKind::Good), PaneTone::Good ); - assert_eq!(tone(dirty(1, 0, 1), 2, UiStatusKind::Good), PaneTone::Error); - assert_eq!( - tone(dirty(2, 1, 0), 0, UiStatusKind::Good), - PaneTone::Warning - ); - assert_eq!(tone(dirty(0, 1, 0), 0, UiStatusKind::Good), PaneTone::Live); - assert_eq!( - tone(dirty(0, 0, 0), 1, UiStatusKind::Good), - PaneTone::Working - ); + assert_eq!(tone(dirty(1, 1), 2, UiStatusKind::Good), PaneTone::Error); + assert_eq!(tone(dirty(2, 0), 0, UiStatusKind::Good), PaneTone::Warning); + assert_eq!(tone(dirty(0, 1), 0, UiStatusKind::Good), PaneTone::Error); + assert_eq!(tone(dirty(0, 0), 1, UiStatusKind::Good), PaneTone::Working); // An error pane status is never masked by a dirty wash. - assert_eq!( - tone(dirty(0, 1, 0), 0, UiStatusKind::Error), - PaneTone::Error - ); + assert_eq!(tone(dirty(1, 0), 0, UiStatusKind::Error), PaneTone::Error); } } diff --git a/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs b/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs index 0b5fa4c8f..67bf6e00d 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_pane_stories.rs @@ -36,7 +36,6 @@ pub(crate) fn uncommitted() -> Element { StoryPane { dirty: DirtySummary { persisted: 2, - transient: 1, failed: 0, }, edits_in_flight: 0, @@ -46,18 +45,68 @@ pub(crate) fn uncommitted() -> Element { } #[story( - description = "Only live (transient) edits: blue header wash; no persisted edits, so no Save/Revert icons and a quiet 'i' trigger." + description = "D8 tier (a): the global \"Debug active · N · Clear all\" chip. Three debug overrides are live somewhere in the project, and NOTHING else announces them — the header wash stays clean, there are no Save/Revert icons, and the detail trigger keeps its quiet \"i\", because a debug override is not unsaved work (D7). Pressing the chip dispatches `ProjectOp::ClearDebugEdits`, which leaves persisted edits alone." )] -pub(crate) fn live_only() -> Element { +pub(crate) fn debug_active_chip() -> Element { + rsx! { + StoryPane { + dirty: DirtySummary::default(), + edits_in_flight: 0, + actions: false, + debug_overrides: 3, + } + } +} + +#[story( + description = "The chip beside real unsaved work: two persisted edits (amber wash, Save/Revert icons, edited trigger) AND three debug overrides. The two channels read as different things — the chip is hazard-striped orange, the dirty treatment is amber — and the counts never mix." +)] +pub(crate) fn debug_active_with_unsaved() -> Element { rsx! { StoryPane { dirty: DirtySummary { - persisted: 0, - transient: 2, + persisted: 2, failed: 0, }, edits_in_flight: 0, - actions: false, + actions: true, + debug_overrides: 3, + } + } +} + +#[story( + description = "G1 absence proof (D7): the save panel open while three debug overrides are active. The change list holds ONLY the two persisted edits, the Unsaved count reads 2, and there is no debug section anywhere in the popup — Save has nothing to do with debug values. The chip in the header is their one and only announcement." +)] +pub(crate) fn debug_absent_from_save_panel() -> Element { + rsx! { + div { class: "tw:flex tw:min-h-[640px] tw:justify-start", + StoryPane { + dirty: DirtySummary { + persisted: 2, + failed: 0, + }, + edits_in_flight: 0, + actions: true, + debug_overrides: 3, + initially_open: true, + pending_edits: vec![ + pending_edit( + "Orbit shader", + "brightness", + UiPendingEditKind::Assign { + value_display: "0.82".to_string(), + }, + UiPendingEditPhase::Persisted, + ), + pending_edit( + "Sunrise palette", + "entries[dusk]", + UiPendingEditKind::Added, + UiPendingEditPhase::Persisted, + ), + ], + } } } } @@ -70,7 +119,6 @@ pub(crate) fn in_progress() -> Element { StoryPane { dirty: DirtySummary { persisted: 1, - transient: 0, failed: 0, }, edits_in_flight: 1, @@ -80,7 +128,7 @@ pub(crate) fn in_progress() -> Element { } #[story( - description = "The detail popup as the save panel: identity with the status pill, state, overlay revision, and the per-bucket sections as headed change lists (counts in the headers, node label + path + op/value + revert per row), plus the project stats section." + description = "The detail popup as the save panel: identity with the status pill, state, overlay revision, and the per-bucket sections as headed change lists (counts in the headers, node label + path + op/value + revert per row), plus the project stats section. Debug overrides never appear here (D7)." )] pub(crate) fn detail_popup() -> Element { rsx! { @@ -88,7 +136,6 @@ pub(crate) fn detail_popup() -> Element { StoryPane { dirty: DirtySummary { persisted: 2, - transient: 1, failed: 0, }, edits_in_flight: 0, @@ -105,7 +152,6 @@ pub(crate) fn detail_popup() -> Element { UiPendingEditKind::Added, UiPendingEditPhase::Persisted, ), - assign_edit("Orbit shader", "controls.rate", "2.0", UiPendingEditPhase::Live), ], } } @@ -113,7 +159,7 @@ pub(crate) fn detail_popup() -> Element { } #[story( - description = "A mixed change list in the save panel: value assigns (old → new where the saved value is known), a structural add and remove (the remove with its replaced value), a live control, and a failed entry with its reason in the error-tinted section — every row with its own revert." + description = "A mixed change list in the save panel: value assigns (old → new where the saved value is known), a structural add and remove (the remove with its replaced value), and a failed entry with its reason in the error-tinted section — every row with its own revert. Debug overrides are deliberately absent: they are not dirty (D7)." )] pub(crate) fn change_list() -> Element { rsx! { @@ -121,7 +167,6 @@ pub(crate) fn change_list() -> Element { StoryPane { dirty: DirtySummary { persisted: 3, - transient: 1, failed: 1, }, edits_in_flight: 0, @@ -147,7 +192,6 @@ pub(crate) fn change_list() -> Element { ), "{\"shader\":\"stripe.glsl\",\"duration\":2.0}", ), - assign_edit("Orbit shader", "controls.rate", "2.0", UiPendingEditPhase::Live), pending_edit( "Sunrise palette", "entries[ghost]", @@ -197,7 +241,6 @@ pub(crate) fn change_list_overflow() -> Element { StoryPane { dirty: DirtySummary { persisted: 14, - transient: 0, failed: 0, }, edits_in_flight: 0, @@ -321,7 +364,6 @@ pub(crate) fn staged_node_removal() -> Element { StoryPane { dirty: DirtySummary { persisted: 3, - transient: 0, failed: 0, }, edits_in_flight: 0, @@ -351,10 +393,12 @@ fn StoryPane( actions: bool, #[props(default = false)] initially_open: bool, #[props(default = false)] add_picker_open: bool, + #[props(default = 0)] debug_overrides: usize, #[props(default = Vec::new())] pending_edits: Vec, ) -> Element { let mut view = project_editor_fixture(ProjectSyncPhase::Ready); view.dirty = dirty; + view.debug_overrides = debug_overrides; view.edits_in_flight = edits_in_flight; view.pending_edits = pending_edits; view.header_actions = if actions { diff --git a/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs index 75ba26dfc..e0ee39070 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs @@ -28,26 +28,24 @@ pub(crate) fn project_pane() -> Element { } #[story( - description = "The project pane with a dirty tree: uncommitted header with Save/Revert icons and the pencil trigger; the focused shader row's selection highlight mixes in the unsaved yellow, the palette row wears the live blue header tint, the root row the aggregate unsaved tint — rows carry only the small affordance glyph (no status words or count badges; the breakdown is in the tooltip, counts in the popup)." + description = "The project pane with a dirty tree: uncommitted header with Save/Revert icons and the pencil trigger; the focused shader row's selection highlight mixes in the unsaved yellow, the palette row wears the failed red tint, the root row the aggregate tint — rows carry only the small affordance glyph (no status words or count badges; the breakdown is in the tooltip, counts in the popup)." )] pub(crate) fn sidebar_dirty_tree() -> Element { let mut view = project_editor_fixture(ProjectSyncPhase::Ready); let unsaved = DirtySummary { persisted: 2, - transient: 0, failed: 0, }; - let live = DirtySummary { + let failed = DirtySummary { persisted: 0, - transient: 1, - failed: 0, + failed: 1, }; if let Some(root) = view.tree.roots.first_mut() { - root.dirty = unsaved.merge(live); + root.dirty = unsaved.merge(failed); root.children[1].dirty = unsaved; - root.children[2].dirty = live; + root.children[2].dirty = failed; } - view.dirty = unsaved.merge(live); + view.dirty = unsaved.merge(failed); view.edits_in_flight = 0; view.header_actions = vec![ UiPaneAction::new( diff --git a/lp-app/lpa-studio-web/src/base/detail_popover.rs b/lp-app/lpa-studio-web/src/base/detail_popover.rs index 5e830cc2c..e054ec1d7 100644 --- a/lp-app/lpa-studio-web/src/base/detail_popover.rs +++ b/lp-app/lpa-studio-web/src/base/detail_popover.rs @@ -148,6 +148,9 @@ pub enum DetailSectionTint { Error, /// Live/transient (blue) wash. Live, + /// **Debug** (D9): attention-orange + hazard stripes, the section wash + /// for transient-by-nature diagnostics territory. + Debug, /// Bound/bus-linked (violet) wash. Bound, } @@ -178,6 +181,7 @@ pub fn detail_popover_section_class(tint: DetailSectionTint) -> &'static str { DetailSectionTint::Live => { "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-live-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" } + DetailSectionTint::Debug => "lp-debug-detail-section", DetailSectionTint::Bound => { "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-bound-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" } @@ -208,6 +212,7 @@ fn detail_section_title_class(tint: DetailSectionTint) -> &'static str { DetailSectionTint::Live => { "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:text-status-live-foreground" } + DetailSectionTint::Debug => "tw:m-0 tw:text-xs tw:font-bold tw:uppercase lp-debug-title", DetailSectionTint::Bound => { "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:text-status-bound-foreground" } diff --git a/lp-app/lpa-studio-web/src/base/detail_popover_stories.rs b/lp-app/lpa-studio-web/src/base/detail_popover_stories.rs index 6db62e9ac..0bc791680 100644 --- a/lp-app/lpa-studio-web/src/base/detail_popover_stories.rs +++ b/lp-app/lpa-studio-web/src/base/detail_popover_stories.rs @@ -15,6 +15,7 @@ pub(crate) fn open_sections() -> Element { ("Warning", DetailSectionTint::Warning), ("Error", DetailSectionTint::Error), ("Live", DetailSectionTint::Live), + ("Debug", DetailSectionTint::Debug), ]; rsx! { diff --git a/lp-app/lpa-studio-web/src/base/icon_menu.rs b/lp-app/lpa-studio-web/src/base/icon_menu.rs index 75aeff032..da89c5030 100644 --- a/lp-app/lpa-studio-web/src/base/icon_menu.rs +++ b/lp-app/lpa-studio-web/src/base/icon_menu.rs @@ -60,6 +60,7 @@ pub(crate) fn icon_menu_chrome_class(tone: IconMenuTone) -> &'static str { IconMenuTone::Good => "ux-popover-chrome-good", IconMenuTone::Working => "ux-popover-chrome-working", IconMenuTone::Live => "ux-popover-chrome-live", + IconMenuTone::Debug => "ux-popover-chrome-debug", IconMenuTone::Warning => "ux-popover-chrome-warning", IconMenuTone::Attention => "ux-popover-chrome-attention", IconMenuTone::Error => "ux-popover-chrome-error", @@ -76,6 +77,10 @@ pub enum IconMenuTone { Working, /// Live-only (transient) edit state, blue. Live, + /// **Debug** territory (D9): attention-orange + hazard stripes. Distinct + /// from [`Self::Attention`] (flat orange = device health) and from + /// [`Self::Live`] (blue = live values). Look defined in `style.css`. + Debug, /// Unsaved/edit state, yellow (node vocabulary). Warning, /// Health-attention state, orange (device/roster vocabulary). @@ -133,6 +138,9 @@ fn icon_menu_class(tone: IconMenuTone, active: bool) -> &'static str { (IconMenuTone::Live, _) => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-live-border tw:bg-status-live-bg tw:p-0 tw:text-status-live-foreground tw:transition-colors tw:hover:border-status-live-foreground" } + (IconMenuTone::Debug, _) => { + "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:p-0 tw:transition-colors lp-debug-icon-chrome" + } (IconMenuTone::Warning, _) => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground tw:transition-colors tw:hover:border-status-warning-foreground" } @@ -174,6 +182,9 @@ fn icon_menu_hover_class(tone: IconMenuTone, active: bool) -> &'static str { (IconMenuTone::Live, _) => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-live-foreground tw:bg-status-live-bg tw:p-0 tw:text-status-live-foreground tw:transition-colors" } + (IconMenuTone::Debug, _) => { + "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:p-0 tw:transition-colors lp-debug-icon-chrome lp-debug-icon-chrome--hover" + } (IconMenuTone::Warning, _) => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-foreground tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground tw:transition-colors" } @@ -209,6 +220,9 @@ fn icon_menu_open_class(tone: IconMenuTone) -> &'static str { IconMenuTone::Live => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-live-border tw:bg-status-live-bg tw:p-0 tw:text-status-live-foreground" } + IconMenuTone::Debug => { + "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:p-0 tw:transition-colors lp-debug-icon-chrome" + } IconMenuTone::Warning => { "tw:inline-flex tw:h-8 tw:w-8 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground" } diff --git a/lp-app/lpa-studio-web/src/exploration/rich_object_stories.rs b/lp-app/lpa-studio-web/src/exploration/rich_object_stories.rs index 43dd49a67..2ab32794c 100644 --- a/lp-app/lpa-studio-web/src/exploration/rich_object_stories.rs +++ b/lp-app/lpa-studio-web/src/exploration/rich_object_stories.rs @@ -530,7 +530,10 @@ fn tint_severity(tint: DetailSectionTint) -> u8 { match tint { DetailSectionTint::Error => 4, DetailSectionTint::Warning | DetailSectionTint::Attention => 3, - DetailSectionTint::Working | DetailSectionTint::Live | DetailSectionTint::Bound => 2, + DetailSectionTint::Working + | DetailSectionTint::Live + | DetailSectionTint::Debug + | DetailSectionTint::Bound => 2, DetailSectionTint::Good => 1, DetailSectionTint::None => 0, } diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 671a61528..37729fddc 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -126,6 +126,27 @@ --studio-status-attention-bg: #291f14; --studio-status-attention-border: #795833; --studio-status-attention-text: #f0b97a; + /* Debug (D9): transient-by-nature diagnostics/authoring territory. + Borrows the attention family's orange so it reads as "not ordinary + config", then adds DIAGONAL HAZARD STRIPES as the distinguishing mark + — flat orange stays device/roster health, and stripes are what keep + the two apart at a glance. Never amber-unsaved, blue-live, + violet-bound, or green. This is the ONE place the debug look is + defined; the semantic variant lives in `lpa-studio-core` + (`UiAffordance::Debug`) and the web maps it here. */ + --studio-status-debug-bg: #291f14; + --studio-status-debug-border: #795833; + --studio-status-debug-text: #f0b97a; + --studio-status-debug-stripes: repeating-linear-gradient( + 135deg, + rgba(240, 185, 122, 0.03) 0 4px, + transparent 4px 14px + ); + --studio-status-debug-stripes-strong: repeating-linear-gradient( + 135deg, + rgba(240, 185, 122, 0.07) 0 4px, + transparent 4px 14px + ); --studio-status-error-bg: #301b1d; --studio-status-error-border: #874b4b; --studio-status-error-text: #ffc7c7; @@ -2448,6 +2469,14 @@ label:has(> input:not(:disabled)) { --ux-popover-panel-fill-away: var(--studio-color-surface-raised); } +.ux-popover-chrome-debug { + --ux-popover-border-color: var(--studio-status-debug-border); + --ux-popover-icon-color: var(--studio-status-debug-text); + --ux-popover-trigger-fill-top: var(--studio-status-debug-bg); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + .ux-popover-chrome-bound { --ux-popover-border-color: var(--studio-status-bound-border); --ux-popover-icon-color: var(--studio-status-bound-text); @@ -4389,3 +4418,320 @@ label:has(> input:not(:disabled)) { .lpb-ed-hidden-input { display: none; } + +/* --------------------------------------------------------------------------- + Debug (hazard) treatment — D9. + --------------------------------------------------------------------------- + The SEMANTIC variant lives in `lpa-studio-core` (`UiAffordance::Debug`, + projected by `UiAffordance::from_debug_overrides`). The web layer maps that + one variant onto `PaneTone::Debug` / `IconMenuTone::Debug` / + `DetailSectionTint::Debug` in `src/app/affordance.rs`, and every pixel of + the look is defined HERE — change the treatment by editing this block, not + by touching core. + + Grammar: attention-orange + diagonal hazard stripes. Three tiers (D8): + (a) `.lp-debug-global-chip` — the project header's "Debug active · N · + Clear all"; + (b) `.lp-debug-marker` — the node card's marking while it carries an + override; + (c) `.lp-debug-section` — the Debug section itself, striped ALWAYS, even + with no override active, so a control announces "transient" before it + is touched. `.lp-debug-section--active` deepens the stripes once an + override is live. + -------------------------------------------------------------------------- */ + +.lp-debug-section { + display: grid; + min-width: 0; + background: + var(--studio-status-debug-stripes), + linear-gradient(90deg, var(--studio-status-debug-bg), transparent 78%); + border-top: 1px solid var(--studio-status-debug-border); + box-shadow: inset 3px 0 0 0 var(--studio-status-debug-border); +} + +.lp-debug-section--active { + background: + var(--studio-status-debug-stripes-strong), + linear-gradient(90deg, var(--studio-status-debug-bg), transparent 72%); +} + +/* The section's own header strip: the disclosure toggle (chevron + DEBUG + + note) on the left, Clear on the right. It is ALWAYS visible — collapsing + hides only the rows — and its height is FIXED so the count and the Clear + button appearing can never reflow the card (G1 feedback). The min-height + is sized to the tallest occupant (the Clear pill: 10px text + 2*3px pad + + 2*1px border = 18px). */ +.lp-debug-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + padding: 5px 10px 5px 8px; +} + +/* The divider belongs between the header and the rows, so it appears only + once the disclosure is open. */ +.lp-debug-section--open .lp-debug-section-header { + border-bottom: 1px solid color-mix(in oklab, var(--studio-status-debug-border) 55%, transparent); +} + +/* The whole label cluster is the disclosure control (chevron + label + note), + so the hit target is generous without a separate affordance. */ +.lp-debug-section-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + height: 18px; + appearance: none; + cursor: pointer; + border: 0; + background: transparent; + padding: 0 4px; + border-radius: var(--studio-radius-xs); + color: inherit; + text-align: left; +} + +.lp-debug-section-toggle:hover .lp-debug-section-label { + color: var(--studio-color-text-strong); +} + +/* Rotation follows the section grammar: the glyph previews the state the + press leads to. */ +/* The chevron states the section's ACTUAL open/closed state and nothing + else — no hover-previews-the-other-state rotation here (G1 feedback: + confusing on this header); hover feedback is opacity only. */ +.lp-debug-section-chevron { + display: inline-flex; + flex: none; + opacity: 0.75; + transition: opacity 150ms ease; +} + +.lp-debug-section-toggle:hover .lp-debug-section-chevron { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .lp-debug-section-chevron { + transition: none; + } +} + +.lp-debug-section-label { + display: inline-flex; + align-items: center; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + line-height: 1; + color: var(--studio-status-debug-text); + user-select: none; +} + +.lp-debug-section-note { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.04em; + line-height: 1; + text-transform: none; + color: var(--studio-color-text-dim); + user-select: none; +} + +/* Settings section header — the quiet counterpart, so the two sections read + as a pair (D4/D6: "Settings" above, "Debug" below). Same fixed height as + the debug header so the two strips line up. */ +.lp-settings-section-header { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 5px 10px 5px 12px; + border-bottom: 1px solid var(--studio-color-border-muted); +} + +.lp-settings-section-label { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.14em; + line-height: 1; + color: var(--studio-color-text-dim); + user-select: none; +} + +/* Shared pill shape for the Clear buttons and the debug markers. */ +.lp-debug-button, +.lp-debug-chip, +.lp-debug-marker, +.lp-debug-global-chip { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + font-family: inherit; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 1; + color: var(--studio-status-debug-text); + background: + var(--studio-status-debug-stripes), + var(--studio-status-debug-bg); + border: 1px solid var(--studio-status-debug-border); + border-radius: var(--studio-radius-pill); + padding: 3px 9px; +} + +/* Pinned to the header's reserved height so Clear appearing cannot grow the + strip (the header's min-height is sized to exactly this). */ +.lp-debug-button { + height: 18px; +} + +.lp-debug-button, +.lp-debug-global-chip { + appearance: none; + cursor: pointer; + transition: border-color 120ms ease, color 120ms ease; +} + +.lp-debug-button:hover, +.lp-debug-global-chip:hover { + border-color: var(--studio-status-debug-text); + color: var(--studio-color-text-strong); +} + +@media (prefers-reduced-motion: reduce) { + .lp-debug-button, + .lp-debug-global-chip { + transition: none; + } +} + +/* Node-card marking (tier b): sits in the card header's trailing slot, whose + items stretch full height — the pill centers itself instead. */ +.lp-debug-marker { + align-self: center; + flex: none; + margin-right: 4px; + font-size: 0.58rem; + padding: 2px 8px; +} + +/* Pane header wash for `PaneTone::Debug`. */ +.lp-debug-pane-tint { + background: + var(--studio-status-debug-stripes), + linear-gradient(90deg, var(--studio-status-debug-bg), transparent 62%); +} + +/* A touched Debug slot row: a plain orange wash at row scale — the section + already supplies the stripes, so rows never re-stripe (stacking the two is + what made the first cut read many times too strong; G1 feedback). + + Geometry is deliberately IDENTICAL to `slot_row_class`'s (the class an + untouched row wears), and `min-height` pins the shared floor, so becoming + touched changes colour and nothing else. */ +.lp-debug-row { + display: grid; + min-width: 0; + grid-template-columns: minmax(120px, 0.4fr) minmax(0, 1fr) 32px; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: linear-gradient(270deg, var(--studio-status-debug-bg) 0%, var(--studio-status-debug-bg) 34%, transparent 100%); +} + +/* The shared floor both debug row states carry — 32px detail button + 2*6px + row padding, i.e. the height the row already had. It exists so the two + states are provably the same box even if their content differs in height. */ +.lp-debug-row-floor { + min-height: 44px; +} + +/* The untouched half of that contract: an empty box the exact size of the + inline Clear button, so the verb appearing neither grows the row nor + shifts the value editor sideways. */ +.lp-debug-row-verb-reserve { + display: inline-flex; + height: 24px; + width: 24px; + flex: none; +} + +/* The row's inline Clear icon button. */ +.lp-debug-row-button { + display: inline-flex; + height: 24px; + width: 24px; + flex: none; + align-items: center; + justify-content: center; + appearance: none; + cursor: pointer; + border-radius: var(--studio-radius-xs); + border: 1px solid var(--studio-status-debug-border); + background: var(--studio-status-debug-bg); + color: var(--studio-status-debug-text); + padding: 0; + transition: border-color 120ms ease; +} + +.lp-debug-row-button:hover { + border-color: var(--studio-status-debug-text); +} + +/* Tree-row / inline indicator foreground. */ +.lp-debug-indicator { + display: inline-flex; + height: 16px; + align-items: center; + justify-content: center; + color: var(--studio-status-debug-text); +} + +/* Detail-popover section wash for `DetailSectionTint::Debug`. */ +.lp-debug-detail-section { + display: grid; + gap: 2px; + border-top: 1px solid var(--studio-color-border-muted); + padding: 6px 12px; + background: + var(--studio-status-debug-stripes), + linear-gradient(90deg, var(--studio-status-debug-bg) 0%, transparent 72%); +} + +.lp-debug-detail-section:first-child { + border-top: 0; +} + +/* Icon-button chrome for `IconMenuTone::Debug` (the geometry stays on the + shared Tailwind utilities; only the debug colors live here). */ +.lp-debug-icon-chrome { + color: var(--studio-status-debug-text); + background: + var(--studio-status-debug-stripes), + var(--studio-status-debug-bg); + border-color: var(--studio-status-debug-border); +} + +.lp-debug-icon-chrome:hover, +.lp-debug-icon-chrome--hover { + border-color: var(--studio-status-debug-text); +} + +/* Debug section-title text color (the "color on the TITLE" convention). */ +.lp-debug-title { + color: var(--studio-status-debug-text); +} diff --git a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__lg.png b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__lg.png index 761d74446..e3edfea46 100644 Binary files a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__lg.png and b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__md.png b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__md.png index 9eefba1ee..1be18067d 100644 Binary files a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__md.png and b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__sm.png b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__sm.png index 9294c8d3d..f089d9afa 100644 Binary files a/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__sm.png and b/lp-app/lpa-studio-web/story-images/base__detail-popover__open-sections__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__lg.png index bd7135607..39b40651e 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__lg.png and b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__md.png index 9861c9516..f9afde1a2 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__md.png and b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__sm.png index f97c1dcf6..c219f7d77 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__sm.png and b/lp-app/lpa-studio-web/story-images/exploration__node-cards__gallery-drawers-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png index 5b6cc7908..2d41f7663 100644 Binary files a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__lg.png new file mode 100644 index 000000000..af0a931d4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__md.png new file mode 100644 index 000000000..b18a4be41 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__sm.png new file mode 100644 index 000000000..ad984b4bc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__lg.png new file mode 100644 index 000000000..c0cce67db Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__md.png new file mode 100644 index 000000000..a46e1bbaa Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__sm.png new file mode 100644 index 000000000..0e2f49e7e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-clean-controls__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-clean-controls__sm.png index 391032d87..9d473b7a9 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-clean-controls__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-clean-controls__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__lg.png deleted file mode 100644 index 01c863d83..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__md.png deleted file mode 100644 index d05b7814a..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__sm.png deleted file mode 100644 index 3ae37ce51..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__lg.png deleted file mode 100644 index fcecb4240..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__md.png deleted file mode 100644 index 3d46cbb64..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__sm.png deleted file mode 100644 index fa67f2654..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__rejected-edit__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__rejected-edit__sm.png index cd96c2f1b..92fd30273 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__rejected-edit__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__rejected-edit__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__lg.png index c748ce620..5c5015b69 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__md.png b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__md.png index 96c123641..c90501693 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__sm.png index 5e85edd15..94e230461 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__fixture-face__advanced-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__lg.png new file mode 100644 index 000000000..c35254d05 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__md.png new file mode 100644 index 000000000..21805ed03 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__sm.png new file mode 100644 index 000000000..624e7aa24 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__lg.png new file mode 100644 index 000000000..ad87c2001 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__md.png new file mode 100644 index 000000000..9db8017c0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__sm.png new file mode 100644 index 000000000..792467d81 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__lg.png new file mode 100644 index 000000000..36082ce83 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__md.png new file mode 100644 index 000000000..7bbc172c8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__sm.png new file mode 100644 index 000000000..f022f35be Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__lg.png new file mode 100644 index 000000000..707e77176 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__md.png new file mode 100644 index 000000000..8830f187b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__sm.png new file mode 100644 index 000000000..6d0910ca7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__lg.png new file mode 100644 index 000000000..12a01b5b1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__md.png new file mode 100644 index 000000000..2bfd3ab8a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__sm.png new file mode 100644 index 000000000..d648a4985 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__lg.png index f4b08bf0d..e03db65a8 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__md.png index 39ea33c36..50790c546 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__sm.png index 3fc617ca0..4501bdfb7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__lg.png index dbd9d3e05..969f33f80 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__md.png index 534f9bc96..c301ef628 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__sm.png index afdd51386..2676c8431 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-header-tint__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__lg.png index 57a0d8efe..e1e494e3f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__md.png index 21dcadd39..5a3d35f96 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__sm.png index a78c3961e..f838ddae5 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-failed-surface-tint__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__lg.png deleted file mode 100644 index 2d7ad18d4..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__md.png deleted file mode 100644 index f010e9b49..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__sm.png deleted file mode 100644 index 506728681..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__lg.png deleted file mode 100644 index 399d7536e..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__md.png deleted file mode 100644 index 5738476b8..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__sm.png deleted file mode 100644 index 68fc7b77e..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__lg.png index 477999c43..f3836aec5 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__md.png index 07819b10e..a7d305861 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__sm.png index beb7312ae..ff4c04c28 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-header-tint__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__lg.png index fe894a959..d53164170 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__md.png index b130d11f4..3d6ae78e5 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__sm.png index b2fcbe9c5..b205555ca 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__dirty-unsaved-surface-tint__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png index 9177a85f6..1b6fb14ec 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png index 2420bc2c3..d6c9a1ee5 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png index d30d0bdd6..a12f7d158 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__lg.png index 420f80001..e85176882 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__md.png index 51e75afbf..e666042d5 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__sm.png index d46a50e4b..057d6d878 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__header-delete-action__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__lg.png index bc719a56b..3fd229780 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__md.png index a65da9950..c7b679229 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__sm.png index f4c1cb378..f97a67927 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__nested-dirty-children__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png index 9b302369e..3cec401d6 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png index 537da3893..606502e81 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png index 578e0ca61..be41f0349 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__lg.png new file mode 100644 index 000000000..e153405ee Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__md.png new file mode 100644 index 000000000..ae8877226 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__sm.png new file mode 100644 index 000000000..4d1c912f6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__lg.png new file mode 100644 index 000000000..7212868b1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__md.png new file mode 100644 index 000000000..59a7a5150 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__sm.png new file mode 100644 index 000000000..2bccc9c2b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__lg.png index 368d2a50a..492cfdd6e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__md.png b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__md.png index 4b9b5ed4f..dfc635277 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__sm.png index fc2a547c5..75a3f6202 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__sm.png and b/lp-app/lpa-studio-web/story-images/studio__node__shader-face__advanced-open__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png index 2a1d00a5b..f64f791cf 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png index 035cfe3e8..bbebc35db 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png index 111802b39..61b6abdd9 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png index 6f5a0250a..94e075b6a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png index 165fa6a80..2138809df 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png index 49d99bd47..453417a3e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-overflow__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png index ff4a2f0ff..0bad32aa3 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png index 724faab25..3e9c73aab 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png index e3c44e588..3d0b80d39 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png new file mode 100644 index 000000000..5a8b416a7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png new file mode 100644 index 000000000..8c9cf9ceb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png new file mode 100644 index 000000000..5ea71ffc7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__lg.png new file mode 100644 index 000000000..cc71706bc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__md.png new file mode 100644 index 000000000..f2d869942 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png new file mode 100644 index 000000000..e8b6123e9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__lg.png new file mode 100644 index 000000000..7d91339f0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__md.png new file mode 100644 index 000000000..d1b403afd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png new file mode 100644 index 000000000..cfee3c540 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png index a98ac9ca0..41f603424 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png index 8fb6894a2..e2b6b8aad 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png index 7d9135005..2977ccc7c 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__lg.png deleted file mode 100644 index 4f473b85f..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__md.png deleted file mode 100644 index 428900f51..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png deleted file mode 100644 index 0161cce50..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png index d5dccc034..2b371867e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png index 36e1bc1a3..50c4bb2ed 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png index 12809f93c..90cb5c5cf 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__staged-node-removal__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png index 84fbb938e..1b2eced67 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png index f6ffe0ae7..6e29f8196 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png index b1c0bd939..7cc3fe739 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__lg.png index a3a21bb2f..781bfffa2 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__md.png index 2e0f5b68f..ccf5d47b3 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png index 1169b8698..e9190a261 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__sidebar-dirty-tree__sm.png differ diff --git a/lp-cli/src/commands/schema/generate.rs b/lp-cli/src/commands/schema/generate.rs index 45adf761a..e3c568676 100644 --- a/lp-cli/src/commands/schema/generate.rs +++ b/lp-cli/src/commands/schema/generate.rs @@ -419,6 +419,31 @@ mod tests { } } + /// D2 (P4): the clock's `controls.*` fields are `SlotRole::Debug` + /// (session-only), so `node.schema.json` must not advertise them as + /// authorable — kills W2, where the schema previously invited a def + /// author to write a value the loader now warns-and-ignores. + #[test] + fn node_schema_omits_clock_debug_controls() { + let outputs = generate_outputs().unwrap(); + let node: Value = serde_json::from_str(&outputs["node.schema.json"]).unwrap(); + let clock_branch = node["oneOf"] + .as_array() + .expect("node schema oneOf") + .iter() + .find(|branch| branch["properties"]["kind"]["const"] == json!("Clock")) + .expect("clock branch"); + + let controls = &clock_branch["properties"]["controls"]; + let controls_properties = controls["properties"] + .as_object() + .expect("controls is a compiled object schema"); + assert!( + controls_properties.is_empty(), + "clock controls.* are all Debug-role and must be omitted: {controls}" + ); + } + #[test] fn project_schema_pins_kind_and_format() { let outputs = generate_outputs().unwrap(); diff --git a/lp-core/lpc-engine/src/engine/project_read_stream.rs b/lp-core/lpc-engine/src/engine/project_read_stream.rs index 11e21eb26..be36e772e 100644 --- a/lp-core/lpc-engine/src/engine/project_read_stream.rs +++ b/lp-core/lpc-engine/src/engine/project_read_stream.rs @@ -818,17 +818,34 @@ mod tests { ); // Mutate the overlay at revision 9; the next read must report it. - let fs = lpfs::LpFsMemory::new(); + // `mutate` validates like the batch path (W9), so the edit must be a + // real one: load a minimal project root and stage a value on `name`, + // ProjectDef's one writable authored field. + let mut fs = lpfs::LpFsMemory::new(); + fs.write_file_mut( + lpfs::LpPath::new("/project.json"), + br#"{"kind": "Project", "format": 2}"#, + ) + .expect("write project root"); let ctx = lpc_registry::ParseCtx { shapes: h.engine.slot_shapes(), }; + h.registry + .load_root( + &fs, + lpfs::LpPath::new("/project.json"), + Revision::new(8), + &ctx, + ) + .expect("load project root"); h.registry .mutate( &fs, lpc_model::MutationOp::PutSlotEdit { artifact: lpc_model::ArtifactLocation::file("/project.json"), - edit: lpc_model::SlotEdit::ensure_present( - lpc_model::SlotPath::parse("nodes[clock]").expect("slot path"), + edit: lpc_model::SlotEdit::assign_value( + lpc_model::SlotPath::parse("name.some").expect("slot path"), + lpc_model::LpValue::String(alloc::string::String::from("overlay probe")), ), }, Revision::new(9), diff --git a/lp-core/lpc-engine/src/node/contexts.rs b/lp-core/lpc-engine/src/node/contexts.rs index 9f0678018..a42744871 100644 --- a/lp-core/lpc-engine/src/node/contexts.rs +++ b/lp-core/lpc-engine/src/node/contexts.rs @@ -693,6 +693,7 @@ mod tests { use lpc_model::{Kind, LpValue, SlotPath, SlotShapeRegistry, Slotted, ValueSlot}; #[derive(Default, Slotted)] + #[slot(default_role = "state")] struct TestRuntimeState { #[slot(produced)] pub value: ValueSlot, diff --git a/lp-core/lpc-engine/src/nodes/output/output_node.rs b/lp-core/lpc-engine/src/nodes/output/output_node.rs index fbf42b16d..9e8197ffe 100644 --- a/lp-core/lpc-engine/src/nodes/output/output_node.rs +++ b/lp-core/lpc-engine/src/nodes/output/output_node.rs @@ -3,12 +3,12 @@ use alloc::vec::Vec; -use lpc_model::{Revision, SlotPath, WithRevision}; +use lpc_model::{OutputDefView, Revision, SlotPath, WithRevision}; use crate::dataflow::resolver::QueryKey; use crate::node::{ DestroyCtx, MemPressureCtx, NodeError, NodeResourceInitContext, NodeRuntime, PressureLevel, - TickContext, + TickContext, err_ctx, }; use crate::products::control::{ControlRenderRequest, ControlRenderTarget, ControlSampleFormat}; use crate::resource::{ @@ -16,10 +16,20 @@ use crate::resource::{ RuntimeChannelSampleFormat, }; +/// The color the `test_pattern` Debug slot paints. +/// +/// 25% white on every channel (G2 decision): the slot answers "is this pin +/// wired to that strip?", which needs an unmistakable, graph-independent +/// color — but full white is the maximum-current draw on a long strip, so +/// the pattern stays deliberately dim. If power limits ever become +/// first-class settings, this is the constant a smarter policy replaces. +const TEST_PATTERN_RGB: [u8; 3] = [64, 64, 64]; + /// Output node that owns the materialized control sample buffer. pub struct OutputNode { channel_buffer_id: Option, control_samples: Vec, + def_view: Option, } impl OutputNode { @@ -28,12 +38,86 @@ impl OutputNode { Self { channel_buffer_id: None, control_samples: Vec::new(), + def_view: None, } } pub fn channel_buffer_id(&self) -> Option { self.channel_buffer_id } + + fn def_view(&mut self, ctx: &TickContext<'_>) -> Result<&OutputDefView, NodeError> { + OutputDefView::get_or_compile(&mut self.def_view, ctx.slot_shapes()) + .map_err(err_ctx("compile output def view")) + } + + /// Read the `test_pattern` Debug slot from the EFFECTIVE def for this frame. + /// + /// Nothing is latched: the override lives in the node's slot data (overlay + /// on top of the authored base), so the bypass switches on and off purely + /// by what this read returns. + /// + /// "Absent" (an output attached with no project def behind it) reads as + /// **off** and is logged, never fatal: this slot is a diagnostic, and a + /// diagnostic must not be able to stop an output pushing pixels. A path + /// that cannot exist in the `OutputDef` shape is a different thing — a + /// code bug — and it still fails loudly, out of `def_view`, because + /// compiling the view is exactly that shape check. + fn test_pattern_active(&mut self, ctx: &mut TickContext<'_>) -> Result { + let reader = self.def_view(ctx)?.test_pattern(); + match reader.get(ctx) { + Ok(active) => Ok(active), + Err(error) => { + log::debug!("[output] test_pattern unavailable: {error}"); + Ok(false) + } + } + } + + /// Overwrite every established sample with one color, in place. + /// + /// Keeps the LAST-ESTABLISHED sample count: a test pattern never resizes + /// the channel extent, it only repaints it, so the flush path sees exactly + /// the buffer shape the real render produced. + fn fill_solid(&mut self, rgb: [u8; 3]) { + // 8-bit unorm to 16-bit unorm: 0..=255 maps onto 0..=65535 exactly. + let rgb16 = [ + u16::from(rgb[0]) * 257, + u16::from(rgb[1]) * 257, + u16::from(rgb[2]) * 257, + ]; + for (index, sample) in self.control_samples.iter_mut().enumerate() { + *sample = rgb16[index % 3]; + } + } + + /// Copy `control_samples` into the runtime buffer and mark it dirty for this frame. + /// + /// Shared by the render path and the test-pattern bypass so both publish + /// byte-identical buffer kind, metadata, and revision. + fn publish_channel_buffer(&self, ctx: &mut TickContext<'_>) -> Result<(), NodeError> { + let buffer_id = self + .channel_buffer_id + .ok_or_else(|| NodeError::msg("output channel buffer not initialized"))?; + ctx.with_runtime_buffer_mut(buffer_id, ctx.revision(), |buffer| { + buffer.kind = RuntimeBufferKind::OutputChannels; + buffer.metadata = RuntimeBufferMetadata::OutputChannels { + channels: (self.control_samples.len() / 3) as u32, + sample_format: RuntimeChannelSampleFormat::U16, + }; + buffer + .bytes + .resize(self.control_samples.len().saturating_mul(2), 0); + for (chunk, sample) in buffer + .bytes + .chunks_exact_mut(2) + .zip(self.control_samples.iter()) + { + chunk.copy_from_slice(&sample.to_le_bytes()); + } + Ok(()) + }) + } } pub fn output_input_path() -> SlotPath { @@ -58,6 +142,19 @@ impl NodeRuntime for OutputNode { } fn consume(&mut self, ctx: &mut TickContext<'_>) -> Result<(), NodeError> { + // Bypass, not overwrite: while the Debug slot is on, the graph resolve + // is skipped entirely, so upstream demand from this root stops. Other + // outputs are unaffected. The frame the slot goes back to false we fall + // straight through to the normal resolve below — no black frame. + // + // A pattern only ever REPAINTS an extent the graph already established; + // before the first rendered frame there is nothing to repaint, so that + // frame renders the graph and establishes it. + if self.test_pattern_active(ctx)? && !self.control_samples.is_empty() { + self.fill_solid(TEST_PATTERN_RGB); + return self.publish_channel_buffer(ctx); + } + let prod = ctx .resolve(&QueryKey::ConsumedSlot { node: ctx.node_id(), @@ -87,28 +184,7 @@ impl NodeRuntime for OutputNode { ); let _layout = ctx.render_control(control, &request, target)?; - let buffer_id = self - .channel_buffer_id - .ok_or_else(|| NodeError::msg("output channel buffer not initialized"))?; - ctx.with_runtime_buffer_mut(buffer_id, ctx.revision(), |buffer| { - buffer.kind = RuntimeBufferKind::OutputChannels; - buffer.metadata = RuntimeBufferMetadata::OutputChannels { - channels: (self.control_samples.len() / 3) as u32, - sample_format: RuntimeChannelSampleFormat::U16, - }; - buffer - .bytes - .resize(self.control_samples.len().saturating_mul(2), 0); - for (chunk, sample) in buffer - .bytes - .chunks_exact_mut(2) - .zip(self.control_samples.iter()) - { - chunk.copy_from_slice(&sample.to_le_bytes()); - } - Ok(()) - })?; - Ok(()) + self.publish_channel_buffer(ctx) } fn destroy(&mut self, _ctx: &mut DestroyCtx<'_>) -> Result<(), NodeError> { @@ -123,3 +199,407 @@ impl NodeRuntime for OutputNode { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + use alloc::string::{String, ToString}; + use alloc::vec; + + use lpc_model::{ + ControlExtent, ControlProduct, ControlSampleLayout, LpValue, NodeId, ProductRef, + SlotShapeRegistry, + }; + + use crate::dataflow::resolver::{Production, ProductionSource, ResolveError, TickResolver}; + use crate::products::visual::{RenderTextureRequest, TextureRenderProduct, VisualProduct}; + use crate::resource::RuntimeBuffer; + + const BUFFER: RuntimeBufferId = RuntimeBufferId::new(1); + /// One RGB lamp: the smallest extent that still exercises the triplet layout. + const ONE_LAMP: ControlExtent = ControlExtent::new(1, 3); + /// The white the bypass paints, in the u16 unorm units the buffer carries. + /// The pattern color as the 16-bit unorm samples `fill_solid` publishes, + /// derived from the const so the tests track a retune automatically. + const PATTERN16: u16 = TEST_PATTERN_RGB[0] as u16 * 257; + + fn node_id() -> NodeId { + NodeId::new(1) + } + + /// Minimal [`TickResolver`] standing in for the engine's session bridge. + /// + /// It counts resolves of the output's `input` slot — that is how "the graph + /// was consulted" is asserted, never inferred — and paints whatever + /// `graph_color` currently is, so a color change that does NOT reach the + /// buffer proves the bypass. `test_pattern` stands in for the effective def: + /// the field the Studio's Debug section writes. + struct FakeResolver { + buffer: RuntimeBuffer, + buffer_frame: Revision, + /// Resolves of the output's `input` slot: the graph edge the bypass skips. + input_resolve_calls: u32, + /// Calls into the render path: the work the bypass skips. + render_control_calls: u32, + /// Effective value of the `test_pattern` Debug slot, read every frame. + test_pattern: bool, + /// When false the Debug slot has no def behind it and fails to resolve, + /// as it does for an output attached outside a loaded project. + test_pattern_resolvable: bool, + graph_extent: ControlExtent, + graph_color: [u16; 3], + } + + impl FakeResolver { + fn new() -> Self { + Self { + buffer: RuntimeBuffer::output_channels_u16(0, Vec::new()), + buffer_frame: Revision::default(), + input_resolve_calls: 0, + render_control_calls: 0, + test_pattern: false, + test_pattern_resolvable: true, + graph_extent: ONE_LAMP, + graph_color: [1000, 2000, 3000], + } + } + + /// The published buffer decoded back into u16 samples. + fn published_samples(&self) -> Vec { + self.buffer + .bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect() + } + } + + impl TickResolver for FakeResolver { + fn resolve(&mut self, query: &QueryKey) -> Result { + let path = query + .consumed_slot_path() + .ok_or_else(|| ResolveError::new(String::from("unexpected query kind")))?; + match path.to_string().as_str() { + "test_pattern" if self.test_pattern_resolvable => Ok(Production::leaf( + WithRevision::new(Revision::new(1), LpValue::Bool(self.test_pattern)), + ProductionSource::Literal, + )), + "test_pattern" => Err(ResolveError::new(String::from( + "unresolved consumed slot test_pattern", + ))), + "input" => { + self.input_resolve_calls += 1; + Ok(Production::leaf( + WithRevision::new( + Revision::new(1), + LpValue::Product(ProductRef::Control(ControlProduct::new( + node_id(), + 0, + self.graph_extent, + ))), + ), + ProductionSource::Literal, + )) + } + other => Err(ResolveError::new(alloc::format!( + "fake resolver has no slot {other}" + ))), + } + } + + fn resolve_static_consumed( + &mut self, + node: NodeId, + path: &'static str, + ) -> Result { + let slot = SlotPath::parse(path) + .map_err(|e| ResolveError::new(alloc::format!("bad static path: {e}")))?; + self.resolve(&QueryKey::ConsumedSlot { node, slot }) + } + + fn publish_produced_slot( + &mut self, + _node: NodeId, + _slot: SlotPath, + _production: Production, + ) -> Result<(), ResolveError> { + Ok(()) + } + + fn render_texture( + &mut self, + _product: VisualProduct, + _request: &RenderTextureRequest, + ) -> Result { + Err(ResolveError::new(String::from( + "fake resolver renders no textures", + ))) + } + + fn render_control( + &mut self, + _product: ControlProduct, + _request: &ControlRenderRequest, + target: ControlRenderTarget<'_>, + ) -> Result { + self.render_control_calls += 1; + for (index, sample) in target.samples.iter_mut().enumerate() { + *sample = self.graph_color[index % 3]; + } + Ok(ControlSampleLayout::empty()) + } + + fn runtime_buffer_mut( + &mut self, + id: RuntimeBufferId, + frame: Revision, + ) -> Result<&mut RuntimeBuffer, ResolveError> { + if id != BUFFER { + return Err(ResolveError::new(String::from("unknown runtime buffer"))); + } + self.buffer_frame = frame; + Ok(&mut self.buffer) + } + } + + /// An output whose buffer id is already assigned (stands in for `init_resources`). + fn output_node() -> OutputNode { + let mut node = OutputNode::new(); + node.channel_buffer_id = Some(BUFFER); + node + } + + /// Run one `consume` at `frame`, as the engine's tick would. + fn consume_at( + node: &mut OutputNode, + resolver: &mut FakeResolver, + frame: Revision, + ) -> Result<(), NodeError> { + let shapes = SlotShapeRegistry::default(); + let mut ctx = TickContext::new(node_id(), frame, resolver, &shapes); + node.consume(&mut ctx) + } + + #[test] + fn a_graph_frame_establishes_the_channel_extent() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + + assert_eq!(resolver.published_samples(), vec![1000, 2000, 3000]); + assert_eq!(resolver.input_resolve_calls, 1); + assert_eq!(resolver.render_control_calls, 1); + } + + #[test] + fn the_test_pattern_bypasses_the_graph_at_the_established_sample_count() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + + resolver.test_pattern = true; + // A graph color change from here on must NOT reach the buffer. + resolver.graph_color = [4000, 5000, 6000]; + let resolves_before = resolver.input_resolve_calls; + let renders_before = resolver.render_control_calls; + + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("pattern frame"); + + assert_eq!( + resolver.published_samples(), + vec![PATTERN16, PATTERN16, PATTERN16], + "buffer should hold the solid test color as u16 unorm triplets", + ); + assert_eq!( + resolver.input_resolve_calls, resolves_before, + "the graph resolve must be skipped entirely while the pattern is active", + ); + assert_eq!( + resolver.render_control_calls, renders_before, + "the render path must be skipped entirely while the pattern is active", + ); + assert_eq!( + resolver.buffer.metadata, + RuntimeBufferMetadata::OutputChannels { + channels: 1, + sample_format: RuntimeChannelSampleFormat::U16, + }, + ); + assert_eq!(resolver.buffer.kind, RuntimeBufferKind::OutputChannels); + assert_eq!( + resolver.buffer_frame, + Revision::new(2), + "the bypass must mark the buffer dirty for this frame, like the render path", + ); + } + + #[test] + fn the_test_pattern_holds_across_frames_and_repaints_every_one() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + + resolver.test_pattern = true; + for frame in 2..=4i64 { + consume_at(&mut node, &mut resolver, Revision::new(frame)).expect("pattern frame"); + assert_eq!( + resolver.buffer_frame, + Revision::new(frame), + "every held frame republishes, so the flush path stays fed", + ); + assert_eq!( + resolver.published_samples(), + vec![PATTERN16, PATTERN16, PATTERN16] + ); + } + + assert_eq!( + resolver.input_resolve_calls, 1, + "only the establishing graph frame ever resolved the input", + ); + assert_eq!(resolver.render_control_calls, 1); + } + + #[test] + fn clearing_the_test_pattern_renders_the_graph_again_on_the_same_frame() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + resolver.test_pattern = true; + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("pattern frame"); + + resolver.graph_color = [4000, 5000, 6000]; + resolver.test_pattern = false; + consume_at(&mut node, &mut resolver, Revision::new(3)).expect("post-clear frame"); + + assert_eq!( + resolver.published_samples(), + vec![4000, 5000, 6000], + "no black frame: the graph renders the very frame the override ends", + ); + assert_eq!(resolver.input_resolve_calls, 2); + } + + #[test] + fn the_def_bool_is_read_every_frame_not_latched() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + + // on -> off -> on, with nothing but the effective def changing. + resolver.test_pattern = true; + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("pattern frame"); + assert_eq!( + resolver.published_samples(), + vec![PATTERN16, PATTERN16, PATTERN16] + ); + + resolver.test_pattern = false; + consume_at(&mut node, &mut resolver, Revision::new(3)).expect("graph frame"); + assert_eq!(resolver.published_samples(), vec![1000, 2000, 3000]); + + resolver.test_pattern = true; + consume_at(&mut node, &mut resolver, Revision::new(4)).expect("pattern frame"); + assert_eq!( + resolver.published_samples(), + vec![PATTERN16, PATTERN16, PATTERN16] + ); + + assert_eq!( + resolver.input_resolve_calls, 2, + "exactly the two non-pattern frames consulted the graph", + ); + } + + #[test] + fn the_test_pattern_never_resizes_the_channel_extent() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + resolver.graph_extent = ControlExtent::new(2, 3); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + + resolver.test_pattern = true; + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("pattern frame"); + + assert_eq!( + resolver.published_samples(), + vec![PATTERN16; 6], + "the pattern repaints the last-established extent, it does not resize it", + ); + assert_eq!( + resolver.buffer.metadata, + RuntimeBufferMetadata::OutputChannels { + channels: 2, + sample_format: RuntimeChannelSampleFormat::U16, + }, + ); + } + + #[test] + fn the_bypass_publishes_the_same_buffer_shape_as_the_render_path() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + let graph_kind = resolver.buffer.kind.clone(); + let graph_metadata = resolver.buffer.metadata.clone(); + let graph_len = resolver.buffer.bytes.len(); + + resolver.test_pattern = true; + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("pattern frame"); + + assert_eq!(resolver.buffer.kind, graph_kind); + assert_eq!(resolver.buffer.metadata, graph_metadata); + assert_eq!(resolver.buffer.bytes.len(), graph_len); + } + + #[test] + fn the_test_pattern_before_any_frame_falls_through_to_the_graph() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + resolver.test_pattern = true; + + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("first frame"); + + assert_eq!( + resolver.input_resolve_calls, 1, + "with no established extent there is nothing to repaint, so the graph runs", + ); + assert_eq!(resolver.published_samples(), vec![1000, 2000, 3000]); + } + + #[test] + fn an_unreadable_debug_slot_never_stops_the_output() { + let mut node = output_node(); + let mut resolver = FakeResolver::new(); + // No project def behind the node — exactly the engine-level harness + // case, and any future one where an output outlives its def. + resolver.test_pattern_resolvable = false; + + consume_at(&mut node, &mut resolver, Revision::new(1)).expect("graph frame"); + consume_at(&mut node, &mut resolver, Revision::new(2)).expect("graph frame"); + + assert_eq!( + resolver.published_samples(), + vec![1000, 2000, 3000], + "an unreadable diagnostic reads as off; the output keeps pushing pixels", + ); + assert_eq!(resolver.input_resolve_calls, 2); + } + + #[test] + fn the_output_still_accepts_no_runtime_commands() { + let mut node = output_node(); + + // The Debug slot rides the overlay, not the command channel: nothing + // here is wire state, and WIRE_PROTO_VERSION is untouched. + assert!( + node.handle_command( + &lpc_wire::WireNodeCommand::PlaylistActivateEntry { entry: 1 }, + 0.0, + ) + .is_err(), + ); + } +} diff --git a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs index 8a60e5bd0..e085be7a1 100644 --- a/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs +++ b/lp-core/lpc-engine/src/nodes/shader/compute_shader_state.rs @@ -75,7 +75,10 @@ impl ComputeShaderState { .map_err(|e| ComputeStateError::InvalidSlotName(field.name.clone(), e))?, shape: shape_for_shader_slot(&field.slot, registry)?, semantics: lpc_model::SlotSemantics::produced(), - role: Default::default(), + // Produced ⇔ `SlotRole::State` (G2): this shape is assembled + // at runtime, so the derive cannot check it — the registry + // does, at `replace_shape_named` below. + role: lpc_model::SlotRole::State, default_bind: None, }); } diff --git a/lp-core/lpc-model/Cargo.toml b/lp-core/lpc-model/Cargo.toml index ec6b30aeb..d3d0ecb51 100644 --- a/lp-core/lpc-model/Cargo.toml +++ b/lp-core/lpc-model/Cargo.toml @@ -21,6 +21,7 @@ lp-collection = { workspace = true, features = ["serde"] } base64 = { workspace = true } hashbrown = { workspace = true } libm = "0.2" +log = { workspace = true, default-features = false } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, optional = true } schemars = { workspace = true, optional = true } diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index 8db6dcdb8..16458b8d2 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -168,17 +168,17 @@ pub use slot::{ SlotOptionAccess, SlotOptionAccessMut, SlotOptionDyn, SlotOptionMutAccess, SlotOptionReader, SlotOwner, SlotPath, SlotPathError, SlotPathSegment, SlotReadContext, SlotRecord, SlotRecordAccess, SlotRecordAccessMut, SlotRecordMutAccess, SlotRecordShape, SlotRef, SlotRole, - SlotRoleResolution, SlotSemantics, SlotShape, SlotShapeEntry, SlotShapeId, SlotShapeIdError, - SlotShapeLookup, SlotShapeRegistry, SlotShapeRegistryError, SlotShapeRegistrySnapshot, - SlotShapeView, SlotValueAccess, SlotValueMut, SlotValueMutAccess, SlotValueShapeView, - SlotVariantShape, SlotVariantShapeView, SlottedEnum, SlottedEnumMut, StaticLpType, - StaticModelEnumVariant, StaticModelStructMember, StaticSlotAccess, StaticSlotEnumEncoding, - StaticSlotEnumOption, StaticSlotFieldShape, StaticSlotMeta, StaticSlotShape, - StaticSlotShapeDescriptor, StaticSlotValueShape, StaticSlotVariantShape, StaticValueEditorHint, - ValueRef, ValueSlot, create_dynamic_slot_data, ensure_slot_present, + SlotRoleDirectionError, SlotRoleResolution, SlotSemantics, SlotShape, SlotShapeEntry, + SlotShapeId, SlotShapeIdError, SlotShapeLookup, SlotShapeRegistry, SlotShapeRegistryError, + SlotShapeRegistrySnapshot, SlotShapeView, SlotValueAccess, SlotValueMut, SlotValueMutAccess, + SlotValueShapeView, SlotVariantShape, SlotVariantShapeView, SlottedEnum, SlottedEnumMut, + StaticLpType, StaticModelEnumVariant, StaticModelStructMember, StaticSlotAccess, + StaticSlotEnumEncoding, StaticSlotEnumOption, StaticSlotFieldShape, StaticSlotMeta, + StaticSlotShape, StaticSlotShapeDescriptor, StaticSlotValueShape, StaticSlotVariantShape, + StaticValueEditorHint, ValueRef, ValueSlot, create_dynamic_slot_data, ensure_slot_present, insert_slot_map_entry_default, lookup_slot_data, lookup_slot_data_and_shape, lookup_slot_data_mut, lp_value_matches_type, remove_slot_map_entry, resolve_slot_role, - set_slot_option_none, set_slot_option_some_default, set_slot_value, set_slot_variant_default, - slot_data_revision, + role_matches_direction, set_slot_option_none, set_slot_option_some_default, set_slot_value, + set_slot_variant_default, slot_data_revision, }; pub use value::value_path::ValuePath; diff --git a/lp-core/lpc-model/src/nodes/button/button_def.rs b/lp-core/lpc-model/src/nodes/button/button_def.rs index aea4b2a6a..d1f3b3033 100644 --- a/lp-core/lpc-model/src/nodes/button/button_def.rs +++ b/lp-core/lpc-model/src/nodes/button/button_def.rs @@ -47,6 +47,7 @@ impl ButtonDef { /// Runtime button state published to shader-compatible control maps. #[derive(Debug, Clone, Default, PartialEq, Slotted)] +#[slot(default_role = "state")] pub struct ButtonState { /// Present for one tick when the button transitions to pressed. #[slot(produced, map(key = "u32", value_ref = "lp::control::Message"))] diff --git a/lp-core/lpc-model/src/nodes/clock/clock_state.rs b/lp-core/lpc-model/src/nodes/clock/clock_state.rs index ec6e72c6f..f5cd18351 100644 --- a/lp-core/lpc-model/src/nodes/clock/clock_state.rs +++ b/lp-core/lpc-model/src/nodes/clock/clock_state.rs @@ -2,6 +2,7 @@ use crate::{Slotted, ValueSlot}; /// Runtime state exposed by the clock node. #[derive(Slotted)] +#[slot(default_role = "state")] pub struct ClockState { /// Clock time in seconds after rate and scrub offset are applied. #[slot(produced, default_bind = "bus:time")] diff --git a/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs b/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs index 16dba1b2a..d66e6088e 100644 --- a/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs +++ b/lp-core/lpc-model/src/nodes/fixture/fixture_state.rs @@ -4,6 +4,7 @@ use crate::{ControlExtent, ControlProduct, ControlProductSlot, NodeId, Slotted, /// Runtime state exposed by a fixture node. #[derive(Slotted)] +#[slot(default_role = "state")] pub struct FixtureState { /// Renderable control output produced by this fixture node. #[slot(produced, default_bind = "bus:control.out")] diff --git a/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs b/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs index ebd2ff516..13c43a4e2 100644 --- a/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs +++ b/lp-core/lpc-model/src/nodes/fluid/fluid_state.rs @@ -4,6 +4,7 @@ use crate::{Slotted, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a fluid node. #[derive(Default, Slotted)] +#[slot(default_role = "state")] pub struct FluidState { /// Renderable visual output produced by this fluid node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/output/output_def.rs b/lp-core/lpc-model/src/nodes/output/output_def.rs index 9d15be93b..a00352f3a 100644 --- a/lp-core/lpc-model/src/nodes/output/output_def.rs +++ b/lp-core/lpc-model/src/nodes/output/output_def.rs @@ -15,6 +15,13 @@ pub struct OutputDef { pub bindings: BindingDefs, /// Optional display pipeline options. pub options: OptionSlot, + /// Light every channel of this output solid white, bypassing the graph. + /// + /// A `Debug` slot: diagnostics only ("is this pin wired to that strip?"), + /// never authored into a project file and never saved. It survives the + /// client that set it and dies on unload or reboot. + #[slot(role = "debug")] + pub test_pattern: ValueSlot, } impl OutputDef { @@ -26,6 +33,7 @@ impl OutputDef { endpoint: ValueSlot::new(endpoint), bindings: BindingDefs::default(), options: OptionSlot::none(), + test_pattern: ValueSlot::new(false), } } @@ -88,7 +96,9 @@ fn default_true_slot() -> ValueSlot { mod tests { use super::*; use crate::node::kind::NodeKind; - use crate::{NodeDef, OutputDefView, SlotPath, SlotShapeRegistry}; + use crate::{ + NodeDef, OutputDefView, SlotPath, SlotRole, SlotShape, SlotShapeRegistry, StaticSlotShape, + }; use alloc::format; #[test] @@ -140,6 +150,36 @@ mod tests { assert_eq!(view.options().path(), &SlotPath::parse("options").unwrap()); } + #[test] + fn test_pattern_is_a_debug_slot() { + let SlotShape::Record { fields, .. } = OutputDef::slot_shape() else { + panic!("output def is a record"); + }; + + let field = fields + .iter() + .find(|field| field.name.as_str() == "test_pattern") + .expect("test_pattern field"); + + assert_eq!(field.role, SlotRole::Debug); + assert!(field.is_writable()); + } + + #[test] + fn authored_test_pattern_is_ignored() { + let json = r#"{ "kind": "Output", "endpoint": "ws281x:rmt:D10", "test_pattern": true }"#; + + let def = NodeDef::read_json(®istry(), json).unwrap(); + + let NodeDef::Output(def) = def else { + panic!("expected output def"); + }; + assert!( + !*def.test_pattern.value(), + "a Debug slot never takes an authored value (D2)" + ); + } + fn registry() -> SlotShapeRegistry { SlotShapeRegistry::default() } diff --git a/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs b/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs index 14b51d164..450fddc27 100644 --- a/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs +++ b/lp-core/lpc-model/src/nodes/playlist/playlist_state.rs @@ -4,6 +4,7 @@ use crate::{Slotted, ValueSlot, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a playlist node. #[derive(Default, Slotted)] +#[slot(default_role = "state")] pub struct PlaylistState { /// Renderable visual output produced by this playlist node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs index b97fd924b..d28b27047 100644 --- a/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs +++ b/lp-core/lpc-model/src/nodes/radio/control_radio_def.rs @@ -59,6 +59,7 @@ impl ControlRadioDef { /// Runtime control radio state. #[derive(Debug, Clone, Default, PartialEq, Slotted)] +#[slot(default_role = "state")] pub struct ControlRadioState { /// Accepted local and remote control messages for this tick. #[slot(produced, map(key = "u32", value_ref = "lp::control::Message"))] diff --git a/lp-core/lpc-model/src/nodes/shader/shader_state.rs b/lp-core/lpc-model/src/nodes/shader/shader_state.rs index a5e4720f0..dc450ef0a 100644 --- a/lp-core/lpc-model/src/nodes/shader/shader_state.rs +++ b/lp-core/lpc-model/src/nodes/shader/shader_state.rs @@ -4,6 +4,7 @@ use crate::{Slotted, VisualProduct, VisualProductSlot}; /// Runtime state exposed by a shader node. #[derive(Default, Slotted)] +#[slot(default_role = "state")] pub struct ShaderState { /// Renderable visual output produced by this shader node. #[slot(produced, default_bind = "bus:visual.out")] diff --git a/lp-core/lpc-model/src/nodes/texture/texture_state.rs b/lp-core/lpc-model/src/nodes/texture/texture_state.rs index 02b63dfb0..c214ba7ba 100644 --- a/lp-core/lpc-model/src/nodes/texture/texture_state.rs +++ b/lp-core/lpc-model/src/nodes/texture/texture_state.rs @@ -4,6 +4,7 @@ use crate::{Revision, Slotted, ValueSlot}; /// Runtime metadata exposed by a texture node. #[derive(Default, Slotted)] +#[slot(default_role = "state")] pub struct TextureState { #[slot(produced)] pub width: ValueSlot, @@ -38,14 +39,14 @@ impl TextureState { #[cfg(test)] mod tests { use super::*; - use crate::{SlotDirection, SlotShape, StaticSlotShape}; + use crate::{SlotDirection, SlotRole, SlotShape, StaticSlotShape}; - /// TextureState carries no explicit role on any field (unlike its sibling - /// state records, which used to need a container-wide - /// `read_only_transient` marking) — it is safe by construction because - /// direction alone implies read-only/never-serialized (D1). + /// TextureState is the record that proved the direction-implied rule was + /// too quiet: it was safe only because the studio rewrote produced slots + /// at DTO-build time. Since the G2 amendment it declares the `State` role + /// outright, and the derive would refuse to compile it otherwise. #[test] - fn texture_state_fields_are_produced_and_present_read_only_with_default_role() { + fn texture_state_fields_declare_the_state_role_and_are_read_only() { let SlotShape::Record { fields, .. } = TextureState::slot_shape() else { panic!("record shape"); }; @@ -56,10 +57,10 @@ mod tests { .find(|field| field.name.as_str() == name) .expect("texture state field"); assert_eq!(field.semantics.direction, SlotDirection::Produced); - assert!(field.role.is_default(), "no explicit role is declared"); + assert_eq!(field.role, SlotRole::State); assert!( !field.is_writable(), - "a produced field is never writable, regardless of its default role" + "a State/produced field is never writable" ); } } diff --git a/lp-core/lpc-model/src/project/node_attach_site.rs b/lp-core/lpc-model/src/project/node_attach_site.rs index b1fadfda5..09e1dc163 100644 --- a/lp-core/lpc-model/src/project/node_attach_site.rs +++ b/lp-core/lpc-model/src/project/node_attach_site.rs @@ -22,7 +22,7 @@ use crate::{ArtifactLocation, SlotPath}; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum NodeAttachSite { - /// `project.json` `nodes[key]` — the policy-locked site owned by + /// `project.json` `nodes[key]` — the role-locked site owned by /// dedicated node operations. ProjectNodes { key: String }, /// Any writable `NodeInvocationSlot`, e.g. a playlist's diff --git a/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs b/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs index caefc6ddb..2fede78c0 100644 --- a/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs +++ b/lp-core/lpc-model/src/project/overlay_mutation/mutation_cmd.rs @@ -250,7 +250,7 @@ pub enum MutationRejectionReason { /// Mutation referenced a slot path that does not resolve in the /// artifact's shape. UnknownSlotPath, - /// Mutation targeted a slot whose policy is not writable. + /// Mutation targeted a slot whose role (or direction) makes it read-only. NotWritable, /// Mutation assigned a value that does not match the slot's value type. TypeMismatch, diff --git a/lp-core/lpc-model/src/schema_gen/mod.rs b/lp-core/lpc-model/src/schema_gen/mod.rs index 044a5f83d..1cc668006 100644 --- a/lp-core/lpc-model/src/schema_gen/mod.rs +++ b/lp-core/lpc-model/src/schema_gen/mod.rs @@ -27,6 +27,13 @@ //! - Value-level parse validation that inspects string *content* beyond a //! regular pattern (e.g. `ArtifactSpec::parse` of `lib:` suffixes, the //! affine bottom-row epsilon check). +//! - [`SlotRole::Debug`](crate::SlotRole::Debug) fields (D2, P4): the reader +//! still tolerates an authored value at these paths (it warns and ignores +//! it rather than rejecting — alpha posture), but the *authoring* schema +//! omits them entirely. This is the one deliberate exception to the +//! "accepted implies valid" direction: the schema describes what a def +//! file may validly author, which is narrower than everything the reader +//! is lenient about. //! //! # Custom-codec side table //! diff --git a/lp-core/lpc-model/src/schema_gen/slot_shape_schema.rs b/lp-core/lpc-model/src/schema_gen/slot_shape_schema.rs index ad1bad636..b5452a358 100644 --- a/lp-core/lpc-model/src/schema_gen/slot_shape_schema.rs +++ b/lp-core/lpc-model/src/schema_gen/slot_shape_schema.rs @@ -15,8 +15,8 @@ use serde_json::{Map, Value, json}; use crate::{ LpType, ModelEnumVariant, ModelStructMember, ProductKind, SlotEnumEncoding, SlotFieldShape, - SlotMapKeyShape, SlotMeta, SlotName, SlotShape, SlotShapeId, SlotShapeRegistry, SlotValueShape, - SlotVariantShape, + SlotMapKeyShape, SlotMeta, SlotName, SlotRole, SlotShape, SlotShapeId, SlotShapeRegistry, + SlotValueShape, SlotVariantShape, }; use super::custom_codec_schemas::custom_codec_schema; @@ -195,9 +195,21 @@ impl<'r> SchemaCompiler<'r> { /// require any field — missing fields keep their factory defaults (see /// `dynamic_slot_reader_leaves_missing_fields_at_defaults`) — so there is /// deliberately no `required` list, not even for non-`Option` fields. + /// + /// [`SlotRole::Debug`] fields are omitted entirely (D2, P4): the reader + /// still tolerates an authored value at these paths (it warns and + /// ignores it — see `dynamic_slot_reader_ignores_authored_debug_role_fields`), + /// but the *authoring* schema describes what a def file may validly + /// author, not everything the reader is lenient about, so Debug fields + /// never appear here. This is the one place this compiler's "accepted + /// implies valid" fidelity contract (module docs) is deliberately + /// narrower than the reader. fn compile_record(&mut self, meta: &SlotMeta, fields: &[SlotFieldShape]) -> Value { let mut properties = Map::new(); for field in fields { + if field.role == SlotRole::Debug { + continue; + } properties.insert( String::from(field.name.as_str()), self.compile(&field.shape), @@ -266,7 +278,15 @@ impl<'r> SchemaCompiler<'r> { ); match payload { SlotShape::Record { fields, .. } => { + // Same Debug-role omission as `compile_record` (D2, P4): a + // variant's flattened top-level fields go through this loop + // instead, so the filter is duplicated here rather than + // shared, since the discriminator property must stay merged + // into the same object. for record_field in fields { + if record_field.role == SlotRole::Debug { + continue; + } properties.insert( String::from(record_field.name.as_str()), self.compile(&record_field.shape), @@ -576,12 +596,37 @@ mod tests { use crate::slot::shape; use crate::slot_codec::{JsonSyntaxSource, SlotReader, SyntaxError, read_dynamic_slot}; use crate::{ - LpType, ModelEnumVariant, ModelStructMember, ProductKind, SlotShape, SlotShapeId, + LpType, ModelEnumVariant, ModelStructMember, ProductKind, SlotRole, SlotShape, SlotShapeId, SlotShapeRegistry, }; use super::compile_slot_shape_schema; + /// D2 (P4): the authoring schema omits `SlotRole::Debug` fields entirely + /// — `node.schema.json`/`project.schema.json` must not advertise a + /// session-only control as something a def file can author, even though + /// the reader still tolerates (and now warns-and-ignores) the value. + #[test] + fn debug_role_fields_are_omitted_from_the_authoring_schema() { + let (registry, id) = registered( + "test.SchemaDebugField", + shape::record(vec![ + shape::field("pin", shape::value(LpType::U32)), + shape::field_with_role("rate", shape::value(LpType::F32), SlotRole::Debug), + ]), + ); + let shape = registry.get(&id).unwrap().clone(); + + let schema = compile_slot_shape_schema(®istry, &shape); + + let properties = schema["properties"].as_object().expect("properties"); + assert!(properties.contains_key("pin"), "{schema}"); + assert!( + !properties.contains_key("rate"), + "debug-role field must not appear in the authoring schema: {schema}" + ); + } + #[test] fn record_accepts_partial_objects_and_rejects_unknown_fields() { let (registry, id) = registered( diff --git a/lp-core/lpc-model/src/slot/mod.rs b/lp-core/lpc-model/src/slot/mod.rs index cce4a56f8..28bfbc3f8 100644 --- a/lp-core/lpc-model/src/slot/mod.rs +++ b/lp-core/lpc-model/src/slot/mod.rs @@ -74,12 +74,12 @@ pub use slot_persistence::{SlotPersistence, effective_persistence}; pub use slot_reader::{SlotFieldReader, SlotOptionReader, SlotReadContext}; pub use slot_record_shape::SlotRecordShape; pub use slot_ref::SlotRef; -pub use slot_role::{SlotRole, effective_writable}; +pub use slot_role::{SlotRole, effective_writable, role_matches_direction}; pub use slot_role_lookup::{SlotRoleResolution, resolve_slot_role}; pub use slot_semantics::SlotSemantics; pub use slot_shape::{ - SlotEnumEncoding, SlotFieldShape, SlotMapKeyShape, SlotShape, SlotShapeId, SlotShapeIdError, - SlotVariantShape, + SlotEnumEncoding, SlotFieldShape, SlotMapKeyShape, SlotRoleDirectionError, SlotShape, + SlotShapeId, SlotShapeIdError, SlotVariantShape, }; pub use slot_shape_lookup::SlotShapeLookup; pub use slot_value::{ diff --git a/lp-core/lpc-model/src/slot/slot_persistence.rs b/lp-core/lpc-model/src/slot/slot_persistence.rs index d3270ec11..dde51aeee 100644 --- a/lp-core/lpc-model/src/slot/slot_persistence.rs +++ b/lp-core/lpc-model/src/slot/slot_persistence.rs @@ -18,7 +18,7 @@ use super::{SlotDirection, SlotRole}; #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SlotPersistence { - /// Save this slot when writing the authored model unless another policy overrides it. + /// Save this slot when writing the authored model. #[default] Persisted, /// User-editable runtime/session control; skip on ordinary save/writeback. @@ -29,11 +29,27 @@ impl SlotPersistence { pub fn is_persisted(self: &Self) -> bool { matches!(self, Self::Persisted) } + + /// Classification for an edit whose path resolves in **no** shape — + /// a stale artifact, an unmounted node, a field the def no longer has. + /// + /// Client and server resolve roles independently (the studio walks the + /// shipped shape snapshot, the registry walks the effective def), so they + /// must agree on the fallback or the two sides disagree about what an + /// edit *is*: the studio would hold an invisible live override that the + /// server silently dropped at commit, or vice versa. The shared answer is + /// **Setting** (`Persisted`) — the save-relevant default: an + /// unclassifiable edit presents as authored work and resolves at commit + /// rather than lingering as a Debug override nothing accounts for. + pub fn for_unresolved_edit() -> Self { + Self::Persisted + } } /// Derive the persistence classification governing a field carrying `role` -/// and `direction`: transient unless the role persists (not [`SlotRole::Debug`]) -/// and the direction does not imply a produced (never-serialized) field. +/// and `direction`: transient unless the role persists (neither +/// [`SlotRole::Debug`] nor [`SlotRole::State`]) and the direction does not +/// imply a produced (never-serialized) field. pub fn effective_persistence(role: SlotRole, direction: SlotDirection) -> SlotPersistence { if role.is_persisted() && direction != SlotDirection::Produced { SlotPersistence::Persisted @@ -72,6 +88,20 @@ mod tests { ); } + /// The G2 amendment renames what direction implied; it must not move the + /// classification of a produced field. + #[test] + fn state_role_classifies_exactly_like_an_unmarked_produced_field() { + assert_eq!( + effective_persistence(SlotRole::State, SlotDirection::Produced), + effective_persistence(SlotRole::Setting, SlotDirection::Produced), + ); + assert_eq!( + effective_persistence(SlotRole::State, SlotDirection::Produced), + SlotPersistence::Transient + ); + } + #[test] fn debug_role_is_always_transient() { assert_eq!( diff --git a/lp-core/lpc-model/src/slot/slot_role.rs b/lp-core/lpc-model/src/slot/slot_role.rs index bae98d923..9f51bfe10 100644 --- a/lp-core/lpc-model/src/slot/slot_role.rs +++ b/lp-core/lpc-model/src/slot/slot_role.rs @@ -2,10 +2,13 @@ //! //! A role is distinct from [`SlotMeta`](crate::SlotMeta), which describes //! presentation, and from [`SlotSemantics`](crate::SlotSemantics), which -//! describes resolver-facing dataflow behavior. It is also distinct from a -//! field's [`SlotDirection`](crate::SlotDirection): a produced field is -//! always effectively read-only and never persisted regardless of its role -//! (see [`effective_writable`]) — role governs *authored* fields only. +//! describes resolver-facing dataflow behavior. It is related to — and +//! cross-checked against — a field's +//! [`SlotDirection`](crate::SlotDirection): produced runtime state declares +//! [`SlotRole::State`], and the two must agree (see +//! [`role_matches_direction`]). A produced field is always effectively +//! read-only and never persisted (see [`effective_writable`]) — the other +//! roles govern *authored* fields only. use serde::{Deserialize, Serialize}; @@ -25,30 +28,62 @@ pub enum SlotRole { /// Writable, transient by nature: diagnostics/authoring overrides. Never /// serialized; lives only in the session overlay. Debug, + /// Runtime-produced state: never authored, never client-writable, never + /// serialized. Declared on every field whose direction is + /// [`SlotDirection::Produced`], and only on those (G2 amendment — + /// declaration beats inference; see [`role_matches_direction`]). + State, } impl SlotRole { /// True when clients may request mutation of this role's slot. pub fn is_writable(self) -> bool { - !matches!(self, Self::Fixed) + !matches!(self, Self::Fixed | Self::State) } - /// Save/writeback hint: true unless the role is [`Self::Debug`]. + /// Save/writeback hint: true unless the role is [`Self::Debug`] or + /// [`Self::State`]. pub fn is_persisted(self) -> bool { - !matches!(self, Self::Debug) + !matches!(self, Self::Debug | Self::State) } pub fn is_default(self: &Self) -> bool { *self == Self::default() } + + /// Stable snake_case token, matching the serde representation and the + /// `#[slot(role = "...")]` spelling. + pub fn as_str(self) -> &'static str { + match self { + Self::Setting => "setting", + Self::Fixed => "fixed", + Self::Debug => "debug", + Self::State => "state", + } + } +} + +/// Whether a field may declare `role` together with `direction`. +/// +/// [`SlotRole::State`] and [`SlotDirection::Produced`] are two spellings of +/// one fact, and the model requires **both**: an unmarked produced field +/// (the old "direction-implied" state record) and a `State` field that is +/// not produced are equally rejected. Enforced at declaration time by the +/// `Slotted` derive when role and direction are both statically visible, and +/// at shape-registration time by +/// [`SlotShape::validate_role_direction`](crate::SlotShape::validate_role_direction) +/// for shapes built at runtime. +pub fn role_matches_direction(role: SlotRole, direction: SlotDirection) -> bool { + (role == SlotRole::State) == (direction == SlotDirection::Produced) } /// Whether a slot governed by `role` accepts client-requested mutation. /// /// A produced field is always effectively read-only: it is written by its /// owning node at runtime, so no declared role can make it writable (D1 — -/// direction implies the constraint, no `read_only_transient` marking -/// needed). +/// direction implies the constraint; since the G2 amendment such a field +/// also declares [`SlotRole::State`], which is read-only in its own right, +/// so the two clauses agree by construction). pub fn effective_writable(role: SlotRole, direction: SlotDirection) -> bool { role.is_writable() && direction != SlotDirection::Produced } @@ -76,6 +111,27 @@ mod tests { assert!(SlotRole::Fixed.is_persisted()); } + #[test] + fn state_role_is_neither_writable_nor_persisted() { + assert!(!SlotRole::State.is_writable()); + assert!(!SlotRole::State.is_persisted()); + } + + /// The G2 amendment must be a pure renaming of what direction already + /// implied: `State` + `Produced` classifies exactly as the unmarked + /// (`Setting` + `Produced`) state records did before it existed. + #[test] + fn state_role_classifies_exactly_like_an_unmarked_produced_field() { + assert_eq!( + effective_writable(SlotRole::State, SlotDirection::Produced), + effective_writable(SlotRole::Setting, SlotDirection::Produced), + ); + assert!(!effective_writable( + SlotRole::State, + SlotDirection::Produced + )); + } + #[test] fn produced_direction_is_never_effectively_writable() { assert!(!effective_writable( @@ -89,11 +145,56 @@ mod tests { assert!(effective_writable(SlotRole::Setting, SlotDirection::Local)); } + #[test] + fn role_and_direction_must_agree_in_both_directions() { + assert!(role_matches_direction( + SlotRole::State, + SlotDirection::Produced + )); + assert!(role_matches_direction( + SlotRole::Setting, + SlotDirection::Local + )); + assert!(role_matches_direction( + SlotRole::Debug, + SlotDirection::Consumed + )); + + // State without Produced. + assert!(!role_matches_direction( + SlotRole::State, + SlotDirection::Local + )); + assert!(!role_matches_direction( + SlotRole::State, + SlotDirection::Consumed + )); + // Produced without State — the case that made an unmarked + // `TextureState` possible. + assert!(!role_matches_direction( + SlotRole::Setting, + SlotDirection::Produced + )); + assert!(!role_matches_direction( + SlotRole::Fixed, + SlotDirection::Produced + )); + assert!(!role_matches_direction( + SlotRole::Debug, + SlotDirection::Produced + )); + } + #[test] fn slot_role_serde_is_snake_case_and_skips_default() { let json = serde_json::to_string(&SlotRole::Debug).unwrap(); assert_eq!(json, "\"debug\""); let back: SlotRole = serde_json::from_str(&json).unwrap(); assert_eq!(back, SlotRole::Debug); + + let json = serde_json::to_string(&SlotRole::State).unwrap(); + assert_eq!(json, "\"state\""); + let back: SlotRole = serde_json::from_str(&json).unwrap(); + assert_eq!(back, SlotRole::State); } } diff --git a/lp-core/lpc-model/src/slot/slot_role_lookup.rs b/lp-core/lpc-model/src/slot/slot_role_lookup.rs index bee6aba3a..406f086f5 100644 --- a/lp-core/lpc-model/src/slot/slot_role_lookup.rs +++ b/lp-core/lpc-model/src/slot/slot_role_lookup.rs @@ -8,7 +8,8 @@ use crate::{ LpType, SlotDirection, SlotPath, SlotPathSegment, SlotRole, SlotSemantics, SlotShapeLookup, - SlotShapeView, slot::effective_writable, + SlotShapeView, + slot::{SlotPersistence, effective_persistence, effective_writable}, }; /// Role, governing direction, and leaf value type resolved for one slot path. @@ -34,6 +35,18 @@ impl SlotRoleResolution { pub fn is_writable(&self) -> bool { effective_writable(self.role, self.direction) } + + /// The persistence classification governing this path + /// ([`effective_persistence`] of its role and direction). + /// + /// This is the **one** classifier both sides of an edit's life must + /// consult — the studio for display/dirty accounting, the registry for + /// commit retention — so a Debug override and an authored edit can never + /// swap places between client and server. Paths that resolve in no shape + /// take [`SlotPersistence::for_unresolved_edit`] instead. + pub fn persistence(&self) -> SlotPersistence { + effective_persistence(self.role, self.direction) + } } /// Resolve the [`SlotRole`] plus governing direction and leaf type for `path` @@ -165,7 +178,7 @@ mod tests { use crate::slot::shape::{field, field_with_role, map, record, value}; use crate::{ ClockDef, LpType, SlotMapKeyShape, SlotShape, SlotShapeId, SlotShapeRegistry, - StaticSlotShape, + StaticSlotShape, slot::effective_persistence, }; use alloc::vec; @@ -256,29 +269,103 @@ mod tests { } #[test] - fn produced_direction_forces_read_only_regardless_of_role() { - let (registry, id) = registry_with(record(vec![crate::slot::shape::field_with_semantics( - "output", - value(LpType::F32), - crate::SlotSemantics::produced(), - )])); + fn produced_state_field_resolves_read_only_and_transient() { + let (registry, id) = registry_with(record(vec![state_field("output", value(LpType::F32))])); let output = resolve(®istry, id, "output"); assert_eq!(output.direction, SlotDirection::Produced); + assert_eq!(output.role, SlotRole::State); assert!(!output.is_writable(), "produced fields are never writable"); + assert_eq!(output.persistence(), SlotPersistence::Transient); } #[test] - fn produced_direction_is_inherited_by_nested_leaves() { - let (registry, id) = registry_with(record(vec![crate::slot::shape::field_with_semantics( + fn produced_state_is_inherited_by_nested_leaves() { + let (registry, id) = registry_with(record(vec![state_field( "output", record(vec![field("inner", value(LpType::F32))]), - crate::SlotSemantics::produced(), )])); let inner = resolve(®istry, id, "output.inner"); assert_eq!(inner.direction, SlotDirection::Produced); + assert_eq!(inner.role, SlotRole::State); assert!(!inner.is_writable()); + assert_eq!(inner.persistence(), SlotPersistence::Transient); + } + + /// The G2 amendment must not move any existing classification: a + /// `State`/`Produced` field resolves exactly as an unmarked produced + /// field used to. + #[test] + fn state_role_classifies_like_the_former_unmarked_produced_field() { + assert_eq!( + effective_writable(SlotRole::State, SlotDirection::Produced), + effective_writable(SlotRole::Setting, SlotDirection::Produced), + ); + assert_eq!( + effective_persistence(SlotRole::State, SlotDirection::Produced), + effective_persistence(SlotRole::Setting, SlotDirection::Produced), + ); + } + + /// Produced state that forgets to declare `State` (the old + /// direction-implied spelling) no longer reaches the registry. + #[test] + fn registering_an_unmarked_produced_field_fails_loudly() { + let id = SlotShapeId::from_static_name("test.role_lookup.unmarked"); + let mut registry = SlotShapeRegistry::default(); + let error = registry + .register_dynamic_shape( + id, + record(vec![crate::slot::shape::field_with_semantics( + "output", + value(LpType::F32), + crate::SlotSemantics::produced(), + )]), + ) + .expect_err("an unmarked produced field is rejected"); + assert!( + matches!( + error, + crate::SlotShapeRegistryError::RoleDirectionMismatch(bad, _) if bad == id + ), + "{error:?}" + ); + } + + /// And the mirror image: `State` on something the runtime does not + /// produce. + #[test] + fn registering_a_state_role_without_produced_fails_loudly() { + let id = SlotShapeId::from_static_name("test.role_lookup.mislabeled"); + let mut registry = SlotShapeRegistry::default(); + let error = registry + .register_dynamic_shape( + id, + record(vec![field_with_role( + "output", + value(LpType::F32), + SlotRole::State, + )]), + ) + .expect_err("a State role without a produced direction is rejected"); + assert!( + matches!( + error, + crate::SlotShapeRegistryError::RoleDirectionMismatch(bad, _) if bad == id + ), + "{error:?}" + ); + } + + /// A produced state field, spelled the only way the model now accepts. + fn state_field(name: &str, shape: SlotShape) -> crate::SlotFieldShape { + crate::slot::shape::field_with_semantics_and_role( + name, + shape, + crate::SlotSemantics::produced(), + SlotRole::State, + ) } fn registry_with(shape: SlotShape) -> (SlotShapeRegistry, SlotShapeId) { diff --git a/lp-core/lpc-model/src/slot/slot_shape.rs b/lp-core/lpc-model/src/slot/slot_shape.rs index e497b5956..edcfe1865 100644 --- a/lp-core/lpc-model/src/slot/slot_shape.rs +++ b/lp-core/lpc-model/src/slot/slot_shape.rs @@ -122,6 +122,43 @@ impl fmt::Display for SlotShapeIdError { impl core::error::Error for SlotShapeIdError {} +/// A record field whose declared role and direction contradict each other. +/// +/// Produced runtime state must declare [`SlotRole::State`], and nothing else +/// may (G2 amendment to `2026-08-01-debug-slots-taxonomy`). Reported by +/// [`SlotShape::validate_role_direction`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SlotRoleDirectionError { + pub field: SlotName, + pub role: SlotRole, + pub direction: crate::SlotDirection, +} + +impl fmt::Display for SlotRoleDirectionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.role == SlotRole::State { + write!( + f, + "slot field `{}` declares role \"state\" but is not produced \ + (direction {:?}): the State role marks runtime-produced \ + state only", + self.field.as_str(), + self.direction + ) + } else { + write!( + f, + "slot field `{}` is produced but declares role \"{}\": \ + produced runtime state must declare role \"state\"", + self.field.as_str(), + self.role.as_str() + ) + } + } +} + +impl core::error::Error for SlotRoleDirectionError {} + /// Authored syntax used when reading and writing an enum slot. /// /// Encoding changes only the source/document representation of an enum. The @@ -191,6 +228,44 @@ impl SlotShape { refs } + /// Check that every field declared inside this shape pairs + /// [`SlotRole::State`] with [`crate::SlotDirection::Produced`] and vice + /// versa (G2 amendment — see [`crate::role_matches_direction`]). + /// + /// This is the runtime half of the enforcement: the `Slotted` derive + /// rejects a mismatch at compile time, and shapes assembled at runtime + /// (dynamic node state, artifact shapes) are checked here when they are + /// registered. The walk deliberately does **not** follow + /// [`SlotShape::Ref`]: a referenced shape is validated when it is itself + /// registered, so each field declaration is checked exactly once. + pub fn validate_role_direction(&self) -> Result<(), SlotRoleDirectionError> { + match self { + Self::Ref { .. } | Self::Unit { .. } | Self::Value { .. } => Ok(()), + Self::Record { fields, .. } => { + for field in fields { + if !crate::role_matches_direction(field.role, field.semantics.direction) { + return Err(SlotRoleDirectionError { + field: field.name.clone(), + role: field.role, + direction: field.semantics.direction, + }); + } + field.shape.validate_role_direction()?; + } + Ok(()) + } + Self::Map { value, .. } => value.validate_role_direction(), + Self::Enum { variants, .. } => { + for variant in variants { + variant.shape.validate_role_direction()?; + } + Ok(()) + } + Self::Option { some, .. } => some.validate_role_direction(), + Self::Custom { shape, .. } => shape.validate_role_direction(), + } + } + fn collect_referenced_shape_ids(&self, refs: &mut Vec) { match self { Self::Ref { id } => refs.push(*id), diff --git a/lp-core/lpc-model/src/slot/slot_shape_registry.rs b/lp-core/lpc-model/src/slot/slot_shape_registry.rs index 871205838..57787e857 100644 --- a/lp-core/lpc-model/src/slot/slot_shape_registry.rs +++ b/lp-core/lpc-model/src/slot/slot_shape_registry.rs @@ -160,6 +160,7 @@ impl SlotShapeRegistry { if self.shapes.contains_key(&id) { return Err(SlotShapeRegistryError::DuplicateShapeId(id)); } + check_role_direction(id, &shape)?; self.shapes.insert(id, SlotShapeEntry::new(revision, shape)); self.factories.insert(id, factory); self.ids_revision = revision; @@ -193,6 +194,7 @@ impl SlotShapeRegistry { if self.shapes.contains_key(&id) { return Err(SlotShapeRegistryError::DuplicateShapeId(id)); } + check_role_direction(id, &shape)?; self.shapes .insert(id, SlotShapeEntry::named(revision, name, shape)); self.factories.insert(id, factory); @@ -314,6 +316,7 @@ impl SlotShapeRegistry { }; } + check_role_direction(id, &shape)?; self.shapes.insert(id, SlotShapeEntry::new(revision, shape)); self.factories.insert(id, factory); self.ids_revision = revision; @@ -354,6 +357,7 @@ impl SlotShapeRegistry { }; } + check_role_direction(id, &shape)?; self.shapes .insert(id, SlotShapeEntry::named(revision, name, shape)); self.factories.insert(id, factory); @@ -398,6 +402,7 @@ impl SlotShapeRegistry { shape: SlotShape, factory: SlotFactory, ) { + assert_role_direction(id, &shape); self.shapes.insert(id, SlotShapeEntry::new(revision, shape)); self.factories.insert(id, factory); self.ids_revision = revision; @@ -436,6 +441,7 @@ impl SlotShapeRegistry { shape: SlotShape, factory: SlotFactory, ) { + assert_role_direction(id, &shape); self.shapes .insert(id, SlotShapeEntry::named(revision, name, shape)); self.factories.insert(id, factory); @@ -719,6 +725,32 @@ impl SlotShapeLookup for SlotShapeRegistry { } } +/// Reject a shape whose fields contradict the role/direction pairing rule +/// (G2 amendment: produced ⇔ [`crate::SlotRole::State`]). +/// +/// The `Slotted` derive already refuses to emit such a field, so this only +/// ever fires for shapes assembled at runtime — a node publishing dynamic +/// state, an artifact shape built from a file. +fn check_role_direction(id: SlotShapeId, shape: &SlotShape) -> Result<(), SlotShapeRegistryError> { + shape + .validate_role_direction() + .map_err(|error| SlotShapeRegistryError::RoleDirectionMismatch(id, error)) +} + +/// [`check_role_direction`] for the infallible `replace_*` entry points. +/// +/// These are hot runtime paths whose signature cannot report an error, so the +/// check is a debug assertion: it fails loudly in tests and dev builds and +/// costs nothing in release. Every *fallible* registration path returns +/// [`SlotShapeRegistryError::RoleDirectionMismatch`] instead. +fn assert_role_direction(id: SlotShapeId, shape: &SlotShape) { + debug_assert!( + shape.validate_role_direction().is_ok(), + "slot shape {id} violates the role/direction pairing rule: {:?}", + shape.validate_role_direction() + ); +} + fn min_shape_id(left: Option, right: Option) -> Option { match (left, right) { (Some(left), Some(right)) => Some(core::cmp::min(left, right)), @@ -740,6 +772,9 @@ pub enum SlotShapeRegistryError { DuplicateShapeId(SlotShapeId), ShapeIdConflict(SlotShapeId), MissingReferencedShape(SlotShapeId), + /// A registered shape declares a field whose role and direction + /// contradict each other (G2: produced ⇔ `SlotRole::State`). + RoleDirectionMismatch(SlotShapeId, crate::SlotRoleDirectionError), FactoryError(String), } @@ -751,6 +786,9 @@ impl core::fmt::Display for SlotShapeRegistryError { Self::MissingReferencedShape(id) => { write!(f, "missing referenced slot shape id: {id}") } + Self::RoleDirectionMismatch(id, error) => { + write!(f, "slot shape {id}: {error}") + } Self::FactoryError(message) => f.write_str(message), } } diff --git a/lp-core/lpc-model/src/slot_codec/dynamic_slot_reader.rs b/lp-core/lpc-model/src/slot_codec/dynamic_slot_reader.rs index 5d8814811..cb076a145 100644 --- a/lp-core/lpc-model/src/slot_codec/dynamic_slot_reader.rs +++ b/lp-core/lpc-model/src/slot_codec/dynamic_slot_reader.rs @@ -199,6 +199,19 @@ where else { return Err(prop.unknown_field(prop.name(), &expected)); }; + // D2 (no authored Debug values): a Debug-role field is session-only + // and must never adopt an authored def-file value as its base. Warn + // and leave `prop` unconsumed — `PropReader::drop` skips its JSON + // value — so the field keeps whatever `create_default`/factory value + // it already has (alpha posture: warn, don't hard-reject). + if fields[index].role == crate::SlotRole::Debug { + log::warn!( + "ignoring authored value for debug-role field `{}`: Debug slots are \ + session-only and never adopt an authored value as their base", + fields[index].name.as_str() + ); + continue; + } let Some(field_data) = record.field_mut(index) else { return Err(syntax_error(format!( "record slot is missing field {:?}", @@ -543,6 +556,45 @@ mod tests { ); } + /// D2 (no authored Debug values): a Debug-role field's authored value is + /// ignored on parse — the field keeps its factory default rather than + /// adopting the def-file content — and unrelated sibling fields still + /// read normally. + #[test] + fn dynamic_slot_reader_ignores_authored_debug_role_fields() { + let shape_id = crate::SlotShapeId::from_static_name("test.DynamicDebugIgnored"); + let mut registry = SlotShapeRegistry::default(); + registry + .register_dynamic_shape( + shape_id, + shape::record(vec![ + shape::field("pin", shape::value(LpType::U32)), + shape::field_with_role( + "rate", + shape::value(LpType::F32), + crate::SlotRole::Debug, + ), + ]), + ) + .unwrap(); + + let object = read_json(®istry, shape_id, r#"{"pin":18,"rate":9.5}"#); + + let SlotDataAccess::Record(record) = object.data() else { + panic!("expected record"); + }; + assert_eq!( + record_value(record, 0), + LpValue::U32(18), + "sibling field reads normally" + ); + assert_eq!( + record_value(record, 1), + LpValue::F32(0.0), + "authored debug-role value must not become the base; factory default wins" + ); + } + #[test] fn dynamic_slot_reader_reads_maps() { let shape_id = crate::SlotShapeId::from_static_name("test.DynamicMap"); diff --git a/lp-core/lpc-model/tests/shape_guardrails.rs b/lp-core/lpc-model/tests/shape_guardrails.rs index c28706100..d205dc120 100644 --- a/lp-core/lpc-model/tests/shape_guardrails.rs +++ b/lp-core/lpc-model/tests/shape_guardrails.rs @@ -12,9 +12,41 @@ use std::collections::HashSet; use lpc_model::slot_shapes::{static_slot_shape, static_slot_shape_ids, static_slot_shape_name}; use lpc_model::{ BindingRef, SlotDirection, SlotShapeId, StaticLpType, StaticSlotFieldShape, - StaticSlotShapeDescriptor, + StaticSlotShapeDescriptor, role_matches_direction, }; +/// Produced runtime state is a **declared** role, not an inference from +/// direction (G2 amendment to `2026-08-01-debug-slots-taxonomy`): every +/// `#[slot(produced)]` field declares `role = "state"`, and nothing else +/// does. The `Slotted` derive rejects a mismatch at the declaration site; +/// this walks the whole generated catalog so a hand-written static +/// descriptor cannot slip one past it either. +#[test] +fn produced_fields_and_state_roles_are_the_same_set() { + let mut offenders = Vec::new(); + for &id in static_slot_shape_ids() { + let Some(shape) = static_slot_shape(id) else { + continue; + }; + let name = static_slot_shape_name(id) + .map(str::to_string) + .unwrap_or_else(|| format!("{id:?}")); + let mut visited = HashSet::new(); + walk(shape, &name, &mut visited, &mut |context, field| { + if !role_matches_direction(field.role, field.semantics.direction) { + offenders.push(format!( + "{context}.{} (role {:?}, direction {:?})", + field.name, field.role, field.semantics.direction + )); + } + }); + } + assert!( + offenders.is_empty(), + "produced ⇔ SlotRole::State must hold for every declared field: {offenders:?}" + ); +} + /// Every field whose value carries a product (visual/control) must declare a /// direction: products are dataflow endpoints, never `Local` bookkeeping. /// Produced outputs and consumed inputs (`FixtureDef.input`) are both fine. diff --git a/lp-core/lpc-registry/README.md b/lp-core/lpc-registry/README.md index 7e021471f..a7665f20a 100644 --- a/lp-core/lpc-registry/README.md +++ b/lp-core/lpc-registry/README.md @@ -35,8 +35,9 @@ wire-facing caller and any new caller must route through the same validation dedicated node-lifecycle operations behind the `CreateNode` / `RemoveNode` wire commands (`docs/adr/2026-07-27-node-authoring-operations.md`). They are the sanctioned path around the `nodes` map's `Fixed` role: generic slot -gestures on the map stay rejected, while the ops validate -everything up front and then act atomically. +gestures on the map stay rejected, while the ops validate everything up +front and then act atomically, staging through the crate-private +`ProjectRegistry::stage_dedicated_op`. - `create_node` **commits immediately**: it writes asset and def files through the injected `LpFs`, rewrites the attach site's **base** file with @@ -53,15 +54,20 @@ everything up front and then act atomically. existing ops (`RemoveSlotEdit` at the site + `ClearArtifact` per staged delete). -## Commit Filtering (Transient vs Persisted) +## Commit Filtering (Debug vs Persisted) `commit_overlay` materializes persisted edits into node-def artifacts and **retains transient overlay entries** instead of clearing the overlay -wholesale: entries whose resolved policy persistence is `Transient` survive -the commit and keep applying to the effective inventory. Belt-and-braces, the -JSON slot writer in `lpc-model` also omits transient fields, so no transient -value can appear in written def bytes regardless of caller. An only-transient -commit changes no overlay content and does not bump the overlay revision. +wholesale: entries whose resolved persistence is `Transient` — Debug-role +fields, plus anything produced (`SlotRoleResolution::persistence`) — survive +the commit and keep applying to the effective inventory. That classifier is +the one the studio also uses, so client and server cannot disagree about +whether an edit is a Debug override; an edit whose path resolves in no shape +takes the shared `SlotPersistence::for_unresolved_edit` rule (Setting) and +drops. Belt-and-braces, the JSON slot writer in `lpc-model` also omits Debug +and produced fields, so no transient value can appear in written def bytes +regardless of caller. An only-Debug commit changes no overlay content and +does not bump the overlay revision. The editing model (why dirty state derives from the overlay, revision gating, the client edit buffer) is recorded in diff --git a/lp-core/lpc-registry/src/registry/base_value_display.rs b/lp-core/lpc-registry/src/registry/base_value_display.rs index aaa76a9e5..592c5422e 100644 --- a/lp-core/lpc-registry/src/registry/base_value_display.rs +++ b/lp-core/lpc-registry/src/registry/base_value_display.rs @@ -272,11 +272,15 @@ mod tests { fn leaf_base_display_uses_plain_value_formatting() { let shapes = ctx_shapes(); let ctx = ParseCtx { shapes: &shapes }; - let def = clock_def(r#"{ "kind": "Clock", "controls": { "rate": 2.5 } }"#); + // `controls.*` is Debug-role (D2): it can never be authored, so this + // deliberately does not author it — every leaf here shows its shape + // default, which is what this test exercises for a float and a bool. + let def = clock_def(r#"{ "kind": "Clock" }"#); assert_eq!( base_display_in_def(&def, &path("controls.rate"), &ctx), - Some("2.5".to_string()) + Some("1.0".to_string()), + "unauthored (and unauthorable) leaves display their shape default" ); assert_eq!( base_display_in_def(&def, &path("controls.running"), &ctx), @@ -328,11 +332,14 @@ mod tests { fn variant_prefix_resolves_only_against_the_base_variant() { let shapes = ctx_shapes(); let ctx = ParseCtx { shapes: &shapes }; - let def = clock_def(r#"{ "kind": "Clock", "controls": { "rate": 4.0 } }"#); + // `controls.rate` is Debug-role and never authored (D2); this test's + // point is the variant-prefix resolution, not the value, so it + // exercises the shape default. + let def = clock_def(r#"{ "kind": "Clock" }"#); assert_eq!( base_display_in_def(&def, &path("Clock.controls.rate"), &ctx), - Some("4.0".to_string()) + Some("1.0".to_string()) ); assert_eq!( base_display_in_def(&def, &path("Fixture.color_order"), &ctx), @@ -379,15 +386,46 @@ mod tests { fn base_value_resolves_leaves_only() { let shapes = ctx_shapes(); let ctx = ParseCtx { shapes: &shapes }; - let def = clock_def(r#"{ "kind": "Clock", "controls": { "rate": 2.0 } }"#); + // `controls.rate` is Debug-role and never authored (D2); this test's + // point is that only leaves resolve to a value (`controls` itself, + // a structural target, does not), not the specific value. + let def = clock_def(r#"{ "kind": "Clock" }"#); assert_eq!( base_value_in_def(&def, &path("controls.rate"), &ctx), - Some(LpValue::F32(2.0)) + Some(LpValue::F32(1.0)) ); assert_eq!(base_value_in_def(&def, &path("controls"), &ctx), None); } + /// D2 (P4, kills W3): an authored Debug-role value in a def file must + /// never become the base — the loader ignores it (with a warning), so + /// the base display/value/presence always reflects the shape default + /// regardless of what the def file authored. This is the regression + /// guard for "a commit can no longer shift base under a live override". + #[test] + fn debug_role_authored_value_never_becomes_base() { + let shapes = ctx_shapes(); + let ctx = ParseCtx { shapes: &shapes }; + let def = + clock_def(r#"{ "kind": "Clock", "controls": { "rate": 2.5, "running": false } }"#); + + assert_eq!( + base_value_in_def(&def, &path("controls.rate"), &ctx), + Some(LpValue::F32(1.0)), + "authored 2.5 must not become the base; the shape default (1.0) wins" + ); + assert_eq!( + base_display_in_def(&def, &path("controls.running"), &ctx), + Some("true".to_string()), + "authored false must not become the base; the shape default (true) wins" + ); + assert!( + base_presence_in_def(&def, &path("controls.rate"), &ctx), + "the shape default is still present: EnsurePresent is still a base no-op" + ); + } + #[test] fn format_matches_client_display_conventions() { assert_eq!(format_base_lp_value(&LpValue::Bool(true)), "true"); diff --git a/lp-core/lpc-registry/src/registry/node_authoring.rs b/lp-core/lpc-registry/src/registry/node_authoring.rs index 629cc81cb..221db8080 100644 --- a/lp-core/lpc-registry/src/registry/node_authoring.rs +++ b/lp-core/lpc-registry/src/registry/node_authoring.rs @@ -3,7 +3,7 @@ //! `create_node` and `remove_node` are the operations the `ProjectDef.nodes` //! `Fixed` role promises: node creation and removal never //! arrive as raw slot edits, they arrive here. Both address the node through -//! a [`NodeAttachSite`] — either the policy-locked project `nodes` map (the +//! a [`NodeAttachSite`] — either the role-locked project `nodes` map (the //! dedicated-op bypass applies **only** to that site) or any writable //! `NodeInvocationSlot`-shaped slot such as a playlist's `entries[k].node`. //! @@ -45,7 +45,7 @@ use crate::overlay::apply_op_to_def; use crate::overlay::inventory_change_summary::change_summary_between; use crate::registry::base_value_display; use crate::registry::project_registry::{ - ProjectRegistry, effective_map_entry_presence, resolve_edit_policy, shape_at_path, + ProjectRegistry, effective_map_entry_presence, resolve_edit_role, shape_at_path, }; /// Outcome of an accepted [`ProjectRegistry::create_node`]. @@ -194,7 +194,7 @@ impl ProjectRegistry { } NodeAttachSite::Slot { artifact, path } => { let def = self.loaded_def_for_mutation(artifact)?; - let Some(resolution) = resolve_edit_policy(def, path, ctx) else { + let Some(resolution) = resolve_edit_role(def, path, ctx) else { return Err(reject( MutationRejectionReason::UnknownSlotPath, format!( @@ -444,13 +444,14 @@ impl ProjectRegistry { .any(|(path, _)| is_at_or_under(&entry_path, path)) }); - // Stage the entry removal through the singular mutation path: it is - // deliberately unvalidated (the dedicated-op bypass of the `nodes` - // map's `Fixed` role), normalizes an overlay-only entry - // away instead of storing a no-op edit, and re-derives the effective - // inventory. `PutSlotEdit` cannot fail on that path. + // Stage the entry removal through the dedicated-op staging path: it + // deliberately skips validation (the sanctioned bypass of the `nodes` + // map's `Fixed` role — this operation IS how that map is edited), + // normalizes an overlay-only entry away instead of storing a no-op + // edit, and re-derives the effective inventory. `PutSlotEdit` cannot + // fail on that path. let removal = self - .mutate( + .stage_dedicated_op( fs, MutationOp::PutSlotEdit { artifact: site_artifact.clone(), @@ -564,7 +565,7 @@ impl ProjectRegistry { } NodeAttachSite::Slot { artifact, path } => { let def = self.loaded_def_for_mutation(artifact)?; - let Some(resolution) = resolve_edit_policy(def, path, ctx) else { + let Some(resolution) = resolve_edit_role(def, path, ctx) else { return Err(reject( MutationRejectionReason::UnknownSlotPath, format!( @@ -1026,11 +1027,12 @@ mod tests { assert_eq!(rejection.reason, MutationRejectionReason::TargetOccupied); assert_nothing_written(&fs, "/texture.json"); - // Overlay-staged key: stage `nodes[strip]` through the unvalidated - // single-mutation path (the validated path rejects the read-only - // map), then create at the same key — the EFFECTIVE map counts. + // Overlay-staged key: stage `nodes[strip]` through the dedicated-op + // staging path (the validated path rejects the read-only map, which + // is exactly why the authoring ops stage there), then create at the + // same key — the EFFECTIVE map counts. registry - .mutate( + .stage_dedicated_op( &fs, MutationOp::PutSlotEdit { artifact: ArtifactLocation::file("/project.json"), @@ -1114,8 +1116,8 @@ mod tests { let body = texture_body(&shapes); // Read-only slot site: `nodes[…]` on the project root is invocation- - // shaped but policy-locked — only the dedicated `ProjectNodes` site - // bypasses the policy. + // shaped but role-locked — only the dedicated `ProjectNodes` site + // bypasses the `Fixed` role. let rejection = create( &fs, &mut registry, @@ -1453,7 +1455,7 @@ mod tests { let outcome = remove(&fs, &mut registry, &shapes, &project_nodes("shader")).expect("remove accepted"); - // Revert = RemoveSlotEdit at the site (policy-exempt) + ClearArtifact + // Revert = RemoveSlotEdit at the site (role-exempt) + ClearArtifact // per staged delete — existing ops only, through the validated path. let mut commands = vec![MutationCmd { id: MutationCmdId::new(1), @@ -1529,7 +1531,7 @@ mod tests { .expect_err("non-invocation path rejects"); assert_eq!(rejection.reason, MutationRejectionReason::UnknownSlotPath); - // The policy bypass applies only to the dedicated `ProjectNodes` + // The role bypass applies only to the dedicated `ProjectNodes` // site; the same target addressed as a raw slot stays locked. let rejection = remove( &fs, diff --git a/lp-core/lpc-registry/src/registry/project_registry.rs b/lp-core/lpc-registry/src/registry/project_registry.rs index 082091b13..70ecbe0a1 100644 --- a/lp-core/lpc-registry/src/registry/project_registry.rs +++ b/lp-core/lpc-registry/src/registry/project_registry.rs @@ -10,9 +10,10 @@ use lpc_model::{ MutationEffect, MutationOp, MutationRejection, MutationRejectionReason, MutationResult, NodeArtifact, NodeDef, NodeDefEntry, NodeDefLocation, NodeDefState, PROJECT_FORMAT_VERSION, ProjectFormatProbe, ProjectInventory, ProjectOverlay, Revision, SlotAccess, SlotDataAccess, - SlotEditOp, SlotMapKey, SlotName, SlotPath, SlotPathSegment, SlotRole, SlotRoleResolution, + SlotEditOp, SlotMapKey, SlotName, SlotPath, SlotPathSegment, SlotRoleResolution, SlotShapeLookup, SlotShapeView, StaticSlotShape, StoredSlotEdit, WithRevision, lookup_slot_data, lp_value_matches_type, read_project_format_json, resolve_slot_role, + slot::SlotPersistence, }; use lpfs::{FsEvent, FsEventKind, LpFs, LpPath}; @@ -93,18 +94,54 @@ impl ProjectRegistry { } } + /// Apply one mutation, validated exactly like a [`Self::mutate_batch`] + /// command. + /// + /// This used to be the registry's unvalidated bypass (follow-up (d) of + /// the editing-model ADR): anything reaching it could store an edit the + /// batch path would have rejected — an unwritable slot, a type mismatch, + /// a path that resolves nowhere. It now runs the same + /// [`Self::validate_mutation`] first, so there is no public write path + /// into the overlay that skips validation. The remaining bypass is + /// [`Self::stage_dedicated_op`], crate-private and used only by the + /// authoring operations that deliberately write `Fixed` containers. pub fn mutate( &mut self, fs: &dyn LpFs, mutation: MutationOp, frame: Revision, ctx: &ParseCtx<'_>, + ) -> Result { + self.validate_mutation(&mutation, ctx) + .map_err(|rejection| EditApplyError::InvalidPath { + message: rejection.message, + })?; + self.stage_dedicated_op(fs, mutation, frame, ctx) + } + + /// Apply one mutation **without** validating it — the staging path for + /// dedicated authoring operations. + /// + /// Crate-private on purpose. Its callers are the node-authoring ops + /// ([`crate::registry::node_authoring`]), which validate their own + /// preconditions and then write containers no client may edit directly: + /// removing a `nodes` / `entries` entry targets a `Fixed` map, so + /// [`Self::validate_mutation`] would (correctly) reject the very edit the + /// sanctioned operation exists to make. Every other caller — the wire + /// path, tests, tooling — goes through [`Self::mutate`] or + /// [`Self::mutate_batch`] and is validated. + pub(crate) fn stage_dedicated_op( + &mut self, + fs: &dyn LpFs, + mutation: MutationOp, + frame: Revision, + ctx: &ParseCtx<'_>, ) -> Result { let before = self.inventory.clone(); let covered_before = self.overlay_covered_artifacts(); let overlay_changed = match mutation { // Moves must materialize (and therefore validate) even on this - // otherwise-unvalidated path; an invalid move maps to the error + // unvalidated staging path; an invalid move maps to the error // channel rather than silently storing nothing. MutationOp::MoveSlotEntry { artifact, from, to } => { self.validate_move_slot_entry(&artifact, &from, &to, ctx) @@ -537,14 +574,21 @@ impl ProjectRegistry { Ok(CommitResult { artifact_changes }) } - /// Post-commit overlay: keep slot edits whose governing role is `Debug` - /// (they never serialize to def files, so commit does not resolve them; - /// they stay pending and runtime-effective). Persisted slot edits and - /// asset overlays drop — their content is now on disk. A stale edit - /// whose path no longer resolves in the def shape drops like a persisted - /// one: it was already unenforceable. Artifacts left without edits are - /// not retained, preserving the empty-artifact-overlay removal - /// invariant. + /// Post-commit overlay: keep slot edits whose governing persistence is + /// transient (they never serialize to def files, so commit does not + /// resolve them; they stay pending and runtime-effective). Persisted slot + /// edits and asset overlays drop — their content is now on disk. + /// Artifacts left without edits are not retained, preserving the + /// empty-artifact-overlay removal invariant. + /// + /// The classifier is [`SlotRoleResolution::persistence`] — role **and** + /// direction, the same function the studio classifies display and dirty + /// state with, so the two sides cannot disagree about whether an edit is + /// a Debug override (D1: a produced path is transient whatever its role). + /// An edit whose path resolves in no shape (stale artifact, errored def, + /// removed field) takes the shared unresolvable rule + /// ([`SlotPersistence::for_unresolved_edit`] — Setting) and therefore + /// drops like a persisted one: it was already unenforceable. fn retain_debug_edits(&self, committed: &ProjectOverlay, ctx: &ParseCtx<'_>) -> ProjectOverlay { let mut retained = ProjectOverlay::new(); for (location, artifact_overlay) in committed.iter() { @@ -560,8 +604,10 @@ impl ProjectRegistry { continue; }; let debug = slot_overlay.filtered(|path, _| { - resolve_edit_policy(def, path, ctx) - .is_some_and(|resolution| resolution.role == SlotRole::Debug) + resolve_edit_role(def, path, ctx) + .map(|resolution| resolution.persistence()) + .unwrap_or_else(SlotPersistence::for_unresolved_edit) + == SlotPersistence::Transient }); if !debug.is_empty() { retained @@ -720,13 +766,14 @@ impl ProjectRegistry { ) } - /// Validate one batch command against the effective inventory before it - /// touches the overlay. + /// Validate one command against the effective inventory before it + /// touches the overlay — the shared gate behind both + /// [`Self::mutate_batch`] and [`Self::mutate`]. /// - /// Slot policy applies to slot edits only: + /// Slot roles apply to slot edits only: /// /// - `PutSlotEdit` requires a resolvable artifact and slot path, a - /// writable policy at the path, and (for `AssignValue`) a value-leaf + /// writable role at the path, and (for `AssignValue`) a value-leaf /// target plus a value matching its value type. A structural target /// rejects as [`MutationRejectionReason::NotAValueLeaf`]: composite /// values are never assigned wholesale — gestures compose from @@ -739,7 +786,7 @@ impl ProjectRegistry { /// map, a source key present in the **effective** definition, and a /// target key absent from it ([`Self::validate_move_slot_entry`]). /// - Whole-artifact ops (`SetArtifactBody` / `ClearArtifact` / `Clear`) - /// carry no slot policy and are accepted unchanged. + /// address no slot and are accepted unchanged. fn validate_mutation( &self, mutation: &MutationOp, @@ -748,7 +795,7 @@ impl ProjectRegistry { match mutation { MutationOp::PutSlotEdit { artifact, edit } => { let def = self.loaded_def_for_mutation(artifact)?; - let Some(resolution) = resolve_edit_policy(def, &edit.path, ctx) else { + let Some(resolution) = resolve_edit_role(def, &edit.path, ctx) else { return Err(MutationRejection::new( MutationRejectionReason::UnknownSlotPath, format!( @@ -804,7 +851,7 @@ impl ProjectRegistry { /// does not resolve to a map, or the source key is absent from the /// **effective** def (moves act on what the user sees, unlike /// base-relative normalization); `NotWritable` per the map's entry - /// policy; [`MutationRejectionReason::TargetOccupied`] when the target + /// role; [`MutationRejectionReason::TargetOccupied`] when the target /// key is already present in the effective def. fn validate_move_slot_entry( &self, @@ -832,7 +879,7 @@ impl ProjectRegistry { "move endpoints must address the same map: {from} vs {to}" ))); } - let Some(resolution) = resolve_edit_policy(def, to, ctx) else { + let Some(resolution) = resolve_edit_role(def, to, ctx) else { return Err(unknown_path(format!( "slot path {to} does not resolve in artifact {}", artifact.file_path() @@ -1001,7 +1048,7 @@ impl ProjectRegistry { /// variant must clear, or empty when the mutation is no such edit. /// /// Detection is a shape-only walk (the same resolution family as - /// [`resolve_edit_policy`] / [`resolve_slot_role`], so it works + /// [`resolve_edit_role`] / [`resolve_slot_role`], so it works /// regardless of which variant is currently active): the edit's terminal /// segment must name a declared variant of the enum its parent path /// resolves to in the effective definition's shape. Returns the paths of @@ -1324,15 +1371,15 @@ impl NormalizedEdit { } } -/// Resolve the policy (and leaf value type) governing `path` inside the -/// effective definition `def`. +/// Resolve the role (and governing direction, and leaf value type) for +/// `path` inside the effective definition `def`. /// /// Slot edit paths may carry the artifact root variant as their first segment /// (mirroring how [`crate::overlay`] applies them): such paths resolve /// against the artifact wrapper shape so edits that switch the variant /// validate against the target variant's shape. Bare paths resolve against /// the effective definition's own shape. -pub(crate) fn resolve_edit_policy( +pub(crate) fn resolve_edit_role( def: &NodeDef, path: &SlotPath, ctx: &ParseCtx<'_>, @@ -1350,7 +1397,7 @@ pub(crate) fn resolve_edit_policy( /// Paths of the other declared variants when `path` terminates at an enum /// variant per the shape walk, or empty otherwise. /// -/// The root shape follows [`resolve_edit_policy`]'s rule (a leading artifact +/// The root shape follows [`resolve_edit_role`]'s rule (a leading artifact /// root variant segment resolves against the artifact wrapper shape), and the /// walk to the parent path is shape-only ([`shape_at_path`]) — enum variant /// segments resolve against any declared variant, matching how the edits are @@ -1396,7 +1443,7 @@ fn enum_variant_sibling_paths(def: &NodeDef, path: &SlotPath, ctx: &ParseCtx<'_> /// ([`ProjectRegistry::structural_ensure_scope`]): the parent option path /// when the terminal segment is an option's `some`, `path` itself otherwise. /// -/// The root shape follows [`resolve_edit_policy`]'s rule and the walk to the +/// The root shape follows [`resolve_edit_role`]'s rule and the walk to the /// parent is shape-only ([`shape_at_path`]), matching how the edit is /// validated and applied — including the segment-resolution precedence, so a /// record field literally named `some` stays a field terminal, not an option @@ -1433,7 +1480,7 @@ fn ensure_effective_scope(def: &NodeDef, path: &SlotPath, ctx: &ParseCtx<'_>) -> /// Shape at `segments` under `shape`: the same shape-only walk as /// [`resolve_slot_role`] (chasing `Ref` indirections and `Custom` /// projections, resolving enum variant segments against any declared -/// variant), returning the shape view instead of a policy. `None` when the +/// variant), returning the shape view instead of a role. `None` when the /// path does not resolve in the shape. pub(crate) fn shape_at_path<'s>( shape: SlotShapeView<'s>, @@ -1464,7 +1511,7 @@ pub(crate) fn shape_at_path<'s>( } /// Chase `Ref` indirections and `Custom` projections to a concrete shape -/// (the local counterpart of the policy walk's projection step). +/// (the local counterpart of the role walk's projection step). fn resolve_projected_shape<'s>( mut shape: SlotShapeView<'s>, ctx: &ParseCtx<'s>, @@ -1758,10 +1805,11 @@ mod tests { let (fs, mut registry) = clock_project(&shapes); let clock = clock_artifact(); - // Seed a pending edit through the unvalidated single-mutation path so - // there is overlay state to remove on the now read-only slot. + // Seed a pending edit through the unvalidated staging path (the + // validated `mutate`/`mutate_batch` entry points reject the now + // read-only slot) so there is overlay state to remove. registry - .mutate( + .stage_dedicated_op( &fs, MutationOp::PutSlotEdit { artifact: clock.clone(), @@ -1917,6 +1965,200 @@ mod tests { assert!(def.bindings.entries().contains_key(&String::from("speed"))); } + #[test] + fn commit_classifies_retention_by_persistence_not_role_alone() { + // S5: the studio and the registry classify an edit with SEPARATE + // resolvers, so they must run the SAME rule or they disagree about + // what an edit is. The studio classifies through + // `effective_persistence(role, direction)`, where a produced path is + // transient whatever its role (D1); retention must therefore ask the + // same question. Asking `role == Debug` alone would drop such an + // entry at commit while the studio still believed it held a live + // override — a value that vanishes with no Clear. + // + // The probe is a `State`/produced field. Since the G2 amendment made + // the produced role explicit, `State` + `Produced` is the *only* legal + // spelling of a produced field — and it is a SECOND role that + // classifies transient, which is exactly what "not role alone" means. + // (The original probe here was `Debug` + `Produced`; that pairing is + // now rejected at shape registration by `role_matches_direction`, so + // the divergence it guarded against is unrepresentable rather than + // merely handled.) + let mut shapes = SlotShapeRegistry::default(); + shapes.replace_shape( + ClockDef::SHAPE_ID, + clock_shape_with(|rate| { + rate.role = lpc_model::SlotRole::State; + rate.semantics = lpc_model::SlotSemantics::produced(); + }), + ); + let (fs, mut registry) = clock_project(&shapes); + let clock = clock_artifact(); + let rate = SlotPath::parse("controls.rate").unwrap(); + + // A produced path is not client-writable, so it can only be staged + // through the dedicated-op path (W9 keeps `mutate` validated). + registry + .stage_dedicated_op( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value(rate.clone(), LpValue::F32(2.5)), + }, + Revision::new(4), + &ParseCtx { shapes: &shapes }, + ) + .unwrap(); + + registry + .commit_overlay(&fs, Revision::new(20), &ParseCtx { shapes: &shapes }) + .unwrap(); + + let retained = registry + .overlay() + .get() + .artifact(&clock) + .and_then(ArtifactOverlay::as_slot) + .expect("a transient-classified edit keeps its artifact overlay"); + assert!( + retained.contains_path(&rate), + "a produced path classifies transient on both sides, so commit \ + retains it exactly like a Debug-role edit" + ); + let text = String::from_utf8(fs.read_file(LpPath::new("/clock.json")).unwrap()).unwrap(); + assert!( + !text.contains("rate"), + "and the writer still never serializes it: {text}" + ); + } + + #[test] + fn unresolvable_edit_paths_classify_as_settings_on_both_sides() { + // The other half of S5: a path that resolves in NO shape. The studio + // falls back to `SlotPersistence::for_unresolved_edit` (Setting) when + // its shape snapshot cannot classify an entry; commit must reach the + // same verdict from the def side, so an unclassifiable edit resolves + // away instead of lingering as an override nothing accounts for. + assert_eq!( + SlotPersistence::for_unresolved_edit(), + SlotPersistence::Persisted + ); + let shapes = SlotShapeRegistry::default(); + let (_fs, registry) = clock_project(&shapes); + + // An absent or errored def cannot classify anything: + // `retain_debug_edits` must drop what it cannot resolve, never keep + // it. (Reached directly: the overlay cannot legally hold such an + // entry through any validated path — which is the point.) + let mut ghost = lpc_model::SlotOverlay::new(); + ghost.put_edit(SlotEdit::assign_value( + SlotPath::parse("controls.rate").unwrap(), + LpValue::F32(2.0), + )); + let mut overlay = ProjectOverlay::new(); + overlay.artifacts.insert( + ArtifactLocation::file("/not-a-node.json"), + ArtifactOverlay::slot(ghost), + ); + + let retained = registry.retain_debug_edits(&overlay, &ParseCtx { shapes: &shapes }); + assert!( + retained.is_empty(), + "edits the registry cannot classify are Settings by the shared \ + rule and are never retained" + ); + } + + #[test] + fn singular_mutate_validates_like_the_batch_path() { + // W9: `mutate` used to be a documented unvalidated bypass. It now + // runs the same `validate_mutation` as `mutate_batch`; the bypass + // survives only as the crate-private `stage_dedicated_op` the + // authoring operations use to write `Fixed` containers. + let shapes = shapes_with_read_only_rate(); + let (fs, mut registry) = clock_project(&shapes); + let clock = clock_artifact(); + let ctx = ParseCtx { shapes: &shapes }; + let rate = SlotPath::parse("controls.rate").unwrap(); + + let not_writable = registry + .mutate( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value(rate.clone(), LpValue::F32(2.0)), + }, + Revision::new(4), + &ctx, + ) + .expect_err("a read-only slot is rejected, not stored"); + assert!( + matches!(¬_writable, EditApplyError::InvalidPath { message } + if message.contains("not writable")), + "{not_writable:?}" + ); + + let unknown_path = registry + .mutate( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value( + SlotPath::parse("controls.no_longer_in_shape").unwrap(), + LpValue::F32(2.0), + ), + }, + Revision::new(5), + &ctx, + ) + .expect_err("an unresolvable path is rejected, not stored"); + assert!( + matches!(&unknown_path, EditApplyError::InvalidPath { message } + if message.contains("does not resolve")), + "{unknown_path:?}" + ); + + let type_mismatch = registry + .mutate( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value( + SlotPath::parse("controls.running").unwrap(), + LpValue::F32(2.0), + ), + }, + Revision::new(6), + &ctx, + ) + .expect_err("a type-mismatched value is rejected, not stored"); + assert!( + matches!(&type_mismatch, EditApplyError::InvalidPath { message } + if message.contains("expects")), + "{type_mismatch:?}" + ); + + assert!( + registry.overlay().get().is_empty(), + "a rejected mutation stores nothing" + ); + + // The sanctioned bypass still stages exactly what validation refuses + // — that is what the node-authoring ops need it for. + registry + .stage_dedicated_op( + &fs, + MutationOp::PutSlotEdit { + artifact: clock.clone(), + edit: SlotEdit::assign_value(rate.clone(), LpValue::F32(2.0)), + }, + Revision::new(7), + &ctx, + ) + .expect("the dedicated-op path stages without validating"); + assert!(!registry.overlay().get().is_empty()); + } + #[test] fn removing_a_slot_edit_advances_the_def_revision() { // Gated-read contract: effective def revisions are monotonic. A @@ -1968,9 +2210,10 @@ mod tests { ); // The stamp is sticky, not per-derivation: an unrelated later - // mutation must not re-stamp the reverted def. + // mutation must not re-stamp the reverted def. (Staged through the + // dedicated-op path: `nodes` is a `Fixed` map.) registry - .mutate( + .stage_dedicated_op( &fs, MutationOp::PutSlotEdit { artifact: ArtifactLocation::file("/project.json"), @@ -2078,9 +2321,10 @@ mod tests { "leaving the overlay must advance the def revision" ); - // Sticky: an unrelated later mutation must not re-stamp it. + // Sticky: an unrelated later mutation must not re-stamp it. (Staged + // through the dedicated-op path: `nodes` is a `Fixed` map.) registry - .mutate( + .stage_dedicated_op( &fs, MutationOp::PutSlotEdit { artifact: ArtifactLocation::file("/project.json"), @@ -3607,7 +3851,9 @@ mod tests { /// Shape registry where `controls.rate` on the clock definition is /// read-only. No authored definition declares a non-writable field today, /// so the fixture flips one role in the real clock shape. - fn shapes_with_read_only_rate() -> SlotShapeRegistry { + /// The clock def shape with `controls.rate`'s field shape rewritten by + /// `edit` — the seam behind the role/direction variants below. + fn clock_shape_with(edit: impl FnOnce(&mut lpc_model::SlotFieldShape)) -> SlotShape { let mut shape = ClockDef::slot_shape(); let SlotShape::Record { fields, .. } = &mut shape else { panic!("clock def shape must be a record"); @@ -3623,10 +3869,16 @@ mod tests { .iter_mut() .find(|field| field.name.as_str() == "rate") .expect("rate field"); - rate.role = SlotRole::Fixed; + edit(rate); + shape + } + fn shapes_with_read_only_rate() -> SlotShapeRegistry { let mut shapes = SlotShapeRegistry::default(); - shapes.replace_shape(ClockDef::SHAPE_ID, shape); + shapes.replace_shape( + ClockDef::SHAPE_ID, + clock_shape_with(|rate| rate.role = SlotRole::Fixed), + ); shapes } diff --git a/lp-core/lpc-shared/src/project/builder.rs b/lp-core/lpc-shared/src/project/builder.rs index 91817f47b..e51e63b03 100644 --- a/lp-core/lpc-shared/src/project/builder.rs +++ b/lp-core/lpc-shared/src/project/builder.rs @@ -353,6 +353,7 @@ impl OutputBuilder { endpoint: ValueSlot::new(self.endpoint), bindings: bus_input_binding_defs("control.out"), options: OptionSlot::some(self.options), + ..Default::default() }; let json = authored_node_json(&slot_shape_registry(), &NodeDef::Output(config)); diff --git a/lp-core/lpc-slot-macros/src/attr.rs b/lp-core/lpc-slot-macros/src/attr.rs index 0817bed3b..b6eb66ee9 100644 --- a/lp-core/lpc-slot-macros/src/attr.rs +++ b/lp-core/lpc-slot-macros/src/attr.rs @@ -52,6 +52,7 @@ pub(crate) enum SlotRoleAttr { Setting, Fixed, Debug, + State, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -423,6 +424,38 @@ pub(crate) fn field_role_tokens(role: SlotRoleAttr) -> TokenStream { SlotRoleAttr::Setting => quote::quote! { ::lpc_model::SlotRole::Setting }, SlotRoleAttr::Fixed => quote::quote! { ::lpc_model::SlotRole::Fixed }, SlotRoleAttr::Debug => quote::quote! { ::lpc_model::SlotRole::Debug }, + SlotRoleAttr::State => quote::quote! { ::lpc_model::SlotRole::State }, + } +} + +/// Reject a field whose declared role and direction contradict each other. +/// +/// `role = "state"` and `#[slot(produced)]` are two spellings of one fact and +/// the model requires both (G2 amendment to the debug-slots taxonomy ADR: +/// declaration beats inference, so an unmarked produced state record must be +/// impossible). Both halves are statically visible here, so both are compile +/// errors; runtime-assembled shapes get the same check from +/// `SlotShape::validate_role_direction` at registration. +pub(crate) fn check_role_direction( + role: Option, + direction: FieldDirectionAttr, + span: proc_macro2::Span, +) -> Result<()> { + let is_state = role == Some(SlotRoleAttr::State); + let is_produced = direction == FieldDirectionAttr::Produced; + match (is_state, is_produced) { + (true, false) => Err(syn::Error::new( + span, + "slot role \"state\" marks runtime-produced state, so the field \ + must also declare #[slot(produced)]", + )), + (false, true) => Err(syn::Error::new( + span, + "a #[slot(produced)] field must declare role = \"state\" (or sit \ + in a container with default_role = \"state\"): produced runtime \ + state is a declared role, not an inference from direction", + )), + _ => Ok(()), } } @@ -506,9 +539,10 @@ fn parse_role(value: &LitStr) -> Result { "setting" => Ok(SlotRoleAttr::Setting), "fixed" => Ok(SlotRoleAttr::Fixed), "debug" => Ok(SlotRoleAttr::Debug), + "state" => Ok(SlotRoleAttr::State), _ => Err(syn::Error::new_spanned( value, - "unsupported slot role; expected \"setting\", \"fixed\", or \"debug\"", + "unsupported slot role; expected \"setting\", \"fixed\", \"debug\", or \"state\"", )), } } diff --git a/lp-core/lpc-slot-macros/src/lib.rs b/lp-core/lpc-slot-macros/src/lib.rs index ca7f09102..82575207d 100644 --- a/lp-core/lpc-slot-macros/src/lib.rs +++ b/lp-core/lpc-slot-macros/src/lib.rs @@ -38,6 +38,9 @@ //! - No container marker is required for a slot-modeled type; `Slotted` //! derives static shape support for every record. //! - `#[slot(shape_id = "...")]`: override the generated static shape id. +//! - `#[slot(default_role = "setting" | "fixed" | "debug" | "state")]`: role +//! for every field that does not declare its own. Runtime-state records use +//! `default_role = "state"`. //! Build-time slot-view generation discovers every `Slotted` and emits the //! corresponding `*View` type. //! @@ -51,7 +54,12 @@ //! - `#[slot(map(key = "...", value_ref = "..."))]`: shape a map whose values //! reference another shape root. //! - `#[slot(consumed)]`: mark the field as a consumed dataflow slot. -//! - `#[slot(produced)]`: mark the field as a produced dataflow slot. +//! - `#[slot(produced)]`: mark the field as a produced dataflow slot. Must be +//! paired with role `"state"` (declared on the field or inherited from the +//! container's `default_role`), and role `"state"` must be paired with +//! `produced` — the derive rejects either one alone. +//! - `#[slot(role = "setting" | "fixed" | "debug" | "state")]`: editing and +//! persistence role for this field. //! - `#[slot(merge = "latest" | "error" | "by_key")]`: set the receiver-owned //! merge policy for aggregate consumed slots. diff --git a/lp-core/lpc-slot-macros/src/slotted_enum.rs b/lp-core/lpc-slot-macros/src/slotted_enum.rs index 23e9b4465..a72e6f821 100644 --- a/lp-core/lpc-slot-macros/src/slotted_enum.rs +++ b/lp-core/lpc-slot-macros/src/slotted_enum.rs @@ -127,6 +127,14 @@ pub(crate) fn derive_enum( .as_ref() .map_or_else(|| field_ident.to_string(), syn::LitStr::value); validate_slot_name(&field_name, field_ident.span())?; + // Variant payload fields are always local settings (see + // the static shape below), so a `produced`/`state` + // declaration here would be silently dropped. Reject it. + attr::check_role_direction( + field_attr.role, + field_attr.direction, + field_ident.span(), + )?; let field_ty = field.ty; let shape = attr::field_shape_tokens(&field_attr.shape, &field_ty); diff --git a/lp-core/lpc-slot-macros/src/slotted_record.rs b/lp-core/lpc-slot-macros/src/slotted_record.rs index 8e7d1bf7c..95c770740 100644 --- a/lp-core/lpc-slot-macros/src/slotted_record.rs +++ b/lp-core/lpc-slot-macros/src/slotted_record.rs @@ -41,6 +41,7 @@ pub(crate) fn derive_record( let static_shape_binding = format_ident!("__field_shape_{}", static_shape_bindings.len()); let semantics = attr::field_semantics_tokens(field_attr.direction, field_attr.merge); let selected_role = field_attr.role.or(container_attrs.default_role); + attr::check_role_direction(selected_role, field_attr.direction, field_ident.span())?; let role = selected_role .map(attr::field_role_tokens) .unwrap_or_else(|| quote! { ::lpc_model::SlotRole::default() }); @@ -78,14 +79,16 @@ pub(crate) fn derive_record( access_arms.push(quote! { #index => Some(#access), }); - // Only produced fields drop dynamic mut access: they are never - // authored, so nothing legitimate writes them dynamically - // (direction implies read-only regardless of role, D1). A - // read-only-but-persisted (`Fixed`) field is still authored - // JSON — the dynamic reader must be able to deserialize it — - // and its write protection is mutate-time role enforcement - // (`resolve_slot_role`), not a codec-level hole. - if field_attr.direction != attr::FieldDirectionAttr::Produced + // Only `State` fields drop dynamic mut access: they are never + // authored, so nothing legitimate writes them dynamically. Keyed + // on the declared ROLE since the G2 amendment; `direction == + // Produced` is implied and would select exactly the same fields + // (`attr::check_role_direction` above rejects any field where + // the two disagree). A read-only-but-persisted (`Fixed`) field is + // still authored JSON — the dynamic reader must be able to + // deserialize it — and its write protection is mutate-time role + // enforcement (`resolve_slot_role`), not a codec-level hole. + if selected_role != Some(attr::SlotRoleAttr::State) && let Some(mut_access) = attr::field_mut_access_tokens(&field_attr.shape, &field_ty, &field_ident) { diff --git a/lp-fw/fw-browser/www/smoke-project/clock.json b/lp-fw/fw-browser/www/smoke-project/clock.json index 79834c140..253ac2aae 100644 --- a/lp-fw/fw-browser/www/smoke-project/clock.json +++ b/lp-fw/fw-browser/www/smoke-project/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/fyeah-sign/clock.json b/projects/test/fyeah-sign/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/fyeah-sign/clock.json +++ b/projects/test/fyeah-sign/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-equal100-v3/clock.json b/projects/test/quad-equal100-v3/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-equal100-v3/clock.json +++ b/projects/test/quad-equal100-v3/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-gamma-full/clock.json b/projects/test/quad-gamma-full/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-gamma-full/clock.json +++ b/projects/test/quad-gamma-full/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-gamma-v3/clock.json b/projects/test/quad-gamma-v3/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-gamma-v3/clock.json +++ b/projects/test/quad-gamma-v3/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-strips-1fix/clock.json b/projects/test/quad-strips-1fix/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-strips-1fix/clock.json +++ b/projects/test/quad-strips-1fix/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-strips-v3/clock.json b/projects/test/quad-strips-v3/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-strips-v3/clock.json +++ b/projects/test/quad-strips-v3/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad-strips/clock.json b/projects/test/quad-strips/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad-strips/clock.json +++ b/projects/test/quad-strips/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/projects/test/quad60-v3/clock.json b/projects/test/quad60-v3/clock.json index 79834c140..253ac2aae 100644 --- a/projects/test/quad60-v3/clock.json +++ b/projects/test/quad60-v3/clock.json @@ -1,8 +1,3 @@ { - "kind": "Clock", - "controls": { - "running": true, - "rate": 1, - "scrub_offset_seconds": 0 - } + "kind": "Clock" } diff --git a/schemas/README.md b/schemas/README.md index b8936bad9..5b8b870f7 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -26,9 +26,14 @@ neither can drift from the parser — but the codec's real contract includes behavior JSON Schema cannot express: record fields are all optional on read (missing → factory default), unit payloads accept arbitrary junk, `Ratio`/`PositiveF32` bounds are unenforced hints, the `kind` discriminator -must be the *first* property, and `LpValue::Any` reads narrower than it -writes. A future offline upgrader (Studio/desktop; the device never -upgrades) will consume shape dumps and fixture files, not JSON Schemas. +must be the *first* property, `LpValue::Any` reads narrower than it writes, +and `SlotRole::Debug` fields (session-only diagnostics, e.g. the clock's +`controls.*`) are omitted from the JSON Schema entirely even though the +reader still accepts (and now warns-and-ignores) an authored value there — +the dump still carries their role, since it describes the model, not what a +def file may validly author. A future offline upgrader (Studio/desktop; the +device never upgrades) will consume shape dumps and fixture files, not JSON +Schemas. ## Regenerating and CI diff --git a/schemas/node.schema.json b/schemas/node.schema.json index 1bccc3f0c..646202c8d 100644 --- a/schemas/node.schema.json +++ b/schemas/node.schema.json @@ -385,17 +385,7 @@ }, "controls": { "additionalProperties": false, - "properties": { - "rate": { - "type": "number" - }, - "running": { - "type": "boolean" - }, - "scrub_offset_seconds": { - "type": "number" - } - }, + "properties": {}, "type": "object" }, "kind": { diff --git a/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json b/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json index 191b07269..a7e2d786e 100644 --- a/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json +++ b/schemas/shapes/lpc_model.nodes.button.button_def.ButtonState.json @@ -3,6 +3,7 @@ "fields": [ { "name": "down", + "role": "state", "semantics": { "direction": "produced" }, @@ -20,6 +21,7 @@ }, { "name": "held", + "role": "state", "semantics": { "direction": "produced" }, @@ -37,6 +39,7 @@ }, { "name": "up", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json b/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json index f49b291ea..816803e4b 100644 --- a/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json +++ b/schemas/shapes/lpc_model.nodes.clock.clock_state.ClockState.json @@ -4,6 +4,7 @@ { "default_bind": "bus:time", "name": "seconds", + "role": "state", "semantics": { "direction": "produced" }, @@ -20,6 +21,7 @@ }, { "name": "delta_seconds", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json b/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json index 540e24cee..6a4ac0502 100644 --- a/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json +++ b/schemas/shapes/lpc_model.nodes.fixture.fixture_state.FixtureState.json @@ -4,6 +4,7 @@ { "default_bind": "bus:control.out", "name": "output", + "role": "state", "semantics": { "direction": "produced" }, @@ -22,6 +23,7 @@ }, { "name": "estimated_draw_ma", + "role": "state", "semantics": { "direction": "produced" }, @@ -38,6 +40,7 @@ }, { "name": "power_scale", + "role": "state", "semantics": { "direction": "produced" }, @@ -54,6 +57,7 @@ }, { "name": "power_budget_ma", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json b/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json index 077772a4b..cdc13310e 100644 --- a/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json +++ b/schemas/shapes/lpc_model.nodes.fluid.fluid_state.FluidState.json @@ -4,6 +4,7 @@ { "default_bind": "bus:visual.out", "name": "output", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.output.output_def.OutputDef.json b/schemas/shapes/lpc_model.nodes.output.output_def.OutputDef.json index b46400da2..e0c8c7242 100644 --- a/schemas/shapes/lpc_model.nodes.output.output_def.OutputDef.json +++ b/schemas/shapes/lpc_model.nodes.output.output_def.OutputDef.json @@ -59,6 +59,20 @@ } } } + }, + { + "name": "test_pattern", + "role": "debug", + "shape": { + "value": { + "shape": { + "editor": "plain", + "id": 1196386242, + "meta": {}, + "ty": "bool" + } + } + } } ], "meta": {} diff --git a/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json b/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json index 100fbaf46..c9043bec9 100644 --- a/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json +++ b/schemas/shapes/lpc_model.nodes.playlist.playlist_state.PlaylistState.json @@ -4,6 +4,7 @@ { "default_bind": "bus:visual.out", "name": "output", + "role": "state", "semantics": { "direction": "produced" }, @@ -22,6 +23,7 @@ }, { "name": "entry_time", + "role": "state", "semantics": { "direction": "produced" }, @@ -38,6 +40,7 @@ }, { "name": "entry_progress", + "role": "state", "semantics": { "direction": "produced" }, @@ -54,6 +57,7 @@ }, { "name": "active_entry", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json b/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json index a49065f52..d3e7aa381 100644 --- a/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json +++ b/schemas/shapes/lpc_model.nodes.radio.control_radio_def.ControlRadioState.json @@ -3,6 +3,7 @@ "fields": [ { "name": "output", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json b/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json index 077772a4b..cdc13310e 100644 --- a/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json +++ b/schemas/shapes/lpc_model.nodes.shader.shader_state.ShaderState.json @@ -4,6 +4,7 @@ { "default_bind": "bus:visual.out", "name": "output", + "role": "state", "semantics": { "direction": "produced" }, diff --git a/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json b/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json index 440bd8c27..d97eb5a5a 100644 --- a/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json +++ b/schemas/shapes/lpc_model.nodes.texture.texture_state.TextureState.json @@ -3,6 +3,7 @@ "fields": [ { "name": "width", + "role": "state", "semantics": { "direction": "produced" }, @@ -19,6 +20,7 @@ }, { "name": "height", + "role": "state", "semantics": { "direction": "produced" }, @@ -35,6 +37,7 @@ }, { "name": "format", + "role": "state", "semantics": { "direction": "produced" },