Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d883ec9
feat(studio): module face + panel DTOs for the M2 UX spike
Yona-Appletree Jul 31, 2026
b5bec0a
feat(studio): engaged (latch) treatment on knob, fader, and toggle
Yona-Appletree Jul 31, 2026
71e53e5
feat(studio): module face, panel surface, and play mode (M2 spike)
Yona-Appletree Jul 31, 2026
b5dd9f1
feat(studio): module-face stories — root card, zoom levels, bus split
Yona-Appletree Jul 31, 2026
25536ae
feat(studio): panel-state stories — three states, reset, auto-save
Yona-Appletree Jul 31, 2026
2818408
feat(studio): playlist meta-switching mock (modules.md E2)
Yona-Appletree Jul 31, 2026
e32348d
feat(studio): play-mode mock — root panel only, desktop and mobile
Yona-Appletree Jul 31, 2026
4e407e5
polish(studio): panel legibility at phone width
Yona-Appletree Jul 31, 2026
8edde58
refactor(studio): children leave the module face (G2 rev 1)
Yona-Appletree Aug 1, 2026
9b82aae
feat(studio): module children expand under the card as sibling cards
Yona-Appletree Aug 1, 2026
c3a4b6e
polish(studio): panel reset and auto-save become quiet upper-right ch…
Yona-Appletree Aug 1, 2026
cfbe52c
test(studio): pin children-below-the-card and one-control-two-cards
Yona-Appletree Aug 1, 2026
658f954
feat(studio): panel controls and groups grow detail-popup aspects
Yona-Appletree Aug 1, 2026
12278b3
feat(studio): panel controls lose their sublabels, gain a detail popup
Yona-Appletree Aug 1, 2026
64fa350
feat(studio): nested panel groups become bordered clusters in a flex row
Yona-Appletree Aug 1, 2026
0d4f2c5
fix(studio): constant-height group legend — sibling boxes stay level
Yona-Appletree Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lp-app/lpa-studio-core/src/app/node/face/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,22 @@
//! fallback, also always available inside the advanced drawer.

mod ui_fixture_face;
mod ui_module_face;
mod ui_node_face;
mod ui_panel_control;
mod ui_panel_control_view;
mod ui_panel_group;
mod ui_panel_widget;
mod ui_playlist_entry;
mod ui_playlist_face;
mod ui_shader_face;

pub use ui_fixture_face::UiFixtureFace;
pub use ui_module_face::UiModuleFace;
pub use ui_node_face::UiNodeFace;
pub use ui_panel_control::UiPanelControl;
pub use ui_panel_control_view::{UiPanelControlState, UiPanelControlView};
pub use ui_panel_group::UiPanelGroup;
pub use ui_panel_widget::UiPanelWidget;
pub use ui_playlist_entry::UiPlaylistEntry;
pub use ui_playlist_face::UiPlaylistFace;
Expand Down
64 changes: 64 additions & 0 deletions lp-app/lpa-studio-core/src/app/node/face/ui_module_face.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! The **module** card's face — the one face worn at every depth.
//!
//! `docs/design/modules.md` §5: one face, three zoom levels. The root
//! module wears it as the single top-level workspace card (the flat-root
//! reversal — the root now *does* something); an embedded module wears the
//! same face as a child card inside its host; play mode renders the root
//! module's panel alone, without any face at all.
//!
//! Top-down: output-mirror hero (R7) → panel (R8) → the bus-as-wiring
//! drawer → provenance. The split between the panel and the wiring drawer
//! is the sidebar bus pane's replacement: bus-as-controls sits on the face,
//! bus-as-writers/readers goes in a drawer.
//!
//! **Children are not on the face.** They render below the card as full
//! sibling cards, through the same [`crate::UiNodeChild`] path the playlist
//! and the old project node use — the module contributes no new nesting
//! grammar. All of a module's children render, not just an active one:
//! module children are collaborators, not branches.
//!
//! M2 UX spike: this DTO is fed by mock fixtures. Deriving it from real
//! scope data is M4's work.

use crate::{UiBusView, UiPanelGroup, UiProducedProduct};

