diff --git a/Cargo.lock b/Cargo.lock index db138612b..2b9357ca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5567,6 +5567,7 @@ dependencies = [ name = "lpa-boards" version = "40.0.0" dependencies = [ + "dioxus", "lpc-hardware", "schemars", "serde", @@ -5715,6 +5716,7 @@ dependencies = [ "js-sys", "log", "lpa-agent", + "lpa-boards", "lpa-client", "lpa-fs-opfs", "lpa-link", diff --git a/docs/design/board-diagrams.md b/docs/design/board-diagrams.md new file mode 100644 index 000000000..b357dd383 --- /dev/null +++ b/docs/design/board-diagrams.md @@ -0,0 +1,102 @@ +# Board diagram design language + +Vocabulary for LightPlayer board diagrams. Every board drawing renders from +metadata (`boards//.display.json` sidecars, see +`lp-app/lpa-boards/`) through one SVG renderer: the `BoardDiagram` Dioxus +component (`lpa-boards/src/diagram.rs`), whose geometry lives entirely in +`lpa-boards/src/geometry.rs`. There are no hand-drawn board images. + +Ported from the approved UX spike (`spikes/hardware-boards/DESIGN-LANGUAGE.md` +rev 5, PR #222, two visual gates). + +## Layout vocabulary + +| Term | Meaning | +|---|---| +| **Unit** `u` | Pin pitch = row height. THE scaling factor — every other dimension derives from it. | +| **Row** | One pin's horizontal band, `u` tall. All annotation for a pin lives in its row, so every pin can be annotated simultaneously. | +| **Cell** | A typed chip inside a row. Width fits content; height is fixed (`0.78u`). | +| **Rail** | The stack of rows on a left/right board edge. Rails extend outward from the pads. | +| **Band** | Horizontal strip above/below the rails holding rows for top/bottom-edge pins (screw terminals etc.), which can't have vertical rows. | +| **Leader** | Elbow line connecting a top/bottom pad to its band row. | +| **Pad** | The pin's physical marker on the board edge, colored by role. | +| **Label** | The pin's name, silkscreen-style *inside* the board edge, colored by role. Keeps outside cells left-aligned into columns. | + +The annotated-anatomy story (`studio/boards/board-diagram/anatomy` in the +story book) draws this vocabulary on a real board; its callout anchors are +computed from the same `BoardLayout` the renderer draws, so the figure tracks +the engine. + +## Derived geometry (all from `u`) + +Constants live in `lpa-boards/src/geometry.rs` — that module is the single +home for these numbers. + +| Element | Size | +|---|---| +| Row height / pin pitch | `1.00u` | +| Cell height | `0.78u` | +| Pad | `0.62u × 0.45u` | +| Cell font | `0.50u` | +| Cell gap | `0.22u` | + +The spike settled on `u = 12` for compact contexts (device card, discovery) +and `u = 13` for detail contexts (boards page pinout). `scale` multiplies the +rendered SVG size only — geometry never changes with it. + +## Cell types + +Colors ride `lpb-cell--*` classes in the studio stylesheet +(`lpa-studio-web/src/style.css`), mapped onto studio palette families where +one exists. + +| Type | Color family | Used for | +|---|---|---| +| `name` | slate | Pin name (band rows only; rails put names inside the board) | +| `pwr` / `gnd` | red (status-error) / dim | Power and ground | +| `adc` `dac` `touch` | green (status-good) / blue (status-live) / orange (status-attention) | Analog capabilities | +| `spi` `i2c` `uart` `usb` | violet / cyan / brown / amber | Buses | +| `strap` | amber | Boot-strap pins — usable, with care | +| `warn` | dim orange | Caution: input-only, XTAL, PSRAM-reserved, JTAG | +| `conn` | **violet** (status-bound) | A bound connection (studio convention: bound = violet, never green) | +| swatch | — | Discovery color code (n solid squares) | + +## Pin roles (pad + label color, drives eligibility) + +`pwr5` `pwr3` `gnd` `io` `ioin` (input-only) `strap` `usb` `ctl` (EN/RST) +`nc` `rsvd` (in-package flash/PSRAM — present but never claimable) + +Output-eligible for LEDs: `io`, `strap` (`PinRole::output_eligible()`). +Never: `ioin`, `usb` (e.g. C6 IO12/13 USB-JTAG), `nc`, `rsvd`, power/ctl, +and pins already bound or reserved (onboard RGB). + +## Renderer modes + +| Mode | Question it answers | Where | +|---|---|---| +| `plain` | what does the board look like? | catalog / picker thumbnails | +| `caps` | what's *supported* on each pin? | boards page detail | +| `wired` | what's *connected*? | device card hardware pane | +| `swatch` | which pin are my LEDs on? | pin discovery | + +## Discovery language + +- **Order test**: the 9-pixel palindrome `K-R-G-B-W-B-G-R-K`, repeated on every + free pin. K bounds each repeat, W marks the center; palindrome ⇒ direction + doesn't matter; K/W are permutation-invariant ⇒ the perceived row uniquely + identifies the strip's color order (RGB/GRB/…). +- **Code**: a pin's steady color sequence. Codes are **palindromes** — data + direction is unknown, so a code and its reverse are indistinguishable; a + palindrome reads the same from either end. Displayed with **K (off) pixels + between digits** so color runs read unambiguously, but no leading/trailing K — + short strips (even 3 LEDs) must still show 1-digit codes. A `d`-digit code + occupies `2d−1` physical pixels. +- **Code plan**: smallest `d digits × c colors` with `c^ceil(d/2) ≥ free pins` + (a length-`d` palindrome has `ceil(d/2)` free digits), colors drawn in order + from the palette `R G B C M Y W` (K is reserved as the separator). Even digit + counts are never optimal, so the ladder is 1 digit (1 px) → 3 digits (5 px) → + 5 digits (9 px). E.g. 20 pins → `3 digits × 5 colors` = 25 `X·Y·X` codes on + 5 pixels. + +The discovery *mechanics* (driver rotation, channel budgets) are M7's plan; +this section defines only the visual language the `swatch` mode renders. diff --git a/lp-app/lpa-boards/Cargo.toml b/lp-app/lpa-boards/Cargo.toml index abff60f1f..a579312da 100644 --- a/lp-app/lpa-boards/Cargo.toml +++ b/lp-app/lpa-boards/Cargo.toml @@ -10,8 +10,14 @@ rust-version.workspace = true default = [] # JSON Schema derives for the board display sidecar types (host-side tooling). schema-gen = ["dep:schemars"] +# The BoardDiagram Dioxus component. Off by default so data-only consumers +# (lp-cli) never pull a UI framework. +diagram = ["dep:dioxus"] [dependencies] +# Component-library feature set (no renderer, no launch): the consuming app +# picks the platform. +dioxus = { version = "0.7", default-features = false, features = ["lib"], optional = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } schemars = { workspace = true, optional = true } diff --git a/lp-app/lpa-boards/README.md b/lp-app/lpa-boards/README.md index dfbd8aa50..65ec314a6 100644 --- a/lp-app/lpa-boards/README.md +++ b/lp-app/lpa-boards/README.md @@ -16,8 +16,39 @@ is enforced by `tests/manifest_drift.rs`, not code coupling. Rationale: Authoring guide and tier definitions: `lp-core/lpc-hardware/boards/README.md`. Schema: `schemas/board-display.schema.json` (generated by `just schema-gen`). -Drawing vocabulary: `spikes/hardware-boards/DESIGN-LANGUAGE.md` until the -renderer milestone moves it into `docs/design/`. +Drawing vocabulary: `docs/design/board-diagrams.md`. -Coming milestones add the row-engine `BoardDiagram` component and the -`#/boards` catalog page here. +## The `BoardDiagram` component + +The row-engine SVG renderer, behind the `diagram` cargo feature (off by +default so data-only consumers never pull a UI framework): + +```toml +lpa-boards = { path = "../lpa-boards", features = ["diagram"] } +``` + +```rust +use lpa_boards::{BoardDiagram, DiagramMode, WiredConnection, board_by_id}; + +rsx! { + BoardDiagram { + board: board_by_id("espressif/esp32-s3-devkitc-1").unwrap().clone(), + mode: DiagramMode::Wired, // plain | caps | wired | swatch + u: 12.0, // pin pitch — the design language's unit + scale: 1.05, // on-screen size only; geometry is u-driven + wired: vec![WiredConnection { + gpio: 16, + title: "porch strip".into(), + extra: Some("WS2812 ×300".into()), + }], + } +} +``` + +Colors ride `lpb-*` classes; the class block lives in the consuming app's +stylesheet (`lpa-studio-web/src/style.css`), mapped onto the studio palette +(bound = violet). All geometry — rails, band rows, leader lines, viewBox — +comes from `geometry::BoardLayout`, which is public and deterministic: +stories compute annotation anchors from the same layout the renderer draws. + +A coming milestone adds the `#/boards` catalog page. diff --git a/lp-app/lpa-boards/src/catalog.rs b/lp-app/lpa-boards/src/catalog.rs index 736833ed8..16b4f7d0a 100644 --- a/lp-app/lpa-boards/src/catalog.rs +++ b/lp-app/lpa-boards/src/catalog.rs @@ -41,6 +41,10 @@ pub const DISPLAY_MANIFEST_SOURCES: &[(&str, &str)] = &[ "quinled/dig-uno", include_str!("../../../lp-core/lpc-hardware/boards/quinled/dig-uno.display.json"), ), + ( + "domraem/dom-z-102", + include_str!("../../../lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json"), + ), ]; /// Every checked-in board, parsed once. Panics on malformed embedded data — diff --git a/lp-app/lpa-boards/src/diagram.rs b/lp-app/lpa-boards/src/diagram.rs new file mode 100644 index 000000000..d7a80eeba --- /dev/null +++ b/lp-app/lpa-boards/src/diagram.rs @@ -0,0 +1,345 @@ +//! `BoardDiagram`: the row-engine SVG renderer as a Dioxus component. +//! +//! All geometry comes from [`crate::geometry::BoardLayout`]; this module only +//! walks the computed layout and emits SVG. Colors ride `lpb-*` CSS classes +//! (studio palette; the class block lives in the consuming app's stylesheet — +//! see `lpa-studio-web/src/style.css`), so the same component serves the +//! catalog, provisioning picker, hardware pane, and discovery UI. + +use dioxus::prelude::*; + +use crate::display_manifest::BoardDisplayFile; +use crate::geometry::{ + BoardLayout, CellBody, CellLayout, DiagramMode, DiagramOptions, PinSwatch, RowLabel, + WiredConnection, pad_css_suffix, +}; + +/// Extra viewBox space around the board, in drawing units, for overlays that +/// draw outside the layout (the anatomy story's callouts). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct DiagramMargin { + pub top: f32, + pub right: f32, + pub bottom: f32, + pub left: f32, +} + +/// One board, drawn by the row layout engine. +/// +/// `u` is the pin pitch — the single scaling unit of the design language. +/// `scale` multiplies the rendered size only; geometry never changes with it. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn BoardDiagram( + board: BoardDisplayFile, + #[props(default)] mode: DiagramMode, + #[props(default = 13.0)] u: f32, + #[props(default = 1.0)] scale: f32, + #[props(default = true)] labels: bool, + #[props(default)] wired: Vec, + #[props(default)] swatches: Vec, + #[props(default)] margin: DiagramMargin, + /// Extra SVG content rendered above the board (annotation layers). + #[props(default)] + overlay: Option, +) -> Element { + let options = DiagramOptions { + mode, + u, + labels, + wired, + swatches, + }; + let layout = BoardLayout::compute(&board, &options); + let hw = &board.hw; + + let [vx, vy, vw, vh] = layout.view_box; + let (vx, vy) = (vx - margin.left, vy - margin.top); + let (vw, vh) = ( + vw + margin.left + margin.right, + vh + margin.top + margin.bottom, + ); + let width = (vw * scale).round(); + let height = (vh * scale).round(); + + let board_w = layout.board_w; + let board_h = layout.board_h; + + // Module body: the antenna keep-out strip occupies the top 14 units. + let module = &hw.module; + let module_y = if module.antenna { + module.y + 14.0 + } else { + module.y + }; + let module_h = module.h - if module.antenna { 14.0 } else { 0.0 }; + let module_font = if module.w > 60.0 { 9.0 } else { 7.5 }; + let antenna_zigzag = module.antenna.then(|| { + let mut path = format!("M {} {}", module.x + 6.0, module.y + 6.0); + for step in 1..=8 { + let x = module.x + 6.0 + step as f32 * (module.w - 12.0) / 8.0; + let y = module.y + if step % 2 == 1 { 2.5 } else { 9.5 }; + path.push_str(&format!(" L {x} {y}")); + } + path + }); + + rsx! { + svg { + class: "lpb-diagram", + view_box: "{vx} {vy} {vw} {vh}", + width: "{width}", + height: "{height}", + xmlns: "http://www.w3.org/2000/svg", + + // ---- pcb ---------------------------------------------------- + rect { + class: "lpb-pcb", + x: "0", + y: "0", + width: "{board_w}", + height: "{board_h}", + rx: "7", + } + for (hx, hy) in [ + (8.0, 8.0), + (board_w - 8.0, 8.0), + (8.0, board_h - 8.0), + (board_w - 8.0, board_h - 8.0), + ] { + circle { class: "lpb-hole", cx: "{hx}", cy: "{hy}", r: "2.6" } + } + + // ---- module ------------------------------------------------- + if module.antenna { + rect { + class: "lpb-antenna", + x: "{module.x}", + y: "{module.y}", + width: "{module.w}", + height: "12", + } + } + if let Some(zigzag) = antenna_zigzag { + path { class: "lpb-antenna-zigzag", d: "{zigzag}" } + } + rect { + class: "lpb-module", + x: "{module.x}", + y: "{module_y}", + width: "{module.w}", + height: "{module_h}", + rx: "2", + } + text { + class: "lpb-module-label", + x: "{module.x + module.w / 2.0}", + y: "{module_y + module_h / 2.0 + 3.0}", + text_anchor: "middle", + style: "font-size: {module_font}px", + "{module.label}" + } + + // ---- fixed hardware ----------------------------------------- + for usb in hw.usb.iter() { + rect { + class: "lpb-usb", + x: "{usb.x - 11.0}", + y: "{board_h - 9.0}", + width: "22", + height: "13", + rx: "3", + } + if labels { + text { + class: "lpb-silk", + x: "{usb.x}", + y: "{board_h + 0.9 * layout.u}", + text_anchor: "middle", + "{usb.label}" + } + } + } + for button in hw.buttons.iter() { + { + let by = if button.y < 0.0 { board_h + button.y } else { button.y }; + let inner = button.x < board_w / 2.0; + let caption_x = if inner { button.x + 10.0 } else { button.x - 10.0 }; + let caption_anchor = if inner { "start" } else { "end" }; + rsx! { + rect { + class: "lpb-button", + x: "{button.x - 7.0}", + y: "{by - 5.0}", + width: "14", + height: "10", + rx: "2", + } + circle { class: "lpb-button-cap", cx: "{button.x}", cy: "{by}", r: "2.6" } + if labels { + text { + class: "lpb-silk", + x: "{caption_x}", + y: "{by + 2.5}", + text_anchor: caption_anchor, + "{button.label}" + } + } + } + } + } + if let Some(rgb) = &hw.rgb { + rect { + class: "lpb-rgb", + x: "{rgb.x - 4.0}", + y: "{rgb.y - 4.0}", + width: "8", + height: "8", + rx: "1.5", + } + circle { class: "lpb-rgb-die", cx: "{rgb.x}", cy: "{rgb.y}", r: "2" } + } + for terminal in layout.terminals.iter() { + rect { + class: "lpb-terminal", + x: "{terminal.rect.x}", + y: "{terminal.rect.y}", + width: "{terminal.rect.w}", + height: "{terminal.rect.h}", + rx: "2", + } + circle { + class: "lpb-screw", + cx: "{terminal.screw_center.0}", + cy: "{terminal.screw_center.1}", + r: "{terminal.screw_radius}", + } + if let Some(label) = &terminal.label { + text { + class: "lpb-silk", + x: "{label.x}", + y: "{label.y}", + text_anchor: "middle", + "{label.text}" + } + } + } + + // ---- rails -------------------------------------------------- + for row in layout.rail_rows() { + rect { + class: "lpb-pad lpb-pad--{pad_css_suffix(row.role)}", + x: "{row.pad.x}", + y: "{row.pad.y}", + width: "{row.pad.w}", + height: "{row.pad.h}", + rx: "1.4", + } + if let Some(label) = &row.label { + PinLabel { label: label.clone() } + } + CellRow { cells: row.cells.clone(), font: layout.font, gpio: row.gpio } + } + + // ---- band + leaders ----------------------------------------- + for row in layout.band.iter() { + if !row.cells.is_empty() { + path { + class: "lpb-leader", + d: "M {row.leader[0].0} {row.leader[0].1} L {row.leader[1].0} {row.leader[1].1} L {row.leader[2].0} {row.leader[2].1}", + } + CellRow { cells: row.cells.clone(), font: layout.font, gpio: row.gpio } + } + } + + if let Some(overlay) = overlay { + {overlay} + } + } + } +} + +/// A pin's name, inside the board edge. Plain mode uses the silkscreen color; +/// annotated modes color it by the pin's name-cell family. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn PinLabel(label: RowLabel) -> Element { + let class = match label.kind { + None => "lpb-silk".to_string(), + Some(kind) => format!("lpb-pinlabel lpb-fg--{}", kind.css_suffix()), + }; + let anchor = if label.start_anchored { "start" } else { "end" }; + rsx! { + text { + class: "{class}", + x: "{label.x}", + y: "{label.y}", + text_anchor: anchor, + style: "font-size: {label.font_size}px", + "{label.text}" + } + } +} + +/// One row's cells (already positioned by the layout). +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn CellRow(cells: Vec, font: f32, gpio: Option) -> Element { + let gpio_attr = gpio.map(|gpio| gpio.to_string()).unwrap_or_default(); + rsx! { + g { class: "lpb-row", "data-gpio": "{gpio_attr}", + for cell in cells.iter() { + match &cell.body { + CellBody::Text { text, kind } => rsx! { + g { class: "lpb-cell lpb-cell--{kind.css_suffix()}", + rect { + class: "lpb-cell-rect", + x: "{cell.rect.x}", + y: "{cell.rect.y}", + width: "{cell.rect.w}", + height: "{cell.rect.h}", + rx: "3", + } + text { + class: "lpb-cell-text", + x: "{cell.rect.x + cell.rect.w / 2.0}", + y: "{cell.rect.y + cell.rect.h / 2.0 + font * 0.36}", + text_anchor: "middle", + style: "font-size: {font}px", + "{text}" + } + } + }, + CellBody::Swatch { colors, selected } => rsx! { + g { + class: if *selected { + "lpb-cell lpb-cell--swatch lpb-cell--swatch-selected" + } else { + "lpb-cell lpb-cell--swatch" + }, + rect { + class: "lpb-cell-rect", + x: "{cell.rect.x}", + y: "{cell.rect.y}", + width: "{cell.rect.w}", + height: "{cell.rect.h}", + rx: "3", + } + for (index, color) in colors.iter().enumerate() { + rect { + class: "lpb-swatch-px", + x: "{cell.rect.x + 4.0 + index as f32 * cell.rect.h * 0.74}", + y: "{cell.rect.y + cell.rect.h / 2.0 - cell.rect.h * 0.29}", + width: "{cell.rect.h * 0.58}", + height: "{cell.rect.h * 0.58}", + rx: "1.5", + fill: "{color}", + } + } + } + }, + } + } + } + } +} diff --git a/lp-app/lpa-boards/src/display_manifest.rs b/lp-app/lpa-boards/src/display_manifest.rs index bf298a034..13f97c93e 100644 --- a/lp-app/lpa-boards/src/display_manifest.rs +++ b/lp-app/lpa-boards/src/display_manifest.rs @@ -68,8 +68,7 @@ pub struct PurchaseUrl { /// The drawing block the row-engine diagram renderer consumes. /// /// Geometry is in board-local units; the renderer derives every other size -/// from the pin pitch (`u`). See `spikes/hardware-boards/DESIGN-LANGUAGE.md` -/// (moving to `docs/design/board-diagrams.md` with the renderer milestone). +/// from the pin pitch (`u`). See `docs/design/board-diagrams.md`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] pub struct BoardDrawing { diff --git a/lp-app/lpa-boards/src/geometry.rs b/lp-app/lpa-boards/src/geometry.rs new file mode 100644 index 000000000..174fcaca4 --- /dev/null +++ b/lp-app/lpa-boards/src/geometry.rs @@ -0,0 +1,755 @@ +//! Row-engine layout for board diagrams: every drawn coordinate in one place. +//! +//! The layout unit is `u` — the pin pitch, which is also the row height. Every +//! other dimension derives from it (see `docs/design/board-diagrams.md`), so +//! diagrams stay dense and collision-free at any scale. The renderer +//! ([`crate::diagram::BoardDiagram`]) walks the computed [`BoardLayout`] +//! without doing geometry of its own, and stories reuse the same layout to +//! anchor annotations — the engine is deterministic, so computed positions +//! ARE the rendered positions. +//! +//! Ported from the approved UX spike (`spikes/hardware-boards/index.html`, +//! rev 5, PR #222). Deviations from the spike are called out inline. + +use crate::display_manifest::{BoardDisplayFile, CapKind, PinCap, PinRole}; + +/// Cell height as a fraction of `u`. +pub const CELL_HEIGHT_U: f32 = 0.78; +/// Pad width as a fraction of `u`. +pub const PAD_WIDTH_U: f32 = 0.62; +/// Pad height as a fraction of `u`. +pub const PAD_HEIGHT_U: f32 = 0.45; +/// Cell font size as a fraction of `u`. +pub const FONT_SIZE_U: f32 = 0.5; +/// Gap between cells in a row as a fraction of `u`. +pub const CELL_GAP_U: f32 = 0.22; + +/// What a diagram is answering. See the design language doc for where each +/// mode is used. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DiagramMode { + /// What does the board look like? (catalog / picker thumbnails) + #[default] + Plain, + /// What's supported on each pin? (boards page detail) + Caps, + /// What's connected? (device card hardware pane) + Wired, + /// Which pin are my LEDs on? (pin discovery) + Swatch, +} + +/// One bound connection shown in [`DiagramMode::Wired`]. Bound = violet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WiredConnection { + pub gpio: u8, + /// The connection's name ("porch strip"). + pub title: String, + /// Optional second cell ("WS2812 ×300"). + pub extra: Option, +} + +/// One pin's discovery color code shown in [`DiagramMode::Swatch`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PinSwatch { + pub gpio: u8, + /// CSS colors, one per displayed pixel (separators included). + pub colors: Vec, + /// Highlight as the confirmed pin. + pub selected: bool, +} + +/// Layout inputs. `scale` is not here on purpose: it only multiplies the SVG's +/// on-screen size, never the geometry. +#[derive(Clone, Debug, PartialEq)] +pub struct DiagramOptions { + pub mode: DiagramMode, + /// Pin pitch in drawing units. The spike settled on 12 (compact contexts) + /// and 13 (detail contexts). + pub u: f32, + /// Draw pin labels and hardware captions. Thumbnails turn this off. + pub labels: bool, + /// Connections consulted in [`DiagramMode::Wired`]. + pub wired: Vec, + /// Codes consulted in [`DiagramMode::Swatch`]. + pub swatches: Vec, +} + +impl Default for DiagramOptions { + fn default() -> Self { + Self { + mode: DiagramMode::Plain, + u: 13.0, + labels: true, + wired: Vec::new(), + swatches: Vec::new(), + } + } +} + +/// Visual family of a cell (or of a pin label in non-plain modes). Maps 1:1 +/// onto the `lpb-cell--*` / `lpb-fg--*` CSS classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CellKind { + Name, + Pwr, + Gnd, + Ctl, + Adc, + Dac, + Touch, + Spi, + I2c, + Uart, + Usb, + Strap, + Warn, + Note, + /// A bound connection — violet, per the studio convention. + Conn, +} + +impl CellKind { + /// CSS class suffix (`lpb-cell--{suffix}`). + pub fn css_suffix(self) -> &'static str { + match self { + Self::Name => "name", + Self::Pwr => "pwr", + Self::Gnd => "gnd", + Self::Ctl => "ctl", + Self::Adc => "adc", + Self::Dac => "dac", + Self::Touch => "touch", + Self::Spi => "spi", + Self::I2c => "i2c", + Self::Uart => "uart", + Self::Usb => "usb", + Self::Strap => "strap", + Self::Warn => "warn", + Self::Note => "note", + Self::Conn => "conn", + } + } +} + +impl From for CellKind { + fn from(kind: CapKind) -> Self { + match kind { + CapKind::Adc => Self::Adc, + CapKind::Dac => Self::Dac, + CapKind::Touch => Self::Touch, + CapKind::Spi => Self::Spi, + CapKind::I2c => Self::I2c, + CapKind::Uart => Self::Uart, + CapKind::Usb => Self::Usb, + CapKind::Strap => Self::Strap, + CapKind::Pwr => Self::Pwr, + CapKind::Warn => Self::Warn, + CapKind::Note => Self::Note, + } + } +} + +/// An axis-aligned rectangle in board coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + +impl Rect { + pub fn center(&self) -> (f32, f32) { + (self.x + self.w / 2.0, self.y + self.h / 2.0) + } + + pub fn right(&self) -> f32 { + self.x + self.w + } + + pub fn bottom(&self) -> f32 { + self.y + self.h + } +} + +/// What a cell displays. +#[derive(Clone, Debug, PartialEq)] +pub enum CellBody { + Text { text: String, kind: CellKind }, + Swatch { colors: Vec, selected: bool }, +} + +/// One positioned cell in a row. +#[derive(Clone, Debug, PartialEq)] +pub struct CellLayout { + pub rect: Rect, + pub body: CellBody, +} + +/// A pin's label. On rails it sits inside the board (silkscreen-style) so the +/// outside cells share one start edge; on band rows the name is a leading +/// cell instead and this is absent. +#[derive(Clone, Debug, PartialEq)] +pub struct RowLabel { + pub text: String, + pub x: f32, + /// Text baseline y. + pub y: f32, + /// `true` = `text-anchor: start` (left rail), else `end` (right rail). + pub start_anchored: bool, + /// `None` = plain-mode silkscreen color; `Some` = the name-cell color + /// family for the pin's role. + pub kind: Option, + pub font_size: f32, +} + +/// One rail pin's row: pad on the board edge, label inside, cells outside. +#[derive(Clone, Debug, PartialEq)] +pub struct RailRow { + pub gpio: Option, + pub role: PinRole, + /// Row centerline. + pub y: f32, + pub pad: Rect, + pub label: Option, + pub cells: Vec, +} + +/// A top-edge terminal's row in the band above the board, tied to its pad by +/// a leader line. +#[derive(Clone, Debug, PartialEq)] +pub struct BandRow { + pub gpio: Option, + pub role: PinRole, + /// Row centerline (negative: above the board). + pub y: f32, + /// The leader's vertical x (the terminal pad center). + pub pad_x: f32, + /// Elbow polyline: down from the pad, across to the row. + pub leader: [(f32, f32); 3], + pub cells: Vec, +} + +/// A screw terminal's hardware footprint on the board's top edge. +#[derive(Clone, Debug, PartialEq)] +pub struct TerminalBlock { + pub rect: Rect, + pub screw_center: (f32, f32), + pub screw_radius: f32, + /// Present in plain mode with labels on (band rows carry the name + /// otherwise). + pub label: Option, +} + +/// The fully resolved diagram geometry. All coordinates are board-local +/// drawing units; the board's top-left corner is the origin, so rails extend +/// into negative x and the band into negative y. +#[derive(Clone, Debug, PartialEq)] +pub struct BoardLayout { + pub mode: DiagramMode, + pub u: f32, + pub cell_h: f32, + pub font: f32, + pub pad_w: f32, + pub pad_h: f32, + pub gap: f32, + /// PCB outline size. + pub board_w: f32, + pub board_h: f32, + /// y where the first pin row starts. + pub header_top: f32, + /// `[x, y, w, h]`. + pub view_box: [f32; 4], + pub left: Vec, + pub right: Vec, + pub band: Vec, + pub terminals: Vec, +} + +impl BoardLayout { + pub fn compute(board: &BoardDisplayFile, opts: &DiagramOptions) -> Self { + let hw = &board.hw; + let u = opts.u; + let cell_h = CELL_HEIGHT_U * u; + let font = FONT_SIZE_U * u; + let pad_w = PAD_WIDTH_U * u; + let pad_h = PAD_HEIGHT_U * u; + let gap = CELL_GAP_U * u; + let label_font = font.min(6.5); + + let header_top = hw.module.y + hw.module.h + 0.8 * u; + let n_max = hw.left.len().max(hw.right.len()) as f32; + let board_w = hw.width; + let board_h = header_top + + n_max * u + + if hw.buttons.is_empty() { + 0.8 * u + } else { + 2.2 * u + }; + + let cell_width = |body: &CellBody| -> f32 { + match body { + CellBody::Swatch { colors, .. } => colors.len() as f32 * (cell_h * 0.74) + 8.0, + CellBody::Text { text, .. } => { + (text.chars().count() as f32 * font * 0.66 + 9.0).max(18.0) + } + } + }; + let row_width = |bodies: &[CellBody]| -> f32 { + bodies.iter().map(cell_width).sum::() + + gap * (bodies.len().saturating_sub(1)) as f32 + }; + let cells_for = + |label: &str, role: PinRole, gpio: Option, caps: &[PinCap], with_name: bool| { + cell_bodies(opts, label, role, gpio, caps, with_name) + }; + + // Rails: rows extend outward from the pads; cells run away from the + // board so every pin's row can hold cells simultaneously. + let mut rails: [Vec; 2] = [Vec::new(), Vec::new()]; + let mut rail_extents = [6.0f32, 6.0f32]; + for (side, pins) in [(0usize, &hw.left), (1usize, &hw.right)] { + for (index, pin) in pins.iter().enumerate() { + let y = header_top + index as f32 * u + u / 2.0; + let bodies = cells_for(&pin.label, pin.role, pin.gpio, &pin.caps, false); + rail_extents[side] = rail_extents[side].max(row_width(&bodies) + 8.0); + let px = if side == 0 { + 1.0 + } else { + board_w - 1.0 - pad_w + }; + let pad = Rect { + x: px, + y: y - pad_h / 2.0, + w: pad_w, + h: pad_h, + }; + let label = opts.labels.then(|| { + let (text, kind) = match opts.mode { + DiagramMode::Plain => (pin.label.clone(), None), + _ => ( + name_cell_text(&pin.label, pin.role), + Some(name_cell_kind(pin.role)), + ), + }; + RowLabel { + text, + x: if side == 0 { + px + pad_w + 3.0 + } else { + px - 3.0 + }, + y: y + font * 0.36, + start_anchored: side == 0, + kind, + font_size: label_font, + } + }); + let (start_x, dir) = if side == 0 { + (-4.0, -1.0) + } else { + (board_w + 4.0, 1.0) + }; + let cells = place_cells(bodies, y, start_x, dir, cell_h, gap, &cell_width); + rails[side].push(RailRow { + gpio: pin.gpio, + role: pin.role, + y, + pad, + label, + cells, + }); + } + } + let [left, right] = rails; + let [cw_left, cw_right] = rail_extents; + + // Terminal hardware blocks along the top edge. + let term_w = 20.0; + let term_x0 = (board_w - hw.terminals.len() as f32 * term_w) / 2.0; + let terminals: Vec = hw + .terminals + .iter() + .enumerate() + .map(|(index, terminal)| { + let tx = term_x0 + index as f32 * term_w; + let center_x = tx + (term_w - 2.0) / 2.0; + let label = (opts.mode == DiagramMode::Plain && opts.labels).then(|| RowLabel { + text: terminal.label.clone(), + x: center_x, + y: -7.0, + start_anchored: false, + kind: None, + font_size: label_font, + }); + TerminalBlock { + rect: Rect { + x: tx, + y: -2.0, + w: term_w - 2.0, + h: 16.0, + }, + screw_center: (center_x, 6.0), + screw_radius: 4.4, + label, + } + }) + .collect(); + + // Band rows for the terminals, stacked above the board: the deepest + // terminal row belongs to the first terminal. + let band: Vec = if opts.mode == DiagramMode::Plain { + Vec::new() + } else { + hw.terminals + .iter() + .enumerate() + .map(|(index, terminal)| { + let bodies = cells_for( + &terminal.label, + terminal.role, + terminal.gpio, + &terminal.caps, + true, + ); + let pad_x = term_x0 + index as f32 * term_w + (term_w - 2.0) / 2.0; + let y = -(1.6 * u) - (hw.terminals.len() - 1 - index) as f32 * u; + let cells = place_cells(bodies, y, pad_x + 8.0, 1.0, cell_h, gap, &cell_width); + BandRow { + gpio: terminal.gpio, + role: terminal.role, + y, + pad_x, + leader: [(pad_x, -3.0), (pad_x, y), (pad_x + 6.0, y)], + cells, + } + }) + .collect() + }; + + let band_top = if band.is_empty() { + 0.0 + } else { + band.iter().map(|row| row.y).fold(f32::INFINITY, f32::min) - u / 2.0 + }; + // Band rows always lead with a name cell, so `last()` is the row's + // outer edge; +2 reproduces the spike's `padX + 10 + rowWidth`. + let band_right = band + .iter() + .map(|row| row.cells.last().map_or(0.0, |cell| cell.rect.right() + 2.0)) + .fold(0.0f32, f32::max); + let term_caption_h: f32 = if !terminals.is_empty() && opts.mode == DiagramMode::Plain { + 18.0 + } else { + 0.0 + }; + + let vb_x = -cw_left; + let vb_y = (-4.0 - term_caption_h).min(band_top - 4.0); + let vb_w = (board_w + cw_left + cw_right).max(band_right + cw_left + 6.0); + let vb_h = board_h + 1.6 * u - vb_y; + + Self { + mode: opts.mode, + u, + cell_h, + font, + pad_w, + pad_h, + gap, + board_w, + board_h, + header_top, + view_box: [vb_x, vb_y, vb_w, vb_h], + left, + right, + band, + terminals, + } + } + + /// All rail rows, left then right — the pad order the spike's anatomy + /// figure indexed by. + pub fn rail_rows(&self) -> impl Iterator { + self.left.iter().chain(self.right.iter()) + } +} + +/// The display text of a pin's name: numeric GPIO silkscreen labels gain an +/// `IO` prefix outside plain mode ("4" → "IO4"); named pins keep their label. +pub fn name_cell_text(label: &str, role: PinRole) -> String { + let io_ish = matches!( + role, + PinRole::Io | PinRole::IoIn | PinRole::Strap | PinRole::Usb | PinRole::Rsvd + ); + if io_ish && !label.is_empty() && label.bytes().all(|byte| byte.is_ascii_digit()) { + format!("IO{label}") + } else { + label.to_string() + } +} + +/// The color family of a pin's name cell / label, by role. +pub fn name_cell_kind(role: PinRole) -> CellKind { + match role { + PinRole::Pwr5 | PinRole::Pwr3 => CellKind::Pwr, + PinRole::Gnd | PinRole::Nc => CellKind::Gnd, + PinRole::Ctl => CellKind::Ctl, + PinRole::Io | PinRole::IoIn | PinRole::Strap | PinRole::Usb | PinRole::Rsvd => { + CellKind::Name + } + } +} + +/// CSS class suffix for a pad's role fill (`lpb-pad--{suffix}`). +pub fn pad_css_suffix(role: PinRole) -> &'static str { + match role { + PinRole::Pwr5 => "pwr5", + PinRole::Pwr3 => "pwr3", + PinRole::Gnd => "gnd", + PinRole::Io => "io", + PinRole::IoIn => "ioin", + PinRole::Strap => "strap", + PinRole::Usb => "usb", + PinRole::Ctl => "ctl", + PinRole::Nc => "nc", + // Not in the spike (M1 added the role): reserved pins take the muted + // input-only fill — present but visually recessive. + PinRole::Rsvd => "ioin", + } +} + +/// The cells a pin shows in the current mode. Band rows pass +/// `with_name = true` — they have no inside-the-board space for a label, so +/// the name leads the row as a cell. +fn cell_bodies( + opts: &DiagramOptions, + label: &str, + role: PinRole, + gpio: Option, + caps: &[PinCap], + with_name: bool, +) -> Vec { + if opts.mode == DiagramMode::Plain { + return Vec::new(); + } + let mut bodies = Vec::new(); + if with_name { + bodies.push(CellBody::Text { + text: name_cell_text(label, role), + kind: name_cell_kind(role), + }); + } + match opts.mode { + DiagramMode::Plain => {} + DiagramMode::Caps => { + bodies.extend(caps.iter().map(|cap| CellBody::Text { + text: cap.text.clone(), + kind: cap.kind.into(), + })); + } + DiagramMode::Wired => { + if let Some(connection) = + gpio.and_then(|gpio| opts.wired.iter().find(|wired| wired.gpio == gpio)) + { + bodies.push(CellBody::Text { + text: connection.title.clone(), + kind: CellKind::Conn, + }); + if let Some(extra) = &connection.extra { + bodies.push(CellBody::Text { + text: extra.clone(), + kind: CellKind::Conn, + }); + } + } + } + DiagramMode::Swatch => { + if let Some(swatch) = + gpio.and_then(|gpio| opts.swatches.iter().find(|swatch| swatch.gpio == gpio)) + { + bodies.push(CellBody::Swatch { + colors: swatch.colors.clone(), + selected: swatch.selected, + }); + } + } + } + bodies +} + +/// Position a row's cells from `start_x`, running in `dir` (+1 rightward for +/// right rail and band, −1 leftward for the left rail). +fn place_cells( + bodies: Vec, + y: f32, + start_x: f32, + dir: f32, + cell_h: f32, + gap: f32, + cell_width: &impl Fn(&CellBody) -> f32, +) -> Vec { + let mut x = start_x; + bodies + .into_iter() + .map(|body| { + let w = cell_width(&body); + let cx = if dir > 0.0 { x } else { x - w }; + x += dir * (w + gap); + CellLayout { + rect: Rect { + x: cx, + y: y - cell_h / 2.0, + w, + h: cell_h, + }, + body, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::catalog::board_by_id; + + fn quinled() -> &'static BoardDisplayFile { + board_by_id("quinled/dig-uno").expect("quinled sidecar embedded") + } + + fn c6() -> &'static BoardDisplayFile { + board_by_id("espressif/esp32-c6-devkitc-1").expect("c6 sidecar embedded") + } + + #[test] + fn row_pitch_is_u() { + let layout = BoardLayout::compute( + c6(), + &DiagramOptions { + mode: DiagramMode::Caps, + ..DiagramOptions::default() + }, + ); + for rail in [&layout.left, &layout.right] { + for pair in rail.windows(2) { + assert!((pair[1].y - pair[0].y - layout.u).abs() < 1e-4); + } + } + } + + #[test] + fn derived_sizes_follow_u() { + for u in [12.0, 13.0] { + let layout = BoardLayout::compute( + c6(), + &DiagramOptions { + mode: DiagramMode::Caps, + u, + ..DiagramOptions::default() + }, + ); + assert!((layout.cell_h - CELL_HEIGHT_U * u).abs() < 1e-4); + assert!((layout.pad_w - PAD_WIDTH_U * u).abs() < 1e-4); + assert!((layout.font - FONT_SIZE_U * u).abs() < 1e-4); + assert!((layout.gap - CELL_GAP_U * u).abs() < 1e-4); + } + } + + #[test] + fn view_box_contains_all_rows_and_band() { + let layout = BoardLayout::compute( + quinled(), + &DiagramOptions { + mode: DiagramMode::Caps, + ..DiagramOptions::default() + }, + ); + let [vx, vy, vw, vh] = layout.view_box; + assert!(!layout.band.is_empty(), "quinled has terminal band rows"); + for row in layout.rail_rows() { + for cell in &row.cells { + assert!(cell.rect.x >= vx && cell.rect.right() <= vx + vw); + assert!(cell.rect.y >= vy && cell.rect.bottom() <= vy + vh); + } + } + for row in &layout.band { + assert!(row.y < 0.0, "band rows sit above the board"); + for cell in &row.cells { + assert!(cell.rect.x >= vx && cell.rect.right() <= vx + vw); + assert!(cell.rect.y >= vy && cell.rect.bottom() <= vy + vh); + } + } + } + + #[test] + fn plain_mode_has_no_cells_or_band() { + let layout = BoardLayout::compute(quinled(), &DiagramOptions::default()); + assert!(layout.band.is_empty()); + assert!(layout.rail_rows().all(|row| row.cells.is_empty())); + // Terminal hardware still draws, with plain-mode caption labels. + assert_eq!(layout.terminals.len(), 4); + assert!(layout.terminals.iter().all(|term| term.label.is_some())); + } + + #[test] + fn wired_mode_adds_conn_cells_only_for_matched_gpios() { + let layout = BoardLayout::compute( + c6(), + &DiagramOptions { + mode: DiagramMode::Wired, + wired: vec![WiredConnection { + gpio: 4, + title: "porch strip".into(), + extra: Some("WS2812 ×300".into()), + }], + ..DiagramOptions::default() + }, + ); + let wired_row = layout + .rail_rows() + .find(|row| row.gpio == Some(4)) + .expect("gpio 4 on the c6 devkit"); + assert_eq!(wired_row.cells.len(), 2); + assert!(wired_row.cells.iter().all(|cell| matches!( + &cell.body, + CellBody::Text { + kind: CellKind::Conn, + .. + } + ))); + let unwired = layout.rail_rows().find(|row| row.gpio == Some(5)).unwrap(); + assert!(unwired.cells.is_empty()); + } + + #[test] + fn left_rail_cells_run_leftward_from_shared_start_edge() { + let layout = BoardLayout::compute( + c6(), + &DiagramOptions { + mode: DiagramMode::Caps, + ..DiagramOptions::default() + }, + ); + for row in &layout.left { + if let Some(first) = row.cells.first() { + assert!((first.rect.right() - -4.0).abs() < 1e-3); + } + } + for row in &layout.right { + if let Some(first) = row.cells.first() { + assert!((first.rect.x - (layout.board_w + 4.0)).abs() < 1e-3); + } + } + } + + #[test] + fn numeric_gpio_labels_gain_io_prefix() { + assert_eq!(name_cell_text("4", PinRole::Io), "IO4"); + assert_eq!(name_cell_text("35", PinRole::Rsvd), "IO35"); + assert_eq!(name_cell_text("D10", PinRole::Io), "D10"); + assert_eq!(name_cell_text("3V3", PinRole::Pwr3), "3V3"); + } +} diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs index d8e770226..b5c86eb80 100644 --- a/lp-app/lpa-boards/src/lib.rs +++ b/lp-app/lpa-boards/src/lib.rs @@ -12,10 +12,16 @@ //! here, app-side. See `docs/adr/2026-07-31-board-display-metadata-split.md`. mod catalog; +#[cfg(feature = "diagram")] +mod diagram; mod display_manifest; +pub mod geometry; pub use catalog::{DISPLAY_MANIFEST_SOURCES, all_boards, board_by_id}; +#[cfg(feature = "diagram")] +pub use diagram::{BoardDiagram, DiagramMargin}; pub use display_manifest::{ BoardDisplayError, BoardDisplayFile, BoardDrawing, CapKind, DrawnButton, DrawnModule, DrawnPin, DrawnRgb, DrawnTerminal, DrawnUsb, PinCap, PinRole, PurchaseUrl, SupportTier, }; +pub use geometry::{DiagramMode, DiagramOptions, PinSwatch, WiredConnection}; diff --git a/lp-app/lpa-boards/tests/manifest_drift.rs b/lp-app/lpa-boards/tests/manifest_drift.rs index fe83de07f..4936d785d 100644 --- a/lp-app/lpa-boards/tests/manifest_drift.rs +++ b/lp-app/lpa-boards/tests/manifest_drift.rs @@ -25,6 +25,10 @@ const DISPLAY_ONLY: &[(&str, &str)] = &[ "quinled/dig-uno", "classic ESP32 (v3) has no HardwareTarget yet", ), + ( + "domraem/dom-z-102", + "classic ESP32 (v3) has no HardwareTarget yet", + ), ]; fn boards_dir() -> PathBuf { diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml index 72558e7b1..9baa19533 100644 --- a/lp-app/lpa-studio-web/Cargo.toml +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -28,6 +28,8 @@ lpa-studio-core = { path = "../lpa-studio-core", features = ["browser-worker", " lpc-mapping = { path = "../../lp-core/lpc-mapping", default-features = false, optional = true } # Shared lamp-view geometry (universe palette, wiring arrows) + the editor. lpa-mapping-editor = { path = "../lpa-mapping-editor" } +# Board catalog data + the BoardDiagram row-engine renderer. +lpa-boards = { path = "../lpa-boards", features = ["diagram"] } lpa-studio-web-story-macros = { path = "../lpa-studio-web-story-macros", optional = true } lpc-wire = { path = "../../lp-core/lpc-wire" } # Markdown parsing for agent chat replies (parser only; rendering goes diff --git a/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs b/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs new file mode 100644 index 000000000..f5ba49538 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs @@ -0,0 +1,510 @@ +//! Stories for the board diagram renderer (`lpa-boards::BoardDiagram`). +//! +//! Coverage per the M2 milestone: every mode at u=12 and u=13, the smallest +//! and largest checked-in boards, and the annotated-anatomy figure whose +//! callout anchors are computed from the shared geometry module — the layout +//! engine is deterministic, so computed positions ARE rendered positions. +//! Scales mirror the approved spike (caps 1.45, wired 1.05, swatch 1.15) so +//! captured density is comparable to spike rev 5. + +use dioxus::prelude::*; +use lpa_boards::geometry::{BoardLayout, DiagramOptions}; +use lpa_boards::{ + BoardDiagram, BoardDisplayFile, DiagramMargin, DiagramMode, PinSwatch, WiredConnection, + all_boards, board_by_id, +}; +use lpa_studio_web_story_macros::story; + +fn board(id: &str) -> BoardDisplayFile { + board_by_id(id) + .unwrap_or_else(|| panic!("board {id} in the embedded catalog")) + .clone() +} + +/// The spike's hardware-pane wiring for the S3 devkit. +fn s3_wiring() -> Vec { + vec![ + WiredConnection { + gpio: 16, + title: "porch strip".into(), + extra: Some("WS2812 ×300".into()), + }, + WiredConnection { + gpio: 4, + title: "eave strip".into(), + extra: Some("WS2811 ×150".into()), + }, + WiredConnection { + gpio: 6, + title: "mode button".into(), + extra: None, + }, + WiredConnection { + gpio: 48, + title: "status pixel".into(), + extra: None, + }, + ] +} + +/// Palindromic discovery codes with K separators for every free +/// output-capable pin, mirroring the spike's code plan (3 digits × 5 colors +/// covers the C6's free-pin count on 5 pixels). The real planner is M7's; +/// this is display fixture data. +fn discovery_swatches(board: &BoardDisplayFile, reserved: &[u8]) -> Vec { + const DIGITS: [&str; 5] = ["#ef5350", "#3fd68e", "#4f9cf0", "#2fd4c9", "#d06bd6"]; + const OFF: &str = "#15181d"; + board + .pins() + .filter(|pin| pin.role.output_eligible()) + .filter_map(|pin| pin.gpio) + .filter(|gpio| !reserved.contains(gpio)) + .enumerate() + .map(|(index, gpio)| { + let first = DIGITS[index % DIGITS.len()]; + let middle = DIGITS[(index / DIGITS.len()) % DIGITS.len()]; + PinSwatch { + gpio, + colors: vec![ + first.into(), + OFF.into(), + middle.into(), + OFF.into(), + first.into(), + ], + selected: false, + } + }) + .collect() +} + +/// The spike's figure chrome: diagrams sit on the terminal surface. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn BoardFigure(children: Element) -> Element { + rsx! { + div { style: "display: inline-flex; gap: 22px; align-items: flex-start; background: var(--studio-color-terminal); border: 1px solid var(--studio-color-border); border-radius: 12px; padding: 18px;", + {children} + } + } +} + +#[story( + description = "Every checked-in board as a plain thumbnail — the catalog/picker rendering. One metadata source, one renderer, no hand-drawn images." +)] +pub(crate) fn catalog_thumbnails() -> Element { + rsx! { + div { style: "display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end;", + for entry in all_boards() { + div { style: "display: flex; flex-direction: column; align-items: center; gap: 6px; background: var(--studio-color-terminal); border: 1px solid var(--studio-color-border); border-radius: 10px; padding: 12px;", + BoardDiagram { + board: entry.clone(), + mode: DiagramMode::Plain, + scale: 0.55, + labels: false, + } + span { style: "font-size: 10px; color: var(--studio-color-text-subtle); font-family: var(--studio-font-mono);", + "{entry.display_name}" + } + } + } + } + } +} + +#[story(description = "Plain mode at u=12: the board at rest, labels on (C6 devkit).")] +pub(crate) fn plain_u12() -> Element { + rsx! { + BoardFigure { + BoardDiagram { board: board("espressif/esp32-c6-devkitc-1"), u: 12.0, scale: 1.15 } + } + } +} + +#[story(description = "Plain mode at u=13 (C6 devkit).")] +pub(crate) fn plain_u13() -> Element { + rsx! { + BoardFigure { + BoardDiagram { board: board("espressif/esp32-c6-devkitc-1"), u: 13.0, scale: 1.15 } + } + } +} + +#[story(description = "Caps mode at u=12: every pin's capability cells in their rows (C6 devkit).")] +pub(crate) fn caps_u12() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("espressif/esp32-c6-devkitc-1"), + mode: DiagramMode::Caps, + u: 12.0, + scale: 1.45, + } + } + } +} + +#[story( + description = "Caps mode at u=13 — the boards-page pinout density the spike settled on (C6 devkit)." +)] +pub(crate) fn caps_u13() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("espressif/esp32-c6-devkitc-1"), + mode: DiagramMode::Caps, + u: 13.0, + scale: 1.45, + } + } + } +} + +#[story( + description = "The smallest checked-in board (XIAO ESP32-C6) in caps mode: named D-pins, no IO prefix." +)] +pub(crate) fn caps_smallest_xiao() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("seeed/xiao-esp32-c6"), + mode: DiagramMode::Caps, + u: 13.0, + scale: 1.45, + } + } + } +} + +#[story( + description = "The largest checked-in board (ESP32 DevKitC v4, 38 pins) in caps mode: the density stress case — input-only warns, DACs, touch, SPI/I2C/UART, strap and reserved flash pins." +)] +pub(crate) fn caps_largest_devkitc_v4() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("espressif/esp32-devkitc-v4"), + mode: DiagramMode::Caps, + u: 13.0, + scale: 1.45, + } + } + } +} + +#[story( + description = "Wired mode at u=12: bound connections as violet cells beside the pin names (S3 devkit, device-card density)." +)] +pub(crate) fn wired_u12() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("espressif/esp32-s3-devkitc-1"), + mode: DiagramMode::Wired, + u: 12.0, + scale: 1.05, + wired: s3_wiring(), + } + } + } +} + +#[story(description = "Wired mode at u=13 (S3 devkit).")] +pub(crate) fn wired_u13() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("espressif/esp32-s3-devkitc-1"), + mode: DiagramMode::Wired, + u: 13.0, + scale: 1.05, + wired: s3_wiring(), + } + } + } +} + +#[story( + description = "Wired mode with top-edge screw terminals (QuinLED-Dig-Uno): terminal rows live in a band above the board, tied to their pads by leader lines." +)] +pub(crate) fn wired_band_quinled() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("quinled/dig-uno"), + mode: DiagramMode::Wired, + u: 12.0, + scale: 1.05, + wired: vec![ + WiredConnection { + gpio: 16, + title: "garden run".into(), + extra: Some("WS2815 ×600".into()), + }, + WiredConnection { + gpio: 3, + title: "path lights".into(), + extra: Some("WS2811 ×100".into()), + }, + ], + } + } + } +} + +#[story( + description = "The DOMRAEM DOM-Z-102 (Ethernet WLED-class controller, screw terminals both sides) wired on all four fused data channels. Its stacked OPT/RST buttons render side by side — the 2D compromise pending the callout milestone." +)] +pub(crate) fn wired_domraem_4ch() -> Element { + rsx! { + BoardFigure { + BoardDiagram { + board: board("domraem/dom-z-102"), + mode: DiagramMode::Wired, + u: 12.0, + scale: 1.05, + wired: vec![ + WiredConnection { + gpio: 18, + title: "roofline".into(), + extra: Some("WS2815 ×800".into()), + }, + WiredConnection { + gpio: 16, + title: "porch".into(), + extra: Some("WS2812 ×300".into()), + }, + WiredConnection { + gpio: 14, + title: "garden".into(), + extra: Some("WS2811 ×150".into()), + }, + WiredConnection { + gpio: 2, + title: "path".into(), + extra: None, + }, + ], + } + } + } +} + +#[story( + description = "Swatch mode at u=12: palindromic K-separated discovery codes on every free output-capable pin (C6 devkit; IO8 reserved for the onboard pixel, USB-JTAG pins skipped by role)." +)] +pub(crate) fn swatch_u12() -> Element { + let c6 = board("espressif/esp32-c6-devkitc-1"); + let swatches = discovery_swatches(&c6, &[8]); + rsx! { + BoardFigure { + BoardDiagram { + board: c6, + mode: DiagramMode::Swatch, + u: 12.0, + scale: 1.15, + swatches, + } + } + } +} + +#[story(description = "Swatch mode at u=13 (C6 devkit).")] +pub(crate) fn swatch_u13() -> Element { + let c6 = board("espressif/esp32-c6-devkitc-1"); + let swatches = discovery_swatches(&c6, &[8]); + rsx! { + BoardFigure { + BoardDiagram { + board: c6, + mode: DiagramMode::Swatch, + u: 13.0, + scale: 1.15, + swatches, + } + } + } +} + +#[story( + description = "Swatch mode after the user clicks their code: only the confirmed pin keeps its swatch, framed in bound violet." +)] +pub(crate) fn swatch_confirmed() -> Element { + let c6 = board("espressif/esp32-c6-devkitc-1"); + let confirmed: Vec = discovery_swatches(&c6, &[8]) + .into_iter() + .filter(|swatch| swatch.gpio == 21) + .map(|swatch| PinSwatch { + selected: true, + ..swatch + }) + .collect(); + rsx! { + BoardFigure { + BoardDiagram { + board: c6, + mode: DiagramMode::Swatch, + u: 12.0, + scale: 1.15, + swatches: confirmed, + } + } + } +} + +// ---- annotated anatomy --------------------------------------------------- + +/// Estimated bbox of an end-anchored pin label (the layout stores anchor + +/// baseline; width comes from the same char-count heuristic cells use). +fn label_center(x: f32, baseline_y: f32, text_len: usize, font_size: f32) -> (f32, f32) { + let width = text_len as f32 * font_size * 0.62; + (x - width / 2.0, baseline_y - 2.0) +} + +fn note(lx: f32, ly: f32, px: f32, py: f32, text: &str, anchor: &str) -> Element { + let ty = if ly < py { ly - 3.0 } else { ly + 8.0 }; + rsx! { + path { class: "lpb-anat-line", d: "M {lx} {ly} L {px} {py}" } + circle { class: "lpb-anat-dot", cx: "{px}", cy: "{py}", r: "1.6" } + text { class: "lpb-anat-label", x: "{lx}", y: "{ty}", text_anchor: "{anchor}", "{text}" } + } +} + +fn bracket(x: f32, y1: f32, y2: f32, dir: f32) -> Element { + let tip = x + 4.0 * dir; + rsx! { + path { + class: "lpb-anat-shape lpb-anat-solid", + d: "M {tip} {y1} L {x} {y1} L {x} {y2} L {tip} {y2}", + } + } +} + +#[story( + label = "Anatomy", + description = "The design language drawn on a real board (QuinLED-Dig-Uno): unit, row, cell, rail, band, leader, pad, label — every callout anchor computed from the shared geometry module, not hand-positioned. Doc figure for docs/design/board-diagrams.md." +)] +pub(crate) fn anatomy() -> Element { + let quinled = board("quinled/dig-uno"); + let options = DiagramOptions { + mode: DiagramMode::Caps, + u: 13.0, + ..DiagramOptions::default() + }; + let layout = BoardLayout::compute(&quinled, &options); + let u = layout.u; + + // Pad — right rail, last pin (free space beyond: no cells on that row). + let pad_target = layout.right.last().expect("quinled right rail").pad; + let (pad_cx, pad_cy) = pad_target.center(); + + // Label — the "IO15" name inside the board (right rail, second pin). + let io15 = &layout.right[1]; + let label = io15.label.as_ref().expect("labels are on"); + let (lbl_x, lbl_y) = label_center( + label.x, + label.y, + label.text.chars().count(), + label.font_size, + ); + + // Row + Cell — IO15's row: outline pad → cells, then point at the cell. + let cells_x0 = io15 + .cells + .iter() + .map(|cell| cell.rect.x) + .fold(f32::MAX, f32::min); + let cells_x1 = io15 + .cells + .iter() + .map(|cell| cell.rect.right()) + .fold(f32::MIN, f32::max); + let row_x0 = cells_x0.min(io15.pad.x) - 2.0; + let row_x1 = cells_x1.max(io15.pad.right()) + 2.0; + let row_y0 = io15.pad.center().1 - u / 2.0; + let first_cell = io15.cells.first().expect("IO15 has an ADC cell"); + let (cell_cx, cell_cy) = first_cell.rect.center(); + + // Rail — bracket spanning the right rail's pads. + let rail_x = row_x1 + 44.0; + let rail_y0 = layout.right.first().unwrap().pad.y - 2.0; + let rail_y1 = layout.right.last().unwrap().pad.bottom() + 2.0; + let rail_mid = + (layout.right.first().unwrap().pad.y + layout.right.last().unwrap().pad.y) / 2.0 + 4.0; + + // Band — bracket spanning the terminal band rows above the board. + let band_x = layout + .band + .iter() + .filter_map(|row| row.cells.last()) + .map(|cell| cell.rect.right()) + .fold(f32::MIN, f32::max) + + 6.0; + let band_y0 = layout + .band + .iter() + .flat_map(|row| row.cells.first()) + .map(|cell| cell.rect.y) + .fold(f32::MAX, f32::min) + - 2.0; + let band_y1 = layout + .band + .iter() + .flat_map(|row| row.cells.first()) + .map(|cell| cell.rect.bottom()) + .fold(f32::MIN, f32::max) + + 2.0; + let band_mid = (band_y0 + band_y1) / 2.0 + 3.0; + + // Leader — the elbow line from the first (deepest) terminal pad. + let leader = &layout.band.first().expect("quinled has terminals").leader; + let (leader_x, leader_bottom) = (leader[0].0, leader[0].1); + + // Unit u — dimension ticks between the first two left pads. + let (p0x, p0y) = layout.left[0].pad.center(); + let (_p1x, p1y) = layout.left[1].pad.center(); + let ux = p0x - 12.0; + + let overlay = rsx! { + {note(pad_cx + 26.0, pad_cy + 14.0, pad_cx + 3.0, pad_cy + 2.0, "Pad", "start")} + {note(lbl_x - 4.0, lbl_y + 30.0, lbl_x, lbl_y + 4.0, "Label", "middle")} + rect { + class: "lpb-anat-shape", + x: "{row_x0}", + y: "{row_y0}", + width: "{row_x1 - row_x0}", + height: "{u}", + rx: "4", + } + {note(row_x1 + 26.0, io15.pad.y - 12.0, row_x1 + 1.0, row_y0 + 2.0, "Row (1u tall)", "start")} + {note(cell_cx + 10.0, cell_cy + 26.0, cell_cx, cell_cy + 4.0, "Cell", "start")} + {bracket(rail_x, rail_y0, rail_y1, -1.0)} + {note(rail_x + 20.0, rail_mid, rail_x + 1.0, rail_mid, "Rail", "start")} + {bracket(band_x, band_y0, band_y1, -1.0)} + {note(band_x + 18.0, band_mid, band_x + 1.0, band_mid, "Band", "start")} + {note(leader_x - 28.0, leader_bottom + 16.0, leader_x + 1.0, leader_bottom - 4.0, "Leader", "start")} + path { + class: "lpb-anat-shape lpb-anat-solid", + d: "M {ux - 3.0} {p0y} L {ux + 3.0} {p0y} M {ux} {p0y} L {ux} {p1y} M {ux - 3.0} {p1y} L {ux + 3.0} {p1y}", + } + {note(ux - 26.0, p0y + u / 2.0 + 3.0, ux, (p0y + p1y) / 2.0, "u = pitch", "end")} + }; + + rsx! { + BoardFigure { + BoardDiagram { + board: quinled, + mode: DiagramMode::Caps, + u: 13.0, + scale: 1.35, + margin: DiagramMargin { + top: 8.0, + right: 92.0, + bottom: 12.0, + left: 44.0, + }, + overlay, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/mod.rs b/lp-app/lpa-studio-web/src/app/mod.rs index 894ed6853..6313e25d4 100644 --- a/lp-app/lpa-studio-web/src/app/mod.rs +++ b/lp-app/lpa-studio-web/src/app/mod.rs @@ -5,6 +5,8 @@ //! controls and `base` primitives into app-specific workflows. pub(crate) mod affordance; +#[cfg(feature = "stories")] +pub(crate) mod board_diagram_stories; pub mod bus; pub mod device; pub mod home; diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index bc7276e76..115c607f7 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -2967,3 +2967,211 @@ label:has(> input:not(:disabled)) { } } + +/* ---- board diagrams (lpa-boards BoardDiagram) -------------------------- + The row-engine renderer's palette, mapped onto the studio system. Where a + studio semantic family exists it is used directly — conn = bound (violet, + never green), adc = good, touch = attention, dac = live, pwr = error — + and hues the palette lacks (spi, i2c, uart, amber strap/usb, silk slate) + carry literal values tuned against the dark surfaces in the approved + spike (spikes/hardware-boards, rev 5). Geometry never lives here: every + size the design language derives from `u` is computed in + lpa-boards/src/geometry.rs and arrives inline. */ + +.lpb-diagram { + display: block; +} + +.lpb-pcb { + fill: #101418; + stroke: #2c3440; + stroke-width: 1.5; +} + +.lpb-hole { + fill: var(--studio-color-bg); + stroke: #2c3440; +} + +.lpb-antenna { + fill: none; + stroke: #2c3440; + stroke-width: 1; + stroke-dasharray: 2 2; +} + +.lpb-antenna-zigzag { + fill: none; + stroke: #8b93a0; + stroke-width: 1; + opacity: 0.7; +} + +.lpb-module { + fill: #1a2027; + stroke: #39414d; + stroke-width: 1.2; +} + +.lpb-module-label { + font-family: var(--studio-font-mono); + font-weight: 700; + fill: #8d96a4; +} + +.lpb-usb { + fill: #2b313a; + stroke: #454d59; + stroke-width: 1; +} + +.lpb-button { + fill: #232830; + stroke: #454d59; +} + +.lpb-button-cap { + fill: #3a414c; +} + +.lpb-rgb { + fill: #20262e; + stroke: #454d59; +} + +.lpb-rgb-die { + fill: #5a5470; +} + +.lpb-terminal { + fill: #1d232b; + stroke: #454d59; +} + +.lpb-screw { + fill: none; + stroke: #5a6270; + stroke-width: 1.6; +} + +.lpb-silk { + font-family: var(--studio-font-mono); + font-weight: 600; + font-size: 6.5px; + fill: #8b93a0; +} + +.lpb-pinlabel { + font-family: var(--studio-font-mono); + font-weight: 600; +} + +.lpb-leader { + fill: none; + stroke: var(--studio-color-border-strong); + stroke-width: 1; +} + +.lpb-cell-rect { + stroke-width: 0.8; +} + +.lpb-cell-text { + font-family: var(--studio-font-mono); + font-weight: 600; +} + +/* Pad fills by pin role: physical-marker colors, not UI semantics. */ +.lpb-pad--pwr5 { fill: #b3564f; } +.lpb-pad--pwr3 { fill: #a3703f; } +.lpb-pad--gnd { fill: #3a3f48; } +.lpb-pad--io { fill: #4a5260; } +.lpb-pad--ioin { fill: #3d4854; } +.lpb-pad--strap { fill: #8a6d3a; } +.lpb-pad--usb { fill: #8a6d3a; } +.lpb-pad--ctl { fill: #556070; } +.lpb-pad--nc { fill: #2a2e35; } + +/* Cell families: rect fill/stroke + text fill (labels reuse via lpb-fg--*). */ +.lpb-cell--name .lpb-cell-rect { fill: rgba(138, 143, 152, 0.1); stroke: #4a5260; } +.lpb-cell--name .lpb-cell-text, .lpb-fg--name { fill: #c7cdd6; } + +.lpb-cell--pwr .lpb-cell-rect { fill: var(--studio-status-error-bg); stroke: var(--studio-status-error-border); } +.lpb-cell--pwr .lpb-cell-text, .lpb-fg--pwr { fill: #e99a97; } + +.lpb-cell--gnd .lpb-cell-rect { fill: rgba(255, 255, 255, 0.03); stroke: #33383f; } +.lpb-cell--gnd .lpb-cell-text, .lpb-fg--gnd { fill: #767c85; } + +.lpb-cell--ctl .lpb-cell-rect { fill: rgba(138, 143, 152, 0.08); stroke: #454b54; } +.lpb-cell--ctl .lpb-cell-text, .lpb-fg--ctl { fill: #9aa1ab; } + +.lpb-cell--adc .lpb-cell-rect { fill: var(--studio-status-good-bg); stroke: var(--studio-status-good-border); } +.lpb-cell--adc .lpb-cell-text, .lpb-fg--adc { fill: var(--studio-status-good-text); } + +.lpb-cell--dac .lpb-cell-rect { fill: var(--studio-status-live-bg); stroke: var(--studio-status-live-border); } +.lpb-cell--dac .lpb-cell-text, .lpb-fg--dac { fill: var(--studio-status-live-text); } + +.lpb-cell--touch .lpb-cell-rect { fill: var(--studio-status-attention-bg); stroke: var(--studio-status-attention-border); } +.lpb-cell--touch .lpb-cell-text, .lpb-fg--touch { fill: var(--studio-status-attention-text); } + +.lpb-cell--spi .lpb-cell-rect { fill: rgba(167, 139, 250, 0.1); stroke: #54487c; } +.lpb-cell--spi .lpb-cell-text, .lpb-fg--spi { fill: #b9a2f6; } + +.lpb-cell--i2c .lpb-cell-rect { fill: rgba(47, 212, 201, 0.09); stroke: #265c58; } +.lpb-cell--i2c .lpb-cell-text, .lpb-fg--i2c { fill: #59d8cf; } + +.lpb-cell--uart .lpb-cell-rect { fill: rgba(201, 141, 92, 0.1); stroke: #5f4530; } +.lpb-cell--uart .lpb-cell-text, .lpb-fg--uart { fill: #d3a077; } + +.lpb-cell--usb .lpb-cell-rect { fill: rgba(228, 192, 101, 0.1); stroke: #6a5a30; } +.lpb-cell--usb .lpb-cell-text, .lpb-fg--usb { fill: #e0c377; } + +.lpb-cell--strap .lpb-cell-rect { fill: rgba(228, 192, 101, 0.08); stroke: #5c512e; } +.lpb-cell--strap .lpb-cell-text, .lpb-fg--strap { fill: #cbb168; } + +.lpb-cell--warn .lpb-cell-rect { fill: rgba(240, 145, 59, 0.07); stroke: #5c4224; } +.lpb-cell--warn .lpb-cell-text, .lpb-fg--warn { fill: #c98d5c; } + +.lpb-cell--note .lpb-cell-rect { fill: rgba(255, 255, 255, 0.03); stroke: #3a3f48; } +.lpb-cell--note .lpb-cell-text, .lpb-fg--note { fill: #8b93a0; } + +/* Bound connection: the studio bound family. Violet, never green. */ +.lpb-cell--conn .lpb-cell-rect { fill: var(--studio-status-bound-bg); stroke: var(--studio-status-bound-border); stroke-width: 1; } +.lpb-cell--conn .lpb-cell-text, .lpb-fg--conn { fill: var(--studio-status-bound-text); } + +/* Discovery swatch cells: neutral frame; the confirmed pin goes bound. */ +.lpb-cell--swatch .lpb-cell-rect { fill: rgba(138, 143, 152, 0.1); stroke: #3a3f48; } +.lpb-cell--swatch-selected .lpb-cell-rect { fill: var(--studio-status-bound-bg); stroke: var(--studio-status-bound-border); stroke-width: 1.4; } +.lpb-swatch-px { stroke: rgba(255, 255, 255, 0.12); stroke-width: 0.5; } + +/* Anatomy annotations (the design-language figure). */ +.lpb-anat-line { + stroke: #e4c065; + stroke-width: 0.9; + stroke-dasharray: 2 2.5; + opacity: 0.65; + fill: none; +} + +.lpb-anat-dot { + fill: #e4c065; +} + +.lpb-anat-label { + font-family: var(--studio-font-sans); + font-weight: 700; + font-size: 8px; + fill: #e4c065; +} + +.lpb-anat-shape { + stroke: #e4c065; + fill: none; + stroke-width: 1; + stroke-dasharray: 3 2.5; + opacity: 0.8; +} + +.lpb-anat-solid { + stroke-dasharray: none; +} diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__lg.png new file mode 100644 index 000000000..4d16e945d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__md.png new file mode 100644 index 000000000..7a7c97ce8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__sm.png new file mode 100644 index 000000000..8b324d705 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__anatomy__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__lg.png new file mode 100644 index 000000000..f19e0f6a1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__md.png new file mode 100644 index 000000000..441cf9450 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__sm.png new file mode 100644 index 000000000..78613f858 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-largest-devkitc-v4__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__lg.png new file mode 100644 index 000000000..d010cfffb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__md.png new file mode 100644 index 000000000..820c8d501 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__sm.png new file mode 100644 index 000000000..0a5bb5f2e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-smallest-xiao__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__lg.png new file mode 100644 index 000000000..667ff1c35 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__md.png new file mode 100644 index 000000000..16faeae6a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__sm.png new file mode 100644 index 000000000..6f80ec6ce Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u12__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__lg.png new file mode 100644 index 000000000..1e3305649 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__md.png new file mode 100644 index 000000000..78352ab35 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__sm.png new file mode 100644 index 000000000..2b0c94616 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__caps-u13__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__lg.png new file mode 100644 index 000000000..2e40c3716 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__md.png new file mode 100644 index 000000000..aacc58a17 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__sm.png new file mode 100644 index 000000000..f993033ae Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__catalog-thumbnails__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__lg.png new file mode 100644 index 000000000..ffa5246e3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__md.png new file mode 100644 index 000000000..882de36eb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__sm.png new file mode 100644 index 000000000..bdf8aa6c1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u12__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__lg.png new file mode 100644 index 000000000..db6673be9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__md.png new file mode 100644 index 000000000..06d5674ed Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__sm.png new file mode 100644 index 000000000..b33a4298a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__plain-u13__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__lg.png new file mode 100644 index 000000000..22ac6d0d1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__md.png new file mode 100644 index 000000000..5a6e8a196 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__sm.png new file mode 100644 index 000000000..56027f983 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-confirmed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__lg.png new file mode 100644 index 000000000..7ac6f2df2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__md.png new file mode 100644 index 000000000..1e6a15a47 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__sm.png new file mode 100644 index 000000000..e00b546c4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u12__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__lg.png new file mode 100644 index 000000000..c04362ca4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__md.png new file mode 100644 index 000000000..7c1276634 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__sm.png new file mode 100644 index 000000000..5543f7420 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__swatch-u13__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__lg.png new file mode 100644 index 000000000..ba764abd8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__md.png new file mode 100644 index 000000000..d615882e0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__sm.png new file mode 100644 index 000000000..50a003d0d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-band-quinled__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__lg.png new file mode 100644 index 000000000..7b59f3c33 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__md.png new file mode 100644 index 000000000..89862d748 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__sm.png new file mode 100644 index 000000000..5b49ea5fd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-domraem-4ch__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__lg.png new file mode 100644 index 000000000..550717391 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__md.png new file mode 100644 index 000000000..7a207ab23 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__sm.png new file mode 100644 index 000000000..110fab5c7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u12__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__lg.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__lg.png new file mode 100644 index 000000000..3b20c9b4f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__md.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__md.png new file mode 100644 index 000000000..be5874596 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__sm.png b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__sm.png new file mode 100644 index 000000000..9ccdb7f54 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__board-diagram__wired-u13__sm.png differ diff --git a/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json b/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json new file mode 100644 index 000000000..8fe38bbc0 --- /dev/null +++ b/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json @@ -0,0 +1,46 @@ +{ + "board_id": "domraem/dom-z-102", + "display_name": "WLED LAN 4-Channel (DOM-Z-102)", + "manufacturer": "DOMRAEM", + "soc": "ESP32", + "family": "esp32", + "flash": "4 MB", + "price_usd": 30.0, + "tier": "bronze", + "capabilities": ["Ethernet", "Wi-Fi", "4 fused outputs", "30 A max", "5-24 V input", "DIN rail"], + "blurb": "Ethernet + Wi-Fi controller in a DIN-rail case: four fused 7.5 A channels, screw terminals throughout, 5-24 V input. WROOM-32E inside.", + "support_note": "Runs when classic ESP32 (v3) firmware lands — in progress.", + "purchase_urls": [ + { "label": "Amazon", "href": "https://www.amazon.com/dp/B0GH7LPNQV" } + ], + "hw": { + "width": 150, + "module": { "x": 62, "y": 8, "w": 60, "h": 54, "label": "WROOM-32E", "antenna": true }, + "buttons": [ + { "x": 18, "y": 46, "label": "OPT" }, + { "x": 18, "y": 62, "label": "RST" } + ], + "left": [ + { "label": "GND", "role": "gnd" }, + { "label": "GND", "role": "gnd" }, + { "label": "V+", "role": "pwr5", "caps": [ { "text": "5-24V", "kind": "pwr" } ] }, + { "label": "V+", "role": "pwr5" } + ], + "right": [ + { "label": "IO13", "role": "rsvd", "gpio": 13 }, + { "label": "GND", "role": "gnd" }, + { "label": "IO18", "role": "io", "gpio": 18, "caps": [ { "text": "data", "kind": "note" } ] }, + { "label": "IO16", "role": "io", "gpio": 16, "caps": [ { "text": "data", "kind": "note" } ] }, + { "label": "IO14", "role": "io", "gpio": 14, "caps": [ { "text": "data", "kind": "note" } ] }, + { "label": "IO2", "role": "io", "gpio": 2, "caps": [ { "text": "data", "kind": "note" } ] }, + { "label": "GND", "role": "gnd" }, + { "label": "GND", "role": "gnd" }, + { "label": "GND", "role": "gnd" }, + { "label": "GND", "role": "gnd" }, + { "label": "V+", "role": "pwr5" }, + { "label": "V+", "role": "pwr5" }, + { "label": "V+", "role": "pwr5" }, + { "label": "V+", "role": "pwr5" } + ] + } +} diff --git a/schemas/board-display.schema.json b/schemas/board-display.schema.json index e0ecad1ce..a6633d720 100644 --- a/schemas/board-display.schema.json +++ b/schemas/board-display.schema.json @@ -1,7 +1,7 @@ { "$defs": { "BoardDrawing": { - "description": "The drawing block the row-engine diagram renderer consumes.\n\nGeometry is in board-local units; the renderer derives every other size\nfrom the pin pitch (`u`). See `spikes/hardware-boards/DESIGN-LANGUAGE.md`\n(moving to `docs/design/board-diagrams.md` with the renderer milestone).", + "description": "The drawing block the row-engine diagram renderer consumes.\n\nGeometry is in board-local units; the renderer derives every other size\nfrom the pin pitch (`u`). See `docs/design/board-diagrams.md`.", "properties": { "buttons": { "items": {