diff --git a/docs/adr/2026-07-16-primary-visual-product.md b/docs/adr/2026-07-16-primary-visual-product.md new file mode 100644 index 000000000..b5fbb3400 --- /dev/null +++ b/docs/adr/2026-07-16-primary-visual-product.md @@ -0,0 +1,75 @@ +# ADR: Primary Visual Product — the Project's Face Is a Bus Convention + +- **Status:** Accepted +- **Date:** 2026-07-16 +- **Deciders:** Photomancer +- **Supersedes:** the informal `channel.name == "visual.out"` check in + `ProjectController::ui_bus_view` (the bus pane's PRIMARY badge) +- **Superseded by:** None + +## Context + +Several surfaces need one answer to "what do I render to represent this +project?": the Studio editor's default preview, the bus pane's PRIMARY +badge, home-gallery card imagery (the GPU live-gallery plan's +PreviewHost and its static snapshot fallback), and any future headless +thumbnailer. Today the answer is folklore: the default visual-output +policy targets `bus:visual.out` (ADR 2026-07-09), fixtures sample it, +and the bus pane hardcodes the channel name for its badge — a +coincidence of defaults, not a contract. Consumers that re-derive +"which product is the face" will disagree at the edges (multiple +providers, no provider, provider that fails to resolve). + +## Decision + +**The project's primary visual is the resolved value of +`bus:visual.out`: the product produced by the channel's +highest-priority provider.** + +- The channel name is a constant in the well-known registry + (`lpc-model/src/bus/well_known.rs`, `PRIMARY_VISUAL_CHANNEL`) — the + one place the name is written. +- **Resolution is the engine's, not the client's.** The binding-graph + probe (ADR 2026-07-06) already resolves each channel's value by + provider priority; for `visual.out` that value is a + `LpValue::Product(ProductRef::Visual(..))`. Clients read the resolved + answer instead of re-deriving it from provider lists — + `ProjectController::primary_visual_product()` is the client-side + helper, resolved from the cached graph with **no new wire surface**. +- **Determinism:** + - No provider, unresolvable value, or a non-visual product → `None`: + the defined empty state (consumers show their placeholder; nothing + is invented). + - Multiple providers → binding priority (authored > default, per the + existing `BindingPriority` ordering). + - Equal priority → the binding index's registration order decides, + which the graph probe reports deterministically + (`Reverse(priority)`, then `BindingRef`). This tie-break is now + contract, not accident. +- `control.out` is the analogous convention for control-first projects. + It is declared here for symmetry and left unconsumed: no + control-product preview surface exists yet. + +## Consumers + +- **Bus pane PRIMARY badge** — same constant, same meaning. +- **Studio always-live preview** — the primary product is subscribed + whenever a project is open (independent of node focus), so the + project's face is always streaming somewhere stable. +- **Gallery card imagery** — the GPU live-gallery plan's PreviewHost + presents the primary product per leased card; its save-time snapshot + fallback captures the same product. (See + `Planning/lp2025/2026-07-16-gpu-live-gallery-cards/`.) +- Future: headless thumbnailers, device-side "what's playing" surfaces. + +## Consequences + +- Exactly one definition of "the project's face"; surfaces cannot + drift. +- Projects whose primary output is intentionally not `visual.out` + (control-first installations) have no visual face by definition — + their preview surfaces show the empty state until a `control.out` + preview story exists. +- The helper is only as fresh as the cached binding graph; consumers + needing synchronous freshness after an edit should treat `None` / + stale as "keep the previous frame", never as an error. diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs index 294157e4d..05aec876b 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs @@ -166,6 +166,7 @@ impl NodeController { &SlotEditJoin::empty(), &|_| Vec::new(), &|_, _| None, + None, ) } @@ -187,12 +188,14 @@ impl NodeController { edits: &SlotEditJoin<'_>, extra_config: &impl Fn(NodeId) -> Vec, asset_editor: &impl Fn(&NodeController, &UiSlotAsset) -> Option, + always_live: Option<&UiProductRef>, ) -> UiNodeView { let children = self.ui_children_with_product_previews( product_preview, edits, extra_config, asset_editor, + always_live, ); let dirty = self.own_slots_dirty_summary(edits) + children @@ -213,8 +216,12 @@ impl NodeController { header = header.with_detail(detail); } - let mut sections = - self.ui_sections_with_product_previews(product_preview, edits, extra_config); + let mut sections = self.ui_sections_with_product_previews( + product_preview, + edits, + extra_config, + always_live, + ); self.embed_asset_editors(&mut sections, asset_editor); let mut view = UiNodeView::new(header, vec![UiNodeTab::main(sections)]) .with_node_id(self.address.to_string()) @@ -412,6 +419,7 @@ impl NodeController { product_preview: &impl Fn(&UiProductRef) -> Option, edits: &SlotEditJoin<'_>, extra_config: &impl Fn(NodeId) -> Vec, + always_live: Option<&UiProductRef>, ) -> Vec { let mut products = Vec::new(); let mut produced_values = Vec::new(); @@ -443,12 +451,18 @@ impl NodeController { product.preview = preview; has_cached_preview = true; } - product.tracking = - if base_tracking == UiProductTrackingState::Untracked && has_cached_preview { - UiProductTrackingState::Paused - } else { - base_tracking - } + // The primary visual is always presented live: it is + // subscribed project-wide regardless of node focus (ADR + // 2026-07-16-primary-visual-product, M6 P3). + let is_always_live = + product.product.is_some() && product.product.as_ref() == always_live; + product.tracking = if is_always_live { + UiProductTrackingState::Tracking + } else if base_tracking == UiProductTrackingState::Untracked && has_cached_preview { + UiProductTrackingState::Paused + } else { + base_tracking + } } sections.push(UiNodeSection::ProducedProducts(products)); } @@ -492,6 +506,7 @@ impl NodeController { edits: &SlotEditJoin<'_>, extra_config: &impl Fn(NodeId) -> Vec, asset_editor: &impl Fn(&NodeController, &UiSlotAsset) -> Option, + always_live: Option<&UiProductRef>, ) -> Vec { self.children .iter() @@ -505,14 +520,19 @@ impl NodeController { view.summary = child.status.detail.clone(); view.focused = child.state.focused; view.action = Some(node_focus_action(child)); - view.sections = - child.ui_sections_with_product_previews(product_preview, edits, extra_config); + view.sections = child.ui_sections_with_product_previews( + product_preview, + edits, + extra_config, + always_live, + ); child.embed_asset_editors(&mut view.sections, asset_editor); view.children = child.ui_children_with_product_previews( product_preview, edits, extra_config, asset_editor, + always_live, ); view.dirty = child.own_slots_dirty_summary(edits) + view diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index bc2f21961..55f77db45 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -310,7 +310,7 @@ impl ProjectController { .and_then(|value| value.value.as_ref()) .map(format_lp_value), value_error: channel.value.as_ref().and_then(|value| value.error.clone()), - primary_visual: channel.name == "visual.out", + primary_visual: channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL, writers: channel.providers.iter().filter_map(site).collect(), readers: channel.consumers.iter().filter_map(site).collect(), }) @@ -318,6 +318,28 @@ impl ProjectController { Some(crate::UiBusView { channels }) } + /// The project's **primary visual product**: the resolved value of + /// `bus:visual.out` (ADR 2026-07-16-primary-visual-product). + /// + /// Reads the engine's answer off the cached binding graph — the probe + /// already resolves the channel by provider priority, so this never + /// re-derives precedence client-side. `None` is the defined empty + /// state: no graph yet, no provider, an unresolved value, or a + /// non-visual product on the channel. + pub fn primary_visual_product(&self) -> Option { + let graph = self.binding_graph()?; + let channel = graph + .channels + .iter() + .find(|channel| channel.name == lpc_model::PRIMARY_VISUAL_CHANNEL)?; + let value = channel.value.as_ref()?.value.as_ref()?; + let lpc_model::LpValue::Product(product) = value else { + return None; + }; + matches!(product, lpc_model::ProductRef::Visual(_)) + .then(|| UiProductRef::from_product_ref(*product)) + } + /// Channels the binding picker offers: every channel observed in the /// effective binding graph plus the well-known registry, well-known /// first (M4). Kinds come from the registry, falling back to the wire @@ -389,6 +411,7 @@ impl ProjectController { /// Project root node controllers into node-pane DTOs in project tree order. pub fn ui_nodes(&self) -> Vec { + let primary_visual = self.primary_visual_product(); let product_preview = |product: &UiProductRef| self.sync.as_ref()?.product_preview(product).cloned(); let asset_editor = @@ -403,6 +426,7 @@ impl ProjectController { &edits, &extra_config, &asset_editor, + primary_visual.as_ref(), ) }) .collect() @@ -1071,6 +1095,7 @@ impl ProjectController { inventory: &ProjectInventorySummary, ) -> ProjectEditorView { let summary = self.sync_summary().unwrap_or_default(); + let primary_visual = self.primary_visual_product(); let product_preview = |product: &UiProductRef| self.sync.as_ref()?.product_preview(product).cloned(); let asset_editor = @@ -1087,6 +1112,7 @@ impl ProjectController { &edits, &extra_config, &asset_editor, + primary_visual.as_ref(), ) }) .collect::>(); @@ -1668,6 +1694,10 @@ impl ProjectController { for node in &self.root_nodes { self.collect_subscribed_products(node, &mut product_refs); } + // The primary visual streams whenever a project is open — the + // project's face is always live regardless of node focus (ADR + // 2026-07-16-primary-visual-product; M6 P3). + product_refs.extend(self.primary_visual_product()); product_refs.into_iter().collect() } @@ -4038,6 +4068,134 @@ mod tests { ); } + fn primary_visual_graph(value: Option) -> lpc_wire::WireBindingGraph { + lpc_wire::WireBindingGraph { + revision: Revision::new(2), + bindings: Vec::new(), + channels: vec![lpc_wire::WireBusChannel { + name: lpc_model::PRIMARY_VISUAL_CHANNEL.to_string(), + kind: Some(lpc_model::Kind::Color), + providers: Vec::new(), + consumers: Vec::new(), + value: Some(lpc_wire::WireBusChannelValue { + revision: Revision::new(2), + value, + error: None, + }), + }], + } + } + + fn project_with_graph(graph: lpc_wire::WireBindingGraph) -> ProjectController { + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + project + .apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok)) + .unwrap(); + project + .sync_mut() + .unwrap() + .set_binding_graph_for_test(graph); + project + } + + #[test] + fn primary_visual_product_reads_the_resolved_channel_value() { + // The engine already resolved visual.out by provider priority; the + // helper reads that answer (ADR 2026-07-16-primary-visual-product). + let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new( + lpc_model::NodeId::new(5), + 0, + )); + let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::Product( + product, + )))); + assert_eq!( + project.primary_visual_product(), + Some(UiProductRef::Visual { + node_id: 5, + output: 0 + }) + ); + } + + #[test] + fn primary_visual_product_empty_states_are_none() { + // No graph yet. + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + assert_eq!(project.primary_visual_product(), None); + + // Channel present but unresolved (no provider produced a value). + let project = project_with_graph(primary_visual_graph(None)); + assert_eq!(project.primary_visual_product(), None); + + // Channel resolved to a non-product value: defined empty, not a guess. + let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::F32(1.0)))); + assert_eq!(project.primary_visual_product(), None); + } + + #[test] + fn primary_visual_product_presents_live_without_focus() { + // The owning node is unfocused with Default intent, but the primary + // visual's preview presents as Tracking, not Paused/Untracked. + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_ui_projection_slots(&mut view, 1, Revision::new(4)); + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + project.apply_project_view(&view).unwrap(); + let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new( + lpc_model::NodeId::new(1), + 0, + )); + project + .sync_mut() + .unwrap() + .set_binding_graph_for_test(primary_visual_graph(Some(lpc_model::LpValue::Product( + product, + )))); + + let nodes = project.ui_nodes(); + let products = node_sections(&nodes[0]) + .iter() + .find_map(|section| match section { + UiNodeSection::ProducedProducts(products) => Some(products.clone()), + _ => None, + }) + .expect("produced products section"); + let primary = products + .iter() + .find(|product| { + product.product + == Some(UiProductRef::Visual { + node_id: 1, + output: 0, + }) + }) + .expect("primary product row"); + assert_eq!(primary.tracking, UiProductTrackingState::Tracking); + } + + #[test] + fn primary_visual_product_is_always_subscribed() { + // Nothing focused, no explicit intent: the project's face still + // streams (M6 P3 — always-live primary preview). + let product = lpc_model::ProductRef::visual(lpc_model::VisualProduct::new( + lpc_model::NodeId::new(1), + 0, + )); + let project = project_with_graph(primary_visual_graph(Some(lpc_model::LpValue::Product( + product, + )))); + assert_eq!( + project.subscribed_products(), + vec![UiProductRef::Visual { + node_id: 1, + output: 0 + }] + ); + } + #[test] fn default_wiring_offers_bind_not_retarget() { let mut view = single_node_view(1, NodeRuntimeStatus::Ok); diff --git a/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs index 20313d853..00e1cdffc 100644 --- a/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs +++ b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs @@ -646,6 +646,12 @@ async function waitForStoryReady(cdp, sessionId, storyId) { if (!el || el.getAttribute('data-story-id') !== ${JSON.stringify(storyId)}) { return false; } + // Webfont fallback metrics shift text by a few pixels; a capture that + // races font decoding diffs nondeterministically run-to-run (scattered + // ~Δ180 text pixels on whichever pages lost the race). + if (document.fonts && document.fonts.status !== 'loaded') { + return false; + } return !document.querySelector('[data-story-wait="1"]'); })() `; diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png index 9fb77f088..c073a7d75 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png index 2890b8c69..55307174c 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__loaded__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png index 67743dc02..f2792272d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__layout__version-badge__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-matrix-cells__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-matrix-cells__lg.png index 52f0063b7..15d2d5e94 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-matrix-cells__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__editable-matrix-cells__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png index 32bbb1ae2..03de4f72b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-add-string-key__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png index 943afefd5..6514ce558 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-entry-key-edit-open__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png index f150e8338..9e0ffa30b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-move-rejected-occupied__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png index 3191a97a7..976b78e53 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__map-removed-entry-parent-dirty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png index 9f5671213..3bc701dc7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__option-set-and-clear__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png index 3f0be7801..a61368f50 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__scalar-input-states__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__scalar-input-states__md.png index 3a46c9c8c..da9f290d7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__scalar-input-states__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__scalar-input-states__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-rows__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-rows__md.png index e9d535219..d9d442a41 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-rows__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-rows__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-states__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-states__md.png index 2ebf5f529..6aaf46e69 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-states__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__special-editor-states__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__candidates-side-by-side__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__candidates-side-by-side__lg.png index c7867b4cf..f1162d2ff 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__candidates-side-by-side__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__candidates-side-by-side__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png index 3b3e629c4..450802032 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__checkbox-square__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__child-row__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__child-row__md.png index b6462265e..171f96b38 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__child-row__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__child-row__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png index 60cec5d1e..5ffd8e7bc 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png index 37dc6db51..c9aacdfb7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__presence-in-row__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__lg.png index e2f886ae7..d0907410b 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__md.png index 9649a7832..32d88e8fa 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-option-presence__width-tiers__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dimensions-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dimensions-field__md.png index 55f7bab0d..424a2af58 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dimensions-field__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dimensions-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png index 980099d04..397fe8dae 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png index e380bbe02..e4d7c6ca4 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__lg.png index 19807ddd2..8ee69e629 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__md.png index 1916c4ec4..e52fc5486 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-raw-input-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png index 9ac3269e9..633d81079 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__md.png index 3f79bf05c..396a73e3d 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__md.png and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-raw-input-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png index 60ef2b9a9..be9dd007f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-pane__change-list-empty__md.png differ diff --git a/lp-core/lpc-model/src/bus/mod.rs b/lp-core/lpc-model/src/bus/mod.rs index 9fd7d661f..4820cb163 100644 --- a/lp-core/lpc-model/src/bus/mod.rs +++ b/lp-core/lpc-model/src/bus/mod.rs @@ -2,4 +2,7 @@ pub mod channel_name; pub mod well_known; pub use channel_name::ChannelName; -pub use well_known::{WELL_KNOWN_CHANNELS, WellKnownChannel, well_known_channel}; +pub use well_known::{ + PRIMARY_CONTROL_CHANNEL, PRIMARY_VISUAL_CHANNEL, WELL_KNOWN_CHANNELS, WellKnownChannel, + well_known_channel, +}; diff --git a/lp-core/lpc-model/src/bus/well_known.rs b/lp-core/lpc-model/src/bus/well_known.rs index 3243d0e4a..420589cf0 100644 --- a/lp-core/lpc-model/src/bus/well_known.rs +++ b/lp-core/lpc-model/src/bus/well_known.rs @@ -9,6 +9,16 @@ use crate::Kind; +/// The channel whose resolved value is the project's **primary visual** — +/// "the project's face" for previews, gallery cards, and thumbnailers +/// (ADR 2026-07-16-primary-visual-product). The one place this name is +/// written; every consumer references the constant. +pub const PRIMARY_VISUAL_CHANNEL: &str = "visual.out"; + +/// The analogous convention for control-first projects (declared for +/// symmetry by the same ADR; no preview surface consumes it yet). +pub const PRIMARY_CONTROL_CHANNEL: &str = "control.out"; + /// One well-known channel: canonical name, semantic kind, and the docs the /// picker surfaces. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -35,12 +45,12 @@ pub const WELL_KNOWN_CHANNELS: &[WellKnownChannel] = &[ doc: "Control events (button presses, remote triggers); map readers merge by message id.", }, WellKnownChannel { - name: "visual.out", + name: PRIMARY_VISUAL_CHANNEL, kind: Kind::Color, doc: "The project's primary visual output; fixtures sample it.", }, WellKnownChannel { - name: "control.out", + name: PRIMARY_CONTROL_CHANNEL, kind: Kind::Color, doc: "Rendered control samples; hardware outputs drive from it.", }, diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index b6edcd01a..a4ac6bf71 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -73,7 +73,10 @@ pub use binding::{ BindingDef, BindingDefError, BindingDefView, BindingDefs, BindingRef, BindingRefError, BusSlotRef, BusSlotRefError, NodeSlotRef, NodeSlotRefError, }; -pub use bus::{ChannelName, WELL_KNOWN_CHANNELS, WellKnownChannel, well_known_channel}; +pub use bus::{ + ChannelName, PRIMARY_CONTROL_CHANNEL, PRIMARY_VISUAL_CHANNEL, WELL_KNOWN_CHANNELS, + WellKnownChannel, well_known_channel, +}; pub use constraint::{Constraint, ConstraintChoice, ConstraintFree, ConstraintRange}; /// Legacy semantic value kind used by the pre-slot property model. ///