From d883ec9d98a41fd43840dc9a8f81186ea1144025 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:13 -0700 Subject: [PATCH 01/16] feat(studio): module face + panel DTOs for the M2 UX spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DTO layer for docs/design/modules.md R8 and docs/design/panel.md P1/P2: - UiPanelControlState — the three states P-Q2 requires to be visibly distinct (Read-at-default, Read-following-automation, Engaged/Latch). - UiPanelControlView — one channel on one panel: the existing UiPanelControl payload (so knob v2 / fader / toggle are reused, not forked) plus the panel state and its Read-state provenance caption. - UiPanelGroup — a scope's channel list plus nested child-module groups (R8's presentation recursion), with the engaged counts a per-module reset gesture needs. - UiModuleFace / UiModuleChild — the face worn at every depth: output mirror (R7), panel, children nested inside, bus-as-wiring drawer, provenance (§8), and the P11 auto-save flag. Spike posture: these are fed by mock fixtures. Deriving them from real scope data is M4's; nothing in the project layer builds them yet. Co-Authored-By: Claude Fable 5 --- .../lpa-studio-core/src/app/node/face/mod.rs | 6 + .../src/app/node/face/ui_module_face.rs | 135 ++++++++++++++ .../app/node/face/ui_panel_control_view.rs | 113 ++++++++++++ .../src/app/node/face/ui_panel_group.rs | 169 ++++++++++++++++++ lp-app/lpa-studio-core/src/app/node/mod.rs | 4 +- lp-app/lpa-studio-core/src/lib.rs | 19 +- 6 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs create mode 100644 lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs create mode 100644 lp-app/lpa-studio-core/src/app/node/face/ui_panel_group.rs 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..1be8440cd 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::{UiModuleChild, 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..bec85c51a --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs @@ -0,0 +1,135 @@ +//! 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) → children nested inside +//! the card → 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. +//! +//! M2 UX spike: this DTO is fed by mock fixtures. Deriving it from real +//! scope data is M4's work. + +use crate::{UiBusView, UiPanelControlView, 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, + /// Children, nested INSIDE the card: the effect author's zoom level. + pub children: Vec, + /// 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). Lives on the module that owns the scope. + pub auto_save: bool, +} + +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, + children: Vec::new(), + provenance: None, + auto_save: true, + } + } +} + +/// One child inside a module card. +/// +/// A child that is itself a module carries a whole [`UiModuleFace`] and +/// wears the same face one level in — that recursion *is* the "one face at +/// every depth" claim. A leaf child shows its preview and its own panel +/// (its bound slots, R8). +#[derive(Clone, Debug, PartialEq)] +pub struct UiModuleChild { + /// Child node name. + pub name: String, + /// Human-readable node kind ("Shader", "Clock", "Module"). + pub kind: String, + /// One-line role summary under the name. + pub summary: Option, + /// The child's produced visual, when it has one. + pub preview: Option, + /// A leaf child's panel: exactly its bound slots (R3/R8). + pub controls: Vec, + /// Present when the child is a module — it wears the same face. + pub module: Option>, + /// Whether the child renders collapsed to its header row. + pub collapsed: bool, +} + +impl UiModuleChild { + /// A leaf child (shader, clock, fixture…). + pub fn leaf(name: impl Into, kind: impl Into) -> Self { + Self { + name: name.into(), + kind: kind.into(), + summary: None, + preview: None, + controls: Vec::new(), + module: None, + collapsed: false, + } + } + + /// A child module, wearing the same face one level in. + pub fn module(name: impl Into, face: UiModuleFace) -> Self { + Self { + name: name.into(), + kind: "Module".to_string(), + summary: None, + preview: face.preview.clone(), + controls: Vec::new(), + module: Some(Box::new(face)), + collapsed: false, + } + } + + /// Attach the one-line role summary. + pub fn with_summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + /// Attach the child's produced visual. + pub fn with_preview(mut self, preview: UiProducedProduct) -> Self { + self.preview = Some(preview); + self + } + + /// Attach a leaf child's own panel controls. + pub fn with_controls(mut self, controls: Vec) -> Self { + self.controls = controls; + self + } + + /// Render the child collapsed to its header row. + pub fn collapsed(mut self) -> Self { + self.collapsed = true; + self + } +} 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..2596c58b7 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs @@ -0,0 +1,113 @@ +//! 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; + +/// 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 + } +} + +#[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..d4c5504cb --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/face/ui_panel_group.rs @@ -0,0 +1,169 @@ +//! 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; + +/// 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). + pub groups: Vec, + /// Whether this group renders collapsed to its summary row. Nested + /// groups start collapsed on a busy panel; the root group is never + /// collapsed. + pub collapsed: bool, +} + +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(), + collapsed: false, + } + } + + /// 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 + } + + /// Render this group collapsed to its summary row. + pub fn collapsed(mut self) -> Self { + self.collapsed = true; + 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"), + } + } +} + +#[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..080b0a97b 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, UiModuleChild, 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/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index c2534048a..b7bea9e01 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, UiModuleChild, 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}; From b5bec0a8af0509e5fe93328f0bc17ca541160f98 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:19 -0700 Subject: [PATCH 02/16] feat(studio): engaged (latch) treatment on knob, fader, and toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One additive `engaged` prop per widget, defaulting to false, so every existing face renders byte-identically. When set, the value arc / fill / pill ring wears the amber status-attention family instead of the violet bound family. Engaged deliberately OUTRANKS bound: a captured control has stopped following the thing it is wired to, and panel.md P6 says that has to be visible at a glance — bound means "wired", engaged means "captured". Green stays valid-only, so it was never a candidate. A dedicated status-engaged token family is the eventual home; amber is the spike's proposal for the G2 gate to judge. Co-Authored-By: Claude Fable 5 --- .../src/app/node/panel/h_fader_field.rs | 71 +++++++++++++------ .../src/app/node/panel/knob_field.rs | 51 ++++++++++--- .../src/app/node/panel/toggle_field.rs | 32 ++++++--- lp-app/lpa-studio-web/src/style.css | 10 +++ 4 files changed, 125 insertions(+), 39 deletions(-) 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/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. */ From 71e53e5244270db9b204e779f302adc1d126e9e3 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:34 -0700 Subject: [PATCH 03/16] feat(studio): module face, panel surface, and play mode (M2 spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike's components, parallel to app/node/face/ rather than merged into it so the seam is easy to promote or delete: - ModuleFace — output hero, panel, children nested INSIDE the card, the bus wiring drawer, provenance. An embedded module child renders the same component one level in, which is the "one face at every depth" claim made literal (modules.md §5). - ModulePanel / NestedPanelGroup — a scope's controls plus each child module's panel as a hairline-boxed, collapsible group (R8). The header row carries the two module-level gestures: reset-N (present only when something is held) and the P11 auto-save toggle. - ModulePanelControl — the three panel states on the production widgets, with the per-control reset glyph appearing only while engaged. - PlayModeSurface — panels only, no faces (P12). - PanelGesture — the component-level seam standing in for panel.md P8's PanelWrite/PanelClear until M4 adds them; the variants are shaped like the two wire ops so the mapping is mechanical. UiNodeFace gains a Module arm so the face renders inside the real NodePane card chrome, and NodePane threads the panel-gesture handler. Co-Authored-By: Claude Fable 5 --- .../src/app/node/face/ui_node_face.rs | 7 +- lp-app/lpa-studio-web/src/app/mod.rs | 2 + lp-app/lpa-studio-web/src/app/module/mod.rs | 46 +++ .../src/app/module/module_face.rs | 231 ++++++++++++ .../src/app/module/module_panel.rs | 249 +++++++++++++ .../src/app/module/module_panel_control.rs | 337 ++++++++++++++++++ .../src/app/module/panel_gesture.rs | 45 +++ .../src/app/module/play_mode.rs | 71 ++++ .../src/app/node/face/node_face_body.rs | 12 + lp-app/lpa-studio-web/src/app/node/mod.rs | 4 +- .../lpa-studio-web/src/app/node/node_pane.rs | 5 + 11 files changed, 1006 insertions(+), 3 deletions(-) create mode 100644 lp-app/lpa-studio-web/src/app/module/mod.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/module_face.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/module_panel.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/module_panel_control.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/panel_gesture.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/play_mode.rs 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..e5daa5799 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, UiPlaylistFace, UiShaderFace}; /// Kind-specific permanent face for a node card. /// @@ -16,4 +16,9 @@ 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, children nested inside, and the bus-wiring drawer. + /// Worn at every depth — root workspace card and embedded child card + /// alike. **M2 UX spike:** fed by mock fixtures, not yet derived. + Module(UiModuleFace), } 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..65908d025 --- /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::{ModuleChildCard, 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..62b05a1df --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_face.rs @@ -0,0 +1,231 @@ +//! 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. `children` — the module's children, nested INSIDE the card. This is +//! the effect-author zoom level, and the reason the root module belongs +//! back in the node area: the workspace column becomes one card whose +//! inside *is* the project. +//! 4. `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. +//! 5. provenance — a quiet footer line (§8). +//! +//! **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, UiModuleChild, UiModuleFace as UiModuleFaceData}; + +use crate::app::BusPaneBody; +use crate::app::node::{NodeCardSection, ProductPreview}; +use crate::base::{StudioIcon, node_kind_icon}; + +use super::{ModulePanel, ModulePanelControl, PanelGesture}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ModuleFace( + face: UiModuleFaceData, + /// Nesting depth: 0 is the root module's workspace card, 1+ is an + /// embedded module inside its host. Depth only affects chrome density + /// — the sections and their order are identical, which is the claim + /// under test at G2. + #[props(default = 0)] + depth: usize, + /// 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 has_children = !face.children.is_empty(); + 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 belongs to the module that OWNS the scope, so a + // nested face does not repeat its host's toggle. + auto_save: (depth == 0).then_some(face.auto_save), + on_panel, + on_action, + } + } + if has_children { + NodeCardSection { label: "children", + div { class: "tw:grid tw:min-w-0 tw:gap-2 tw:px-4 tw:py-3", + for child in face.children.clone() { + ModuleChildCard { + key: "{child.name}", + child, + depth: depth + 1, + scope: face.panel.scope.clone(), + 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}" + } + } + } +} + +/// One child inside a module card. +/// +/// A child module renders the SAME [`ModuleFace`] one level in — that +/// recursion is the "one face at every depth" claim made literal. A leaf +/// child renders its preview and its own panel (its bound slots, R3/R8). +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ModuleChildCard( + child: UiModuleChild, + depth: usize, + /// The ENCLOSING module's scope. A leaf child introduces no scope of + /// its own (R1), so its bound slots' channels — and therefore its + /// controls' identity (panel.md P1) — belong to this one. + scope: String, + #[props(default = None)] on_panel: Option>, + #[props(default)] on_action: Option>, +) -> Element { + let UiModuleChild { + name, + kind, + summary, + preview, + controls, + module, + collapsed, + } = child; + let is_module = module.is_some(); + // A nested module card wears a slightly stronger edge than a leaf: the + // thing that owns a scope should read as a container. + let card_class = if is_module { + "tw:grid tw:min-w-0 tw:gap-0 tw:overflow-hidden tw:rounded-sm tw:border tw:border-border-strong tw:bg-card" + } else { + "tw:grid tw:min-w-0 tw:gap-0 tw:overflow-hidden tw:rounded-sm tw:border tw:border-border-muted tw:bg-card-subtle" + }; + let scope_hint = module.as_ref().map(|module| module.panel.scope.clone()); + + rsx! { + section { class: card_class, + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2 tw:border-b tw:border-border-muted tw:px-3 tw:py-1.5", + span { class: "tw:inline-flex tw:flex-none tw:text-subtle-foreground", + StudioIcon { name: node_kind_icon(&kind), size: 12 } + } + span { class: "tw:min-w-0 tw:truncate tw:text-sm tw:text-strong-foreground", "{name}" } + if let Some(summary) = summary { + span { class: "tw:min-w-0 tw:truncate tw:text-[11px] tw:text-dim-foreground", "{summary}" } + } + span { class: "tw:ml-auto tw:flex tw:flex-none tw:items-center tw:gap-2", + if let Some(scope) = scope_hint { + span { + class: "tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", + title: "This module introduces a scope", + "scope {scope}" + } + } + span { class: "tw:text-[11px] tw:font-bold tw:lowercase tw:tracking-wide tw:text-dim-foreground", + "{kind}" + } + } + } + if !collapsed { + if let Some(module) = module { + // The same face, one level in. + ModuleFace { + face: *module, + depth, + on_panel, + on_action, + } + } else { + if let Some(preview) = preview { + ProductPreview { + kind: preview.kind, + preview: preview.preview.clone(), + tracking: preview.tracking, + frame: preview.frame, + focus_action: None, + on_action, + } + } + if !controls.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start tw:gap-4 tw:px-3 tw:py-2", + for control in controls { + ModulePanelControl { + key: "{control.channel}", + view: control, + scope: scope.clone(), + on_panel, + on_action, + } + } + } + } + } + } + } + } +} 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..8127fe6b2 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -0,0 +1,249 @@ +//! 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-boxed subsection with its own heading, collapsible +//! to a summary row; the root group is never collapsed (it *is* the panel). +//! +//! The panel's own header row carries the two module-level affordances: +//! +//! - **Reset panel** (`docs/design/panel.md` P2 clear at scope +//! granularity). Present only while something under this scope is held, +//! so an untouched panel shows no destructive control at all, and the +//! count says how much it would drop. +//! - **Auto-save** (P11 — on by default, with a user toggle). 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. +//! +//! Placement is a G2 question: header row of the panel section (here) vs. +//! the card header vs. a footer under the controls. + +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiPanelGroup}; + +use crate::base::{StudioIcon, StudioIconName}; + +use super::{ModulePanelControl, PanelGesture}; + +#[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, + #[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. Reset only exists when there is + // something to clear; auto-save is always legible when shown. + if held > 0 || auto_save.is_some() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-3", + 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}", + view: control, + scope: scope.clone(), + play, + on_panel, + on_action, + } + } + } + } + for group in panel.groups.clone() { + NestedPanelGroup { + key: "{group.scope}", + group, + play, + on_panel, + on_action, + } + } + } + } +} + +/// One embedded module's panel inside its host's panel (R8 recursion). +/// +/// Hairline-boxed rather than full-bleed: the box is what makes "these +/// knobs belong to plasma_1, those to plasma_2" readable at a glance, which +/// is the whole point of two side-by-side instances. +#[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>, + #[props(default)] on_action: Option>, +) -> Element { + let held = group.engaged_total(); + let summary = group.summary(); + let scope = group.scope.clone(); + let toggle_scope = group.scope.clone(); + let collapsed = group.collapsed; + let label = group.label.clone(); + let heading_class = if held > 0 { + "tw:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-status-attention-foreground" + } else { + "tw:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-subtle-foreground" + }; + + rsx! { + section { class: "tw:grid tw:min-w-0 tw:gap-2 tw:rounded-sm tw:border tw:border-border-muted tw:bg-card-subtle tw:px-3 tw:py-2", + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2", + button { + class: "tw:group tw:inline-flex tw:min-w-0 tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1.5 tw:border-0 tw:bg-transparent tw:p-0 tw:text-left", + r#type: "button", + aria_expanded: "{!collapsed}", + title: if collapsed { "Expand {label}" } else { "Collapse {label}" }, + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_panel { + handler.call(PanelGesture::ToggleGroup { scope: toggle_scope.clone() }); + } + }, + span { class: "tw:inline-flex tw:flex-none tw:text-subtle-foreground", + StudioIcon { + name: if collapsed { StudioIconName::Collapsed } else { StudioIconName::Expanded }, + size: 11, + } + } + span { class: "tw:truncate {heading_class}", "{group.label}" } + } + // The scope path: the identity half of every control below + // (panel.md P1), and what makes two instances distinct. + span { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", + "{group.scope}" + } + span { class: "tw:ml-auto tw:flex tw:flex-none tw:items-center tw:gap-2", + span { class: "tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", "{summary}" } + if held > 0 && let Some(handler) = on_panel { + PanelResetButton { scope: scope.clone(), held, on_panel: handler } + } + } + } + if !collapsed { + if !group.controls.is_empty() { + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start tw:gap-4", + for control in group.controls.clone() { + ModulePanelControl { + key: "{control.channel}", + view: control, + scope: scope.clone(), + play, + on_panel, + on_action, + } + } + } + } + for nested in group.groups.clone() { + NestedPanelGroup { + key: "{nested.scope}", + group: nested, + play, + on_panel, + on_action, + } + } + } + } + } +} + +/// "Reset · N held" — the per-scope clear (P2). Amber, matching the state +/// it removes; it appears only when there is a writer to remove. +#[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" }; + rsx! { + button { + class: "tw:inline-flex tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-status-attention-border tw:bg-status-attention-bg tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-status-attention-foreground", + r#type: "button", + title: "Reset {held} held {noun} in {scope} — panel writers are dropped and the project drives again", + onclick: move |event| { + event.stop_propagation(); + on_panel.call(PanelGesture::ClearScope { scope: scope.clone() }); + }, + StudioIcon { name: StudioIconName::Revert, size: 10 } + "reset {held}" + } + } +} + +/// The P11 auto-save toggle: whether held values persist to +/// `.lp/state.json` and come back on the next boot. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn AutoSaveToggle( + auto_save: bool, + #[props(default = None)] on_panel: Option>, +) -> Element { + let class = if auto_save { + "tw:inline-flex tw:flex-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-raised tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-muted-foreground" + } else { + "tw:inline-flex tw:flex-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-border-muted tw:bg-transparent tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-dim-foreground" + }; + let title = if auto_save { + "Panel settings are saved and restored on boot (.lp/state.json)" + } else { + "Panel settings are NOT saved — they are lost on restart" + }; + + rsx! { + button { + class: "{class} tw:cursor-pointer", + r#type: "button", + role: "switch", + aria_checked: "{auto_save}", + title: "{title}", + onclick: move |event| { + event.stop_propagation(); + if let Some(handler) = on_panel { + handler.call(PanelGesture::SetAutoSave(!auto_save)); + } + }, + StudioIcon { name: StudioIconName::Save, size: 10 } + if auto_save { "auto-save" } else { "auto-save off" } + } + } +} 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..e6a8b06fe --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_panel_control.rs @@ -0,0 +1,337 @@ +//! One control on a module's panel, wearing its panel state. +//! +//! The three states `docs/design/panel.md` P-Q2 requires to be visibly +//! distinct, rendered with the existing knob v2 / fader / toggle widgets so +//! the panel and the node face speak one widget language: +//! +//! | state | widget | label | caption | +//! |---|---|---|---| +//! | Read, at default | accent arc at the authored default | subtle | `default` + the value's origin | +//! | Read, following | **violet** arc at the LIVE value | violet | `following …` (who is driving) | +//! | Engaged (Latch) | **amber** arc + amber body ring | amber | `held` + the reset gesture | +//! +//! 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 gate question is +//! whether amber reads as "captured" at a glance. +//! +//! **Reset** (P2 clear) is per control: the small revert glyph appears +//! beside the label ONLY while engaged, because there is nothing to clear +//! otherwise — the affordance's presence is itself part of the state +//! signal. The per-module reset lives on the group header +//! ([`super::ModulePanel`]). + +use dioxus::prelude::*; +use lpa_studio_core::{ + UiAction, UiPanelControlState, UiPanelControlView, UiPanelWidget, UiSlotValueKind, +}; + +use crate::app::node::{HFaderField, KnobField, PanelEmit, SlotUnitSuffix, ToggleField}; +use crate::base::{StudioIcon, StudioIconName}; + +use super::PanelGesture; + +#[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) and what a reset gesture carries. + scope: String, + /// Play mode renders the same control at a roomier size and drops the + /// authoring-side captions to one line. + #[props(default = false)] + play: bool, + /// Panel gestures (reset). Absent = the control is display-only. + #[props(default = None)] + on_panel: Option>, + /// 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 UiPanelControlView { + channel, + control, + state, + source, + } = view; + let engaged = state.engaged(); + let following = matches!(state, UiPanelControlState::ReadFollowing); + let label_class = panel_state_label_class(state); + let caption = control_caption(state, source.as_deref()); + let reset_scope = scope.clone(); + let reset_channel = channel.clone(); + + let label = rsx! { + span { class: "tw:inline-flex tw:min-w-0 tw:items-center tw:gap-1", + span { class: "tw:truncate tw:text-[0.66rem] tw:font-bold tw:uppercase tw:leading-none tw:tracking-[0.08em] {label_class}", + "{control.label}" + } + // Reset exists only where there is a writer to remove (P2). + 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 {control.label} — drop the held value and follow the project again", + aria_label: "Reset {control.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 readout_class = panel_state_readout_class(state); + let readout = rsx! { + span { class: "tw:inline-flex tw:items-baseline tw:gap-1 tw:font-mono tw:text-[0.7rem] {readout_class}", + span { "{shown_display(&control.value.display, control.live_value.as_deref(), state)}" } + SlotUnitSuffix { unit: control.unit.clone(), reserve: false } + } + }; + + let caption_row = rsx! { + if let Some(caption) = caption.clone() { + span { + class: "tw:max-w-[13ch] tw:truncate tw:text-center tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", + title: "{caption}", + "{caption}" + } + } + }; + + let column_class = 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" + }; + + 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! { + div { class: column_class, + 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} + {caption_row} + } + } + } + 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:grid tw:min-w-0 tw:gap-1", + div { class: "tw:flex 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, + } + if let Some(caption) = caption { + span { class: "tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", "{caption}" } + } + } + } + } + UiPanelWidget::Toggle => { + let UiSlotValueKind::Bool(value) = control.value.kind else { + return mismatch(&control.label, &control.value.display); + }; + rsx! { + div { class: column_class, + 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} + {caption_row} + } + } + } + } +} + +/// 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 caption under the control: what state it is in and, in Read, whose +/// value is on screen (P2 — the UI distinguishes inherited / authored / +/// default). +fn control_caption(state: UiPanelControlState, source: Option<&str>) -> Option { + match (state, source) { + (UiPanelControlState::Engaged, Some(source)) => Some(format!("held · was {source}")), + (UiPanelControlState::Engaged, None) => Some("held".to_string()), + (_, Some(source)) => Some(source.to_string()), + (UiPanelControlState::ReadDefault, None) => Some("default".to_string()), + (UiPanelControlState::ReadFollowing, None) => Some("following".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:text-muted-foreground", "{display}" } + } + } +} + +#[cfg(test)] +mod tests { + use lpa_studio_core::UiPanelControlState; + + use super::{ + control_caption, panel_state_label_class, panel_state_readout_class, shown_display, + }; + + #[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" + ); + } + + #[test] + fn captions_say_what_the_state_is_and_what_it_displaced() { + assert_eq!( + control_caption(UiPanelControlState::Engaged, Some("clock")), + Some("held · was clock".to_string()) + ); + assert_eq!( + control_caption(UiPanelControlState::ReadFollowing, Some("clock")), + Some("clock".to_string()) + ); + assert_eq!( + control_caption(UiPanelControlState::ReadDefault, None), + Some("default".to_string()) + ); + } +} 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..187ab530d --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs @@ -0,0 +1,45 @@ +//! 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 +//! - [`PanelGesture::ToggleGroup`] is pure disclosure — it has no wire op +//! and never will; it is view state (a `CardUiState` sibling at M4). +//! +//! 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, + }, + /// Collapse or expand a nested panel group. Disclosure only. + ToggleGroup { + /// The nested group's scope. + scope: String, + }, + /// Flip panel-state auto-save (P11 — on by default). + SetAutoSave(bool), +} 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..59de6a777 --- /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. + #[props(default = true)] + auto_save: bool, + #[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: Some(auto_save), + play: true, + on_panel, + on_action, + } + } + } +} 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..a5d298ad2 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,6 +19,7 @@ use lpa_studio_core::{ NodeCardUiState, UiAction, UiAddNodeMenu, UiNodeFace, UiNodeSection, UiPendingEdit, }; +use crate::app::module::{ModuleFace, PanelGesture}; use crate::app::node::NodeDirtyTint; use crate::base::Platform; @@ -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,13 @@ pub fn NodeFaceBody( on_action, } }, + // M2 UX spike: the module face carries its own drawers + // (wiring) and nests its children inside the card, so it + // does NOT compose `NodeCardDrawers` — the advanced slot + // view lives on the individual child nodes instead. + UiNodeFace::Module(module) => rsx! { + ModuleFace { face: module, on_panel: module_panel, 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_pane.rs b/lp-app/lpa-studio-web/src/app/node/node_pane.rs index d30b44a20..4e0e9dacb 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 { From b5dd9f1a30452f494318f5861544343c67c163e7 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:42 -0700 Subject: [PATCH 04/16] =?UTF-8?q?feat(studio):=20module-face=20stories=20?= =?UTF-8?q?=E2=80=94=20root=20card,=20zoom=20levels,=20bus=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six stories carrying G2 questions 1-3, on mock fixtures that reproduce the design doc's worked examples rather than inventing content: E3's drop-in embed, E4's shared control and detach, and panel.md's scarf. - root_card — the root module as the single top-level workspace card (flat-root reversal). Walkable: the fixture state is real, so a knob drag actually engages the control and a reset actually lets go. - author_zoom — every child expanded, both embedded modules wearing the same face one level in. - embedded_module — the artist zoom level: one effect as its own card. - nested_groups — two instances of one effect side by side, one detached and one following, on the root panel. - wiring_drawer_open / workspace_no_bus_pane — the bus split: controls on the face, writers/readers in a drawer, sidebar pane gone. The fixtures keep a pristine Read face as the clear-target and apply "already held" controls through the same transition a live touch takes, so a reset in the walkable stories lands somewhere honest. Co-Authored-By: Claude Fable 5 --- .../src/app/module/module_face_stories.rs | 148 +++ .../src/app/module/module_fixtures.rs | 846 ++++++++++++++++++ 2 files changed, 994 insertions(+) create mode 100644 lp-app/lpa-studio-web/src/app/module/module_face_stories.rs create mode 100644 lp-app/lpa-studio-web/src/app/module/module_fixtures.rs 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..b0f0f1a08 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_face_stories.rs @@ -0,0 +1,148 @@ +//! 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 +//! — so what is being judged is the face inside a genuine workspace card, +//! not a mock frame. + +use dioxus::prelude::*; +use lpa_studio_core::UiNodeFace; +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, module_node_view, plasma_face, + plasma_one_panel, root_face, 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, children nested INSIDE the card, and the wiring drawer. 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. + let mut spike = use_signal(|| PanelSpike::new(root_face()).with_held(HELD)); + let mut view = root_module_node_view(); + view.face = Some(UiNodeFace::Module(spike().face.clone())); + + rsx! { + WorkspaceCanvas { + NodePane { + view, + 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 = "Author zoom: every child expanded inside the root card, including both embedded plasma modules wearing the SAME face one level in. Shows the presentation overlap the model implies — an embedded module's controls appear both as a nested panel group above and on its own child card below." +)] +fn author_zoom() -> Element { + let mut face = held_root_face(); + for child in &mut face.children { + child.collapsed = false; + } + rsx! { + WorkspaceCanvas { + NodePane { + view: module_node_view("Aurora Sign", ROOT_SCOPE, "5 nodes · 2 effects", face), + 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, its child shader, wiring drawer, provenance line." +)] +fn embedded_module() -> Element { + rsx! { + div { class: "tw:w-full tw:max-w-md", + NodePane { + view: module_node_view( + "plasma_1", + PLASMA_1_SCOPE, + "effect · detached", + plasma_face(plasma_one_panel(), 3.1), + ), + 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." +)] +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): two instances of one effect side by side on the root panel — plasma_1 expanded and detached (amber), plasma_2 collapsed to its summary row and still following the host. Same channel names, different scopes, independent controls." +)] +fn nested_groups() -> Element { + let mut face = held_root_face(); + face.preview = None; + face.children.clear(); + face.wiring = None; + if let Some(second) = face.panel.groups.get_mut(1) { + second.collapsed = true; + } + 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 is the whole node area, and the bus lives on it — controls on the face, wiring in the drawer. Compare against studio/bus/bus-pane/fyeah-sign, which is what this replaces." +)] +fn workspace_no_bus_pane() -> Element { + let mut face = held_root_face(); + face.wiring_open = true; + if let Some(second) = face.panel.groups.get_mut(1) { + second.collapsed = 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: module_node_view("Aurora Sign", ROOT_SCOPE, "5 nodes · 2 effects", face), + 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..42cbd9a5b --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs @@ -0,0 +1,846 @@ +//! 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, UiModuleChild, UiModuleFace, UiNodeFace, + UiNodeHeader, 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 { + UiPanelGroup::new("plasma", scope).with_controls(vec![ + speed, + at_default( + knob(scope, "hue", "hue", 0.32, 0.0, 1.0, None), + "authored default", + ), + ]) +} + +/// 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. +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, + children: vec![ + UiModuleChild::leaf("sim", "Shader") + .with_summary("visual → bus:visual.out") + .with_preview( + UiProducedProduct::visual("output") + .with_tracking(UiProductTrackingState::Tracking) + .with_frame(UiProductPreviewFrame::new(16, 5)) + .with_preview(aurora_preview(48, 15, seed + 0.4)), + ), + ], + provenance: Some("PhotomancerArt · v1.2 · CC0-1.0".to_string()), + auto_save: true, + } +} + +/// 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 most stories render, and what [`PanelSpike`] clears back from. +pub(crate) fn held_root_face() -> UiModuleFace { + PanelSpike::new(root_face()).with_held(HELD).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, + children: root_children(), + provenance: Some("Yona · v0.4 · created 2026-07-31".to_string()), + auto_save: true, + } +} + +/// The root module's children, nested inside the card: two leaves that +/// write host channels, the two embedded plasma modules, and the fixture. +pub(crate) fn root_children() -> Vec { + vec![ + UiModuleChild::leaf("clock", "Clock").with_summary("seconds → bus:time"), + UiModuleChild::leaf("Master speed", "Button") + .with_summary("value → bus:speed") + .with_controls(vec![following( + knob(ROOT_SCOPE, "speed", "value", 0.62, 0.0, 1.0, None), + "0.62", + "this node writes it", + )]), + UiModuleChild::module( + "plasma_1", + plasma_face(plasma_read_panel(PLASMA_1_SCOPE), 3.1), + ) + .with_summary("effect") + .collapsed(), + UiModuleChild::module( + "plasma_2", + plasma_face(plasma_read_panel(PLASMA_2_SCOPE), 6.5), + ) + .with_summary("effect") + .collapsed(), + // 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 inside a single card. + UiModuleChild::leaf("halo", "Fixture") + .with_summary("241 LEDs · input ← bus:visual.out") + .with_controls(vec![at_default( + fader(ROOT_SCOPE, "brightness", "brightness", 200.0, 255.0), + "authored 200", + )]), + ] +} + +/// 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 — the single top-level workspace +/// card (the flat-root reversal, §5). +pub(crate) fn root_module_node_view() -> UiNodeView { + module_node_view( + "Aurora Sign", + ROOT_SCOPE, + "5 nodes · 2 effects", + root_face(), + ) +} + +/// 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 face plus a pristine baseline, applies the widget's own +/// `SlotEditOp::SetValue` dispatch as a panel write, and applies +/// [`PanelGesture`]s as clears. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PanelSpike { + /// The face the stories render. + pub face: UiModuleFace, + /// The untouched face, for restoring cleared controls. + baseline: UiModuleFace, +} + +impl PanelSpike { + /// Wrap a **Read-form** face as walkable spike state. The face passed + /// in becomes the clear-target, so it must have nothing engaged. + pub fn new(face: UiModuleFace) -> Self { + Self { + baseline: face.clone(), + 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 { + engage_face(&mut self.face, scope, channel, *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_controls(&mut self.face, &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) => self.face.auto_save = *next, + PanelGesture::ToggleGroup { scope } => { + visit_groups(&mut self.face, &mut |group| { + if group.scope == *scope { + group.collapsed = !group.collapsed; + } + }); + } + 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_controls_scoped(&mut self.baseline.clone(), &mut |scope, view| { + pristine.push((scope.to_string(), view.clone())); + }); + visit_controls_scoped(&mut self.face, &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 on a face: the same +/// `(scope, channel)` is ONE control (panel.md P1), so a nested panel group +/// and the child card that repeats it move together. +fn engage_face(face: &mut UiModuleFace, scope: &str, channel: &str, value: f32) { + visit_controls_scoped(face, &mut |control_scope, view| { + if control_scope == scope && view.channel == channel { + hold(view, value); + } + }); +} + +/// The same, within one panel group tree. +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 control on a face — the module's own panel, its nested +/// groups, its leaf children's controls, and every nested module face. +fn visit_controls(face: &mut UiModuleFace, visit: &mut impl FnMut(&mut UiPanelControlView)) { + visit_controls_scoped(face, &mut |_, view| visit(view)); +} + +/// The same walk, carrying each control's owning scope. +fn visit_controls_scoped( + face: &mut UiModuleFace, + visit: &mut impl FnMut(&str, &mut UiPanelControlView), +) { + visit_group_controls(&mut face.panel, visit); + let scope = face.panel.scope.clone(); + for child in &mut face.children { + for view in &mut child.controls { + visit(&scope, view); + } + if let Some(module) = child.module.as_mut() { + visit_controls_scoped(module, 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 on a face, including nested module faces. +fn visit_groups(face: &mut UiModuleFace, visit: &mut impl FnMut(&mut UiPanelGroup)) { + visit_group(&mut face.panel, visit); + for child in &mut face.children { + if let Some(module) = child.module.as_mut() { + visit_groups(module, 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::UiPanelControlState; + + use super::{ + HELD, PLASMA_1_SCOPE, PLASMA_2_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, root_face, + }; + use crate::app::module::PanelGesture; + + fn spike() -> PanelSpike { + PanelSpike::new(root_face()).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_and_group_disclosure_are_view_gestures() { + let mut spike = spike(); + assert!(spike.face.auto_save); + spike.apply_gesture(&PanelGesture::SetAutoSave(false)); + assert!(!spike.face.auto_save); + + assert!(!spike.face.panel.groups[1].collapsed); + spike.apply_gesture(&PanelGesture::ToggleGroup { + scope: PLASMA_2_SCOPE.to_string(), + }); + assert!(spike.face.panel.groups[1].collapsed); + } +} From 25536ae6e79e3fe0b04444602f9c1b38968ea223 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:57 -0700 Subject: [PATCH 05/16] =?UTF-8?q?feat(studio):=20panel-state=20stories=20?= =?UTF-8?q?=E2=80=94=20three=20states,=20reset,=20auto-save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2 question 5, and panel.md P-Q2's "confirm the three states are visibly distinct" turned into something judgeable in pixels: - three_states — all three states across knob, fader, and toggle. - engaged_vs_bound — amber against violet on one channel, one range. - reset_gestures — per-control glyph vs per-module chip, with the chip counting everything under the scope including nested groups. - auto_save_off — the P11 toggle in the panel header, on the module that owns the scope rather than in app settings. - latch_walkthrough — walkable Read to Latch to Clear from a pristine panel, so the first touch is the thing being felt. Co-Authored-By: Claude Fable 5 --- .../src/app/module/panel_state_stories.rs | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 lp-app/lpa-studio-web/src/app/module/panel_state_stories.rs 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..432c1395c --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/panel_state_stories.rs @@ -0,0 +1,132 @@ +//! 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::{ + PanelSpike, ROOT_SCOPE, held_root_face, root_face, 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. Read-at-default = quiet accent, subtle label, caption says nothing writes it. Read-following = violet at the LIVE value, caption names the writer. Engaged = amber arc/fill/ring plus a per-control reset glyph and a 'held · was …' caption." +)] +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 — and per module — the 'reset N' chip in the panel header, which counts everything under the scope including nested groups. An untouched panel shows no destructive control at all." +)] +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 toggle sits in the panel's header row beside the reset chip, 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 = false; + rsx! { + PanelCanvas { + ModulePanel { + panel: face.panel, + auto_save: Some(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 header chip 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_face())); + + rsx! { + PanelCanvas { + ModulePanel { + panel: spike().face.panel.clone(), + auto_save: Some(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 = "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 |_| {}, + } + } + } +} From 2818408479fedac44288183e141c8f6c87808058 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:57 -0700 Subject: [PATCH 06/16] feat(studio): playlist meta-switching mock (modules.md E2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 0-1 "Drift" becomes 0-10 "Whirl" on the same channel name. entry_drift and entry_whirl are the two static ends; meta_switch is walkable — each entry gets its OWN state holder, which is the model's separation and not a story convenience, so tweaking Drift, switching to Whirl, and switching back finds the tweak still there. Entry activation has no wire op today, so the switch rides plain buttons; what is under test is what happens to the panel, not how the entry gets activated. Co-Authored-By: Claude Fable 5 --- .../src/app/module/playlist_panel_stories.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 lp-app/lpa-studio-web/src/app/module/playlist_panel_stories.rs 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..bc1455b16 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/playlist_panel_stories.rs @@ -0,0 +1,157 @@ +//! 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_face(0)).with_held(&[(scope.as_str(), "speed", 0.35)]) + }); + let mut whirl = use_signal(|| PanelSpike::new(spike_face(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_face(entry: u32) -> lpa_studio_core::UiModuleFace { + lpa_studio_core::UiModuleFace::new(entry_panel(entry)) +} From e32348dca2b1f163b232174bbb3d26827a409dc8 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 14:56:57 -0700 Subject: [PATCH 07/16] =?UTF-8?q?feat(studio):=20play-mode=20mock=20?= =?UTF-8?q?=E2=80=94=20root=20panel=20only,=20desktop=20and=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2 question 4. Panels only, no faces (panel.md P12): the root module's panel with its nested effect groups over a slim output banner — no hero-dominant preview, no children, no wiring, no card chrome. Reset and auto-save stay, because the scarf scenario (P10) is an end user's. Three widths/densities: desktop, phone (375px), and phone with the nested groups collapsed — the last one asking which should be the default when a project embeds several effects. Co-Authored-By: Claude Fable 5 --- .../src/app/module/play_mode_stories.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 lp-app/lpa-studio-web/src/app/module/play_mode_stories.rs 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..daeea6fc1 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/module/play_mode_stories.rs @@ -0,0 +1,73 @@ +//! 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 channels plus the two effect groups — over a slim output banner. No hero-dominant preview, no children, no wiring, no card chrome. 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 = "Play mode with the nested groups collapsed: the end-user default when a project embeds several effects — the root's own controls up front, each effect one tap away. Compare against the expanded desktop story for which should be the default." +)] +fn groups_collapsed() -> Element { + let mut face = held_root_face(); + for group in &mut face.panel.groups { + group.collapsed = true; + } + rsx! { + div { class: "tw:h-[560px] 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 |_| {}, + } + } + } +} From 4e407e53768a1613f9f21493879a00d319195a29 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 15:21:35 -0700 Subject: [PATCH 08/16] polish(studio): panel legibility at phone width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the first capture pass: at 375px the nested group header squeezed "plasma" down to "P" and the scope path to "/aurora.m…". The header row now wraps instead of shrinking, so the summary and reset chip drop to a second line and the group keeps its name. Control captions get a few more characters before truncating ("held · was inherited …" instead of "held · was inhe…"). Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/src/app/module/module_panel.rs | 9 ++++++--- .../src/app/module/module_panel_control.rs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index 8127fe6b2..7e8be91b7 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_panel.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -128,9 +128,12 @@ pub fn NestedPanelGroup( rsx! { section { class: "tw:grid tw:min-w-0 tw:gap-2 tw:rounded-sm tw:border tw:border-border-muted tw:bg-card-subtle tw:px-3 tw:py-2", - div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2", + // Wraps rather than squeezes: at phone width the summary and + // reset chip drop to a second line instead of crushing the + // group's name to one letter. + div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-x-2 tw:gap-y-1", button { - class: "tw:group tw:inline-flex tw:min-w-0 tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1.5 tw:border-0 tw:bg-transparent tw:p-0 tw:text-left", + class: "tw:group tw:inline-flex tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1.5 tw:border-0 tw:bg-transparent tw:p-0 tw:text-left", r#type: "button", aria_expanded: "{!collapsed}", title: if collapsed { "Expand {label}" } else { "Collapse {label}" }, @@ -146,7 +149,7 @@ pub fn NestedPanelGroup( size: 11, } } - span { class: "tw:truncate {heading_class}", "{group.label}" } + span { class: heading_class, "{group.label}" } } // The scope path: the identity half of every control below // (panel.md P1), and what makes two instances distinct. 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 index e6a8b06fe..c3ed647bb 100644 --- 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 @@ -102,7 +102,7 @@ pub fn ModulePanelControl( let caption_row = rsx! { if let Some(caption) = caption.clone() { span { - class: "tw:max-w-[13ch] tw:truncate tw:text-center tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", + class: "tw:max-w-[17ch] tw:truncate tw:text-center tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", title: "{caption}", "{caption}" } From 8edde58758d6aeae75550fd478d2f65d7d2e40ba Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:24:18 -0700 Subject: [PATCH 09/16] refactor(studio): children leave the module face (G2 rev 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module's children are not part of its face. They render below the card as full sibling cards through the existing UiNodeChild path — the same grammar the playlist's active child and the old project node use — so the module contributes no new nesting rules of its own. Drops UiModuleChild (children are UiNodeChild now) and adds UiNodeFace::Controls for a leaf whose whole face is its bound slots: the clock, the control node, the fixture. Its group carries the ENCLOSING scope, because a leaf introduces no scope — which is what keeps a fixture's brightness and the module panel's brightness one control with one panel state (panel.md P1). Co-Authored-By: Claude Fable 5 --- .../lpa-studio-core/src/app/node/face/mod.rs | 2 +- .../src/app/node/face/ui_module_face.rs | 96 +++---------------- .../src/app/node/face/ui_node_face.rs | 17 +++- lp-app/lpa-studio-core/src/app/node/mod.rs | 2 +- lp-app/lpa-studio-core/src/lib.rs | 2 +- lp-app/lpa-studio-web/assets/tailwind.css | 48 ++++++++-- 6 files changed, 66 insertions(+), 101 deletions(-) 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 1be8440cd..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 @@ -24,7 +24,7 @@ mod ui_playlist_face; mod ui_shader_face; pub use ui_fixture_face::UiFixtureFace; -pub use ui_module_face::{UiModuleChild, UiModuleFace}; +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}; 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 index bec85c51a..37922bdfc 100644 --- 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 @@ -6,16 +6,21 @@ //! 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) → children nested inside -//! the card → 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. +//! 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, UiPanelControlView, UiPanelGroup, UiProducedProduct}; +use crate::{UiBusView, UiPanelGroup, UiProducedProduct}; /// A module node card's permanent face. #[derive(Clone, Debug, PartialEq)] @@ -33,8 +38,6 @@ pub struct UiModuleFace { pub wiring: Option, /// Whether the wiring drawer renders expanded. pub wiring_open: bool, - /// Children, nested INSIDE the card: the effect author's zoom level. - pub children: Vec, /// Compact provenance line ("Yona · v1 · CC0-1.0"); `None` when the /// module carries no provenance fields (§8). pub provenance: Option, @@ -51,85 +54,8 @@ impl UiModuleFace { panel, wiring: None, wiring_open: false, - children: Vec::new(), provenance: None, auto_save: true, } } } - -/// One child inside a module card. -/// -/// A child that is itself a module carries a whole [`UiModuleFace`] and -/// wears the same face one level in — that recursion *is* the "one face at -/// every depth" claim. A leaf child shows its preview and its own panel -/// (its bound slots, R8). -#[derive(Clone, Debug, PartialEq)] -pub struct UiModuleChild { - /// Child node name. - pub name: String, - /// Human-readable node kind ("Shader", "Clock", "Module"). - pub kind: String, - /// One-line role summary under the name. - pub summary: Option, - /// The child's produced visual, when it has one. - pub preview: Option, - /// A leaf child's panel: exactly its bound slots (R3/R8). - pub controls: Vec, - /// Present when the child is a module — it wears the same face. - pub module: Option>, - /// Whether the child renders collapsed to its header row. - pub collapsed: bool, -} - -impl UiModuleChild { - /// A leaf child (shader, clock, fixture…). - pub fn leaf(name: impl Into, kind: impl Into) -> Self { - Self { - name: name.into(), - kind: kind.into(), - summary: None, - preview: None, - controls: Vec::new(), - module: None, - collapsed: false, - } - } - - /// A child module, wearing the same face one level in. - pub fn module(name: impl Into, face: UiModuleFace) -> Self { - Self { - name: name.into(), - kind: "Module".to_string(), - summary: None, - preview: face.preview.clone(), - controls: Vec::new(), - module: Some(Box::new(face)), - collapsed: false, - } - } - - /// Attach the one-line role summary. - pub fn with_summary(mut self, summary: impl Into) -> Self { - self.summary = Some(summary.into()); - self - } - - /// Attach the child's produced visual. - pub fn with_preview(mut self, preview: UiProducedProduct) -> Self { - self.preview = Some(preview); - self - } - - /// Attach a leaf child's own panel controls. - pub fn with_controls(mut self, controls: Vec) -> Self { - self.controls = controls; - self - } - - /// Render the child collapsed to its header row. - pub fn collapsed(mut self) -> Self { - self.collapsed = true; - self - } -} 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 e5daa5799..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, UiModuleFace, UiPlaylistFace, UiShaderFace}; +use crate::{UiFixtureFace, UiModuleFace, UiPanelGroup, UiPlaylistFace, UiShaderFace}; /// Kind-specific permanent face for a node card. /// @@ -17,8 +17,17 @@ pub enum UiNodeFace { /// below via the existing [`crate::UiNodeChild`]. Playlist(UiPlaylistFace), /// Module card (`docs/design/modules.md` §5): output-mirror hero, the - /// scope's panel, children nested inside, and the bus-wiring drawer. - /// Worn at every depth — root workspace card and embedded child card - /// alike. **M2 UX spike:** fed by mock fixtures, not yet derived. + /// 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/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs index 080b0a97b..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,7 +38,7 @@ mod ui_slot_unit; mod ui_slot_value; pub use face::{ - UiFixtureFace, UiModuleChild, UiModuleFace, UiNodeFace, UiPanelControl, UiPanelControlState, + UiFixtureFace, UiModuleFace, UiNodeFace, UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelGroup, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiShaderFace, }; pub use ui_asset_editor::UiAssetEditor; diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index b7bea9e01..d0ddfa1e9 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -45,7 +45,7 @@ pub use app::home::{ pub use app::node::{ UiAssetEditor, UiAssetEditorKind, UiBindingAuthoring, UiBindingAuthoringDirection, UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, - UiControlSampleFormat, UiFixtureFace, UiModuleChild, UiModuleFace, UiNodeChild, + UiControlSampleFormat, UiFixtureFace, UiModuleFace, UiNodeChild, UiNodeDirtyState, UiNodeFace, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelGroup, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedBinding, UiProducedBindings, diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 2bc7cface..569280a53 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -180,9 +180,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); } @@ -333,6 +330,9 @@ .tw\:h-\[560px\] { height: 560px; } + .tw\:h-\[720px\] { + height: 720px; + } .tw\:h-full { height: 100%; } @@ -528,6 +528,9 @@ .tw\:w-\[340px\] { width: 340px; } + .tw\:w-\[375px\] { + width: 375px; + } .tw\:w-\[720px\] { width: 720px; } @@ -576,6 +579,9 @@ .tw\:max-w-96 { max-width: calc(var(--tw-spacing) * 96); } + .tw\:max-w-\[17ch\] { + max-width: 17ch; + } .tw\:max-w-\[85\%\] { max-width: 85%; } @@ -594,6 +600,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%; } @@ -627,6 +645,12 @@ .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; } @@ -896,6 +920,9 @@ .tw\:gap-y-0\.5 { row-gap: calc(var(--tw-spacing) * 0.5); } + .tw\:gap-y-1 { + row-gap: calc(var(--tw-spacing) * 1); + } .tw\:gap-y-2 { row-gap: calc(var(--tw-spacing) * 2); } @@ -1157,12 +1184,6 @@ .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); } @@ -1352,6 +1373,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); } @@ -1541,6 +1565,9 @@ .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 +1816,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); } From 9b82aaea48175fccc922add30d00de94687d8613 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:44:02 -0700 Subject: [PATCH 10/16] feat(studio): module children expand under the card as sibling cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2 revision 1. The module face keeps output hero, panel (nested child-module groups included), wiring drawer, and provenance; every child — clock, control node, embedded modules, fixture — now renders BELOW the card on the NodeChildren rail, the same grammar the playlist's active child and the old project node already use. All of them render: module children are collaborators, not branches, so there is no active-child filtering here. An embedded module therefore wears its face in a card of its own, which makes "one face at every depth" literal instead of illustrative, and keeps the host card a readable fixed height. Its controls still appear twice — nested group above, own card below — and that is kept deliberately: it is the playlist precedent, one control with two views (panel.md P1), held in lockstep by the shared (scope, channel) identity. PanelSpike now walks the whole node view rather than one face, because that identity genuinely spans two cards now: holding brightness on the panel has to move the fixture card below it, and a test pins that. auto_save becomes Option — only the module that presents the switch carries it, since persistence is per project folder (P11). Co-Authored-By: Claude Fable 5 --- .../src/app/node/face/ui_module_face.rs | 9 +- lp-app/lpa-studio-core/src/lib.rs | 20 +- lp-app/lpa-studio-web/src/app/module/mod.rs | 2 +- .../src/app/module/module_face.rs | 153 ++------ .../src/app/module/module_face_stories.rs | 75 ++-- .../src/app/module/module_fixtures.rs | 331 ++++++++++++------ .../src/app/module/panel_state_stories.rs | 12 +- .../src/app/module/play_mode.rs | 8 +- .../src/app/module/playlist_panel_stories.rs | 17 +- .../src/app/node/face/node_face_body.rs | 29 +- .../src/app/node/node_children.rs | 5 + .../lpa-studio-web/src/app/node/node_pane.rs | 1 + 12 files changed, 348 insertions(+), 314 deletions(-) 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 index 37922bdfc..849fab989 100644 --- 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 @@ -42,8 +42,11 @@ pub struct UiModuleFace { /// module carries no provenance fields (§8). pub provenance: Option, /// Panel-state auto-save (panel.md P11 — on by default, with a user - /// toggle). Lives on the module that owns the scope. - pub auto_save: bool, + /// 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 { @@ -55,7 +58,7 @@ impl UiModuleFace { wiring: None, wiring_open: false, provenance: None, - auto_save: true, + auto_save: None, } } } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index d0ddfa1e9..98ed6378c 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -45,16 +45,16 @@ pub use app::home::{ pub use app::node::{ UiAssetEditor, UiAssetEditorKind, UiBindingAuthoring, UiBindingAuthoringDirection, UiBindingEndpoint, UiChannelChoice, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, - 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, + 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/src/app/module/mod.rs b/lp-app/lpa-studio-web/src/app/module/mod.rs index 65908d025..10799d12e 100644 --- a/lp-app/lpa-studio-web/src/app/module/mod.rs +++ b/lp-app/lpa-studio-web/src/app/module/mod.rs @@ -39,7 +39,7 @@ pub(crate) mod play_mode_stories; #[cfg(feature = "stories")] pub(crate) mod playlist_panel_stories; -pub use module_face::{ModuleChildCard, ModuleFace}; +pub use module_face::ModuleFace; pub use module_panel::{ModulePanel, NestedPanelGroup}; pub use module_panel_control::ModulePanelControl; pub use panel_gesture::PanelGesture; 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 index 62b05a1df..808b4e62f 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_face.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_face.rs @@ -13,14 +13,25 @@ //! 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. `children` — the module's children, nested INSIDE the card. This is -//! the effect-author zoom level, and the reason the root module belongs -//! back in the node area: the workspace column becomes one card whose -//! inside *is* the project. -//! 4. `wiring` — bus-as-**writers/readers** (today's sidebar bus-pane +//! 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. -//! 5. provenance — a quiet footer line (§8). +//! 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 @@ -29,24 +40,17 @@ //! which path carries the value. use dioxus::prelude::*; -use lpa_studio_core::{UiAction, UiModuleChild, UiModuleFace as UiModuleFaceData}; +use lpa_studio_core::{UiAction, UiModuleFace as UiModuleFaceData}; use crate::app::BusPaneBody; use crate::app::node::{NodeCardSection, ProductPreview}; -use crate::base::{StudioIcon, node_kind_icon}; -use super::{ModulePanel, ModulePanelControl, PanelGesture}; +use super::{ModulePanel, PanelGesture}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] pub fn ModuleFace( face: UiModuleFaceData, - /// Nesting depth: 0 is the root module's workspace card, 1+ is an - /// embedded module inside its host. Depth only affects chrome density - /// — the sections and their order are identical, which is the claim - /// under test at G2. - #[props(default = 0)] - depth: usize, /// Toggle handler for the wiring drawer (spike-local view state). #[props(default = None)] on_wiring_toggle: Option>, @@ -54,7 +58,6 @@ pub fn ModuleFace( #[props(default)] on_action: Option>, ) -> Element { let preview = face.preview.clone(); - let has_children = !face.children.is_empty(); let wiring_open = face.wiring_open; let wiring_summary = face.wiring.as_ref().map(|wiring| { let channels = wiring.channels.len(); @@ -78,29 +81,11 @@ pub fn ModuleFace( NodeCardSection { label: "panel", first: face.preview.is_none(), ModulePanel { panel: face.panel.clone(), - // Auto-save belongs to the module that OWNS the scope, so a - // nested face does not repeat its host's toggle. - auto_save: (depth == 0).then_some(face.auto_save), + auto_save: face.auto_save, on_panel, on_action, } } - if has_children { - NodeCardSection { label: "children", - div { class: "tw:grid tw:min-w-0 tw:gap-2 tw:px-4 tw:py-3", - for child in face.children.clone() { - ModuleChildCard { - key: "{child.name}", - child, - depth: depth + 1, - scope: face.panel.scope.clone(), - on_panel, - on_action, - } - } - } - } - } if let Some(wiring) = face.wiring.clone() { NodeCardSection { label: "wiring", @@ -131,101 +116,3 @@ pub fn ModuleFace( } } } - -/// One child inside a module card. -/// -/// A child module renders the SAME [`ModuleFace`] one level in — that -/// recursion is the "one face at every depth" claim made literal. A leaf -/// child renders its preview and its own panel (its bound slots, R3/R8). -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn ModuleChildCard( - child: UiModuleChild, - depth: usize, - /// The ENCLOSING module's scope. A leaf child introduces no scope of - /// its own (R1), so its bound slots' channels — and therefore its - /// controls' identity (panel.md P1) — belong to this one. - scope: String, - #[props(default = None)] on_panel: Option>, - #[props(default)] on_action: Option>, -) -> Element { - let UiModuleChild { - name, - kind, - summary, - preview, - controls, - module, - collapsed, - } = child; - let is_module = module.is_some(); - // A nested module card wears a slightly stronger edge than a leaf: the - // thing that owns a scope should read as a container. - let card_class = if is_module { - "tw:grid tw:min-w-0 tw:gap-0 tw:overflow-hidden tw:rounded-sm tw:border tw:border-border-strong tw:bg-card" - } else { - "tw:grid tw:min-w-0 tw:gap-0 tw:overflow-hidden tw:rounded-sm tw:border tw:border-border-muted tw:bg-card-subtle" - }; - let scope_hint = module.as_ref().map(|module| module.panel.scope.clone()); - - rsx! { - section { class: card_class, - div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2 tw:border-b tw:border-border-muted tw:px-3 tw:py-1.5", - span { class: "tw:inline-flex tw:flex-none tw:text-subtle-foreground", - StudioIcon { name: node_kind_icon(&kind), size: 12 } - } - span { class: "tw:min-w-0 tw:truncate tw:text-sm tw:text-strong-foreground", "{name}" } - if let Some(summary) = summary { - span { class: "tw:min-w-0 tw:truncate tw:text-[11px] tw:text-dim-foreground", "{summary}" } - } - span { class: "tw:ml-auto tw:flex tw:flex-none tw:items-center tw:gap-2", - if let Some(scope) = scope_hint { - span { - class: "tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", - title: "This module introduces a scope", - "scope {scope}" - } - } - span { class: "tw:text-[11px] tw:font-bold tw:lowercase tw:tracking-wide tw:text-dim-foreground", - "{kind}" - } - } - } - if !collapsed { - if let Some(module) = module { - // The same face, one level in. - ModuleFace { - face: *module, - depth, - on_panel, - on_action, - } - } else { - if let Some(preview) = preview { - ProductPreview { - kind: preview.kind, - preview: preview.preview.clone(), - tracking: preview.tracking, - frame: preview.frame, - focus_action: None, - on_action, - } - } - if !controls.is_empty() { - div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-start tw:gap-4 tw:px-3 tw:py-2", - for control in controls { - ModulePanelControl { - key: "{control.channel}", - view: control, - scope: scope.clone(), - on_panel, - on_action, - } - } - } - } - } - } - } - } -} 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 index b0f0f1a08..ecd231b73 100644 --- 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 @@ -5,18 +5,18 @@ //! in the node area read better or worse? //! //! The card chrome is the real [`NodePane`] — header, kind label, collapse -//! — so what is being judged is the face inside a genuine workspace card, -//! not a mock frame. +//! — 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_core::UiNodeFace; 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, module_node_view, plasma_face, - plasma_one_panel, root_face, root_module_node_view, + 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}; @@ -31,19 +31,18 @@ fn WorkspaceCanvas(children: Element) -> Element { } #[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, children nested INSIDE the card, and the wiring drawer. Walkable — turn a knob and it engages (amber), reset and it follows the project again." + 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. - let mut spike = use_signal(|| PanelSpike::new(root_face()).with_held(HELD)); - let mut view = root_module_node_view(); - view.face = Some(UiNodeFace::Module(spike().face.clone())); + // 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, + view: spike().view.clone(), module_panel: move |gesture: PanelGesture| { spike.with_mut(|spike| spike.apply_gesture(&gesture)); }, @@ -56,44 +55,40 @@ fn root_card() -> Element { } #[story( - description = "Author zoom: every child expanded inside the root card, including both embedded plasma modules wearing the SAME face one level in. Shows the presentation overlap the model implies — an embedded module's controls appear both as a nested panel group above and on its own child card below." + 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 face = held_root_face(); - for child in &mut face.children { - child.collapsed = false; - } + 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: module_node_view("Aurora Sign", ROOT_SCOPE, "5 nodes · 2 effects", face), - on_action: move |_| {}, - } + 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, its child shader, wiring drawer, provenance line." + 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: module_node_view( - "plasma_1", - PLASMA_1_SCOPE, - "effect · detached", - plasma_face(plasma_one_panel(), 3.1), - ), - on_action: move |_| {}, - } + 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." + 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(); @@ -114,7 +109,6 @@ fn wiring_drawer_open() -> Element { fn nested_groups() -> Element { let mut face = held_root_face(); face.preview = None; - face.children.clear(); face.wiring = None; if let Some(second) = face.panel.groups.get_mut(1) { second.collapsed = true; @@ -129,20 +123,19 @@ fn nested_groups() -> Element { } #[story( - description = "The workspace column with the sidebar bus pane GONE: the root module card is the whole node area, and the bus lives on it — controls on the face, wiring in the drawer. Compare against studio/bus/bus-pane/fyeah-sign, which is what this replaces." + 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 face = held_root_face(); - face.wiring_open = true; - if let Some(second) = face.panel.groups.get_mut(1) { - second.collapsed = true; + let mut view = held_root_view(); + if let Some(lpa_studio_core::UiNodeFace::Module(face)) = view.face.as_mut() { + face.wiring_open = true; + if let Some(second) = face.panel.groups.get_mut(1) { + second.collapsed = 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: module_node_view("Aurora Sign", ROOT_SCOPE, "5 nodes · 2 effects", face), - on_action: move |_| {}, - } + 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 index 42cbd9a5b..ccbf7f727 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs @@ -18,10 +18,11 @@ use lpa_studio_core::{ LpValue, ProjectNodeAddress, ProjectSlotAddress, ProjectSlotRoot, SlotEditOp, SlotPath, - UiAction, UiBusChannelView, UiBusSiteView, UiBusView, UiModuleChild, UiModuleFace, UiNodeFace, - UiNodeHeader, UiNodeView, UiPanelControl, UiPanelControlState, UiPanelControlView, - UiPanelGroup, UiPanelWidget, UiPlaylistEntry, UiPlaylistFace, UiProducedProduct, - UiProductPreviewFrame, UiProductTrackingState, UiSlotFieldState, UiSlotValue, UiStatus, + 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; @@ -165,7 +166,8 @@ pub(crate) fn plasma_one_panel() -> UiPanelGroup { } /// An embedded plasma module's own face — the SAME component the root -/// wears, one level in. +/// 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( @@ -178,21 +180,27 @@ pub(crate) fn plasma_face(panel: UiPanelGroup, seed: f32) -> UiModuleFace { panel, wiring: Some(plasma_wiring()), wiring_open: false, - children: vec![ - UiModuleChild::leaf("sim", "Shader") - .with_summary("visual → bus:visual.out") - .with_preview( - UiProducedProduct::visual("output") - .with_tracking(UiProductTrackingState::Tracking) - .with_frame(UiProductPreviewFrame::new(16, 5)) - .with_preview(aurora_preview(48, 15, seed + 0.4)), - ), - ], provenance: Some("PhotomancerArt · v1.2 · CC0-1.0".to_string()), - auto_save: true, + 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). @@ -287,9 +295,12 @@ pub(crate) fn root_controls() -> Vec { } /// The root module's face with the story's held controls already engaged — -/// what most stories render, and what [`PanelSpike`] clears back from. +/// what the panel stories render, and what [`PanelSpike`] clears back from. pub(crate) fn held_root_face() -> UiModuleFace { - PanelSpike::new(root_face()).with_held(HELD).face + 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. @@ -305,49 +316,107 @@ pub(crate) fn root_face() -> UiModuleFace { panel: root_panel(), wiring: Some(root_wiring()), wiring_open: false, - children: root_children(), provenance: Some("Yona · v0.4 · created 2026-07-31".to_string()), - auto_save: true, + // The project root owns panel persistence (P11). + auto_save: Some(true), } } -/// The root module's children, nested inside the card: two leaves that -/// write host channels, the two embedded plasma modules, and the fixture. -pub(crate) fn root_children() -> Vec { +/// 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![ - UiModuleChild::leaf("clock", "Clock").with_summary("seconds → bus:time"), - UiModuleChild::leaf("Master speed", "Button") - .with_summary("value → bus:speed") - .with_controls(vec![following( + 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", )]), - UiModuleChild::module( + ), + module_child( "plasma_1", + PLASMA_1_SCOPE, + "effect", plasma_face(plasma_read_panel(PLASMA_1_SCOPE), 3.1), - ) - .with_summary("effect") - .collapsed(), - UiModuleChild::module( + plasma_children(3.1), + ), + module_child( "plasma_2", + PLASMA_2_SCOPE, + "effect", plasma_face(plasma_read_panel(PLASMA_2_SCOPE), 6.5), - ) - .with_summary("effect") - .collapsed(), + 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 inside a single card. - UiModuleChild::leaf("halo", "Fixture") - .with_summary("241 LEDs · input ← bus:visual.out") - .with_controls(vec![at_default( + // 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 { @@ -424,8 +493,9 @@ fn site(node_label: &str, slot: &str) -> UiBusSiteView { } } -/// The root module as a node card view — the single top-level workspace -/// card (the flat-root reversal, §5). +/// 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", @@ -433,6 +503,14 @@ pub(crate) fn root_module_node_view() -> UiNodeView { "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 @@ -578,33 +656,53 @@ pub(crate) fn three_state_panel() -> UiPanelGroup { /// /// 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 face plus a pristine baseline, applies the widget's own +/// 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 face the stories render. - pub face: UiModuleFace, - /// The untouched face, for restoring cleared controls. - baseline: UiModuleFace, + /// The node view the stories render. + pub view: UiNodeView, + /// The untouched view, for restoring cleared controls. + baseline: UiNodeView, } impl PanelSpike { - /// Wrap a **Read-form** face as walkable spike state. The face passed - /// in becomes the clear-target, so it must have nothing engaged. - pub fn new(face: UiModuleFace) -> Self { + /// 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: face.clone(), - face, + 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 { - engage_face(&mut self.face, scope, channel, *value); + 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 } @@ -617,7 +715,7 @@ impl PanelSpike { return; }; let (address, value) = (address.clone(), value.clone()); - visit_controls(&mut self.face, &mut |view| { + visit_view_controls(&mut self.view, &mut |_, view| { if view.control.address.as_ref() != Some(&address) { return; } @@ -640,9 +738,13 @@ impl PanelSpike { /// group, and auto-save flips the persistence flag. pub fn apply_gesture(&mut self, gesture: &PanelGesture) { match gesture { - PanelGesture::SetAutoSave(next) => self.face.auto_save = *next, + PanelGesture::SetAutoSave(next) => { + if let Some(UiNodeFace::Module(face)) = self.view.face.as_mut() { + face.auto_save = Some(*next); + } + } PanelGesture::ToggleGroup { scope } => { - visit_groups(&mut self.face, &mut |group| { + visit_view_groups(&mut self.view, &mut |group| { if group.scope == *scope { group.collapsed = !group.collapsed; } @@ -663,10 +765,10 @@ impl PanelSpike { /// Copy matching controls back from the pristine baseline. fn restore(&mut self, matches: impl Fn(&str, &UiPanelControlView) -> bool) { let mut pristine = Vec::new(); - visit_controls_scoped(&mut self.baseline.clone(), &mut |scope, view| { + visit_view_controls(&mut self.baseline.clone(), &mut |scope, view| { pristine.push((scope.to_string(), view.clone())); }); - visit_controls_scoped(&mut self.face, &mut |scope, view| { + visit_view_controls(&mut self.view, &mut |scope, view| { if !matches(scope, view) { return; } @@ -679,18 +781,8 @@ impl PanelSpike { } } -/// Engage one control everywhere it appears on a face: the same -/// `(scope, channel)` is ONE control (panel.md P1), so a nested panel group -/// and the child card that repeats it move together. -fn engage_face(face: &mut UiModuleFace, scope: &str, channel: &str, value: f32) { - visit_controls_scoped(face, &mut |control_scope, view| { - if control_scope == scope && view.channel == channel { - hold(view, value); - } - }); -} - -/// The same, within one panel group tree. +/// 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 { @@ -707,26 +799,46 @@ fn hold(view: &mut UiPanelControlView, value: f32) { view.state = UiPanelControlState::Engaged; } -/// Visit every control on a face — the module's own panel, its nested -/// groups, its leaf children's controls, and every nested module face. -fn visit_controls(face: &mut UiModuleFace, visit: &mut impl FnMut(&mut UiPanelControlView)) { - visit_controls_scoped(face, &mut |_, view| visit(view)); +/// 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, carrying each control's owning scope. -fn visit_controls_scoped( - face: &mut UiModuleFace, +/// 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_group_controls(&mut face.panel, visit); - let scope = face.panel.scope.clone(); - for child in &mut face.children { - for view in &mut child.controls { - visit(&scope, view); - } - if let Some(module) = child.module.as_mut() { - visit_controls_scoped(module, visit); - } + 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); } } @@ -744,13 +856,23 @@ fn visit_group_controls( } } -/// Visit every panel group on a face, including nested module faces. -fn visit_groups(face: &mut UiModuleFace, visit: &mut impl FnMut(&mut UiPanelGroup)) { - visit_group(&mut face.panel, visit); - for child in &mut face.children { - if let Some(module) = child.module.as_mut() { - visit_groups(module, 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); } } @@ -764,15 +886,16 @@ fn visit_group(group: &mut UiPanelGroup, visit: &mut impl FnMut(&mut UiPanelGrou #[cfg(test)] mod tests { - use lpa_studio_core::UiPanelControlState; + use lpa_studio_core::{UiNodeFace, UiPanelControlState}; use super::{ - HELD, PLASMA_1_SCOPE, PLASMA_2_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, root_face, + 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_face()).with_held(HELD) + PanelSpike::new(root_module_node_view()).with_held(HELD) } #[test] @@ -795,14 +918,14 @@ mod tests { 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); + 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(), + spike.face().panel.engaged_total(), 0, "reset means reset — the nested plasma writer goes too" ); @@ -816,16 +939,16 @@ mod tests { channel: "speed".to_string(), }); - assert_eq!(spike.face.panel.groups[0].engaged_total(), 0); + assert_eq!(spike.face().panel.groups[0].engaged_total(), 0); assert_eq!( - spike.face.panel.engaged_here(), + 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, + spike.face().panel.groups[0].controls[0].state, UiPanelControlState::ReadFollowing ); } @@ -833,14 +956,14 @@ mod tests { #[test] fn auto_save_and_group_disclosure_are_view_gestures() { let mut spike = spike(); - assert!(spike.face.auto_save); + assert!(spike.face().auto_save); spike.apply_gesture(&PanelGesture::SetAutoSave(false)); - assert!(!spike.face.auto_save); + assert!(!spike.face().auto_save); - assert!(!spike.face.panel.groups[1].collapsed); + assert!(!spike.face().panel.groups[1].collapsed); spike.apply_gesture(&PanelGesture::ToggleGroup { scope: PLASMA_2_SCOPE.to_string(), }); - assert!(spike.face.panel.groups[1].collapsed); + assert!(spike.face().panel.groups[1].collapsed); } } 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 index 432c1395c..8d369ddf7 100644 --- 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 @@ -16,7 +16,7 @@ use dioxus::prelude::*; use lpa_studio_web_story_macros::story; use super::module_fixtures::{ - PanelSpike, ROOT_SCOPE, held_root_face, root_face, three_state_panel, + PanelSpike, ROOT_SCOPE, held_root_face, root_module_node_view, three_state_panel, }; use super::{ModulePanel, PanelGesture}; @@ -68,12 +68,12 @@ fn reset_gestures() -> Element { )] fn auto_save_off() -> Element { let mut face = held_root_face(); - face.auto_save = false; + face.auto_save = Some(false); rsx! { PanelCanvas { ModulePanel { panel: face.panel, - auto_save: Some(face.auto_save), + auto_save: face.auto_save, on_panel: move |_| {}, on_action: move |_| {}, } @@ -87,13 +87,13 @@ fn auto_save_off() -> Element { 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_face())); + let mut spike = use_signal(|| PanelSpike::new(root_module_node_view())); rsx! { PanelCanvas { ModulePanel { - panel: spike().face.panel.clone(), - auto_save: Some(spike().face.auto_save), + panel: spike().face().panel.clone(), + auto_save: spike().face().auto_save, on_panel: move |gesture: PanelGesture| { spike.with_mut(|spike| spike.apply_gesture(&gesture)); }, 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 index 59de6a777..907ef43ad 100644 --- a/lp-app/lpa-studio-web/src/app/module/play_mode.rs +++ b/lp-app/lpa-studio-web/src/app/module/play_mode.rs @@ -31,9 +31,9 @@ pub fn PlayModeSurface( #[props(default = None)] preview: Option, /// Panel-state auto-save (P11): an end user owns this, so it stays - /// reachable in play mode. - #[props(default = true)] - auto_save: bool, + /// 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 { @@ -61,7 +61,7 @@ pub fn PlayModeSurface( } ModulePanel { panel, - auto_save: Some(auto_save), + auto_save, play: true, on_panel, on_action, 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 index bc1455b16..c96ac1b54 100644 --- 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 @@ -111,16 +111,16 @@ fn meta_switch() -> Element { // still be there after a round trip through Whirl. let mut drift = use_signal(|| { let scope = entry_scope(0); - PanelSpike::new(spike_face(0)).with_held(&[(scope.as_str(), "speed", 0.35)]) + PanelSpike::new(spike_view(0)).with_held(&[(scope.as_str(), "speed", 0.35)]) }); - let mut whirl = use_signal(|| PanelSpike::new(spike_face(1))); + 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() + drift().face().panel.clone() } else { - whirl().face.panel.clone() + whirl().face().panel.clone() }; rsx! { @@ -152,6 +152,11 @@ fn meta_switch() -> Element { /// 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_face(entry: u32) -> lpa_studio_core::UiModuleFace { - lpa_studio_core::UiModuleFace::new(entry_panel(entry)) +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 a5d298ad2..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,11 +19,11 @@ use lpa_studio_core::{ NodeCardUiState, UiAction, UiAddNodeMenu, UiNodeFace, UiNodeSection, UiPendingEdit, }; -use crate::app::module::{ModuleFace, PanelGesture}; +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")] @@ -110,13 +110,30 @@ pub fn NodeFaceBody( on_action, } }, - // M2 UX spike: the module face carries its own drawers - // (wiring) and nests its children inside the card, so it - // does NOT compose `NodeCardDrawers` — the advanced slot - // view lives on the individual child nodes instead. + // 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/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 4e0e9dacb..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 @@ -193,6 +193,7 @@ pub fn NodePane( on_action, pending_edits, dirty_tint, + module_panel, } } } From c3a4b6edc6d248d1e0bd0d4dd97cea4625b37366 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:44:08 -0700 Subject: [PATCH 11/16] polish(studio): panel reset and auto-save become quiet upper-right chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2 revision 2. The all-caps "RESET 2" / "AUTO-SAVE" chips read as loud as the controls they sit above. They become small icon affordances in the panel's upper right: the revert glyph alone with the count as a tiny superscript, and a small switch for auto-save, both with the full sentence in the tooltip so the chrome does not have to carry it. Nested group resets wear the same treatment on the right of their heading row. Per-control reset glyphs are unchanged — they already appear only while engaged, which is the point of them. Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/assets/tailwind.css | 24 ++++++ .../src/app/module/module_panel.rs | 86 +++++++++++++------ 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 569280a53..835dabce7 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; } @@ -291,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); } @@ -318,6 +327,9 @@ .tw\:h-\[7px\] { height: 7px; } + .tw\:h-\[11px\] { + height: 11px; + } .tw\:h-\[15px\] { height: 15px; } @@ -513,9 +525,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; } @@ -1187,6 +1205,9 @@ .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); } @@ -1559,6 +1580,9 @@ 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; } 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 index 7e8be91b7..da27f9b51 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_panel.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -7,19 +7,22 @@ //! group is a hairline-boxed subsection with its own heading, collapsible //! to a summary row; the root group is never collapsed (it *is* the panel). //! -//! The panel's own header row carries the two module-level affordances: +//! 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). Present only while something under this scope is held, -//! so an untouched panel shows no destructive control at all, and the -//! count says how much it would drop. -//! - **Auto-save** (P11 — on by default, with a user toggle). 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. +//! 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. //! -//! Placement is a G2 question: header row of the panel section (here) vs. -//! the card header vs. a footer under the controls. +//! 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}; @@ -62,10 +65,11 @@ pub fn ModulePanel( rsx! { div { class: "tw:grid tw:min-w-0 tw:gap-3 tw:px-4 tw:py-3", - // Module-level affordances. Reset only exists when there is - // something to clear; auto-save is always legible when shown. + // 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:flex-wrap tw:items-center tw:gap-3", + 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 } } @@ -192,52 +196,74 @@ pub fn NestedPanelGroup( } } -/// "Reset · N held" — the per-scope clear (P2). Amber, matching the state -/// it removes; it appears only when there is a writer to remove. +/// 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:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-status-attention-border tw:bg-status-attention-bg tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-status-attention-foreground", + 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: "Reset {held} held {noun} in {scope} — panel writers are dropped and the project drives again", + title: "{label}", + aria_label: "{label}", onclick: move |event| { event.stop_propagation(); on_panel.call(PanelGesture::ClearScope { scope: scope.clone() }); }, - StudioIcon { name: StudioIconName::Revert, size: 10 } - "reset {held}" + 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. +/// `.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 class = if auto_save { - "tw:inline-flex tw:flex-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-raised tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-muted-foreground" + 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:inline-flex tw:flex-none tw:items-center tw:gap-1 tw:rounded-xs tw:border tw:border-border-muted tw:bg-transparent tw:px-1.5 tw:py-0.5 tw:text-[0.6rem] tw:font-bold tw:uppercase tw:tracking-[0.08em] tw:text-dim-foreground" + "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 { - "Panel settings are saved and restored on boot (.lp/state.json)" + "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 { - "Panel settings are NOT saved — they are lost on restart" + "tw:inline-flex tw:flex-none tw:text-dim-foreground" }; rsx! { button { - class: "{class} tw:cursor-pointer", + 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(); @@ -245,8 +271,12 @@ fn AutoSaveToggle( handler.call(PanelGesture::SetAutoSave(!auto_save)); } }, - StudioIcon { name: StudioIconName::Save, size: 10 } - if auto_save { "auto-save" } else { "auto-save off" } + span { class: icon_class, + StudioIcon { name: StudioIconName::Save, size: 11 } + } + span { class: track_class, + span { class: knob_class } + } } } } From cfbe52c9e1521244ffe2168c785769056cdd6e9c Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:56:47 -0700 Subject: [PATCH 12/16] test(studio): pin children-below-the-card and one-control-two-cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two claims the revision rests on: a module's children are sibling cards on the rail (all of them, at every depth), and a leaf's control and the module panel's are one control that moves together even though they now live on two different cards. Also fixes the host-target test build, which `just check` does not compile — auto_save is Option now. Co-Authored-By: Claude Fable 5 --- .../src/app/module/module_fixtures.rs | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) 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 index ccbf7f727..a26299ed0 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs @@ -956,9 +956,9 @@ mod tests { #[test] fn auto_save_and_group_disclosure_are_view_gestures() { let mut spike = spike(); - assert!(spike.face().auto_save); + assert_eq!(spike.face().auto_save, Some(true)); spike.apply_gesture(&PanelGesture::SetAutoSave(false)); - assert!(!spike.face().auto_save); + assert_eq!(spike.face().auto_save, Some(false)); assert!(!spike.face().panel.groups[1].collapsed); spike.apply_gesture(&PanelGesture::ToggleGroup { @@ -966,4 +966,37 @@ mod tests { }); assert!(spike.face().panel.groups[1].collapsed); } + + /// 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" + ); + } } From 658f95460d37587f9c4d76c1d0aa20c3cb566e92 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 22:51:40 -0700 Subject: [PATCH 13/16] feat(studio): panel controls and groups grow detail-popup aspects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for "think actual control panel": a control on the face is about to become widget + label + value and nothing else, so everything it currently says in a caption needs a home. UiPanelControlView::detail_aspects and UiPanelGroup::detail_aspects build that home out of the existing slot aspect vocabulary — the state section, the (scope, channel) identity, and then the backing slot's own aspects, so the panel popup is a superset of the slot popup rather than a fork of it. Adds UiSlotAspectKind::PanelState, the one kind that titles itself: "Held" / "Following" / "At default" IS the section, so there is nothing for the shared summary machinery to derive. Co-Authored-By: Claude Fable 5 --- .../app/node/face/ui_panel_control_view.rs | 70 ++++++++++++++++++- .../src/app/node/face/ui_panel_group.rs | 35 +++++++++- .../src/app/node/ui_slot_aspect.rs | 6 ++ .../src/app/node/slot_detail_button.rs | 18 +++++ 4 files changed, 127 insertions(+), 2 deletions(-) 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 index 2596c58b7..1e8a8023a 100644 --- 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 @@ -10,7 +10,9 @@ //! 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; +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). @@ -93,6 +95,72 @@ impl UiPanelControlView { 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)] 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 index d4c5504cb..24bf15140 100644 --- 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 @@ -7,7 +7,9 @@ //! behind a group, and two embedded instances of the same effect present //! two independent groups because they are two different scopes. -use crate::UiPanelControlView; +use crate::{ + UiPanelControlView, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, +}; /// One panel: the channels of one scope, plus its child modules' panels. #[derive(Clone, Debug, Default, PartialEq)] @@ -91,6 +93,37 @@ impl UiPanelGroup { 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 noun = if held == 1 { "control" } else { "controls" }; + UiSlotAspect::new(UiSlotAspectKind::PanelState, "Held") + .with_affordance(UiSlotAffordance::Edited) + .with_row(UiSlotAspectRow::new( + "", + format!("{held} {noun} in this group are held by the panel."), + )) + }; + + 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)] 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-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, From 12278b31b4072a67bf324cbd43ad394f167aa756 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:10:24 -0700 Subject: [PATCH 14/16] feat(studio): panel controls lose their sublabels, gain a detail popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Think actual control panel — minimize distraction, maximize clarity." A control is now a widget, a LABEL, and a value; every caption under it ("control · Master speed", "held · was inherited · …", "authored default", "no writer yet") is gone from the face. None of that information is lost — it moved behind the label, which is now 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 into the aspect card and reads as diving into it. State stays legible on the face through color alone plus the reset glyph, whose presence is itself the "something is held here" signal. Values render in tabular numerals in a reserved slot and faders get a fixed footprint, because a panel row that reflows while you drag a fader is not a control panel. That was a real bug on root-card. Co-Authored-By: Claude Fable 5 --- .../src/app/module/module_panel_control.rs | 410 ++++++++++++------ 1 file changed, 268 insertions(+), 142 deletions(-) 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 index c3ed647bb..853cfc251 100644 --- 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 @@ -1,144 +1,238 @@ //! One control on a module's panel, wearing its panel state. //! -//! The three states `docs/design/panel.md` P-Q2 requires to be visibly -//! distinct, rendered with the existing knob v2 / fader / toggle widgets so -//! the panel and the node face speak one widget language: +//! **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 | widget | label | caption | -//! |---|---|---|---| -//! | Read, at default | accent arc at the authored default | subtle | `default` + the value's origin | -//! | Read, following | **violet** arc at the LIVE value | violet | `following …` (who is driving) | -//! | Engaged (Latch) | **amber** arc + amber body ring | amber | `held` + the reset gesture | +//! 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 gate question is -//! whether amber reads as "captured" at a glance. +//! `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) is per control: the small revert glyph appears -//! beside the label ONLY while engaged, because there is nothing to clear -//! otherwise — the affordance's presence is itself part of the state -//! signal. The per-module reset lives on the group header -//! ([`super::ModulePanel`]). +//! **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, UiPanelControlState, UiPanelControlView, UiPanelWidget, UiSlotValueKind, + UiAction, UiPanelControl, UiPanelControlState, UiPanelControlView, UiPanelWidget, + UiSlotValueKind, }; -use crate::app::node::{HFaderField, KnobField, PanelEmit, SlotUnitSuffix, ToggleField}; -use crate::base::{StudioIcon, StudioIconName}; +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) and what a reset gesture carries. + /// (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 and drops the - /// authoring-side captions to one line. + /// 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, + source: _, } = view; let engaged = state.engaged(); - let following = matches!(state, UiPanelControlState::ReadFollowing); let label_class = panel_state_label_class(state); - let caption = control_caption(state, source.as_deref()); + 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", - span { class: "tw:truncate tw:text-[0.66rem] tw:font-bold tw:uppercase tw:leading-none tw:tracking-[0.08em] {label_class}", - "{control.label}" - } - // Reset exists only where there is a writer to remove (P2). - 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 {control.label} — drop the held value and follow the project again", - aria_label: "Reset {control.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 } - } + 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} } }; - let readout_class = panel_state_readout_class(state); + 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: "tw:inline-flex tw:items-baseline tw:gap-1 tw:font-mono tw:text-[0.7rem] {readout_class}", + 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 } } }; - let caption_row = rsx! { - if let Some(caption) = caption.clone() { - span { - class: "tw:max-w-[17ch] tw:truncate tw:text-center tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", - title: "{caption}", - "{caption}" - } - } - }; - - let column_class = 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" - }; - 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! { - div { class: column_class, - 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} - {caption_row} + 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 } => { @@ -146,27 +240,22 @@ pub fn ModulePanelControl( return mismatch(&control.label, &control.value.display); }; rsx! { - div { class: "tw:grid tw:min-w-0 tw:gap-1", - div { class: "tw:flex 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, - } - if let Some(caption) = caption { - span { class: "tw:text-[0.6rem] tw:leading-none tw:text-dim-foreground", "{caption}" } - } + 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, } } } @@ -175,22 +264,35 @@ pub fn ModulePanelControl( return mismatch(&control.label, &control.value.display); }; rsx! { - div { class: column_class, - 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, - } + 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} - {caption_row} } + {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 } } } } @@ -225,19 +327,6 @@ fn shown_display(value: &str, live: Option<&str>, state: UiPanelControlState) -> } } -/// The caption under the control: what state it is in and, in Read, whose -/// value is on screen (P2 — the UI distinguishes inherited / authored / -/// default). -fn control_caption(state: UiPanelControlState, source: Option<&str>) -> Option { - match (state, source) { - (UiPanelControlState::Engaged, Some(source)) => Some(format!("held · was {source}")), - (UiPanelControlState::Engaged, None) => Some("held".to_string()), - (_, Some(source)) => Some(source.to_string()), - (UiPanelControlState::ReadDefault, None) => Some("default".to_string()), - (UiPanelControlState::ReadFollowing, None) => Some("following".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 { @@ -256,19 +345,43 @@ fn mismatch(label: &str, display: &str) -> Element { 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:text-muted-foreground", "{display}" } + 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::UiPanelControlState; - - use super::{ - control_caption, panel_state_label_class, panel_state_readout_class, shown_display, + 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> = [ @@ -319,19 +432,32 @@ mod tests { ); } + /// The captions left the face — so everything they said has to be in + /// the popup, or the information is simply gone. #[test] - fn captions_say_what_the_state_is_and_what_it_displaced() { - assert_eq!( - control_caption(UiPanelControlState::Engaged, Some("clock")), - Some("held · was clock".to_string()) - ); - assert_eq!( - control_caption(UiPanelControlState::ReadFollowing, Some("clock")), - Some("clock".to_string()) + 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")) ); - assert_eq!( - control_caption(UiPanelControlState::ReadDefault, None), - Some("default".to_string()) + // 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"); } } From 64fa350a29bfc6e7ab82724dc039cfb4edbf4a67 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:29:13 -0700 Subject: [PATCH 15/16] feat(studio): nested panel groups become bordered clusters in a flex row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amended G2 round-3 item 3. A nested module group is a light hairline box with its name embedded in the top border — a real fieldset/legend, so the notch is the browser's rather than a stack of CSS tricks, which keeps the "— PLASMA 1 —" reading Yona liked while making the group a bordered CLUSTER. ModulePanel lays the groups out in a wrapping flex row, so two effect boxes sit next to each other when the width allows: function groups on hardware, which is the whole "actual control panel" image. No collapse. UiPanelGroup::collapsed and PanelGesture::ToggleGroup are deleted rather than left unused: wrapping is the density mechanism, and a panel you have to unfold before you can play it is not a control panel. The scope path and the control tally leave the header for the label's detail popup, so the border carries only the instance name — which is why the fixtures now label the groups "plasma 1" / "plasma 2" instead of both "plasma". A test pins that two side-by-side groups stay tellable apart and that the path is still reachable. play-mode/groups-collapsed is repurposed to play-mode/groups-wrapped: the same panel at a width where the row wraps, which is now the density story worth having next to the side-by-side desktop one. Co-Authored-By: Claude Fable 5 --- .../app/node/face/ui_panel_control_view.rs | 4 +- .../src/app/node/face/ui_panel_group.rs | 27 ++- lp-app/lpa-studio-web/assets/tailwind.css | 36 +++- .../src/app/module/module_face_stories.rs | 8 +- .../src/app/module/module_fixtures.rs | 47 +++-- .../src/app/module/module_panel.rs | 161 +++++++++++------- .../src/app/module/panel_gesture.rs | 11 +- .../src/app/module/panel_state_stories.rs | 52 +++++- .../src/app/module/play_mode_stories.rs | 13 +- 9 files changed, 234 insertions(+), 125 deletions(-) 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 index 1e8a8023a..78d0861a0 100644 --- 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 @@ -10,9 +10,7 @@ //! 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, -}; +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). 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 index 24bf15140..b6ec4d917 100644 --- 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 @@ -24,11 +24,12 @@ pub struct UiPanelGroup { /// 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, - /// Whether this group renders collapsed to its summary row. Nested - /// groups start collapsed on a busy panel; the root group is never - /// collapsed. - pub collapsed: bool, } impl UiPanelGroup { @@ -39,7 +40,6 @@ impl UiPanelGroup { scope: scope.into(), controls: Vec::new(), groups: Vec::new(), - collapsed: false, } } @@ -55,12 +55,6 @@ impl UiPanelGroup { self } - /// Render this group collapsed to its summary row. - pub fn collapsed(mut self) -> Self { - self.collapsed = true; - self - } - /// Whether the group presents nothing at all — no channels here and /// nothing in any nested group. pub fn is_empty(&self) -> bool { @@ -107,13 +101,14 @@ impl UiPanelGroup { UiSlotAspectRow::new("", "Every control in this group follows the project."), ) } else { - let noun = if held == 1 { "control" } else { "controls" }; + 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( - "", - format!("{held} {noun} in this group are held by the panel."), - )) + .with_row(UiSlotAspectRow::new("", clause)) }; vec![ diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 835dabce7..f934c9abd 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -339,6 +339,9 @@ .tw\:h-\[46px\] { height: 46px; } + .tw\:h-\[420px\] { + height: 420px; + } .tw\:h-\[560px\] { height: 560px; } @@ -543,12 +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; } @@ -597,9 +606,6 @@ .tw\:max-w-96 { max-width: calc(var(--tw-spacing) * 96); } - .tw\:max-w-\[17ch\] { - max-width: 17ch; - } .tw\:max-w-\[85\%\] { max-width: 85%; } @@ -657,6 +663,9 @@ .tw\:min-w-\[2ch\] { min-width: 2ch; } + .tw\:min-w-\[4\.5ch\] { + min-width: 4.5ch; + } .tw\:min-w-\[52px\] { min-width: 52px; } @@ -672,6 +681,9 @@ .tw\:flex-1 { flex: 1; } + .tw\:flex-auto { + flex: auto; + } .tw\:flex-none { flex: none; } @@ -684,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; } @@ -938,9 +956,6 @@ .tw\:gap-y-0\.5 { row-gap: calc(var(--tw-spacing) * 0.5); } - .tw\:gap-y-1 { - row-gap: calc(var(--tw-spacing) * 1); - } .tw\:gap-y-2 { row-gap: calc(var(--tw-spacing) * 2); } @@ -1496,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); } @@ -1535,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; } 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 index ecd231b73..944b885e8 100644 --- 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 @@ -104,15 +104,12 @@ fn wiring_drawer_open() -> Element { } #[story( - description = "Nested panel groups (R8): two instances of one effect side by side on the root panel — plasma_1 expanded and detached (amber), plasma_2 collapsed to its summary row and still following the host. Same channel names, different scopes, independent controls." + 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; - if let Some(second) = face.panel.groups.get_mut(1) { - second.collapsed = true; - } rsx! { WorkspaceCanvas { div { class: "tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card", @@ -129,9 +126,6 @@ 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; - if let Some(second) = face.panel.groups.get_mut(1) { - second.collapsed = true; - } } rsx! { div { class: "tw:grid tw:w-full tw:max-w-[860px] tw:gap-3.5 tw:bg-page tw:p-3", 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 index a26299ed0..22c9cc01a 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_fixtures.rs @@ -132,7 +132,10 @@ fn at_default(view: UiPanelControlView, source: &str) -> UiPanelControlView { /// 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 { - UiPanelGroup::new("plasma", scope).with_controls(vec![ + // 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), @@ -141,6 +144,17 @@ fn plasma_panel(scope: &str, speed: UiPanelControlView) -> UiPanelGroup { ]) } +/// "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 @@ -743,13 +757,6 @@ impl PanelSpike { face.auto_save = Some(*next); } } - PanelGesture::ToggleGroup { scope } => { - visit_view_groups(&mut self.view, &mut |group| { - if group.scope == *scope { - group.collapsed = !group.collapsed; - } - }); - } PanelGesture::ClearControl { scope, channel } => { self.restore(|group_scope, view| group_scope == scope && view.channel == *channel); } @@ -954,17 +961,29 @@ mod tests { } #[test] - fn auto_save_and_group_disclosure_are_view_gestures() { + 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)); + } - assert!(!spike.face().panel.groups[1].collapsed); - spike.apply_gesture(&PanelGesture::ToggleGroup { - scope: PLASMA_2_SCOPE.to_string(), - }); - assert!(spike.face().panel.groups[1].collapsed); + /// 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 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 index da27f9b51..36f71d46b 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_panel.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -4,8 +4,10 @@ //! `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-boxed subsection with its own heading, collapsible -//! to a summary row; the root group is never collapsed (it *is* the panel). +//! 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 @@ -27,10 +29,14 @@ use dioxus::prelude::*; use lpa_studio_core::{UiAction, UiPanelGroup}; -use crate::base::{StudioIcon, StudioIconName}; +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( @@ -43,6 +49,15 @@ pub fn ModulePanel( /// 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 { @@ -83,6 +98,7 @@ pub fn ModulePanel( 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, @@ -92,13 +108,22 @@ pub fn ModulePanel( } } } - for group in panel.groups.clone() { - NestedPanelGroup { - key: "{group.scope}", - group, - 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, + } + } } } } @@ -107,69 +132,81 @@ pub fn ModulePanel( /// One embedded module's panel inside its host's panel (R8 recursion). /// -/// Hairline-boxed rather than full-bleed: the box is what makes "these -/// knobs belong to plasma_1, those to plasma_2" readable at a glance, which -/// is the whole point of two side-by-side instances. +/// 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 summary = group.summary(); + let aspects = group.detail_aspects(); let scope = group.scope.clone(); - let toggle_scope = group.scope.clone(); - let collapsed = group.collapsed; let label = group.label.clone(); let heading_class = if held > 0 { - "tw:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-status-attention-foreground" + "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:text-[0.62rem] tw:font-bold tw:uppercase tw:tracking-[0.12em] tw:text-subtle-foreground" + "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! { - section { class: "tw:grid tw:min-w-0 tw:gap-2 tw:rounded-sm tw:border tw:border-border-muted tw:bg-card-subtle tw:px-3 tw:py-2", - // Wraps rather than squeezes: at phone width the summary and - // reset chip drop to a second line instead of crushing the - // group's name to one letter. - div { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-x-2 tw:gap-y-1", - button { - class: "tw:group tw:inline-flex tw:flex-none tw:cursor-pointer tw:appearance-none tw:items-center tw:gap-1.5 tw:border-0 tw:bg-transparent tw:p-0 tw:text-left", - r#type: "button", - aria_expanded: "{!collapsed}", - title: if collapsed { "Expand {label}" } else { "Collapse {label}" }, - onclick: move |event| { - event.stop_propagation(); - if let Some(handler) = on_panel { - handler.call(PanelGesture::ToggleGroup { scope: toggle_scope.clone() }); - } - }, - span { class: "tw:inline-flex tw:flex-none tw:text-subtle-foreground", - StudioIcon { - name: if collapsed { StudioIconName::Collapsed } else { StudioIconName::Expanded }, - size: 11, + fieldset { class: box_class, + // The legend rides IN the top border — the name and, beside it, + // the box's own reset. + legend { class: "tw:flex 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 } + } } - } - span { class: heading_class, "{group.label}" } + }), + trigger_class: GROUP_LABEL_TRIGGER_CLASS.to_string(), + trigger_open_class: GROUP_LABEL_TRIGGER_CLASS.to_string(), + on_action, } - // The scope path: the identity half of every control below - // (panel.md P1), and what makes two instances distinct. - span { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", - "{group.scope}" - } - span { class: "tw:ml-auto tw:flex tw:flex-none tw:items-center tw:gap-2", - span { class: "tw:font-mono tw:text-[0.6rem] tw:text-dim-foreground", "{summary}" } - if held > 0 && let Some(handler) = on_panel { - PanelResetButton { scope: scope.clone(), held, on_panel: handler } - } + if held > 0 && let Some(handler) = on_panel { + PanelResetButton { scope: scope.clone(), held, on_panel: handler } } } - if !collapsed { + 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 tw:gap-4", + 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}", @@ -182,13 +219,19 @@ pub fn NestedPanelGroup( } } } - for nested in group.groups.clone() { - NestedPanelGroup { - key: "{nested.scope}", - group: nested, - 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, + } + } } } } 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 index 187ab530d..d6b2d9399 100644 --- a/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs +++ b/lp-app/lpa-studio-web/src/app/module/panel_gesture.rs @@ -12,8 +12,10 @@ //! - [`PanelGesture::ClearControl`] → `PanelClear { scope, channel }` //! - [`PanelGesture::ClearScope`] → `PanelClear { scope }` //! - [`PanelGesture::SetAutoSave`] → the P11 auto-save toggle -//! - [`PanelGesture::ToggleGroup`] is pure disclosure — it has no wire op -//! and never will; it is view state (a `CardUiState` sibling at M4). +//! +//! 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 @@ -35,11 +37,6 @@ pub enum PanelGesture { /// The scope to clear. scope: String, }, - /// Collapse or expand a nested panel group. Disclosure only. - ToggleGroup { - /// The nested group's scope. - 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 index 8d369ddf7..bb17f1f3f 100644 --- 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 @@ -16,7 +16,8 @@ use dioxus::prelude::*; use lpa_studio_web_story_macros::story; use super::module_fixtures::{ - PanelSpike, ROOT_SCOPE, held_root_face, root_module_node_view, three_state_panel, + PLASMA_1_SCOPE, PanelSpike, ROOT_SCOPE, held_root_face, root_module_node_view, + three_state_panel, }; use super::{ModulePanel, PanelGesture}; @@ -33,7 +34,7 @@ fn PanelCanvas(children: Element) -> Element { } #[story( - description = "The three panel states across all three widget families. Read-at-default = quiet accent, subtle label, caption says nothing writes it. Read-following = violet at the LIVE value, caption names the writer. Engaged = amber arc/fill/ring plus a per-control reset glyph and a 'held · was …' caption." + 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! { @@ -48,7 +49,7 @@ fn three_states() -> Element { } #[story( - description = "Reset granularity (P2 clear): per control — the amber revert glyph beside the label, present ONLY while engaged — and per module — the 'reset N' chip in the panel header, which counts everything under the scope including nested groups. An untouched panel shows no destructive control at all." + 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! { @@ -64,7 +65,7 @@ fn reset_gestures() -> Element { } #[story( - description = "Auto-save off (P11): the toggle sits in the panel's header row beside the reset chip, 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)." + 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(); @@ -82,7 +83,7 @@ fn auto_save_off() -> Element { } #[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 header chip drops the writer and the control falls back to following the project. Latch, not Touch — letting go changes nothing." + 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 @@ -105,6 +106,47 @@ fn latch_walkthrough() -> Element { } } +#[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." )] 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 index daeea6fc1..cd85decc1 100644 --- 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 @@ -16,7 +16,7 @@ use super::PlayModeSurface; use super::module_fixtures::held_root_face; #[story( - description = "Play mode, desktop: the root module's panel alone — its own channels plus the two effect groups — over a slim output banner. No hero-dominant preview, no children, no wiring, no card chrome. Reset and auto-save stay because they are end-user gestures." + 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(); @@ -52,15 +52,12 @@ fn mobile() -> Element { } #[story( - description = "Play mode with the nested groups collapsed: the end-user default when a project embeds several effects — the root's own controls up front, each effect one tap away. Compare against the expanded desktop story for which should be the default." + 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_collapsed() -> Element { - let mut face = held_root_face(); - for group in &mut face.panel.groups { - group.collapsed = true; - } +fn groups_wrapped() -> Element { + let face = held_root_face(); rsx! { - div { class: "tw:h-[560px] tw:w-[375px] tw:overflow-auto tw:border tw:border-border", + div { class: "tw:h-[560px] tw:w-[430px] tw:overflow-auto tw:border tw:border-border", PlayModeSurface { panel: face.panel, preview: face.preview, From 0d4f2c53147df4ebb8307cac79976d58fc1a4c60 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Sat, 1 Aug 2026 07:32:47 -0700 Subject: [PATCH 16/16] =?UTF-8?q?fix(studio):=20constant-height=20group=20?= =?UTF-8?q?legend=20=E2=80=94=20sibling=20boxes=20stay=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset button is h-5 while the bare label line is shorter, so a box with a held control grew taller than its unheld sibling in the same flex row. Pin the legend line box to h-5 either way. Co-Authored-By: Claude Fable 5 --- lp-app/lpa-studio-web/src/app/module/module_panel.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 36f71d46b..242384c3e 100644 --- a/lp-app/lpa-studio-web/src/app/module/module_panel.rs +++ b/lp-app/lpa-studio-web/src/app/module/module_panel.rs @@ -180,8 +180,9 @@ pub fn NestedPanelGroup( rsx! { fieldset { class: box_class, // The legend rides IN the top border — the name and, beside it, - // the box's own reset. - legend { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-1.5 tw:px-1.5", + // 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,