From 4dc470f8f28328d84274f666a155f4edbc349664 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 13:31:53 -0700 Subject: [PATCH 01/13] feat: Debug edits leave dirty/save accounting; Clear verb at three scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirtySummary drops its transient bucket (D7): a Debug override is never dirty, never gates unload, never tints a header, and never appears in the Save panel — the live-only-never-gates rule is now structural. UiPendingEditPhase::Live and the Save panel's "Live (transient)" sections are deleted (retires S1, S2, S10; defuses W8). Debug values get Clear as their verb: SlotEditOp::Clear (per value), NodeClearDebugOp (per node), ProjectOp::ClearDebugEdits (project-wide, consumed by P3's global chip). All three land on RemoveSlotEdit; for a Debug slot the authored default is the shape default. Failed Debug edits still count as failed (rejected writes need attention regardless of target); a test pins this. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P2) Co-Authored-By: Claude Fable 5 --- .../src/app/node/ui_slot_field_state.rs | 9 +- .../src/app/project/dirty_summary.rs | 93 ++-- lp-app/lpa-studio-core/src/app/project/mod.rs | 4 +- .../src/app/project/node/mod.rs | 2 + .../app/project/node/node_clear_debug_op.rs | 84 +++ .../src/app/project/project_controller.rs | 502 ++++++++++++++++-- .../src/app/project/project_editor_view.rs | 5 +- .../src/app/project/project_op.rs | 37 +- .../src/app/project/slot/slot_edit_join.rs | 33 +- .../src/app/project/slot/slot_edit_op.rs | 32 +- .../src/app/project/ui_affordance.rs | 45 +- .../src/app/project/ui_pending_edit.rs | 9 +- .../src/app/server/studio_server_client.rs | 2 +- .../src/app/studio/studio_actor_tests.rs | 3 + .../src/app/studio/studio_agent_e2e_tests.rs | 2 +- .../src/app/studio/studio_controller.rs | 35 +- .../src/app/studio/studio_edit_e2e_tests.rs | 52 +- .../src/app/studio/unsaved_changes.rs | 36 +- lp-app/lpa-studio-core/src/lib.rs | 24 +- .../src/app/node/config_slot_row.rs | 29 +- .../src/app/node/config_slot_row_stories.rs | 4 +- .../src/app/node/node_detail_popover.rs | 9 - .../lpa-studio-web/src/app/node/node_pane.rs | 32 +- .../src/app/node/node_stories.rs | 39 +- .../src/app/node/node_story_fixtures.rs | 38 +- .../src/app/node/slot_detail_button.rs | 19 +- .../src/app/node/slot_edit_actions.rs | 12 +- .../src/app/project/pending_edit_section.rs | 37 +- .../src/app/project/project_node_tree.rs | 35 +- .../src/app/project/project_pane.rs | 52 +- .../src/app/project/project_pane_stories.rs | 29 +- .../app/project/project_workspace_stories.rs | 14 +- 32 files changed, 909 insertions(+), 449 deletions(-) create mode 100644 lp-app/lpa-studio-core/src/app/project/node/node_clear_debug_op.rs 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..513b2af9b 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,9 +11,12 @@ 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 + /// True when the slot is live-only (a Debug-role field, or any produced + /// field): edits apply /// live to the running project and are **not** written back by save. - /// M2 styles transient (`live`) dirty differently from persisted dirty. + /// M2 styles live dirty differently from persisted dirty; note such a + /// slot is not "dirty" in the `DirtySummary` sense at all (D7) — its + /// verb is Clear. pub live: bool, } @@ -50,7 +53,7 @@ impl UiSlotFieldState { self } - /// Mark whether the field is a live (transient-persistence) control. + /// Mark whether the field is a live (Debug/produced) control. pub fn with_live(mut self, live: bool) -> Self { self.live = live; 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 9eb62ff53..473a7b60b 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/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 2b4f3ed25..1fce87e92 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 @@ -959,7 +959,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 @@ -973,6 +975,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) @@ -1039,7 +1046,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( @@ -1084,8 +1094,6 @@ impl ProjectController { .unwrap_or_default() .to_string(), } - } else if entry.summary.transient > 0 { - UiPendingEditPhase::Live } else { UiPendingEditPhase::Persisted }; @@ -2536,7 +2544,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 + } } } @@ -2586,12 +2603,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. @@ -2770,6 +2787,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`]): @@ -3651,11 +3787,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 @@ -3672,7 +3815,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 )))); } @@ -8549,7 +8692,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 1, - transient: 0, failed: 0, } ); @@ -8966,7 +9108,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 1, - transient: 0, failed: 0, } ); @@ -9016,7 +9157,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -9168,6 +9308,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 @@ -9256,7 +9634,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 2, - transient: 0, failed: 0, } ); @@ -9320,7 +9697,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -9377,7 +9753,6 @@ mod tests { ); let expected = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!( @@ -9543,10 +9918,6 @@ mod tests { persisted: 1, ..DirtySummary::default() }, - crate::UiPendingEditPhase::Live => DirtySummary { - transient: 1, - ..DirtySummary::default() - }, crate::UiPendingEditPhase::Failed { .. } => DirtySummary { failed: 1, ..DirtySummary::default() @@ -9614,7 +9985,6 @@ mod tests { editor.dirty, DirtySummary { persisted: 2, - transient: 0, failed: 1, } ); @@ -9667,8 +10037,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( &[ @@ -9708,9 +10081,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 @@ -9720,11 +10093,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 = config_slot(&nodes, "Rate"); + assert!(rate.state.live); + assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); } /// Overlay entries whose artifact no longer reverse-maps to a synced node @@ -9773,8 +10150,8 @@ mod tests { } #[test] - fn save_overlay_commits_persisted_edits_and_keeps_transient_dirty() { - // Post-commit overlay retains only the transient rate edit (P2). + 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(), @@ -9784,7 +10161,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( &[ @@ -9821,9 +10198,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(); @@ -9845,20 +10222,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"); assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); - assert!(rate.state.live, "transient dirty is distinguishable"); + assert!( + rate.state.live, + "the live override is still distinguishable" + ); assert_eq!( config_slot(&nodes, "Brightness").state.dirty, UiNodeDirtyState::Clean @@ -10010,7 +10386,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); @@ -10033,7 +10408,6 @@ mod tests { let expected = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; assert_eq!(project.dirty_summary(), expected); @@ -10090,7 +10464,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -10140,7 +10513,6 @@ mod tests { project.dirty_summary(), DirtySummary { persisted: 0, - transient: 0, failed: 1, } ); @@ -10516,7 +10888,6 @@ mod tests { ); let one_persisted = DirtySummary { persisted: 1, - transient: 0, failed: 0, }; @@ -10570,7 +10941,6 @@ mod tests { let expected = DirtySummary { persisted: 0, - transient: 0, failed: 1, }; assert_eq!(project.dirty_summary(), expected); @@ -10661,20 +11031,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 @@ -10683,7 +11070,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" ); } @@ -10705,9 +11092,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..d55c87bac 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,8 +33,9 @@ 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, /// 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 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_edit_join.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_edit_join.rs index d46098444..4599b8402 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 @@ -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. @@ -492,7 +501,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 +524,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() @@ -566,7 +585,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 +627,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..d8e144e65 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,8 +27,12 @@ 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-only overrides in the subtree; blue, never written by Save. + /// + /// [`Self::from_dirty`] no longer produces this: since D7 a Debug value + /// is not dirty, so it has no bucket in [`DirtySummary`]. The variant + /// stays as the vocabulary slot the debug treatment occupies (P3 re-homes + /// it onto the debug-override count and its global chip). Live, /// Unsaved persisted edits in the subtree; yellow edit glyph. Unsaved, @@ -62,14 +66,13 @@ 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 } @@ -137,29 +140,31 @@ 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, 2, 1)), - UiAffordance::Error - ); + assert_eq!(UiAffordance::from_dirty(&dirty(1, 1)), UiAffordance::Error); + } + + #[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 +174,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 53ecd4d0a..3294921f7 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 @@ -596,7 +596,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 87ddd93c5..4ae82d28b 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 @@ -23,13 +23,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, UiProgress, 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, UiProgress, UiResult, UiStatus, UiStudioView, UiViewContent, + UxActivityTarget, UxUpdate, UxUpdateSink, }; /// How often the quiet PortHeld retry re-attempts the granted attach @@ -1999,6 +1999,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; @@ -2867,6 +2871,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) + } } } @@ -3122,6 +3133,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..6d5a04aac 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 @@ -78,7 +78,7 @@ fn simulator_session_edit_save_and_revert_end_to_end() { 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.live, "clock rate is a debug (live) 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); @@ -119,12 +119,12 @@ fn simulator_session_edit_save_and_revert_end_to_end() { 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 +140,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 +156,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 +997,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 +1026,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 +1039,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" ); } @@ -2311,6 +2316,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 +2389,11 @@ 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) } /// Find a config slot anywhere in the editor DTO tree by its address path. 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/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 72617f836..d168ca7ac 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -63,18 +63,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/src/app/node/config_slot_row.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs index b89cc048d..c90e40275 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, }; @@ -269,14 +269,24 @@ 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 live/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::Live => ("Clear", "Clear this debug override"), + } +} + +/// The op the row's verb dispatches: a persisted edit is reverted, a +/// live/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::Live => slot_clear_action(address), } } @@ -327,7 +337,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 +357,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?, }) } 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..0300f712a 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 @@ -173,7 +173,7 @@ 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." + description = "Touched debug controls: the live (blue) row tint, the detail icon, and the inline Clear icon on rows with an own edit entry — no text chips." )] pub(crate) fn live_chrome() -> Element { rsx! { @@ -218,7 +218,7 @@ 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)." + 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 { rsx! { 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 d30b44a20..9a8e4c342 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 @@ -336,8 +336,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)]" } @@ -379,12 +377,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] @@ -405,22 +399,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 ); } @@ -428,15 +424,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 dc0e5cee6..f793ea285 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,8 +3,8 @@ 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, + error_node_view, failed_dirty_node_view, nested_dirty_node_view, node_delete_pane_action, + playlist_node_view, playlist_pending_edits, unsaved_dirty_node_view, }; use crate::app::node::{NodeDetailPopover, NodeDirtyTint, NodePane}; @@ -90,38 +90,6 @@ 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)." -)] -pub(crate) fn dirty_live_header_tint() -> Element { - let mut view = live_dirty_node_view(); - view.action = Some(story_focus_action()); - - rsx! { - NodePane { - view, - on_action: move |_| {}, - dirty_tint: NodeDirtyTint::HeaderOnly, - } - } -} - -#[story( - description = "D7 variant (b), live-only: the blue tint re-mixed into the whole pane surface." -)] -pub(crate) fn dirty_live_surface_tint() -> Element { - let mut view = live_dirty_node_view(); - view.action = Some(story_focus_action()); - - rsx! { - NodePane { - view, - on_action: move |_| {}, - dirty_tint: NodeDirtyTint::FullSurface, - } - } -} - #[story( description = "D7 variant (a), failed: the error wash dominates the header and the detail trigger wears the red warning glyph (the live default)." )] @@ -188,8 +156,7 @@ pub(crate) fn error_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 13c012443..5bdd3bee0 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 @@ -127,21 +127,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()]; @@ -149,9 +134,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", @@ -173,15 +159,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", @@ -229,7 +206,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()]; @@ -238,12 +214,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, }; @@ -285,7 +260,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/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..5cb464fc5 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 @@ -17,7 +17,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 +25,16 @@ 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 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/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..75e00eb22 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 @@ -155,7 +155,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 +205,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 +240,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::Live => "Project has debug overrides", UiAffordance::Unsaved => "Project has unsaved changes", UiAffordance::Error => "Project needs attention", } @@ -261,7 +251,7 @@ fn state_label(affordance: UiAffordance) -> &'static str { match affordance { UiAffordance::Info => "unchanged", UiAffordance::Busy => "in progress", - UiAffordance::Live => "live edits only", + UiAffordance::Live => "debug overrides only", UiAffordance::Unsaved => "uncommitted", UiAffordance::Error => "needs attention", } @@ -274,12 +264,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 +291,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 +317,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 a315e7596..213e7f66b 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 @@ -35,7 +35,6 @@ pub(crate) fn uncommitted() -> Element { StoryPane { dirty: DirtySummary { persisted: 2, - transient: 1, failed: 0, }, edits_in_flight: 0, @@ -44,23 +43,6 @@ 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." -)] -pub(crate) fn live_only() -> Element { - rsx! { - StoryPane { - dirty: DirtySummary { - persisted: 0, - transient: 2, - failed: 0, - }, - edits_in_flight: 0, - actions: false, - } - } -} - #[story( description = "An edit awaiting its server ack while persisted edits are pending: Unsaved outranks Busy in the shared priority, so the pencil trigger and yellow wash win; the awaiting-ack count is in the popup." )] @@ -69,7 +51,6 @@ pub(crate) fn in_progress() -> Element { StoryPane { dirty: DirtySummary { persisted: 1, - transient: 0, failed: 0, }, edits_in_flight: 1, @@ -79,7 +60,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! { @@ -87,7 +68,6 @@ pub(crate) fn detail_popup() -> Element { StoryPane { dirty: DirtySummary { persisted: 2, - transient: 1, failed: 0, }, edits_in_flight: 0, @@ -104,7 +84,6 @@ pub(crate) fn detail_popup() -> Element { UiPendingEditKind::Added, UiPendingEditPhase::Persisted, ), - assign_edit("Orbit shader", "controls.rate", "2.0", UiPendingEditPhase::Live), ], } } @@ -112,7 +91,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! { @@ -120,7 +99,6 @@ pub(crate) fn change_list() -> Element { StoryPane { dirty: DirtySummary { persisted: 3, - transient: 1, failed: 1, }, edits_in_flight: 0, @@ -146,7 +124,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]", @@ -196,7 +173,6 @@ pub(crate) fn change_list_overflow() -> Element { StoryPane { dirty: DirtySummary { persisted: 14, - transient: 0, failed: 0, }, edits_in_flight: 0, @@ -254,7 +230,6 @@ pub(crate) fn staged_node_removal() -> Element { StoryPane { dirty: DirtySummary { persisted: 3, - transient: 0, failed: 0, }, edits_in_flight: 0, 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 edfb90237..813308d7c 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 @@ -55,26 +55,24 @@ pub(crate) fn device_project_selection() -> 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( From 67ae3cb466604d11cb40b7ed29d1f2190872608e Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 14:12:44 -0700 Subject: [PATCH 02/13] feat: Debug section on node cards with unmissable hazard treatment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node cards partition config by SlotRole (D3/D4): Setting/Fixed under a 'Settings' header, Debug fields FLATTENED into a dedicated Debug section regardless of declaring record — clock's controls.* render as top-level rows and the old 'Controls' record row disappears. The section comes from role, not record name. Treatment is a semantic UiAffordance::Debug in lpa-studio-core, produced only by from_debug_overrides (never by dirty merging); the web layer maps it to attention-orange + diagonal hazard stripes in one seam (app/affordance.rs + one style.css block), so the visual can change later without hunting call sites (D9). Three tiers (D8): global 'Debug active · N · Clear all' header chip (dispatches ProjectOp::ClearDebugEdits), node-card marker while a node carries an override, and the Debug section striped ALWAYS — even idle — which is the structural fix for clean-transient invisibility. Per-node Clear lands in the section header (first UI entry for NodeClearDebugOp). Also fixes a P2 web test asserting 'Reset' where the code ships 'Clear' (web tests are outside just check). Plan: lp2025/2026-07-31-1736-ephemeral-slots (P3) Co-Authored-By: Claude Fable 5 --- .../src/app/agent/agent_controller.rs | 4 +- .../src/app/node/ui_node_child.rs | 10 + .../src/app/node/ui_node_header.rs | 19 ++ .../src/app/node/ui_node_section.rs | 14 +- .../src/app/node/ui_slot_field_state.rs | 24 +- .../src/app/project/node/node_controller.rs | 63 ++++- .../src/app/project/node/node_face_builder.rs | 6 +- .../src/app/project/project_controller.rs | 38 ++- .../src/app/project/project_editor_view.rs | 20 ++ .../src/app/project/slot/slot_controller.rs | 88 +++++- .../src/app/project/slot/slot_edit_join.rs | 85 ++++++ .../src/app/project/ui_affordance.rs | 41 ++- .../src/app/studio/studio_edit_e2e_tests.rs | 75 ++++- .../src/core/view/view_content.rs | 3 + lp-app/lpa-studio-web/src/app/affordance.rs | 39 +-- .../src/app/layout/studio_pane.rs | 6 + .../src/app/node/config_slot_row.rs | 76 +++--- .../src/app/node/config_slot_row_stories.rs | 24 +- .../src/app/node/face/node_card_drawers.rs | 24 +- .../src/app/node/face_story_fixtures.rs | 2 +- .../src/app/node/h_fader_field_stories.rs | 2 +- .../src/app/node/knob_field_stories.rs | 4 +- .../src/app/node/node_children.rs | 5 +- .../lpa-studio-web/src/app/node/node_pane.rs | 107 +++++++- .../src/app/node/node_stories.rs | 38 ++- .../src/app/node/node_story_fixtures.rs | 69 +++++ .../src/app/node/panel/panel_control.rs | 11 +- .../src/app/node/slot_edit_actions.rs | 18 +- .../src/app/node/toggle_field_stories.rs | 2 +- .../src/app/project/project_pane.rs | 60 +++- .../src/app/project/project_pane_stories.rs | 69 +++++ .../lpa-studio-web/src/base/detail_popover.rs | 5 + .../src/base/detail_popover_stories.rs | 1 + lp-app/lpa-studio-web/src/base/icon_menu.rs | 14 + .../src/exploration/rich_object_stories.rs | 5 +- lp-app/lpa-studio-web/src/style.css | 257 ++++++++++++++++++ 36 files changed, 1182 insertions(+), 146 deletions(-) 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 da209346a..b578d3511 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, } impl UiNodeHeader { @@ -36,6 +41,7 @@ impl UiNodeHeader { summary: None, detail: None, dirty: DirtySummary::clean(), + debug_overrides: 0, } } @@ -51,6 +57,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 + } + /// Set the file or asset source label. pub fn with_source(mut self, source: impl Into) -> Self { self.source = Some(source.into()); @@ -74,4 +86,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 513b2af9b..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,13 +11,13 @@ pub struct UiSlotFieldState { pub dirty: UiNodeDirtyState, /// Validation error shown near the field when present. pub invalid: Option, - /// True when the slot is live-only (a Debug-role field, or any produced - /// field): edits apply - /// live to the running project and are **not** written back by save. - /// M2 styles live dirty differently from persisted dirty; note such a - /// slot is not "dirty" in the `DirtySummary` sense at all (D7) — its - /// verb is Clear. - 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 { @@ -27,7 +27,7 @@ impl UiSlotFieldState { editable: true, dirty: UiNodeDirtyState::Clean, invalid: None, - live: false, + debug: false, } } @@ -37,7 +37,7 @@ impl UiSlotFieldState { editable: false, dirty: UiNodeDirtyState::Clean, invalid: None, - live: false, + debug: false, } } @@ -53,9 +53,9 @@ impl UiSlotFieldState { self } - /// Mark whether the field is a live (Debug/produced) 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/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs index 50f6a07f7..2f84785ad 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,13 +229,21 @@ 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(), self.address.to_string(), ) .with_status(self.ui_status()) - .with_dirty(dirty); + .with_dirty(dirty) + .with_debug_overrides(debug_overrides); // Status detail (error/warning/failure text) rides the header so the // node detail popup can answer "why" — the compact status alone read // as an unexplained Error state (gate follow-up, 2026-07-15). @@ -516,6 +524,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 { @@ -523,7 +532,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, + ); } } } @@ -566,6 +580,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 } @@ -579,15 +601,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 } @@ -639,6 +672,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)); @@ -671,6 +712,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()) } @@ -704,7 +757,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/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 1fce87e92..89a801708 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 @@ -1586,6 +1586,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(add_node_menu(&UiAttachTarget::ProjectRoot)) @@ -7060,6 +7061,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); @@ -8561,6 +8572,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"); @@ -8685,7 +8705,7 @@ 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!( @@ -10099,8 +10119,8 @@ mod tests { // 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 = config_slot(&nodes, "Rate"); - assert!(rate.state.live); + let rate = debug_slot(&nodes, "Rate"); + assert!(rate.state.debug); assert_eq!(rate.state.dirty, UiNodeDirtyState::Dirty); } @@ -10229,10 +10249,10 @@ mod tests { "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, + rate.state.debug, "the live override is still distinguishable" ); assert_eq!( @@ -10288,7 +10308,7 @@ mod tests { UiNodeDirtyState::Clean ); assert_eq!( - config_slot(&nodes, "Rate").state.dirty, + debug_slot(&nodes, "Rate").state.dirty, UiNodeDirtyState::Clean ); } @@ -10756,9 +10776,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, }) } 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 d55c87bac..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 @@ -37,6 +37,12 @@ pub struct ProjectEditorView { /// 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. @@ -80,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, @@ -118,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; @@ -157,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/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 4599b8402..c8cb70f4f 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 @@ -356,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. @@ -546,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(); @@ -554,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()); } 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 d8e144e65..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,13 +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 overrides in the subtree; blue, never written by Save. + /// **Debug** overrides are active on the surface (D8/D9): transient + /// diagnostics/authoring values with no durable value underneath. /// - /// [`Self::from_dirty`] no longer produces this: since D7 a Debug value - /// is not dirty, so it has no bucket in [`DirtySummary`]. The variant - /// stays as the vocabulary slot the debug treatment occupies (P3 re-homes - /// it onto the debug-override count and its global chip). - Live, + /// [`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 @@ -78,6 +82,15 @@ impl UiAffordance { } } + /// 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) @@ -98,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); } @@ -146,6 +159,18 @@ mod tests { 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::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 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 6d5a04aac..f3148460a 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 debug (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,11 +132,11 @@ 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), @@ -1714,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)); @@ -2093,7 +2114,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, }) } @@ -2396,6 +2419,40 @@ pub(crate) fn editor_dirty(view: &UiStudioView) -> (usize, usize) { (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. pub(crate) fn find_slot<'a>(view: &'a UiStudioView, path: &str) -> &'a UiConfigSlot { try_find_slot(view, path).unwrap_or_else(|| panic!("config slot with path {path} should exist")) @@ -2428,9 +2485,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/core/view/view_content.rs b/lp-app/lpa-studio-core/src/core/view/view_content.rs index 7e0993960..6cd8affa8 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 @@ -144,6 +144,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-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 c90e40275..b364bee21 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 @@ -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] @@ -71,7 +72,7 @@ pub fn ConfigSlotRow( let primary = primary_affordance(&aspects); let chrome = slot_edit_chrome(&slot.state); let row_class = match chrome { - Some(SlotEditChrome::Live) if slot.state.invalid.is_none() => live_row_class(), + Some(SlotEditChrome::Debug) if slot.state.invalid.is_none() => debug_row_class(), _ => slot_row_class(primary, index), }; let indent = depth * 14; @@ -270,38 +271,36 @@ pub fn ConfigSlotRow( } /// The one verb vocabulary (M3 UX gate, D7): "Revert" for unsaved -/// (persisted) edits, **"Clear"** for live/debug overrides — never -/// "Reset" — shared by the inline row icon and the detail-popup footer so -/// the two access points can never diverge. +/// (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 => ("Clear", "Clear this debug override"), + SlotEditChrome::Debug => ("Clear", "Clear this debug override"), } } -/// The op the row's verb dispatches: a persisted edit is reverted, a -/// live/debug override is **cleared** (same `RemoveSlotEdit` mechanism, the -/// vocabulary debug values use). +/// 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::Live => slot_clear_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", } } @@ -477,18 +476,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" } fn record_summary_class(expanded: bool) -> &'static str { @@ -520,16 +521,19 @@ 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" + ); + assert_eq!(debug_row_class(), "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 0300f712a..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 debug controls: the live (blue) row tint, the detail icon, and the inline Clear 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 debug control: the edited section hosts the Clear button; no saved value is known for a debug override, 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..3bdbbb001 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::{ @@ -52,9 +55,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 +80,15 @@ pub fn NodeCardDrawers( AssetEditor { editor, on_action, platform } } } + for section in debug_sections { + NodeSection { + section, + node: section_node.clone(), + on_action, + pending_edits: pending_edits.clone(), + dirty_tint, + } + } if has_advanced { NodeCardSection { label: "advanced", @@ -83,10 +100,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_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_pane.rs b/lp-app/lpa-studio-web/src/app/node/node_pane.rs index 9a8e4c342..f9cf7432c 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,10 +1,12 @@ use dioxus::prelude::*; use lpa_studio_core::{ - DirtySummary, UiAction, UiNodeSection, UiNodeTabBody, UiNodeView, UiPendingEdit, UiSlotRecord, + DirtySummary, 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::slot_edit_actions::node_clear_debug_action; use crate::app::node::{ NodeChildren, NodeDetailPopover, NodeFaceBody, ProducedProducts, ProducedValues, SlotRecordEditor, @@ -48,6 +50,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. @@ -75,6 +80,8 @@ 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. + let section_node = Some(view.header.path.clone()); let face_card_ui = view.card_ui.clone(); let add_node_menu = view.add_node_menu.clone(); @@ -105,6 +112,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}" @@ -155,6 +163,7 @@ pub fn NodePane( section, first: index == 0, focus_action: focus_action.clone(), + node: section_node.clone(), on_action, pending_edits: pending_edits.clone(), dirty_tint, @@ -250,6 +259,10 @@ 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. + #[props(default = None)] + node: Option, /// The editor-level pending-edit list, threaded through extracted child /// sections into their nested panes' detail popovers. #[props(default)] @@ -269,12 +282,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, on_action } + }, UiNodeSection::AssetSlots(assets) => rsx! { section { class: section_class("tw:bg-card tw:p-0", first), SlotRecordEditor { @@ -296,6 +315,92 @@ pub fn NodeSection( } } +/// The node card's **Debug** section (D3/D4/D8 tier c): the node's +/// `SlotRole::Debug` rows, flattened by core, under a hazard-striped surface +/// that is worn **always** — an idle debug control announces "transient" +/// before it is touched, which is the whole point (clean-transient +/// invisibility). An active override deepens the stripes and reveals the +/// per-node **Clear** (`NodeClearDebugOp`); nothing here ever says "Revert" +/// or "Reset" (D7). +/// +/// 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; `None` (or an unparsable story path) + /// renders the section without its Clear action. + #[props(default = None)] + node: Option, + #[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 class = if active > 0 { + "lp-debug-section lp-debug-section--active" + } else { + "lp-debug-section" + }; + + rsx! { + section { class, + div { class: "lp-debug-section-header", + 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" + } + } + } + SlotRecordEditor { + record: UiSlotRecord::new(slots), + on_action, + } + } + } +} + +/// 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 { 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 f793ea285..74b09b42e 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,8 +3,8 @@ 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, nested_dirty_node_view, node_delete_pane_action, - playlist_node_view, playlist_pending_edits, unsaved_dirty_node_view, + clock_node_view, error_node_view, failed_dirty_node_view, nested_dirty_node_view, + node_delete_pane_action, playlist_node_view, playlist_pending_edits, unsaved_dirty_node_view, }; use crate::app::node::{NodeDetailPopover, NodeDirtyTint, NodePane}; @@ -134,6 +134,40 @@ pub(crate) fn nested_dirty_children() -> Element { } } +#[story( + description = "D8 tier (c), idle: a Clock card whose three `controls.*` fields are Debug-role, so core lifts them FLAT into the Debug section (Running / Rate / Scrub offset seconds — no nested `Controls` group). Nothing is overridden yet, and the section STILL wears the hazard treatment: you know a control is session-only before you touch it. The persisted rows sit above under `Settings`; no card marking, no Clear." +)] +pub(crate) fn debug_section_idle() -> Element { + rsx! { + NodePane { view: clock_node_view(0), on_action: move |_| {} } + } +} + +#[story( + description = "D8 tiers (b)+(c), active: two Debug overrides are live on the Clock. The section deepens its stripes and reveals the per-node Clear (`NodeClearDebugOp`); the touched rows wear the hazard row tint with the inline Clear verb; the card header carries the `debug 2` marking. 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), on_action: move |_| {} } + } +} + +#[story( + description = "The hazard family beside its neighbours, for the G1 distinctness question: the idle and active Debug sections next to an amber-unsaved card. Debug = attention-orange + diagonal stripes; flat orange stays device health; amber stays unsaved." +)] +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), on_action: move |_| {} } + NodePane { view: clock_node_view(2), on_action: move |_| {} } + NodePane { view: unsaved, on_action: move |_| {} } + } + } +} + #[story( description = "The node detail popup on an erroring node: the status pill plus the runtime's error text — the popup answers WHY a node is in the error state (the compact status alone doesn't)." )] 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 5bdd3bee0..78e7eed83 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 @@ -120,6 +120,75 @@ 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 is still debug territory — D8 tier c), and a +/// non-zero count also lights the card's header marking (tier b). +pub(crate) fn clock_node_view(overrides: usize) -> 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) + }; + UiConfigSlot::value(key, label, value) + .with_address(clock_slot_address(&format!("controls.{key}"))) + .with_state(state) + }; + + 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.action = Some(UiAction::from_op( + ControllerId::new("story.project"), + SlotEditOp::Revert { + address: clock_slot_address("epoch_offset_seconds"), + }, + )); + view +} + +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. 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_edit_actions.rs b/lp-app/lpa-studio-web/src/app/node/slot_edit_actions.rs index 5cb464fc5..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. @@ -35,6 +36,21 @@ pub(crate) fn slot_clear_action(address: ProjectSlotAddress) -> UiAction { ) } +/// 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/project_pane.rs b/lp-app/lpa-studio-web/src/app/project/project_pane.rs index 75e00eb22..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). @@ -240,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 debug overrides", + UiAffordance::Debug => "Project has debug overrides", UiAffordance::Unsaved => "Project has unsaved changes", UiAffordance::Error => "Project needs attention", } @@ -251,7 +297,7 @@ fn state_label(affordance: UiAffordance) -> &'static str { match affordance { UiAffordance::Info => "unchanged", UiAffordance::Busy => "in progress", - UiAffordance::Live => "debug overrides only", + UiAffordance::Debug => "debug overrides only", UiAffordance::Unsaved => "uncommitted", UiAffordance::Error => "needs attention", } 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 213e7f66b..e441246fd 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 @@ -43,6 +43,73 @@ pub(crate) fn uncommitted() -> Element { } } +#[story( + 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 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: 2, + failed: 0, + }, + edits_in_flight: 0, + 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, + ), + ], + } + } + } +} + #[story( description = "An edit awaiting its server ack while persisted edits are pending: Unsaved outranks Busy in the shared priority, so the pencil trigger and yellow wash win; the awaiting-ack count is in the popup." )] @@ -259,10 +326,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/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 838aab7a5..649d819d3 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.1) 0 5px, + transparent 5px 11px + ); + --studio-status-debug-stripes-strong: repeating-linear-gradient( + 135deg, + rgba(240, 185, 122, 0.2) 0 5px, + transparent 5px 11px + ); --studio-status-error-bg: #301b1d; --studio-status-error-border: #874b4b; --studio-status-error-text: #ffc7c7; @@ -2389,6 +2410,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); @@ -3629,3 +3658,231 @@ label:has(> input:not(:disabled)) { color: var(--studio-color-text-strong); border-color: var(--studio-status-bound-border); } + +/* --------------------------------------------------------------------------- + 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 60%); +} + +/* The section's own header strip: label on the left, Clear on the right. */ +.lp-debug-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 10px 5px 12px; + border-bottom: 1px solid color-mix(in oklab, var(--studio-status-debug-border) 55%, transparent); +} + +.lp-debug-section-label { + display: inline-flex; + align-items: baseline; + gap: 7px; + 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 { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: none; + color: var(--studio-color-text-dim); +} + +/* Settings section header — the quiet counterpart, so the two sections read + as a pair (D4/D6: "Settings" above, "Debug" below). */ +.lp-settings-section-header { + display: flex; + align-items: center; + gap: 8px; + 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-strong), + var(--studio-status-debug-bg); + border: 1px solid var(--studio-status-debug-border); + border-radius: var(--studio-radius-pill); + padding: 3px 9px; +} + +.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: the same hazard language at row scale, in place + of the amber-unsaved tint a persisted edit wears. */ +.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: + var(--studio-status-debug-stripes-strong), + linear-gradient(270deg, var(--studio-status-debug-bg) 0%, var(--studio-status-debug-bg) 34%, transparent 100%); +} + +/* 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-strong), + 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); +} From e1219d2f33c358ab9285e9121f4f2f6308fb7ce3 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 14:25:19 -0700 Subject: [PATCH 03/13] =?UTF-8?q?chore:=20regenerate=20tailwind.css=20?= =?UTF-8?q?=E2=80=94=20live-tint=20utilities=20no=20longer=20referenced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2/P3 removed the blue live chrome from components; the regenerated bundle drops the corresponding arbitrary-value utilities (removals only). Plan: lp2025/2026-07-31-1736-ephemeral-slots (P3) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/assets/tailwind.css | 24 ----------------------- 1 file changed, 24 deletions(-) diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 5c816465d..3596c3897 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -1301,9 +1301,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%); } @@ -1851,9 +1848,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); } @@ -1863,12 +1857,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)) { @@ -1884,12 +1872,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)) { @@ -1905,12 +1887,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)) { From e609b931c1df4b45ffdbade70a456516a62d39d1 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 15:58:42 -0700 Subject: [PATCH 04/13] style: soften the debug hazard treatment ~3-5x (G1 feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows no longer re-stripe on top of the striped section — the stacking is what made v1 read many times too strong; rows keep only the soft orange wash. Stripe alpha drops 0.1/0.2 -> 0.03/0.07 with a wider pitch (4px/14px), and chips/icon chrome move to the faint variant. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P3, G1 iteration) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/src/style.css | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 649d819d3..fbe1649b2 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -139,13 +139,13 @@ --studio-status-debug-text: #f0b97a; --studio-status-debug-stripes: repeating-linear-gradient( 135deg, - rgba(240, 185, 122, 0.1) 0 5px, - transparent 5px 11px + 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.2) 0 5px, - transparent 5px 11px + rgba(240, 185, 122, 0.07) 0 4px, + transparent 4px 14px ); --studio-status-error-bg: #301b1d; --studio-status-error-border: #874b4b; @@ -3693,7 +3693,7 @@ label:has(> input:not(:disabled)) { .lp-debug-section--active { background: var(--studio-status-debug-stripes-strong), - linear-gradient(90deg, var(--studio-status-debug-bg), transparent 60%); + linear-gradient(90deg, var(--studio-status-debug-bg), transparent 72%); } /* The section's own header strip: label on the left, Clear on the right. */ @@ -3763,7 +3763,7 @@ label:has(> input:not(:disabled)) { line-height: 1; color: var(--studio-status-debug-text); background: - var(--studio-status-debug-stripes-strong), + var(--studio-status-debug-stripes), var(--studio-status-debug-bg); border: 1px solid var(--studio-status-debug-border); border-radius: var(--studio-radius-pill); @@ -3807,8 +3807,9 @@ label:has(> input:not(:disabled)) { linear-gradient(90deg, var(--studio-status-debug-bg), transparent 62%); } -/* A touched Debug slot row: the same hazard language at row scale, in place - of the amber-unsaved tint a persisted edit wears. */ +/* 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). */ .lp-debug-row { display: grid; min-width: 0; @@ -3816,9 +3817,7 @@ label:has(> input:not(:disabled)) { align-items: center; gap: 8px; padding: 6px 8px; - background: - var(--studio-status-debug-stripes-strong), - linear-gradient(270deg, var(--studio-status-debug-bg) 0%, var(--studio-status-debug-bg) 34%, transparent 100%); + background: linear-gradient(270deg, var(--studio-status-debug-bg) 0%, var(--studio-status-debug-bg) 34%, transparent 100%); } /* The row's inline Clear icon button. */ @@ -3872,7 +3871,7 @@ label:has(> input:not(:disabled)) { .lp-debug-icon-chrome { color: var(--studio-status-debug-text); background: - var(--studio-status-debug-stripes-strong), + var(--studio-status-debug-stripes), var(--studio-status-debug-bg); border-color: var(--studio-status-debug-border); } From 99e07da21bbb02240c66535202b58cf30687391b Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 16:56:16 -0700 Subject: [PATCH 05/13] feat: Debug section collapses by default; rows never reflow on edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G1 feedback round 2. The Debug section becomes a disclosure using the established card-UI idiom (NodeCardUiState.debug_open, default false, via NodeUiOp::SetDrawer): the striped DEBUG header stays always visible with the active count and a working Clear, rows render only when expanded — most sessions never need them open. Row geometry is pinned so touching a control moves nothing: debug rows carry a fixed 44px floor and reserve the trailing verb footprint when untouched, and the section header holds 28px in every state (count and Clear appearing measure 0px). Verified via live getBoundingClientRect. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P3, G1 iteration 2) Co-Authored-By: Claude Fable 5 --- .../src/app/project/node_card_ui_state.rs | 27 +++- .../src/app/node/config_slot_row.rs | 38 +++++- .../src/app/node/face/node_card_drawers.rs | 4 + .../src/app/node/face/node_face_body.rs | 3 + .../lpa-studio-web/src/app/node/node_pane.rs | 128 ++++++++++++++---- .../src/app/node/node_stories.rs | 33 ++++- .../src/app/node/node_story_fixtures.rs | 39 ++++-- lp-app/lpa-studio-web/src/style.css | 109 ++++++++++++++- 8 files changed, 325 insertions(+), 56 deletions(-) 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-web/src/app/node/config_slot_row.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs index b364bee21..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 @@ -71,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::Debug) if slot.state.invalid.is_none() => debug_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; @@ -186,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 @@ -489,7 +504,7 @@ fn slot_edit_chrome(state: &UiSlotFieldState) -> Option { /// 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 lp-debug-row-floor" } fn record_summary_class(expanded: bool) -> &'static str { @@ -527,7 +542,22 @@ mod tests { chrome_revert_button_class(SlotEditChrome::Debug), "lp-debug-row-button" ); - assert_eq!(debug_row_class(), "lp-debug-row"); + } + + #[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] 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 3bdbbb001..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 @@ -47,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)] @@ -84,6 +87,7 @@ pub fn NodeCardDrawers( NodeSection { section, node: section_node.clone(), + debug_open, 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/node_pane.rs b/lp-app/lpa-studio-web/src/app/node/node_pane.rs index f9cf7432c..686c72936 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,17 +1,18 @@ use dioxus::prelude::*; use lpa_studio_core::{ - DirtySummary, UiAction, UiConfigSlot, UiNodeDirtyState, 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. @@ -80,8 +81,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. + // 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(); @@ -164,6 +167,7 @@ pub fn NodePane( first: index == 0, focus_action: focus_action.clone(), node: section_node.clone(), + debug_open, on_action, pending_edits: pending_edits.clone(), dirty_tint, @@ -260,9 +264,14 @@ pub fn NodeSection( #[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. + /// 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)] @@ -292,7 +301,7 @@ pub fn NodeSection( } }, UiNodeSection::DebugSlots(slots) => rsx! { - DebugSlotsSection { slots, node, on_action } + DebugSlotsSection { slots, node, open: debug_open, on_action } }, UiNodeSection::AssetSlots(assets) => rsx! { section { class: section_class("tw:bg-card tw:p-0", first), @@ -316,12 +325,28 @@ pub fn NodeSection( } /// The node card's **Debug** section (D3/D4/D8 tier c): the node's -/// `SlotRole::Debug` rows, flattened by core, under a hazard-striped surface -/// that is worn **always** — an idle debug control announces "transient" -/// before it is touched, which is the whole point (clean-transient -/// invisibility). An active override deepens the stripes and reveals the -/// per-node **Clear** (`NodeClearDebugOp`); nothing here ever says "Revert" -/// or "Reset" (D7). +/// `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. @@ -329,10 +354,15 @@ pub fn NodeSection( #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] fn DebugSlotsSection( slots: Vec, - /// The owning node's address path; `None` (or an unparsable story path) - /// renders the section without its Clear action. + /// 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 @@ -343,19 +373,57 @@ fn DebugSlotsSection( .as_deref() .filter(|_| active > 0) .and_then(node_clear_debug_action); - let class = if active > 0 { - "lp-debug-section lp-debug-section--active" + 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 { - "lp-debug-section" + "Expand debug controls" }; rsx! { section { class, div { class: "lp-debug-section-header", - 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(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 { @@ -373,14 +441,26 @@ fn DebugSlotsSection( } } } - SlotRecordEditor { - record: UiSlotRecord::new(slots), - on_action, + 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 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 74b09b42e..de82a3b5b 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 @@ -135,25 +135,43 @@ pub(crate) fn nested_dirty_children() -> Element { } #[story( - description = "D8 tier (c), idle: a Clock card whose three `controls.*` fields are Debug-role, so core lifts them FLAT into the Debug section (Running / Rate / Scrub offset seconds — no nested `Controls` group). Nothing is overridden yet, and the section STILL wears the hazard treatment: you know a control is session-only before you touch it. The persisted rows sit above under `Settings`; no card marking, no Clear." + 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 debug_section_idle() -> Element { rsx! { - NodePane { view: clock_node_view(0), on_action: move |_| {} } + NodePane { view: clock_node_view(0, false), on_action: move |_| {} } } } #[story( - description = "D8 tiers (b)+(c), active: two Debug overrides are live on the Clock. The section deepens its stripes and reveals the per-node Clear (`NodeClearDebugOp`); the touched rows wear the hazard row tint with the inline Clear verb; the card header carries the `debug 2` marking. The header wash stays neutral on purpose — a debug override is NOT unsaved work (D7), so it never borrows the amber dirty treatment." + 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: 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), on_action: move |_| {} } + 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: the idle and active Debug sections next to an amber-unsaved card. Debug = attention-orange + diagonal stripes; flat orange stays device health; amber stays unsaved." + 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(); @@ -161,8 +179,9 @@ pub(crate) fn debug_section_vs_unsaved() -> Element { rsx! { div { class: "tw:grid tw:gap-4", - NodePane { view: clock_node_view(0), on_action: move |_| {} } - NodePane { view: clock_node_view(2), on_action: move |_| {} } + 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 |_| {} } } } 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 78e7eed83..cbb958db6 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) { @@ -127,9 +127,13 @@ pub(crate) fn node_delete_pane_action() -> UiPaneAction { /// 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 is still debug territory — D8 tier c), and a +/// 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). -pub(crate) fn clock_node_view(overrides: usize) -> UiNodeView { +/// +/// `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() @@ -138,9 +142,16 @@ pub(crate) fn clock_node_view(overrides: usize) -> UiNodeView { } else { UiSlotFieldState::editable().with_debug(true) }; - UiConfigSlot::value(key, label, value) + let mut row = UiConfigSlot::value(key, label, value) .with_address(clock_slot_address(&format!("controls.{key}"))) - .with_state(state) + .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( @@ -170,6 +181,10 @@ pub(crate) fn clock_node_view(overrides: usize) -> UiNodeView { ])], ) .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 { diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index fbe1649b2..b836eb63e 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -3696,20 +3696,81 @@ label:has(> input:not(:disabled)) { linear-gradient(90deg, var(--studio-status-debug-bg), transparent 72%); } -/* The section's own header strip: label on the left, Clear on the right. */ +/* 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; - padding: 5px 10px 5px 12px; + 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); } -.lp-debug-section-label { +/* 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: baseline; + 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. */ +.lp-debug-section-chevron { + display: inline-flex; + flex: none; + opacity: 0.75; + transition: transform 150ms ease, opacity 150ms ease; +} + +.lp-debug-section-toggle:hover .lp-debug-section-chevron { + opacity: 1; +} + +/* Hover previews the other state, exactly as the collapsed drawer row and + the expanded rail do (`node_card_section.rs`). */ +.lp-debug-section-toggle:hover .lp-debug-section-chevron:not(.lp-debug-section-chevron--open) { + transform: rotate(90deg); +} + +.lp-debug-section-toggle:hover .lp-debug-section-chevron--open { + transform: rotate(-90deg); +} + +@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; @@ -3720,19 +3781,28 @@ label:has(> input:not(:disabled)) { } .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). */ + 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); } @@ -3770,6 +3840,12 @@ label:has(> input:not(:disabled)) { 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; @@ -3809,7 +3885,11 @@ label:has(> input:not(:disabled)) { /* 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). */ + 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; @@ -3820,6 +3900,23 @@ label:has(> input:not(:disabled)) { 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; From 2d72512bb0d1d81ffff69870e4780718d2c60304 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 16:58:39 -0700 Subject: [PATCH 06/13] style: debug chevron shows actual state only, no hover rotation (G1 feedback) Plan: lp2025/2026-07-31-1736-ephemeral-slots (P3, G1 iteration 3) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/src/style.css | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index b836eb63e..16da82e96 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -3741,27 +3741,20 @@ label:has(> input:not(:disabled)) { /* 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: transform 150ms ease, opacity 150ms ease; + transition: opacity 150ms ease; } .lp-debug-section-toggle:hover .lp-debug-section-chevron { opacity: 1; } -/* Hover previews the other state, exactly as the collapsed drawer row and - the expanded rail do (`node_card_section.rs`). */ -.lp-debug-section-toggle:hover .lp-debug-section-chevron:not(.lp-debug-section-chevron--open) { - transform: rotate(90deg); -} - -.lp-debug-section-toggle:hover .lp-debug-section-chevron--open { - transform: rotate(-90deg); -} - @media (prefers-reduced-motion: reduce) { .lp-debug-section-chevron { transition: none; From 18ac35b40fdb8d85c3594d2806a8c56b311c53f2 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 17:47:18 -0700 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20Debug=20values=20are=20session-on?= =?UTF-8?q?ly=20=E2=80=94=20nothing=20authored=20in=20files=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dynamic slot reader warns and skips an authored Debug-role field (single seam, covers host tooling, studio, and firmware parses alike), so an on-disk value can never become base — reset targets the shape default structurally (kills W3). Schema-gen omits Debug fields from authoring schemas while shape dumps keep roles (kills W2); the 17 example clock.json files drop their all-defaults controls stanzas. The lpa-server fs-refresh test now probes with a Setting-role binding, since an external controls.rate rewrite is correctly a no-op. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P4) Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + examples/basic/clock.json | 7 +-- examples/basic2/clock.json | 7 +-- examples/button-playlist/clock.json | 7 +-- examples/button-sign/clock.json | 7 +-- examples/events/clock.json | 7 +-- examples/fast/clock.json | 7 +-- examples/fiber-headband/clock.json | 7 +-- examples/fluid/clock.json | 7 +-- examples/fyeah-button/clock.json | 7 +-- examples/fyeah-sign/clock.json | 7 +-- examples/perf/baseline/clock.json | 7 +-- examples/perf/fastmath/clock.json | 7 +-- examples/rocaille/clock.json | 7 +-- lp-app/lpa-server/tests/project_fs_refresh.rs | 33 +++++++++++- lp-cli/src/commands/schema/generate.rs | 25 +++++++++ lp-core/lpc-model/Cargo.toml | 1 + lp-core/lpc-model/src/schema_gen/mod.rs | 7 +++ .../src/schema_gen/slot_shape_schema.rs | 51 ++++++++++++++++-- .../src/slot_codec/dynamic_slot_reader.rs | 52 +++++++++++++++++++ .../src/registry/base_value_display.rs | 50 +++++++++++++++--- lp-fw/fw-browser/www/smoke-project/clock.json | 7 +-- projects/test/fyeah-sign/clock.json | 7 +-- projects/test/quad-strips-1fix/clock.json | 7 +-- projects/test/quad-strips/clock.json | 7 +-- schemas/README.md | 8 ++- schemas/node.schema.json | 12 +---- 27 files changed, 233 insertions(+), 126 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 805d1bb44..add221484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5828,6 +5828,7 @@ dependencies = [ "hashbrown 0.15.5", "jsonschema", "libm", + "log", "lp-collection 1.0.0", "lpc-slot-codegen", "lpc-slot-macros", 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-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-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/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_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-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-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-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/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/schemas/README.md b/schemas/README.md index b8936bad9..871923656 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -26,8 +26,12 @@ 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 +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": { From e684597adadc981c4a6bd7088b659da1345eeee7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 18:20:34 -0700 Subject: [PATCH 08/13] =?UTF-8?q?feat:=20OutputDef.test=5Fpattern=20?= =?UTF-8?q?=E2=80=94=20the=20Debug-slot=20proof=20case=20(harvested=20bypa?= =?UTF-8?q?ss)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first Debug bool and first hardware-mode Debug slot. The engine bypass is harvested from 25c3581f3 recast onto the effective def: while test_pattern is true the graph resolve is skipped entirely and the last-established extent repaints solid white through the shared publish seam; the frame it flips false the graph renders again — no black frame. All TTL/press_id/renewal machinery is dropped (a Debug slot has a durable home); the node accepts no runtime commands and the wire is untouched (proto stays 4). The FakeResolver harness counts input resolves and render calls separately, so 'the graph was skipped' is proved, not inferred; a studio e2e pins that the output card's Debug section derives entirely from role + partition with zero output-specific UI code. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P5) Co-Authored-By: Claude Fable 5 --- .../src/app/studio/studio_edit_e2e_tests.rs | 56 ++ .../src/app/node/node_stories.rs | 21 +- .../src/app/node/node_story_fixtures.rs | 83 +++ .../src/nodes/output/output_node.rs | 523 +++++++++++++++++- .../lpc-model/src/nodes/output/output_def.rs | 42 +- lp-core/lpc-shared/src/project/builder.rs | 1 + ...del.nodes.output.output_def.OutputDef.json | 14 + 7 files changed, 714 insertions(+), 26 deletions(-) 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 f3148460a..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 @@ -1996,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 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 de82a3b5b..6977b9ea8 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 @@ -4,7 +4,8 @@ use lpa_studio_web_story_macros::story; use crate::app::node::node_story_fixtures::{ clock_node_view, error_node_view, failed_dirty_node_view, nested_dirty_node_view, - node_delete_pane_action, playlist_node_view, playlist_pending_edits, unsaved_dirty_node_view, + node_delete_pane_action, output_node_view, playlist_node_view, playlist_pending_edits, + unsaved_dirty_node_view, }; use crate::app::node::{NodeDetailPopover, NodeDirtyTint, NodePane}; @@ -187,6 +188,24 @@ pub(crate) fn debug_section_vs_unsaved() -> Element { } } +#[story( + 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 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: output_node_view(false, false), on_action: move |_| {} } + } +} + #[story( description = "The node detail popup on an erroring node: the status pill plus the runtime's error text — the popup answers WHY a node is in the error state (the compact status alone doesn't)." )] 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 cbb958db6..908b97086 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 @@ -194,6 +194,89 @@ pub(crate) fn clock_node_view(overrides: usize, debug_open: bool) -> UiNodeView 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 { 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..02064ffd6 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,17 @@ use crate::resource::{ RuntimeChannelSampleFormat, }; +/// The color the `test_pattern` Debug slot paints. +/// +/// Full white on every channel: the slot exists to answer "is this pin wired to +/// that strip?", so it must be unmistakable and independent of the graph. +const TEST_PATTERN_RGB: [u8; 3] = [255, 255, 255]; + /// 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 +35,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 +139,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 +181,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 +196,405 @@ 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. + const WHITE16: u16 = 255 * 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![WHITE16, WHITE16, WHITE16], + "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![WHITE16, WHITE16, WHITE16] + ); + } + + 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![WHITE16, WHITE16, WHITE16] + ); + + 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![WHITE16, WHITE16, WHITE16] + ); + + 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![WHITE16; 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-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-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/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": {} From 8ac837aea11e03f71d428049d188dbdb858c813b Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 18:49:36 -0700 Subject: [PATCH 09/13] docs: ADR ratifying the slot taxonomy; S4/S5/W9 paydown; debt register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-08-01-debug-slots-taxonomy records the Settings/Panel/Debug/ State language table, the Panel-vs-Debug boundary (exposure vs transient-by-nature), and the D1 routing rule (events → command channel; ephemeral state → Debug slots; produced state → direction-implied), reconciling — without amending — the 2026-07-27 command-channel ADR and marking its sim-button follow-up obsolete. Glossary gains a Debug entry; the pane-grammar ADR gains the debug colour family. Paydown: stale overlay entries classify by role (S4); both sides share one persistence classifier incl. the unresolvable→Setting rule (S5); ProjectRegistry::mutate now validates like the batch path, with the one legitimate Fixed-map writer isolated as crate-private stage_dedicated_op (W9). S6/S7/S8/S9 logged in docs/debt/. Stale SlotPolicy-era prose swept from crate READMEs and module docs. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P6) Co-Authored-By: Claude Fable 5 --- docs/adr/2026-07-05-studio-pane-grammar.md | 27 ++ docs/adr/2026-08-01-debug-slots-taxonomy.md | 280 +++++++++++++++ docs/adr/README.md | 7 +- docs/debt/README.md | 4 + .../clock-transport-has-no-transport-ui.md | 54 +++ .../project-reload-drops-debug-silently.md | 58 ++++ ...egistry-apis-without-production-callers.md | 56 +++ .../save-notice-assumes-header-dispatch.md | 55 +++ docs/glossary.md | 9 + lp-app/lpa-studio-core/README.md | 24 +- .../src/app/project/project_controller.rs | 169 +++++++++- .../src/app/project/slot/slot_edit_join.rs | 6 +- .../lpc-model/src/project/node_attach_site.rs | 2 +- .../project/overlay_mutation/mutation_cmd.rs | 2 +- .../lpc-model/src/slot/slot_persistence.rs | 17 +- lp-core/lpc-model/src/slot/slot_role.rs | 4 +- .../lpc-model/src/slot/slot_role_lookup.rs | 15 +- lp-core/lpc-registry/README.md | 22 +- .../src/registry/node_authoring.rs | 38 ++- .../src/registry/project_registry.rs | 318 +++++++++++++++--- schemas/README.md | 5 +- 21 files changed, 1070 insertions(+), 102 deletions(-) create mode 100644 docs/adr/2026-08-01-debug-slots-taxonomy.md create mode 100644 docs/debt/clock-transport-has-no-transport-ui.md create mode 100644 docs/debt/project-reload-drops-debug-silently.md create mode 100644 docs/debt/registry-apis-without-production-callers.md create mode 100644 docs/debt/save-notice-assumes-header-dispatch.md diff --git a/docs/adr/2026-07-05-studio-pane-grammar.md b/docs/adr/2026-07-05-studio-pane-grammar.md index 6ebbcec08..40783a6ff 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..d6ace08e3 --- /dev/null +++ b/docs/adr/2026-08-01-debug-slots-taxonomy.md @@ -0,0 +1,280 @@ +# 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; direction-implied | + +### The taxonomy line + +**Events → command channel; ephemeral state → Debug slots; produced state → +direction-implied.** + +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. The authored value is the + **default**; the panel writer is a **live override** on top of it. That is + precisely why 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. +- **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: leases only existed because a + command has no durable home; a Debug slot has 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 6891c2993..a7239531e 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 | @@ -127,6 +127,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 c68002e82..07125139e 100644 --- a/docs/debt/README.md +++ b/docs/debt/README.md @@ -64,3 +64,7 @@ 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) | carried | 2026-07-31 | lp-fw/fw-esp32c6/src/output | C6 still runs its own single-channel WS281x driver, not `lp-ws281x`; the two now diverge in features and both hardcode `MAX_LEDS` | | [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 | 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..42b1b5096 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -96,6 +96,15 @@ 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 slot whose authored value is the default, which + is why panel state persists and Debug does not. 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/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/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 89a801708..f3419638f 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::{ @@ -1118,13 +1117,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(), @@ -1199,21 +1220,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 @@ -10169,6 +10197,111 @@ 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 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). 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 c8cb70f4f..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 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/slot/slot_persistence.rs b/lp-core/lpc-model/src/slot/slot_persistence.rs index d3270ec11..6885ce8c6 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,6 +29,21 @@ 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` diff --git a/lp-core/lpc-model/src/slot/slot_role.rs b/lp-core/lpc-model/src/slot/slot_role.rs index bae98d923..ced686157 100644 --- a/lp-core/lpc-model/src/slot/slot_role.rs +++ b/lp-core/lpc-model/src/slot/slot_role.rs @@ -47,8 +47,8 @@ impl SlotRole { /// /// 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; the former `read_only_transient` +/// marking on state records is gone because of it). pub fn effective_writable(role: SlotRole, direction: SlotDirection) -> bool { role.is_writable() && direction != SlotDirection::Produced } 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..f59349537 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` 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/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..9694b5428 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,190 @@ 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 a produced + // path's entry at commit while the studio still believed it held a + // live override — a Debug value that vanishes with no Clear. + let mut shapes = SlotShapeRegistry::default(); + shapes.replace_shape( + ClockDef::SHAPE_ID, + clock_shape_with(|rate| { + 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 +2200,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 +2311,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 +3841,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 +3859,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/schemas/README.md b/schemas/README.md index 871923656..5b8b870f7 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -31,8 +31,9 @@ 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. +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 From 8b1bbc20b884816485d7693752671c3b538bfaf7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 18:57:48 -0700 Subject: [PATCH 10/13] docs: apply nested-modules review to the taxonomy ADR + glossary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the panel.md/glossary owner's review (verdict: no structural collision): latching-vs-momentary panel persistence spelled out (P14 gestures never persist yet are Panel — fallback is bus resolution, not a shape default); exposure stated as binding via the bound channel, control identity (scope, channel); the lease-rejection scoped to state-with-a-durable-home so P9's momentary wire leases aren't foreclosed. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P6, G2 review) Co-Authored-By: Claude Fable 5 --- docs/adr/2026-08-01-debug-slots-taxonomy.md | 22 ++++++++++++++------- docs/glossary.md | 6 ++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/adr/2026-08-01-debug-slots-taxonomy.md b/docs/adr/2026-08-01-debug-slots-taxonomy.md index d6ace08e3..9a1aa6706 100644 --- a/docs/adr/2026-08-01-debug-slots-taxonomy.md +++ b/docs/adr/2026-08-01-debug-slots-taxonomy.md @@ -83,11 +83,16 @@ 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. The authored value is the - **default**; the panel writer is a **live override** on top of it. That is - precisely why 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. + 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 @@ -235,8 +240,11 @@ remains the right home for any future genuine event. 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: leases only existed because a - command has no durable home; a Debug slot has one. The collapse was + 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 diff --git a/docs/glossary.md b/docs/glossary.md index 42b1b5096..0eb317d00 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -100,8 +100,10 @@ of implementation — code can lag these names during the transition. 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 slot whose authored value is the default, which - is why panel state persists and Debug does not. Never dirty, never saved; + 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)). From 672e851edca451f36d5e7f57ef96590d3f507be7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 19:02:26 -0700 Subject: [PATCH 11/13] fix: scrub authored controls stanzas from 5 test projects added on main The merge of main brought quad-equal100-v3, quad-gamma-full, quad-gamma-v3, quad-strips-v3 and quad60-v3, whose clock.json files were authored before D2 landed; all carried identical shape-default controls values. Schema conformance is green again. Plus rustfmt of a hand-resolved import. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P6, merge follow-up) Co-Authored-By: Claude Fable 5 --- .../src/app/studio/studio_controller.rs | 14 +++++++------- projects/test/quad-equal100-v3/clock.json | 7 +------ projects/test/quad-gamma-full/clock.json | 7 +------ projects/test/quad-gamma-v3/clock.json | 7 +------ projects/test/quad-strips-v3/clock.json | 7 +------ projects/test/quad60-v3/clock.json | 7 +------ 6 files changed, 12 insertions(+), 37 deletions(-) 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 037ee1406..6ff7b2d9e 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, 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, + 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 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-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/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" } From a628712316734c1b64f850bb6456d15725c29fd2 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 19:10:47 -0700 Subject: [PATCH 12/13] fix: overlay-changed_at test stages a valid edit under W9 validation The test (new on main) fabricated an overlay entry through the then- unvalidated ProjectRegistry::mutate against an unknown artifact; with mutate now validating like the batch path, the setup loads a real minimal project root and assigns ProjectDef's one writable field (name.some) instead. Test intent unchanged: the read reports the mutation revision. Plan: lp2025/2026-07-31-1736-ephemeral-slots (P6, merge follow-up) Co-Authored-By: Claude Fable 5 --- .../src/engine/project_read_stream.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) 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), From a73a39a51d59c68d91b03932b37cb899936dc223 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:06:23 +0000 Subject: [PATCH 13/13] chore(studio): auto-refresh story baselines [validate-stories] Captured in the pinned CI environment by run: https://github.com/light-player/lightplayer/actions/runs/30746740573 --- ...__node-cards__gallery-drawers-open__lg.png | Bin 191304 -> 193774 bytes ...__node-cards__gallery-drawers-open__md.png | Bin 179895 -> 182405 bytes ...__node-cards__gallery-drawers-open__sm.png | Bin 149082 -> 151783 bytes ...ode__config-slot-row__debug-chrome__lg.png | Bin 0 -> 9971 bytes ...ode__config-slot-row__debug-chrome__md.png | Bin 0 -> 9093 bytes ...ode__config-slot-row__debug-chrome__sm.png | Bin 0 -> 8128 bytes ...onfig-slot-row__debug-detail-popup__lg.png | Bin 0 -> 31186 bytes ...onfig-slot-row__debug-detail-popup__md.png | Bin 0 -> 29351 bytes ...onfig-slot-row__debug-detail-popup__sm.png | Bin 0 -> 26382 bytes ...-slot-row__editable-clean-controls__sm.png | Bin 14280 -> 14220 bytes ...node__config-slot-row__live-chrome__lg.png | Bin 31836 -> 0 bytes ...node__config-slot-row__live-chrome__md.png | Bin 21544 -> 0 bytes ...node__config-slot-row__live-chrome__sm.png | Bin 11162 -> 0 bytes ...config-slot-row__live-detail-popup__lg.png | Bin 43346 -> 0 bytes ...config-slot-row__live-detail-popup__md.png | Bin 39006 -> 0 bytes ...config-slot-row__live-detail-popup__sm.png | Bin 33479 -> 0 bytes ...de__config-slot-row__rejected-edit__sm.png | Bin 6112 -> 6086 bytes ..._node__fixture-face__advanced-open__lg.png | Bin 38779 -> 40174 bytes ..._node__fixture-face__advanced-open__md.png | Bin 35802 -> 37293 bytes ..._node__fixture-face__advanced-open__sm.png | Bin 28066 -> 29226 bytes ...__node__node__debug-section-active__lg.png | Bin 0 -> 30446 bytes ...__node__node__debug-section-active__md.png | Bin 0 -> 28037 bytes ...__node__node__debug-section-active__sm.png | Bin 0 -> 25144 bytes ...de__debug-section-collapsed-active__lg.png | Bin 0 -> 19347 bytes ...de__debug-section-collapsed-active__md.png | Bin 0 -> 17808 bytes ...de__debug-section-collapsed-active__sm.png | Bin 0 -> 16184 bytes ..._node__debug-section-expanded-idle__lg.png | Bin 0 -> 29010 bytes ..._node__debug-section-expanded-idle__md.png | Bin 0 -> 26427 bytes ..._node__debug-section-expanded-idle__sm.png | Bin 0 -> 23437 bytes ...io__node__node__debug-section-idle__lg.png | Bin 0 -> 17220 bytes ...io__node__node__debug-section-idle__md.png | Bin 0 -> 16017 bytes ...io__node__node__debug-section-idle__sm.png | Bin 0 -> 14307 bytes ...de__node__debug-section-vs-unsaved__lg.png | Bin 0 -> 175144 bytes ...de__node__debug-section-vs-unsaved__md.png | Bin 0 -> 162946 bytes ...de__node__debug-section-vs-unsaved__sm.png | Bin 0 -> 138103 bytes ...io__node__node__dirty-detail-popup__lg.png | Bin 56449 -> 46647 bytes ...io__node__node__dirty-detail-popup__md.png | Bin 53051 -> 43739 bytes ...io__node__node__dirty-detail-popup__sm.png | Bin 47400 -> 38343 bytes ...de__node__dirty-failed-header-tint__lg.png | Bin 108009 -> 111967 bytes ...de__node__dirty-failed-header-tint__md.png | Bin 99839 -> 103303 bytes ...de__node__dirty-failed-header-tint__sm.png | Bin 82639 -> 85838 bytes ...e__node__dirty-failed-surface-tint__lg.png | Bin 109278 -> 113771 bytes ...e__node__dirty-failed-surface-tint__md.png | Bin 101462 -> 104705 bytes ...e__node__dirty-failed-surface-tint__sm.png | Bin 82884 -> 86551 bytes ...node__node__dirty-live-header-tint__lg.png | Bin 107880 -> 0 bytes ...node__node__dirty-live-header-tint__md.png | Bin 99697 -> 0 bytes ...node__node__dirty-live-header-tint__sm.png | Bin 82594 -> 0 bytes ...ode__node__dirty-live-surface-tint__lg.png | Bin 111016 -> 0 bytes ...ode__node__dirty-live-surface-tint__md.png | Bin 102608 -> 0 bytes ...ode__node__dirty-live-surface-tint__sm.png | Bin 84007 -> 0 bytes ...e__node__dirty-unsaved-header-tint__lg.png | Bin 108326 -> 112289 bytes ...e__node__dirty-unsaved-header-tint__md.png | Bin 100080 -> 103564 bytes ...e__node__dirty-unsaved-header-tint__sm.png | Bin 82688 -> 85958 bytes ...__node__dirty-unsaved-surface-tint__lg.png | Bin 111289 -> 115356 bytes ...__node__dirty-unsaved-surface-tint__md.png | Bin 103001 -> 106296 bytes ...__node__dirty-unsaved-surface-tint__sm.png | Bin 84053 -> 87587 bytes .../studio__node__node__error-node__lg.png | Bin 16764 -> 18081 bytes .../studio__node__node__error-node__md.png | Bin 15088 -> 16229 bytes .../studio__node__node__error-node__sm.png | Bin 12848 -> 13955 bytes ...__node__node__header-delete-action__lg.png | Bin 107299 -> 111256 bytes ...__node__node__header-delete-action__md.png | Bin 99181 -> 102743 bytes ...__node__node__header-delete-action__sm.png | Bin 82090 -> 85247 bytes ..._node__node__nested-dirty-children__lg.png | Bin 46848 -> 50541 bytes ..._node__node__nested-dirty-children__md.png | Bin 41561 -> 45432 bytes ..._node__node__nested-dirty-children__sm.png | Bin 34167 -> 37660 bytes .../studio__node__node__node-pane__lg.png | Bin 106997 -> 110962 bytes .../studio__node__node__node-pane__md.png | Bin 98828 -> 102355 bytes .../studio__node__node__node-pane__sm.png | Bin 82330 -> 85415 bytes ...__output-debug-test-pattern-active__lg.png | Bin 0 -> 27770 bytes ...__output-debug-test-pattern-active__md.png | Bin 0 -> 25411 bytes ...__output-debug-test-pattern-active__sm.png | Bin 0 -> 22744 bytes ...de__output-debug-test-pattern-idle__lg.png | Bin 0 -> 22523 bytes ...de__output-debug-test-pattern-idle__md.png | Bin 0 -> 20362 bytes ...de__output-debug-test-pattern-idle__sm.png | Bin 0 -> 18019 bytes ...__node__shader-face__advanced-open__lg.png | Bin 73840 -> 75143 bytes ...__node__shader-face__advanced-open__md.png | Bin 68737 -> 70027 bytes ...__node__shader-face__advanced-open__sm.png | Bin 56757 -> 57793 bytes ...t__project-pane__change-list-empty__lg.png | Bin 48674 -> 47194 bytes ...t__project-pane__change-list-empty__md.png | Bin 44972 -> 43914 bytes ...t__project-pane__change-list-empty__sm.png | Bin 39391 -> 37864 bytes ...project-pane__change-list-overflow__lg.png | Bin 84466 -> 84533 bytes ...project-pane__change-list-overflow__md.png | Bin 79708 -> 79709 bytes ...project-pane__change-list-overflow__sm.png | Bin 71813 -> 71144 bytes ...project__project-pane__change-list__lg.png | Bin 101535 -> 90549 bytes ...project__project-pane__change-list__md.png | Bin 96988 -> 86472 bytes ...project__project-pane__change-list__sm.png | Bin 88282 -> 77577 bytes ...pane__debug-absent-from-save-panel__lg.png | Bin 0 -> 71501 bytes ...pane__debug-absent-from-save-panel__md.png | Bin 0 -> 66371 bytes ...pane__debug-absent-from-save-panel__sm.png | Bin 0 -> 58795 bytes ...t__project-pane__debug-active-chip__lg.png | Bin 0 -> 18202 bytes ...t__project-pane__debug-active-chip__md.png | Bin 0 -> 16792 bytes ...t__project-pane__debug-active-chip__sm.png | Bin 0 -> 14392 bytes ...ct-pane__debug-active-with-unsaved__lg.png | Bin 0 -> 19041 bytes ...ct-pane__debug-active-with-unsaved__md.png | Bin 0 -> 17491 bytes ...ct-pane__debug-active-with-unsaved__sm.png | Bin 0 -> 14958 bytes ...roject__project-pane__detail-popup__lg.png | Bin 77636 -> 65698 bytes ...roject__project-pane__detail-popup__md.png | Bin 73517 -> 61603 bytes ...roject__project-pane__detail-popup__sm.png | Bin 66256 -> 54196 bytes ...__project__project-pane__live-only__lg.png | Bin 17338 -> 0 bytes ...__project__project-pane__live-only__md.png | Bin 16012 -> 0 bytes ...__project__project-pane__live-only__sm.png | Bin 13524 -> 0 bytes ..._project-pane__staged-node-removal__lg.png | Bin 80719 -> 80715 bytes ..._project-pane__staged-node-removal__md.png | Bin 75973 -> 75265 bytes ..._project-pane__staged-node-removal__sm.png | Bin 67352 -> 66865 bytes ...__project-workspace__project-ready__lg.png | Bin 143152 -> 148903 bytes ...__project-workspace__project-ready__md.png | Bin 135028 -> 139851 bytes ...__project-workspace__project-ready__sm.png | Bin 116623 -> 121233 bytes ...ject-workspace__sidebar-dirty-tree__lg.png | Bin 20222 -> 19887 bytes ...ject-workspace__sidebar-dirty-tree__md.png | Bin 18603 -> 18130 bytes ...ject-workspace__sidebar-dirty-tree__sm.png | Bin 15601 -> 15157 bytes 110 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-chrome__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__debug-detail-popup__sm.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__lg.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__md.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-chrome__sm.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__lg.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__md.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__live-detail-popup__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-active__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-collapsed-active__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-expanded-idle__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-idle__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__debug-section-vs-unsaved__sm.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__lg.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__md.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-header-tint__sm.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__lg.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__md.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__dirty-live-surface-tint__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-active__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__node__node__output-debug-test-pattern-idle__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-absent-from-save-panel__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-chip__sm.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__lg.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__md.png create mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__debug-active-with-unsaved__sm.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__lg.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__md.png delete mode 100644 lp-app/lpa-studio-web/story-images/studio__project__project-pane__live-only__sm.png 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 bd71356075e977e4a00ab191683bc89324c179d8..a929704a9544ef1acbc8d3e41c75b6090aba6b61 100644 GIT binary patch delta 181836 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Ȯi2k5jy!O?)FZ$M~ZM-ls4>(9wBrobP9& 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