/// A module node card's permanent face.
#[derive(Clone, Debug, PartialEq)]
pub struct UiModuleFace {
/// The module's produced `output` slot, mirroring its own scope's
/// `visual.out` (R7) — the face hero. `None` for a module with no
/// visual, which is a legitimate shape (E6).
pub preview: Option<UiProducedProduct>,
/// 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<UiBusView>,
/// Whether the wiring drawer renders expanded.
pub wiring_open: bool,
/// Compact provenance line ("Yona · v1 · CC0-1.0"); `None` when the
/// module carries no provenance fields (§8).
pub provenance: Option<String>,
/// Panel-state auto-save (panel.md P11 — on by default, with a user
/// toggle). `Some` only on the module that presents the toggle: panel
/// state persists per project folder (`.lp/state.json`), so it is the
/// project's root module that owns it, and an embedded module carries
/// `None` rather than repeating its host's switch.
pub auto_save: Option<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,
provenance: None,
auto_save: None,
}
}
}
16 changes: 15 additions & 1 deletion lp-app/lpa-studio-core/src/app/node/face/ui_node_face.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! The kind-specific face variants a node card can render.

use crate::{UiFixtureFace, UiPlaylistFace, UiShaderFace};
use crate::{UiFixtureFace, UiModuleFace, UiPanelGroup, UiPlaylistFace, UiShaderFace};

/// Kind-specific permanent face for a node card.
///
Expand All @@ -16,4 +16,18 @@ pub enum UiNodeFace {
/// Playlist card: entry strip; the active child's real card renders
/// below via the existing [`crate::UiNodeChild`].
Playlist(UiPlaylistFace),
/// Module card (`docs/design/modules.md` §5): output-mirror hero, the
/// scope's panel, and the bus-wiring drawer. Worn at every depth — root
/// workspace card and embedded child card alike. Its children render
/// below it as sibling cards via [`crate::UiNodeChild`], like every
/// other kind. **M2 UX spike:** fed by mock fixtures, not yet derived.
Module(UiModuleFace),
/// A non-module node whose whole face is its bound slots, presented as
/// panel controls (`docs/design/modules.md` R3): the clock, the control
/// node, the fixture sitting under a module. The group carries the
/// ENCLOSING scope, because a leaf introduces no scope of its own — so
/// these controls share their `(scope, channel)` identity, and their
/// panel state, with the module panel above (`panel.md` P1).
/// **M2 UX spike:** mock-fed.
Controls(UiPanelGroup),
}
179 changes: 179 additions & 0 deletions lp-app/lpa-studio-core/src/app/node/face/ui_panel_control_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! One control on a **panel**, plus the panel state behind it.
//!
//! `docs/design/panel.md` is the normative treatment. A control is
//! identified by `(scope, channel)` (P1); the scope is the owning
//! [`crate::UiPanelGroup`], the channel is [`UiPanelControlView::channel`].
//! The widget payload is the existing [`UiPanelControl`] verbatim — the
//! module model reuses the node-face panel widgets rather than forking a
//! second control family.
//!
//! M2 UX spike: the state below is carried by mock fixtures. The engine
//! runtime that materializes real panel writers is M4's.

use crate::{UiPanelControl, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow};

/// The three visibly distinct states a panel control can be in
/// (`docs/design/panel.md` P2; the three-state requirement is P-Q2).
///
/// These are *not* a severity ladder and must not reuse the bound-violet
/// family: "bound" means wired, "engaged" means captured (P6).
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum UiPanelControlState {
/// **Read, at default.** No writer anywhere in the scope chain, so the
/// consuming slot falls back to its own authored default
/// (`modules.md` R6). The channel still lists — an unfilled public
/// input is an invitation, not an error.
#[default]
ReadDefault,
/// **Read, following automation.** Some writer that is *not* this
/// control's drives the channel: an authored node in this scope (an
/// LFO, a clock) or an inherited writer from an enclosing scope
/// (`modules.md` R5). The control displays the live value and can be
/// grabbed (P5 jump takeover).
ReadFollowing,
/// **Engaged (Latch).** This control's panel writer exists and holds,
/// shadowing other writers in its scope until cleared (P2/P4).
Engaged,
}

