From 634f5ce8dd93876368574834c237415c06f012f8 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:28:03 -0700 Subject: [PATCH 1/7] feat(lpa-boards): usb_bridge metadata + per-OS driver guidance (D5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver need is a property of the USB bridge chip, not the board: boards declare their bridge (usb_bridge sidecar field, set only when verified) and usb_bridge.rs owns the facts — display name, VID:PID, and per-OS DriverGuidance with the hardware-verified CH340K macOS procedure embedded verbatim. VID:PID enables connect-time matching before board identity is known. dom-z-102 = ch340k (verified on hardware by the classic-ESP32 bring-up session); native-USB boards annotated; QuinLED and DevKitC v4 stay unset until their bridge variants are verified. Plan: 2026-07-31-1002-hardware-board-selection (M3 phase 1, decision D5) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-boards/src/display_manifest.rs | 8 + lp-app/lpa-boards/src/lib.rs | 2 + lp-app/lpa-boards/src/usb_bridge.rs | 231 ++++++++++++++++++ .../boards/domraem/dom-z-102.display.json | 1 + .../espressif/esp32-c6-devkitc-1.display.json | 1 + .../espressif/esp32-s3-devkitc-1.display.json | 1 + .../boards/seeed/xiao-esp32-c6.display.json | 1 + .../seeed/xiao-esp32-s3-plus.display.json | 1 + schemas/board-display.schema.json | 46 ++++ 9 files changed, 292 insertions(+) create mode 100644 lp-app/lpa-boards/src/usb_bridge.rs diff --git a/lp-app/lpa-boards/src/display_manifest.rs b/lp-app/lpa-boards/src/display_manifest.rs index 13f97c93e..ca5152112 100644 --- a/lp-app/lpa-boards/src/display_manifest.rs +++ b/lp-app/lpa-boards/src/display_manifest.rs @@ -10,6 +10,8 @@ use std::fmt; use serde::{Deserialize, Serialize}; +use crate::usb_bridge::UsbBridge; + /// One board's display sidecar (`/.display.json`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] @@ -41,6 +43,11 @@ pub struct BoardDisplayFile { pub support_note: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub purchase_urls: Vec, + /// The USB-UART bridge the board's USB connector goes through — drives + /// per-OS driver warnings (plan decision D5). Set only when verified; + /// omitted = unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usb_bridge: Option, pub hw: BoardDrawing, } @@ -327,6 +334,7 @@ mod tests { blurb: "A board.".into(), support_note: None, purchase_urls: vec![], + usb_bridge: None, hw: BoardDrawing { width: 100.0, module: DrawnModule { diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs index b5c86eb80..d0f6468a2 100644 --- a/lp-app/lpa-boards/src/lib.rs +++ b/lp-app/lpa-boards/src/lib.rs @@ -16,6 +16,7 @@ mod catalog; mod diagram; mod display_manifest; pub mod geometry; +pub mod usb_bridge; pub use catalog::{DISPLAY_MANIFEST_SOURCES, all_boards, board_by_id}; #[cfg(feature = "diagram")] @@ -25,3 +26,4 @@ pub use display_manifest::{ DrawnRgb, DrawnTerminal, DrawnUsb, PinCap, PinRole, PurchaseUrl, SupportTier, }; pub use geometry::{DiagramMode, DiagramOptions, PinSwatch, WiredConnection}; +pub use usb_bridge::{DriverGuidance, DriverNeedLevel, HostOs, UsbBridge}; diff --git a/lp-app/lpa-boards/src/usb_bridge.rs b/lp-app/lpa-boards/src/usb_bridge.rs new file mode 100644 index 000000000..4a9cbfe71 --- /dev/null +++ b/lp-app/lpa-boards/src/usb_bridge.rs @@ -0,0 +1,231 @@ +//! USB-UART bridge chips and their per-OS driver needs. +//! +//! Driver need is a property of the bridge chip, not the board — so boards +//! declare which bridge they carry (`BoardDisplayFile::usb_bridge`) and this +//! module owns the facts: display name, USB VID:PID, and what each OS needs +//! before the board shows up as a serial port. Decision D5 in the +//! hardware-board-selection plan (2026-07-31), driven by a real failure: a +//! CH340K board enumerates on macOS but exposes NO /dev port — silence a +//! non-expert cannot tell from a dead board. +//! +//! The VID:PID pairs are here on purpose: connect-time surfaces can map a +//! browser-serial vid/pid to guidance *before* the board's identity is known. +//! +//! Authoring policy applies: set `usb_bridge` on a sidecar only when the +//! chip is verified (silkscreen, vendor docs, or a real enumeration). + +use serde::{Deserialize, Serialize}; + +/// The USB-UART bridge (or native USB) a board's USB connector goes through. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum UsbBridge { + /// The SoC's own USB-Serial-JTAG peripheral (S3, C6). No bridge chip, + /// no driver anywhere. + NativeUsbJtag, + /// WCH CH340G — covered by Apple's built-in driver on modern macOS. + #[serde(rename = "ch340g")] + Ch340G, + /// WCH CH340C — covered by Apple's built-in driver on modern macOS. + #[serde(rename = "ch340c")] + Ch340C, + /// WCH CH340K — NOT covered by Apple's built-in driver (it matches only + /// 0x7523 and CH9102F); needs WCH's DriverKit extension on macOS. + #[serde(rename = "ch340k")] + Ch340K, + /// WCH CH9102F — covered by Apple's built-in driver on modern macOS. + #[serde(rename = "ch9102f")] + Ch9102F, + /// Silicon Labs CP2102 — driverless on modern macOS. + Cp2102, +} + +/// How loudly a driver situation needs to be surfaced. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DriverNeedLevel { + /// Works out of the box. + Ok, + /// May need a vendor driver depending on system particulars. + Info, + /// Will not appear as a serial port until the user acts. Surface + /// pre-purchase and at connect time. + Warning, +} + +/// The OS the guidance is for. Callers detect and pass it; this crate stays +/// platform-blind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostOs { + MacOs, + Windows, + Linux, + Other, +} + +/// What one OS needs for one bridge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DriverGuidance { + pub level: DriverNeedLevel, + /// One-line status ("needs the WCH driver — the board is invisible + /// without it"). + pub summary: &'static str, + /// Ordered instructions. Empty when level is `Ok`. + pub steps: &'static [&'static str], +} + +const OK: DriverGuidance = DriverGuidance { + level: DriverNeedLevel::Ok, + summary: "works out of the box — no driver needed", + steps: &[], +}; + +/// The macOS CH340K procedure, verified end-to-end on real hardware +/// 2026-07-31 (classic-ESP32 bring-up session). Keep verbatim: the +/// open-the-app activation step is the part everyone misses. +const CH340K_MACOS: DriverGuidance = DriverGuidance { + level: DriverNeedLevel::Warning, + summary: "invisible on macOS without the WCH driver — the board enumerates but no serial port appears", + steps: &[ + "Install the driver: `brew install --cask wch-ch34x-usb-serial-driver` (or WCH's CH34XSER_MAC package from wch.cn)", + "Open /Applications/CH34xVCPDriver.app — launching the app is what requests activation; nothing appears in System Settings until you do", + "System Settings → General → Login Items & Extensions → Driver Extensions → enable CH34xVCPDriver", + "Replug the board — the port appears as /dev/cu.wchusbserial*", + "If macOS warns that extensions signed by 'Nanjing Qinheng Microelectronics' \"need to be updated\", ignore it — that refers to the legacy kext in the package; the DriverKit extension is what loads", + ], +}; + +impl UsbBridge { + pub fn display_name(self) -> &'static str { + match self { + Self::NativeUsbJtag => "native USB-Serial-JTAG", + Self::Ch340G => "CH340G", + Self::Ch340C => "CH340C", + Self::Ch340K => "CH340K", + Self::Ch9102F => "CH9102F", + Self::Cp2102 => "CP2102", + } + } + + /// USB (vendor id, product id) as the chip enumerates. + pub fn vid_pid(self) -> (u16, u16) { + match self { + // Espressif USB-Serial-JTAG. + Self::NativeUsbJtag => (0x303A, 0x1001), + // WCH: CH340G and CH340C share a product id; CH340K differs — + // which is exactly why Apple's driver misses it. + Self::Ch340G | Self::Ch340C => (0x1A86, 0x7523), + Self::Ch340K => (0x1A86, 0x7522), + Self::Ch9102F => (0x1A86, 0x55D4), + // Silicon Labs. + Self::Cp2102 => (0x10C4, 0xEA60), + } + } + + /// The bridge a (vid, pid) pair enumerated as, for connect-time guidance + /// before the board is identified. Shared product ids resolve to the + /// family representative (CH340G for 0x7523) — the guidance is identical. + pub fn from_vid_pid(vid: u16, pid: u16) -> Option { + match (vid, pid) { + (0x303A, 0x1001) => Some(Self::NativeUsbJtag), + (0x1A86, 0x7523) => Some(Self::Ch340G), + (0x1A86, 0x7522) => Some(Self::Ch340K), + (0x1A86, 0x55D4) => Some(Self::Ch9102F), + (0x10C4, 0xEA60) => Some(Self::Cp2102), + _ => None, + } + } + + /// What `os` needs before this bridge shows up as a serial port. + /// + /// Facts encoded only where verified: the CH340K macOS procedure ran + /// end-to-end on hardware; Apple's built-in driver covers 0x7523 and + /// CH9102F; CP2102 is driverless on modern macOS. Windows/Linux entries + /// stay at `Info` with no steps until someone verifies a procedure. + pub fn guidance(self, os: HostOs) -> DriverGuidance { + match (self, os) { + (Self::NativeUsbJtag, _) => OK, + (Self::Ch340K, HostOs::MacOs) => CH340K_MACOS, + (Self::Ch340G | Self::Ch340C | Self::Ch9102F | Self::Cp2102, HostOs::MacOs) => OK, + // Linux ships ch341/cp210x kernel modules. + (_, HostOs::Linux) => OK, + (Self::Ch340G | Self::Ch340C | Self::Ch340K, HostOs::Windows) => DriverGuidance { + level: DriverNeedLevel::Info, + summary: "may need the WCH CH340/CH341 driver from wch.cn on Windows", + steps: &[], + }, + (Self::Ch9102F, HostOs::Windows) => DriverGuidance { + level: DriverNeedLevel::Info, + summary: "may need the WCH CH9102 driver from wch.cn on Windows", + steps: &[], + }, + (Self::Cp2102, HostOs::Windows) => DriverGuidance { + level: DriverNeedLevel::Info, + summary: "may need the Silicon Labs CP210x VCP driver on Windows", + steps: &[], + }, + (_, HostOs::Other) => DriverGuidance { + level: DriverNeedLevel::Info, + summary: "driver needs unknown on this platform", + steps: &[], + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ch340k_is_a_macos_warning_with_steps() { + let guidance = UsbBridge::Ch340K.guidance(HostOs::MacOs); + assert_eq!(guidance.level, DriverNeedLevel::Warning); + assert!(!guidance.steps.is_empty()); + } + + #[test] + fn apple_covered_bridges_are_ok_on_macos() { + for bridge in [ + UsbBridge::NativeUsbJtag, + UsbBridge::Ch340G, + UsbBridge::Ch340C, + UsbBridge::Ch9102F, + UsbBridge::Cp2102, + ] { + assert_eq!(bridge.guidance(HostOs::MacOs).level, DriverNeedLevel::Ok); + } + } + + #[test] + fn vid_pid_round_trips_to_guidance_equivalent_bridges() { + for bridge in [ + UsbBridge::NativeUsbJtag, + UsbBridge::Ch340G, + UsbBridge::Ch340K, + UsbBridge::Ch9102F, + UsbBridge::Cp2102, + ] { + let (vid, pid) = bridge.vid_pid(); + let resolved = UsbBridge::from_vid_pid(vid, pid).expect("known pair resolves"); + // Same guidance on every OS — shared-pid families may resolve to + // a representative variant. + for os in [HostOs::MacOs, HostOs::Windows, HostOs::Linux, HostOs::Other] { + assert_eq!(resolved.guidance(os), bridge.guidance(os)); + } + } + assert_eq!(UsbBridge::from_vid_pid(0xFFFF, 0xFFFF), None); + } + + #[test] + fn serializes_kebab_case() { + assert_eq!( + serde_json::to_string(&UsbBridge::Ch340K).unwrap(), + "\"ch340k\"" + ); + assert_eq!( + serde_json::from_str::("\"native-usb-jtag\"").unwrap(), + UsbBridge::NativeUsbJtag + ); + } +} 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 index b4c7904c3..0837709b6 100644 --- a/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json +++ b/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json @@ -13,6 +13,7 @@ "purchase_urls": [ { "label": "Amazon", "href": "https://www.amazon.com/dp/B0GH7LPNQV" } ], + "usb_bridge": "ch340k", "hw": { "width": 150, "module": { "x": 62, "y": 8, "w": 60, "h": 54, "label": "WROOM-32E", "antenna": true }, diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json index 57f152106..dc889b199 100644 --- a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json +++ b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json @@ -13,6 +13,7 @@ { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-c6-devkitc-1" }, { "label": "DigiKey", "href": "https://www.digikey.com/en/products/detail/espressif-systems/ESP32-C6-DEVKITC-1-N8/17728861" } ], + "usb_bridge": "native-usb-jtag", "hw": { "width": 112, "module": { "x": 16, "y": 8, "w": 80, "h": 66, "label": "ESP32-C6", "antenna": true }, diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json index 70cbab913..b9b4c5342 100644 --- a/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json +++ b/lp-core/lpc-hardware/boards/espressif/esp32-s3-devkitc-1.display.json @@ -15,6 +15,7 @@ { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-s3-devkitc-1" }, { "label": "Adafruit", "href": "https://www.adafruit.com/product/5312" } ], + "usb_bridge": "native-usb-jtag", "hw": { "width": 120, "module": { "x": 18, "y": 8, "w": 84, "h": 74, "label": "ESP32-S3", "antenna": true }, diff --git a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json index 5c5ce3e23..957777cc7 100644 --- a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-c6.display.json @@ -12,6 +12,7 @@ "purchase_urls": [ { "label": "Seeed", "href": "https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32C6-p-5884.html" } ], + "usb_bridge": "native-usb-jtag", "hw": { "width": 76, "module": { "x": 14, "y": 6, "w": 48, "h": 30, "label": "C6", "antenna": false }, diff --git a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json index f565dfb7b..10d0e2946 100644 --- a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json @@ -14,6 +14,7 @@ "purchase_urls": [ { "label": "Seeed", "href": "https://www.seeedstudio.com/Seeed-Studio-XIAO-ESP32S3-Plus-p-6361.html" } ], + "usb_bridge": "native-usb-jtag", "hw": { "width": 76, "module": { "x": 14, "y": 6, "w": 48, "h": 30, "label": "S3", "antenna": false }, diff --git a/schemas/board-display.schema.json b/schemas/board-display.schema.json index a6633d720..898c2deee 100644 --- a/schemas/board-display.schema.json +++ b/schemas/board-display.schema.json @@ -339,6 +339,41 @@ "type": "string" } ] + }, + "UsbBridge": { + "description": "The USB-UART bridge (or native USB) a board's USB connector goes through.", + "oneOf": [ + { + "const": "native-usb-jtag", + "description": "The SoC's own USB-Serial-JTAG peripheral (S3, C6). No bridge chip,\nno driver anywhere.", + "type": "string" + }, + { + "const": "ch340g", + "description": "WCH CH340G — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "ch340c", + "description": "WCH CH340C — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "ch340k", + "description": "WCH CH340K — NOT covered by Apple's built-in driver (it matches only\n0x7523 and CH9102F); needs WCH's DriverKit extension on macOS.", + "type": "string" + }, + { + "const": "ch9102f", + "description": "WCH CH9102F — covered by Apple's built-in driver on modern macOS.", + "type": "string" + }, + { + "const": "cp2102", + "description": "Silicon Labs CP2102 — driverless on modern macOS.", + "type": "string" + } + ] } }, "$id": "https://lightplayer.dev/schemas/board-display.schema.json", @@ -405,6 +440,17 @@ }, "tier": { "$ref": "#/$defs/SupportTier" + }, + "usb_bridge": { + "anyOf": [ + { + "$ref": "#/$defs/UsbBridge" + }, + { + "type": "null" + } + ], + "description": "The USB-UART bridge the board's USB connector goes through — drives\nper-OS driver warnings (plan decision D5). Set only when verified;\nomitted = unknown." } }, "required": [ From 454f4be1195f09b23cd22202a0a702f406be8eca Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 17:37:55 -0700 Subject: [PATCH 2/7] feat(lpa-boards,studio-web): #/boards catalog page + route BoardsCatalogPage renders the embedded sidecars through BoardDiagram: tier legend, recommended/price/name sort, SoC family chips derived from data, cards with thumbnail/tier/price/specs/blurb/purchase links, and the D5 driver warning (attention-level chip + expandable verified steps) for bridges the detected OS can't see. StudioRoute::Boards follows the mapping-editor standalone-page pattern at all four match sites; OS detection stays at the studio-web platform edge. Plan: 2026-07-31-1002-hardware-board-selection (M3 phases 2-3) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-boards/src/catalog_page.rs | 223 ++++++++++++ lp-app/lpa-boards/src/lib.rs | 4 + .../src/app/board_diagram_stories.rs | 15 +- lp-app/lpa-studio-web/src/router.rs | 6 + lp-app/lpa-studio-web/src/style.css | 318 ++++++++++++++++++ lp-app/lpa-studio-web/src/web_app.rs | 51 ++- 6 files changed, 609 insertions(+), 8 deletions(-) create mode 100644 lp-app/lpa-boards/src/catalog_page.rs diff --git a/lp-app/lpa-boards/src/catalog_page.rs b/lp-app/lpa-boards/src/catalog_page.rs new file mode 100644 index 000000000..4dcb2a835 --- /dev/null +++ b/lp-app/lpa-boards/src/catalog_page.rs @@ -0,0 +1,223 @@ +//! `BoardsCatalogPage`: the public "what should I buy" page (`#/boards`). +//! +//! Boundary: catalog data in, nothing out. The page renders the embedded +//! display sidecars ([`crate::all_boards`]) through [`BoardDiagram`] and +//! knows nothing about projects, devices, or studio state. The host app +//! passes the detected [`HostOs`] so driver warnings (plan decision D5) stay +//! platform-blind here. Styling rides `lpb-cat-*` classes owned by the +//! consuming app's stylesheet. + +use dioxus::prelude::*; + +use crate::display_manifest::{BoardDisplayFile, SupportTier}; +use crate::geometry::DiagramMode; +use crate::usb_bridge::{DriverNeedLevel, HostOs}; +use crate::{BoardDiagram, all_boards}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SortBy { + Recommended, + Price, + Name, +} + +fn tier_rank(tier: SupportTier) -> u8 { + match tier { + SupportTier::Gold => 0, + SupportTier::Silver => 1, + SupportTier::Bronze => 2, + } +} + +fn tier_label(tier: SupportTier) -> &'static str { + match tier { + SupportTier::Gold => "gold", + SupportTier::Silver => "silver", + SupportTier::Bronze => "bronze", + } +} + +/// `family` value → the human SoC name shown on its filter chip, taken from +/// the first board carrying the family. +fn family_chips() -> Vec<(String, String)> { + let mut chips: Vec<(String, String)> = Vec::new(); + for board in all_boards() { + if !chips.iter().any(|(family, _)| *family == board.family) { + chips.push((board.family.clone(), board.soc.clone())); + } + } + chips +} + +/// The whole catalog page. `os` drives which driver warnings show — hosts +/// detect it at the platform edge and pass it in. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn BoardsCatalogPage(os: HostOs) -> Element { + let mut sort_by = use_signal(|| SortBy::Recommended); + let mut family_filter = use_signal(|| Option::::None); + + let mut boards: Vec<&'static BoardDisplayFile> = all_boards() + .iter() + .filter(|board| { + family_filter() + .map(|family| board.family == family) + .unwrap_or(true) + }) + .collect(); + match sort_by() { + SortBy::Recommended => boards.sort_by(|a, b| { + tier_rank(a.tier) + .cmp(&tier_rank(b.tier)) + .then(a.price_usd.total_cmp(&b.price_usd)) + }), + SortBy::Price => boards.sort_by(|a, b| a.price_usd.total_cmp(&b.price_usd)), + SortBy::Name => boards.sort_by(|a, b| a.display_name.cmp(&b.display_name)), + } + + rsx! { + div { class: "lpb-cat-page", + header { class: "lpb-cat-header", + h1 { "Supported boards" } + p { class: "lpb-cat-sub", + "Every drawing renders from the same checked-in metadata that drives provisioning and pin discovery — no hand-drawn images." + } + } + div { class: "lpb-cat-tier-legend", + span { class: "lpb-cat-def", + span { class: "lpb-cat-tier lpb-cat-tier--gold", "Gold" } + "first-class, tested every release" + } + span { class: "lpb-cat-def", + span { class: "lpb-cat-tier lpb-cat-tier--silver", "Silver" } + "supported, tested occasionally" + } + span { class: "lpb-cat-def", + span { class: "lpb-cat-tier lpb-cat-tier--bronze", "Bronze" } + "community-verified, should work" + } + } + div { class: "lpb-cat-controls", + span { class: "lpb-cat-group", + b { "Sort" } + select { + class: "lpb-cat-select", + onchange: move |event| { + sort_by.set(match event.value().as_str() { + "price" => SortBy::Price, + "name" => SortBy::Name, + _ => SortBy::Recommended, + }); + }, + option { value: "rec", "Recommended" } + option { value: "price", "Price ↑" } + option { value: "name", "Name" } + } + } + span { class: "lpb-cat-group", + b { "SoC" } + span { class: "lpb-cat-chiprow", + button { + class: "lpb-cat-fchip", + "aria-pressed": if family_filter().is_none() { "true" } else { "false" }, + onclick: move |_| family_filter.set(None), + "All" + } + for (family, soc) in family_chips() { + button { + class: "lpb-cat-fchip", + "aria-pressed": if family_filter().as_deref() == Some(family.as_str()) { "true" } else { "false" }, + onclick: { + let family = family.clone(); + move |_| family_filter.set(Some(family.clone())) + }, + "{soc}" + } + } + } + } + } + div { class: "lpb-cat-grid", + for board in boards { + BoardCard { board: board.clone(), os } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn BoardCard(board: BoardDisplayFile, os: HostOs) -> Element { + let price = if board.price_usd.fract() == 0.0 { + format!("${:.0}", board.price_usd) + } else { + format!("${:.2}", board.price_usd) + }; + let driver = board + .usb_bridge + .map(|bridge| (bridge, bridge.guidance(os))) + .filter(|(_, guidance)| guidance.level == DriverNeedLevel::Warning); + + rsx! { + article { class: "lpb-cat-card", + div { class: "lpb-cat-figure", + span { class: "lpb-cat-tier lpb-cat-tier--{tier_label(board.tier)}", + "{tier_label(board.tier)}" + } + span { class: "lpb-cat-price", "{price}" } + BoardDiagram { + board: board.clone(), + mode: DiagramMode::Plain, + scale: 0.6, + labels: false, + } + } + div { class: "lpb-cat-info", + div { class: "lpb-cat-title", + span { class: "lpb-cat-name", "{board.display_name}" } + span { class: "lpb-cat-mfr", "{board.manufacturer}" } + } + div { class: "lpb-cat-specs", + span { class: "lpb-cat-spec lpb-cat-spec--soc", "{board.soc}" } + span { class: "lpb-cat-spec", "{board.flash} flash" } + if let Some(psram) = &board.psram { + span { class: "lpb-cat-spec", "{psram} psram" } + } + for capability in board.capabilities.iter() { + span { class: "lpb-cat-spec", "{capability}" } + } + } + if let Some((bridge, guidance)) = driver { + details { class: "lpb-cat-driver-warn", + summary { + span { class: "lpb-cat-driver-badge", "driver required" } + "{guidance.summary}" + } + ol { + for step in guidance.steps { + li { "{step}" } + } + } + span { class: "lpb-cat-driver-chip-note", "bridge: {bridge.display_name()}" } + } + } + p { class: "lpb-cat-blurb", "{board.blurb}" } + if let Some(note) = &board.support_note { + p { class: "lpb-cat-support-note", "{note}" } + } + div { class: "lpb-cat-links", + for url in board.purchase_urls.iter() { + a { + class: "lpb-cat-buy", + href: "{url.href}", + target: "_blank", + rel: "noopener", + "{url.label} ↗" + } + } + } + } + } + } +} diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs index d0f6468a2..da545254f 100644 --- a/lp-app/lpa-boards/src/lib.rs +++ b/lp-app/lpa-boards/src/lib.rs @@ -13,6 +13,8 @@ mod catalog; #[cfg(feature = "diagram")] +mod catalog_page; +#[cfg(feature = "diagram")] mod diagram; mod display_manifest; pub mod geometry; @@ -20,6 +22,8 @@ pub mod usb_bridge; pub use catalog::{DISPLAY_MANIFEST_SOURCES, all_boards, board_by_id}; #[cfg(feature = "diagram")] +pub use catalog_page::BoardsCatalogPage; +#[cfg(feature = "diagram")] pub use diagram::{BoardDiagram, DiagramMargin}; pub use display_manifest::{ BoardDisplayError, BoardDisplayFile, BoardDrawing, CapKind, DrawnButton, DrawnModule, DrawnPin, 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 index f5ba49538..de724d224 100644 --- a/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs +++ b/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs @@ -10,8 +10,8 @@ use dioxus::prelude::*; use lpa_boards::geometry::{BoardLayout, DiagramOptions}; use lpa_boards::{ - BoardDiagram, BoardDisplayFile, DiagramMargin, DiagramMode, PinSwatch, WiredConnection, - all_boards, board_by_id, + BoardDiagram, BoardDisplayFile, BoardsCatalogPage, DiagramMargin, DiagramMode, HostOs, + PinSwatch, WiredConnection, all_boards, board_by_id, }; use lpa_studio_web_story_macros::story; @@ -352,6 +352,17 @@ pub(crate) fn swatch_confirmed() -> Element { } } +#[story( + description = "The #/boards catalog page: tier legend, sort + SoC filter controls, cards with renderer thumbnails — and the D5 driver warning on the CH340K board (rendered as on macOS)." +)] +pub(crate) fn catalog_page_macos() -> Element { + rsx! { + div { style: "max-width: 1080px;", + BoardsCatalogPage { os: HostOs::MacOs } + } + } +} + // ---- annotated anatomy --------------------------------------------------- /// Estimated bbox of an end-anchored pin label (the layout stores anchor + diff --git a/lp-app/lpa-studio-web/src/router.rs b/lp-app/lpa-studio-web/src/router.rs index 4be77f178..fd1e5a7f7 100644 --- a/lp-app/lpa-studio-web/src/router.rs +++ b/lp-app/lpa-studio-web/src/router.rs @@ -86,6 +86,9 @@ pub(crate) enum StudioRoute { /// The standalone 2D mapping editor (project-free; edits /// `.map2d.json` documents with localStorage autosave). MappingEditor, + /// The public boards catalog (project-free, renders the checked-in + /// board display metadata). + Boards, } #[cfg_attr( @@ -119,6 +122,7 @@ impl StudioRoute { _ => StudioRoute::Home, }, Some("mapping") if segments.next().is_none() => StudioRoute::MappingEditor, + Some("boards") if segments.next().is_none() => StudioRoute::Boards, Some("stories") => { let rest: Vec<&str> = segments.collect(); StudioRoute::Stories { @@ -139,6 +143,7 @@ impl StudioRoute { StudioRoute::Stories { story_id: None } => "#/stories".to_string(), StudioRoute::Stories { story_id: Some(id) } => format!("#/stories/{id}"), StudioRoute::MappingEditor => "#/mapping".to_string(), + StudioRoute::Boards => "#/boards".to_string(), } } @@ -370,6 +375,7 @@ mod tests { story_id: Some("base/detail-popover/open-sections".to_string()), }, StudioRoute::MappingEditor, + StudioRoute::Boards, ]; for route in routes { assert_eq!(StudioRoute::parse(&route.hash()), route, "{route:?}"); diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 115c607f7..75ccc2e5a 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -3175,3 +3175,321 @@ label:has(> input:not(:disabled)) { .lpb-anat-solid { stroke-dasharray: none; } + +/* ---- boards catalog page (#/boards, lpa-boards BoardsCatalogPage) ------ */ + +.lpb-cat-page { + max-width: 1340px; + margin: 0 auto; + padding: 28px 32px 120px; +} + +.lpb-cat-header h1 { + font-size: 13px; + letter-spacing: 0.12em; + color: var(--studio-color-text-muted); + text-transform: uppercase; + margin: 0 0 6px; +} + +.lpb-cat-sub { + color: var(--studio-color-text-dim); + font-size: 12px; + margin: 0 0 22px; + max-width: 720px; +} + +.lpb-cat-tier-legend { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + font-size: 11.5px; + color: var(--studio-color-text-dim); + margin: 0 0 16px; +} + +.lpb-cat-def { + display: inline-flex; + gap: 7px; + align-items: center; +} + +.lpb-cat-tier { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + border-radius: var(--studio-radius-pill); + padding: 2px 9px 2px 7px; + border: 1px solid transparent; +} + +.lpb-cat-tier::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; +} + +.lpb-cat-tier--gold { color: #e4c065; background: rgba(228, 192, 101, 0.1); border-color: rgba(228, 192, 101, 0.35); } +.lpb-cat-tier--silver { color: #b9c0cc; background: rgba(185, 192, 204, 0.09); border-color: rgba(185, 192, 204, 0.3); } +.lpb-cat-tier--bronze { color: #c98d5c; background: rgba(201, 141, 92, 0.1); border-color: rgba(201, 141, 92, 0.35); } + +.lpb-cat-controls { + display: flex; + gap: 18px; + flex-wrap: wrap; + align-items: center; + padding: 10px 14px; + border: 1px dashed var(--studio-color-border-strong); + border-radius: var(--studio-radius-md); + color: var(--studio-color-text-muted); + font-size: 12px; + margin-bottom: 16px; +} + +.lpb-cat-group { + display: inline-flex; + gap: 10px; + align-items: center; +} + +.lpb-cat-group b { + color: var(--studio-color-text); + font-weight: 600; +} + +.lpb-cat-select { + background: var(--studio-color-terminal); + color: var(--studio-color-text); + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-sm); + font-size: 12px; + padding: 3px 6px; +} + +.lpb-cat-chiprow { + display: inline-flex; + gap: 4px; + flex-wrap: wrap; +} + +.lpb-cat-fchip { + appearance: none; + border: 1px solid var(--studio-color-border-strong); + background: transparent; + color: var(--studio-color-text-muted); + font-size: 11.5px; + font-weight: 600; + border-radius: var(--studio-radius-pill); + padding: 3px 10px; +} + +.lpb-cat-fchip:hover { + color: var(--studio-color-text); +} + +.lpb-cat-fchip[aria-pressed="true"] { + color: var(--studio-status-bound-text); + border-color: var(--studio-status-bound-border); + background: var(--studio-status-bound-bg); +} + +.lpb-cat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + align-items: stretch; +} + +.lpb-cat-card { + background: var(--studio-color-surface); + border: 1px solid var(--studio-color-border); + border-radius: 10px; + overflow: hidden; + display: flex; + flex-direction: column; + transition: border-color 0.2s; +} + +.lpb-cat-card:hover { + border-color: var(--studio-color-border-strong); +} + +.lpb-cat-figure { + background: + radial-gradient(120% 90% at 50% 0%, rgba(167, 139, 250, 0.05), transparent 60%), + var(--studio-color-terminal); + border-bottom: 1px solid var(--studio-color-border); + display: flex; + align-items: center; + justify-content: center; + height: 170px; + padding: 12px; + position: relative; +} + +.lpb-cat-figure .lpb-diagram { + max-height: 100%; + max-width: 100%; +} + +.lpb-cat-figure .lpb-cat-tier { + position: absolute; + top: 8px; + left: 8px; +} + +.lpb-cat-price { + position: absolute; + top: 8px; + right: 8px; + font-family: var(--studio-font-mono); + font-size: 12px; + color: var(--studio-color-text-muted); + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--studio-color-border); + border-radius: var(--studio-radius-sm); + padding: 2px 7px; +} + +.lpb-cat-info { + padding: 10px 12px 12px; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.lpb-cat-title { + display: flex; + align-items: baseline; + gap: 8px; +} + +.lpb-cat-name { + font-weight: 700; + color: var(--studio-color-text-strong); + font-size: 14px; +} + +.lpb-cat-mfr { + color: var(--studio-color-text-dim); + font-size: 11.5px; +} + +.lpb-cat-specs { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.lpb-cat-spec { + font-family: var(--studio-font-mono); + font-size: 10.5px; + color: var(--studio-color-text-muted); + border: 1px solid var(--studio-color-border); + border-radius: 5px; + padding: 2px 6px; + background: rgba(255, 255, 255, 0.02); +} + +.lpb-cat-spec--soc { + color: #e4c065; + border-color: rgba(228, 192, 101, 0.3); +} + +/* Driver warning (plan D5): warning-level prominence, expandable steps. */ +.lpb-cat-driver-warn { + border: 1px solid var(--studio-status-attention-border); + background: var(--studio-status-attention-bg); + border-radius: var(--studio-radius-md); + padding: 6px 9px; + font-size: 11.5px; + color: var(--studio-color-text); +} + +.lpb-cat-driver-warn summary { + cursor: pointer; + display: flex; + gap: 7px; + align-items: baseline; + color: var(--studio-status-attention-text); + list-style: none; +} + +.lpb-cat-driver-warn summary::-webkit-details-marker { + display: none; +} + +.lpb-cat-driver-badge { + flex: none; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--studio-status-attention-border); + border-radius: var(--studio-radius-pill); + padding: 1px 7px; +} + +.lpb-cat-driver-warn ol { + margin: 8px 0 2px; + padding-left: 18px; + color: var(--studio-color-text-muted); + font-size: 11px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.lpb-cat-driver-chip-note { + display: block; + margin-top: 4px; + font-family: var(--studio-font-mono); + font-size: 10px; + color: var(--studio-color-text-dim); +} + +.lpb-cat-blurb { + color: var(--studio-color-text-muted); + font-size: 12px; + flex: 1; + margin: 0; +} + +.lpb-cat-support-note { + color: var(--studio-color-text-dim); + font-size: 11px; + margin: 0; +} + +.lpb-cat-links { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.lpb-cat-buy { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 600; + color: var(--studio-color-text-muted); + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-sm); + padding: 3px 9px; + text-decoration: none; +} + +.lpb-cat-buy:hover { + color: var(--studio-color-text-strong); + border-color: var(--studio-status-bound-border); +} diff --git a/lp-app/lpa-studio-web/src/web_app.rs b/lp-app/lpa-studio-web/src/web_app.rs index de0c94784..480815c5f 100644 --- a/lp-app/lpa-studio-web/src/web_app.rs +++ b/lp-app/lpa-studio-web/src/web_app.rs @@ -78,6 +78,17 @@ pub fn App() -> Element { }; } + // The boards catalog: same standalone-page pattern. The detected OS + // drives per-bridge driver warnings (plan D5) — detected here at the + // platform edge; lpa-boards stays platform-blind. + if matches!(router::current_route(), StudioRoute::Boards) { + return rsx! { + style { "{STYLE}" } + document::Stylesheet { href: asset!("/assets/tailwind.css") } + lpa_boards::BoardsCatalogPage { os: detect_host_os() } + }; + } + let mut view = use_signal(UiStudioView::empty); // The OpenRouter connect return leg (`?code=…`): consumed synchronously // BEFORE the router reads the URL — it scrubs the query and restores the @@ -393,11 +404,11 @@ pub fn App() -> Element { ))); } } - StudioRoute::Stories { .. } | StudioRoute::MappingEditor => { - // the story book and the mapping editor mount on fresh - // page loads only (their early returns in App run - // before any hooks); reload to keep the hook order - // sound + StudioRoute::Stories { .. } | StudioRoute::MappingEditor | StudioRoute::Boards => { + // the story book, mapping editor, and boards catalog + // mount on fresh page loads only (their early returns + // in App run before any hooks); reload to keep the + // hook order sound router::hard_reload(); } } @@ -458,7 +469,10 @@ pub fn App() -> Element { }, ))); } - StudioRoute::Home | StudioRoute::Stories { .. } | StudioRoute::MappingEditor => {} + StudioRoute::Home + | StudioRoute::Stories { .. } + | StudioRoute::MappingEditor + | StudioRoute::Boards => {} } // D32 auto-connect (M6): the load-time attach sweep — queued // AFTER the route dispatch, so a `#/device/` reload's own @@ -556,6 +570,31 @@ pub fn App() -> Element { /// The pull loop's per-request progress-deadline timer on wasm: a `setTimeout` /// via `gloo_timers`. The actor calls this to build each pull's quiet-gap /// deadline; native callers would pass a `sleep`-backed factory instead. +/// The OS the page runs on, for per-bridge driver guidance (plan D5). +/// User-agent sniffing is exactly the right tool here: the answer only +/// picks which driver instructions to show. +fn detect_host_os() -> lpa_boards::HostOs { + #[cfg(target_arch = "wasm32")] + { + let user_agent = web_sys::window() + .and_then(|window| window.navigator().user_agent().ok()) + .unwrap_or_default(); + if user_agent.contains("Mac") { + lpa_boards::HostOs::MacOs + } else if user_agent.contains("Win") { + lpa_boards::HostOs::Windows + } else if user_agent.contains("Linux") || user_agent.contains("X11") { + lpa_boards::HostOs::Linux + } else { + lpa_boards::HostOs::Other + } + } + #[cfg(not(target_arch = "wasm32"))] + { + lpa_boards::HostOs::Other + } +} + fn make_pull_timer(delay: Duration) -> TimeoutFuture { TimeoutFuture::new(delay.as_millis() as u32) } From e8fd8f8b1553cd142610a88fa695fcdad344b5d6 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 22:57:36 -0700 Subject: [PATCH 3/7] feat(lpa-boards): board detail view + notes; gate revisions from Yona's sketch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate feedback applied: - DOM-Z-102 corrected against the physical-board sketch: the top-right terminal is IO12 (relay, GPIO12) not IO13; buttons are BOOT/RESET; proportions widened toward the square DIN case (210 units). - Tier flips: XIAO S3 Plus -> gold, C6-DevKitC-1 -> silver; legend copy now reads as recommendation levels (definition proposal pending). - Card driver warning shrinks to a compact 'DRIVER REQUIRED · macOS' chip; the full verified steps move to the new detail view. - Board detail view: in-page selection deep-linked as #/boards// via replaceState (no events, so the standalone page keeps SPA behavior); caps pinout, driver section, and the minimal os-tagged note system (BoardNote { text, os, os_version }); DOM-Z-102 carries relay + stacked-button notes. Plan: 2026-07-31-1002-hardware-board-selection (M3, gate revisions) Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + lp-app/lpa-boards/Cargo.toml | 6 + lp-app/lpa-boards/src/catalog_page.rs | 249 +++++++++++++++--- lp-app/lpa-boards/src/display_manifest.rs | 50 ++++ lp-app/lpa-boards/src/lib.rs | 5 +- lp-app/lpa-boards/src/usb_bridge.rs | 11 + .../src/app/board_diagram_stories.rs | 14 + lp-app/lpa-studio-web/src/router.rs | 20 +- lp-app/lpa-studio-web/src/style.css | 164 +++++++++--- lp-app/lpa-studio-web/src/web_app.rs | 10 +- .../boards/domraem/dom-z-102.display.json | 14 +- .../espressif/esp32-c6-devkitc-1.display.json | 2 +- .../seeed/xiao-esp32-s3-plus.display.json | 2 +- schemas/board-display.schema.json | 45 ++++ 14 files changed, 504 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b9357ca7..c567df990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5572,6 +5572,8 @@ dependencies = [ "schemars", "serde", "serde_json", + "wasm-bindgen", + "web-sys", ] [[package]] diff --git a/lp-app/lpa-boards/Cargo.toml b/lp-app/lpa-boards/Cargo.toml index a579312da..37dc933a1 100644 --- a/lp-app/lpa-boards/Cargo.toml +++ b/lp-app/lpa-boards/Cargo.toml @@ -22,6 +22,12 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } schemars = { workspace = true, optional = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# Deep-link plumbing for the catalog's detail selection only (replaceState — +# fires no events). The components themselves stay DOM-free. +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["History", "Window"] } + [dev-dependencies] lpc-hardware = { path = "../../lp-core/lpc-hardware" } diff --git a/lp-app/lpa-boards/src/catalog_page.rs b/lp-app/lpa-boards/src/catalog_page.rs index 4dcb2a835..dc609048c 100644 --- a/lp-app/lpa-boards/src/catalog_page.rs +++ b/lp-app/lpa-boards/src/catalog_page.rs @@ -4,15 +4,21 @@ //! display sidecars ([`crate::all_boards`]) through [`BoardDiagram`] and //! knows nothing about projects, devices, or studio state. The host app //! passes the detected [`HostOs`] so driver warnings (plan decision D5) stay -//! platform-blind here. Styling rides `lpb-cat-*` classes owned by the -//! consuming app's stylesheet. +//! platform-blind here. Styling rides `lpb-cat-*` / `lpb-det-*` classes +//! owned by the consuming app's stylesheet. +//! +//! Detail is in-page selection, not routing: clicking a card swaps the grid +//! for [`BoardDetail`], and the hash is mirrored to +//! `#/boards//` via `replaceState` (fires no events) so +//! board pages stay deep-linkable — the host passes the initial selection +//! parsed from the URL. use dioxus::prelude::*; use crate::display_manifest::{BoardDisplayFile, SupportTier}; use crate::geometry::DiagramMode; -use crate::usb_bridge::{DriverNeedLevel, HostOs}; -use crate::{BoardDiagram, all_boards}; +use crate::usb_bridge::{DriverGuidance, DriverNeedLevel, HostOs}; +use crate::{BoardDiagram, all_boards, board_by_id}; #[derive(Clone, Copy, PartialEq, Eq)] enum SortBy { @@ -37,6 +43,22 @@ fn tier_label(tier: SupportTier) -> &'static str { } } +fn price_label(price_usd: f64) -> String { + if price_usd.fract() == 0.0 { + format!("${price_usd:.0}") + } else { + format!("${price_usd:.2}") + } +} + +/// The driver guidance worth surfacing for a board on `os`, if any. +fn driver_warning(board: &BoardDisplayFile, os: HostOs) -> Option { + board + .usb_bridge + .map(|bridge| bridge.guidance(os)) + .filter(|guidance| guidance.level == DriverNeedLevel::Warning) +} + /// `family` value → the human SoC name shown on its filter chip, taken from /// the first board carrying the family. fn family_chips() -> Vec<(String, String)> { @@ -49,13 +71,50 @@ fn family_chips() -> Vec<(String, String)> { chips } +/// Mirror the selection into the URL without firing any events — board +/// detail stays deep-linkable while the page keeps SPA behavior. +fn mirror_selection_hash(selected: Option<&str>) { + #[cfg(target_arch = "wasm32")] + { + let hash = match selected { + Some(board_id) => format!("#/boards/{board_id}"), + None => "#/boards".to_string(), + }; + if let Some(history) = web_sys::window().and_then(|window| window.history().ok()) { + let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&hash)); + } + } + #[cfg(not(target_arch = "wasm32"))] + let _ = selected; +} + /// The whole catalog page. `os` drives which driver warnings show — hosts -/// detect it at the platform edge and pass it in. +/// detect it at the platform edge and pass it in. `initial_board` is the +/// deep-linked selection parsed from the URL, if any. #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn BoardsCatalogPage(os: HostOs) -> Element { +pub fn BoardsCatalogPage(os: HostOs, #[props(default)] initial_board: Option) -> Element { let mut sort_by = use_signal(|| SortBy::Recommended); let mut family_filter = use_signal(|| Option::::None); + let mut selected = + use_signal(|| initial_board.filter(|board_id| board_by_id(board_id).is_some())); + + if let Some(board_id) = selected() { + let board = board_by_id(&board_id).expect("selection validated on set"); + return rsx! { + div { class: "lpb-cat-page", + button { + class: "lpb-det-back", + onclick: move |_| { + selected.set(None); + mirror_selection_hash(None); + }, + "← All boards" + } + BoardDetail { board: board.clone(), os } + } + }; + } let mut boards: Vec<&'static BoardDisplayFile> = all_boards() .iter() @@ -86,11 +145,11 @@ pub fn BoardsCatalogPage(os: HostOs) -> Element { div { class: "lpb-cat-tier-legend", span { class: "lpb-cat-def", span { class: "lpb-cat-tier lpb-cat-tier--gold", "Gold" } - "first-class, tested every release" + "recommended first — full feature set" } span { class: "lpb-cat-def", span { class: "lpb-cat-tier lpb-cat-tier--silver", "Silver" } - "supported, tested occasionally" + "supported — limits apply (features or testing)" } span { class: "lpb-cat-def", span { class: "lpb-cat-tier lpb-cat-tier--bronze", "Bronze" } @@ -139,7 +198,17 @@ pub fn BoardsCatalogPage(os: HostOs) -> Element { } div { class: "lpb-cat-grid", for board in boards { - BoardCard { board: board.clone(), os } + BoardCard { + board: board.clone(), + os, + on_open: { + let board_id = board.board_id.clone(); + move |_| { + selected.set(Some(board_id.clone())); + mirror_selection_hash(Some(&board_id)); + } + }, + } } } } @@ -148,36 +217,31 @@ pub fn BoardsCatalogPage(os: HostOs) -> Element { #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn BoardCard(board: BoardDisplayFile, os: HostOs) -> Element { - let price = if board.price_usd.fract() == 0.0 { - format!("${:.0}", board.price_usd) - } else { - format!("${:.2}", board.price_usd) - }; - let driver = board - .usb_bridge - .map(|bridge| (bridge, bridge.guidance(os))) - .filter(|(_, guidance)| guidance.level == DriverNeedLevel::Warning); +fn BoardCard(board: BoardDisplayFile, os: HostOs, on_open: EventHandler<()>) -> Element { + let price = price_label(board.price_usd); + let driver = driver_warning(&board, os); rsx! { article { class: "lpb-cat-card", - div { class: "lpb-cat-figure", - span { class: "lpb-cat-tier lpb-cat-tier--{tier_label(board.tier)}", - "{tier_label(board.tier)}" - } - span { class: "lpb-cat-price", "{price}" } - BoardDiagram { - board: board.clone(), - mode: DiagramMode::Plain, - scale: 0.6, - labels: false, + button { class: "lpb-cat-open", onclick: move |_| on_open.call(()), + div { class: "lpb-cat-figure", + span { class: "lpb-cat-tier lpb-cat-tier--{tier_label(board.tier)}", + "{tier_label(board.tier)}" + } + span { class: "lpb-cat-price", "{price}" } + BoardDiagram { + board: board.clone(), + mode: DiagramMode::Plain, + scale: 0.6, + labels: false, + } } - } - div { class: "lpb-cat-info", div { class: "lpb-cat-title", span { class: "lpb-cat-name", "{board.display_name}" } span { class: "lpb-cat-mfr", "{board.manufacturer}" } } + } + div { class: "lpb-cat-info", div { class: "lpb-cat-specs", span { class: "lpb-cat-spec lpb-cat-spec--soc", "{board.soc}" } span { class: "lpb-cat-spec", "{board.flash} flash" } @@ -187,19 +251,67 @@ fn BoardCard(board: BoardDisplayFile, os: HostOs) -> Element { for capability in board.capabilities.iter() { span { class: "lpb-cat-spec", "{capability}" } } - } - if let Some((bridge, guidance)) = driver { - details { class: "lpb-cat-driver-warn", - summary { - span { class: "lpb-cat-driver-badge", "driver required" } - "{guidance.summary}" + if let Some(guidance) = driver { + span { + class: "lpb-cat-driver-chip", + title: "{guidance.summary} — open the board for setup steps", + "DRIVER REQUIRED · {os.display_name()}" } - ol { - for step in guidance.steps { - li { "{step}" } - } + } + } + p { class: "lpb-cat-blurb", "{board.blurb}" } + if let Some(note) = &board.support_note { + p { class: "lpb-cat-support-note", "{note}" } + } + div { class: "lpb-cat-links", + for url in board.purchase_urls.iter() { + a { + class: "lpb-cat-buy", + href: "{url.href}", + target: "_blank", + rel: "noopener", + "{url.label} ↗" } - span { class: "lpb-cat-driver-chip-note", "bridge: {bridge.display_name()}" } + } + } + } + } + } +} + +/// One board's detail view: full pinout, specs, driver setup for the +/// current OS, and the board's notes. +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn BoardDetail(board: BoardDisplayFile, os: HostOs) -> Element { + let price = price_label(board.price_usd); + let bridge = board.usb_bridge; + let notes: Vec<_> = board + .notes + .iter() + .filter(|note| note.os.is_none_or(|note_os| note_os.matches(os))) + .cloned() + .collect(); + + rsx! { + div { class: "lpb-det", + header { class: "lpb-det-header", + div { class: "lpb-det-title", + h1 { "{board.display_name}" } + span { class: "lpb-cat-mfr", "{board.manufacturer}" } + span { class: "lpb-cat-tier lpb-cat-tier--{tier_label(board.tier)}", + "{tier_label(board.tier)}" + } + span { class: "lpb-cat-price", "{price}" } + } + div { class: "lpb-cat-specs", + span { class: "lpb-cat-spec lpb-cat-spec--soc", "{board.soc}" } + span { class: "lpb-cat-spec", "{board.flash} flash" } + if let Some(psram) = &board.psram { + span { class: "lpb-cat-spec", "{psram} psram" } + } + for capability in board.capabilities.iter() { + span { class: "lpb-cat-spec", "{capability}" } } } p { class: "lpb-cat-blurb", "{board.blurb}" } @@ -218,6 +330,59 @@ fn BoardCard(board: BoardDisplayFile, os: HostOs) -> Element { } } } + div { class: "lpb-det-figure", + BoardDiagram { + board: board.clone(), + mode: DiagramMode::Caps, + u: 13.0, + scale: 1.35, + } + } + if let Some(bridge) = bridge { + { + let guidance = bridge.guidance(os); + let warning = guidance.level == DriverNeedLevel::Warning; + rsx! { + section { + class: if warning { "lpb-det-section lpb-det-driver lpb-det-driver--warning" } else { "lpb-det-section lpb-det-driver" }, + h2 { "USB driver — {os.display_name()}" } + p { class: "lpb-det-driver-summary", + if warning { + span { class: "lpb-cat-driver-chip", "DRIVER REQUIRED" } + } + "{guidance.summary} (bridge: {bridge.display_name()})" + } + if !guidance.steps.is_empty() { + ol { class: "lpb-det-steps", + for step in guidance.steps { + li { "{step}" } + } + } + } + } + } + } + } + if !notes.is_empty() { + section { class: "lpb-det-section", + h2 { "Notes" } + ul { class: "lpb-det-notes", + for note in notes.iter() { + li { + if let Some(note_os) = note.os { + span { class: "lpb-det-note-tag", + "{note_os.display_name()}" + if let Some(version) = ¬e.os_version { + " {version}" + } + } + } + "{note.text}" + } + } + } + } + } } } } diff --git a/lp-app/lpa-boards/src/display_manifest.rs b/lp-app/lpa-boards/src/display_manifest.rs index ca5152112..65df874c1 100644 --- a/lp-app/lpa-boards/src/display_manifest.rs +++ b/lp-app/lpa-boards/src/display_manifest.rs @@ -48,9 +48,58 @@ pub struct BoardDisplayFile { /// omitted = unknown. #[serde(default, skip_serializing_if = "Option::is_none")] pub usb_bridge: Option, + /// Freeform board notes shown on the detail view, optionally OS-tagged + /// (an untagged note shows everywhere; a tagged one only on that OS). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, pub hw: BoardDrawing, } +/// One detail-view note. Deliberately minimal: text plus optional OS (and +/// freeform OS-version qualifier) tags — the whole "note system". +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct BoardNote { + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os: Option, + /// Freeform version qualifier shown with the OS tag ("Sequoia+"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os_version: Option, +} + +/// The OS a note applies to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "kebab-case")] +pub enum NoteOs { + #[serde(rename = "macos")] + MacOs, + Windows, + Linux, +} + +impl NoteOs { + pub fn display_name(self) -> &'static str { + match self { + Self::MacOs => "macOS", + Self::Windows => "Windows", + Self::Linux => "Linux", + } + } + + /// Whether a note tagged with this OS applies on `host`. + pub fn matches(self, host: crate::usb_bridge::HostOs) -> bool { + use crate::usb_bridge::HostOs; + matches!( + (self, host), + (Self::MacOs, HostOs::MacOs) + | (Self::Windows, HostOs::Windows) + | (Self::Linux, HostOs::Linux) + ) + } +} + /// Support tier shown on the catalog. Definitions live in /// `lp-core/lpc-hardware/boards/README.md`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -335,6 +384,7 @@ mod tests { support_note: None, purchase_urls: vec![], usb_bridge: None, + notes: vec![], hw: BoardDrawing { width: 100.0, module: DrawnModule { diff --git a/lp-app/lpa-boards/src/lib.rs b/lp-app/lpa-boards/src/lib.rs index da545254f..57c42fa9d 100644 --- a/lp-app/lpa-boards/src/lib.rs +++ b/lp-app/lpa-boards/src/lib.rs @@ -26,8 +26,9 @@ pub use catalog_page::BoardsCatalogPage; #[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, + BoardDisplayError, BoardDisplayFile, BoardDrawing, BoardNote, CapKind, DrawnButton, + DrawnModule, DrawnPin, DrawnRgb, DrawnTerminal, DrawnUsb, NoteOs, PinCap, PinRole, PurchaseUrl, + SupportTier, }; pub use geometry::{DiagramMode, DiagramOptions, PinSwatch, WiredConnection}; pub use usb_bridge::{DriverGuidance, DriverNeedLevel, HostOs, UsbBridge}; diff --git a/lp-app/lpa-boards/src/usb_bridge.rs b/lp-app/lpa-boards/src/usb_bridge.rs index 4a9cbfe71..704f49acc 100644 --- a/lp-app/lpa-boards/src/usb_bridge.rs +++ b/lp-app/lpa-boards/src/usb_bridge.rs @@ -63,6 +63,17 @@ pub enum HostOs { Other, } +impl HostOs { + pub fn display_name(self) -> &'static str { + match self { + Self::MacOs => "macOS", + Self::Windows => "Windows", + Self::Linux => "Linux", + Self::Other => "this OS", + } + } +} + /// What one OS needs for one bridge. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DriverGuidance { 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 index de724d224..44403addd 100644 --- a/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs +++ b/lp-app/lpa-studio-web/src/app/board_diagram_stories.rs @@ -363,6 +363,20 @@ pub(crate) fn catalog_page_macos() -> Element { } } +#[story( + description = "The board detail view (#/boards/domraem/dom-z-102 deep link): full caps pinout, driver section with the verified macOS CH340K steps, and the os-tagged note system." +)] +pub(crate) fn detail_page_dom_z_102() -> Element { + rsx! { + div { style: "max-width: 1080px;", + BoardsCatalogPage { + os: HostOs::MacOs, + initial_board: Some("domraem/dom-z-102".to_string()), + } + } + } +} + // ---- annotated anatomy --------------------------------------------------- /// Estimated bbox of an end-anchored pin label (the layout stores anchor + diff --git a/lp-app/lpa-studio-web/src/router.rs b/lp-app/lpa-studio-web/src/router.rs index fd1e5a7f7..9e0e5bdf0 100644 --- a/lp-app/lpa-studio-web/src/router.rs +++ b/lp-app/lpa-studio-web/src/router.rs @@ -87,8 +87,9 @@ pub(crate) enum StudioRoute { /// `.map2d.json` documents with localStorage autosave). MappingEditor, /// The public boards catalog (project-free, renders the checked-in - /// board display metadata). - Boards, + /// board display metadata). `board` deep-links one board's detail view + /// (`vendor/product`). + Boards { board: Option }, } #[cfg_attr( @@ -122,7 +123,12 @@ impl StudioRoute { _ => StudioRoute::Home, }, Some("mapping") if segments.next().is_none() => StudioRoute::MappingEditor, - Some("boards") if segments.next().is_none() => StudioRoute::Boards, + Some("boards") => { + let rest: Vec<&str> = segments.collect(); + StudioRoute::Boards { + board: (rest.len() == 2).then(|| rest.join("/")), + } + } Some("stories") => { let rest: Vec<&str> = segments.collect(); StudioRoute::Stories { @@ -143,7 +149,8 @@ impl StudioRoute { StudioRoute::Stories { story_id: None } => "#/stories".to_string(), StudioRoute::Stories { story_id: Some(id) } => format!("#/stories/{id}"), StudioRoute::MappingEditor => "#/mapping".to_string(), - StudioRoute::Boards => "#/boards".to_string(), + StudioRoute::Boards { board: None } => "#/boards".to_string(), + StudioRoute::Boards { board: Some(board) } => format!("#/boards/{board}"), } } @@ -375,7 +382,10 @@ mod tests { story_id: Some("base/detail-popover/open-sections".to_string()), }, StudioRoute::MappingEditor, - StudioRoute::Boards, + StudioRoute::Boards { board: None }, + StudioRoute::Boards { + board: Some("domraem/dom-z-102".to_string()), + }, ]; for route in routes { assert_eq!(StudioRoute::parse(&route.hash()), route, "{route:?}"); diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 75ccc2e5a..198bf1625 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -3405,56 +3405,160 @@ label:has(> input:not(:disabled)) { border-color: rgba(228, 192, 101, 0.3); } -/* Driver warning (plan D5): warning-level prominence, expandable steps. */ -.lpb-cat-driver-warn { - border: 1px solid var(--studio-status-attention-border); +/* Driver warning (plan D5): a compact attention chip on cards; the full + setup steps live on the board detail view. */ +.lpb-cat-driver-chip { + font-family: var(--studio-font-mono); + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.07em; + color: var(--studio-status-attention-text); background: var(--studio-status-attention-bg); - border-radius: var(--studio-radius-md); - padding: 6px 9px; - font-size: 11.5px; - color: var(--studio-color-text); + border: 1px solid var(--studio-status-attention-border); + border-radius: var(--studio-radius-pill); + padding: 2px 8px; } -.lpb-cat-driver-warn summary { - cursor: pointer; +/* Cards open their detail view: the clickable region keeps link affordance + without nesting interactive elements around the buy links. */ +.lpb-cat-open { + appearance: none; + border: 0; + background: none; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + display: block; + width: 100%; +} + +.lpb-cat-open .lpb-cat-title { + padding: 10px 12px 0; +} + +/* ---- board detail view (#/boards//) ------------------ */ + +.lpb-det-back { + appearance: none; + border: 1px solid var(--studio-color-border-strong); + background: transparent; + color: var(--studio-color-text-muted); + font-size: 12px; + font-weight: 600; + border-radius: var(--studio-radius-sm); + padding: 4px 12px; + margin-bottom: 16px; +} + +.lpb-det-back:hover { + color: var(--studio-color-text-strong); +} + +.lpb-det { + display: flex; + flex-direction: column; + gap: 18px; + max-width: 980px; +} + +.lpb-det-header { + display: flex; + flex-direction: column; + gap: 10px; +} + +.lpb-det-title { display: flex; - gap: 7px; align-items: baseline; - color: var(--studio-status-attention-text); - list-style: none; + gap: 10px; + flex-wrap: wrap; } -.lpb-cat-driver-warn summary::-webkit-details-marker { - display: none; +.lpb-det-title h1 { + font-size: 20px; + letter-spacing: 0; + text-transform: none; + color: var(--studio-color-text-strong); + font-family: inherit; + margin: 0; } -.lpb-cat-driver-badge { - flex: none; - font-size: 9.5px; - font-weight: 800; - letter-spacing: 0.08em; +.lpb-det-title .lpb-cat-price { + position: static; +} + +.lpb-det-figure { + background: var(--studio-color-terminal); + border: 1px solid var(--studio-color-border); + border-radius: 12px; + padding: 18px; + overflow-x: auto; +} + +.lpb-det-section { + background: var(--studio-color-surface); + border: 1px solid var(--studio-color-border); + border-radius: 10px; + padding: 12px 14px; +} + +.lpb-det-section h2 { + font-size: 11px; + letter-spacing: 0.09em; text-transform: uppercase; - border: 1px solid var(--studio-status-attention-border); - border-radius: var(--studio-radius-pill); - padding: 1px 7px; + color: var(--studio-color-text-dim); + font-family: var(--studio-font-mono); + border: 0; + margin: 0 0 8px; + padding: 0; } -.lpb-cat-driver-warn ol { - margin: 8px 0 2px; +.lpb-det-driver--warning { + border-color: var(--studio-status-attention-border); + background: var(--studio-status-attention-bg); +} + +.lpb-det-driver-summary { + display: flex; + gap: 8px; + align-items: baseline; + flex-wrap: wrap; + margin: 0; + font-size: 12.5px; + color: var(--studio-color-text-muted); +} + +.lpb-det-steps { + margin: 10px 0 2px; + padding-left: 20px; + color: var(--studio-color-text-muted); + font-size: 12px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.lpb-det-notes { + margin: 0; padding-left: 18px; color: var(--studio-color-text-muted); - font-size: 11px; + font-size: 12.5px; display: flex; flex-direction: column; - gap: 4px; + gap: 6px; } -.lpb-cat-driver-chip-note { - display: block; - margin-top: 4px; +.lpb-det-note-tag { font-family: var(--studio-font-mono); font-size: 10px; - color: var(--studio-color-text-dim); + font-weight: 700; + color: var(--studio-status-live-text); + background: var(--studio-status-live-bg); + border: 1px solid var(--studio-status-live-border); + border-radius: var(--studio-radius-pill); + padding: 1px 7px; + margin-right: 7px; } .lpb-cat-blurb { diff --git a/lp-app/lpa-studio-web/src/web_app.rs b/lp-app/lpa-studio-web/src/web_app.rs index 480815c5f..b26470777 100644 --- a/lp-app/lpa-studio-web/src/web_app.rs +++ b/lp-app/lpa-studio-web/src/web_app.rs @@ -81,11 +81,11 @@ pub fn App() -> Element { // The boards catalog: same standalone-page pattern. The detected OS // drives per-bridge driver warnings (plan D5) — detected here at the // platform edge; lpa-boards stays platform-blind. - if matches!(router::current_route(), StudioRoute::Boards) { + if let StudioRoute::Boards { board } = router::current_route() { return rsx! { style { "{STYLE}" } document::Stylesheet { href: asset!("/assets/tailwind.css") } - lpa_boards::BoardsCatalogPage { os: detect_host_os() } + lpa_boards::BoardsCatalogPage { os: detect_host_os(), initial_board: board } }; } @@ -404,7 +404,9 @@ pub fn App() -> Element { ))); } } - StudioRoute::Stories { .. } | StudioRoute::MappingEditor | StudioRoute::Boards => { + StudioRoute::Stories { .. } + | StudioRoute::MappingEditor + | StudioRoute::Boards { .. } => { // the story book, mapping editor, and boards catalog // mount on fresh page loads only (their early returns // in App run before any hooks); reload to keep the @@ -472,7 +474,7 @@ pub fn App() -> Element { StudioRoute::Home | StudioRoute::Stories { .. } | StudioRoute::MappingEditor - | StudioRoute::Boards => {} + | StudioRoute::Boards { .. } => {} } // D32 auto-connect (M6): the load-time attach sweep — queued // AFTER the route dispatch, so a `#/device/` reload's own 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 index d7133fa8f..dd04c5266 100644 --- a/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json +++ b/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json @@ -14,12 +14,16 @@ { "label": "Amazon", "href": "https://www.amazon.com/dp/B0GH7LPNQV" } ], "usb_bridge": "ch340k", + "notes": [ + { "text": "The IO12 terminal drives the onboard relay (GPIO12) — it is not an LED data output." }, + { "text": "The BOOT/RESET buttons are stacked: BOOT is the top button of the pair, RESET the bottom." } + ], "hw": { - "width": 150, - "module": { "x": 62, "y": 8, "w": 60, "h": 54, "label": "WROOM-32E", "antenna": true }, + "width": 210, + "module": { "x": 118, "y": 8, "w": 62, "h": 50, "label": "WROOM-32E", "antenna": true }, "buttons": [ - { "x": 18, "y": 46, "label": "OPT" }, - { "x": 18, "y": 62, "label": "RST" } + { "x": 20, "y": 30, "label": "BOOT" }, + { "x": 20, "y": 46, "label": "RST" } ], "left": [ { "label": "GND", "role": "gnd" }, @@ -28,7 +32,7 @@ { "label": "V+", "role": "pwr5" } ], "right": [ - { "label": "IO13", "role": "rsvd", "gpio": 13 }, + { "label": "IO12", "role": "rsvd", "gpio": 12, "caps": [ { "text": "relay", "kind": "note" } ] }, { "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" } ] }, diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json index dc889b199..4e04b3f96 100644 --- a/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json +++ b/lp-core/lpc-hardware/boards/espressif/esp32-c6-devkitc-1.display.json @@ -6,7 +6,7 @@ "family": "esp32c6", "flash": "8 MB", "price_usd": 9.0, - "tier": "gold", + "tier": "silver", "capabilities": ["Wi-Fi 6", "BLE 5", "802.15.4", "onboard RGB pixel"], "blurb": "The reference C6 devkit and our best-tested board. RISC-V, Wi-Fi 6, cheap — the easiest way to start.", "purchase_urls": [ diff --git a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json index 10d0e2946..3472a9f45 100644 --- a/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json +++ b/lp-core/lpc-hardware/boards/seeed/xiao-esp32-s3-plus.display.json @@ -7,7 +7,7 @@ "flash": "16 MB", "psram": "8 MB", "price_usd": 9.9, - "tier": "silver", + "tier": "gold", "capabilities": ["Wi-Fi", "BLE 5", "thumb-sized", "camera header"], "blurb": "Tiny S3 with real memory headroom. Calibrated profile in the repo; the castellated pads are deliberately unlisted.", "support_note": "S3 support is new — tested as the S3 track matures.", diff --git a/schemas/board-display.schema.json b/schemas/board-display.schema.json index 898c2deee..6a10a175f 100644 --- a/schemas/board-display.schema.json +++ b/schemas/board-display.schema.json @@ -61,6 +61,35 @@ ], "type": "object" }, + "BoardNote": { + "description": "One detail-view note. Deliberately minimal: text plus optional OS (and\nfreeform OS-version qualifier) tags — the whole \"note system\".", + "properties": { + "os": { + "anyOf": [ + { + "$ref": "#/$defs/NoteOs" + }, + { + "type": "null" + } + ] + }, + "os_version": { + "description": "Freeform version qualifier shown with the OS tag (\"Sequoia+\").", + "type": [ + "string", + "null" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, "CapKind": { "description": "Cell type: drives the capability cell's color family.", "oneOf": [ @@ -249,6 +278,15 @@ ], "type": "object" }, + "NoteOs": { + "description": "The OS a note applies to.", + "enum": [ + "macos", + "windows", + "linux" + ], + "type": "string" + }, "PinCap": { "description": "A capability cell in the pin's row (\"ADC1_3\", \"Touch4\", \"U0_TXD\").", "properties": { @@ -410,6 +448,13 @@ "manufacturer": { "type": "string" }, + "notes": { + "description": "Freeform board notes shown on the detail view, optionally OS-tagged\n(an untagged note shows everywhere; a tagged one only on that OS).", + "items": { + "$ref": "#/$defs/BoardNote" + }, + "type": "array" + }, "price_usd": { "description": "Approximate street price, USD, for catalog sorting.", "format": "double", From e2edee3f62e3c5ac4cd20cc0c3ff697a251bf849 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:26:04 -0700 Subject: [PATCH 4/7] chore(lpa-boards): drop the classic-ESP32 'firmware in progress' notes Per the M3 re-gate: classic ESP32 support is close enough to assume working on the catalog. Co-Authored-By: Claude Fable 5 --- lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json | 1 - .../lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json | 1 - lp-core/lpc-hardware/boards/quinled/dig-uno.display.json | 1 - 3 files changed, 3 deletions(-) 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 index dd04c5266..1ce68856b 100644 --- a/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json +++ b/lp-core/lpc-hardware/boards/domraem/dom-z-102.display.json @@ -9,7 +9,6 @@ "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" } ], diff --git a/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json b/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json index 7d1011481..86e9bc8c3 100644 --- a/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json +++ b/lp-core/lpc-hardware/boards/espressif/esp32-devkitc-v4.display.json @@ -9,7 +9,6 @@ "tier": "bronze", "capabilities": ["Wi-Fi", "BT classic", "everywhere"], "blurb": "The classic WROOM-32 board. Millions exist; clones vary. The board classic-ESP32 support will target.", - "support_note": "Runs when classic ESP32 (v3) firmware lands — in progress.", "purchase_urls": [ { "label": "Espressif", "href": "https://www.espressif.com/en/products/devkits/esp32-devkitc" }, { "label": "AliExpress", "href": "https://www.aliexpress.com/w/wholesale-esp32-devkitc.html" } diff --git a/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json b/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json index 744e12ed1..e5d8bc6bb 100644 --- a/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json +++ b/lp-core/lpc-hardware/boards/quinled/dig-uno.display.json @@ -9,7 +9,6 @@ "tier": "bronze", "capabilities": ["level shifter", "fuse", "screw terminals", "2 LED ports", "5-24 V input"], "blurb": "Purpose-built LED controller: fused, level-shifted, screw terminals. The clean permanent-install choice.", - "support_note": "Runs when classic ESP32 (v3) firmware lands — in progress.", "purchase_urls": [ { "label": "quinled.info", "href": "https://quinled.info/pre-assembled-quinled-dig-uno/" } ], From 5b11e41188cbbf06962d93096e991ccd4c26ecd4 Mon Sep 17 00:00:00 2001 From: Yona Appletree Date: Fri, 31 Jul 2026 23:31:25 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat(lpa-boards):=20board=20detail=20expand?= =?UTF-8?q?s=20in=20place=20=E2=80=94=20no=20page=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the M3 re-gate: the selected card grows to a full-width grid row at its own position (sticky collapse bar, neighbors reflow) instead of swapping the page. The mirrored #/boards// hash is a bookmark, not a history entry, so the back button has nothing to break; deep links land with the expanded card scrolled into view. Filtering collapses the selection so a hidden card can't strand the deep link. Plan: 2026-07-31-1002-hardware-board-selection (M3, re-gate revision) Co-Authored-By: Claude Fable 5 --- lp-app/lpa-boards/src/catalog_page.rs | 89 +++++++++++++++++++-------- lp-app/lpa-studio-web/src/style.css | 34 +++++++++- 2 files changed, 95 insertions(+), 28 deletions(-) diff --git a/lp-app/lpa-boards/src/catalog_page.rs b/lp-app/lpa-boards/src/catalog_page.rs index dc609048c..bf2d6c600 100644 --- a/lp-app/lpa-boards/src/catalog_page.rs +++ b/lp-app/lpa-boards/src/catalog_page.rs @@ -94,27 +94,26 @@ fn mirror_selection_hash(selected: Option<&str>) { #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] pub fn BoardsCatalogPage(os: HostOs, #[props(default)] initial_board: Option) -> Element { + let deep_linked = initial_board.is_some(); let mut sort_by = use_signal(|| SortBy::Recommended); let mut family_filter = use_signal(|| Option::::None); let mut selected = use_signal(|| initial_board.filter(|board_id| board_by_id(board_id).is_some())); - - if let Some(board_id) = selected() { - let board = board_by_id(&board_id).expect("selection validated on set"); - return rsx! { - div { class: "lpb-cat-page", - button { - class: "lpb-det-back", - onclick: move |_| { - selected.set(None); - mirror_selection_hash(None); - }, - "← All boards" - } - BoardDetail { board: board.clone(), os } + // Deep links land with the expanded card scrolled into view; in-page + // expansion never scrolls (the card grows where the user clicked). + let mut pending_scroll = use_signal(|| deep_linked); + use_effect(move || { + if pending_scroll() && selected.read().is_some() { + #[cfg(target_arch = "wasm32")] + if let Some(element) = web_sys::window() + .and_then(|window| window.document()) + .and_then(|document| document.get_element_by_id("lpb-expanded-board")) + { + element.scroll_into_view(); } - }; - } + pending_scroll.set(false); + } + }); let mut boards: Vec<&'static BoardDisplayFile> = all_boards() .iter() @@ -179,7 +178,11 @@ pub fn BoardsCatalogPage(os: HostOs, #[props(default)] initial_board: Option