diff --git a/docs/adr/2026-06-22-studio-device-ux-workflow.md b/docs/adr/2026-06-22-studio-device-ux-workflow.md index e00811ba2..1711b8e07 100644 --- a/docs/adr/2026-06-22-studio-device-ux-workflow.md +++ b/docs/adr/2026-06-22-studio-device-ux-workflow.md @@ -1,6 +1,12 @@ # ADR: Studio Device UX Workflow -- **Status:** Accepted +- **Status:** Superseded (2026-07-28) — the Device pane and its + `UiStackView`/`UiStackSection`/`UiStepState` primitives are deleted. D43 + made the grown device CARD the editor's device surface and the gallery + roster the connect surface; see + `docs/defects/2026-07-28-retired-device-pane-still-reachable.md` and + [2026-07-05 Studio Pane Grammar](./2026-07-05-studio-pane-grammar.md). + The workflow reasoning below is kept as the historical record. - **Date:** 2026-06-22 - **Deciders:** Photomancer - **Builds On:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md), diff --git a/docs/adr/2026-07-05-studio-pane-grammar.md b/docs/adr/2026-07-05-studio-pane-grammar.md index 6ebbcec08..f0e0645a8 100644 --- a/docs/adr/2026-07-05-studio-pane-grammar.md +++ b/docs/adr/2026-07-05-studio-pane-grammar.md @@ -158,10 +158,14 @@ Adopted by the node pane (P3: selection toggle in `primary`, tabs in project name as title, controller actions, node tree as the body; the status word, dirty counts, and project stats live in the detail popup, and the merged affordance drives the trigger and the header wash). -**The device pane is the intended third consumer**: its header should map status onto -`PaneTone`, its detail popup onto `DetailPopover`, and its contextual -operations onto `UiPaneAction`s the next time device-pane UX work is -scheduled (Follow-ups). +~~**The device pane is the intended third consumer**~~ — **superseded +2026-07-28**: the device pane was DELETED rather than adopted. D43 made +the grown device CARD the editor's device surface, and the pane's +step-stack body (`UiStepsView` / `UiViewContent::Stack`) went with it. +The grammar's third consumer never arrived; `PaneView`, `PaneFrame`, and +the `UiViewContent` body types the pane shared with the project and bus +panes are unaffected. See +`docs/defects/2026-07-28-retired-device-pane-still-reachable.md`. ## Consequences @@ -208,10 +212,9 @@ scheduled (Follow-ups). Per the deferred-decision convention, these are indexed in `docs/adr/README.md`. -- **(a) Device-pane adoption.** The device pane still draws pre-grammar - chrome. Map it onto `StudioPane` (status → `PaneTone`, popup → - `DetailPopover`, operations → `UiPaneAction`). **Revisit when** the next - device-pane UX work is scheduled. +- ~~**(a) Device-pane adoption.**~~ **Closed 2026-07-28 — will not + happen.** The pane was deleted instead (D43: the device card is the + editor's device surface). Nothing draws pre-grammar chrome now. - **(b) Save visibility while scrolling.** The M2 save strip was shell-mounted and always visible; the project header that replaced it scrolls with the project sidebar, so a long tree can push Save off diff --git a/docs/defects/2026-07-28-retired-device-pane-still-reachable.md b/docs/defects/2026-07-28-retired-device-pane-still-reachable.md index 3958eea75..e50ebe0c4 100644 --- a/docs/defects/2026-07-28-retired-device-pane-still-reachable.md +++ b/docs/defects/2026-07-28-retired-device-pane-still-reachable.md @@ -1,10 +1,12 @@ --- -status: open +status: fixed found: 2026-07-28 # how: audit (README hero story showed the retired device UI) +fixed: this change area: lpa-studio-core/home (lens_device_card) + lpa-studio-web/app/layout/studio_shell class: retired-surface-still-reachable related: - ../adr/2026-07-05-studio-pane-grammar.md + - ../adr/2026-06-22-studio-device-ux-workflow.md --- # Unplugging a device mid-project falls back to the retired device pane @@ -53,3 +55,44 @@ the retired pane (~700 lines across core + web, ~54 baselines). The deletion's real cost is retargeting the 16 production `UxActivityTarget::StackSection` call sites that narrate connect/flash progress into the pane. Scheduled as its own PR after #163. + +**Fixed 2026-07-28.** Two commits: + +1. *The holes.* `device_card_from_live_evidence` is the unfiltered, total + derivation; `live_device_card` keeps the Offline filter for the GALLERY + roster only. `lens_device_card()` is now total over a live lens + session, and an unplugged lens fades to "Seen …/Reconnect" (sighting + borrowed from the remembered card, since live evidence carries no + registry entry). The lens and the project are untouched — a flaky cable + never yanks anyone out of their editor. + `settle_connect_outcome`'s four `remove_kind` calls became + `clear_connect_slot`, which quiesces the lens first when it sits on + that kind, so a soft-ended connect can no longer leave the project + `Ready` with no lens. + Pinned by `panes_never_render_without_a_lens_card` (Booting / + BlankFlash / Bootloader / ForeignFirmware / Gone + the sim lens), + `an_unplugged_lens_fades_to_an_offline_card_and_keeps_the_editor`, and + `a_soft_connect_ending_on_the_lens_kind_returns_to_the_gallery`. + +2. *The deletion.* `DeviceController::view` + its section helpers, + `UiStepsView`/`UiStepView`/`UiStepState`, `UiViewContent::Stack` and + `::Empty`, `UxActivityTarget::StackSection`, the web `StepsView`, + `src/app/device/`, and the shell's `group_panes` split. The 16 + `device_section_target` call sites split: six project-facing ones + retargeted to the PROJECT pane (which has the + `UxActivityTarget::pane(ProjectController::NODE_ID)` precedent), ten + device-link ones removed — the card derives that narration from + `ConnectFlowState`, `session.operation_label()`, and the card-owned op + flow. `management_event_sink` lost its parallel `UiActivityView` + mirror; `run_device_management` publishes a fresh view instead so the + card overlay is up before the long manage await. + +**Left behind, deliberately** — `DeviceOp::DisconnectDevice` lost its only +UI entry point (the pane's "Disconnect" button). The op, its handler, and +its e2e coverage all stay; the CARD has no disconnect verb. Erase is +unaffected: the card's Danger tab dispatches `DeployOp::EraseDevice`, +which runs the same `reset_to_blank` the pane's "Erase device" did. +`UiTerminalLine`/`TerminalOutput` were KEPT — they hang off +`UiActivityView`, which survives, and still render + have story coverage +there; nothing in production populates the field, which is its own +cleanup. diff --git a/docs/defects/README.md b/docs/defects/README.md index 212227c69..61bb8044e 100644 --- a/docs/defects/README.md +++ b/docs/defects/README.md @@ -125,6 +125,9 @@ genuinely fits none of these, and define it here in one line. a member that tool actually does assign — so the gap survives exactly as long as nothing exercises that one member. Real toolchain output (a sequence transcription, not memory) is the only falsifier. +- **`retired-surface-still-reachable`** — a surface believed replaced is + still rendered, because its replacement can be absent and the old + surface is the fallback branch. ## Index @@ -218,7 +221,7 @@ sentence (arguments in, returns out; registers and stack). | stand-in-divergence | 2026-07-23 | [popover-open-resizes-card](2026-07-23-popover-open-resizes-card.md) | fixed | lpa-studio-web/base/popover | | stand-in-divergence | 2026-07-27 | [story-check-tolerance-ignores-amplitude](2026-07-27-story-check-tolerance-ignores-amplitude.md) | **open** | lpa-studio-web/scripts + CI | | nondeterministic-capture | 2026-07-28 | [overview-composite-capture-races](2026-07-28-overview-composite-capture-races.md) | fixed | lpa-studio-web story capture (overview composites) | -| retired-surface-still-reachable | 2026-07-28 | [retired-device-pane-still-reachable](2026-07-28-retired-device-pane-still-reachable.md) | **open** | lpa-studio-core/home + studio_shell | +| retired-surface-still-reachable | 2026-07-28 | [retired-device-pane-still-reachable](2026-07-28-retired-device-pane-still-reachable.md) | fixed | lpa-studio-core/home + studio_shell | | stale-measurement | 2026-07-30 | [deploy-compiles-previous-upload](2026-07-30-deploy-compiles-previous-upload.md) | **fixed** (CLI-side; hardware confirmation pending P7) | lp-cli (upload observability) | | stale-measurement | 2026-07-26 | [popover-outline-stale-on-content-resize](2026-07-26-popover-outline-stale-on-content-resize.md) | fixed | lpa-studio-web/base/popover | | stale-measurement | 2026-07-27 | [code-editor-gutter-misaligned](2026-07-27-code-editor-gutter-misaligned.md) | fixed | lpa-studio-web/base/code_editor | diff --git a/lp-app/lpa-studio-core/src/app/device/device_controller.rs b/lp-app/lpa-studio-core/src/app/device/device_controller.rs index df6d03e32..802723ab1 100644 --- a/lp-app/lpa-studio-core/src/app/device/device_controller.rs +++ b/lp-app/lpa-studio-core/src/app/device/device_controller.rs @@ -31,23 +31,18 @@ use std::rc::Rc; use lpa_link::providers::{LinkEnv, LinkProviderRegistry}; use lpa_link::{ - DeviceSession, DeviceState, DeviceTimers, LinkConnector, LinkEndpointId, LinkProvider, - LinkProviderKind, + DeviceSession, DeviceTimers, LinkConnector, LinkEndpointId, LinkProvider, LinkProviderKind, }; use crate::app::device::connect_choices::{provider_auto_connects, provider_choices}; use crate::app::device::device_event_adapter::console_event_sink; use crate::app::device::link_ux::{link_session_logs, map_link_error}; use crate::app::runtime_pool::runtime_session::DeviceHandle; -use crate::core::view::steps_view::{UiStepState, UiStepView}; use crate::{ ConnectFlowState, ConnectedDeviceSummary, Controller, ControllerId, DeviceOp, EndpointChoice, - ProgressState, RuntimePayload, ServerFailureKind, ServerState, SimAttachment, UiAction, - UiError, UiIssue, UiLogDraft, UiMetric, UiPaneView, UiStatus, UiStepsView, UiViewContent, + ProgressState, RuntimePayload, SimAttachment, UiError, UiIssue, UiLogDraft, }; -use crate::app::places::{DeviceContent, DeviceSyncState}; - use crate::app::device::link_ux::is_port_held_error; /// The breath between the ladder's two connect attempts (M6): long @@ -82,28 +77,6 @@ pub struct DeviceController { pending_device_logs: Rc>>, } -/// The runtime evidence the device pane derives its sections from, read -/// off the [`RuntimePool`](crate::RuntimePool) by `StudioController` (the -/// controller itself is slotless). -pub(crate) struct DeviceRuntimeEvidence { - /// The attached runtime is real hardware (the sim is not a device — - /// D22). - pub is_hardware: bool, - /// The attached runtime is the simulator. - pub is_sim: bool, - /// The attached hardware's device state, when hardware is attached. - pub device_state: Option, - /// The lens session's server protocol state (`Disconnected` when no - /// session exists). - pub server_state: ServerState, -} - -impl DeviceRuntimeEvidence { - fn has_lightplayer_state(&self) -> bool { - matches!(self.server_state, ServerState::Connected { .. }) - } -} - /// Outcome of [`DeviceController::open_provider`]. pub enum DeviceOpenOutcome { /// Endpoint discovery finished; the picker state carries the choices. @@ -125,11 +98,6 @@ pub enum DeviceOpenOutcome { impl DeviceController { pub const NODE_ID: &'static str = "studio|device"; - /// The pane's single device section — also the activity target the - /// connect/flash/push flows report progress against. - pub const SECTION_DEVICE: &'static str = "device"; - /// Firmware operations, visually separate from project deploy (D15). - pub const SECTION_FIRMWARE: &'static str = "firmware"; pub fn new() -> Self { Self::with_registry(LinkProviderRegistry::from_env(LinkEnv::default())) @@ -636,178 +604,6 @@ impl DeviceController { _ => None, } } - - // ----------------------------------------------------------------- - // Views - // ----------------------------------------------------------------- - - /// The editor's device pane (D23). `runtime` is the pool-derived - /// runtime evidence; `device_sync` is the connect-time pull result; - /// `usual_device` names where this project usually lives when nothing - /// is connected. - pub(crate) fn view( - &self, - runtime: &DeviceRuntimeEvidence, - device_sync: Option<&DeviceSyncState>, - usual_device: Option, - ) -> UiPaneView { - let sections = if runtime.is_hardware { - let mut sections = vec![self.connected_device_section(runtime, device_sync)]; - sections.push(self.firmware_section()); - sections - } else { - vec![self.disconnected_device_section(runtime, usual_device)] - }; - UiPaneView::new( - Self::NODE_ID, - "Device", - self.status(runtime, device_sync), - UiViewContent::Stack(Box::new(UiStepsView::new(sections))), - Vec::new(), - ) - } - - fn status( - &self, - runtime: &DeviceRuntimeEvidence, - device_sync: Option<&DeviceSyncState>, - ) -> UiStatus { - if !runtime.is_hardware { - return UiStatus::neutral("No device"); - } - // Incompatible firmware outranks the server state: the ONE - // affordance is reflashing (explicit, never automatic). - if matches!(runtime.device_state, Some(DeviceState::Incompatible { .. })) { - return UiStatus::attention("Reflash needed"); - } - match &runtime.server_state { - ServerState::Failed { - kind: ServerFailureKind::NoFirmware, - .. - } => UiStatus::attention("Ready to flash"), - ServerState::Failed { .. } => UiStatus::error("Needs attention"), - ServerState::Connecting { .. } => UiStatus::working("Connecting"), - ServerState::Connected { .. } => { - match device_sync.and_then(|sync| sync.identity.as_ref()) { - Some(identity) => UiStatus::good(identity.name.clone()), - None => UiStatus::good("Connected"), - } - } - ServerState::Disconnected => UiStatus::working("Connecting"), - } - } - - /// The pane when no hardware is attached: association line, ambient - /// runtime line, and the dialog entry. - fn disconnected_device_section( - &self, - runtime: &DeviceRuntimeEvidence, - usual_device: Option, - ) -> UiStepView { - let mut lines = Vec::new(); - if let Some(usual) = usual_device { - lines.push(usual); - } - if runtime.is_sim && runtime.has_lightplayer_state() { - // D16: name where you are — ambient, not a "device" - lines.push("Running in the simulator.".to_string()); - } - if lines.is_empty() { - lines.push("No device connected.".to_string()); - } - UiStepView::new(Self::SECTION_DEVICE, "Device", UiStepState::Pending) - .with_body(UiViewContent::text(lines.join("\n"))) - // M8′: the dialog is gone — the pane's door is the connect - // flow itself (the gallery card carries everything else) - .with_actions(vec![ - UiAction::from_op( - self.node_id(), - DeviceOp::OpenProvider { - provider_id: LinkProviderKind::BrowserSerialEsp32, - }, - ) - .with_label("Connect device…") - .with_summary("Connect a LightPlayer device over USB.") - .with_icon("usb"), - ]) - } - - /// The pane when hardware is attached: identity, contents relation, - /// and the push/disconnect actions. - fn connected_device_section( - &self, - runtime: &DeviceRuntimeEvidence, - device_sync: Option<&DeviceSyncState>, - ) -> UiStepView { - // Incompatible surfaces here with its reflash-only explanation; the - // firmware section below carries the affordance. - if let Some(DeviceState::Incompatible { reason }) = &runtime.device_state { - return UiStepView::new(Self::SECTION_DEVICE, "Device", UiStepState::NeedsAttention) - .with_body(UiViewContent::Issue(UiIssue::new(reason.message()))) - .with_actions(self.connected_device_actions()); - } - let state = match &runtime.server_state { - ServerState::Failed { .. } => UiStepState::NeedsAttention, - ServerState::Connecting { .. } | ServerState::Disconnected => UiStepState::Active, - ServerState::Connected { .. } => UiStepState::Complete, - }; - let mut metrics = Vec::new(); - if let Some(sync) = device_sync { - if let Some(identity) = &sync.identity { - metrics.push(UiMetric::new("Name", &identity.name)); - } - metrics.push(UiMetric::new("Holds", content_line(&sync.content))); - } - if let ServerState::Connected { protocol } = &runtime.server_state { - metrics.push(UiMetric::new("Protocol", protocol)); - } - let body = if metrics.is_empty() { - match &runtime.server_state { - ServerState::Failed { - kind: ServerFailureKind::NoFirmware, - .. - } => UiViewContent::text("No LightPlayer firmware is running on this device."), - ServerState::Failed { issue, .. } => UiViewContent::Issue(issue.clone()), - _ => UiViewContent::text("Connecting to the device…"), - } - } else { - UiViewContent::Metrics(metrics) - }; - UiStepView::new(Self::SECTION_DEVICE, "Device", state) - .with_body(body) - .with_actions(self.connected_device_actions()) - } - - fn connected_device_actions(&self) -> Vec { - // M8′: pushing lives on the CARDS (the gallery's Push and the - // Project-tab picker); the pane keeps only the session verb. - vec![ - UiAction::from_op(self.node_id(), DeviceOp::DisconnectDevice) - .with_label("Disconnect") - .with_summary("Close the device session."), - ] - } - - /// Firmware ops, visually separate from deploy (D15). - fn firmware_section(&self) -> UiStepView { - UiStepView::new(Self::SECTION_FIRMWARE, "Firmware", UiStepState::Complete) - .with_body(UiViewContent::text( - "Firmware operations are separate from project deploys.", - )) - .with_actions(vec![ - UiAction::from_op( - self.node_id(), - DeviceOp::ProvisionFirmware { setup_name: None }, - ) - .with_label("Update firmware") - .with_summary("Flash the bundled LightPlayer firmware.") - .with_icon("zap"), - UiAction::from_op(self.node_id(), DeviceOp::ResetToBlank) - .with_label("Erase device") - .with_summary("Erase the device's flash storage entirely.") - .with_icon("remove"), - ]) - } } /// Test seams: stubbed connect-flow state for view/derivation tests that @@ -880,23 +676,6 @@ async fn open_sim_attachment( )) } -/// One line for what the device holds, from the connect-time pull. -fn content_line(content: &DeviceContent) -> String { - match content { - DeviceContent::Empty => "Nothing yet".to_string(), - DeviceContent::Known { slug, relation, .. } => match relation { - lpc_history::SyncRelation::AtHead => format!("{slug} — up to date"), - lpc_history::SyncRelation::Behind => format!("{slug} — behind your copy"), - lpc_history::SyncRelation::Diverged => format!("{slug} — edited elsewhere"), - }, - DeviceContent::Adopted { slug, .. } => format!("{slug} — pulled into your library"), - DeviceContent::PendingIdentity { .. } => { - "A project — name this device to keep it".to_string() - } - DeviceContent::Unreadable { .. } => "Contents unreadable".to_string(), - } -} - impl Controller for DeviceController { type Op = DeviceOp; diff --git a/lp-app/lpa-studio-core/src/app/device/device_event_adapter.rs b/lp-app/lpa-studio-core/src/app/device/device_event_adapter.rs index 5b777d5dd..6ce6ccc58 100644 --- a/lp-app/lpa-studio-core/src/app/device/device_event_adapter.rs +++ b/lp-app/lpa-studio-core/src/app/device/device_event_adapter.rs @@ -18,10 +18,7 @@ use std::rc::Rc; use lpa_link::{DeviceEvent, DeviceEventSink, DeviceLineOrigin}; use crate::app::server::device_log_line::parse_device_log_line; -use crate::{ - UiActivityView, UiLogDraft, UiLogLevel, UiLogOrigin, UiLogSource, UiProgress, UiStatus, - UxActivityTarget, UxUpdate, UxUpdateSink, -}; +use crate::{UiLogDraft, UiLogLevel, UiLogOrigin, UiLogSource, UxUpdate, UxUpdateSink}; /// Map one device event's log line into a console draft. fn log_line_draft(line: &str, origin: DeviceLineOrigin) -> UiLogDraft { @@ -55,16 +52,17 @@ pub(crate) fn console_event_sink(pending: Rc>>) -> Devic }) } -/// The management-operation sink: capture + mirror log lines, drive the -/// live activity view from progress events — and feed the CARD-OWNED op -/// flow (state-flow model §2): progress ticks keep it Running with the -/// live label/percent; a mid-manage device state change (reboot, -/// re-enumeration — the op's EXPECTED gap) flips it to AwaitingDevice -/// wearing `awaiting_detail` (I2). +/// The management-operation sink: capture + mirror log lines, and feed +/// the CARD-OWNED op flow (state-flow model §2): progress ticks keep it +/// Running with the live label/percent; a mid-manage device state change +/// (reboot, re-enumeration — the op's EXPECTED gap) flips it to +/// AwaitingDevice wearing `awaiting_detail` (I2). +/// +/// The card flow is the WHOLE narration since the retired step-stack +/// device pane went away — this sink used to drive a parallel +/// `UiActivityView` into that pane's Device section as well. pub(crate) fn management_event_sink( updates: UxUpdateSink, - target: UxActivityTarget, - activity: Rc>, captured_logs: Rc>>, card_op: Rc>, card_uid: Option, @@ -96,19 +94,7 @@ pub(crate) fn management_event_sink( updates.emit(UxUpdate::Log(draft)); } DeviceEvent::Progress { label, percent } => { - { - let mut activity = activity.borrow_mut(); - activity.progress = Some(match percent { - Some(percent) => UiProgress::determinate(&label, u32::from(percent)), - None => UiProgress::indeterminate(&label), - }); - } publish_card_op(crate::CardOp::new(format!("{label}…"), percent)); - updates.emit(UxUpdate::Activity { - target: target.clone(), - status: UiStatus::working("Managing"), - activity: activity.borrow().clone(), - }); } DeviceEvent::State { state } => { // The device leaving Ready mid-manage is the expected gap; @@ -157,12 +143,9 @@ mod tests { move |update| updates.borrow_mut().push(update) }); let captured = Rc::new(RefCell::new(Vec::new())); - let activity = Rc::new(RefCell::new(UiActivityView::new("Flashing firmware"))); let card_op = Rc::new(RefCell::new(crate::CardOp::new("Flashing firmware…", None))); let sink = management_event_sink( sink_updates, - UxActivityTarget::pane(crate::ControllerId::new("studio|device")), - Rc::clone(&activity), Rc::clone(&captured), Rc::clone(&card_op), Some("dev_abc".to_string()), @@ -180,28 +163,29 @@ mod tests { assert_eq!(captured.borrow().len(), 1); assert_eq!(captured.borrow()[0].source.origin, UiLogOrigin::Link); - assert!(activity.borrow().progress.is_some()); - // progress feeds the card-owned op flow (model §2) + // progress feeds the card-owned op flow (model §2) — the ONLY + // narration now that the step-stack device pane is gone assert_eq!( *card_op.borrow(), crate::CardOp::new("Writing…", Some(42)), "progress ticks keep the flow Running with the live label" ); let updates = updates.borrow(); - assert!(matches!(updates[0], UxUpdate::Log(_))); // The slot alone never reaches the screen mid-op: the op holds - // `&mut controller`, so the delta is what moves the card. + // `&mut controller`, so the delta is what moves the card. It is + // now the ONLY delta — the parallel `Activity` update retired with + // the step-stack device pane it patched. assert!( matches!( - &updates[1], - UxUpdate::CardOp { uid, op } - if uid.as_deref() == Some("dev_abc") - && *op == crate::CardOp::new("Writing…", Some(42)) + updates.as_slice(), + [ + UxUpdate::Log(_), + UxUpdate::CardOp { uid, op }, + ] if uid.as_deref() == Some("dev_abc") + && *op == crate::CardOp::new("Writing…", Some(42)) ), - "a progress tick publishes the card op, not just the slot: {:?}", - updates[1] + "a progress tick mirrors the log and publishes the card op: {updates:?}" ); - assert!(matches!(updates[2], UxUpdate::Activity { .. })); } #[test] @@ -214,8 +198,6 @@ mod tests { let card_op = Rc::new(RefCell::new(crate::CardOp::new("Wiping device…", None))); let sink = management_event_sink( updates, - UxActivityTarget::pane(crate::ControllerId::new("studio|device")), - Rc::new(RefCell::new(UiActivityView::new("Wiping device"))), Rc::new(RefCell::new(Vec::new())), Rc::clone(&card_op), None, diff --git a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs index 5ddfa9ad0..7485c8ae8 100644 --- a/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs +++ b/lp-app/lpa-studio-core/src/app/home/home_view_builder.rs @@ -337,14 +337,33 @@ pub(crate) fn sim_card(sim: &HomeSimEvidence) -> UiDeviceCard { } } -/// The live session's card, derived from evidence. `None` when there is +/// The live session's card for the GALLERY roster. `None` when there is /// no live evidence at all, and also when the evidence derives *Offline* -/// (a `Gone` link or stale sync) — the registry card is the -/// better-informed offline view (it knows the last sighting). +/// (a `Gone` link or stale sync) — there the registry card is the +/// better-informed offline view (it knows the last sighting), so the live +/// row yields to it rather than showing a duplicate. pub(crate) fn live_device_card(live: &HomeDeviceEvidence) -> Option { if live.sync.is_none() && live.link.is_none() && live.connect == ConnectEvidence::Idle { return None; } + let card = device_card_from_live_evidence(live); + if matches!(card.state, RosterCardState::Offline { .. }) { + return None; + } + Some(card) +} + +/// The same derivation WITHOUT the roster's Offline filter, and total +/// over every evidence combination (empty evidence derives Offline). +/// +/// The editor's LENS card (D43) is the shell's only device surface, so it +/// must survive an unplug: a `Gone` link has to leave a faded +/// "Seen …/Reconnect" card standing, not a hole. Filtering Offline here +/// is what let the retired step-stack pane back on screen — defect +/// `docs/defects/2026-07-28-retired-device-pane-still-reachable.md`. +/// There is no registry entry to fall back on at this scale, so the +/// caller fills the sighting (see `StudioController::lens_device_card`). +pub(crate) fn device_card_from_live_evidence(live: &HomeDeviceEvidence) -> UiDeviceCard { let state = derive_roster_card_state(&RosterEvidence { link: live.link.as_ref(), content: live.sync.as_ref().map(|sync| &sync.content), @@ -359,9 +378,6 @@ pub(crate) fn live_device_card(live: &HomeDeviceEvidence) -> Option Option Some(hello.fw.clone()), _ => None, }; - Some(UiDeviceCard { + UiDeviceCard { uid: identity.map(|identity| identity.uid.clone()), name: identity .map(|identity| identity.name.clone()) @@ -393,7 +409,7 @@ pub(crate) fn live_device_card(live: &HomeDeviceEvidence) -> Option bool { - self.pool.device_session().is_some() - } - /// The live hardware [`lpa_link::DeviceSession`], when one is attached /// (test stubs have none). fn hardware_session(&self) -> Option<&lpa_link::DeviceSession> { @@ -536,23 +530,6 @@ impl StudioController { .transport_label() } - /// The pool-derived runtime evidence the device pane renders from. - /// The server-protocol slice is the DEVICE session's when hardware is - /// attached (the pane is about hardware), the lens session's otherwise - /// (the "Running in the simulator" ambient line). - fn device_runtime_evidence(&self) -> DeviceRuntimeEvidence { - let server_state = match self.pool.device_session() { - Some(device) => device.server_state().clone(), - None => self.server_snapshot().state, - }; - DeviceRuntimeEvidence { - is_hardware: self.is_hardware_attached(), - is_sim: self.pool.sim_session().is_some(), - device_state: self.device_state(), - server_state, - } - } - /// The delay before the next passive tick: the MINIMUM over sessions /// (runtime-pool P2, per-session tick policy). /// @@ -706,11 +683,6 @@ impl StudioController { .with_device_sync(self.device_sync().cloned()) .with_settings(self.settings.ui_view()); } - let device_view = self.device.view( - &self.device_runtime_evidence(), - self.device_sync(), - self.usual_device_line(), - ); // gallery-always (D24): home covers every no-project state, so the // pane layout exists only for an open project let mut project_pane = self.project.view(self.has_lightplayer_state()); @@ -727,7 +699,7 @@ impl StudioController { UiViewContent::ProjectEditor(editor) => editor.dirty, _ => crate::DirtySummary::clean(), }; - let panes = vec![project_pane, self.bus_pane(), device_view]; + let panes = vec![project_pane, self.bus_pane()]; UiStudioView::new(panes, self.console_view()) .with_lens(self.lens_runtime()) .with_open_project( @@ -769,10 +741,20 @@ impl StudioController { /// The LENS session's card (D43): the grown control panel the editor /// docks as its right-side pane. The same construction the gallery /// roster uses — one derivation, both scales. + /// + /// TOTAL over a live lens session: whenever the pool has a lens the + /// editor gets a card. The shell has no other device surface, so a + /// `None` here is a hole in the right column — which is exactly how + /// the retired step-stack pane stayed reachable (defect + /// `docs/defects/2026-07-28-retired-device-pane-still-reachable.md`). + /// Unplugging therefore FADES the card ("Seen …/Reconnect") instead of + /// removing it; the lens and the project are left alone, so a flaky + /// cable never yanks anyone out of their editor. fn lens_device_card(&self) -> Option { let session = self.pool.lens_session()?; let evidence = self.home_pool_evidence(); let card = if session.is_sim() { + // the sim's evidence exists exactly while its session does evidence .sim .as_ref() @@ -781,11 +763,43 @@ impl StudioController { evidence .devices .first() - .and_then(crate::app::home::home_view_builder::live_device_card) + .map(crate::app::home::home_view_builder::device_card_from_live_evidence) + .map(|card| self.with_remembered_sighting(card)) }; card.map(|card| self.overlay_card_ui(card)) } + /// Live evidence carries no registry entry, so an Offline derivation + /// from it has no sighting ("Not seen yet" — wrong for a board that + /// answered a second ago). Borrow the remembered card's `last_seen_at` + /// so the unplugged lens reads "Seen …", the same words the gallery + /// uses for the same device. + fn with_remembered_sighting(&self, mut card: crate::UiDeviceCard) -> crate::UiDeviceCard { + let crate::RosterCardState::Offline { + last_seen_at: None, .. + } = &card.state + else { + return card; + }; + let Some(uid) = card.uid.clone() else { + return card; + }; + let seen = self.home_inputs.as_ref().and_then(|inputs| { + inputs + .devices + .iter() + .find(|remembered| remembered.uid.as_deref() == Some(uid.as_str())) + .and_then(|remembered| match remembered.state { + crate::RosterCardState::Offline { last_seen_at } => last_seen_at, + _ => None, + }) + }); + if seen.is_some() { + card.state = crate::RosterCardState::Offline { last_seen_at: seen }; + } + card + } + /// The lens's runtime binding for the view (SDI: the URL is the /// focused document — the web shell's D37 route reconciliation binds /// to this). A device session's `dev_` uid prefers the wire hello and @@ -1588,19 +1602,6 @@ impl StudioController { /// Where the open project usually lives: the registered device whose /// association points at it, for the pane's disconnected state (D23). - fn usual_device_line(&self) -> Option { - let slug = self.project.active_library_slug()?; - let inputs = self.home_inputs.as_ref()?; - inputs.devices.iter().find_map(|device| { - let offline = matches!(device.state, crate::RosterCardState::Offline { .. }); - let holds_it = device - .project - .as_ref() - .is_some_and(|chip| chip.name == slug); - (offline && holds_it).then(|| format!("Usually on {}.", device.name)) - }) - } - /// Resolve a slug-or-uid key to a concrete push target from a fresh /// library snapshot. async fn resolve_deploy_target(&mut self, key: &str) -> Result { @@ -2077,15 +2078,6 @@ impl StudioController { self.open_provider_link_only(provider_id, updates).await } DeviceOp::OpenProvider { provider_id } => { - if provider_id != LinkProviderKind::BrowserSerialEsp32 { - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Opening device", - "Opening", - format!("Opening {}", provider_id.label()), - ); - } let outcome = self.device.open_provider(provider_id).await; self.settle_connect_outcome(runtime_kind_for(provider_id), outcome, updates) .await @@ -2094,13 +2086,6 @@ impl StudioController { provider_id, endpoint_id, } => { - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Opening device session", - "Connecting", - "Opening device endpoint", - ); let outcome = self.device.connect_endpoint(provider_id, endpoint_id).await; self.settle_connect_outcome(runtime_kind_for(provider_id), outcome, updates) .await @@ -2309,11 +2294,11 @@ impl StudioController { ) -> UiResult { match outcome { Ok(DeviceOpenOutcome::Opened) => { - self.pool.remove_kind(kind); + self.clear_connect_slot(kind); Ok(UiNotices::new()) } Ok(DeviceOpenOutcome::Cancelled { message }) => { - self.pool.remove_kind(kind); + self.clear_connect_slot(kind); Ok(UiNotices::new().with_notice(UiNotice::info(message))) } Ok(DeviceOpenOutcome::Connected { payload, logs }) => { @@ -2325,17 +2310,39 @@ impl StudioController { // M6: the ladder's honest ending lives on the CARD // (PortHeld/Unresponsive flow states → card evidence); // nothing toasts, nothing errors. - self.pool.remove_kind(kind); + self.clear_connect_slot(kind); self.mark_dirty(); Ok(UiNotices::new()) } Err(error) => { - self.pool.remove_kind(kind); + self.clear_connect_slot(kind); Err(error) } } } + /// Clear a kind's pool slot after a connect ended without a session. + /// + /// `remove_kind` alone drops the lens ID with the session but leaves + /// the MIRROR dressed — `project.state` stays `Ready`, so the shell + /// keeps rendering panes for an editor with nothing behind it and no + /// lens card. Quiescing first is the pairing every other teardown + /// path uses (`teardown_crashed_sim`, `detach_lens`, + /// `open_provider_link_only`); it returns the user to the gallery, + /// which is the honest reading of "the connect ended with no + /// session". Defect + /// `docs/defects/2026-07-28-retired-device-pane-still-reachable.md`. + fn clear_connect_slot(&mut self, kind: crate::RuntimeKind) { + if self + .pool + .lens_session() + .is_some_and(|session| session.kind() == kind) + { + self.quiesce_lens(); + } + self.pool.remove_kind(kind); + } + /// Install a connected payload into the pool under the capacity /// policy. A refusal (an operation is still in flight on the session /// that would be replaced — DQ-A swap semantics) closes the fresh @@ -2748,13 +2755,6 @@ impl StudioController { } // No sim yet: start the simulator runtime. A device session stays // attached throughout — only the SIM slot is touched on failure. - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Starting simulator", - "Opening", - "Starting the simulator runtime", - ); let outcome = self .device .open_provider(LinkProviderKind::BrowserWorker) @@ -2797,7 +2797,7 @@ impl StudioController { .ok_or_else(|| UiError::MissingSession("no pending package to open".to_string()))?; emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Opening project", "Opening", "Pushing the project to the simulator", @@ -3199,13 +3199,6 @@ impl StudioController { if let Ok(session) = self.pool.device_session_mut() { session.disconnect_server(); } - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Opening device for flashing", - "Opening", - "Opening device without attaching LightPlayer", - ); let outcome = self.device.open_provider(provider_id).await; match outcome { Ok(DeviceOpenOutcome::Opened) => { @@ -3278,13 +3271,6 @@ impl StudioController { if let Some(session) = self.pool.session_mut(id) { session.disconnect_server(); } - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Reopening device", - "Connecting", - "Resetting device before server connect", - ); let result = { let session = self .pool @@ -3311,19 +3297,8 @@ impl StudioController { /// session being attached, never "the lens" — the lens may be on the /// OTHER session while a device reconnects under an open sim project. async fn attach_runtime(&mut self, id: crate::RuntimeId, updates: UxUpdateSink) -> UiResult { - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Connecting LightPlayer", - "Connecting", - "Opening server protocol", - ); - let server_updates = retarget_activity_updates( - updates.clone(), - device_section_target(DeviceController::SECTION_DEVICE), - ); let (is_sim, attach_result) = match self.pool.session_mut(id) { - Some(session) => (session.is_sim(), session.attach_server(server_updates)), + Some(session) => (session.is_sim(), session.attach_server(updates.clone())), None => ( false, Err(UiError::MissingSession( @@ -3350,7 +3325,7 @@ impl StudioController { } emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Checking running projects", "Checking", "Checking server response", @@ -3368,7 +3343,7 @@ impl StudioController { self.connect_running_project_if_available(updates.clone()) .await } else { - self.probe_server_readiness(id, updates.clone()).await + self.probe_server_readiness(id).await }; let auto_connect = match probe { Ok(auto_connect) => auto_connect, @@ -3468,7 +3443,7 @@ impl StudioController { async fn connect_running_project(&mut self, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Connecting project", "Connecting", "Checking loaded projects", @@ -3517,15 +3492,7 @@ impl StudioController { async fn probe_server_readiness( &mut self, id: crate::RuntimeId, - updates: UxUpdateSink, ) -> Result { - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Checking device", - "Checking", - "Checking server response", - ); let catalog = { let server = self .pool @@ -3546,7 +3513,7 @@ impl StudioController { ) -> Result { emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Checking running projects", "Checking", "Checking loaded projects", @@ -3579,7 +3546,7 @@ impl StudioController { async fn connect_loaded_project(&mut self, handle_id: u32, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Connecting project", "Connecting", "Loading project shape", @@ -3613,7 +3580,7 @@ impl StudioController { async fn load_demo_project(&mut self, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceController::SECTION_DEVICE), + UxActivityTarget::pane(ProjectController::NODE_ID), "Loading demo project", "Loading", "Uploading demo project", @@ -4165,11 +4132,6 @@ impl StudioController { session.set_operation(Some(spec.progress_label.to_string())); } let captured_logs = Rc::new(RefCell::new(Vec::new())); - let target = device_section_target(DeviceController::SECTION_DEVICE); - let activity = Rc::new(RefCell::new( - UiActivityView::new(spec.progress_label) - .with_progress(UiProgress::indeterminate(spec.progress_label)), - )); // MOUNT THE OVERLAY BEFORE THE WORK STARTS. The op slot went in // just above, and only a full view build carries it onto a card // (`overlay_card_ui`) — but everything below holds `&mut self` @@ -4179,15 +4141,8 @@ impl StudioController { // minute-long install look like a dead button. self.mark_dirty(); updates.emit(UxUpdate::View(self.view())); - updates.emit(UxUpdate::Activity { - target: target.clone(), - status: UiStatus::working("Managing"), - activity: activity.borrow().clone(), - }); let event_sink = management_event_sink( updates.clone(), - target, - Rc::clone(&activity), Rc::clone(&captured_logs), Rc::clone(&card_op), managed_uid_for_events.clone(), @@ -4235,13 +4190,6 @@ impl StudioController { self.record_logs(management_result_logs(&management.result)); let mut outcome = UiNotices::new().with_notice((spec.done_notice)(&management.result)); - emit_activity( - &updates, - device_section_target(DeviceController::SECTION_DEVICE), - "Reconnecting device", - "Connecting", - spec.reconnect_detail, - ); // The reattach half is the op's AwaitingDevice phase (I2) — the // overlay stays up, narrating the expected gap. It is a LONG // phase (the board reboots and re-enumerates), so the delta goes @@ -4253,6 +4201,7 @@ impl StudioController { op: card_op.borrow().clone(), }); self.mark_dirty(); + updates.emit(UxUpdate::View(self.view())); // The link was already rebuilt inside `manage`; what remains is the // server reattach + post-attach sequence on the managed session. match self.attach_runtime(device_id, updates).await { @@ -4555,23 +4504,6 @@ fn emit_activity( }); } -fn device_section_target(section_id: &'static str) -> UxActivityTarget { - UxActivityTarget::stack_section(DeviceController::NODE_ID, section_id) -} - -fn retarget_activity_updates(updates: UxUpdateSink, target: UxActivityTarget) -> UxUpdateSink { - UxUpdateSink::new(move |update| match update { - UxUpdate::Activity { - status, activity, .. - } => updates.emit(UxUpdate::Activity { - target: target.clone(), - status, - activity, - }), - update => updates.emit(update), - }) -} - fn view_actions(view: &UiStudioView) -> Vec { let mut actions = Vec::new(); for pane in &view.panes { @@ -4583,17 +4515,7 @@ fn view_actions(view: &UiStudioView) -> Vec { fn body_actions(body: &UiViewContent) -> Vec { match body { - UiViewContent::Stack(stack) => stack - .sections - .iter() - .flat_map(|section| { - let mut actions = section.actions.clone(); - actions.extend(body_actions(§ion.body)); - actions - }) - .collect(), - UiViewContent::Empty - | UiViewContent::Text(_) + UiViewContent::Text(_) | UiViewContent::Progress(_) | UiViewContent::Activity(_) | UiViewContent::Issue(_) @@ -4700,11 +4622,10 @@ mod tests { }; use super::*; - use crate::core::status::UiStatusKind; use crate::{ ConnectFlowState, ControllerId, ProjectController, ProjectEditorOp, ProjectEditorTarget, ProjectInventorySummary, ProjectNodeAddress, ProjectNodeTarget, ProjectState, - ProjectSyncPhase, ServerFailureKind, ServerState, StudioServerClient, UiIssue, + ProjectSyncPhase, ServerState, StudioServerClient, }; #[test] @@ -5053,8 +4974,12 @@ mod tests { ); } + /// The reflash escalation, pinned where it renders now: the LENS + /// CARD. The retired step-stack pane carried a status label and an + /// Issue-bodied Device section for this; the card's state derivation + /// and its Danger tab carry it since D43. #[test] - fn incompatible_device_surfaces_reflash_affordance_in_the_pane() { + fn incompatible_device_surfaces_the_reflash_state_on_the_card() { use lpa_link::{DeviceState, IncompatibleReason}; let mut studio = connected_studio(); @@ -5062,33 +4987,16 @@ mod tests { reason: IncompatibleReason::NoHello, }); - let view = studio.view(); - let device_pane = view - .panes - .iter() - .find(|pane| pane.node_id.as_str() == DeviceController::NODE_ID) - .expect("device pane"); - assert_eq!(device_pane.status.kind, UiStatusKind::Attention); - assert_eq!(device_pane.status.label, "Reflash needed"); + let card = studio + .view() + .lens_card + .expect("the editor's device surface is the lens card"); + assert_eq!(card.state, crate::RosterCardState::NeedsFirmwareUpdate); // The ONE affordance: reflash (explicit, never automatic). - let actions = view_actions(&view); - assert!(actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::ProvisionFirmware { setup_name: None }) - ))); - // The device section explains the incompatibility as an issue. - let UiViewContent::Stack(stack) = &device_pane.body else { - panic!("device pane renders a stack"); - }; - let device_section = stack - .sections - .iter() - .find(|section| section.id == DeviceController::SECTION_DEVICE) - .expect("device section"); - assert!(matches!( - &device_section.body, - UiViewContent::Issue(issue) if issue.message.contains("reflash the firmware") - )); + assert_eq!( + card.state.affordance(), + Some(crate::RosterAffordance::UpdateFirmware), + ); } #[test] @@ -5382,96 +5290,55 @@ mod tests { } #[test] - fn no_firmware_marks_the_device_pane_ready_to_flash() { + fn no_firmware_marks_the_lens_card_ready_to_set_up() { // an open project whose device link answers without firmware: - // the pane escalates to the flash affordance (the dialog's Blank - // state is the full wizard; the pane mirrors the status) + // the CARD escalates to the flash affordance let mut studio = connected_studio(); - studio.set_server_state_for_test(ServerState::Failed { - issue: UiIssue::new("No LightPlayer firmware detected."), - kind: ServerFailureKind::NoFirmware, - }); + studio.set_stub_device_for_test(lpa_link::DeviceState::BlankFlash); - let view = studio.view(); - let device_pane = view - .panes - .iter() - .find(|pane| pane.node_id.as_str() == DeviceController::NODE_ID) - .expect("device pane"); - assert_eq!(device_pane.status.kind, UiStatusKind::Attention); - assert_eq!(device_pane.status.label, "Ready to flash"); - let actions = view_actions(&view); - assert!(actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::ProvisionFirmware { setup_name: None }) - ))); + let card = studio.view().lens_card.expect("lens card"); + assert_eq!(card.state, crate::RosterCardState::ReadyToSetUp); + assert_eq!( + card.state.affordance(), + Some(crate::RosterAffordance::SetUp), + ); } #[test] - fn loaded_project_gets_project_pane() { + fn loaded_project_gets_project_and_bus_panes_plus_the_lens_card() { let studio = connected_studio(); let view = studio.view(); - let actions = view_actions(&view); - assert_eq!(view.panes.len(), 3); + // The editor is project + bus, with the device surface docked as + // the lens CARD. The retired step-stack device pane was the + // third pane; it is gone. + assert_eq!(view.panes.len(), 2); assert_eq!(view.panes[0].node_id.as_str(), ProjectController::NODE_ID); assert_eq!(view.panes[1].node_id.as_str(), "bus"); - assert_eq!(view.panes[2].node_id.as_str(), DeviceController::NODE_ID); - // D23: the pane is about hardware — one device section plus the - // visually separate firmware section; the wizard steps are gone - assert_eq!(device_section_ids(&view), vec!["device", "firmware"]); - assert!(!actions.iter().any(|action| matches!( - action.op_as::(), - Some(ProjectOp::ConnectRunningProject | ProjectOp::LoadDemoProject) - ))); - // the pane keeps only the session verb (M8′: pushing lives on - // the cards) - assert!( - actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::DisconnectDevice) - )) - ); - } - - #[test] - fn device_pane_offers_firmware_ops_separately() { - let studio = connected_studio(); - - let actions = view_actions(&studio.view()); - - // firmware ops live in their own section (D15), away from deploy - assert!(actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::ProvisionFirmware { setup_name: None }) - ))); + assert!(view.lens_card.is_some()); assert!( - actions + !view + .panes .iter() - .any(|action| matches!(action.op_as::(), Some(DeviceOp::ResetToBlank))) + .any(|pane| pane.node_id.as_str() == DeviceController::NODE_ID), + "no device pane" ); - // the wizard's connect plumbing is gone - assert!(!actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::ConnectLightPlayer | DeviceOp::OpenProvider { .. }) - ))); - } - - #[test] - fn loaded_project_keeps_management_recovery_actions_visible() { - let studio = connected_studio(); - let actions = view_actions(&studio.view()); - // recovery stays reachable from the editor's firmware section - assert!(actions.iter().any(|action| matches!( - action.op_as::(), - Some(DeviceOp::ProvisionFirmware { setup_name: None }) + // The wizard's project steps stayed gone through the deletion. + let actions = view_actions(&view); + assert!(!actions.iter().any(|action| matches!( + action.op_as::(), + Some(ProjectOp::ConnectRunningProject | ProjectOp::LoadDemoProject) ))); + // …and so did its connect plumbing. Firmware/erase/disconnect are + // no longer PANE actions: the card's Danger tab carries flash and + // erase (`DeployOp::EraseDevice` runs the same `reset_to_blank`). assert!( actions .iter() - .any(|action| matches!(action.op_as::(), Some(DeviceOp::ResetToBlank))) + .all(|action| action.op_as::().is_none()), + "the editor's panes carry no device ops at all now" ); } @@ -5799,19 +5666,17 @@ mod tests { )); } + /// The M6 ladder ending, pinned at the surface that carries it now: + /// the CARD. This used to assert an "Opening device session" activity + /// landing in the retired step-stack pane's Device section — pane + /// shape, not behaviour. The ladder contract is unchanged: soft + /// ending, no toast, no issue chip, honest Not-responding card. #[test] - fn failed_link_dispatch_emits_final_failed_view_after_activity() { + fn failed_link_dispatch_ends_soft_on_the_card() { let mut studio = StudioController::with_link_registry_for_test( || 0.0, registry_with_fake_connect_error("Failed to open serial port."), ); - let updates = Rc::new(RefCell::new(Vec::new())); - let sink = UxUpdateSink::new({ - let updates = Rc::clone(&updates); - move |update| { - updates.borrow_mut().push(update); - } - }); let action = UiAction::from_op( ControllerId::new(DeviceController::NODE_ID), DeviceOp::ConnectEndpoint { @@ -5820,27 +5685,12 @@ mod tests { }, ); - let result = drive(studio.dispatch_with_updates(action, sink)); + let result = drive(studio.dispatch_with_updates(action, UxUpdateSink::noop())); // M6: the ladder ends SOFT — no error, no toast, no issue chip. // The honest ending is the card's Not-responding state. let notices = result.expect("ladder endings are soft"); assert!(notices.notices.is_empty(), "no toast from the ladder"); - assert!(updates.borrow().iter().any(|update| { - matches!( - update, - UxUpdate::Activity { - target: UxActivityTarget::StackSection { - pane_node_id, - section_id, - }, - activity, - .. - } if pane_node_id.as_str() == DeviceController::NODE_ID - && section_id == DeviceController::SECTION_DEVICE - && activity.title == "Opening device session" - ) - })); assert!(matches!( studio.device_flow_state_for_test(), ConnectFlowState::Unresponsive { .. } @@ -5859,34 +5709,120 @@ mod tests { ); } + // ----------------------------------------------------------------- + // The lens-card invariant (D43) + // + // The editor layout has exactly ONE device surface. Nothing pinned + // that before, which is how a device-gone hole stayed invisible long + // enough for the shell to keep falling back to the retired + // step-stack pane — defect + // docs/defects/2026-07-28-retired-device-pane-still-reachable.md. + // ----------------------------------------------------------------- + + /// Panes render ⇒ the editor is open ⇒ a lens card exists. Held + /// across every state the lens can reach while the pane layout is up. #[test] - fn retarget_activity_updates_rewrites_activity_target() { - let updates = Rc::new(RefCell::new(Vec::new())); - let sink = UxUpdateSink::new({ - let updates = Rc::clone(&updates); - move |update| { - updates.borrow_mut().push(update); + fn panes_never_render_without_a_lens_card() { + use lpa_link::DeviceState; + + fn assert_invariant(studio: &StudioController, what: &str) { + let view = studio.view(); + if view.panes.is_empty() { + return; } - }); - let target = UxActivityTarget::stack_section( - DeviceController::NODE_ID, - DeviceController::SECTION_DEVICE, + assert!( + view.lens_card.is_some(), + "{what}: panes render with no lens card — the editor's right column has no \ + device surface" + ); + } + + let mut studio = connected_studio(); + assert_invariant(&studio, "live device lens"); + + for state in [ + DeviceState::Booting, + DeviceState::BlankFlash, + DeviceState::Bootloader, + DeviceState::ForeignFirmware, + DeviceState::Gone, + ] { + studio.set_stub_device_for_test(state.clone()); + assert_invariant(&studio, &format!("device lens, link {state:?}")); + } + + let mut studio = connected_studio(); + studio.set_stub_sim_for_test(); + assert_invariant(&studio, "sim lens"); + } + + /// Unplugging mid-project FADES the card; it never removes it, and it + /// never touches the editor. Yona 2026-07-28: a flaky cable must not + /// yank anyone out of their work, so the lens and the project stay. + #[test] + fn an_unplugged_lens_fades_to_an_offline_card_and_keeps_the_editor() { + use lpa_link::DeviceState; + + let mut studio = connected_studio(); + studio.set_stub_device_for_test(DeviceState::Gone); + + let view = studio.view(); + assert!(view.home.is_none(), "the editor stays open"); + assert!(!view.panes.is_empty(), "the pane layout stays up"); + let card = view.lens_card.expect("the unplugged lens keeps its card"); + assert!( + matches!(card.state, crate::RosterCardState::Offline { .. }), + "the card reads offline, not gone: {:?}", + card.state + ); + assert_eq!( + card.state.affordance(), + Some(crate::RosterAffordance::Reconnect), + "the way back is on the card" + ); + assert!( + studio.pool.lens_session().is_some(), + "the lens is untouched — the session is still the editor's" ); - let retargeted = retarget_activity_updates(sink, target.clone()); + } - retargeted.emit(UxUpdate::Activity { - target: UxActivityTarget::pane("studio|server"), - status: UiStatus::working("Connecting"), - activity: UiActivityView::new("Connecting ESP32 server"), - }); + /// A connect that ends WITHOUT a session must not leave the mirror + /// dressed: when the ladder's soft ending clears the slot the lens + /// sits on, the project quiesces and the gallery comes back. The old + /// `remove_kind`-only teardown left `project.state` Ready with no + /// lens, which is the second path into the retired pane. + #[test] + fn a_soft_connect_ending_on_the_lens_kind_returns_to_the_gallery() { + let mut studio = StudioController::with_link_registry_for_test( + || 0.0, + registry_with_fake_connect_error("Failed to open serial port."), + ); + studio.set_stub_device_for_test( + crate::app::runtime_pool::runtime_session::ready_state_for_test(), + ); + studio + .project + .mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + assert!(!studio.view().panes.is_empty(), "the editor is open"); - assert!(matches!( - updates.borrow().as_slice(), - [UxUpdate::Activity { - target: actual_target, - .. - }] if *actual_target == target - )); + let action = UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::ConnectEndpoint { + provider_id: LinkProviderKind::Fake, + endpoint_id: LinkEndpointId::new("fake-runtime"), + }, + ); + drive(studio.dispatch_with_updates(action, UxUpdateSink::noop())).expect("soft ending"); + + let view = studio.view(); + assert!( + studio.pool.lens_session().is_none(), + "the lens went with its session" + ); + assert!( + view.panes.is_empty() && view.home.is_some(), + "the mirror quiesced with the session — the gallery shows" + ); } fn connected_studio() -> StudioController { @@ -5973,22 +5909,6 @@ mod tests { view } - fn device_section_ids(view: &UiStudioView) -> Vec<&str> { - let device_pane = view - .panes - .iter() - .find(|pane| pane.node_id.as_str() == DeviceController::NODE_ID) - .expect("device pane should exist"); - let UiViewContent::Stack(stack) = &device_pane.body else { - panic!("device pane should render stack"); - }; - stack - .sections - .iter() - .map(|section| section.id.as_str()) - .collect() - } - fn registry_with_fake_connect_error(message: impl Into) -> LinkProviderRegistry { let mut registry = LinkProviderRegistry::new(); registry.insert( diff --git a/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs index 3db1656c4..8f9e6cd89 100644 --- a/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs @@ -3,8 +3,7 @@ use core::fmt::Write; use crate::app::runtime_pool::CONSOLE_TAIL_LEN; use crate::app::studio::ui_console_view::UiConsoleView; use crate::{ - ActionPriority, UiActivityView, UiPaneView, UiStatus, UiStepState, UiViewContent, - UxActivityTarget, + ActionPriority, UiActivityView, UiPaneView, UiStatus, UiViewContent, UxActivityTarget, }; /// Which runtime session the editor lens is bound to (D35/D37 — the SDI @@ -147,27 +146,7 @@ impl UiStudioView { return; }; pane.status = status; - - match target { - UxActivityTarget::Pane { .. } => { - pane.body = UiViewContent::Activity(activity); - } - UxActivityTarget::StackSection { section_id, .. } => { - if let UiViewContent::Stack(stack) = &mut pane.body { - if let Some(section) = stack - .sections - .iter_mut() - .find(|section| §ion.id == section_id) - { - section.state = UiStepState::Active; - section.body = UiViewContent::Activity(activity); - section.actions.clear(); - return; - } - } - pane.body = UiViewContent::Activity(activity); - } - } + pane.body = UiViewContent::Activity(activity); } /// Apply the CARD-OWNED op flow's live state in place, so a long diff --git a/lp-app/lpa-studio-core/src/app/studio/ux_update.rs b/lp-app/lpa-studio-core/src/app/studio/ux_update.rs index 13a77d64b..34b197a2c 100644 --- a/lp-app/lpa-studio-core/src/app/studio/ux_update.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ux_update.rs @@ -31,15 +31,15 @@ pub enum UxUpdate { }, } +/// Which pane a progressive activity update lands on. +/// +/// The `StackSection` variant retired with the step-stack device pane — +/// it was the only surface with addressable sub-sections, and its only +/// producer narrated connect/flash progress into it. That narration lives +/// on the device CARD now (state-flow model §2). #[derive(Clone, Debug, Eq, PartialEq)] pub enum UxActivityTarget { - Pane { - node_id: ControllerId, - }, - StackSection { - pane_node_id: ControllerId, - section_id: String, - }, + Pane { node_id: ControllerId }, } impl UxActivityTarget { @@ -49,20 +49,9 @@ impl UxActivityTarget { } } - pub fn stack_section( - pane_node_id: impl Into, - section_id: impl Into, - ) -> Self { - Self::StackSection { - pane_node_id: pane_node_id.into(), - section_id: section_id.into(), - } - } - pub fn pane_node_id(&self) -> &ControllerId { match self { Self::Pane { node_id } => node_id, - Self::StackSection { pane_node_id, .. } => pane_node_id, } } } diff --git a/lp-app/lpa-studio-core/src/core/mod.rs b/lp-app/lpa-studio-core/src/core/mod.rs index 386cb8715..9666fffdf 100644 --- a/lp-app/lpa-studio-core/src/core/mod.rs +++ b/lp-app/lpa-studio-core/src/core/mod.rs @@ -32,7 +32,4 @@ pub use view::activity_view::UiActivityStep; pub use view::activity_view::UiActivityStepState; pub use view::activity_view::UiActivityView; pub use view::pane_view::UiPaneView; -pub use view::steps_view::UiStepState; -pub use view::steps_view::UiStepView; -pub use view::steps_view::UiStepsView; pub use view::view_content::UiViewContent; diff --git a/lp-app/lpa-studio-core/src/core/view/activity_view.rs b/lp-app/lpa-studio-core/src/core/view/activity_view.rs index 60c3642eb..4d33a6765 100644 --- a/lp-app/lpa-studio-core/src/core/view/activity_view.rs +++ b/lp-app/lpa-studio-core/src/core/view/activity_view.rs @@ -60,19 +60,6 @@ impl UiActivityView { step.state = state; } } - - /// Append a terminal output line. - pub fn push_terminal_line(&mut self, line: impl Into) { - self.terminal.push(UiTerminalLine::new(line)); - } - - /// Keep only the most recent terminal output lines. - pub fn retain_recent_terminal_lines(&mut self, max_lines: usize) { - if self.terminal.len() > max_lines { - let remove_count = self.terminal.len() - max_lines; - self.terminal.drain(0..remove_count); - } - } } /// One step inside a `UiActivityView`. diff --git a/lp-app/lpa-studio-core/src/core/view/mod.rs b/lp-app/lpa-studio-core/src/core/view/mod.rs index 97a217a01..58a258fa2 100644 --- a/lp-app/lpa-studio-core/src/core/view/mod.rs +++ b/lp-app/lpa-studio-core/src/core/view/mod.rs @@ -8,5 +8,4 @@ pub mod activity_view; pub mod pane_view; pub mod progress_state; -pub mod steps_view; pub mod view_content; diff --git a/lp-app/lpa-studio-core/src/core/view/steps_view.rs b/lp-app/lpa-studio-core/src/core/view/steps_view.rs deleted file mode 100644 index 4eb290706..000000000 --- a/lp-app/lpa-studio-core/src/core/view/steps_view.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crate::{UiAction, UiTerminalLine, UiViewContent}; - -/// Render data for a multi-section workflow body. -/// -/// Use steps when a pane or another view needs to show ordered workflow -/// sections, each with its own body and actions. The pane that contains the -/// workflow still owns pane title/status/actions. -#[derive(Clone, Debug, PartialEq)] -pub struct UiStepsView { - /// Ordered workflow sections. - pub sections: Vec, - /// Optional terminal-like output associated with the workflow. - pub terminal: Vec, -} - -impl UiStepsView { - /// Create a workflow from ordered sections. - pub fn new(sections: Vec) -> Self { - Self { - sections, - terminal: Vec::new(), - } - } - - /// Attach terminal-like output to the workflow. - pub fn with_terminal(mut self, terminal: Vec) -> Self { - self.terminal = terminal; - self - } -} - -/// One section inside a `UiStepsView`. -#[derive(Clone, Debug, PartialEq)] -pub struct UiStepView { - /// Stable section id. - pub id: String, - /// Visible section title. - pub title: String, - /// Current section state. - pub state: UiStepState, - /// Section body content. - pub body: UiViewContent, - /// Section-level actions. - pub actions: Vec, -} - -impl UiStepView { - /// Create a step section with empty body and no actions. - pub fn new(id: impl Into, title: impl Into, state: UiStepState) -> Self { - Self { - id: id.into(), - title: title.into(), - state, - body: UiViewContent::Empty, - actions: Vec::new(), - } - } - - /// Replace the section body. - pub fn with_body(mut self, body: UiViewContent) -> Self { - self.body = body; - self - } - - /// Replace the section actions. - pub fn with_actions(mut self, actions: Vec) -> Self { - self.actions = actions; - self - } -} - -/// State for a workflow step section. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UiStepState { - /// The section is not ready to run yet. - Pending, - /// The section is the current active work. - Active, - /// The section completed successfully. - Complete, - /// The section needs user attention. - NeedsAttention, -} - -impl UiStepState { - /// Return a plain-text marker for non-visual renderers and logs. - pub fn text_marker(self) -> &'static str { - match self { - Self::Pending => "[ ]", - Self::Active => "[*]", - Self::Complete => "[x]", - Self::NeedsAttention => "[!]", - } - } -} diff --git a/lp-app/lpa-studio-core/src/core/view/view_content.rs b/lp-app/lpa-studio-core/src/core/view/view_content.rs index 7e0993960..a992b31a9 100644 --- a/lp-app/lpa-studio-core/src/core/view/view_content.rs +++ b/lp-app/lpa-studio-core/src/core/view/view_content.rs @@ -1,6 +1,4 @@ -use crate::{ - ProjectEditorView, UiActivityView, UiBusView, UiIssue, UiMetric, UiProgress, UiStepsView, -}; +use crate::{ProjectEditorView, UiActivityView, UiBusView, UiIssue, UiMetric, UiProgress}; /// Generic body content for panes and workflow steps. /// @@ -9,8 +7,6 @@ use crate::{ /// DTOs and use these variants for reusable body shapes. #[derive(Clone, Debug, PartialEq)] pub enum UiViewContent { - /// No visible body content. - Empty, /// A single paragraph of text. Text(String), /// Progress for ongoing work. @@ -21,8 +17,6 @@ pub enum UiViewContent { Issue(UiIssue), /// A compact label/value metric grid. Metrics(Vec), - /// A composed workflow with ordered steps. - Stack(Box), /// Project editor surface. ProjectEditor(Box), /// Bus channel surface. @@ -38,7 +32,6 @@ impl UiViewContent { /// Render the body as plain text lines for fallback renderers and tests. pub fn render_text_lines(&self) -> Vec { match self { - Self::Empty => Vec::new(), Self::Text(text) => vec![text.clone()], Self::Progress(progress) => match &progress.detail { Some(detail) => vec![progress.label.clone(), detail.clone()], @@ -72,41 +65,6 @@ impl UiViewContent { .iter() .map(|metric| format!("{}: {}", metric.label, metric.value)) .collect(), - Self::Stack(stack) => { - let mut lines = Vec::new(); - for section in &stack.sections { - lines.push(format!("{} {}", section.state.text_marker(), section.title)); - lines.extend( - section - .body - .render_text_lines() - .into_iter() - .map(|line| format!(" {line}")), - ); - if !section.actions.is_empty() { - lines.push(" actions:".to_string()); - lines.extend( - section - .actions - .iter() - .map(|action| format!(" - {}", action.meta().label)), - ); - } - } - if !stack.terminal.is_empty() { - lines.push("terminal:".to_string()); - lines.extend( - stack - .terminal - .iter() - .rev() - .take(12) - .rev() - .map(|line| format!(" {}", line.text)), - ); - } - lines - } Self::Bus(bus) => { let mut lines = vec![format!("Channels: {}", bus.channels.len())]; for channel in &bus.channels { diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs index 72617f836..fe13606f6 100644 --- a/lp-app/lpa-studio-core/src/lib.rs +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -116,14 +116,12 @@ pub use app::studio::{ pub use core::notice::UiNotices; pub use core::view::activity_view::UiActivityStep; pub use core::view::activity_view::UiActivityStepState; -pub use core::view::steps_view::UiStepState; -pub use core::view::steps_view::UiStepView; pub use core::{ ActionClass, ActionConfirmation, ActionEnablement, ActionMeta, ActionPriority, Controller, ControllerContext, ControllerId, ControllerOp, PASSIVE_REFRESH_DEADLINE, PROJECT_ACTION_DEADLINE, PROJECT_EDITOR_ACTION_DEADLINE, PROJECT_LOAD_DEADLINE, UiAction, UiActions, UiActivityView, UiMetric, UiPaneAction, UiPaneView, UiProgress, UiStatus, - UiStepsView, UiStudioView, UiTerminalLine, UiViewContent, UxNodePath, + UiStudioView, UiTerminalLine, UiViewContent, UxNodePath, }; pub const STUDIO_DEMO_PROJECT_ID: &str = "examples/fyeah-sign"; diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css index 5c816465d..a6beb14e7 100644 --- a/lp-app/lpa-studio-web/assets/tailwind.css +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -171,9 +171,6 @@ .tw\:order-3 { order: 3; } - .tw\:col-span-2 { - grid-column: span 2 / span 2; - } .tw\:m-0 { margin: calc(var(--tw-spacing) * 0); } @@ -719,9 +716,6 @@ .tw\:grid-cols-\[28px_minmax\(0\,1fr\)\] { grid-template-columns: 28px minmax(0,1fr); } - .tw\:grid-cols-\[32px_minmax\(0\,1fr\)\] { - grid-template-columns: 32px minmax(0,1fr); - } .tw\:grid-cols-\[34px_minmax\(0\,1fr\)_auto\] { grid-template-columns: 34px minmax(0,1fr) auto; } @@ -893,9 +887,6 @@ .tw\:gap-y-0\.5 { row-gap: calc(var(--tw-spacing) * 0.5); } - .tw\:gap-y-2 { - row-gap: calc(var(--tw-spacing) * 2); - } .tw\:divide-y { :where(& > :not(:last-child)) { --tw-divide-y-reverse: 0; diff --git a/lp-app/lpa-studio-web/build.rs b/lp-app/lpa-studio-web/build.rs index bdfc94dbe..d4433b30c 100644 --- a/lp-app/lpa-studio-web/build.rs +++ b/lp-app/lpa-studio-web/build.rs @@ -31,6 +31,7 @@ fn main() { .collect::>(); validate_story_ids(&story_modules); + validate_default_story_id(&src_dir, &story_modules); fs::write(generated_path, generate_registry(&story_modules)) .expect("write generated story registry"); } @@ -319,6 +320,46 @@ fn validate_story_ids(story_modules: &[StoryModule]) { } } +/// Fail the build when `DEFAULT_STORY_ID` names a story that no longer +/// exists. A stale default renders an EMPTY storybook page — which the +/// capture pipeline reports as "No story links were discovered", far from +/// the actual cause. Caught the hard way when the step-stack device pane +/// was deleted and took `studio/layout/studio-shell/simulator-idle` with +/// it. +fn validate_default_story_id(src_dir: &Path, story_modules: &[StoryModule]) { + let registry_path = src_dir.join("stories/story_registry.rs"); + let source = fs::read_to_string(®istry_path) + .unwrap_or_else(|error| panic!("read {}: {error}", registry_path.display())); + let Some(default_id) = source + .split("pub const DEFAULT_STORY_ID: &str = \"") + .nth(1) + .and_then(|rest| rest.split('"').next()) + else { + panic!( + "could not read DEFAULT_STORY_ID from {}", + registry_path.display() + ); + }; + // Either a story id or a component-overview id (`/overview`, + // synthesized by the story book rather than declared by a `#[story]`). + let exists = story_modules + .iter() + .flat_map(|module| &module.stories) + .any(|story| match story.id.rsplit_once('/') { + Some((component, _)) => { + story.id == default_id || default_id == format!("{component}/overview") + } + None => story.id == default_id, + }); + assert!( + exists, + "DEFAULT_STORY_ID `{default_id}` in {} names no registered story — the storybook \n\ + would render an empty page and capture would find zero stories. Point it at a \n\ + story that exists.", + registry_path.display() + ); +} + fn generate_registry(story_modules: &[StoryModule]) -> String { let mut generated = String::new(); generated.push_str("// @generated by lpa-studio-web/build.rs\n\n"); diff --git a/lp-app/lpa-studio-web/src/README.md b/lp-app/lpa-studio-web/src/README.md index 900d474d8..0b4451e3a 100644 --- a/lp-app/lpa-studio-web/src/README.md +++ b/lp-app/lpa-studio-web/src/README.md @@ -24,12 +24,12 @@ Data-driven controls. These render generic `Ui*` data structs from Rules: - May depend on `lpa-studio-core` generic UI types such as `UiAction`, - `UiStatus`, `UiProgress`, `UiIssue`, `UiActivityView`, `UiPaneView`, - and `UiStepsView`. + `UiStatus`, `UiProgress`, `UiIssue`, `UiActivityView`, and + `UiPaneView`. - May compose `base`. - Should not own Studio-specific workflows when `app` can compose them. -- Use the `view` submodule for composed render surfaces such as pane bodies, - activities, and step workflows. Smaller controls such as status chips, +- Use the `view` submodule for composed render surfaces such as pane bodies + and activities. Smaller controls such as status chips, progress bars, log lists, and issue views live directly under `core`. Examples: action strips, status chips, progress bars, metric grids, issue @@ -82,7 +82,7 @@ Examples: ```text src/base/popover_stories.rs -> base/popover/ src/core/action/action_strip_stories.rs -> core/action/action-strip/ -src/app/device/device_pane_stories.rs -> studio/device/device-pane/ +src/app/home/home_gallery_stories.rs -> studio/home/home-gallery/ src/exploration/node_ui_stories.rs -> exploration/node-ui/ ``` diff --git a/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs b/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs deleted file mode 100644 index 530f6520f..000000000 --- a/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Stories for the D23 device pane (M5): hardware only, never the sim. -//! -//! The pre-M5 wizard stories (provider/endpoint steps, provisioning -//! sequences over the step stack) died with the wizard; the deploy -//! dialog's stories cover those flows now. - -use dioxus::prelude::*; -use lpa_studio_core::{ - ControllerId, DEPLOY_NODE_ID, DeployOp, DeviceController, DeviceOp, UiAction, UiMetric, - UiPaneView, UiStatus, UiStepState, UiStepView, UiStepsView, UiViewContent, -}; -use lpa_studio_web_story_macros::story; - -use crate::core::PaneView; - -fn pane(status: UiStatus, sections: Vec) -> UiPaneView { - UiPaneView::new( - DeviceController::NODE_ID, - "Device", - status, - UiViewContent::Stack(Box::new(UiStepsView::new(sections))), - Vec::new(), - ) -} - -fn story_pane(view: UiPaneView) -> Element { - rsx! { - section { class: "tw:max-w-[420px] tw:p-4", - PaneView { - view, - primary: false, - running: false, - on_action: move |_| {}, - } - } - } -} - -fn firmware_section() -> UiStepView { - UiStepView::new( - DeviceController::SECTION_FIRMWARE, - "Firmware", - UiStepState::Complete, - ) - .with_body(UiViewContent::text( - "Firmware operations are separate from project deploys.", - )) - .with_actions(vec![ - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::ProvisionFirmware { setup_name: None }, - ) - .with_label("Update firmware"), - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::ResetToBlank, - ) - .with_label("Erase device"), - ]) -} - -#[story] -pub(crate) fn disconnected_with_association() -> Element { - story_pane(pane( - UiStatus::neutral("No device"), - vec![ - UiStepView::new( - DeviceController::SECTION_DEVICE, - "Device", - UiStepState::Pending, - ) - .with_body(UiViewContent::text( - "Usually on Luna's porch sign.\nRunning in the simulator.", - )) - .with_actions(vec![ - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::OpenProvider { - provider_id: lpa_link::LinkProviderKind::BrowserSerialEsp32, - }, - ) - .with_label("Connect device…"), - ]), - ], - )) -} - -#[story] -pub(crate) fn connected_at_head() -> Element { - story_pane(pane( - UiStatus::good("Luna's porch sign"), - vec![ - UiStepView::new( - DeviceController::SECTION_DEVICE, - "Device", - UiStepState::Complete, - ) - .with_body(UiViewContent::Metrics(vec![ - UiMetric::new("Name", "Luna's porch sign"), - UiMetric::new("Holds", "2026-07-02-0930-porch-sign — up to date"), - UiMetric::new("Protocol", "fw-serial-v1"), - ])) - .with_actions(vec![ - UiAction::from_op( - ControllerId::new(DEPLOY_NODE_ID), - DeployOp::PushProject { - key: "prj_fixture".to_string(), - }, - ) - .with_label("Push to device"), - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::DisconnectDevice, - ) - .with_label("Disconnect"), - ]), - firmware_section(), - ], - )) -} - -#[story] -pub(crate) fn ready_to_flash() -> Element { - story_pane(pane( - UiStatus::attention("Ready to flash"), - vec![ - UiStepView::new( - DeviceController::SECTION_DEVICE, - "Device", - UiStepState::Active, - ) - .with_body(UiViewContent::text( - "No LightPlayer firmware is running on this device.", - )) - .with_actions(vec![UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::ProvisionFirmware { setup_name: None }, - )]), - firmware_section(), - ], - )) -} - -/// Incompatible firmware (M4 hello gate): the pane outranks the server -/// state with "Reflash needed" and explains the incompatibility as an -/// issue; reflashing is the ONE affordance (firmware section). -#[story] -pub(crate) fn reflash_needed() -> Element { - story_pane(pane( - UiStatus::attention("Reflash needed"), - vec![ - UiStepView::new( - DeviceController::SECTION_DEVICE, - "Device", - UiStepState::NeedsAttention, - ) - .with_body(UiViewContent::Issue(lpa_studio_core::UiIssue::new( - "device firmware started its server loop but predates the wire hello; \ - reflash the firmware to a compatible build", - ))) - .with_actions(connected_actions()), - firmware_section(), - ], - )) -} - -/// Unresponsive device (readiness deadline expired without a diagnosis): -/// the server attach failed, so the pane needs attention and carries the -/// boot diagnosis; recovery stays reachable through the firmware section. -#[story] -pub(crate) fn unresponsive() -> Element { - story_pane(pane( - UiStatus::error("Needs attention"), - vec![ - UiStepView::new( - DeviceController::SECTION_DEVICE, - "Device", - UiStepState::NeedsAttention, - ) - .with_body(UiViewContent::Issue(lpa_studio_core::UiIssue::new( - "timed out waiting for device readiness; no serial output was received \ - from the device", - ))) - .with_actions(connected_actions()), - firmware_section(), - ], - )) -} - -fn connected_actions() -> Vec { - vec![ - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::ProvisionFirmware { setup_name: None }, - ) - .with_label("Push to device…"), - UiAction::from_op( - ControllerId::new(DeviceController::NODE_ID), - DeviceOp::DisconnectDevice, - ) - .with_label("Disconnect"), - ] -} diff --git a/lp-app/lpa-studio-web/src/app/device/mod.rs b/lp-app/lpa-studio-web/src/app/device/mod.rs deleted file mode 100644 index 195363afa..000000000 --- a/lp-app/lpa-studio-web/src/app/device/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(feature = "stories")] -pub(crate) mod device_pane_stories; diff --git a/lp-app/lpa-studio-web/src/app/layout/mod.rs b/lp-app/lpa-studio-web/src/app/layout/mod.rs index 6af260d35..21be86154 100644 --- a/lp-app/lpa-studio-web/src/app/layout/mod.rs +++ b/lp-app/lpa-studio-web/src/app/layout/mod.rs @@ -10,8 +10,6 @@ pub mod studio_settings_popover; #[cfg(feature = "stories")] pub(crate) mod studio_settings_popover_stories; pub mod studio_shell; -#[cfg(feature = "stories")] -pub(crate) mod studio_shell_stories; pub mod version_badge; #[cfg(feature = "stories")] pub(crate) mod version_badge_stories; diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs b/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs index 345c8bd50..60e16ebf5 100644 --- a/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs +++ b/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs @@ -1,7 +1,5 @@ use dioxus::prelude::*; -use lpa_studio_core::{ - DeviceController, SettingsCommand, UiAction, UiPaneView, UiStudioView, UiViewContent, -}; +use lpa_studio_core::{SettingsCommand, UiAction, UiPaneView, UiStudioView, UiViewContent}; use crate::app::layout::{StudioSettingsPopover, VersionBadge}; use crate::app::{HomeGallery, ProjectNodeWorkspace, ProjectOpeningFrame}; @@ -34,7 +32,7 @@ pub fn StudioShell( lens: _, open_project_uid: _, open_project_slug: _, - // rendered by the device pane (M5) + // the lens card renders the sync facts (D43) device_sync: _, lens_card, settings, @@ -71,7 +69,7 @@ pub fn StudioShell( }; } - let PaneGroups { main, device } = group_panes(panes); + let main = panes; let project_editor = project_editor_view(&main); let layout_class = if project_editor.is_some() { "tw:grid tw:grid-cols-[minmax(220px,280px)_minmax(0,1fr)_minmax(300px,360px)] tw:gap-3.5 tw:max-[960px]:grid-cols-1" @@ -80,8 +78,6 @@ pub fn StudioShell( } else { "tw:grid tw:grid-cols-[minmax(0,1fr)_minmax(300px,380px)] tw:gap-3.5 tw:max-[880px]:grid-cols-1" }; - let device_is_primary = main.is_empty(); - rsx! { main { class: "tw:mx-auto tw:min-h-screen tw:w-[min(1520px,100%)] tw:px-7 tw:pb-16 tw:pt-7 tw:max-[880px]:px-[18px] tw:max-[880px]:pb-[72px] tw:max-[880px]:pt-[18px]", header { class: "tw:mb-[18px] tw:flex tw:items-center tw:justify-start tw:gap-5", @@ -124,9 +120,12 @@ pub fn StudioShell( if let Some(card) = lens_card { // D43: the LENS session's card, grown — the same // control panel the gallery shows, docked as the - // editor's right-side pane. The old device pane - // surface renders only while no lens session - // exists (defensive: the editor implies a lens). + // editor's ONLY device surface. It is present + // whenever panes render (pinned in core by + // `panes_never_render_without_a_lens_card`), and + // an unplugged device fades it rather than + // removing it. The retired step-stack device pane + // that used to backstop this branch is gone. crate::app::home::device_card::DeviceCard { sim: card.sim, pane: true, @@ -134,14 +133,6 @@ pub fn StudioShell( now_secs, on_action, } - } else if let Some(device) = device { - PaneView { - key: "{device.node_id}", - view: device, - primary: device_is_primary, - running, - on_action, - } } } } @@ -176,24 +167,6 @@ fn ShellLogo(on_action: EventHandler) -> Element { } } -struct PaneGroups { - main: Vec, - device: Option, -} - -fn group_panes(panes: Vec) -> PaneGroups { - let mut main = Vec::new(); - let mut device = None; - for pane in panes { - if pane.node_id.as_str() == DeviceController::NODE_ID { - device = Some(pane); - } else { - main.push(pane); - } - } - PaneGroups { main, device } -} - fn project_editor_view(panes: &[UiPaneView]) -> Option { panes.iter().find_map(|pane| match &pane.body { UiViewContent::ProjectEditor(editor) => Some((**editor).clone()), diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs b/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs deleted file mode 100644 index dc90e2bfb..000000000 --- a/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Stories for whole-Studio shell states. - -use dioxus::prelude::*; -use lpa_studio_core::UiLogLevel; -use lpa_studio_web_story_macros::story; - -use crate::app::story_fixtures::{ - endpoint_view, error_view, idle_view, shell_story, simulator_ready_view, starting_view, - studio_log, -}; - -#[story] -pub(crate) fn simulator_idle() -> Element { - shell_story(idle_view(), false, Vec::new()) -} - -#[story] -pub(crate) fn simulator_endpoint() -> Element { - shell_story(endpoint_view(), false, Vec::new()) -} - -#[story] -pub(crate) fn simulator_starting() -> Element { - shell_story(starting_view(), true, Vec::new()) -} - -#[story] -pub(crate) fn simulator_ready() -> Element { - shell_story( - simulator_ready_view(), - false, - vec![ - studio_log(UiLogLevel::Info, "Simulator is running"), - studio_log(UiLogLevel::Info, "Demo project loaded"), - ], - ) -} - -#[story] -pub(crate) fn action_error() -> Element { - shell_story( - error_view(), - false, - vec![studio_log( - UiLogLevel::Error, - "browser worker boot timed out", - )], - ) -} diff --git a/lp-app/lpa-studio-web/src/app/mod.rs b/lp-app/lpa-studio-web/src/app/mod.rs index 6313e25d4..af63dc8c4 100644 --- a/lp-app/lpa-studio-web/src/app/mod.rs +++ b/lp-app/lpa-studio-web/src/app/mod.rs @@ -8,7 +8,6 @@ pub(crate) mod affordance; #[cfg(feature = "stories")] pub(crate) mod board_diagram_stories; pub mod bus; -pub mod device; pub mod home; pub mod layout; #[cfg(feature = "stories")] diff --git a/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs index edfb90237..75ba26dfc 100644 --- a/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs +++ b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs @@ -9,9 +9,8 @@ use lpa_studio_web_story_macros::story; use crate::app::project::ProjectPane; use crate::app::story_fixtures::{ - device_project_empty_view, device_project_selection_view, project_editor_fixture, - project_ready_state, project_ready_view, project_sync_failed_view, project_syncing_view, - project_view, shell_story, studio_log, + project_editor_fixture, project_ready_state, project_ready_view, project_sync_failed_view, + project_syncing_view, project_view, shell_story, studio_log, }; use crate::core::PaneView; @@ -28,32 +27,6 @@ pub(crate) fn project_pane() -> Element { } } -#[story] -pub(crate) fn device_project_empty() -> Element { - let view = device_project_empty_view(); - rsx! { - PaneView { - view, - primary: true, - running: false, - on_action: move |_| {}, - } - } -} - -#[story] -pub(crate) fn device_project_selection() -> Element { - let view = device_project_selection_view(); - rsx! { - PaneView { - view, - primary: true, - running: false, - on_action: move |_| {}, - } - } -} - #[story( description = "The project pane with a dirty tree: uncommitted header with Save/Revert icons and the pencil trigger; the focused shader row's selection highlight mixes in the unsaved yellow, the palette row wears the live blue header tint, the root row the aggregate unsaved tint — rows carry only the small affordance glyph (no status words or count badges; the breakdown is in the tooltip, counts in the popup)." )] diff --git a/lp-app/lpa-studio-web/src/app/story_fixtures.rs b/lp-app/lpa-studio-web/src/app/story_fixtures.rs index 54e7184ce..a282ee232 100644 --- a/lp-app/lpa-studio-web/src/app/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -8,16 +8,14 @@ use crate::app::{PaneFrame, StudioShell}; use crate::base::{FieldRow, TabItem, Tabs}; use crate::core::MetricGrid; use dioxus::prelude::*; -use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; use lpa_studio_core::{ - ControllerId, DeviceController, DeviceOp, LinkEndpointId, LinkProviderKind, ProjectController, - ProjectEditorOp, ProjectEditorView, ProjectInventorySummary, ProjectNodeStatusTone, - ProjectNodeStatusView, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, - ProjectRuntimeSummary, ProjectState, ProjectSyncPhase, ProjectSyncSummary, UiAction, - UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, UiConsoleView, UiIssue, UiLogEntry, - UiLogLevel, UiLogOrigin, UiLogSource, UiMetric, UiNodeChild, UiNodeHeader, UiNodeSection, - UiNodeTab, UiNodeView, UiPaneView, UiProducedProduct, UiProducedValue, UiProgress, UiSlotAsset, - UiSlotSourceState, UiSlotValue, UiStatus, UiStepsView, UiStudioView, UiTerminalLine, + ControllerId, ProjectController, ProjectEditorOp, ProjectEditorView, ProjectInventorySummary, + ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTreeItem, ProjectNodeTreeView, + ProjectRuntimeSummary, ProjectState, ProjectSyncPhase, ProjectSyncSummary, RosterCardState, + UiAction, UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, UiConsoleView, UiDeviceCard, + UiDeviceProjectChip, UiIssue, UiLogEntry, UiLogLevel, UiLogOrigin, UiLogSource, UiMetric, + UiNodeChild, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiPaneView, UiProducedProduct, + UiProducedValue, UiSlotAsset, UiSlotSourceState, UiSlotValue, UiStatus, UiStudioView, UiViewContent, }; @@ -32,7 +30,33 @@ pub(crate) const STORY_LOG_TIMESTAMP: f64 = 1_720_000_000.0; fn story_view(panes: Vec, logs: Vec) -> UiStudioView { let mut console = UiConsoleView::empty(); console.entries = logs; - UiStudioView::new(panes, console) + UiStudioView::new(panes, console).with_lens_card(Some(simulator_lens_card())) +} + +/// The editor's device surface (D43): a running simulator as the LENS +/// card. Every pane-layout story carries one — the shell renders no other +/// device surface since the step-stack pane retired, and core pins +/// "panes non-empty ⇒ lens card". +pub(crate) fn simulator_lens_card() -> UiDeviceCard { + UiDeviceCard { + uid: None, + name: "Simulator".to_string(), + transport: String::new(), + state: RosterCardState::RunningUpToDate, + project: Some(UiDeviceProjectChip { + uid: "prj_9sLm2Xc44dQnUv7BgWkEyt".to_string(), + name: "demo-project".to_string(), + }), + fw: None, + sim: true, + console_tail: vec![UiLogEntry::new( + STORY_LOG_TIMESTAMP, + UiLogLevel::Info, + UiLogSource::with_detail(UiLogOrigin::Device, "fw-browser"), + "engine: project loaded", + )], + ui: Default::default(), + } } pub(crate) fn shell_story( @@ -103,55 +127,9 @@ pub(crate) fn studio_log(level: UiLogLevel, message: impl Into) -> UiLog UiLogEntry::new(STORY_LOG_TIMESTAMP, level, UiLogOrigin::Studio, message) } -pub(crate) fn idle_view() -> UiStudioView { - story_view(vec![idle_device_view()], Vec::new()) -} - -pub(crate) fn endpoint_view() -> UiStudioView { - story_view(vec![endpoint_device_view()], Vec::new()) -} - -pub(crate) fn starting_view() -> UiStudioView { - story_view( - vec![starting_device_view()], - vec![UiLogEntry::new( - STORY_LOG_TIMESTAMP, - UiLogLevel::Info, - UiLogOrigin::Link, - "browser worker session created", - )], - ) -} - -pub(crate) fn simulator_ready_view() -> UiStudioView { - story_view( - vec![project_synced_pane_view(), simulator_ready_device_view()], - vec![ - UiLogEntry::new( - STORY_LOG_TIMESTAMP, - UiLogLevel::Info, - UiLogSource::with_detail(UiLogOrigin::Device, "fw-browser"), - "ready", - ), - UiLogEntry::new( - STORY_LOG_TIMESTAMP, - UiLogLevel::Info, - UiLogOrigin::Link, - "browser worker session owns Worker lifecycle in lpa-link", - ), - UiLogEntry::new( - STORY_LOG_TIMESTAMP, - UiLogLevel::Info, - UiLogSource::with_detail(UiLogOrigin::Device, "fw-browser"), - "project loaded", - ), - ], - ) -} - pub(crate) fn project_ready_view() -> UiStudioView { story_view( - vec![project_synced_pane_view(), simulator_ready_device_view()], + vec![project_synced_pane_view()], vec![ UiLogEntry::new( STORY_LOG_TIMESTAMP, @@ -171,7 +149,7 @@ pub(crate) fn project_ready_view() -> UiStudioView { pub(crate) fn project_syncing_view() -> UiStudioView { story_view( - vec![project_syncing_pane_view(), simulator_ready_device_view()], + vec![project_syncing_pane_view()], vec![UiLogEntry::new( STORY_LOG_TIMESTAMP, UiLogLevel::Info, @@ -183,10 +161,7 @@ pub(crate) fn project_syncing_view() -> UiStudioView { pub(crate) fn project_sync_failed_view() -> UiStudioView { story_view( - vec![ - project_sync_failed_pane_view(), - simulator_ready_device_view(), - ], + vec![project_sync_failed_pane_view()], vec![UiLogEntry::new( STORY_LOG_TIMESTAMP, UiLogLevel::Error, @@ -196,263 +171,6 @@ pub(crate) fn project_sync_failed_view() -> UiStudioView { ) } -pub(crate) fn error_view() -> UiStudioView { - picker_issue_view( - "browser worker boot timed out", - "browser worker boot timed out", - ) -} - -pub(crate) fn picker_issue_view(message: &'static str, log_message: &'static str) -> UiStudioView { - story_view( - vec![device_view( - UiStatus::error("Needs attention"), - vec![stack_section( - "select-connection", - "Select connection", - UiStepState::NeedsAttention, - UiViewContent::Issue(UiIssue::new(message)), - start_actions(), - )], - Vec::new(), - )], - vec![studio_log(UiLogLevel::Error, log_message)], - ) -} - -pub(crate) fn idle_device_view() -> UiPaneView { - device_view( - UiStatus::neutral("Choose connection"), - vec![stack_section( - "select-connection", - "Select connection", - UiStepState::Active, - UiViewContent::text("Choose how Studio should connect."), - start_actions(), - )], - Vec::new(), - ) -} - -pub(crate) fn endpoint_device_view() -> UiPaneView { - device_view( - UiStatus::working("Connecting"), - vec![ - select_connection_complete("Simulator"), - stack_section( - "connect-device", - "Connect device", - UiStepState::Active, - UiViewContent::text("Choose the device endpoint to open."), - vec![ - device_action(DeviceOp::ConnectEndpoint { - provider_id: LinkProviderKind::BrowserWorker, - endpoint_id: LinkEndpointId::new("browser-worker-worker-1"), - }) - .with_label("Open browser simulator") - .with_summary("Open the browser-local firmware runtime."), - ], - ), - ], - vec!["[lpa-link] Browser worker provider selected"], - ) -} - -pub(crate) fn starting_device_view() -> UiPaneView { - device_view( - UiStatus::working("Connecting"), - vec![ - select_connection_complete("Simulator"), - connect_device_complete(browser_worker_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Active, - UiViewContent::Progress(UiProgress::indeterminate("Opening server protocol")), - Vec::new(), - ), - ], - vec![ - "[lpa-link] browser worker session created", - "[fw-browser] booting firmware runtime", - ], - ) -} - -pub(crate) fn simulator_ready_device_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("Simulator"), - connect_device_complete(browser_worker_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiViewContent::Metrics(vec![UiMetric::new( - "Protocol", - "fw-browser-post-message-v1", - )]), - vec![disconnect_device_action(), disconnect_lightplayer_action()], - ), - stack_section( - "open-project", - "Open project", - UiStepState::Complete, - UiViewContent::text("Project controls are available in the Project pane."), - Vec::new(), - ), - ], - vec![ - "[fw-browser] ready", - "[lp-server] loaded project studio-demo", - "[fw-browser] heartbeat frame=42", - ], - ) -} - -pub(crate) fn device_project_empty_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiViewContent::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), - connected_esp32_recovery_actions(), - ), - stack_section( - "open-project", - "Open project", - UiStepState::Active, - UiViewContent::text("Connect to a running project or load the demo project."), - vec![ - project_action(ProjectOp::ConnectRunningProject), - project_action(ProjectOp::LoadDemoProject), - ], - ), - ], - vec![ - "[fw-esp32c6] LightPlayer protocol ready", - "[lp-server] loaded projects: 0", - ], - ) -} - -pub(crate) fn device_project_selection_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiViewContent::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), - connected_esp32_recovery_actions(), - ), - stack_section( - "open-project", - "Open project", - UiStepState::Active, - UiViewContent::text("2 projects are running. Choose one to open."), - vec![ - project_action(ProjectOp::ConnectLoadedProject { handle_id: 1 }) - .with_label("Connect /projects/ambient") - .with_summary("Attach to running project handle 1."), - project_action(ProjectOp::ConnectLoadedProject { handle_id: 2 }) - .with_label("Connect /projects/palette-test") - .with_summary("Attach to running project handle 2."), - ], - ), - ], - vec![ - "[fw-esp32c6] LightPlayer protocol ready", - "[lp-server] loaded projects: 2", - ], - ) -} - -pub(crate) fn device_view( - status: UiStatus, - sections: Vec, - terminal: Vec<&'static str>, -) -> UiPaneView { - UiPaneView::new( - DeviceController::NODE_ID, - "Device", - status, - UiViewContent::Stack(Box::new( - UiStepsView::new(sections).with_terminal( - terminal - .into_iter() - .map(UiTerminalLine::new) - .collect::>(), - ), - )), - Vec::new(), - ) -} - -pub(crate) fn stack_section( - id: &'static str, - title: &'static str, - state: UiStepState, - body: UiViewContent, - actions: Vec, -) -> UiStepView { - UiStepView::new(id, title, state) - .with_body(body) - .with_actions(actions) -} - -pub(crate) fn select_connection_complete(label: &'static str) -> UiStepView { - stack_section( - "select-connection", - "Select connection", - UiStepState::Complete, - UiViewContent::text(label), - Vec::new(), - ) -} - -pub(crate) fn connect_device_complete(metrics: Vec) -> UiStepView { - connect_device_complete_with_actions(metrics, Vec::new()) -} - -pub(crate) fn connect_device_complete_with_actions( - metrics: Vec, - actions: Vec, -) -> UiStepView { - stack_section( - "connect-device", - "Connect device", - UiStepState::Complete, - UiViewContent::Metrics(metrics), - actions, - ) -} - -pub(crate) fn browser_worker_metrics() -> Vec { - vec![ - UiMetric::new("Provider", "Browser worker"), - UiMetric::new("Endpoint", "browser-worker-worker-1"), - UiMetric::new("Session", "browser-worker-worker-1:1"), - ] -} - -pub(crate) fn esp32_metrics() -> Vec { - vec![ - UiMetric::new("Provider", "Browser serial ESP32"), - UiMetric::new("Endpoint", "browser-serial-esp32-port-1"), - UiMetric::new("Session", "browser-serial-esp32-port-1:1"), - ] -} - pub(crate) fn project_synced_pane_view() -> UiPaneView { UiPaneView::new( ProjectController::NODE_ID, @@ -857,55 +575,3 @@ pub(crate) fn project_ready_state() -> ProjectState { }, } } - -pub(crate) fn start_actions() -> Vec { - vec![ - device_action(DeviceOp::OpenProvider { - provider_id: LinkProviderKind::BrowserWorker, - }) - .with_label("Start simulator") - .with_summary("Run LightPlayer locally in a browser worker.") - .with_short_label("Simulator") - .with_icon("play"), - device_action(DeviceOp::OpenProvider { - provider_id: LinkProviderKind::BrowserSerialEsp32, - }) - .with_label("Connect ESP32") - .with_summary("Connect to ESP32 hardware through browser Web Serial.") - .with_short_label("ESP32") - .with_icon("usb"), - device_action(DeviceOp::OpenProviderForRecovery { - provider_id: LinkProviderKind::BrowserSerialEsp32, - }) - .with_label("Open for flashing") - .with_summary("Open the ESP32 connection without attaching LightPlayer.") - .with_short_label("Flash") - .with_icon("usb"), - ] -} - -pub(crate) fn disconnect_device_action() -> UiAction { - device_action(DeviceOp::DisconnectDevice) -} - -pub(crate) fn disconnect_lightplayer_action() -> UiAction { - device_action(DeviceOp::DisconnectLightPlayer) -} - -pub(crate) fn connected_esp32_recovery_actions() -> Vec { - vec![ - device_action(DeviceOp::ProvisionFirmware { setup_name: None }), - device_action(DeviceOp::ResetDevice), - device_action(DeviceOp::ResetToBlank), - disconnect_device_action(), - disconnect_lightplayer_action().with_label("Disconnect LightPlayer"), - ] -} - -pub(crate) fn device_action(op: DeviceOp) -> UiAction { - UiAction::from_op(ControllerId::new(DeviceController::NODE_ID), op) -} - -pub(crate) fn project_action(op: ProjectOp) -> UiAction { - UiAction::from_op(ControllerId::new(ProjectController::NODE_ID), op) -} diff --git a/lp-app/lpa-studio-web/src/core/mod.rs b/lp-app/lpa-studio-web/src/core/mod.rs index b023e9f8c..166c9f3ee 100644 --- a/lp-app/lpa-studio-web/src/core/mod.rs +++ b/lp-app/lpa-studio-web/src/core/mod.rs @@ -43,5 +43,4 @@ pub use status_chip::StatusChip; pub use terminal_output::TerminalOutput; pub use view::activity_view::ActivityView; pub use view::pane_view::PaneView; -pub use view::stack_view::StepsView; pub use view::view_content::ViewContent; diff --git a/lp-app/lpa-studio-web/src/core/story_fixtures.rs b/lp-app/lpa-studio-web/src/core/story_fixtures.rs index b38103993..4832fe289 100644 --- a/lp-app/lpa-studio-web/src/core/story_fixtures.rs +++ b/lp-app/lpa-studio-web/src/core/story_fixtures.rs @@ -1,12 +1,11 @@ use core::any::Any; use lpa_studio_core::core::view::activity_view::{UiActivityStep, UiActivityStepState}; -use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; use lpa_studio_core::{ ActionClass, ActionConfirmation, ActionMeta, ActionPriority, ControllerId, ControllerOp, PROJECT_ACTION_DEADLINE, UiAction, UiActivityView, UiIssue, UiLogEntry, UiLogLevel, - UiLogOrigin, UiLogSource, UiMetric, UiPaneView, UiProgress, UiStatus, UiStepsView, - UiTerminalLine, UiViewContent, + UiLogOrigin, UiLogSource, UiMetric, UiPaneView, UiProgress, UiStatus, UiTerminalLine, + UiViewContent, }; /// Timestamp shared by the core story log fixtures (deterministic stories). @@ -117,30 +116,15 @@ pub(crate) fn story_activity() -> UiActivityView { .with_terminal(story_terminal_lines()) } -pub(crate) fn story_steps() -> UiStepsView { - UiStepsView::new(vec![ - UiStepView::new("connection", "Select connection", UiStepState::Complete) - .with_body(UiViewContent::text("Simulator provider selected.")), - UiStepView::new("device", "Connect device", UiStepState::Active) - .with_body(UiViewContent::Progress(UiProgress::indeterminate( - "Opening link session", - ))) - .with_actions(vec![disabled_action()]), - UiStepView::new("project", "Open project", UiStepState::Pending) - .with_body(UiViewContent::text("Connect LightPlayer first.")), - UiStepView::new("sync", "Sync project", UiStepState::NeedsAttention) - .with_body(UiViewContent::Issue(story_issue())) - .with_actions(vec![story_action(StoryOp::Retry)]), - ]) - .with_terminal(story_terminal_lines()) -} - +/// The generic pane fixture: a bus-shaped body with a confirmation +/// action, standing in for "a pane with a busy status". Was a Device +/// step-stack until that pane retired. pub(crate) fn story_pane() -> UiPaneView { UiPaneView::new( ControllerId::new("story|core|pane"), - "Device", - UiStatus::working("Connecting"), - UiViewContent::Stack(Box::new(story_steps())), + "Project", + UiStatus::working("Syncing"), + UiViewContent::Metrics(story_metrics()), vec![confirmation_action()], ) } @@ -154,7 +138,6 @@ pub(crate) enum StoryOp { Primary, Secondary, Tertiary, - Retry, } impl ControllerOp for StoryOp { @@ -178,11 +161,6 @@ impl ControllerOp for StoryOp { ActionPriority::Tertiary, ) .with_icon("disconnect"), - Self::Retry => ActionMeta::new( - "Retry sync", - "Retry the failed project sync.", - ActionPriority::Primary, - ), } } diff --git a/lp-app/lpa-studio-web/src/core/view/mod.rs b/lp-app/lpa-studio-web/src/core/view/mod.rs index 28051b67f..807b68bc2 100644 --- a/lp-app/lpa-studio-web/src/core/view/mod.rs +++ b/lp-app/lpa-studio-web/src/core/view/mod.rs @@ -4,9 +4,6 @@ pub(crate) mod activity_view_stories; pub mod pane_view; #[cfg(feature = "stories")] pub(crate) mod pane_view_stories; -pub mod stack_view; -#[cfg(feature = "stories")] -pub(crate) mod steps_view_stories; pub mod view_content; #[cfg(feature = "stories")] pub(crate) mod view_content_stories; diff --git a/lp-app/lpa-studio-web/src/core/view/stack_view.rs b/lp-app/lpa-studio-web/src/core/view/stack_view.rs deleted file mode 100644 index 9163ce62f..000000000 --- a/lp-app/lpa-studio-web/src/core/view/stack_view.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::base::{StudioIcon, StudioIconName}; -use crate::core::{ActionStrip, TerminalOutput, ViewContent}; -use dioxus::prelude::*; -use lpa_studio_core::core::view::steps_view::UiStepState; -use lpa_studio_core::{UiAction, UiStepsView}; - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn StepsView(stack: UiStepsView, running: bool, on_action: EventHandler) -> Element { - let terminal = stack.terminal; - let sections = stack.sections.into_iter().collect::>(); - - rsx! { - div { class: "tw:grid tw:min-w-0 tw:gap-4", - ol { class: "tw:m-0 tw:grid tw:list-none tw:gap-0 tw:p-0", - for section in sections { - li { class: "{stack_section_class(section.state)}", - div { class: "{stack_marker_class(section.state)}", aria_label: "{step_state_label(section.state)}", - if let Some(icon) = stack_marker_icon(section.state) { - StudioIcon { - name: icon, - size: 14, - } - } - } - h3 { class: "tw:m-0 tw:self-center tw:text-base tw:font-bold tw:leading-tight tw:text-strong-foreground tw:break-words", "{section.title}" } - div { class: "tw:col-span-2 tw:min-w-0", - ViewContent { - body: section.body, - running, - on_action, - } - } - if !section.actions.is_empty() { - div { class: "tw:col-span-2 tw:min-w-0", - ActionStrip { - actions: section.actions, - running, - on_action, - } - } - } - } - } - } - TerminalOutput { - lines: terminal, - } - } - } -} - -fn stack_section_class(state: UiStepState) -> &'static str { - match state { - UiStepState::Pending => { - "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-subtle-foreground tw:first:border-t-0" - } - UiStepState::Active => { - "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-working-foreground tw:first:border-t-0" - } - UiStepState::Complete => { - "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-good-foreground tw:first:border-t-0" - } - UiStepState::NeedsAttention => { - "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-error-foreground tw:first:border-t-0" - } - } -} - -fn stack_marker_class(state: UiStepState) -> &'static str { - match state { - UiStepState::Pending => { - "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-transparent tw:text-subtle-foreground" - } - UiStepState::Active => { - "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-step-active tw:text-status-working-foreground" - } - UiStepState::Complete => { - "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-status-good-bg tw:text-status-good-foreground" - } - UiStepState::NeedsAttention => { - "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-status-error-bg tw:text-status-error-foreground" - } - } -} - -fn stack_marker_icon(state: UiStepState) -> Option { - match state { - UiStepState::Pending => None, - UiStepState::Active => Some(StudioIconName::StepActive), - UiStepState::Complete => Some(StudioIconName::StepComplete), - UiStepState::NeedsAttention => Some(StudioIconName::StepAttention), - } -} - -fn step_state_label(state: UiStepState) -> &'static str { - match state { - UiStepState::Pending => "pending", - UiStepState::Active => "active", - UiStepState::Complete => "complete", - UiStepState::NeedsAttention => "needs attention", - } -} diff --git a/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs b/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs deleted file mode 100644 index 1b2367904..000000000 --- a/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs +++ /dev/null @@ -1,66 +0,0 @@ -use dioxus::prelude::*; -use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; -use lpa_studio_core::{UiProgress, UiStepsView, UiViewContent}; -use lpa_studio_web_story_macros::story; - -use crate::core::StepsView; -use crate::core::story_fixtures::{ - confirmation_action, story_actions, story_issue, story_metrics, story_steps, - story_terminal_lines, -}; - -#[story] -pub(crate) fn workflow() -> Element { - rsx! { - StepsView { - stack: story_steps(), - running: false, - on_action: move |_| {}, - } - } -} - -#[story] -pub(crate) fn nested_content() -> Element { - let steps = UiStepsView::new(vec![ - UiStepView::new("text", "Text body", UiStepState::Complete) - .with_body(UiViewContent::text("The simulator provider is selected.")), - UiStepView::new("progress", "Progress body", UiStepState::Active).with_body( - UiViewContent::Progress( - UiProgress::determinate("Reading project", 68) - .with_detail("Fetching node shape metadata."), - ), - ), - UiStepView::new("metrics", "Metrics body", UiStepState::Complete) - .with_body(UiViewContent::Metrics(story_metrics())), - UiStepView::new("issue", "Issue body", UiStepState::NeedsAttention) - .with_body(UiViewContent::Issue(story_issue())) - .with_actions(story_actions()), - ]) - .with_terminal(story_terminal_lines()); - - rsx! { - StepsView { - stack: steps, - running: false, - on_action: move |_| {}, - } - } -} - -#[story] -pub(crate) fn running_actions() -> Element { - let steps = UiStepsView::new(vec![ - UiStepView::new("active", "Flashing firmware", UiStepState::Active) - .with_body(UiViewContent::text("Studio is writing the firmware image.")) - .with_actions(vec![confirmation_action()]), - ]); - - rsx! { - StepsView { - stack: steps, - running: true, - on_action: move |_| {}, - } - } -} diff --git a/lp-app/lpa-studio-web/src/core/view/view_content.rs b/lp-app/lpa-studio-web/src/core/view/view_content.rs index a3fc198f5..7067e5b3c 100644 --- a/lp-app/lpa-studio-web/src/core/view/view_content.rs +++ b/lp-app/lpa-studio-web/src/core/view/view_content.rs @@ -2,7 +2,7 @@ use dioxus::prelude::*; use lpa_studio_core::{UiAction, UiViewContent}; use crate::app::{BusPaneBody, ProjectPane}; -use crate::core::{ActivityView, IssueView, MetricGrid, ProgressBar, StepsView}; +use crate::core::{ActivityView, IssueView, MetricGrid, ProgressBar}; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -12,7 +12,6 @@ pub fn ViewContent( on_action: EventHandler, ) -> Element { match body { - UiViewContent::Empty => rsx! {}, UiViewContent::Text(text) => rsx! { p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-muted-foreground", "{text}" } }, @@ -28,13 +27,6 @@ pub fn ViewContent( UiViewContent::Metrics(metrics) => rsx! { MetricGrid { metrics } }, - UiViewContent::Stack(stack) => rsx! { - StepsView { - stack: *stack, - running, - on_action, - } - }, // `PaneView` intercepts the project editor to attach the pane's // status and actions to the ProjectPane header; this arm only // serves direct `ViewContent` consumers and renders with defaults. diff --git a/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs b/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs index e7aca72f8..a4bf36a95 100644 --- a/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs +++ b/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs @@ -3,7 +3,7 @@ use lpa_studio_core::{UiProgress, UiViewContent}; use lpa_studio_web_story_macros::story; use crate::core::ViewContent; -use crate::core::story_fixtures::{story_activity, story_issue, story_metrics, story_steps}; +use crate::core::story_fixtures::{story_activity, story_issue, story_metrics}; #[story] pub(crate) fn body_variants() -> Element { @@ -46,7 +46,7 @@ pub(crate) fn composed_variants() -> Element { on_action: move |_| {}, } ViewContent { - body: UiViewContent::Stack(Box::new(story_steps())), + body: UiViewContent::Issue(story_issue()), running: false, on_action: move |_| {}, } diff --git a/lp-app/lpa-studio-web/src/stories/story_book.rs b/lp-app/lpa-studio-web/src/stories/story_book.rs index aeaee44fd..36d52470b 100644 --- a/lp-app/lpa-studio-web/src/stories/story_book.rs +++ b/lp-app/lpa-studio-web/src/stories/story_book.rs @@ -20,9 +20,15 @@ pub fn StoryBook() -> Element { let story_groups = story_groups(&stories); let selected = selected_story_id.read().clone(); let selected_viewport = *viewport.read(); + // Never blank the page: an unknown route falls back to the default, + // and a STALE default (one whose story was deleted) falls back to the + // first registered story. The old `.expect` turned a stale default + // into an empty body — which reads to the capture pipeline as "no + // stories exist" rather than "the book is broken". let selection = story_selection(&selected, &story_groups) .or_else(|| story_selection(DEFAULT_STORY_ID, &story_groups)) - .expect("default story descriptor is registered"); + .or_else(|| first_story_selection(&story_groups)) + .expect("at least one story is registered"); let build_stamp = generated_at_utc(); let story_summary = format!("{} states / sm md lg", stories.len()); let page_title = selection.label(); @@ -339,6 +345,18 @@ fn story_groups(stories: &[StoryDescriptor]) -> Vec { groups } +/// The first registered story, in book order — the last-resort selection +/// so a stale [`DEFAULT_STORY_ID`] degrades to "wrong story" instead of +/// "no page at all". +fn first_story_selection(groups: &[StoryFamilyGroup]) -> Option { + let first = groups + .iter() + .flat_map(|family| family.categories.iter()) + .flat_map(|category| category.components.iter()) + .find_map(|component| component.stories.first())?; + Some(StorySelection::Story(*first)) +} + fn story_selection(selected_id: &str, groups: &[StoryFamilyGroup]) -> Option { for family in groups { for category in &family.categories { diff --git a/lp-app/lpa-studio-web/src/stories/story_registry.rs b/lp-app/lpa-studio-web/src/stories/story_registry.rs index c56fc2e38..306c78baf 100644 --- a/lp-app/lpa-studio-web/src/stories/story_registry.rs +++ b/lp-app/lpa-studio-web/src/stories/story_registry.rs @@ -2,7 +2,15 @@ use dioxus::prelude::*; use crate::stories::story::StoryDescriptor; -pub const DEFAULT_STORY_ID: &str = "studio/layout/studio-shell/simulator-idle"; +/// The story the book opens on, and the fallback for an unknown route. +/// +/// Must name a story that actually exists — see the guard in +/// `story_book::StoryBook`, which falls back to the first registered +/// story rather than blanking the page if this ever goes stale. It did: +/// the previous default (`studio/layout/studio-shell/simulator-idle`) +/// retired with the step-stack device pane, and the storybook rendered +/// an empty body, so capture discovered ZERO stories. +pub const DEFAULT_STORY_ID: &str = "studio/home/home-gallery/populated"; mod generated { include!(concat!(env!("OUT_DIR"), "/story_registry.generated.rs")); diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png index 0f3606e4c..8c91156a6 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png index 2c843d2f9..8b18161db 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png index 32bed7d68..bab283699 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png deleted file mode 100644 index 8fff79f62..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png deleted file mode 100644 index 02604eddb..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png deleted file mode 100644 index 10639f1eb..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png deleted file mode 100644 index 4e188b465..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png deleted file mode 100644 index 08f42edaf..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png deleted file mode 100644 index 4cf31c7a4..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png deleted file mode 100644 index eb679ed1c..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png deleted file mode 100644 index ce6eece3a..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png deleted file mode 100644 index 5c55005be..000000000 Binary files a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png index 0adf0e3f8..55d7db27a 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png index 784618238..4cc0233c3 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png index 762c6068e..c8a15b922 100644 Binary files a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__lg.png deleted file mode 100644 index f8325fed5..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__md.png deleted file mode 100644 index 38de19311..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__sm.png deleted file mode 100644 index 1950a9443..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__connected-at-head__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__lg.png deleted file mode 100644 index fcd14812c..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__md.png deleted file mode 100644 index 24cfc646f..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__sm.png deleted file mode 100644 index 5f8127855..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__disconnected-with-association__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__lg.png deleted file mode 100644 index 64b547b92..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__md.png deleted file mode 100644 index 5b1ee032e..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__sm.png deleted file mode 100644 index c2a1d85c3..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__ready-to-flash__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__lg.png deleted file mode 100644 index 82aab9f2f..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__md.png deleted file mode 100644 index 166c9c159..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__sm.png deleted file mode 100644 index 21bb71bfd..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reflash-needed__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__lg.png deleted file mode 100644 index 516c7e92c..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__md.png deleted file mode 100644 index a2a858c59..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__sm.png deleted file mode 100644 index 8882340fa..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__unresponsive__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png deleted file mode 100644 index 4e1c5d9e9..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png deleted file mode 100644 index 18822d45d..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png deleted file mode 100644 index 6c9bc8fa2..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png deleted file mode 100644 index a018b6708..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png deleted file mode 100644 index a28edd0b8..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png deleted file mode 100644 index d93302ba4..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png deleted file mode 100644 index 0e6563dda..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png deleted file mode 100644 index af42ae4ea..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png deleted file mode 100644 index 6dec9c0c7..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png deleted file mode 100644 index 7cd40f946..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png deleted file mode 100644 index 61ef188b6..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png deleted file mode 100644 index 2b2e65232..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png deleted file mode 100644 index ed21a5818..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png deleted file mode 100644 index 1010d528b..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png deleted file mode 100644 index 46ad6040c..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png deleted file mode 100644 index 259bcdf1d..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png deleted file mode 100644 index 87055124a..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png deleted file mode 100644 index f7c756712..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png deleted file mode 100644 index 9688901b0..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png deleted file mode 100644 index 5d1627cf4..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png deleted file mode 100644 index 6971bf77a..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png index 7cd40f946..84fbb938e 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png index 61ef188b6..f6ffe0ae7 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png index 2b2e65232..b1c0bd939 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png index e7a01101f..307e981de 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png index 9a09320f6..02e6e8d4f 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png index f46822e5b..c1f0e1d93 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png index f9850943c..24cc5970a 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png index ad46a5912..69fc523ef 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png index a8e5877c6..c726d08ce 100644 Binary files a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png differ