impl UiPanelControlState {
/// Whether the control's panel writer exists — the one bit a reset
/// gesture acts on (P2 clear).
pub fn engaged(self) -> bool {
matches!(self, Self::Engaged)
}

/// Short state word for the control's caption and for the group's
/// summary rows.
pub fn word(self) -> &'static str {
match self {
Self::ReadDefault => "default",
Self::ReadFollowing => "following",
Self::Engaged => "held",
}
}
}

/// One channel presented on one panel.
#[derive(Clone, Debug, PartialEq)]
pub struct UiPanelControlView {
/// The channel name within the owning group's scope — the other half of
/// the control's identity (P1).
pub channel: String,
/// Widget, label, value, unit, and detail aspects. Reused verbatim from
/// the node-face panel so knob v2, the fader, and the toggle behave
/// identically wherever they appear.
pub control: UiPanelControl,
/// Which of the three panel states this control is in.
pub state: UiPanelControlState,
/// Who owns the displayed value while the control is in Read: "clock",
/// "inherited from Evening set", "authored default" (P2 — the UI
/// distinguishes inherited / authored / default).
pub source: Option<String>,
}

impl UiPanelControlView {
/// A control in its Read-at-default state.
pub fn new(channel: impl Into<String>, 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<impl Into<String>>,
) -> Self {
self.state = state;
self.source = source.map(Into::into);
self
}

/// The control's detail-popup sections.
///
/// A control on the face is widget + label + value and nothing else —
/// a control panel, not a spec sheet. Everything that used to sit under
/// it as a caption lives here instead, behind the label: which of the
/// three states it is in, what drives it, what a held value displaced,
/// and the `(scope, channel)` identity itself (P1).
///
/// The widget's own aspects (validation, type info, binding on the
/// backing slot) follow, so the panel popup is a superset of the slot
/// popup rather than a fork of it.
pub fn detail_aspects(&self, scope: &str) -> Vec<UiSlotAspect> {
let mut aspects = vec![self.state_aspect()];
aspects.push(
UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Channel")
.with_row(UiSlotAspectRow::new("Name", self.control.label.clone()))
.with_row(UiSlotAspectRow::new("Channel", self.channel.clone()))
.with_row(UiSlotAspectRow::new("Scope", scope)),
);
aspects.extend(self.control.aspects.iter().cloned());
aspects
}

/// The panel-state section: one title per state, and rows that say what
/// the state displaced.
fn state_aspect(&self) -> UiSlotAspect {
let source = self.source.clone();
match self.state {
UiPanelControlState::Engaged => {
let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "Held")
.with_affordance(UiSlotAffordance::Edited)
.with_row(UiSlotAspectRow::new(
"",
"This panel holds the channel; other writers are shadowed until it is reset.",
));
match source {
Some(source) => aspect.with_row(UiSlotAspectRow::new("Was", source)),
None => aspect,
}
}
UiPanelControlState::ReadFollowing => {
let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "Following")
.with_affordance(UiSlotAffordance::Bound)
.with_row(UiSlotAspectRow::new(
"",
"Something else drives this channel; turning the control takes it over.",
));
match source {
Some(source) => aspect.with_row(UiSlotAspectRow::new("Driven by", source)),
None => aspect,
}
}
UiPanelControlState::ReadDefault => {
let aspect = UiSlotAspect::new(UiSlotAspectKind::PanelState, "At default")
.with_row(UiSlotAspectRow::new(
"",
"Nothing writes this channel, so the consuming slot uses its own default.",
));
match source {
Some(source) => aspect.with_row(UiSlotAspectRow::new("Value from", source)),
None => aspect,
}
}
}
}
}

#[cfg(test)]
mod tests {
use super::UiPanelControlState;

#[test]
fn only_the_engaged_state_carries_a_panel_writer() {
assert!(UiPanelControlState::Engaged.engaged());
assert!(!UiPanelControlState::ReadFollowing.engaged());
assert!(!UiPanelControlState::ReadDefault.engaged());
// Read-at-default is the resting state a fresh panel renders in.
assert_eq!(
UiPanelControlState::default(),
UiPanelControlState::ReadDefault
);
}
}
Loading
Loading