diff --git a/lp-app/lpa-studio-core/src/app/node/face/mod.rs b/lp-app/lpa-studio-core/src/app/node/face/mod.rs index d5439b751..58a84e443 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/mod.rs @@ -13,16 +13,22 @@ //! fallback, also always available inside the advanced drawer. mod ui_fixture_face; +mod ui_module_face; mod ui_node_face; mod ui_panel_control; +mod ui_panel_control_view; +mod ui_panel_group; mod ui_panel_widget; mod ui_playlist_entry; mod ui_playlist_face; mod ui_shader_face; pub use ui_fixture_face::UiFixtureFace; +pub use ui_module_face::UiModuleFace; pub use ui_node_face::UiNodeFace; pub use ui_panel_control::UiPanelControl; +pub use ui_panel_control_view::{UiPanelControlState, UiPanelControlView}; +pub use ui_panel_group::UiPanelGroup; pub use ui_panel_widget::UiPanelWidget; pub use ui_playlist_entry::UiPlaylistEntry; pub use ui_playlist_face::UiPlaylistFace; diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs new file mode 100644 index 000000000..849fab989 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs @@ -0,0 +1,64 @@ +//! The **module** card's face — the one face worn at every depth. +//! +//! `docs/design/modules.md` §5: one face, three zoom levels. The root +//! module wears it as the single top-level workspace card (the flat-root +//! reversal — the root now *does* something); an embedded module wears the +//! same face as a child card inside its host; play mode renders the root +//! module's panel alone, without any face at all. +//! +//! Top-down: output-mirror hero (R7) → panel (R8) → the bus-as-wiring +//! drawer → provenance. The split between the panel and the wiring drawer +//! is the sidebar bus pane's replacement: bus-as-controls sits on the face, +//! bus-as-writers/readers goes in a drawer. +//! +//! **Children are not on the face.** They render below the card as full +//! sibling cards, through the same [`crate::UiNodeChild`] path the playlist +//! and the old project node use — the module contributes no new nesting +//! grammar. All of a module's children render, not just an active one: +//! module children are collaborators, not branches. +//! +//! M2 UX spike: this DTO is fed by mock fixtures. Deriving it from real +//! scope data is M4's work. + +use crate::{UiBusView, UiPanelGroup, UiProducedProduct}; + +/// A module node card's permanent face. +#[derive(Clone, Debug, PartialEq)] +pub struct UiModuleFace { + /// The module's produced `output` slot, mirroring its own scope's + /// `visual.out` (R7) — the face hero. `None` for a module with no + /// visual, which is a legitimate shape (E6). + pub preview: Option, + /// The module's panel: this scope's channels plus each child module's + /// panel as a nested group (R8). + pub panel: UiPanelGroup, + /// Bus-as-wiring: writers and readers for this scope's channels — what + /// the sidebar bus pane used to show, now a drawer on the module that + /// owns the scope. + pub wiring: Option, + /// Whether the wiring drawer renders expanded. + pub wiring_open: bool, + /// Compact provenance line ("Yona · v1 · CC0-1.0"); `None` when the + /// module carries no provenance fields (§8). + pub provenance: Option, + /// Panel-state auto-save (panel.md P11 — on by default, with a user + /// toggle). `Some` only on the module that presents the toggle: panel + /// state persists per project folder (`.lp/state.json`), so it is the + /// project's root module that owns it, and an embedded module carries + /// `None` rather than repeating its host's switch. + pub auto_save: Option, +} + +impl UiModuleFace { + /// A face for `panel`'s module with nothing else filled in. + pub fn new(panel: UiPanelGroup) -> Self { + Self { + preview: None, + panel, + wiring: None, + wiring_open: false, + provenance: None, + auto_save: None, + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs index 1a5ad89f3..35a616ce7 100644 --- a/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs @@ -1,6 +1,6 @@ //! The kind-specific face variants a node card can render. -use crate::{UiFixtureFace, UiPlaylistFace, UiShaderFace}; +use crate::{UiFixtureFace, UiModuleFace, UiPanelGroup, UiPlaylistFace, UiShaderFace}; /// Kind-specific permanent face for a node card. /// @@ -16,4 +16,18 @@ pub enum UiNodeFace { /// Playlist card: entry strip; the active child's real card renders /// below via the existing [`crate::UiNodeChild`]. Playlist(UiPlaylistFace), + /// Module card (`docs/design/modules.md` §5): output-mirror hero, the + /// scope's panel, and the bus-wiring drawer. Worn at every depth — root + /// workspace card and embedded child card alike. Its children render + /// below it as sibling cards via [`crate::UiNodeChild`], like every + /// other kind. **M2 UX spike:** fed by mock fixtures, not yet derived. + Module(UiModuleFace), + /// A non-module node whose whole face is its bound slots, presented as + /// panel controls (`docs/design/modules.md` R3): the clock, the control + /// node, the fixture sitting under a module. The group carries the + /// ENCLOSING scope, because a leaf introduces no scope of its own — so + /// these controls share their `(scope, channel)` identity, and their + /// panel state, with the module panel above (`panel.md` P1). + /// **M2 UX spike:** mock-fed. + Controls(UiPanelGroup), } diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs new file mode 100644 index 000000000..78d0861a0 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs @@ -0,0 +1,179 @@ +//! One control on a **panel**, plus the panel state behind it. +//! +//! `docs/design/panel.md` is the normative treatment. A control is +//! identified by `(scope, channel)` (P1); the scope is the owning +//! [`crate::UiPanelGroup`], the channel is [`UiPanelControlView::channel`]. +//! The widget payload is the existing [`UiPanelControl`] verbatim — the +//! module model reuses the node-face panel widgets rather than forking a +//! second control family. +//! +//! M2 UX spike: the state below is carried by mock fixtures. The engine +//! runtime that materializes real panel writers is M4's. + +use crate::{UiPanelControl, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow}; + +/// The three visibly distinct states a panel control can be in +/// (`docs/design/panel.md` P2; the three-state requirement is P-Q2). +/// +/// These are *not* a severity ladder and must not reuse the bound-violet +/// family: "bound" means wired, "engaged" means captured (P6). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum UiPanelControlState { + /// **Read, at default.** No writer anywhere in the scope chain, so the + /// consuming slot falls back to its own authored default + /// (`modules.md` R6). The channel still lists — an unfilled public + /// input is an invitation, not an error. + #[default] + ReadDefault, + /// **Read, following automation.** Some writer that is *not* this + /// control's drives the channel: an authored node in this scope (an + /// LFO, a clock) or an inherited writer from an enclosing scope + /// (`modules.md` R5). The control displays the live value and can be + /// grabbed (P5 jump takeover). + ReadFollowing, + /// **Engaged (Latch).** This control's panel writer exists and holds, + /// shadowing other writers in its scope until cleared (P2/P4). + Engaged, +} + +impl UiPanelControlState { + /// Whether the control's panel writer exists — the one bit a reset + /// gesture acts on (P2 clear). + pub fn engaged(self) -> bool { + matches!(self, Self::Engaged) + } + + /// Short state word for the control's caption and for the group's + /// summary rows. + pub fn word(self) -> &'static str { + match self { + Self::ReadDefault => "default", + Self::ReadFollowing => "following", + Self::Engaged => "held", + } + } +} + +/// One channel presented on one panel. +#[derive(Clone, Debug, PartialEq)] +pub struct UiPanelControlView { + /// The channel name within the owning group's scope — the other half of + /// the control's identity (P1). + pub channel: String, + /// Widget, label, value, unit, and detail aspects. Reused verbatim from + /// the node-face panel so knob v2, the fader, and the toggle behave + /// identically wherever they appear. + pub control: UiPanelControl, + /// Which of the three panel states this control is in. + pub state: UiPanelControlState, + /// Who owns the displayed value while the control is in Read: "clock", + /// "inherited from Evening set", "authored default" (P2 — the UI + /// distinguishes inherited / authored / default). + pub source: Option, +} + +impl UiPanelControlView { + /// A control in its Read-at-default state. + pub fn new(channel: impl Into, control: UiPanelControl) -> Self { + Self { + channel: channel.into(), + control, + state: UiPanelControlState::ReadDefault, + source: None, + } + } + + /// Put the control in a state, with the Read caption that explains it. + pub fn with_state( + mut self, + state: UiPanelControlState, + source: Option>, + ) -> Self { + self.state = state; + self.source = source.map(Into::into); + self + } + + /// The control's detail-popup sections. + /// + /// A control on the face is widget + label + value and nothing else — + /// a control panel, not a spec sheet. Everything that used to sit under + /// it as a caption lives here instead, behind the label: which of the + /// three states it is in, what drives it, what a held value displaced, + /// and the `(scope, channel)` identity itself (P1). + /// + /// The widget's own aspects (validation, type info, binding on the + /// backing slot) follow, so the panel popup is a superset of the slot + /// popup rather than a fork of it. + pub fn detail_aspects(&self, scope: &str) -> Vec { + let mut aspects = vec![self.state_aspect()]; + aspects.push( + UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Channel") + .with_row(UiSlotAspectRow::new("Name", self.control.label.clone())) + .with_row(UiSlotAspectRow::new("Channel", self.channel.clone())) + .with_row(UiSlotAspectRow::new("Scope", scope)), + ); + aspects.extend(self.control.aspects.iter().cloned()); + aspects + } + + /// The panel-state section: one title per state, and rows that say what + /// the state displaced. + fn state_aspect(&self) -> UiSlotAspect { + let source = self.source.clone(); + match self.state { + UiPanelControlState::Engaged => { + let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "Held") + .with_affordance(UiSlotAffordance::Edited) + .with_row(UiSlotAspectRow::new( + "", + "This panel holds the channel; other writers are shadowed until it is reset.", + )); + match source { + Some(source) => aspect.with_row(UiSlotAspectRow::new("Was", source)), + None => aspect, + } + } + UiPanelControlState::ReadFollowing => { + let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "Following") + .with_affordance(UiSlotAffordance::Bound) + .with_row(UiSlotAspectRow::new( + "", + "Something else drives this channel; turning the control takes it over.", + )); + match source { + Some(source) => aspect.with_row(UiSlotAspectRow::new("Driven by", source)), + None => aspect, + } + } + UiPanelControlState::ReadDefault => { + let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "At default") + .with_row(UiSlotAspectRow::new( + "", + "Nothing writes this channel, so the consuming slot uses its own default.", + )); + match source { + Some(source) => aspect.with_row(UiSlotAspectRow::new("Value from", source)), + None => aspect, + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::UiPanelControlState; + + #[test] + fn only_the_engaged_state_carries_a_panel_writer() { + assert!(UiPanelControlState::Engaged.engaged()); + assert!(!UiPanelControlState::ReadFollowing.engaged()); + assert!(!UiPanelControlState::ReadDefault.engaged()); + // Read-at-default is the resting state a fresh panel renders in. + assert_eq!( + UiPanelControlState::default(), + UiPanelControlState::ReadDefault + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/face/ui_panel_group.rs b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_group.rs new file mode 100644 index 000000000..b6ec4d917 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_group.rs @@ -0,0 +1,197 @@ +//! A panel's channel list, and the nested groups it presents beneath it. +//! +//! `docs/design/modules.md` R8: a module's panel presents its scope's +//! channel list — the aggregate of its children's publicity — plus each +//! child module's panel as a **nested group**. The recursion is +//! presentation only: nothing is promoted, no dataflow construct sits +//! behind a group, and two embedded instances of the same effect present +//! two independent groups because they are two different scopes. + +use crate::{ + UiPanelControlView, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, +}; + +/// One panel: the channels of one scope, plus its child modules' panels. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct UiPanelGroup { + /// Group heading — the module's own name. The root module's panel wears + /// the project name; a nested group wears the embedded module's. + pub label: String, + /// The scope this group presents, as a node path (`/`, `/plasma-1`). + /// Together with a channel name this is a control's identity (panel.md + /// P1) and the key every reset gesture carries. + pub scope: String, + /// The scope's channels, in listing order. + pub controls: Vec, + /// Child modules' panels — presentation recursion (R8). + /// + /// Groups render as bordered clusters in a wrapping row and are + /// **always open**: wrapping is the density mechanism, not disclosure. + /// A panel you have to unfold before you can play it is not a control + /// panel, so there is deliberately no collapsed state here. + pub groups: Vec, +} + +impl UiPanelGroup { + /// An empty panel for `scope`, labeled `label`. + pub fn new(label: impl Into, scope: impl Into) -> Self { + Self { + label: label.into(), + scope: scope.into(), + controls: Vec::new(), + groups: Vec::new(), + } + } + + /// Add this scope's own channels. + pub fn with_controls(mut self, controls: Vec) -> Self { + self.controls = controls; + self + } + + /// Add nested child-module groups. + pub fn with_groups(mut self, groups: Vec) -> Self { + self.groups = groups; + self + } + + /// Whether the group presents nothing at all — no channels here and + /// nothing in any nested group. + pub fn is_empty(&self) -> bool { + self.controls.is_empty() && self.groups.iter().all(Self::is_empty) + } + + /// Engaged controls in this group only — what the group's own reset + /// gesture would clear if it did not descend. + pub fn engaged_here(&self) -> usize { + self.controls + .iter() + .filter(|control| control.state.engaged()) + .count() + } + + /// Engaged controls in this group and every nested group — what "reset + /// this module" clears (panel.md P2 clear at scope granularity; P-Q4's + /// lean is that a clear descends). + pub fn engaged_total(&self) -> usize { + self.engaged_here() + self.groups.iter().map(Self::engaged_total).sum::() + } + + /// Collapsed-row summary: how many controls the group holds and how + /// many of them are currently held. + pub fn summary(&self) -> String { + let controls = self.controls.len(); + let noun = if controls == 1 { "control" } else { "controls" }; + match self.engaged_total() { + 0 => format!("{controls} {noun}"), + engaged => format!("{controls} {noun} · {engaged} held"), + } + } + + /// The group heading's detail-popup sections. + /// + /// A nested group's heading is a hairline rule with its name on it and + /// nothing else — the scope path and the control tally are identity and + /// bookkeeping, not things to read while playing, so they live here, + /// behind the same label-popup gesture the controls use. + pub fn detail_aspects(&self) -> Vec { + let held = self.engaged_total(); + let state = if held == 0 { + UiSlotAspect::new(UiSlotAspectKind::PanelState, "Nothing held").with_row( + UiSlotAspectRow::new("", "Every control in this group follows the project."), + ) + } else { + let clause = if held == 1 { + "1 control in this group is held by the panel.".to_string() + } else { + format!("{held} controls in this group are held by the panel.") + }; + UiSlotAspect::new(UiSlotAspectKind::PanelState, "Held") + .with_affordance(UiSlotAffordance::Edited) + .with_row(UiSlotAspectRow::new("", clause)) + }; + + vec![ + state, + UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Group") + .with_row(UiSlotAspectRow::new("Name", self.label.clone())) + .with_row(UiSlotAspectRow::new("Scope", self.scope.clone())) + .with_row(UiSlotAspectRow::new("Controls", self.summary())), + ] + } +} + +#[cfg(test)] +mod tests { + use crate::{ + UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelGroup, UiPanelWidget, + UiSlotFieldState, UiSlotValue, + }; + + fn control(channel: &str, state: UiPanelControlState) -> UiPanelControlView { + UiPanelControlView { + channel: channel.to_string(), + control: UiPanelControl { + label: channel.to_string(), + address: None, + widget: UiPanelWidget::Knob { + min: 0.0, + max: 1.0, + step: None, + }, + value: UiSlotValue::f32(0.5), + live_value: None, + unit: None, + state: UiSlotFieldState::editable(), + aspects: Vec::new(), + }, + state, + source: None, + } + } + + fn plasma(scope: &str, state: UiPanelControlState) -> UiPanelGroup { + UiPanelGroup::new("plasma", scope).with_controls(vec![control("speed", state)]) + } + + #[test] + fn a_module_reset_counts_every_nested_group() { + let panel = UiPanelGroup::new("Aurora", "/") + .with_controls(vec![ + control("brightness", UiPanelControlState::Engaged), + control("time", UiPanelControlState::ReadFollowing), + ]) + .with_groups(vec![ + plasma("/plasma-1", UiPanelControlState::Engaged), + plasma("/plasma-2", UiPanelControlState::ReadDefault), + ]); + + // The root's own reset would clear one; resetting the module clears + // the nested plasma-1 writer too. + assert_eq!(panel.engaged_here(), 1); + assert_eq!(panel.engaged_total(), 2); + // Two instances of one effect are two independent groups (R8). + assert_eq!(panel.groups[0].engaged_total(), 1); + assert_eq!(panel.groups[1].engaged_total(), 0); + } + + #[test] + fn summary_reports_held_controls_only_when_there_are_some() { + let quiet = plasma("/plasma-2", UiPanelControlState::ReadDefault); + assert_eq!(quiet.summary(), "1 control"); + + let held = plasma("/plasma-1", UiPanelControlState::Engaged); + assert_eq!(held.summary(), "1 control · 1 held"); + } + + #[test] + fn emptiness_looks_through_nested_groups() { + let hollow = + UiPanelGroup::new("Aurora", "/").with_groups(vec![UiPanelGroup::new("plasma", "/p")]); + assert!(hollow.is_empty()); + + let filled = UiPanelGroup::new("Aurora", "/") + .with_groups(vec![plasma("/p", UiPanelControlState::ReadDefault)]); + assert!(!filled.is_empty()); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs index bd9021f84..dc5cda30b 100644 --- a/lp-app/lpa-studio-core/src/app/node/mod.rs +++ b/lp-app/lpa-studio-core/src/app/node/mod.rs @@ -38,8 +38,8 @@ mod ui_slot_unit; mod ui_slot_value; pub use face::{ - UiFixtureFace, UiNodeFace, UiPanelControl, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, - UiShaderFace, + UiFixtureFace, UiModuleFace, UiNodeFace, UiPanelControl, UiPanelControlState, + UiPanelControlView, UiPanelGroup, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiShaderFace, }; pub use ui_asset_editor::UiAssetEditor; pub use ui_binding_authoring::{UiBindingAuthoring, UiBindingAuthoringDirection, UiChannelChoice}; diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs index cd9780f08..8a111c33a 100644 --- a/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs @@ -36,6 +36,12 @@ pub enum UiSlotAspectKind { Binding, /// Slot value family and type metadata. TypeInfo, + /// **Panel state** for a control on a module panel + /// (`docs/design/panel.md` P2): held / following / at default, plus + /// what the held value displaced. Unlike the other kinds this one + /// titles itself from [`UiSlotAspect::title`], because the three panel + /// states are the section's whole point. + PanelState, } /// A stable popup section for one slot concern. diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index c2534048a..98ed6378c 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -45,15 +45,16 @@ pub use app::home::{ pub use app::node::{ UiAssetEditor, UiAssetEditorKind, UiBindingAuthoring, UiBindingAuthoringDirection, UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, - UiControlSampleFormat, UiFixtureFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, UiNodeHeader, - UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, UiPanelWidget, - UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, UiProducedProduct, - UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, UiProductRef, - UiProductTrackingState, UiShaderFace, UiShaderUniform, UiSlotAffordance, UiSlotAspect, - UiSlotAspectKind, UiSlotAspectRow, UiSlotAsset, UiSlotComposite, UiSlotEditorHint, - UiSlotEnumComposite, UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, UiSlotOption, - UiSlotOptionality, UiSlotRecord, UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, - UiSlotValue, UiSlotValueKind, + UiControlSampleFormat, UiFixtureFace, UiModuleFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, + UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, + UiPanelControlState, UiPanelControlView, UiPanelGroup, UiPanelWidget, UiPlaylistEntry, + UiPlaylistFace, UiProducedBinding, UiProducedBindings, UiProducedProduct, UiProducedValue, + UiProductKind, UiProductPreview, UiProductPreviewFrame, UiProductRef, UiProductTrackingState, + UiShaderFace, UiShaderUniform, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, + UiSlotAspectRow, UiSlotAsset, UiSlotComposite, UiSlotEditorHint, UiSlotEnumComposite, + UiSlotFieldState, UiSlotMapComposite, UiSlotMapKeyKind, UiSlotOption, UiSlotOptionality, + UiSlotRecord, UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, UiSlotValue, + UiSlotValueKind, }; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] pub use app::preview_host::{PreviewHost, PreviewSlotHandle}; diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 2bc7cface..f934c9abd 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -144,9 +144,15 @@ .tw\:left-1\/2 { left: calc(1/2 * 100%); } + .tw\:left-\[1px\] { + left: 1px; + } .tw\:left-\[2px\] { left: 2px; } + .tw\:left-\[9px\] { + left: 9px; + } .tw\:left-\[19px\] { left: 19px; } @@ -180,9 +186,6 @@ .tw\:-mx-1 { margin-inline: calc(var(--tw-spacing) * -1); } - .tw\:-mx-3 { - margin-inline: calc(var(--tw-spacing) * -3); - } .tw\:-mx-4 { margin-inline: calc(var(--tw-spacing) * -4); } @@ -294,6 +297,9 @@ .tw\:h-4 { height: calc(var(--tw-spacing) * 4); } + .tw\:h-5 { + height: calc(var(--tw-spacing) * 5); + } .tw\:h-6 { height: calc(var(--tw-spacing) * 6); } @@ -321,6 +327,9 @@ .tw\:h-\[7px\] { height: 7px; } + .tw\:h-\[11px\] { + height: 11px; + } .tw\:h-\[15px\] { height: 15px; } @@ -330,9 +339,15 @@ .tw\:h-\[46px\] { height: 46px; } + .tw\:h-\[420px\] { + height: 420px; + } .tw\:h-\[560px\] { height: 560px; } + .tw\:h-\[720px\] { + height: 720px; + } .tw\:h-full { height: 100%; } @@ -513,9 +528,15 @@ .tw\:w-\[6ch\] { width: 6ch; } + .tw\:w-\[7px\] { + width: 7px; + } .tw\:w-\[15px\] { width: 15px; } + .tw\:w-\[20px\] { + width: 20px; + } .tw\:w-\[34px\] { width: 34px; } @@ -525,9 +546,18 @@ .tw\:w-\[128px\] { width: 128px; } + .tw\:w-\[184px\] { + width: 184px; + } .tw\:w-\[340px\] { width: 340px; } + .tw\:w-\[375px\] { + width: 375px; + } + .tw\:w-\[430px\] { + width: 430px; + } .tw\:w-\[720px\] { width: 720px; } @@ -594,6 +624,18 @@ .tw\:max-w-\[520px\] { max-width: 520px; } + .tw\:max-w-\[640px\] { + max-width: 640px; + } + .tw\:max-w-\[720px\] { + max-width: 720px; + } + .tw\:max-w-\[860px\] { + max-width: 860px; + } + .tw\:max-w-\[900px\] { + max-width: 900px; + } .tw\:max-w-full { max-width: 100%; } @@ -621,15 +663,27 @@ .tw\:min-w-\[2ch\] { min-width: 2ch; } + .tw\:min-w-\[4\.5ch\] { + min-width: 4.5ch; + } .tw\:min-w-\[52px\] { min-width: 52px; } .tw\:min-w-\[58px\] { min-width: 58px; } + .tw\:min-w-\[64px\] { + min-width: 64px; + } + .tw\:min-w-\[76px\] { + min-width: 76px; + } .tw\:flex-1 { flex: 1; } + .tw\:flex-auto { + flex: auto; + } .tw\:flex-none { flex: none; } @@ -642,6 +696,12 @@ .tw\:shrink-0 { flex-shrink: 0; } + .tw\:basis-\[220px\] { + flex-basis: 220px; + } + .tw\:basis-\[240px\] { + flex-basis: 240px; + } .tw\:origin-left { transform-origin: left; } @@ -1157,15 +1217,12 @@ .tw\:bg-card-subtle { background-color: var(--tw-color-card-subtle); } - .tw\:bg-card-subtle\/60 { - background-color: var(--tw-color-card-subtle); - @supports (color: color-mix(in lab, red, red)) { - background-color: color-mix(in oklab, var(--tw-color-card-subtle) 60%, transparent); - } - } .tw\:bg-dim-foreground { background-color: var(--tw-color-dim-foreground); } + .tw\:bg-muted-foreground { + background-color: var(--tw-color-muted-foreground); + } .tw\:bg-panel-primary { background-color: var(--tw-color-panel-primary); } @@ -1352,6 +1409,9 @@ .tw\:p-\[22px\] { padding: 22px; } + .tw\:px-0 { + padding-inline: calc(var(--tw-spacing) * 0); + } .tw\:px-1 { padding-inline: calc(var(--tw-spacing) * 1); } @@ -1451,12 +1511,18 @@ .tw\:pr-1\.5 { padding-right: calc(var(--tw-spacing) * 1.5); } + .tw\:pr-8 { + padding-right: calc(var(--tw-spacing) * 8); + } .tw\:pr-24 { padding-right: calc(var(--tw-spacing) * 24); } .tw\:pb-1\.5 { padding-bottom: calc(var(--tw-spacing) * 1.5); } + .tw\:pb-2 { + padding-bottom: calc(var(--tw-spacing) * 2); + } .tw\:pb-3 { padding-bottom: calc(var(--tw-spacing) * 3); } @@ -1490,6 +1556,9 @@ .tw\:pl-24 { padding-left: calc(var(--tw-spacing) * 24); } + .tw\:pl-48 { + padding-left: calc(var(--tw-spacing) * 48); + } .tw\:pl-\[18px\] { padding-left: 18px; } @@ -1535,12 +1604,18 @@ font-size: var(--tw-text-xs); line-height: var(--tw-leading, var(--tw-text-xs--line-height)); } + .tw\:text-\[0\.5rem\] { + font-size: 0.5rem; + } .tw\:text-\[0\.6rem\] { font-size: 0.6rem; } .tw\:text-\[0\.7rem\] { font-size: 0.7rem; } + .tw\:text-\[0\.62rem\] { + font-size: 0.62rem; + } .tw\:text-\[0\.64rem\] { font-size: 0.64rem; } @@ -1789,6 +1864,9 @@ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .tw\:ring-status-attention-border { + --tw-ring-color: var(--tw-color-status-attention-border); + } .tw\:ring-status-bound-border { --tw-ring-color: var(--tw-color-status-bound-border); } diff --git a/lp-app/lpa-studio-web/src/app/mod.rs b/lp-app/lpa-studio-web/src/app/mod.rs index 894ed6853..d1ce73cfc 100644 --- a/lp-app/lpa-studio-web/src/app/mod.rs +++ b/lp-app/lpa-studio-web/src/app/mod.rs @@ -11,6 +11,8 @@ pub mod home; pub mod layout; #[cfg(feature = "stories")] pub(crate) mod mapping_editor_stories; +/// M2 UX spike: the module face, panel, and play-mode surfaces. +pub mod module; pub mod node; pub mod project; #[cfg(feature = "stories")] diff --git a/lp-app/lpa-studio-web/src/app/module/mod.rs b/lp-app/lpa-studio-web/src/app/module/mod.rs new file mode 100644 index 000000000..10799d12e --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/mod.rs @@ -0,0 +1,46 @@ +//! **M2 UX spike** — the module face, the panel, and play mode. +//! +//! Prototype surfaces for the module container model +//! (`docs/design/modules.md`, `docs/design/panel.md`), built for the G2 +//! visual gate. Everything here is fed by mock DTOs: the engine has no +//! scopes, no panel writers, and no `PanelWrite`/`PanelClear` ops yet — M4 +//! owns that. What the spike is meant to answer is whether the *shapes* +//! work on screen: +//! +//! 1. Does one face hold up at all three zoom levels — effect author +//! (children expanded), artist (a card in the workspace), end user +//! (play mode)? +//! 2. Is bus-as-controls-on-the-face + bus-as-wiring-in-a-drawer the right +//! split, with the sidebar bus pane gone? +//! 3. Does the root module back in the node area read better or worse? +//! 4. Is play mode as "root face only" the right direction? +//! 5. Where do the reset and auto-save gestures belong? +//! +//! The components are deliberately parallel to the node-face family +//! (`app/node/face/`) rather than merged into it: this is a spike, and the +//! seam should be easy to either promote or delete. The widgets themselves +//! (knob v2, fader, toggle) are the production ones, extended with one +//! `engaged` prop. + +mod module_face; +mod module_panel; +mod module_panel_control; +mod panel_gesture; +mod play_mode; + +#[cfg(feature = "stories")] +pub(crate) mod module_face_stories; +#[cfg(feature = "stories")] +pub(crate) mod module_fixtures; +#[cfg(feature = "stories")] +pub(crate) mod panel_state_stories; +#[cfg(feature = "stories")] +pub(crate) mod play_mode_stories; +#[cfg(feature = "stories")] +pub(crate) mod playlist_panel_stories; + +pub use module_face::ModuleFace; +pub use module_panel::{ModulePanel, NestedPanelGroup}; +pub use module_panel_control::ModulePanelControl; +pub use panel_gesture::PanelGesture; +pub use play_mode::PlayModeSurface; diff --git a/lp-app/lpa-studio-web/src/app/module/module_face.rs b/lp-app/lpa-studio-web/src/app/module/module_face.rs new file mode 100644 index 000000000..808b4e62f --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_face.rs @@ -0,0 +1,118 @@ +//! The module card's permanent face — **one face, every depth**. +//! +//! `docs/design/modules.md` §5. The root module wears this as the single +//! top-level workspace card (the flat-root reversal); an embedded module +//! wears the identical component as a child card inside its host, one level +//! in; play mode renders the panel alone with no face at all +//! ([`super::PlayModeSurface`]). +//! +//! Top-down, in the flat [`NodeCardSection`] grammar: +//! +//! 1. `output` — the module's `output` mirror (R7): its scope's +//! `visual.out`, forwarded. A module with no visual has no hero, which +//! is a legitimate shape (E6). +//! 2. `panel` — bus-as-**controls** (R8): this scope's channels plus each +//! child module's panel as a nested group. +//! 3. `wiring` — bus-as-**writers/readers** (today's sidebar bus-pane +//! content), as a drawer. The split is deliberate: controls are the +//! product surface, wiring is an authoring diagnostic. +//! 4. provenance — a quiet footer line (§8). +//! +//! **Children are NOT here.** They expand under the card as full sibling +//! cards, via [`crate::app::node::NodeChildren`] — the grammar the playlist +//! face and the old project node already use. All of them render, with no +//! active-child filtering: a module's children are collaborators, not +//! branches. A child module therefore wears this same face in a card of its +//! own, which is the "one face at every depth" claim made literal *and* +//! keeps the host card a fixed, readable height. +//! +//! An embedded module's controls consequently appear twice — as a nested +//! group on this panel, and on the child module's own card below. That is +//! deliberate, and it is the playlist precedent (a bound control shows on +//! the parent's face and on the child's card): one control, two views +//! (`panel.md` P1), which the shared `(scope, channel)` identity keeps in +//! lockstep. +//! +//! **Spike shortcut, not a proposal:** a knob drag still dispatches +//! `SlotEditOp::SetValue` at the control's mock slot address, because that +//! is what makes knob v2 work untouched. Panel writes are their own wire op +//! (panel.md P8) and M4 routes them there; nothing in this face depends on +//! which path carries the value. + +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiModuleFace as UiModuleFaceData}; + +use crate::app::BusPaneBody; +use crate::app::node::{NodeCardSection, ProductPreview}; + +use super::{ModulePanel, PanelGesture}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ModuleFace( + face: UiModuleFaceData, + /// Toggle handler for the wiring drawer (spike-local view state). + #[props(default = None)] + on_wiring_toggle: Option>, + #[props(default = None)] on_panel: Option>, + #[props(default)] on_action: Option>, +) -> Element { + let preview = face.preview.clone(); + let wiring_open = face.wiring_open; + let wiring_summary = face.wiring.as_ref().map(|wiring| { + let channels = wiring.channels.len(); + let noun = if channels == 1 { "channel" } else { "channels" }; + format!("{channels} {noun} · writers → readers") + }); + + rsx! { + if let Some(preview) = preview { + NodeCardSection { label: "output", first: true, + ProductPreview { + kind: preview.kind, + preview: preview.preview.clone(), + tracking: preview.tracking, + frame: preview.frame, + focus_action: None, + on_action, + } + } + } + NodeCardSection { label: "panel", first: face.preview.is_none(), + ModulePanel { + panel: face.panel.clone(), + auto_save: face.auto_save, + on_panel, + on_action, + } + } + if let Some(wiring) = face.wiring.clone() { + NodeCardSection { + label: "wiring", + summary: wiring_summary, + open: Some(wiring_open), + on_toggle: move |()| { + if let Some(handler) = on_wiring_toggle { + handler.call(()); + } + }, + div { class: "tw:grid tw:min-w-0 tw:gap-2 tw:px-4 tw:py-3", + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-dim-foreground", + "Every channel in this module's scope, and what writes and reads it. " + "The controls above are the same bus, presented for playing rather than patching." + } + BusPaneBody { view: wiring, on_action: move |action| { + if let Some(handler) = on_action { + handler.call(action); + } + } } + } + } + } + if let Some(provenance) = face.provenance.clone() { + div { class: "tw:border-t tw:border-border-strong tw:px-4 tw:py-2 tw:text-xs tw:text-dim-foreground", + "{provenance}" + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/module_face_stories.rs b/lp-app/lpa-studio-web/src/app/module/module_face_stories.rs new file mode 100644 index 000000000..944b885e8 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_face_stories.rs @@ -0,0 +1,135 @@ +//! Stories for the module face (M2 UX spike, gate G2). +//! +//! These carry G2 questions 1–3: does one face hold up at every zoom +//! level, is the controls/wiring split right, and does the root module back +//! in the node area read better or worse? +//! +//! The card chrome is the real [`NodePane`] — header, kind label, collapse +//! — and the children below it are real sibling cards on the real +//! [`crate::app::node::NodeChildren`] rail, so what is being judged is the +//! face inside a genuine workspace column, not a mock frame. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::NodePane; + +use super::module_fixtures::{ + HELD, PLASMA_1_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, held_root_view, module_node_view, + plasma_children, plasma_face, plasma_one_panel, root_module_node_view, +}; +use super::{ModuleFace, PanelGesture}; + +/// The workspace column's width, so a card story shows the same measure the +/// app does. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn WorkspaceCanvas(children: Element) -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-[720px]", {children} } + } +} + +#[story( + description = "The root module as the single top-level workspace card (flat-root reversal): output-mirror hero, the scope's panel with two nested effect groups, wiring drawer, provenance. Its children expand BELOW it as full sibling cards on the usual rail — all of them, since a module's children are collaborators, not branches. Walkable: turn a knob and it engages (amber), reset and it follows the project again." +)] +fn root_card() -> Element { + // The walkable fixture: the card view is DERIVED from it each render, + // so a knob drag really does move the knob and engage the control — + // on the panel and on the child card that shares its identity. + let mut spike = use_signal(|| PanelSpike::new(root_module_node_view()).with_held(HELD)); + + rsx! { + WorkspaceCanvas { + NodePane { + view: spike().view.clone(), + module_panel: move |gesture: PanelGesture| { + spike.with_mut(|spike| spike.apply_gesture(&gesture)); + }, + on_action: move |action| { + spike.with_mut(|spike| spike.apply_action(&action)); + }, + } + } + } +} + +#[story( + description = "The presentation overlap the model implies, cropped to the two cards that show it: plasma_1's speed appears as a nested group on the host's panel AND on plasma_1's own card below. Both are amber, because they are the same (scope, channel) control — one control, two views (panel.md P1), the same way a playlist's bound control shows on the parent face and the child card. Kept deliberately, not suppressed." +)] +fn author_zoom() -> Element { + let mut view = held_root_view(); + // Only the embedded module, so the comparison is the whole picture. + view.children + .retain(|child| child.label.starts_with("plasma_1")); + rsx! { + WorkspaceCanvas { + NodePane { view, on_action: move |_| {} } + } + } +} + +#[story( + description = "Artist zoom: one embedded effect module as a card on its own — the identical face, one level in. Output mirror, its scope's panel, wiring drawer, provenance line, and its own child shader on the same nesting rail below it. The rail is the grammar at every depth; the face never changes." +)] +fn embedded_module() -> Element { + let view = module_node_view( + "plasma_1", + PLASMA_1_SCOPE, + "effect · detached", + plasma_face(plasma_one_panel(), 3.1), + ) + .with_children(plasma_children(3.1)); + rsx! { + div { class: "tw:w-full tw:max-w-md", + NodePane { view, on_action: move |_| {} } + } + } +} + +#[story( + description = "Bus split, drawer half: the wiring drawer open on the root module — every channel in this scope with its writers and readers, i.e. exactly today's sidebar bus-pane content, now hung off the module that owns the scope. The panel above is the same bus presented for playing. Left as-is this round; the bus/wiring view gets its own UX spike." +)] +fn wiring_drawer_open() -> Element { + let mut face = held_root_face(); + face.wiring_open = true; + rsx! { + WorkspaceCanvas { + NodePane { + view: module_node_view("Aurora Sign", ROOT_SCOPE, "5 nodes · 2 effects", face), + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Nested panel groups (R8) as bordered clusters SIDE BY SIDE: two instances of one effect, each a light hairline box with its name in the top border, laid out in a wrapping flex row — function groups on hardware. plasma 1 is detached (amber label, amber reset in its border), plasma 2 still follows the host. Same channel names, different scopes, independent controls; the label carries the instance identity now that the path lives in its popup. No collapse — wrapping is the density mechanism." +)] +fn nested_groups() -> Element { + let mut face = held_root_face(); + face.preview = None; + face.wiring = None; + rsx! { + WorkspaceCanvas { + div { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + ModuleFace { face, on_action: move |_| {} } + } + } + } +} + +#[story( + description = "The workspace column with the sidebar bus pane GONE: the root module card heads the column and the bus lives on it — controls on the face, wiring in the drawer — with the project's nodes below it as the sibling cards they always were. Compare against studio/bus/bus-pane/fyeah-sign, which is what this replaces." +)] +fn workspace_no_bus_pane() -> Element { + let mut view = held_root_view(); + if let Some(lpa_studio_core::UiNodeFace::Module(face)) = view.face.as_mut() { + face.wiring_open = true; + } + rsx! { + div { class: "tw:grid tw:w-full tw:max-w-[860px] tw:gap-3.5 tw:bg-page tw:p-3", + NodePane { view, on_action: move |_| {} } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs new file mode 100644 index 000000000..22c9cc01a --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs @@ -0,0 +1,1021 @@ +//! Mock DTOs for the M2 UX spike. +//! +//! Every fixture here is hand-built. The engine has no scopes, no panel +//! writers, and no `PanelWrite`/`PanelClear` ops (M4 owns all three), so +//! the spike fakes the states the design docs describe and the stories walk +//! them. The content is not arbitrary: it reproduces the worked examples +//! from `docs/design/modules.md` so the gate is judging the real shapes. +//! +//! - **E3 (drop-in embed):** `plasma` embedded in a host that already has a +//! visual; the host's panel shows a plasma group. +//! - **E4 (shared control + detach):** one host `speed` control drives both +//! plasma instances; plasma_1 has been touched, so it is engaged and +//! detached while plasma_2 still follows the host. +//! - **E2 (playlist meta-switching):** two entries binding one `speed` +//! channel with different meta — 0–1 "Drift" vs 0–10 "Whirl". +//! - **The scarf (panel.md §3):** `brightness` held at a dim value while +//! the authored default is bright. + +use lpa_studio_core::{ + LpValue, ProjectNodeAddress, ProjectSlotAddress, ProjectSlotRoot, SlotEditOp, SlotPath, + UiAction, UiBusChannelView, UiBusSiteView, UiBusView, UiModuleFace, UiNodeChild, UiNodeFace, + UiNodeHeader, UiNodeSection, UiNodeView, UiPanelControl, UiPanelControlState, + UiPanelControlView, UiPanelGroup, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, + UiProducedProduct, UiProductPreviewFrame, UiProductTrackingState, UiSlotFieldState, + UiSlotValue, UiStatus, +}; + +use crate::app::node::face_story_fixtures::aurora_preview; + +use super::PanelGesture; + +/// The root module's scope path. Scope paths are node paths (§6), so the +/// root module's is the root itself. +pub(crate) const ROOT_SCOPE: &str = "/aurora.module"; +/// The two embedded plasma instances' scopes — different scopes is exactly +/// why their controls are independent (R8). +pub(crate) const PLASMA_1_SCOPE: &str = "/aurora.module/plasma_1.module"; +pub(crate) const PLASMA_2_SCOPE: &str = "/aurora.module/plasma_2.module"; + +/// A story-only slot address, so the widgets render wired and their drags +/// dispatch into the story's own handler. +fn spike_address(node: &str, slot: &str) -> ProjectSlotAddress { + ProjectSlotAddress::new( + ProjectNodeAddress::parse(node).expect("valid spike node address"), + ProjectSlotRoot::def(), + SlotPath::parse(slot).expect("valid spike slot path"), + ) +} + +/// One knob control on a panel. +fn knob( + scope: &str, + channel: &str, + label: &str, + value: f32, + min: f32, + max: f32, + step: Option, +) -> UiPanelControlView { + UiPanelControlView::new( + channel, + UiPanelControl { + label: label.to_string(), + address: Some(spike_address(scope, channel)), + widget: UiPanelWidget::Knob { min, max, step }, + value: UiSlotValue::f32(value), + live_value: None, + unit: None, + state: UiSlotFieldState::editable(), + aspects: Vec::new(), + }, + ) +} + +/// One horizontal fader control (the dominant brightness gesture). +fn fader(scope: &str, channel: &str, label: &str, value: f32, max: f32) -> UiPanelControlView { + UiPanelControlView::new( + channel, + UiPanelControl { + label: label.to_string(), + address: Some(spike_address(scope, channel)), + widget: UiPanelWidget::Fader { + min: 0.0, + max, + step: Some(1.0), + }, + value: UiSlotValue::f32(value), + live_value: None, + unit: None, + state: UiSlotFieldState::editable(), + aspects: Vec::new(), + }, + ) +} + +/// One pill toggle control. +fn toggle(scope: &str, channel: &str, label: &str, value: bool) -> UiPanelControlView { + UiPanelControlView::new( + channel, + UiPanelControl { + label: label.to_string(), + address: Some(spike_address(scope, channel)), + widget: UiPanelWidget::Toggle, + value: UiSlotValue::bool(value), + live_value: None, + unit: None, + state: UiSlotFieldState::editable(), + aspects: Vec::new(), + }, + ) +} + +/// Put a control in Read-following-automation, displaying `live`. +fn following(mut view: UiPanelControlView, live: &str, source: &str) -> UiPanelControlView { + view.control.live_value = Some(live.to_string()); + view.with_state(UiPanelControlState::ReadFollowing, Some(source)) +} + +/// Put a control in Engaged (Latch), noting what it displaced. +fn engaged(view: UiPanelControlView, displaced: &str) -> UiPanelControlView { + view.with_state(UiPanelControlState::Engaged, Some(displaced)) +} + +/// Put a control in Read-at-default with an explicit origin caption. +fn at_default(view: UiPanelControlView, source: &str) -> UiPanelControlView { + view.with_state(UiPanelControlState::ReadDefault, Some(source)) +} + +// ---------------------------------------------------------------- plasma + +/// One embedded plasma instance's panel. Both instances bind the same +/// channel names — they stay independent because they are different scopes +/// (R8), which the scope path on the group heading spells out. +fn plasma_panel(scope: &str, speed: UiPanelControlView) -> UiPanelGroup { + // The group's LABEL carries the instance identity now that the scope + // path has moved into the heading's detail popup — two copies of one + // effect have to be tellable apart from the rule alone. + UiPanelGroup::new(instance_label(scope), scope).with_controls(vec![ + speed, + at_default( + knob(scope, "hue", "hue", 0.32, 0.0, 1.0, None), + "authored default", + ), + ]) +} + +/// "plasma 1" from `/aurora.module/plasma_1.module` — the embedded node's +/// own name, which is what distinguishes two instances on a rule. +fn instance_label(scope: &str) -> String { + scope + .rsplit('/') + .next() + .and_then(|leaf| leaf.split('.').next()) + .map(|name| name.replace('_', " ")) + .unwrap_or_else(|| "module".to_string()) +} + +/// Either plasma instance's panel in its **Read** form: nothing touched, +/// so both walk outward and inherit the host's `speed` writer (R5). The two +/// instances are byte-identical apart from their scope — which is the +/// point: the only thing that makes plasma_1 different in the stories is +/// that somebody turned its knob. +pub(crate) fn plasma_read_panel(scope: &str) -> UiPanelGroup { + plasma_panel( + scope, + following( + knob(scope, "speed", "speed", 0.62, 0.0, 1.0, None), + "0.62", + "inherited · Aurora Sign", + ), + ) +} + +/// plasma_1's panel with its knob already touched — E4's detach, applied +/// through the same path a live touch takes. +pub(crate) fn plasma_one_panel() -> UiPanelGroup { + let mut panel = plasma_read_panel(PLASMA_1_SCOPE); + engage_group(&mut panel, PLASMA_1_SCOPE, "speed", 0.82); + panel +} + +/// An embedded plasma module's own face — the SAME component the root +/// wears, one level in. No `auto_save`: persistence is per project folder, +/// so only the root module presents that switch (P11). +pub(crate) fn plasma_face(panel: UiPanelGroup, seed: f32) -> UiModuleFace { + UiModuleFace { + preview: Some( + UiProducedProduct::visual("output") + .with_detail("mirrors visual.out") + .with_tracking(UiProductTrackingState::Tracking) + .with_frame(UiProductPreviewFrame::new(16, 5)) + .with_preview(aurora_preview(48, 15, seed)), + ), + panel, + wiring: Some(plasma_wiring()), + wiring_open: false, + provenance: Some("PhotomancerArt · v1.2 · CC0-1.0".to_string()), + auto_save: None, + } +} + +/// The plasma module's own children, as sibling cards below ITS card — the +/// nesting rail is the same one level down, which is the other half of the +/// "one face at every depth" claim. +pub(crate) fn plasma_children(seed: f32) -> Vec { + vec![ + product_child("sim", "Shader", "shader.json", "visual → bus:visual.out").with_sections( + vec![UiNodeSection::ProducedProducts(vec![ + UiProducedProduct::visual("output") + .with_tracking(UiProductTrackingState::Tracking) + .with_frame(UiProductPreviewFrame::new(16, 5)) + .with_preview(aurora_preview(48, 15, seed + 0.4)), + ])], + ), + ] +} + +/// The plasma scope's wiring: `time` and `speed` have no local writer, so +/// they resolve outward (R5); `visual.out` is written locally and published +/// up (R7). +fn plasma_wiring() -> UiBusView { + UiBusView { + channels: vec![ + channel( + "time", + "Instant", + Some("12.44"), + vec![], + vec![site("sim", "time")], + ), + channel( + "speed", + "Float", + Some("0.82"), + vec![site("panel", "held")], + vec![site("sim", "speed")], + ), + UiBusChannelView { + primary_visual: true, + ..channel( + "visual.out", + "Color", + Some("visual product #7:0"), + vec![site("sim", "visual")], + vec![site("plasma", "output")], + ) + }, + ], + } +} + +// ------------------------------------------------------------------ root + +/// The controls the stories start out **held**: the scarf's brightness in +/// the root scope, and plasma_1's speed in its own scope (E4's detach). +/// They are applied to the pristine Read face the same way a touch would +/// be, so clearing them lands back on exactly the Read form below. +pub(crate) const HELD: &[(&str, &str, f32)] = &[ + (ROOT_SCOPE, "brightness", 96.0), + (PLASMA_1_SCOPE, "speed", 0.82), +]; + +/// The root module's panel in its **Read** form: its own scope's channels, +/// plus each embedded module's panel as a nested group (R8). The two plasma +/// groups are two independent presentations of the same effect. +pub(crate) fn root_panel() -> UiPanelGroup { + UiPanelGroup::new("Aurora Sign", ROOT_SCOPE) + .with_controls(root_controls()) + .with_groups(vec![ + plasma_read_panel(PLASMA_1_SCOPE), + plasma_read_panel(PLASMA_2_SCOPE), + ]) +} + +/// The root scope's own channels, all in Read. +pub(crate) fn root_controls() -> Vec { + vec![ + // The scarf (panel.md §3): authored bright; the stories hold it + // dim, and P10 says it must come back dim without one bright frame. + at_default( + fader(ROOT_SCOPE, "brightness", "brightness", 200.0, 255.0), + "authored 200", + ), + // E4's host control: an authored control node writes this channel, + // so the panel control follows it until someone grabs it. + following( + knob(ROOT_SCOPE, "speed", "speed", 0.62, 0.0, 1.0, None), + "0.62", + "control · Master speed", + ), + // "Grab the LFO" (panel.md §3): the knob visibly rides the LFO. + following( + knob(ROOT_SCOPE, "hue", "hue", 0.41, 0.0, 1.0, None), + "0.41", + "lfo · hue", + ), + // A stepped channel: squared blocks, no ticks (knob v2 rule). + at_default( + knob(ROOT_SCOPE, "palette", "palette", 2.0, 1.0, 4.0, Some(1.0)), + "authored default", + ), + // R6: no writer anywhere — the channel lists anyway, as an + // invitation. + at_default( + toggle(ROOT_SCOPE, "mirror", "mirror", false), + "no writer yet", + ), + ] +} + +/// The root module's face with the story's held controls already engaged — +/// what the panel stories render, and what [`PanelSpike`] clears back from. +pub(crate) fn held_root_face() -> UiModuleFace { + let Some(UiNodeFace::Module(face)) = held_root_view().face else { + unreachable!("the root module view wears a module face") + }; + face +} + +/// The root module's face in its pristine Read form. +pub(crate) fn root_face() -> UiModuleFace { + UiModuleFace { + preview: Some( + UiProducedProduct::visual("output") + .with_detail("256 x 256 · mirrors visual.out") + .with_tracking(UiProductTrackingState::Tracking) + .with_frame(UiProductPreviewFrame::new(16, 7)) + .with_preview(aurora_preview(48, 21, 0.0)), + ), + panel: root_panel(), + wiring: Some(root_wiring()), + wiring_open: false, + provenance: Some("Yona · v0.4 · created 2026-07-31".to_string()), + // The project root owns panel persistence (P11). + auto_save: Some(true), + } +} + +/// The root module's children, as sibling cards BELOW its card: two leaves +/// that write host channels, the two embedded plasma modules (each with its +/// own child), and the fixture. All of them — a module's children are +/// collaborators, so there is no active-child filtering the way a playlist +/// has. +pub(crate) fn root_children() -> Vec { + vec![ + plain_child("clock", "Clock", "clock.json", "seconds → bus:time"), + // A control node: its bound slot IS its face (R3), and it is the + // same channel the panel's `speed` knob presents. + controls_child( + "Master speed", + "Button", + "button.json", + "value → bus:speed", + UiPanelGroup::new("Master speed", ROOT_SCOPE).with_controls(vec![following( + knob(ROOT_SCOPE, "speed", "value", 0.62, 0.0, 1.0, None), + "0.62", + "this node writes it", + )]), + ), + module_child( + "plasma_1", + PLASMA_1_SCOPE, + "effect", + plasma_face(plasma_read_panel(PLASMA_1_SCOPE), 3.1), + plasma_children(3.1), + ), + module_child( + "plasma_2", + PLASMA_2_SCOPE, + "effect", + plasma_face(plasma_read_panel(PLASMA_2_SCOPE), 6.5), + plasma_children(6.5), + ), + // The fixture's brightness is the SAME (scope, channel) as the + // panel's — one control, two views (panel.md P1). Holding it on the + // panel holds it here too, which is the multi-client rule made + // visible across two cards in one column. + controls_child( + "halo", + "Fixture", + "fixture.json", + "241 LEDs · input ← bus:visual.out", + UiPanelGroup::new("halo", ROOT_SCOPE).with_controls(vec![at_default( + fader(ROOT_SCOPE, "brightness", "brightness", 200.0, 255.0), + "authored 200", + )]), + ), + ] +} + +/// A child card with no face at all — a node whose whole story is its +/// header row (the clock). +fn plain_child(name: &str, kind: &str, detail: &str, summary: &str) -> UiNodeChild { + let mut child = UiNodeChild::new(name, kind, detail); + child.summary = Some(summary.to_string()); + child.status = UiStatus::good("Running"); + child +} + +/// A child card whose face is a product preview (the plasma shader). +fn product_child(name: &str, kind: &str, detail: &str, summary: &str) -> UiNodeChild { + plain_child(name, kind, detail, summary) +} + +/// A leaf child card whose face is exactly its bound controls (R3). The +/// group carries the ENCLOSING scope — a leaf introduces none of its own. +fn controls_child( + name: &str, + kind: &str, + detail: &str, + summary: &str, + controls: UiPanelGroup, +) -> UiNodeChild { + let mut child = plain_child(name, kind, detail, summary); + child.face = Some(UiNodeFace::Controls(controls)); + child +} + +/// A child module card: the same module face one level in, with its own +/// children hanging below it on the same rail. +fn module_child( + name: &str, + scope: &str, + summary: &str, + face: UiModuleFace, + children: Vec, +) -> UiNodeChild { + let mut child = plain_child(name, "Module", scope, summary); + child.face = Some(UiNodeFace::Module(face)); + child.children = children; + child +} + +/// The root scope's wiring — what the sidebar bus pane used to show, now +/// hung off the module that owns the scope. +pub(crate) fn root_wiring() -> UiBusView { + UiBusView { + channels: vec![ + channel( + "time", + "Instant", + Some("12.44"), + vec![site("clock", "seconds")], + vec![site("plasma_1", "time"), site("plasma_2", "time")], + ), + channel( + "speed", + "Float", + Some("0.62"), + vec![site("Master speed", "value")], + vec![site("plasma_2", "speed")], + ), + channel( + "hue", + "Float", + Some("0.41"), + vec![site("hue lfo", "out")], + vec![site("plasma_1", "hue")], + ), + channel( + "brightness", + "Float", + Some("96"), + vec![site("panel", "held")], + vec![site("halo", "brightness")], + ), + UiBusChannelView { + primary_visual: true, + ..channel( + "visual.out", + "Color", + Some("visual product #5:0"), + vec![site("plasma_1", "publish")], + vec![site("halo", "input")], + ) + }, + ], + } +} + +/// One bus channel row for the wiring drawer. +fn channel( + name: &str, + kind: &str, + value: Option<&str>, + writers: Vec, + readers: Vec, +) -> UiBusChannelView { + UiBusChannelView { + name: name.to_string(), + kind: Some(kind.to_string()), + value: value.map(str::to_string), + value_error: (value.is_none()).then(|| "no writer in any enclosing scope".to_string()), + primary_visual: false, + writers, + readers, + } +} + +/// One writer/reader site. +fn site(node_label: &str, slot: &str) -> UiBusSiteView { + UiBusSiteView { + node_label: node_label.to_string(), + slot: Some(slot.to_string()), + default_origin: false, + focus: None, + } +} + +/// The root module as a node card view with its children below it — the +/// single top-level workspace card (the flat-root reversal, §5) and the +/// column of sibling cards it heads. +pub(crate) fn root_module_node_view() -> UiNodeView { + module_node_view( + "Aurora Sign", + ROOT_SCOPE, + "5 nodes · 2 effects", + root_face(), + ) + .with_children(root_children()) +} + +/// The root module view with the story's held controls already engaged. +pub(crate) fn held_root_view() -> UiNodeView { + PanelSpike::new(root_module_node_view()) + .with_held(HELD) + .view +} + +/// Any module as a node card view, so the spike wears the real card chrome +/// (header, kind, collapse) instead of a mock frame. +pub(crate) fn module_node_view( + name: &str, + path: &str, + summary: &str, + face: UiModuleFace, +) -> UiNodeView { + let header = UiNodeHeader::new(name, "Module", path) + .with_source("module.json") + .with_status(UiStatus::good("Running")) + .with_summary(summary); + let mut view = UiNodeView::new(header, Vec::new()).with_node_id(path); + view.face = Some(UiNodeFace::Module(face)); + view +} + +// -------------------------------------------------------------- playlist + +/// The E2 playlist: two entries binding one `speed` channel with different +/// meta. Each entry is its own sink scope, so their panel state never +/// mixes. +pub(crate) fn playlist_face() -> UiPlaylistFace { + UiPlaylistFace { + entries: vec![ + UiPlaylistEntry { + key: 0, + name: "Drift".to_string(), + duration_ms: Some(180_000), + cue: false, + thumb: Some(aurora_preview(18, 10, 3.1)), + action: None, + }, + UiPlaylistEntry { + key: 1, + name: "Whirl".to_string(), + duration_ms: Some(240_000), + cue: false, + thumb: Some(aurora_preview(18, 10, 6.5)), + action: None, + }, + ], + active: Some(0), + } +} + +/// The active entry's sink scope path. Sink scopes are anonymous in the +/// model (R2); the spike names them after the entry's own child node so +/// they are legible on screen and parse as node paths. +pub(crate) fn entry_scope(entry: u32) -> String { + let child = if entry == 0 { "drift" } else { "whirl" }; + format!("/aurora.module/set.playlist/{child}.shader") +} + +/// The active entry's panel. The control is re-derived from whichever slot +/// is bound in the ACTIVE sink scope (R9), so switching entries swaps the +/// label, the range, and the widget's whole feel — same channel name, +/// different control. +pub(crate) fn entry_panel(entry: u32) -> UiPanelGroup { + let scope = entry_scope(entry); + let (label, value, max) = match entry { + 0 => ("Drift", 0.5_f32, 1.0_f32), + _ => ("Whirl", 6.5_f32, 10.0_f32), + }; + UiPanelGroup::new(label, scope.clone()).with_controls(vec![at_default( + knob(&scope, "speed", label, value, 0.0, max, None), + "authored default", + )]) +} + +/// Entry A's panel with its knob already held at 0.35 — the tweak that has +/// to survive switching to Whirl and back, because the two entries are two +/// sink scopes and therefore two identities (P1). +pub(crate) fn entry_held_panel(entry: u32) -> UiPanelGroup { + let mut panel = entry_panel(entry); + if entry == 0 { + engage_group(&mut panel, &entry_scope(entry), "speed", 0.35); + } + panel +} + +// ---------------------------------------------------- three-state fixture + +/// One panel holding exactly the three states, side by side, for the +/// P-Q2 comparison. +pub(crate) fn three_state_panel() -> UiPanelGroup { + UiPanelGroup::new("Panel states", ROOT_SCOPE).with_controls(vec![ + at_default( + knob( + ROOT_SCOPE, + "palette", + "at default", + 2.0, + 1.0, + 4.0, + Some(1.0), + ), + "nothing writes it", + ), + following( + knob(ROOT_SCOPE, "hue", "following", 0.41, 0.0, 1.0, None), + "0.41", + "lfo · hue", + ), + engaged( + knob(ROOT_SCOPE, "speed", "engaged", 0.82, 0.0, 1.0, None), + "lfo · speed", + ), + at_default( + toggle(ROOT_SCOPE, "mirror", "at default", false), + "nothing writes it", + ), + { + let mut view = toggle(ROOT_SCOPE, "beat", "following", false); + view.control.live_value = Some("true".to_string()); + view.with_state(UiPanelControlState::ReadFollowing, Some("audio · beat")) + }, + engaged( + toggle(ROOT_SCOPE, "strobe", "engaged", true), + "audio · beat", + ), + at_default( + fader(ROOT_SCOPE, "brightness", "at default", 200.0, 255.0), + "nothing writes it", + ), + following( + fader(ROOT_SCOPE, "master", "following", 180.0, 255.0), + "180", + "dimmer · out", + ), + engaged( + fader(ROOT_SCOPE, "held", "engaged", 96.0, 255.0), + "authored 200", + ), + ]) +} + +// -------------------------------------------------------- walkable state + +/// Live fixture state for the walkable stories. +/// +/// The dev-server walk is the point of the spike: turning a knob must +/// actually engage it, and resetting must actually let go. This holds the +/// mock node view — the card's face AND the sibling child cards below it — +/// plus a pristine baseline, applies the widget's own +/// `SlotEditOp::SetValue` dispatch as a panel write, and applies +/// [`PanelGesture`]s as clears. +/// +/// The whole view is the unit rather than the face, because a control's +/// identity is `(scope, channel)` (panel.md P1) and the same control now +/// genuinely appears on two different cards — the module's panel, and the +/// leaf child's own face below it. Holding one has to move the other, and +/// that only works if the walk covers the tree. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PanelSpike { + /// The node view the stories render. + pub view: UiNodeView, + /// The untouched view, for restoring cleared controls. + baseline: UiNodeView, +} + +impl PanelSpike { + /// Wrap a **Read-form** node view as walkable spike state. The view + /// passed in becomes the clear-target, so it must have nothing engaged. + pub fn new(view: UiNodeView) -> Self { + Self { + baseline: view.clone(), + view, + } + } + + /// The root module's face, for the panel-only stories. + pub fn face(&self) -> UiModuleFace { + let Some(UiNodeFace::Module(face)) = self.view.face.clone() else { + unreachable!("the spike's root view wears a module face") + }; + face + } + + /// Pre-engage controls, exactly as if they had been touched — same + /// transition a live drag makes, so what a reset lands on is the same + /// Read form either way. + pub fn with_held(mut self, held: &[(&str, &str, f32)]) -> Self { + for (scope, channel, value) in held { + let (scope, channel, value) = (*scope, *channel, *value); + visit_view_controls(&mut self.view, &mut |control_scope, control| { + if control_scope == scope && control.channel == channel { + hold(control, value); + } + }); + } + self + } + + /// Apply a widget dispatch: the first touch of a control materializes + /// its panel writer and captures the channel (panel.md P2 — Latch, not + /// Touch), and every later drag just moves the held value. + pub fn apply_action(&mut self, action: &UiAction) { + let Some(SlotEditOp::SetValue { address, value }) = action.op_as::() else { + return; + }; + let (address, value) = (address.clone(), value.clone()); + visit_view_controls(&mut self.view, &mut |_, view| { + if view.control.address.as_ref() != Some(&address) { + return; + } + view.control.value = match &value { + LpValue::Bool(next) => UiSlotValue::bool(*next), + LpValue::F32(next) => UiSlotValue::f32(*next), + _ => view.control.value.clone(), + }; + if !view.state.engaged() { + // First touch: the writer materializes here, in the scope + // where the control lives, and shadows whatever was + // driving the channel. + view.source = view.source.clone().or_else(|| Some("project".to_string())); + view.state = UiPanelControlState::Engaged; + } + }); + } + + /// Apply a panel gesture: clears restore Read, disclosure toggles the + /// group, and auto-save flips the persistence flag. + pub fn apply_gesture(&mut self, gesture: &PanelGesture) { + match gesture { + PanelGesture::SetAutoSave(next) => { + if let Some(UiNodeFace::Module(face)) = self.view.face.as_mut() { + face.auto_save = Some(*next); + } + } + PanelGesture::ClearControl { scope, channel } => { + self.restore(|group_scope, view| group_scope == scope && view.channel == *channel); + } + // "Reset means reset": the clear descends into nested groups + // (panel.md P-Q4's lean), which is why a scope prefix match is + // the right test rather than equality. + PanelGesture::ClearScope { scope } => { + self.restore(|group_scope, _| group_scope.starts_with(scope.as_str())); + } + } + } + + /// Copy matching controls back from the pristine baseline. + fn restore(&mut self, matches: impl Fn(&str, &UiPanelControlView) -> bool) { + let mut pristine = Vec::new(); + visit_view_controls(&mut self.baseline.clone(), &mut |scope, view| { + pristine.push((scope.to_string(), view.clone())); + }); + visit_view_controls(&mut self.view, &mut |scope, view| { + if !matches(scope, view) { + return; + } + if let Some((_, original)) = pristine.iter().find(|(other, candidate)| { + other.as_str() == scope && candidate.channel == view.channel + }) { + *view = original.clone(); + } + }); + } +} + +/// Engage one control everywhere it appears within one panel group tree. +/// The same `(scope, channel)` is ONE control (panel.md P1). +fn engage_group(group: &mut UiPanelGroup, scope: &str, channel: &str, value: f32) { + visit_group_controls(group, &mut |control_scope, view| { + if control_scope == scope && view.channel == channel { + hold(view, value); + } + }); +} + +/// Materialize one control's panel writer at `value` — the Read → Latch +/// transition (P2). The Read caption is kept, because that is what the +/// engaged control displaced and what a clear restores. +fn hold(view: &mut UiPanelControlView, value: f32) { + view.control.value = UiSlotValue::f32(value); + view.state = UiPanelControlState::Engaged; +} + +/// Visit every panel control in a card and everything below it, carrying +/// each control's owning scope. +fn visit_view_controls( + view: &mut UiNodeView, + visit: &mut impl FnMut(&str, &mut UiPanelControlView), +) { + visit_face_controls(view.face.as_mut(), visit); + for child in &mut view.children { + visit_child_controls(child, visit); + } +} + +/// The same walk, for a child card and its own children. +fn visit_child_controls( + child: &mut UiNodeChild, + visit: &mut impl FnMut(&str, &mut UiPanelControlView), +) { + visit_face_controls(child.face.as_mut(), visit); + for nested in &mut child.children { + visit_child_controls(nested, visit); + } +} + +/// The panel a face owns, if it owns one: a module's panel, or a leaf's +/// bound controls. Every other kind of face has none. +fn face_panel(face: Option<&mut UiNodeFace>) -> Option<&mut UiPanelGroup> { + match face? { + UiNodeFace::Module(module) => Some(&mut module.panel), + UiNodeFace::Controls(group) => Some(group), + _ => None, + } +} + +/// Walk one face's panel, nested groups included. +fn visit_face_controls( + face: Option<&mut UiNodeFace>, + visit: &mut impl FnMut(&str, &mut UiPanelControlView), +) { + if let Some(panel) = face_panel(face) { + visit_group_controls(panel, visit); + } +} + +/// Walk one group and its nested groups. +fn visit_group_controls( + group: &mut UiPanelGroup, + visit: &mut impl FnMut(&str, &mut UiPanelControlView), +) { + let scope = group.scope.clone(); + for view in &mut group.controls { + visit(&scope, view); + } + for nested in &mut group.groups { + visit_group_controls(nested, visit); + } +} + +/// Visit every panel group in a card and everything below it. +fn visit_view_groups(view: &mut UiNodeView, visit: &mut impl FnMut(&mut UiPanelGroup)) { + if let Some(panel) = face_panel(view.face.as_mut()) { + visit_group(panel, visit); + } + for child in &mut view.children { + visit_child_groups(child, visit); + } +} + +/// The same walk, for a child card and its own children. +fn visit_child_groups(child: &mut UiNodeChild, visit: &mut impl FnMut(&mut UiPanelGroup)) { + if let Some(panel) = face_panel(child.face.as_mut()) { + visit_group(panel, visit); + } + for nested in &mut child.children { + visit_child_groups(nested, visit); + } +} + +/// Walk one group and its nested groups. +fn visit_group(group: &mut UiPanelGroup, visit: &mut impl FnMut(&mut UiPanelGroup)) { + visit(group); + for nested in &mut group.groups { + visit_group(nested, visit); + } +} + +#[cfg(test)] +mod tests { + use lpa_studio_core::{UiNodeFace, UiPanelControlState}; + + use super::{ + HELD, PLASMA_1_SCOPE, PLASMA_2_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, + root_module_node_view, + }; + use crate::app::module::PanelGesture; + + fn spike() -> PanelSpike { + PanelSpike::new(root_module_node_view()).with_held(HELD) + } + + #[test] + fn the_two_plasma_instances_are_independent_groups() { + let panel = held_root_face().panel; + assert_eq!(panel.groups.len(), 2); + assert_eq!(panel.groups[0].scope, PLASMA_1_SCOPE); + assert_eq!(panel.groups[1].scope, PLASMA_2_SCOPE); + // E4: plasma_1 was touched and detached; plasma_2 still follows, + // even though the two groups are otherwise identical. + assert_eq!(panel.groups[0].engaged_total(), 1); + assert_eq!(panel.groups[1].engaged_total(), 0); + assert_eq!( + panel.groups[1].controls[0].state, + UiPanelControlState::ReadFollowing + ); + } + + #[test] + fn clearing_a_scope_descends_into_its_nested_groups() { + let mut spike = spike(); + // brightness (root) + plasma_1's speed are held. + assert_eq!(spike.face().panel.engaged_total(), 2); + + spike.apply_gesture(&PanelGesture::ClearScope { + scope: ROOT_SCOPE.to_string(), + }); + + assert_eq!( + spike.face().panel.engaged_total(), + 0, + "reset means reset — the nested plasma writer goes too" + ); + } + + #[test] + fn clearing_one_control_leaves_its_siblings_alone() { + let mut spike = spike(); + spike.apply_gesture(&PanelGesture::ClearControl { + scope: PLASMA_1_SCOPE.to_string(), + channel: "speed".to_string(), + }); + + assert_eq!(spike.face().panel.groups[0].engaged_total(), 0); + assert_eq!( + spike.face().panel.engaged_here(), + 1, + "the root's own held brightness is untouched" + ); + // Clearing restores the Read form, not just the state flag: the + // control falls back into whatever was driving the channel. + assert_eq!( + spike.face().panel.groups[0].controls[0].state, + UiPanelControlState::ReadFollowing + ); + } + + #[test] + fn auto_save_is_a_view_gesture() { + let mut spike = spike(); + assert_eq!(spike.face().auto_save, Some(true)); + spike.apply_gesture(&PanelGesture::SetAutoSave(false)); + assert_eq!(spike.face().auto_save, Some(false)); + } + + /// Groups are bordered clusters in a wrapping row, never folded away — + /// so their labels have to carry the instance identity that the scope + /// path used to. + #[test] + fn side_by_side_groups_are_told_apart_by_their_labels() { + let panel = held_root_face().panel; + assert_eq!(panel.groups[0].label, "plasma 1"); + assert_eq!(panel.groups[1].label, "plasma 2"); + assert_ne!(panel.groups[0].label, panel.groups[1].label); + // And the path is still reachable — in the heading's popup. + let aspects = panel.groups[0].detail_aspects(); + assert!( + aspects + .iter() + .any(|aspect| aspect.rows.iter().any(|row| row.value == PLASMA_1_SCOPE)) + ); + } + + /// The G2 revision-1 claim: children are sibling cards under the module + /// card, not sections inside its face — and every one of them renders, + /// with no active-child filtering the way a playlist has. + #[test] + fn children_hang_below_the_card_not_inside_the_face() { + let view = root_module_node_view(); + assert_eq!(view.children.len(), 5); + // An embedded module keeps its own children on the same rail, one + // level down: the nesting grammar does not change with depth. + let plasma = &view.children[2]; + assert_eq!(plasma.label, "plasma_1"); + assert!(matches!(plasma.face, Some(UiNodeFace::Module(_)))); + assert_eq!(plasma.children.len(), 1); + } + + /// A leaf's controls and the module panel's are ONE control (P1), even + /// though they now live on two different cards: holding it on the panel + /// has to move the fixture card below. + #[test] + fn one_control_two_cards_move_together() { + let spike = spike(); + let Some(UiNodeFace::Controls(halo)) = &spike.view.children[4].face else { + panic!("the fixture child wears a controls face"); + }; + assert_eq!(halo.controls[0].channel, "brightness"); + assert_eq!(halo.controls[0].state, UiPanelControlState::Engaged); + assert_eq!( + halo.controls[0].control.value.display, + spike.face().panel.controls[0].control.value.display, + "the panel and the fixture card show the same held value" + ); + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/module_panel.rs b/lp-app/lpa-studio-web/src/app/module/module_panel.rs new file mode 100644 index 000000000..242384c3e --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -0,0 +1,326 @@ +//! A module's **panel**: this scope's channels, plus each child module's +//! panel as a nested group. +//! +//! `docs/design/modules.md` R8. The recursion is presentation only — +//! nothing is promoted, and two embedded instances of one effect present +//! two independent groups because they are two different scopes. A nested +//! group is a **hairline rule with its name on it**, not a box: boxes stack +//! into noise on a surface that recurses, and a control panel wants one +//! flat field of controls with quiet dividers. The root group is never +//! collapsed (it *is* the panel). +//! +//! The two module-level affordances are **quiet chrome in the panel's +//! upper right**, not chips in the content flow — the controls are the +//! subject, and these are the settings that sit beside them: +//! +//! - **Reset panel** (`docs/design/panel.md` P2 clear at scope +//! granularity): the revert glyph alone, with the count as a small +//! superscript and the full sentence in its tooltip. Present only while +//! something under this scope is held, so an untouched panel shows no +//! destructive control at all. +//! - **Auto-save** (P11 — on by default, with a user toggle): a small +//! switch. It sits on the module that owns the scope rather than in app +//! settings, because panel state is per project folder +//! (`.lp/state.json`) and this is the surface where it is produced. +//! +//! A nested group's reset wears the same small-icon treatment, on the right +//! of its heading row. + +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiPanelGroup}; + +use crate::app::node::SlotDetailButton; +use crate::base::{PopoverPlacement, StudioIcon, StudioIconName}; + +use super::{ModulePanelControl, PanelGesture}; + +/// Bare text button for a nested group's name-as-detail-trigger. +const GROUP_LABEL_TRIGGER_CLASS: &str = "tw:inline-flex tw:min-w-0 tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:border-0 tw:bg-transparent tw:p-0 tw:leading-none tw:decoration-dotted tw:underline-offset-2 tw:hover:underline"; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ModulePanel( + /// The scope's panel. + panel: UiPanelGroup, + /// Panel-state auto-save (P11); `None` hides the toggle (nested groups + /// and play mode do not repeat it). + #[props(default = None)] + auto_save: Option, + /// Roomier play-mode rendering: bigger gaps, no authoring chrome. + #[props(default = false)] + play: bool, + /// Pin this channel's control detail popup open on first render + /// (stories) — the popup is where every caption went, so it has to be + /// capturable. + #[props(default = None)] + detail_open_channel: Option, + /// Pin this nested group's heading popup open on first render + /// (stories), keyed by scope. + #[props(default = None)] + detail_open_group: Option, + #[props(default = None)] on_panel: Option>, + #[props(default)] on_action: Option>, +) -> Element { + if panel.is_empty() { + return rsx! { + div { class: "tw:grid tw:gap-1 tw:px-4 tw:py-3", + p { class: "tw:m-0 tw:text-sm tw:text-subtle-foreground", "No public channels yet." } + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-dim-foreground", + "Binding a slot to " + code { class: "tw:font-mono", "bus:…" } + " is what makes it public — and a control appears here." + } + } + }; + } + + let held = panel.engaged_total(); + let scope = panel.scope.clone(); + let row_gap = if play { "tw:gap-6" } else { "tw:gap-4" }; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-3 tw:px-4 tw:py-3", + // Module-level affordances, upper right: quiet chrome beside + // the controls, never competing with them. Reset only exists + // when there is something to clear. + if held > 0 || auto_save.is_some() { + div { class: "tw:flex tw:min-w-0 tw:items-center tw:justify-end tw:gap-1.5", + if held > 0 && let Some(handler) = on_panel { + PanelResetButton { scope: scope.clone(), held, on_panel: handler } + } + if let Some(auto_save) = auto_save { + AutoSaveToggle { auto_save, on_panel } + } + } + } + if !panel.controls.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start {row_gap}", + for control in panel.controls.clone() { + ModulePanelControl { + key: "{control.channel}", + detail_initially_open: detail_open_channel.as_deref() == Some(control.channel.as_str()), + view: control, + scope: scope.clone(), + play, + on_panel, + on_action, + } + } + } + } + // Groups lay out as a wrapping ROW of bordered clusters — two + // effect boxes side by side when the width allows, stacking + // when it does not. That side-by-side reading is the "actual + // control panel" image: function groups on hardware. + if !panel.groups.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start tw:gap-3", + for group in panel.groups.clone() { + NestedPanelGroup { + key: "{group.scope}", + detail_initially_open: detail_open_group.as_deref() == Some(group.scope.as_str()), + group, + play, + on_panel, + on_action, + } + } + } + } + } + } +} + +/// One embedded module's panel inside its host's panel (R8 recursion). +/// +/// A **light hairline box with its name embedded in the top border** — +/// a real `
`/``, so the notch in the border is the +/// browser's, not a stack of CSS tricks. That keeps the `— PLASMA 1 —` +/// reading while making the group a bordered *cluster*: boxes of related +/// controls sitting side by side is what a hardware control panel looks +/// like, and [`ModulePanel`] lays the groups out in a wrapping flex row to +/// get exactly that. +/// +/// **No collapse.** Groups render always-open; wrapping is the density +/// mechanism, not disclosure. A panel you have to unfold before you can +/// play it is not a control panel. +/// +/// The label is the detail trigger — the scope path and the control tally +/// live behind it, in the same popup language the controls use — and the +/// group's reset sits in the border area beside the name, where it reads as +/// belonging to the box rather than to any control inside it. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn NestedPanelGroup( + group: UiPanelGroup, + #[props(default = false)] play: bool, + #[props(default = None)] on_panel: Option>, + /// Open the group label's detail popup on first render (stories). + #[props(default = false)] + detail_initially_open: bool, + #[props(default)] on_action: Option>, +) -> Element { + let held = group.engaged_total(); + let aspects = group.detail_aspects(); + let scope = group.scope.clone(); + let label = group.label.clone(); + let heading_class = if held > 0 { + "tw:truncate tw:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-status-attention-foreground" + } else { + "tw:truncate tw:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-subtle-foreground" + }; + let control_gap = if play { "tw:gap-5" } else { "tw:gap-4" }; + // A box is a cluster, not a column: it takes its share of the row and + // wraps whole rather than squeezing its controls. + let box_class = if play { + "tw:m-0 tw:min-w-0 tw:flex-auto tw:basis-[240px] tw:rounded-sm tw:border tw:border-border-muted tw:px-3 tw:pb-3" + } else { + "tw:m-0 tw:min-w-0 tw:flex-auto tw:basis-[220px] tw:rounded-sm tw:border tw:border-border-muted tw:px-3 tw:pb-2" + }; + + rsx! { + fieldset { class: box_class, + // The legend rides IN the top border — the name and, beside it, + // the box's own reset. Constant height whether or not the reset + // button is present, so sibling boxes in one row stay level. + legend { class: "tw:flex tw:h-5 tw:min-w-0 tw:items-center tw:gap-1.5 tw:px-1.5", + SlotDetailButton { + label: label.clone(), + aspects, + name_override: Some(label.clone()), + initially_open: detail_initially_open, + placement: PopoverPlacement::BottomMiddle, + trigger: Some(rsx! { + span { class: "tw:inline-flex tw:min-w-0 tw:items-center tw:gap-1", + span { class: heading_class, "{label}" } + span { class: "tw:inline-flex tw:flex-none tw:opacity-50", + StudioIcon { name: StudioIconName::InfoBare, size: 9 } + } + } + }), + trigger_class: GROUP_LABEL_TRIGGER_CLASS.to_string(), + trigger_open_class: GROUP_LABEL_TRIGGER_CLASS.to_string(), + on_action, + } + if held > 0 && let Some(handler) = on_panel { + PanelResetButton { scope: scope.clone(), held, on_panel: handler } + } + } + div { class: "tw:grid tw:min-w-0 tw:gap-3", + if !group.controls.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start {control_gap}", + for control in group.controls.clone() { + ModulePanelControl { + key: "{control.channel}", + view: control, + scope: scope.clone(), + play, + on_panel, + on_action, + } + } + } + } + // A group inside a group: the same box one level in, laid + // out in its own wrapping row. + if !group.groups.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start tw:gap-3", + for nested in group.groups.clone() { + NestedPanelGroup { + key: "{nested.scope}", + group: nested, + play, + on_panel, + on_action, + } + } + } + } + } + } + } +} + +/// The per-scope clear (P2), as a small icon button: the revert glyph +/// alone, amber to match the state it removes, with the count as a tiny +/// superscript. It appears only when there is a writer to remove, so the +/// glyph's mere presence is part of the state signal — and the tooltip +/// carries the whole sentence, so the chrome does not have to. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn PanelResetButton(scope: String, held: usize, on_panel: EventHandler) -> Element { + let noun = if held == 1 { "control" } else { "controls" }; + let label = format!( + "Reset {held} held {noun} in {scope} — panel writers are dropped and the project drives again" + ); + rsx! { + button { + class: "tw:inline-flex tw:h-5 tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-start tw:gap-px tw:rounded-xs tw:border-0 tw:bg-transparent tw:px-1 tw:py-0.5 tw:text-status-attention-foreground tw:opacity-70 tw:hover:opacity-100", + r#type: "button", + title: "{label}", + aria_label: "{label}", + onclick: move |event| { + event.stop_propagation(); + on_panel.call(PanelGesture::ClearScope { scope: scope.clone() }); + }, + StudioIcon { name: StudioIconName::Revert, size: 11 } + // The count, small enough to read as an annotation on the + // glyph rather than as a chip of its own. + span { class: "tw:font-mono tw:text-[0.5rem] tw:leading-none", "{held}" } + } + } +} + +/// The P11 auto-save toggle: whether held values persist to +/// `.lp/state.json` and come back on the next boot. A small switch — +/// a track with a knob, the same language the panel's own toggle control +/// speaks, at chrome size. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn AutoSaveToggle( + auto_save: bool, + #[props(default = None)] on_panel: Option>, +) -> Element { + let track_class = if auto_save { + "tw:relative tw:inline-flex tw:h-[11px] tw:w-[20px] tw:flex-none tw:items-center tw:rounded-full tw:border tw:border-border-strong tw:bg-card-raised" + } else { + "tw:relative tw:inline-flex tw:h-[11px] tw:w-[20px] tw:flex-none tw:items-center tw:rounded-full tw:border tw:border-border-muted tw:bg-transparent" + }; + let knob_class = if auto_save { + "tw:absolute tw:left-[9px] tw:h-[7px] tw:w-[7px] tw:rounded-full tw:bg-muted-foreground" + } else { + "tw:absolute tw:left-[1px] tw:h-[7px] tw:w-[7px] tw:rounded-full tw:bg-dim-foreground" + }; + let title = if auto_save { + "Auto-save on — panel settings are saved and restored on boot (.lp/state.json)" + } else { + "Auto-save off — panel settings are NOT saved, and are lost on restart" + }; + let icon_class = if auto_save { + "tw:inline-flex tw:flex-none tw:text-subtle-foreground" + } else { + "tw:inline-flex tw:flex-none tw:text-dim-foreground" + }; + + rsx! { + button { + class: "tw:inline-flex tw:h-5 tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1 tw:rounded-xs tw:border-0 tw:bg-transparent tw:px-1", + r#type: "button", + role: "switch", + aria_checked: "{auto_save}", + aria_label: "Auto-save panel settings", + title: "{title}", + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_panel { + handler.call(PanelGesture::SetAutoSave(!auto_save)); + } + }, + span { class: icon_class, + StudioIcon { name: StudioIconName::Save, size: 11 } + } + span { class: track_class, + span { class: knob_class } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/module_panel_control.rs b/lp-app/lpa-studio-web/src/app/module/module_panel_control.rs new file mode 100644 index 000000000..853cfc251 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_panel_control.rs @@ -0,0 +1,463 @@ +//! One control on a module's panel, wearing its panel state. +//! +//! **A control is a widget, a label, and a value. Nothing else.** This is a +//! control panel, not a spec sheet: the provenance captions that used to +//! sit under every control ("control · Master speed", "held · was inherited +//! · …", "authored default") are gone from the face and live in the +//! control's detail popup instead, behind its label. +//! +//! State is carried entirely by color and by the presence of the reset +//! glyph — the three states `docs/design/panel.md` P-Q2 requires to be +//! visibly distinct: +//! +//! | state | widget + label + value | reset glyph | +//! |---|---|---| +//! | Read, at default | accent arc at the authored default, subtle label | absent | +//! | Read, following | **violet** arc at the LIVE value, violet label | absent | +//! | Engaged (Latch) | **amber** arc + body ring, amber label | present | +//! +//! Amber (the `status-attention` family) is the spike's engaged proposal: +//! it is the one warm family Studio already owns, it is not violet (bound +//! means *wired*, engaged means *captured* — P6), not green (valid only), +//! and not the blue live family (transient edits). A dedicated +//! `status-engaged` token family is the eventual home. +//! +//! The **label is the detail trigger**, reusing the node face's +//! [`SlotDetailButton`] machinery verbatim: the whole control is the +//! popover's outline anchor, so opening merges the control's outline into +//! the aspect card and reads as diving into it, and a live top-layer copy +//! of the control paints over the anchor while the popup is open. Sections +//! come from [`UiPanelControlView::detail_aspects`]. +//! +//! **Reset** (P2 clear) stays on the face: the small revert glyph sits +//! beside the label ONLY while engaged. It is a gesture, not a detail, so +//! it must not cost a popup to reach. +//! +//! Values render in **tabular numerals inside a reserved slot**, because a +//! panel whose row reflows while you drag a fader is not a control panel. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use dioxus::prelude::*; +use lpa_studio_core::{ + UiAction, UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelWidget, + UiSlotValueKind, +}; + +use crate::app::node::{ + HFaderField, KnobField, PanelEmit, SlotDetailButton, SlotUnitSuffix, ToggleField, +}; +use crate::base::{PopoverPlacement, StudioIcon, StudioIconName}; + +use super::PanelGesture; + +static NEXT_MODULE_CONTROL_ID: AtomicUsize = AtomicUsize::new(1); + +/// Bare text button for the label trigger; the state color rides the inner +/// visual and the hover affordance is a dotted underline. +const LABEL_TRIGGER_CLASS: &str = "tw:inline-flex tw:min-w-0 tw:cursor-pointer tw:appearance-none tw:items-center tw:border-0 tw:bg-transparent tw:p-0 tw:leading-none tw:decoration-dotted tw:underline-offset-2 tw:hover:underline"; + +/// A fader's fixed footprint. Without it the control's width tracks its +/// value string and the whole row shifts mid-drag. +const FADER_CLASS: &str = "tw:grid tw:w-[184px] tw:flex-none tw:gap-1"; + +/// The value slot: tabular numerals in a reserved box, so 0.6 / 0.62 / 200 +/// all occupy the same width and nothing moves during a drag. +const READOUT_CLASS: &str = "tw:inline-flex tw:min-w-[4.5ch] tw:flex-none tw:items-baseline tw:justify-center tw:gap-1 tw:font-mono tw:text-[0.7rem] tw:tabular-nums"; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ModulePanelControl( + /// The channel's control view: widget payload plus panel state. + view: UiPanelControlView, + /// The scope this control lives in — the other half of its identity + /// (panel.md P1), what a reset gesture carries, and what the detail + /// popup names. + scope: String, + /// Play mode renders the same control at a roomier size. + #[props(default = false)] + play: bool, + /// Panel gestures (reset). Absent = the control is display-only. + #[props(default = None)] + on_panel: Option>, + /// Open this control's detail popup on first render (stories). + #[props(default = false)] + detail_initially_open: bool, + /// Widget value writes. The spike keeps knob drags on the existing + /// slot-edit path; M4 routes them to `PanelWrite`. + #[props(default)] + on_action: Option>, +) -> Element { + let aspects = view.detail_aspects(&scope); + let UiPanelControlView { + channel, + control, + state, + source: _, + } = view; + let engaged = state.engaged(); + let label_class = panel_state_label_class(state); + let readout_class = panel_state_readout_class(state); + + // The whole control is the popover's outline anchor (P2c item 3). + let anchor_id = use_hook(|| { + let id = NEXT_MODULE_CONTROL_ID.fetch_add(1, Ordering::Relaxed); + format!("ux-module-control-{id}") + }); + + let is_fader = matches!(control.widget, UiPanelWidget::Fader { .. }); + let column_class = if is_fader { + FADER_CLASS + } else if play { + "tw:flex tw:min-w-[76px] tw:flex-none tw:flex-col tw:items-center tw:gap-1.5" + } else { + "tw:flex tw:min-w-[64px] tw:flex-none tw:flex-col tw:items-center tw:gap-1" + }; + let anchor_class = if is_fader { + "tw:grid tw:h-full tw:w-full tw:content-start tw:gap-1" + } else { + "tw:flex tw:h-full tw:w-full tw:flex-col tw:items-center tw:gap-1" + }; + + let reset_scope = scope.clone(); + let reset_channel = channel.clone(); + let reset_label = control.label.clone(); + // Reset is a GESTURE, so it sits beside the label on the face rather + // than inside the popup — one click, always. + let reset = rsx! { + if engaged && let Some(handler) = on_panel { + button { + class: "tw:inline-flex tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:border-0 tw:bg-transparent tw:p-0 tw:text-status-attention-foreground tw:opacity-70 tw:hover:opacity-100", + r#type: "button", + title: "Reset {reset_label} — drop the held value and follow the project again", + aria_label: "Reset {reset_label}", + onclick: move |event| { + event.stop_propagation(); + handler.call(PanelGesture::ClearControl { + scope: reset_scope.clone(), + channel: reset_channel.clone(), + }); + }, + StudioIcon { name: StudioIconName::Revert, size: 10 } + } + } + }; + + let anchor_control = control.clone(); + let anchor_label = label_visual(&control.label, label_class); + + let label = rsx! { + span { class: "tw:inline-flex tw:min-w-0 tw:items-center tw:gap-1", + SlotDetailButton { + label: control.label.clone(), + aspects, + name_override: Some(control.label.clone()), + initially_open: detail_initially_open, + placement: PopoverPlacement::BottomMiddle, + anchor_id: Some(anchor_id.clone()), + anchor_visual: rsx! { + div { class: anchor_class, + ModulePanelControlBody { + control: anchor_control, + state, + readout_class, + label: anchor_label, + on_action, + } + } + }, + trigger: Some(label_visual(&control.label, label_class)), + trigger_class: LABEL_TRIGGER_CLASS.to_string(), + trigger_open_class: LABEL_TRIGGER_CLASS.to_string(), + on_action, + } + {reset} + } + }; + + rsx! { + div { id: "{anchor_id}", class: column_class, + ModulePanelControlBody { + control, + state, + readout_class, + label, + on_action, + } + } + } +} + +/// The control itself — widget, label slot, value — with no popover wiring. +/// Rendered on the face (label slot = the trigger button) and as the +/// popup's top-layer anchor copy (label slot = a static copy). +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ModulePanelControlBody( + control: UiPanelControl, + state: UiPanelControlState, + readout_class: &'static str, + /// The label element: the interactive trigger in flow, a static copy in + /// the top-layer visual. + label: Element, + #[props(default)] on_action: Option>, +) -> Element { + let engaged = state.engaged(); + let following = matches!(state, UiPanelControlState::ReadFollowing); + + let readout = rsx! { + span { class: "{READOUT_CLASS} {readout_class}", + span { "{shown_display(&control.value.display, control.live_value.as_deref(), state)}" } + SlotUnitSuffix { unit: control.unit.clone(), reserve: false } + } + }; + + match control.widget.clone() { + UiPanelWidget::Knob { min, max, step } => { + let Some((value, emit)) = PanelEmit::for_value(&control.value.kind) else { + return mismatch(&control.label, &control.value.display); + }; + rsx! { + KnobField { + value, + live_value: live_numeric(&control.live_value, following), + min, + max, + step, + state: control.state.clone(), + bound: following, + engaged, + address: control.address.clone(), + emit, + on_action, + } + {label} + {readout} + } + } + UiPanelWidget::Fader { min, max, step } => { + let Some((value, emit)) = PanelEmit::for_value(&control.value.kind) else { + return mismatch(&control.label, &control.value.display); + }; + rsx! { + div { class: "tw:flex tw:min-w-0 tw:items-baseline tw:justify-between tw:gap-2", + {label} + {readout} + } + HFaderField { + value, + live_value: live_numeric(&control.live_value, following), + min, + max, + step, + state: control.state.clone(), + bound: following, + engaged, + address: control.address.clone(), + emit, + on_action, + } + } + } + UiPanelWidget::Toggle => { + let UiSlotValueKind::Bool(value) = control.value.kind else { + return mismatch(&control.label, &control.value.display); + }; + rsx! { + span { class: "tw:flex tw:h-[46px] tw:items-center", + ToggleField { + value, + live_value: live_bool(&control.live_value, following), + state: control.state.clone(), + bound: following, + engaged, + address: control.address.clone(), + on_action, + } + } + {label} + {readout} + } + } + } +} + +/// The label visual shared by the trigger and its top-layer copy: the +/// control name in the panel's label typography plus the small info glyph +/// that stands for "this opens details", both in the state color. +fn label_visual(label: &str, color_class: &'static str) -> Element { + rsx! { + span { class: "tw:inline-flex tw:min-w-0 tw:items-center tw:gap-1 {color_class}", + span { class: "tw:truncate tw:text-[0.66rem] tw:font-bold tw:uppercase tw:leading-none tw:tracking-[0.08em]", + "{label}" + } + span { class: "tw:inline-flex tw:flex-none tw:opacity-60", + StudioIcon { name: StudioIconName::InfoBare, size: 9 } + } + } + } +} + +/// Label color per panel state. Three families, none of them green. +fn panel_state_label_class(state: UiPanelControlState) -> &'static str { + match state { + UiPanelControlState::ReadDefault => "tw:text-subtle-foreground", + UiPanelControlState::ReadFollowing => "tw:text-status-bound-foreground", + UiPanelControlState::Engaged => "tw:text-status-attention-foreground", + } +} + +/// Readout color per panel state: the held value leads in amber, a followed +/// value in violet, an untouched default stays quiet. +fn panel_state_readout_class(state: UiPanelControlState) -> &'static str { + match state { + UiPanelControlState::ReadDefault => "tw:text-dim-foreground", + UiPanelControlState::ReadFollowing => "tw:text-status-bound-foreground", + UiPanelControlState::Engaged => "tw:text-status-attention-foreground", + } +} + +/// The number the readout shows: a followed control displays the live +/// resolved value (P6 item 1 — watch the LFO move the knob); everything +/// else shows its own value. +fn shown_display(value: &str, live: Option<&str>, state: UiPanelControlState) -> String { + match (state, live) { + (UiPanelControlState::ReadFollowing, Some(live)) => live.to_string(), + _ => value.to_string(), + } +} + +/// The live reading as a number for the widget geometry — only meaningful +/// while the control is following something. +fn live_numeric(live: &Option, following: bool) -> Option { + following.then(|| live.as_deref()?.parse().ok()).flatten() +} + +/// The live reading as a bool for the toggle pill. +fn live_bool(live: &Option, following: bool) -> Option { + following.then(|| live.as_deref()?.parse().ok()).flatten() +} + +/// Read-only fallback when the widget family and the value family disagree. +fn mismatch(label: &str, display: &str) -> Element { + rsx! { + div { class: "tw:flex tw:min-w-[64px] tw:flex-none tw:flex-col tw:items-center tw:gap-1", + span { class: "tw:text-[0.66rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-subtle-foreground", + "{label}" + } + span { class: "tw:font-mono tw:text-[0.7rem] tw:tabular-nums tw:text-muted-foreground", + "{display}" + } + } + } +} + +#[cfg(test)] +mod tests { + use lpa_studio_core::{ + UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelWidget, UiSlotAspectKind, + UiSlotFieldState, UiSlotValue, + }; + + use super::{panel_state_label_class, panel_state_readout_class, shown_display}; + + fn view(state: UiPanelControlState, source: Option<&str>) -> UiPanelControlView { + UiPanelControlView::new( + "speed", + UiPanelControl { + label: "speed".to_string(), + address: None, + widget: UiPanelWidget::Knob { + min: 0.0, + max: 1.0, + step: None, + }, + value: UiSlotValue::f32(0.5), + live_value: None, + unit: None, + state: UiSlotFieldState::editable(), + aspects: Vec::new(), + }, + ) + .with_state(state, source) + } + + #[test] + fn the_three_panel_states_wear_three_different_families() { + let families: Vec<&str> = [ + UiPanelControlState::ReadDefault, + UiPanelControlState::ReadFollowing, + UiPanelControlState::Engaged, + ] + .into_iter() + .map(panel_state_label_class) + .collect(); + + // P-Q2's requirement, pinned: three visibly distinct states. + assert_eq!(families.len(), 3); + assert_ne!(families[0], families[1]); + assert_ne!(families[1], families[2]); + assert_ne!(families[0], families[2]); + // Engaged must NOT reuse the bound-violet family (P6). + assert!(families[1].contains("bound")); + assert!(!families[2].contains("bound")); + assert!(families[2].contains("attention")); + // And green stays valid-only, everywhere. + for family in families.iter().chain( + [ + panel_state_readout_class(UiPanelControlState::ReadDefault), + panel_state_readout_class(UiPanelControlState::ReadFollowing), + panel_state_readout_class(UiPanelControlState::Engaged), + ] + .iter(), + ) { + assert!(!family.contains("good"), "green is valid-only: {family}"); + } + } + + #[test] + fn a_following_control_reads_out_the_live_value_not_its_default() { + assert_eq!( + shown_display("1.60", Some("2.72"), UiPanelControlState::ReadFollowing), + "2.72" + ); + // Engaged shows the held value; the live reading is its own. + assert_eq!( + shown_display("1.60", Some("2.72"), UiPanelControlState::Engaged), + "1.60" + ); + assert_eq!( + shown_display("1.60", None, UiPanelControlState::ReadDefault), + "1.60" + ); + } + + /// The captions left the face — so everything they said has to be in + /// the popup, or the information is simply gone. + #[test] + fn provenance_moved_into_the_detail_popup() { + let held = view(UiPanelControlState::Engaged, Some("lfo · speed")); + let aspects = held.detail_aspects("/aurora.module"); + assert_eq!(aspects[0].kind, UiSlotAspectKind::PanelState); + assert_eq!(aspects[0].title, "Held"); + // What the held value displaced — the old "held · was …" caption. + assert!( + aspects[0] + .rows + .iter() + .any(|row| row.value.contains("lfo · speed")) + ); + // The identity behind every reset gesture (P1). + assert!( + aspects[1] + .rows + .iter() + .any(|row| row.value == "/aurora.module") + ); + + let following = view(UiPanelControlState::ReadFollowing, Some("lfo · hue")); + assert_eq!(following.detail_aspects("/x")[0].title, "Following"); + let quiet = view(UiPanelControlState::ReadDefault, None); + assert_eq!(quiet.detail_aspects("/x")[0].title, "At default"); + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs b/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs new file mode 100644 index 000000000..d6b2d9399 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs @@ -0,0 +1,42 @@ +//! Panel gestures the spike's surfaces raise. +//! +//! **M2 UX spike seam.** `docs/design/panel.md` P8 says the wire carries +//! exactly two runtime ops — `PanelWrite { scope, channel, value }` and +//! `PanelClear { scope?, channel? }` — and that Studio, play mode, phones, +//! and future hardware inputs all speak only those. Neither op exists yet +//! (M4 owns the engine runtime), so the spike's components raise this +//! component-level gesture instead and the stories fake the resulting +//! state. The variants are deliberately shaped like the two wire ops so +//! the mapping at implementation time is mechanical: +//! +//! - [`PanelGesture::ClearControl`] → `PanelClear { scope, channel }` +//! - [`PanelGesture::ClearScope`] → `PanelClear { scope }` +//! - [`PanelGesture::SetAutoSave`] → the P11 auto-save toggle +//! +//! There is no group-disclosure gesture: nested groups are bordered +//! clusters in a wrapping row and never collapse, so there is no view +//! state to carry. +//! +//! Panel *writes* are NOT here: a knob drag still rides the existing +//! `SlotEditOp::SetValue` path so the spike reuses knob v2 untouched. That +//! is a spike shortcut, not a proposal — see the module face's doc. + +/// One gesture raised by a panel surface. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PanelGesture { + /// Clear one control's panel writer, returning it to Read (P2). + ClearControl { + /// The scope the control lives in. + scope: String, + /// The channel name within that scope. + channel: String, + }, + /// Clear every panel writer under one scope — the per-module reset. + /// The lean (P-Q4) is that this descends into nested groups. + ClearScope { + /// The scope to clear. + scope: String, + }, + /// Flip panel-state auto-save (P11 — on by default). + SetAutoSave(bool), +} diff --git a/lp-app/lpa-studio-web/src/app/module/panel_state_stories.rs b/lp-app/lpa-studio-web/src/app/module/panel_state_stories.rs new file mode 100644 index 000000000..bb17f1f3f --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/panel_state_stories.rs @@ -0,0 +1,174 @@ +//! Stories for the panel's three control states and its reset gestures +//! (M2 UX spike, gate G2 question 5). +//! +//! `docs/design/panel.md` P-Q2 asks for confirmation that +//! Read-following-automation, Read-at-default, and Engaged are three +//! *visibly distinct* states. These stories put them next to each other so +//! that is a judgement about pixels rather than about prose. +//! +//! The spike's proposal: **amber** (`status-attention`) for engaged. Not +//! violet — bound means *wired*, engaged means *captured* (P6). Not green +//! — green is valid-only. Not the blue live family — that is a transient +//! unsaved edit. A dedicated `status-engaged` token family is the eventual +//! home; what is under test is whether amber reads as "held". + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use super::module_fixtures::{ + PLASMA_1_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, root_module_node_view, + three_state_panel, +}; +use super::{ModulePanel, PanelGesture}; + +/// Panel stories render on a card surface so the states are judged against +/// the background they will actually sit on. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn PanelCanvas(children: Element) -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-[640px] tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + {children} + } + } +} + +#[story( + description = "The three panel states across all three widget families, with NO sublabels — a control is a widget, a label, and a value. Read-at-default = quiet accent, subtle label. Read-following = violet at the LIVE value. Engaged = amber arc/fill/ring plus the per-control reset glyph. Everything the old captions said now lives behind the label (see control-detail)." +)] +fn three_states() -> Element { + rsx! { + PanelCanvas { + ModulePanel { + panel: three_state_panel(), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Reset granularity (P2 clear): per control — the amber revert glyph beside the label, present ONLY while engaged — per nested group, in its box border beside the name, and per module, upper right, counting everything under the scope. An untouched panel shows no destructive control at all, so the glyph's presence is itself part of the state signal." +)] +fn reset_gestures() -> Element { + rsx! { + PanelCanvas { + ModulePanel { + panel: held_root_face().panel, + auto_save: Some(true), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Auto-save off (P11): the small switch sits in the panel chrome upper right beside the reset glyph, on the module that owns the scope — panel state is per project folder (.lp/state.json), not an app setting. Off means held values are lost on restart, which is the opposite of the scarf requirement (P10)." +)] +fn auto_save_off() -> Element { + let mut face = held_root_face(); + face.auto_save = Some(false); + rsx! { + PanelCanvas { + ModulePanel { + panel: face.panel, + auto_save: face.auto_save, + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Walkable Read → Latch → Clear (P2). Drag any knob: the first touch materializes its panel writer and the control turns amber and captures the channel; the reset glyph or the panel-level reset drops the writer and the control falls back to following the project. Latch, not Touch — letting go changes nothing." +)] +fn latch_walkthrough() -> Element { + // Start from the pristine Read face, so the FIRST touch is the thing + // being felt — and so a clear has somewhere honest to land. + let mut spike = use_signal(|| PanelSpike::new(root_module_node_view())); + + rsx! { + PanelCanvas { + ModulePanel { + panel: spike().face().panel.clone(), + auto_save: spike().face().auto_save, + on_panel: move |gesture: PanelGesture| { + spike.with_mut(|spike| spike.apply_gesture(&gesture)); + }, + on_action: move |action| { + spike.with_mut(|spike| spike.apply_action(&action)); + }, + } + } + } +} + +#[story( + description = "Where the sublabels went: a HELD control's detail popup, opened from its label. The control's own outline merges into the aspect card — the same contiguous-outline gesture the node face's panel controls use — and the card carries the state ('Held'), what the held value displaced ('Was: authored 200'), and the (scope, channel) identity. This is the whole cost of taking the captions off the face." +)] +fn control_detail() -> Element { + let mut panel = held_root_face().panel; + panel.groups.clear(); + rsx! { + // Room around the control for the popup to open into. + div { class: "tw:h-[420px] tw:w-full tw:max-w-[860px] tw:pl-48 tw:pr-8", + PanelCanvas { + ModulePanel { + panel, + detail_open_channel: Some("brightness".to_string()), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } + } +} + +#[story( + description = "A nested group's heading popup: the scope path and the '2 controls · 1 held' tally that used to sit on the group header, now behind its name — same popup language as a control. The box's border carries only the instance name, which is what tells two copies of one effect apart." +)] +fn group_detail() -> Element { + let mut panel = held_root_face().panel; + panel.controls.clear(); + rsx! { + div { class: "tw:h-[420px] tw:w-full tw:max-w-[860px] tw:pl-48 tw:pr-8", + PanelCanvas { + ModulePanel { + panel, + detail_open_group: Some(PLASMA_1_SCOPE.to_string()), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } + } +} + +#[story( + description = "Engaged amber vs bound violet, one channel, one range, side by side — the direct comparison behind P-Q2. The left knob is wired and following its writer; the right one has been captured and holds. Nothing on this panel is green." +)] +fn engaged_vs_bound() -> Element { + let mut panel = three_state_panel(); + panel.label = "hue".to_string(); + panel.scope = ROOT_SCOPE.to_string(); + // Keep only the two knobs under comparison. + panel.controls.retain(|control| { + matches!(control.control.label.as_str(), "following" | "engaged") + && matches!( + control.control.widget, + lpa_studio_core::UiPanelWidget::Knob { .. } + ) + }); + rsx! { + PanelCanvas { + ModulePanel { + panel, + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/play_mode.rs b/lp-app/lpa-studio-web/src/app/module/play_mode.rs new file mode 100644 index 000000000..907ef43ad --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/play_mode.rs @@ -0,0 +1,71 @@ +//! **Play mode**: the root module's panel, and nothing else. +//! +//! `docs/design/panel.md` P12 — play mode renders panels only, never +//! faces: no preview drawers, no children, no wiring, no authoring +//! surfaces. It speaks the two runtime ops plus reads, and "anything play +//! mode can do, an end user is allowed to do". +//! +//! The surface is deliberately *not* a card: it fills the viewport, because +//! at this zoom level there is no workspace to sit inside. It carries the +//! project name, the module's own controls, and the nested groups +//! recursively (R8) — the same [`super::ModulePanel`] the face hosts, +//! rendered at play density. +//! +//! The hero preview is present but optional: on a phone at 4 a.m. the +//! controls matter more than a thumbnail, so the preview is a slim banner +//! rather than the face's dominant hero. + +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiPanelGroup, UiProducedProduct}; + +use crate::app::node::ProductPreview; + +use super::{ModulePanel, PanelGesture}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn PlayModeSurface( + /// The root module's panel — the end-user surface (R8). + panel: UiPanelGroup, + /// The root module's output mirror, as a slim banner. + #[props(default = None)] + preview: Option, + /// Panel-state auto-save (P11): an end user owns this, so it stays + /// reachable in play mode. `None` hides the switch. + #[props(default = Some(true))] + auto_save: Option, + #[props(default = None)] on_panel: Option>, + #[props(default)] on_action: Option>, +) -> Element { + let title = panel.label.clone(); + + rsx! { + div { class: "tw:grid tw:min-h-full tw:min-w-0 tw:content-start tw:gap-0 tw:bg-page", + header { class: "tw:flex tw:min-w-0 tw:items-baseline tw:gap-2 tw:border-b tw:border-border-strong tw:px-4 tw:py-3", + h1 { class: "tw:m-0 tw:min-w-0 tw:truncate tw:text-lg tw:text-strong-foreground", "{title}" } + span { class: "tw:ml-auto tw:flex-none tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.14em] tw:text-dim-foreground", + "play" + } + } + if let Some(preview) = preview { + div { class: "tw:border-b tw:border-border-strong", + ProductPreview { + kind: preview.kind, + preview: preview.preview.clone(), + tracking: preview.tracking, + frame: preview.frame, + focus_action: None, + on_action, + } + } + } + ModulePanel { + panel, + auto_save, + play: true, + on_panel, + on_action, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/play_mode_stories.rs b/lp-app/lpa-studio-web/src/app/module/play_mode_stories.rs new file mode 100644 index 000000000..cd85decc1 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/play_mode_stories.rs @@ -0,0 +1,70 @@ +//! Play-mode stories (M2 UX spike, gate G2 question 4). +//! +//! `docs/design/panel.md` P12: play mode renders **panels only** — the root +//! module's panel, recursively presenting its nested module groups (R8) — +//! with no faces, no children, no wiring, no authoring surfaces at all. +//! Reset and auto-save stay, because "anything play mode can do, an end +//! user is allowed to do" and the scarf scenario (P10) is an end user's. +//! +//! Two widths, because the phone case is the real one: the installation +//! controller in E7 and the 4 a.m. dimmer in P10 are both phones. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use super::PlayModeSurface; +use super::module_fixtures::held_root_face; + +#[story( + description = "Play mode, desktop: the root module's panel alone — its own controls in a row, then the two effect groups as bordered clusters SIDE BY SIDE — over a slim output banner. No sublabels, no hero-dominant preview, no children, no wiring, no card chrome. This is the surface the minimalism was for. Reset and auto-save stay because they are end-user gestures." +)] +fn desktop() -> Element { + let face = held_root_face(); + rsx! { + div { class: "tw:h-[560px] tw:w-full tw:max-w-[900px] tw:overflow-auto tw:border tw:border-border", + PlayModeSurface { + panel: face.panel, + preview: face.preview, + auto_save: face.auto_save, + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Play mode, phone width (375px): the same panel wrapping to one column. This is the surface the design docs' two hardest scenarios live on — dimming an LED scarf from a phone at 4 a.m., and driving an installation's XY pad." +)] +fn mobile() -> Element { + let face = held_root_face(); + rsx! { + div { class: "tw:h-[720px] tw:w-[375px] tw:overflow-auto tw:border tw:border-border", + PlayModeSurface { + panel: face.panel, + preview: face.preview, + auto_save: face.auto_save, + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Density without collapse: the same panel at tablet width, where the two effect boxes no longer fit side by side and the flex row wraps them into a stack. Wrapping is the density mechanism — there is no fold-away — so compare against play-mode/desktop, where the same two boxes sit next to each other." +)] +fn groups_wrapped() -> Element { + let face = held_root_face(); + rsx! { + div { class: "tw:h-[560px] tw:w-[430px] tw:overflow-auto tw:border tw:border-border", + PlayModeSurface { + panel: face.panel, + preview: face.preview, + auto_save: face.auto_save, + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/module/playlist_panel_stories.rs b/lp-app/lpa-studio-web/src/app/module/playlist_panel_stories.rs new file mode 100644 index 000000000..c96ac1b54 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/playlist_panel_stories.rs @@ -0,0 +1,162 @@ +//! Stories for playlist meta-switching (M2 UX spike; `modules.md` E2). +//! +//! A playlist is an *isolating* node: each entry gets its own anonymous +//! sink scope (R2), so the same channel name lists separately per entry and +//! neither entry surfaces on the host's panel. The playlist's face presents +//! the **active** entry's panel, and switching entries re-derives the +//! control from whatever is bound in the newly active sink scope (R9): +//! 0–1 "Drift" becomes 0–10 "Whirl" — same channel, different control. +//! +//! Panel state is per `(scope, channel)` (P1/R10), so tweaking Drift, +//! switching to Whirl, and switching back finds Drift's tweak still there. +//! That is what the walkable story demonstrates: Drift starts held at 0.35 +//! while Whirl is untouched at its authored default. + +use dioxus::prelude::*; +use lpa_studio_core::UiPlaylistFace as UiPlaylistFaceData; +use lpa_studio_web_story_macros::story; + +use crate::app::node::{NodeCardSection, PlaylistFace}; + +use super::module_fixtures::{ + PanelSpike, entry_held_panel, entry_panel, entry_scope, playlist_face, +}; +use super::{ModulePanel, PanelGesture}; + +/// The playlist card: the entries strip (its own kind-specific face) above +/// the ACTIVE entry's panel. The strip is the face; the panel below it is +/// the sink child's, hosted here because inactive siblings surface nowhere. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn PlaylistCard( + entries: UiPlaylistFaceData, + entry: u32, + #[props(default = None)] on_select: Option>, + children: Element, +) -> Element { + let scope = entry_scope(entry); + rsx! { + div { class: "tw:w-full tw:max-w-md tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", + PlaylistFace { face: entries.clone(), on_action: move |_| {} } + // Entry switching has no wire op today (activation-by-click is + // still an open authoring gesture), so the spike exposes it as + // plain buttons — the point being measured is what happens to + // the PANEL, not how the entry gets activated. + if let Some(on_select) = on_select { + div { class: "tw:flex tw:flex-wrap tw:gap-2 tw:border-t tw:border-border-strong tw:px-4 tw:py-2", + for candidate in entries.entries.clone() { + button { + key: "{candidate.key}", + class: if candidate.key == entry { + "tw:cursor-pointer tw:rounded-xs tw:border tw:border-status-live-border tw:bg-status-live-bg tw:px-2 tw:py-0.5 tw:text-[11px] tw:text-status-live-foreground" + } else { + "tw:cursor-pointer tw:rounded-xs tw:border tw:border-border tw:bg-transparent tw:px-2 tw:py-0.5 tw:text-[11px] tw:text-muted-foreground" + }, + r#type: "button", + onclick: move |_| on_select.call(candidate.key), + "activate {candidate.name}" + } + } + } + } + NodeCardSection { label: "panel", + div { class: "tw:px-4 tw:pt-2 tw:text-[0.6rem] tw:text-dim-foreground", + "active entry's sink scope " + code { class: "tw:font-mono", "{scope}" } + } + {children} + } + } + } +} + +#[story( + description = "Entry A active: the speed channel binds a 0–1 slot labelled Drift, and it is held (amber) at 0.35. This is the control the panel derives from the ACTIVE sink scope." +)] +fn entry_drift() -> Element { + rsx! { + PlaylistCard { entries: playlist_face(), entry: 0, + ModulePanel { + panel: entry_held_panel(0), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Entry B active: the SAME channel name now binds a 0–10 slot labelled Whirl, so the control re-derives — new label, new range, new resting position — and it is at its own authored default, untouched by anything done to Drift." +)] +fn entry_whirl() -> Element { + let mut entries = playlist_face(); + entries.active = Some(1); + rsx! { + PlaylistCard { entries, entry: 1, + ModulePanel { + panel: entry_panel(1), + on_panel: move |_| {}, + on_action: move |_| {}, + } + } + } +} + +#[story( + description = "Walkable meta-switch: activate Drift or Whirl and watch the control swap under you — 0–1 held-amber vs 0–10 at-default. Tweak one, switch away, switch back: per-(scope, channel) state means the tweak is still there, because the two entries are two different sink scopes." +)] +fn meta_switch() -> Element { + let mut entry = use_signal(|| 0_u32); + // Drift starts with a tweak already on it — the thing that has to + // still be there after a round trip through Whirl. + let mut drift = use_signal(|| { + let scope = entry_scope(0); + PanelSpike::new(spike_view(0)).with_held(&[(scope.as_str(), "speed", 0.35)]) + }); + let mut whirl = use_signal(|| PanelSpike::new(spike_view(1))); + let mut entries = playlist_face(); + entries.active = Some(entry()); + let active = entry(); + let panel = if active == 0 { + drift().face().panel.clone() + } else { + whirl().face().panel.clone() + }; + + rsx! { + PlaylistCard { + entries, + entry: active, + on_select: move |key| entry.set(key), + ModulePanel { + panel, + on_panel: move |gesture: PanelGesture| { + if active == 0 { + drift.with_mut(|spike| spike.apply_gesture(&gesture)); + } else { + whirl.with_mut(|spike| spike.apply_gesture(&gesture)); + } + }, + on_action: move |action| { + if active == 0 { + drift.with_mut(|spike| spike.apply_action(&action)); + } else { + whirl.with_mut(|spike| spike.apply_action(&action)); + } + }, + } + } + } +} + +/// One entry's panel wrapped as walkable spike state. Each entry gets its +/// OWN state holder — that separation is the model's, not a story +/// convenience: two sink scopes are two identities (P1). +fn spike_view(entry: u32) -> lpa_studio_core::UiNodeView { + use lpa_studio_core::{UiModuleFace, UiNodeFace, UiNodeHeader, UiNodeView}; + let scope = entry_scope(entry); + let header = UiNodeHeader::new("entry", "Module", scope.clone()); + let mut view = UiNodeView::new(header, Vec::new()).with_node_id(scope); + view.face = Some(UiNodeFace::Module(UiModuleFace::new(entry_panel(entry)))); + view +} 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..db9e26678 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 @@ -19,10 +19,11 @@ use lpa_studio_core::{ NodeCardUiState, UiAction, UiAddNodeMenu, UiNodeFace, UiNodeSection, UiPendingEdit, }; +use crate::app::module::{ModuleFace, ModulePanel, PanelGesture}; use crate::app::node::NodeDirtyTint; use crate::base::Platform; -use super::{FixtureFace, NodeCardDrawers, PlaylistFace, ShaderFace}; +use super::{FixtureFace, NodeCardDrawers, NodeCardSection, PlaylistFace, ShaderFace}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -49,6 +50,10 @@ pub fn NodeFaceBody( add_node_menu: Option, #[props(default)] pending_edits: Vec, #[props(default)] dirty_tint: NodeDirtyTint, + /// M2 UX spike: panel gestures (reset, auto-save, group disclosure) + /// raised by a module face. `None` renders the panel display-only. + #[props(default = None)] + module_panel: Option>, #[props(default)] on_action: Option>, ) -> Element { rsx! { @@ -105,6 +110,30 @@ pub fn NodeFaceBody( on_action, } }, + // M2 UX spike: the module face carries its own drawer + // (wiring), so it does NOT compose `NodeCardDrawers` — the + // advanced slot view lives on the individual child nodes, + // which are sibling cards below this one. + UiNodeFace::Module(module) => rsx! { + ModuleFace { face: module, on_panel: module_panel, on_action } + }, + // A leaf under a module: its bound slots ARE its face (R3). + // Same widgets and same panel state as the module panel + // above, because it is literally the same control (P1). + UiNodeFace::Controls(panel) => rsx! { + NodeCardSection { label: "controls", first: true, + ModulePanel { panel, on_panel: module_panel, on_action } + } + NodeCardDrawers { + node, + sections, + advanced_open: card_ui.advanced_open, + platform, + pending_edits, + dirty_tint, + on_action, + } + }, }} } } diff --git a/lp-app/lpa-studio-web/src/app/node/mod.rs b/lp-app/lpa-studio-web/src/app/node/mod.rs index cd4cf83a0..cda1d6919 100644 --- a/lp-app/lpa-studio-web/src/app/node/mod.rs +++ b/lp-app/lpa-studio-web/src/app/node/mod.rs @@ -89,9 +89,9 @@ pub use face::{ pub use node_children::NodeChildren; pub(crate) use node_detail_popover::{NodeDetailPopover, node_status_label_class}; pub use node_pane::{NodeDirtyTint, NodePane, NodeSection}; -pub use panel::{HFaderField, KnobField, PanelControl, ToggleField}; +pub use panel::{HFaderField, KnobField, PanelControl, PanelEmit, ToggleField}; pub use produced_product_view::ProducedProductView; -pub(crate) use produced_product_view::ProductPreviewCanvas; +pub(crate) use produced_product_view::{ProductPreview, ProductPreviewCanvas}; pub use produced_products::ProducedProducts; pub use produced_value_view::ProducedValueView; pub use produced_values::ProducedValues; 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..122936444 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 @@ -13,6 +13,10 @@ pub fn NodeChildren( #[props(default)] pending_edits: Vec, #[props(default)] dirty_tint: NodeDirtyTint, + /// M2 UX spike: panel gestures raised by a module child's face, passed + /// down so a child module's panel is as live as its host's. + #[props(default = None)] + module_panel: Option>, ) -> Element { rsx! { div { class: "tw:grid tw:min-w-0 tw:gap-3 tw:border-l tw:border-border-muted tw:pl-4", @@ -23,6 +27,7 @@ pub fn NodeChildren( on_action, pending_edits: pending_edits.clone(), dirty_tint, + module_panel, } } } 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..2a89cc340 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 @@ -42,6 +42,10 @@ pub fn NodePane( /// for deterministic captures. #[props(default = None)] face_platform: Option, + /// M2 UX spike: panel gestures raised by a module face (reset, + /// auto-save, nested-group disclosure). + #[props(default = None)] + module_panel: Option>, ) -> Element { let mut active_tab = use_signal(|| 0_usize); let mut collapsed = use_signal(|| view.collapsed); @@ -144,6 +148,7 @@ pub fn NodePane( add_node_menu: add_node_menu.clone(), pending_edits: pending_edits.clone(), dirty_tint, + module_panel, on_action, } } else { @@ -188,6 +193,7 @@ pub fn NodePane( on_action, pending_edits, dirty_tint, + module_panel, } } } diff --git a/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs index 0a4267b1f..f0819a481 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/h_fader_field.rs @@ -35,6 +35,11 @@ pub fn HFaderField( /// Violet bound treatment on the fill, slot border, and grip ring. #[props(default = false)] bound: bool, + /// Amber ENGAGED treatment (`docs/design/panel.md` P2/P6): a panel + /// writer has captured this channel and holds it. Outranks the violet + /// bound family — bound means "wired", engaged means "captured". + #[props(default = false)] + engaged: bool, #[props(default = None)] address: Option, /// Value family the drag dispatches (`F32` default; integer slots /// round). @@ -47,9 +52,9 @@ pub fn HFaderField( // The fill rides the step grid the native input's thumb already snaps // to, so a stepped fader never shows fill and thumb in different places. let frac = knob_fraction(knob_snap(live_value.unwrap_or(value), min, step), min, max); - let input_class = fader_input_class(bound); - let fill_style = fader_fill_style(frac, &state, bound); - let slot_style = fader_slot_style(&state, bound); + let input_class = fader_input_class(bound, engaged); + let fill_style = fader_fill_style(frac, &state, bound, engaged); + let slot_style = fader_slot_style(&state, bound, engaged); let step = step.map_or_else(|| "any".to_string(), |step| step.to_string()); let invalid_title = state.invalid.clone().unwrap_or_default(); @@ -107,19 +112,30 @@ fn FaderTicks() -> Element { } /// Input classes: the transparent gesture surface (with the styled grip) -/// plus the bound ring. -pub(crate) fn fader_input_class(bound: bool) -> &'static str { - if bound { +/// plus the bound / engaged grip ring. +pub(crate) fn fader_input_class(bound: bool, engaged: bool) -> &'static str { + if engaged { + "ux-hfader is-engaged" + } else if bound { "ux-hfader is-bound" } else { "ux-hfader" } } -/// Fill and border colors by status family: violet when bound, error when -/// invalid, accent otherwise (green stays valid-only). -fn fader_fill_colors(state: &UiSlotFieldState, bound: bool) -> (&'static str, &'static str) { - if bound { +/// Fill and border colors by status family: amber when engaged, violet when +/// bound, error when invalid, accent otherwise (green stays valid-only). +fn fader_fill_colors( + state: &UiSlotFieldState, + bound: bool, + engaged: bool, +) -> (&'static str, &'static str) { + if engaged { + ( + "color-mix(in oklab, var(--studio-status-attention-text) 45%, var(--studio-color-surface-muted))", + "var(--studio-status-attention-border)", + ) + } else if bound { ( "color-mix(in oklab, var(--studio-status-bound-text) 45%, var(--studio-color-surface-muted))", "var(--studio-status-bound-border)", @@ -139,15 +155,20 @@ fn fader_fill_colors(state: &UiSlotFieldState, bound: bool) -> (&'static str, &' /// Inline style for the value fill inside the slot: width = the value /// fraction, background = the status family's fill mix. -pub(crate) fn fader_fill_style(frac: f32, state: &UiSlotFieldState, bound: bool) -> String { - let (fill, _) = fader_fill_colors(state, bound); +pub(crate) fn fader_fill_style( + frac: f32, + state: &UiSlotFieldState, + bound: bool, + engaged: bool, +) -> String { + let (fill, _) = fader_fill_colors(state, bound, engaged); format!("width: {:.1}%; background: {fill};", frac * 100.0) } /// Inline style for the slot border so bound faders read violet at a /// glance. -pub(crate) fn fader_slot_style(state: &UiSlotFieldState, bound: bool) -> String { - let (_, border) = fader_fill_colors(state, bound); +pub(crate) fn fader_slot_style(state: &UiSlotFieldState, bound: bool, engaged: bool) -> String { + let (_, border) = fader_fill_colors(state, bound, engaged); format!("border-color: {border};") } @@ -159,24 +180,34 @@ mod tests { #[test] fn fill_sizes_to_the_value_fraction() { - let style = fader_fill_style(0.72, &UiSlotFieldState::editable(), false); + let style = fader_fill_style(0.72, &UiSlotFieldState::editable(), false, false); assert!(style.contains("width: 72.0%")); assert!(style.contains("--studio-color-accent")); } #[test] fn bound_fader_wears_the_violet_family() { - let fill = fader_fill_style(0.5, &UiSlotFieldState::editable(), true); + let fill = fader_fill_style(0.5, &UiSlotFieldState::editable(), true, false); assert!(fill.contains("--studio-status-bound-text")); - let slot = fader_slot_style(&UiSlotFieldState::editable(), true); + let slot = fader_slot_style(&UiSlotFieldState::editable(), true, false); assert!(slot.contains("--studio-status-bound-border")); - assert!(fader_input_class(true).contains("is-bound")); - assert!(!fader_input_class(false).contains("is-bound")); + assert!(fader_input_class(true, false).contains("is-bound")); + assert!(!fader_input_class(false, false).contains("is-bound")); + } + + #[test] + fn engaged_fader_wears_amber_over_the_bound_violet() { + // Same rule as the knob: a captured fader has stopped following its + // binding, so the engaged family wins (panel.md P-Q2). + let fill = fader_fill_style(0.5, &UiSlotFieldState::editable(), true, true); + assert!(fill.contains("--studio-status-attention-text")); + assert!(!fill.contains("bound")); + assert!(fader_input_class(true, true).contains("is-engaged")); } #[test] fn invalid_fill_wears_the_error_family_when_unbound() { let state = UiSlotFieldState::editable().with_invalid("too bright"); - assert!(fader_fill_style(0.5, &state, false).contains("--studio-status-error-text")); + assert!(fader_fill_style(0.5, &state, false, false).contains("--studio-status-error-text")); } } diff --git a/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs index 38e6a4718..4c3384225 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/knob_field.rs @@ -61,6 +61,13 @@ pub fn KnobField( /// Violet bound treatment on the arc, pointer, and body ring. #[props(default = false)] bound: bool, + /// Amber ENGAGED treatment (`docs/design/panel.md` P2/P6): a panel + /// writer has captured this channel and holds it. Deliberately NOT the + /// violet bound family — bound means "wired", engaged means "captured" + /// — and it outranks violet, because a captured control has stopped + /// following whatever it is wired to. + #[props(default = false)] + engaged: bool, #[props(default = None)] address: Option, /// Value family the drag dispatches (`F32` default; integer slots /// round). @@ -78,8 +85,10 @@ pub fn KnobField( let frac = knob_fraction(shown, min, max); let arc_len = frac * 100.0; let pointer_deg = knob_pointer_deg(frac); - let stroke = knob_value_stroke(&state, bound, editable); - let body_stroke = if bound { + let stroke = knob_value_stroke(&state, bound, engaged, editable); + let body_stroke = if engaged { + "var(--studio-status-attention-border)" + } else if bound { "var(--studio-status-bound-border)" } else { "var(--studio-color-border-strong)" @@ -439,10 +448,22 @@ pub(crate) fn knob_key_value( Some(knob_snap(raw, min, step).clamp(min, max)) } -/// Stroke for the value arc and pointer: violet when bound, error when -/// invalid, subtle when read-only, accent otherwise (green stays valid-only). -fn knob_value_stroke(state: &UiSlotFieldState, bound: bool, editable: bool) -> &'static str { - if bound { +/// Stroke for the value arc and pointer: amber when a panel writer has it +/// engaged, violet when bound, error when invalid, subtle when read-only, +/// accent otherwise (green stays valid-only). +/// +/// Engaged outranks bound: a captured control is no longer following the +/// thing it is wired to, and the panel's whole point is that you can see +/// that at a glance (panel.md P-Q2). +fn knob_value_stroke( + state: &UiSlotFieldState, + bound: bool, + engaged: bool, + editable: bool, +) -> &'static str { + if engaged { + "var(--studio-status-attention-text)" + } else if bound { "var(--studio-status-bound-text)" } else if state.invalid.is_some() { "var(--studio-status-error-text)" @@ -675,20 +696,30 @@ mod tests { fn bound_wins_the_stroke_even_over_invalid() { let invalid = UiSlotFieldState::editable().with_invalid("out of range"); assert_eq!( - knob_value_stroke(&invalid, true, true), + knob_value_stroke(&invalid, true, false, true), "var(--studio-status-bound-text)" ); assert_eq!( - knob_value_stroke(&invalid, false, true), + knob_value_stroke(&invalid, false, false, true), "var(--studio-status-error-text)" ); assert_eq!( - knob_value_stroke(&UiSlotFieldState::editable(), false, true), + knob_value_stroke(&UiSlotFieldState::editable(), false, false, true), "var(--studio-color-accent)" ); assert_eq!( - knob_value_stroke(&UiSlotFieldState::readonly(), false, false), + knob_value_stroke(&UiSlotFieldState::readonly(), false, false, false), "var(--studio-color-text-subtle)" ); } + + #[test] + fn engaged_outranks_bound_and_never_reuses_violet() { + // A captured control has stopped following its binding, so the + // amber engaged family wins over the violet bound family — the + // three panel states must stay visibly distinct (panel.md P-Q2). + let stroke = knob_value_stroke(&UiSlotFieldState::editable(), true, true, true); + assert_eq!(stroke, "var(--studio-status-attention-text)"); + assert!(!stroke.contains("bound")); + } } diff --git a/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs b/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs index aa99da85a..54ba6e836 100644 --- a/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs +++ b/lp-app/lpa-studio-web/src/app/node/panel/toggle_field.rs @@ -24,13 +24,17 @@ pub fn ToggleField( /// Violet bound treatment on the pill ring. #[props(default = false)] bound: bool, + /// Amber ENGAGED ring (`docs/design/panel.md` P2/P6): a panel writer + /// has captured this channel and holds it. Outranks the bound violet. + #[props(default = false)] + engaged: bool, #[props(default = None)] address: Option, #[props(default)] on_action: Option>, ) -> Element { let wired = field_wiring(&state, &address, on_action); let disabled = wired.is_none(); let shown = live_value.unwrap_or(value); - let pill_class = toggle_pill_class(shown, bound, disabled); + let pill_class = toggle_pill_class(shown, bound, engaged, disabled); let thumb_class = toggle_thumb_class(shown); let invalid_title = state.invalid.clone().unwrap_or_default(); @@ -54,14 +58,17 @@ pub fn ToggleField( } /// Pill chrome: good-green surface strictly for the ON state; bound rings -/// violet in both states; disabled pills drop the pointer cursor. -fn toggle_pill_class(on: bool, bound: bool, disabled: bool) -> String { +/// violet in both states, engaged rings amber over it; disabled pills drop +/// the pointer cursor. +fn toggle_pill_class(on: bool, bound: bool, engaged: bool, disabled: bool) -> String { let surface = if on { "tw:border-status-good-border tw:bg-status-good-bg" } else { "tw:border-border-strong tw:bg-page" }; - let ring = if bound { + let ring = if engaged { + " tw:ring-1 tw:ring-status-attention-border" + } else if bound { " tw:ring-1 tw:ring-status-bound-border" } else { "" @@ -88,16 +95,23 @@ mod tests { #[test] fn green_is_reserved_for_the_on_state() { - assert!(toggle_pill_class(true, false, false).contains("status-good")); - assert!(!toggle_pill_class(false, false, false).contains("status-good")); + assert!(toggle_pill_class(true, false, false, false).contains("status-good")); + assert!(!toggle_pill_class(false, false, false, false).contains("status-good")); assert!(toggle_thumb_class(true).contains("status-good")); assert!(!toggle_thumb_class(false).contains("status-good")); } #[test] fn bound_ring_is_violet_in_both_states() { - assert!(toggle_pill_class(true, true, false).contains("status-bound")); - assert!(toggle_pill_class(false, true, false).contains("status-bound")); - assert!(!toggle_pill_class(false, false, false).contains("status-bound")); + assert!(toggle_pill_class(true, true, false, false).contains("status-bound")); + assert!(toggle_pill_class(false, true, false, false).contains("status-bound")); + assert!(!toggle_pill_class(false, false, false, false).contains("status-bound")); + } + + #[test] + fn engaged_ring_replaces_the_bound_ring() { + let engaged = toggle_pill_class(false, true, true, false); + assert!(engaged.contains("status-attention")); + assert!(!engaged.contains("status-bound")); } } 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..0e3015065 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 @@ -362,6 +362,24 @@ fn aspect_summary(aspect: &UiSlotAspect) -> AspectSummary { UiSlotAspectKind::Validation => validation_summary(aspect), UiSlotAspectKind::EditState => edit_state_summary(aspect), UiSlotAspectKind::Binding => binding_summary(aspect), + // Panel state titles itself: "Held" / "Following" / "At default" + // are the section's whole content, so there is nothing to derive. + UiSlotAspectKind::PanelState => AspectSummary { + title: aspect.title.clone(), + code: None, + title_is_code: false, + icon: match aspect.affordance { + Some(UiSlotAffordance::Edited) => StudioIconName::Edited, + Some(UiSlotAffordance::Bound) => StudioIconName::BoundValue, + _ => StudioIconName::UnboundValue, + }, + tone: match aspect.affordance { + Some(UiSlotAffordance::Edited) => AspectTone::Warning, + Some(UiSlotAffordance::Bound) => AspectTone::Bound, + _ => AspectTone::Quiet, + }, + highlight: aspect.affordance, + }, UiSlotAspectKind::TypeInfo => AspectSummary { title: type_info_title(aspect), code: None, diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index bc7276e76..c87100fa0 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -594,6 +594,16 @@ label:has(> input:not(:disabled)) { border-color: var(--studio-status-bound-border); } +/* Engaged (panel.md P2 latch): a panel writer holds this channel. Amber, + never the bound violet — bound means "wired", engaged means "captured". */ +.ux-hfader.is-engaged::-webkit-slider-thumb { + border-color: var(--studio-status-attention-border); +} + +.ux-hfader.is-engaged::-moz-range-thumb { + border-color: var(--studio-status-attention-border); +} + /* Transitional CSS for story-only editor and exploration surfaces. Production Studio components should prefer semantic Tailwind classes. */