Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/adr/2026-06-22-studio-device-ux-workflow.md
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
19 changes: 11 additions & 8 deletions docs/adr/2026-07-05-studio-pane-grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion docs/defects/2026-07-28-retired-device-pane-still-reachable.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
5 changes: 4 additions & 1 deletion docs/defects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
225 changes: 2 additions & 223 deletions lp-app/lpa-studio-core/src/app/device/device_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,28 +77,6 @@ pub struct DeviceController {
pending_device_logs: Rc<RefCell<Vec<UiLogDraft>>>,
}

/// 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<DeviceState>,
/// 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.
Expand All @@ -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()))
Expand Down Expand Up @@ -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<String>,
) -> 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<String>,
) -> 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<UiAction> {
// 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
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading