diff --git a/.github/workflows/deploy-pages-channel.yml b/.github/workflows/deploy-pages-channel.yml index 5740ac954..0ce004834 100644 --- a/.github/workflows/deploy-pages-channel.yml +++ b/.github/workflows/deploy-pages-channel.yml @@ -76,6 +76,9 @@ jobs: - name: Install wasm-bindgen-cli run: cargo install wasm-bindgen-cli --version 0.2.114 --locked + - name: Install dioxus-cli + run: cargo install dioxus-cli --version 0.7.9 --locked + - name: Install espflash if: inputs.channel == 'beta' run: cargo install espflash --version 3.3.0 --locked diff --git a/.github/workflows/deploy-studio-pages.yml b/.github/workflows/deploy-studio-pages.yml index 1e91557ec..71041c62f 100644 --- a/.github/workflows/deploy-studio-pages.yml +++ b/.github/workflows/deploy-studio-pages.yml @@ -66,6 +66,9 @@ jobs: - name: Install wasm-bindgen-cli run: cargo install wasm-bindgen-cli --version 0.2.114 --locked + - name: Install dioxus-cli + run: cargo install dioxus-cli --version 0.7.9 --locked + - name: Install espflash run: cargo install espflash --version 3.3.0 --locked diff --git a/.gitignore b/.gitignore index d16d34d7f..72f600ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,9 +34,7 @@ perf.data* .builtins-source-hash traces/ profiles/ -lp-app/lpa-studio-web/public/pkg/ lp-app/lpa-studio-web/public/fw-browser-worker.js -lp-app/lpa-studio-web/public/firmware/ lp-app/lpa-studio-web/dist/ lp-app/lpa-studio-web/story-images/.new/ lp-app/lpa-studio-web/story-images/.scratch/ diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 36bbf77bd..bcd07f6ba 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -1,6 +1,7 @@ + Lightplayer Lpvm lpir diff --git a/.idea/lp2025.iml b/.idea/lp2025.iml index 8fe621d19..3592369e9 100644 --- a/.idea/lp2025.iml +++ b/.idea/lp2025.iml @@ -110,6 +110,8 @@ + + diff --git a/AGENTS.md b/AGENTS.md index b4405e44e..bf63613e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,14 +246,14 @@ be migrated unless a separate migration plan asks for it. ## Studio UI visual baselines -When a change touches non-generated files under `lp-app/lp-studio-web/`, run the +When a change touches non-generated files under `lp-app/lpa-studio-web/`, run the Studio story baseline helper before committing: ```bash just studio-story-baselines-if-needed ``` -If it updates files under `lp-app/lp-studio-web/story-images/`, include those +If it updates files under `lp-app/lpa-studio-web/story-images/`, include those PNG changes in the same commit and mention the affected story baselines in the final summary. The helper intentionally ignores generated web artifacts, scratch PNGs, fresh check PNGs, and the baseline PNGs themselves. diff --git a/Cargo.lock b/Cargo.lock index fc8ef2505..8a5a58cd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2132,6 +2132,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dioxus-icons" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ae929a4cdde2e51fca98ccb8a8fe2ea11640e5026c57486b15dbd5618ba7e56" +dependencies = [ + "dioxus", + "dioxus-signals", + "lazy-js-bundle", +] + [[package]] name = "dioxus-interpreter-js" version = "0.7.9" @@ -5123,7 +5134,7 @@ dependencies = [ ] [[package]] -name = "lpa-studio-ux" +name = "lpa-studio-core" version = "40.0.0" dependencies = [ "async-trait", @@ -5131,6 +5142,7 @@ dependencies = [ "lpa-client", "lpa-link", "lpc-model", + "lpc-view", "lpc-wire", "wasm-bindgen", "wasm-bindgen-futures", @@ -5142,10 +5154,24 @@ name = "lpa-studio-web" version = "40.0.0" dependencies = [ "dioxus", - "lpa-studio-ux", + "dioxus-icons", + "gloo-timers", + "lpa-studio-core", + "lpa-studio-web-story-macros", + "syn 2.0.117", + "wasm-bindgen", "web-sys", ] +[[package]] +name = "lpa-studio-web-story-macros" +version = "40.0.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "lpc-engine" version = "40.0.0" diff --git a/Cargo.toml b/Cargo.toml index b61df2b65..9da30cc88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,9 @@ members = [ "lp-app/lpa-server", "lp-app/lpa-client", "lp-app/lpa-link", - "lp-app/lpa-studio-ux", + "lp-app/lpa-studio-core", "lp-app/lpa-studio-web", + "lp-app/lpa-studio-web-story-macros", "lp-fw/fw-browser", "lp-fw/fw-core", "lp-fw/fw-host", @@ -74,7 +75,7 @@ default-members = [ "lp-app/lpa-server", "lp-app/lpa-client", "lp-app/lpa-link", - "lp-app/lpa-studio-ux", + "lp-app/lpa-studio-core", "lp-fw/fw-core", "lp-fw/fw-host", "lp-fw/fw-checks", diff --git a/docs/adr/2026-06-18-browser-serial-shim.md b/docs/adr/2026-06-18-browser-serial-shim.md index 3db9127c9..7ba955ed6 100644 --- a/docs/adr/2026-06-18-browser-serial-shim.md +++ b/docs/adr/2026-06-18-browser-serial-shim.md @@ -22,7 +22,7 @@ into unrelated Rust validation. Use a tiny JavaScript shim for direct Web Serial stream ownership. -- `lp-app/lp-studio-web/public/browser-serial.js` owns +- `lp-app/lpa-studio-web/public/browser-serial.js` owns `navigator.serial.requestPort()`, `SerialPort.open()`, stream readers, stream writers, line buffering, and close/cancel behavior. - The shim installs a narrow global function surface before the Rust wasm module diff --git a/docs/adr/2026-06-18-studio-action-session-architecture.md b/docs/adr/2026-06-18-studio-action-session-architecture.md index 8169b01d5..ea8a61989 100644 --- a/docs/adr/2026-06-18-studio-action-session-architecture.md +++ b/docs/adr/2026-06-18-studio-action-session-architecture.md @@ -32,7 +32,7 @@ Studio is split into three application-facing crates: events, diagnostics, capabilities, and session records. - `lp-studio-runtime` executes effects and translates link/client/runtime facts back into Studio events. -- `lp-studio-web` renders `StudioState` with Dioxus and dispatches +- `lpa-studio-web` renders `StudioState` with Dioxus and dispatches `StudioAction` values. The core loop is: diff --git a/docs/adr/2026-06-18-studio-native-storybook.md b/docs/adr/2026-06-18-studio-native-storybook.md index 52edd407c..3a9222cad 100644 --- a/docs/adr/2026-06-18-studio-native-storybook.md +++ b/docs/adr/2026-06-18-studio-native-storybook.md @@ -17,7 +17,7 @@ props. That makes them well suited to local fixture-driven stories. ## Decision -Studio component stories will be native Dioxus code in `lp-studio-web`. +Studio component stories will be native Dioxus code in `lpa-studio-web`. - Story files live next to components as sibling `*_stories.rs` modules. - A small explicit Rust registry collects story descriptors and render functions. diff --git a/docs/adr/2026-06-18-studio-story-png-baselines.md b/docs/adr/2026-06-18-studio-story-png-baselines.md index 46dbc981d..be9033e1d 100644 --- a/docs/adr/2026-06-18-studio-story-png-baselines.md +++ b/docs/adr/2026-06-18-studio-story-png-baselines.md @@ -18,18 +18,18 @@ before investing in CI visual-regression infrastructure. ## Decision -Commit a curated baseline PNG set for `lp-studio-web` stories. +Commit a curated baseline PNG set for `lpa-studio-web` stories. -- Committed baselines live under `lp-app/lp-studio-web/story-images/`. +- Committed baselines live under `lp-app/lpa-studio-web/story-images/`. - Scratch review PNGs stay gitignored under - `lp-app/lp-studio-web/story-images/.scratch/`. + `lp-app/lpa-studio-web/story-images/.scratch/`. - Fresh check output lives under gitignored - `lp-app/lp-studio-web/story-images/.new/`. + `lp-app/lpa-studio-web/story-images/.new/`. - `just studio-story-baselines` regenerates the committed baseline set. - `just studio-story-check` compares fresh story PNGs to committed baselines without updating them. - `just studio-story-baselines-if-needed` runs baseline generation only when - non-generated files under `lp-app/lp-studio-web/` changed since `HEAD`. + non-generated files under `lp-app/lpa-studio-web/` changed since `HEAD`. - Story captures are clipped to the marked story canvas content rather than the full browser viewport. - Baseline and check commands require `oxipng` so fresh captures are normalized @@ -51,3 +51,16 @@ and baseline updates should remain intentional. CI can later run `just studio-story-check`, but CI should not commit updated PNGs. If the image set grows substantially or churn becomes painful, revisit this decision before adding Git LFS or hard visual gates. + +## 2026-06-23 Addendum: Responsive Baseline Matrix + +The project editor foundation makes responsive layout a first-order part of the +Studio UI. The accepted baseline set now captures each story at `sm`, `md`, and +`lg` viewports. Baseline filenames include the viewport id, such as +`studio__editor-shell__sm.png`, so check mode can compare the full story by +viewport matrix and report changed, new, or removed images precisely. + +This increases baseline count and disk usage, but keeps responsive regressions +visible while the editor shell, node tree, and device rail are still taking +shape. The same `oxipng` normalization remains required for baseline and check +modes. diff --git a/docs/adr/2026-06-18-studio-web-provisioning-controller.md b/docs/adr/2026-06-18-studio-web-provisioning-controller.md index 4a104c613..17b4504e2 100644 --- a/docs/adr/2026-06-18-studio-web-provisioning-controller.md +++ b/docs/adr/2026-06-18-studio-web-provisioning-controller.md @@ -23,7 +23,7 @@ plug into the same user journey. ## Decision -Use a thin browser-side provisioning controller in `lp-studio-web`. +Use a thin browser-side provisioning controller in `lpa-studio-web`. - The controller dispatches real `StudioActionKind` values into `StudioApp`. - It drains returned `StudioEffect` values through the active browser runtime. diff --git a/docs/adr/2026-06-22-studio-pages-deployment.md b/docs/adr/2026-06-22-studio-pages-deployment.md index 84c635087..013dd882a 100644 --- a/docs/adr/2026-06-22-studio-pages-deployment.md +++ b/docs/adr/2026-06-22-studio-pages-deployment.md @@ -40,8 +40,10 @@ manual workflow, then published as static commits to the corresponding Pages repository. Deployment tooling stages clean release-only artifacts under `target/pages/` -and generates `version.json`, `.nojekyll`, and `CNAME` files. This avoids -uploading stale debug wasm from `lp-app/lpa-studio-web/public/pkg`. +and generates `version.json`, `.nojekyll`, and `CNAME` files. Studio app +JS/WASM comes from `dx` release output, and generated runtime sidecars are +copied into that output from `target/studio-web-assets/` instead of being staged +under source `public/`. ## Consequences diff --git a/docs/adr/2026-06-23-studio-hierarchical-ux-node-dispatch.md b/docs/adr/2026-06-23-studio-hierarchical-ux-node-dispatch.md new file mode 100644 index 000000000..29984a161 --- /dev/null +++ b/docs/adr/2026-06-23-studio-hierarchical-ux-node-dispatch.md @@ -0,0 +1,78 @@ +# ADR 2026-06-23: Studio Hierarchical UX Node Dispatch + +## Status + +Accepted. + +## Context + +The Studio device manager started with a small static UX tree: + +```text +StudioUx + DeviceUx + ProjectUx +``` + +Exact target matching was enough for that shape. The project editor needs more +dynamic action targets: node tree, individual nodes, slot rows, assets, changes, +bus views, probes, and eventually binding controls attached to slot rows. + +Several designs were considered: + +- Keep exact static dispatch and add ad hoc string checks as needed. +- Add a non-owning `UxRegistry` or router that maps target prefixes to owners. +- Add a registry-owned component tree of boxed dynamic UX nodes. +- Make `UxNodeId` path-shaped and dispatch hierarchically through the explicit + ownership tree. + +## Decision + +Studio will use path-shaped `UxNodeId` values and owner-local hierarchical +dispatch. + +The ownership tree remains explicit Rust structs: + +```text +StudioUx + owns DeviceUx + owns ProjectUx +``` + +The address tree is a UX path: + +```text +studio.device +studio.project +studio.project.node_tree +studio.project.node. +studio.project.node..slot. +studio.project.asset. +studio.project.changes +studio.project.bus +``` + +`StudioUx` owns top-level routing. It handles `studio.device` and routes +`studio.project` plus `studio.project.*` to project ownership. `ProjectUx` owns +interpretation of project-local subtargets. + +Actions remain in-process typed values. The target address identifies the UX +surface; the boxed typed operation identifies what to do. This decision does +not introduce serialized action commands or a remote action protocol. + +## Consequences + +The model keeps Rust ownership simple. There is no weak-reference graph, +`Rc>` ownership tree, or boxed async node runtime. + +Dynamic addresses do not imply dynamic object ownership. A slot row can be +addressed without being stored as an independently owned UX node. + +Dispatch has some manual machinery. Each owner must parse and handle its own +subtree clearly, with explicit errors for unknown targets or wrong operation +types. + +A future `UxRegistry` is still possible if Studio grows non-tree routing, +plugin-style mounting, or cross-cutting introspection needs. If introduced +later, it should build on the path-shaped `UxNodeId` model rather than replacing +typed in-process actions. diff --git a/docs/adr/2026-06-24-studio-core-and-layer-vocabulary.md b/docs/adr/2026-06-24-studio-core-and-layer-vocabulary.md new file mode 100644 index 000000000..21592db8b --- /dev/null +++ b/docs/adr/2026-06-24-studio-core-and-layer-vocabulary.md @@ -0,0 +1,115 @@ +# ADR: Studio Core And Layer Vocabulary + +- **Status:** Accepted +- **Date:** 2026-06-24 +- **Deciders:** Photomancer +- **Updates:** [2026-06-21 Studio UX Layer](./2026-06-21-studio-ux-layer.md) + +## Context + +The first Studio app slices used `lpa-studio-ux` for the headless +resource-owning layer and `lpa-studio-web` for the Dioxus browser renderer. +That split worked technically, but the names blurred several roles: + +- the headless crate owns real application policy, not just UX helper data; +- render data, controllers, snapshots, and operation payloads need separate + vocabulary; +- web component source roots used `ui_base`, `ui_core`, and `ui_studio`, which + repeated "UI" in a crate that is already the renderer; +- `Ui*` data consumed by `App*` components made the data/component boundary + harder to explain. + +Studio needs a durable mental model before the app grows much larger. + +## Decision + +Rename the headless Studio application crate from `lpa-studio-ux` to +`lpa-studio-core`. + +```text +lpa-link / lpa-client / protocol services + owned by +lpa-studio-core + rendered by +lpa-studio-web, future CLI, desktop, tests, and agents +``` + +`lpa-studio-core` owns Studio application state, controller logic, app policy, +typed operations, action offerings, snapshots, live updates, and view data. It +is UI-independent, but its role is broader than "UX". + +Keep `lpa-studio-web` as the browser/Dioxus renderer. The `studio-` prefix +continues to separate the actual Studio application crates from helper app +crates such as `lpa-link` and `lpa-client`. + +Use source module paths for layer: + +```text +base -> primitive UI/data building blocks +core -> reusable data-driven app/view/action substrate +app -> the actual Studio product/application layer +``` + +In `lpa-studio-core`: + +- `core/` holds generic view/action/node substrate. +- `app/` holds Studio ownership areas such as `studio`, `device`, `link`, + `server`, and `project`. +- no empty `base/` module is created until there are truly base core concepts. + +In `lpa-studio-web`: + +- `base/` holds generic Dioxus primitives. +- `core/` renders generic app-core view/action data. +- `app/` holds Studio-specific surfaces and workflows. +- `exploration/` holds spikes and mockups. + +Story routes remain product-facing. Source files under `src/app` still generate +`studio/*` story routes and baseline file names. + +The public type rename is intentionally deferred. Current names such as +`StudioUx`, `UiAction`, `UiPaneView`, and `AppPane` remain for compatibility +while the crate/layer rename lands. The long-term direction is: + +- stateful owners use `*Controller`; +- inert render data uses `*View`; +- command payloads use `*Op`; +- cloneable read models use `*Snapshot`; +- web renderers use plain component/domain nouns rather than `App*` wrappers + where practical. + +## Consequences + +- The headless Studio crate name now describes application ownership instead of + implying a narrow UX-helper layer. +- Imports read as `lpa_studio_core::{StudioUx, StudioView, UiAction}` until the + later public type cleanup. +- Web source roots are shorter and align with dependency direction: + `base <- core <- app`. +- Historical ADRs still mention `lpa-studio-ux`; this ADR is the current naming + update instead of rewriting those historical decisions in place. +- A follow-up rename pass is still needed to align `Ui*`, `*Ux`, and `App*` + type names with the new role vocabulary. + +## Alternatives Considered + +- Rename the crates to `lpa-core` and `lpa-web`. + - Rejected for now because `studio-` usefully distinguishes the actual Studio + app from lower-level helper/service crates. +- Use `lpa-studio-app` for the headless crate. + - Rejected because the web crate is also an app, while `core` better conveys + UI-independent application ownership. +- Keep `lpa-studio-ux`. + - Rejected because the name made the crate sound narrower than its actual + responsibility for service ownership, policy, dispatch, and view data. +- Complete all public type renames in the same pass. + - Deferred to keep this architectural rename reviewable and avoid mixing + package/module structure with larger API vocabulary churn. + +## Follow-Ups + +- Rename `Ui*` render-data types toward `*View` names. +- Rename `*Ux` stateful owners toward `*Controller` names. +- Rename `App*` Dioxus renderers toward plain component/domain nouns. +- Untangle app-specific body variants from generic view data where necessary so + the `base/core/app` layering is enforceable rather than merely documented. diff --git a/docs/adr/2026-06-25-studio-assets-are-config-slot-bodies.md b/docs/adr/2026-06-25-studio-assets-are-config-slot-bodies.md new file mode 100644 index 000000000..6e945e680 --- /dev/null +++ b/docs/adr/2026-06-25-studio-assets-are-config-slot-bodies.md @@ -0,0 +1,42 @@ +# ADR: Studio Assets Are Config Slot Bodies + +## Status + +Accepted + +## Context + +The Studio node UI promotes several kinds of slot data into first-class visual +sections. Products and produced values are shown above the config tree, while +asset slots such as GLSL sources need top-level treatment so they can eventually +open richer editors. + +The first asset model used a separate `UiConfigAsset` DTO and a dedicated web +component path. That made assets look and behave like a parallel concept even +though they are slots underneath. It also duplicated detail affordance, +expansion, and story coverage that config slots already need. + +## Decision + +Promoted Studio assets are represented as config slots with an asset body: +`UiConfigSlotBody::Asset(UiSlotAsset)`. + +The controller/projection layer may still extract asset slots into a dedicated +node section for layout, but the section items remain `UiConfigSlot`s. Web +rendering uses the same slot row, slot detail button, slot affordance priority, +and expansion behavior as other config slots. + +Asset-specific UI belongs inside the asset body expansion. The current web +surface is a compact editor-like `pre` block; richer GLSL, SVG, or resource +editors can replace that expansion without introducing a second asset DTO path. + +## Consequences + +Node UI code has one config-slot detail model for normal values, records, and +assets. This keeps validation, binding, dirty-state affordances, and story +coverage aligned as the node editor grows. + +Asset sections can still be visually prominent, but they should not use a +separate boxed asset panel or compatibility wrapper. If asset editing later +needs additional state, add it to the asset slot body or to web-local editor +state rather than recreating a parallel config asset model. diff --git a/docs/adr/2026-06-25-studio-tailwind-styling.md b/docs/adr/2026-06-25-studio-tailwind-styling.md new file mode 100644 index 000000000..3c6b18825 --- /dev/null +++ b/docs/adr/2026-06-25-studio-tailwind-styling.md @@ -0,0 +1,50 @@ +# ADR: Studio Web Uses Tailwind-First Semantic Styling + +## Status + +Accepted + +## Context + +`lpa-studio-web` had grown a large `src/style.css` file that mixed theme +tokens, reusable component styling, app layout, storybook chrome, exploration +stories, media queries, and animations. That made the component library harder +to scan because the visual behavior of a Dioxus component often lived far away +from the markup that owned it. + +The Studio UI is becoming a reusable app/component surface driven by semantic +`Ui*` data from `lpa-studio-core`. The styling model needs to support that same +data-driven mental model without duplicating broad selector families for every +component state. + +## Decision + +Studio web styling is Tailwind-first. + +- Components should prefer Tailwind utility classes in Dioxus markup. +- Tailwind tokens should be semantic, shadcn-style names such as `background`, + `card`, `border`, `muted-foreground`, `accent`, and status color families. +- Theme values remain centralized as Studio CSS variables and are exposed to + Tailwind from `lp-app/lpa-studio-web/tailwind.css`. +- The existing `tw:` prefix remains during the migration so utility classes can + coexist with historical `ux-*` classes without ambiguity. +- Simple static styles should stay as direct utility strings. +- Repeated stateful variants should use small Rust helper functions rather than + broad CSS selector families. +- `src/style.css` should be limited to theme variables, base rules, keyframes, + browser/measurement behavior, and explicitly transitional legacy surfaces. + +## Consequences + +Most reusable Studio components now carry their visual structure locally in the +Dioxus component that renders them. This makes component behavior easier to +review and keeps visual variants close to the `Ui*` state that drives them. + +Story baselines remain the visual regression safety net for this migration. +Any change under `lp-app/lpa-studio-web` should continue to run +`just studio-story-baselines-if-needed` before commit. + +The exploration node UI still has a substantial historical `ux-node-ui-*` CSS +surface. It is intentionally treated as transitional follow-up rather than +expanded into this migration. + diff --git a/docs/adr/2026-06-26-control-product-preview-probes.md b/docs/adr/2026-06-26-control-product-preview-probes.md new file mode 100644 index 000000000..dcad24caf --- /dev/null +++ b/docs/adr/2026-06-26-control-product-preview-probes.md @@ -0,0 +1,98 @@ +# ADR: Control Product Preview Probes + +## Status + +Accepted + +## Context + +Studio needs to render fixture control products in the node UI. A fixture +control product is not a visual texture; it is the native device-control sample +buffer that an output node or physical hardware consumes. For a fixture, the +same buffer can also be shown to a user as lamps in a physical-ish layout. + +There are two related but distinct questions: + +- What do the native control samples mean? +- Where should logical lamps be drawn in Studio? + +The existing engine `ControlLayout` answered the first question with spans such +as RGB pixels and color order. Fixture mapping code answered the second question +internally through mapping points, but that geometry was not exposed to Studio. + +Studio also should not receive large mapping geometry every refresh when only +the sample values changed. + +## Decision + +Control-product previews use request-scoped project probes. The normal output +path stays focused on native control rendering and does not carry UI-only +display geometry unless a probe asks for it. + +The durable vocabulary lives in `lpc-model`: + +- `ControlSampleLayout` describes what native samples mean. +- `ControlSampleSpan` describes a contiguous native sample range. +- `ControlSampleEncoding` describes the interpretation of a range, initially + RGB pixels with the existing fixture `ColorOrder`, or raw samples. +- `ControlDisplayLayout` describes optional human-facing geometry. +- `ControlLayout2d` and `ControlLamp2d` describe the first supported display + layout: normalized 2D lamp positions and radii. + +The wire protocol adds a `ControlProductProbeRequest` and +`ControlProductProbeResult`. Probe responses return native sample bytes, sample +layout metadata, and optional display layout metadata. For the first slice the +native wire sample format is little-endian `u16`. + +Studio decodes generic RGB sample layout in the web UI instead of asking the +runtime to render a separate RGB8 preview. This keeps the UI preview grounded in +the same sample data that would go to hardware or a debugging tool. + +Display layout is separately revisioned. The revision represents the displayed +geometry, not merely fixture mapping config. It must change when any +layout-affecting input changes, such as mapping config, render size/aspect, or +radius calculations. Studio sends its known display-layout revision and the +server can respond with `Unchanged` while still returning new native sample +bytes. + +Control display layout is an optional product capability. The engine asks the +control node through the control-product path; it does not downcast to fixture +internals. Fixtures implement the first `Layout2d` display capability. + +## Consequences + +Studio can show live fixture-like control previews while preserving native +control sample inspection. + +The frontend needs generic decoders for supported control encodings. Initially +that means `U16` RGB pixel spans with the existing fixture `ColorOrder`. + +Large fixture geometry is not resent every refresh once Studio has the current +display-layout revision. + +The model vocabulary can support future control layouts and encodings without +turning fixture-specific mapping logic into frontend code. + +## Alternatives Considered + +- Return display-ready RGB8 lamp colors from the probe. + Rejected because Studio would no longer be inspecting the exact native data + sent to output hardware. +- Put control preview DTOs only in `lpc-wire`. + Rejected because sample layout and display layout are core control product + vocabulary, not merely transport shapes. +- Add fixture-specific probe data. + Rejected because the Studio UI should consume generic product display + metadata and avoid recomputing or understanding fixture mapping internals. +- Attach display layout to every normal control render. + Rejected because output nodes do not need UI geometry and should not pay for + it unless explicitly requested. + +## Follow-ups + +- Add RGBW, white-only, CCT, or other control encodings when the engine model + supports them. +- Add 1D, 3D, SVG-shape, or mesh display layouts. +- Generate control product stories from real mini-project data instead of + hand-built DTO fixtures. +- Extend non-fixture control products with display layouts where useful. diff --git a/docs/adr/2026-06-26-studio-project-editor-controller-tree.md b/docs/adr/2026-06-26-studio-project-editor-controller-tree.md new file mode 100644 index 000000000..5ad8ed49b --- /dev/null +++ b/docs/adr/2026-06-26-studio-project-editor-controller-tree.md @@ -0,0 +1,123 @@ +# ADR: Studio Project Editor Controller Tree + +## Status + +Accepted + +## Context + +The Studio project editor is moving from a mock-ish project card surface toward +the real node editor UI. The synced LightPlayer client model already has a +`ProjectView` mirror with a node tree, slot mirror, shape registry, resources, +and runtime summary. That mirror is protocol/client state: it should not own +Studio interaction state, product subscription intent, slot expansion, or future +edit/binding behavior. + +The node UI is also intentionally data-driven. `UiNodeView` and its child +`Ui*` structs let stories, tests, and web components render the same concepts +without requiring a live project connection. + +Studio therefore needs a middle layer between `ProjectView` and `UiNodeView`: +a UI-framework agnostic controller tree that reconciles against mirror changes, +preserves local state for stable model addresses, and emits DTOs later. + +The existing `ControllerId` format used dotted paths such as +`studio.project.node.4.slot.palette.primary`. That collided with existing model +path grammars: `TreePath` uses `/` and `.`, while `SlotPath` uses `.` and +bracketed map keys. Real slot paths such as `params["phase.offset"].label` +made dotted controller ids ambiguous. + +## Decision + +Studio project editing keeps four trees with distinct roles: + +- **Mirror tree:** `lpc_view::ProjectView`, updated from LightPlayer sync + responses. It has no Studio UI concepts. +- **Controller tree:** Studio project/node/slot controllers in + `lpa-studio-core`. This is the UI-independent business logic layer. It owns + reconciliation, action addressability, local interaction state, and future + product subscription/edit/binding intent. +- **DTO tree:** render data such as `UiNodeView` and `UiConfigSlot`. It is pure + data for stories and renderers. +- **Component tree:** Dioxus/web components and browser-local view state such + as popovers, animations, and transient layout state. + +Project controller internals are organized by domain object: + +```text +app/project/node/* +app/project/slot/* +``` + +`ProjectController` is the synthetic root of the Studio controller tree. It +owns root `NodeController` values directly. Node controllers own child node +controllers and root slot controllers. Slot controllers own child slot +controllers. A separate public `ProjectEditorTree` aggregate and public +descriptor layer are intentionally not part of the architecture. + +Project nodes use a split identity model: + +- `ProjectNodeAddress` wraps the stable authored `TreePath` and is the + controller key. +- `ProjectNodeTarget` carries both the stable address and current runtime + `NodeId` for action targets. + +Project slots are addressed by: + +```text +ProjectSlotAddress { + node: ProjectNodeAddress, + root: ProjectSlotRoot, + path: SlotPath, +} +``` + +Slot controllers are recursive and exist for container and leaf slots. This +gives expansion state, future dirty state, validation, binding, and edit actions +an addressable home. + +`ControllerId` now uses `|` as its segment separator instead of `.`. Project +targets can therefore carry canonical model paths as readable payload segments: + +```text +studio|project|node|nid|3|path|/demo.project/orbit.shader +studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|config.brightness +``` + +Only payloads containing `|` or `%` need escaping. + +## Consequences + +The project editor can preserve local node and slot state across mirror updates +by matching stable model addresses instead of current runtime ids. + +Action targets still carry `NodeId`, so future server operations do not need to +rediscover runtime handles from path alone. + +The controller id grammar no longer conflicts with `TreePath`, `SlotPath`, or +binding/product endpoint conventions. Existing Studio dispatch remains +hierarchical, but ids are now visibly controller-local rather than pretending to +be model paths. + +The current pre-M3 project workspace can continue using legacy node id targets +while new typed targets are introduced. That compatibility is temporary; later +milestones will project real `ProjectView` data through the controller tree and +remove old project node/slot DTOs. + +The controller tree does not render directly. Rendering still goes through DTOs +so the component library and story fixtures remain data-driven. + +## Alternatives Considered + +- Keep dotted `ControllerId` paths and percent-encode model paths inside them. + Rejected because it makes common node and slot paths unreadable and keeps the + controller grammar in conflict with model grammars. +- Key node controllers by `NodeId`. + Rejected because `NodeId` is runtime identity. It is useful for actions but + not stable enough to preserve local Studio state across reconnects/reloads. +- Put all new types under an `app/project/controller_tree/` module. + Rejected because it names the mechanism instead of the domain vocabulary. + Node and slot concepts deserve their own module map. +- Render directly from `ProjectView`. + Rejected because `ProjectView` is a protocol mirror, not an owner of Studio + interaction state or web-independent UI policy. diff --git a/docs/deploy/studio-pages.md b/docs/deploy/studio-pages.md index 8736c78c7..d68886812 100644 --- a/docs/deploy/studio-pages.md +++ b/docs/deploy/studio-pages.md @@ -96,6 +96,13 @@ Beta and demo: ## Build And Smoke Locally +Studio builds require `dx` from `dioxus-cli` in addition to `wasm-bindgen-cli` +and `espflash`: + +```bash +cargo install dioxus-cli --version 0.7.9 --locked +``` + Studio production artifact: ```bash diff --git a/docs/style/language.md b/docs/style/language.md new file mode 100644 index 000000000..efe45b80d --- /dev/null +++ b/docs/style/language.md @@ -0,0 +1,77 @@ +# Lightplayer Language + +## Product Names + +Use **Lightplayer** as the umbrella product and brand name. Treat **Light +Player** as descriptive prose only when the sentence is intentionally about +playing light, not the product name. + +Use surface names when the distinction matters: + +- **Lightplayer Studio** for the frontend design and control app. +- **Lightplayer Firmware** for the ESP32 device firmware. +- **Lightplayer Engine** for the runtime, compiler, and node graph layer. +- **Lightplayer Compiler** for the GLSL to LPIR to machine-code pipeline. +- **Lightplayer Native** only when the custom RV32 backend needs a proper name. + +In general prose, prefer **Lightplayer** when the surface does not matter. Do +not make **Studio** stand alone as the public product name; internally, `studio` +is fine as a short implementation name. + +## Studio UI Language + +Studio UI should present the user's working model first and the system model +second. + +Use the fewest words that preserve meaning. Main UI should name the thing, +show the value, and stop. Longer explanations belong in details, source, or +debug surfaces. + +## Names Before Types + +Prefer names, labels, and authored concepts in primary UI. + +- Use `output visual`, not `Visual product`. +- Use `blast shader`, not a large `Shader` badge competing with `blast`. +- Use `time`, `brightness`, and `center`, not technical slot categories as the + first thing the user reads. + +Types are supporting language. They can appear inline, in lowercase, near the +name when they help orientation. + +## Keep Technical Detail In Debug Surfaces + +Revision numbers, wire slot roles, internal product refs, binding mechanics, +and implementation vocabulary belong in debug/source panes unless the user is +explicitly editing that concept. + +Main-level node UI should avoid showing labels such as `rev 42`, `consumed`, +`uniform`, `binding`, or `ProductRef`. Those facts are still important, but +they should live in a technical tree or debug pane where their density is +useful. + +## Product Presentation + +For visual and control products, the preview is the main event. The main view +should give the preview most of the space and use a restrained single-line +caption: + +```text +output visual (128 x 72) +``` + +Use formal type names such as `VisualProduct` or `ControlProduct` only in +debug/source surfaces. + +## Slots + +Slot rows should read like fields in a familiar editor: + +```text +[source] time ../playlist#entry_time +[source] brightness 0.72 +``` + +The source icon can indicate direct value, binding, or child pointer. Detailed +binding source metadata and revisions should be discoverable, but not always +visible in the main node surface. diff --git a/docs/style/ui.md b/docs/style/ui.md new file mode 100644 index 000000000..a8b64dcdd --- /dev/null +++ b/docs/style/ui.md @@ -0,0 +1,122 @@ +# Studio UI Style + +Studio UI should be shaped around what the user is doing, not around the +internal shape of the data. + +## Less Is More + +The default rule is to show nothing we do not have to show. + +Every visible label, border, icon, heading, badge, and metric competes with the +thing the user is trying to understand or change. Add UI only when it improves +orientation, comparison, decision-making, or action. + +When in doubt: + +- prefer fewer labels; +- prefer one strong focal surface over several decorated containers; +- prefer quiet inline text over badges; +- prefer whitespace over panel chrome; +- remove explanatory copy once the interaction itself is clear. + +## Avoid Data-Shaped Nesting + +Do not add a visual container every time the underlying model has another +object, enum, record, slot root, or view node. Deep nesting makes the UI feel +like a schema browser instead of a tool. + +Prefer the shallowest presentation that preserves meaning: + +- If a node has one primary produced visual, show the visual directly. +- If a section has one child, do not wrap it in an extra titled box just to + mirror the data structure. +- If several technical facts describe one thing, prefer a single quiet caption + over nested labels and badges. + +Extra borders, cards, and panels should indicate a meaningful interaction or +separate workspace, not merely another layer of data. + +## One Concept, One Frame + +A visible frame should usually mean one user-facing concept: + +- A node window frames a node. +- A product preview frames the image, control strip, or other produced output. +- A modal frames a temporary focused task. + +Avoid putting a framed product inside a framed presentation section inside a +framed node unless each frame has an obvious job from the user's perspective. + +## Progressive Technical Detail + +Main UI should present the useful surface first. Debug panes, source panes, +inspectors, and tooltips can expose exact internal detail. + +For example, main node UI can show: + +```text +output visual 128 x 72 +``` + +The debug pane can show: + +```text +state.output = ProductRef::Visual(node=8, output=0) +revision = 102 +slot root = node.8.state +``` + +The user can still inspect the system precisely, but the everyday view stays +calm. + +## Details On Demand + +Technical detail should usually be available, not always visible. + +Prefer a small details affordance, inspector, source tab, debug tab, popover, or +tooltip over permanently showing implementation facts in the main surface. A +details affordance is useful when the information is sometimes important but +would distract from the normal workflow. + +Good candidates for details-on-demand: + +- revision numbers; +- slot roots and exact slot paths; +- internal product references; +- binding resolution details; +- source file locations; +- transport/probe diagnostics; +- raw serialized values. + +Main UI can show the edited or observed value. Details UI can show why it has +that value, where it came from, and how the runtime represents it. + +## Icons + +Use icons as semantic affordances, not decoration. + +Prefer shared Studio icon components over ad hoc glyphs, emoji, text badges, or +CSS-drawn symbols. Keep common icon sizes stable so live values do not change +button, header, or row dimensions. + +Status must never rely on color alone. Pair tone with a distinct icon or shape, +and make the details available from the same trigger when the status matters. + +Use text labels for primary meaning. Icons should speed recognition, mark a +compact control, or clarify repeated action types; they should not make the user +guess. + +## Stable Layout + +Dynamic values should not cause page reflow in normal operation. + +Status, errors, metrics, revisions, probe results, frame counts, and other live +runtime data should fit inside reserved space, truncate, scroll, or move into a +details surface instead of changing component height or pushing nearby UI around. +This is especially important in node windows, device panels, tables, toolbars, +and other surfaces users scan repeatedly. + +Acceptable layout changes are mostly tied to explicit local user action, such as +switching a tab, expanding a details panel, or opening a popup. Remote changes, +like another user editing a project or a device changing state, should preserve +the current reading surface as much as possible. diff --git a/examples/fiber-headband/clock.toml b/examples/fiber-headband/clock.toml new file mode 100644 index 000000000..3e4ef317c --- /dev/null +++ b/examples/fiber-headband/clock.toml @@ -0,0 +1 @@ +kind = "Clock" diff --git a/examples/fiber-headband/fixture.toml b/examples/fiber-headband/fixture.toml new file mode 100644 index 000000000..bbcf3c11d --- /dev/null +++ b/examples/fiber-headband/fixture.toml @@ -0,0 +1,28 @@ +kind = "Fixture" +color_order = "rgb" +brightness = 255 +gamma_correction = false +sampling = "direct" +transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + +[bindings.input] +source = "bus#visual.out" + +[bindings.output] +target = "bus#control.out" + +[render_size] +width = 2 +height = 1 + +[mapping] +kind = "PathPoints" +sample_diameter = 1.0 + +[mapping.paths.0] +kind = "PointList" +first_channel = 0 + +[mapping.paths.0.points] +0 = [0.25, 0.5] +1 = [0.75, 0.5] diff --git a/examples/fiber-headband/output.toml b/examples/fiber-headband/output.toml new file mode 100644 index 000000000..03b90867c --- /dev/null +++ b/examples/fiber-headband/output.toml @@ -0,0 +1,12 @@ +kind = "Output" +endpoint = "ws281x:rmt:D10" + +[bindings.input] +source = "bus#control.out" + +[options] +white_point = [1.0, 1.0, 1.0] +brightness = 1.0 +interpolation_enabled = true +dithering_enabled = false +lut_enabled = false diff --git a/examples/fiber-headband/project.toml b/examples/fiber-headband/project.toml new file mode 100644 index 000000000..586212a86 --- /dev/null +++ b/examples/fiber-headband/project.toml @@ -0,0 +1,14 @@ +kind = "Project" +name = "fiber-headband" + +[nodes.output] +ref = "./output.toml" + +[nodes.clock] +ref = "./clock.toml" + +[nodes.shader] +ref = "./shader.toml" + +[nodes.fixture] +ref = "./fixture.toml" diff --git a/examples/fiber-headband/shader.glsl b/examples/fiber-headband/shader.glsl new file mode 100644 index 000000000..87a958f31 --- /dev/null +++ b/examples/fiber-headband/shader.glsl @@ -0,0 +1,17 @@ +layout(binding = 0) uniform vec2 outputSize; +layout(binding = 1) uniform float time; + +vec3 rainbow(float t) { + float r = 0.33333; + vec3 v = abs(mod(fract(1.0 - t) + vec3(0.0, 1.0, 2.0) * r, 1.0) * 2.0 - 1.0); + return v * v * (3.0 - 2.0 * v); +} + +vec4 render(vec2 pos) { + float led = 0.0; + if (pos.x >= 1.0) { + led = 1.0; + } + float hue = fract(time * 0.12 + led * 0.5); + return vec4(rainbow(hue), 1.0); +} diff --git a/examples/fiber-headband/shader.toml b/examples/fiber-headband/shader.toml new file mode 100644 index 000000000..24080badb --- /dev/null +++ b/examples/fiber-headband/shader.toml @@ -0,0 +1,18 @@ +kind = "Shader" +source = { path = "shader.glsl" } +render_order = 0 + +[bindings.output] +target = "bus#visual.out" + +[glsl_opts] +add_sub = "wrapping" +mul = "wrapping" +div = "reciprocal" + +[consumed.time] +kind = "value" +value = "f32" +default = 0.0 +label = "Time" +description = "Project clock time in seconds" diff --git a/justfile b/justfile index 6786687f3..480a14924 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,7 @@ rv32_firmware_packages := "fw-esp32" fw_esp32_profile := "release-esp32" fw_esp32_elf := "target/" + rv32_target + "/" + fw_esp32_profile + "/fw-esp32" lps_dir := "lp-shader" +studio_assets_dir := "target/studio-web-assets" # Default recipe - show available commands default: @@ -184,43 +185,103 @@ fw-browser-smoke: fw-browser-build # Studio web app # ============================================================================ -studio-web-dev-build: install-wasm32-target +studio-fw-browser-sidecar profile="debug": install-wasm32-target #!/usr/bin/env bash set -euo pipefail - echo "Building fw-browser for wasm32 debug..." - cargo build -p fw-browser --target wasm32-unknown-unknown - echo "Building lpa-studio-web for wasm32 debug with stories..." - cargo build -p lpa-studio-web --target wasm32-unknown-unknown --features stories if ! command -v wasm-bindgen >/dev/null 2>&1; then echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" exit 1 fi - echo "Generating fw-browser debug JS glue..." - wasm-bindgen target/wasm32-unknown-unknown/debug/fw_browser.wasm \ - --out-dir lp-fw/fw-browser/www/pkg --target web - echo "Generating Studio web debug JS glue..." - mkdir -p lp-app/lpa-studio-web/public/pkg - wasm-bindgen target/wasm32-unknown-unknown/debug/lpa-studio-web.wasm \ - --out-dir lp-app/lpa-studio-web/public/pkg --target web - echo "Copying fw-browser wasm artifacts..." - cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js - cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm - echo "Artifacts: lp-app/lpa-studio-web/public/ (debug build)" - -studio-story-pngs: studio-web-dev-build + + case "{{ profile }}" in + debug) + cargo_profile="dev" + wasm_file="target/wasm32-unknown-unknown/debug/fw_browser.wasm" + ;; + release) + cargo_profile="release" + wasm_file="target/wasm32-unknown-unknown/release/fw_browser.wasm" + ;; + *) + echo "unknown fw-browser sidecar profile: {{ profile }}" >&2 + exit 2 + ;; + esac + + out_dir="{{ studio_assets_dir }}/{{ profile }}/pkg" + echo "Building fw-browser for wasm32 ${cargo_profile}..." + if [[ "{{ profile }}" == "release" ]]; then + cargo build -p fw-browser --target wasm32-unknown-unknown --release + else + cargo build -p fw-browser --target wasm32-unknown-unknown + fi + rm -rf "${out_dir}" + mkdir -p "${out_dir}" + echo "Generating fw-browser ${cargo_profile} JS glue..." + wasm-bindgen "${wasm_file}" --out-dir "${out_dir}" --target web + echo "Artifacts: ${out_dir}/" + +studio-web-copy-sidecars profile out_dir include_firmware="false": + #!/usr/bin/env bash + set -euo pipefail + sidecar_dir="{{ studio_assets_dir }}/{{ profile }}/pkg" + if [[ ! -f "${sidecar_dir}/fw_browser.js" || ! -f "${sidecar_dir}/fw_browser_bg.wasm" ]]; then + echo "missing fw-browser sidecar artifacts in ${sidecar_dir}" >&2 + exit 1 + fi + + mkdir -p "{{ out_dir }}/pkg" + cp "${sidecar_dir}/fw_browser.js" "{{ out_dir }}/pkg/fw_browser.js" + cp "${sidecar_dir}/fw_browser_bg.wasm" "{{ out_dir }}/pkg/fw_browser_bg.wasm" + + if [[ "{{ include_firmware }}" == "true" ]]; then + firmware_dir="{{ studio_assets_dir }}/firmware/esp32c6" + if [[ ! -f "${firmware_dir}/manifest.json" ]]; then + echo "missing Studio firmware assets in ${firmware_dir}" >&2 + exit 1 + fi + mkdir -p "{{ out_dir }}/firmware/esp32c6" + cp "${firmware_dir}/manifest.json" "{{ out_dir }}/firmware/esp32c6/manifest.json" + cp "${firmware_dir}"/*.bin "{{ out_dir }}/firmware/esp32c6/" + fi + +studio-web-dev-build: install-wasm32-target + #!/usr/bin/env bash + set -euo pipefail + just studio-fw-browser-sidecar debug + echo "Building lpa-studio-web with dx for wasm32 debug with stories..." + rm -rf target/dx/lpa-studio-web/debug/web/public + dx build --web -p lpa-studio-web --features stories --debug-symbols false + just studio-web-copy-sidecars debug target/dx/lpa-studio-web/debug/web/public false + echo "Artifacts: target/dx/lpa-studio-web/debug/web/public/ (debug build)" + +studio-web-story-build: install-wasm32-target + #!/usr/bin/env bash + set -euo pipefail + just studio-fw-browser-sidecar debug + echo "Building lpa-studio-web with dx for story capture..." + rm -rf target/dx/lpa-studio-web/release/web/public + dx build --web -p lpa-studio-web --features stories --release --debug-symbols false + just studio-web-copy-sidecars debug target/dx/lpa-studio-web/release/web/public false + echo "Artifacts: target/dx/lpa-studio-web/release/web/public/ (story build)" + +studio-story-pngs: studio-web-story-build #!/usr/bin/env bash set -euo pipefail - node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs + STUDIO_STORY_SITE_DIR="target/dx/lpa-studio-web/release/web/public" \ + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs -studio-story-baselines: studio-web-dev-build +studio-story-baselines: studio-web-story-build #!/usr/bin/env bash set -euo pipefail - node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs baselines + STUDIO_STORY_SITE_DIR="target/dx/lpa-studio-web/release/web/public" \ + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs baselines -studio-story-check: studio-web-dev-build +studio-story-check: studio-web-story-build #!/usr/bin/env bash set -euo pipefail - node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs check + STUDIO_STORY_SITE_DIR="target/dx/lpa-studio-web/release/web/public" \ + node lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs check studio-story-baselines-if-needed: #!/usr/bin/env bash @@ -242,15 +303,30 @@ studio-story-baselines-if-needed: printf '%s\n' "$changed" | sed 's/^/ /' just studio-story-baselines -studio-dev: studio-web-dev-build +studio-dev: install-wasm32-target #!/usr/bin/env bash set -euo pipefail + just studio-fw-browser-sidecar debug port="${STUDIO_WEB_PORT:-2820}" + public_dir="target/dx/lpa-studio-web/debug/web/public" + sidecar_dir="{{ studio_assets_dir }}/debug/pkg" + sync_sidecars() { + [[ -d "${public_dir}" ]] || return 0 + mkdir -p "${public_dir}/pkg" + cp "${sidecar_dir}/fw_browser.js" "${public_dir}/pkg/fw_browser.js" + cp "${sidecar_dir}/fw_browser_bg.wasm" "${public_dir}/pkg/fw_browser_bg.wasm" + } + ( + while true; do + sync_sidecars || true + sleep 1 + done + ) & + sync_pid="$!" + trap 'kill "${sync_pid}" 2>/dev/null || true' EXIT echo "Serving LightPlayer Studio dev build at http://127.0.0.1:${port}/" echo "Storybook: http://127.0.0.1:${port}/#/stories" - echo "Re-run just studio-dev after Rust changes; generated artifacts are ignored." - cd lp-app/lpa-studio-web/public - python3 -m http.server "${port}" --bind 127.0.0.1 + dx serve --web -p lpa-studio-web --features stories --port "${port}" --addr 127.0.0.1 --open false studio-firmware-package-esp32c6: install-rv32-target #!/usr/bin/env bash @@ -263,7 +339,7 @@ studio-firmware-package-esp32c6: install-rv32-target firmware_id="lightplayer-esp32c6-server" display_name="LightPlayer ESP32-C6 server firmware" features="esp32c6,server" - out_dir="lp-app/lpa-studio-web/public/firmware/esp32c6" + out_dir="{{ studio_assets_dir }}/firmware/esp32c6" image_name="fw-esp32c6-server-merged.bin" image_file="${out_dir}/${image_name}" manifest_file="${out_dir}/manifest.json" @@ -306,23 +382,15 @@ studio-firmware-package-esp32c6: install-rv32-target echo "Firmware manifest: ${manifest_file}" echo "Firmware image: ${image_file} (${size_bytes} bytes, sha256=${sha256})" -studio-web-build: install-wasm32-target fw-browser-build studio-firmware-package-esp32c6 +studio-web-build: install-wasm32-target studio-firmware-package-esp32c6 #!/usr/bin/env bash set -euo pipefail - echo "Building lpa-studio-web for wasm32..." - cargo build -p lpa-studio-web --target wasm32-unknown-unknown --release - if ! command -v wasm-bindgen >/dev/null 2>&1; then - echo "wasm-bindgen not found. Install: cargo install wasm-bindgen-cli --version 0.2.114" - exit 1 - fi - echo "Generating Studio web JS glue..." - mkdir -p lp-app/lpa-studio-web/public/pkg - wasm-bindgen target/wasm32-unknown-unknown/release/lpa-studio-web.wasm \ - --out-dir lp-app/lpa-studio-web/public/pkg --target web - echo "Copying fw-browser wasm artifacts..." - cp lp-fw/fw-browser/www/pkg/fw_browser.js lp-app/lpa-studio-web/public/pkg/fw_browser.js - cp lp-fw/fw-browser/www/pkg/fw_browser_bg.wasm lp-app/lpa-studio-web/public/pkg/fw_browser_bg.wasm - echo "Artifacts: lp-app/lpa-studio-web/public/ (index.html, pkg/)" + just studio-fw-browser-sidecar release + echo "Building lpa-studio-web with dx for wasm32 release..." + rm -rf target/dx/lpa-studio-web/release/web/public + dx build --web -p lpa-studio-web --release --debug-symbols false + just studio-web-copy-sidecars release target/dx/lpa-studio-web/release/web/public true + echo "Artifacts: target/dx/lpa-studio-web/release/web/public/ (index.html, assets/, pkg/, firmware/)" # Build a clean GitHub Pages artifact for Studio. studio-web-deploy-dir channel="local" out_dir="target/pages/studio" domain="": @@ -350,7 +418,7 @@ studio-web: studio-web-build set -euo pipefail port="${STUDIO_WEB_PORT:-2820}" echo "Serving LightPlayer Studio at http://127.0.0.1:${port}/" - cd lp-app/lpa-studio-web/public + cd target/dx/lpa-studio-web/release/web/public python3 -m http.server "${port}" --bind 127.0.0.1 # ============================================================================ diff --git a/lp-app/README.md b/lp-app/README.md index 76a3a6aae..e169144a3 100644 --- a/lp-app/README.md +++ b/lp-app/README.md @@ -19,8 +19,8 @@ logic. server or firmware target. - `lpa-link` — low-level endpoint/link layer for discovery, status, management, diagnostics, logs, and opening server/client connections. -- `lpa-studio-ux` — UI-independent Studio UX/controller layer. It owns - lower-level services and exposes Studio views, node states, typed actions, +- `lpa-studio-core` — headless Studio application core. It owns lower-level + services and exposes Studio controllers, views, node states, typed actions, logs, and project summaries to UI shells. - `lpa-studio-web` — static Dioxus browser shell for the first Studio UI slice. - `web-demo` — browser demo and tooling for the shader pipeline. diff --git a/lp-app/lpa-link/README.md b/lp-app/lpa-link/README.md index 2e025aaa0..3882a47b0 100644 --- a/lp-app/lpa-link/README.md +++ b/lp-app/lpa-link/README.md @@ -96,7 +96,7 @@ Browser providers own their browser resource bindings: - `browser-serial-esp32` owns Web Serial permission/open/release/close and ESP32 probe/flash bindings. -`lpa-studio-ux` adapts provider send/receive streams into +`lpa-studio-core` adapts provider send/receive streams into `lpa-client::ClientIo`; UI shells should not reimplement provider resource ownership, request ids, response correlation, server error handling, heartbeat/log handling, or project deploy ordering. diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js index 6b33c2a8f..5cd89a21b 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.js @@ -1,13 +1,13 @@ -import { BrowserEsp32DeviceController } from "/lpa-link/browser_esp32_device_controller.js"; - const sessions = new Map(); let nextSessionId = 1; +let controllerModulePromise = null; export function isSupported() { - return BrowserEsp32DeviceController.isSupported(); + return Boolean(globalThis.navigator?.serial); } export async function requestPort() { + const { BrowserEsp32DeviceController } = await loadControllerModule(); const { port, label } = await BrowserEsp32DeviceController.requestPort(); const id = nextSessionId++; sessions.set(id, new BrowserEsp32DeviceController({ port, label })); @@ -47,6 +47,13 @@ export async function releasePort(id) { await session.releaseProtocol(); } +export async function resetAndRead(id, baudRate, readWindowMs) { + return requireSession(id).resetAndRead({ + baudRate, + readWindowMs, + }); +} + export function getPort(id) { return requireSession(id).port; } @@ -58,3 +65,12 @@ function requireSession(id) { } return session; } + +function loadControllerModule() { + controllerModulePromise ??= import(controllerModulePath()); + return controllerModulePromise; +} + +function controllerModulePath() { + return "/lpa-link/browser_esp32_device_controller.js"; +} diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs index dbc286380..da2f150bc 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/browser_serial.rs @@ -24,6 +24,11 @@ pub struct BrowserSerialProtocolProgress { pub percent: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BrowserSerialResetResult { + pub logs: Vec, +} + #[wasm_bindgen(module = "/src/providers/browser_serial_esp32/browser_serial.js")] extern "C" { #[wasm_bindgen(js_name = isSupported)] @@ -47,6 +52,9 @@ extern "C" { #[wasm_bindgen(js_name = releasePort)] fn js_release(id: u32) -> Promise; + #[wasm_bindgen(js_name = resetAndRead)] + fn js_reset_and_read(id: u32, baud_rate: u32, read_window_ms: u32) -> Promise; + #[wasm_bindgen(js_name = closePort)] fn js_close(id: u32) -> Promise; } @@ -96,6 +104,19 @@ pub async fn release(id: u32) -> Result<(), LinkError> { .map_err(js_error) } +pub async fn reset_and_read( + id: u32, + baud_rate: u32, + read_window_ms: u32, +) -> Result { + let value = JsFuture::from(js_reset_and_read(id, baud_rate, read_window_ms)) + .await + .map_err(js_error)?; + Ok(BrowserSerialResetResult { + logs: reflect_string_array(&value, "logs")?, + }) +} + pub async fn close(id: u32) -> Result<(), LinkError> { JsFuture::from(js_close(id)) .await diff --git a/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs index 596d5ebd9..04696a215 100644 --- a/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs +++ b/lp-app/lpa-link/src/providers/browser_serial_esp32/provider.rs @@ -18,6 +18,9 @@ use crate::{ LinkManagementProgress, LinkProvider, LinkSession, LinkSessionStatus, }; +const RESET_BAUD_RATE: u32 = 115_200; +const RESET_READ_WINDOW_MS: u32 = 1_500; + pub fn descriptor() -> LinkProviderDescriptor { LinkProviderKind::BrowserSerialEsp32.descriptor() } @@ -205,6 +208,7 @@ impl BrowserSerialEsp32Provider { ) -> Result { self.session_capabilities_support(session_id, &request)?; let endpoint_id = self.session(session_id)?.session.endpoint_id.clone(); + let port_id = self.session(session_id)?.port_id; self.release_protocol_if_open(session_id).await?; match request { LinkManagementRequest::FlashFirmware => { @@ -249,7 +253,30 @@ impl BrowserSerialEsp32Provider { map_erase_device_result(result), )) } - LinkManagementRequest::ResetRuntime | LinkManagementRequest::EraseRawFilesystem => { + LinkManagementRequest::ResetRuntime => { + events.emit(crate::LinkManagementEvent::log("Resetting device")); + let result = + browser_serial::reset_and_read(port_id, RESET_BAUD_RATE, RESET_READ_WINDOW_MS) + .await?; + for message in &result.logs { + events.emit(crate::LinkManagementEvent::log(message.clone())); + } + let logs = result + .logs + .iter() + .map(|message| { + LinkLogEntry::new( + endpoint_id.clone(), + Some(session_id.clone()), + LinkLogLevel::Info, + message.clone(), + ) + }) + .collect::>(); + self.session_mut(session_id)?.logs.extend(logs); + Ok(LinkManagementResult::ResetRuntime) + } + LinkManagementRequest::EraseRawFilesystem => { Err(LinkError::unsupported(format!("{:?}", request.operation()))) } } diff --git a/lp-app/lpa-studio-ux/Cargo.toml b/lp-app/lpa-studio-core/Cargo.toml similarity index 88% rename from lp-app/lpa-studio-ux/Cargo.toml rename to lp-app/lpa-studio-core/Cargo.toml index c3ca16eb9..4ec727a97 100644 --- a/lp-app/lpa-studio-ux/Cargo.toml +++ b/lp-app/lpa-studio-core/Cargo.toml @@ -1,17 +1,18 @@ [package] -name = "lpa-studio-ux" +name = "lpa-studio-core" version.workspace = true authors.workspace = true edition.workspace = true license.workspace = true rust-version.workspace = true publish = false -description = "UI-independent LightPlayer Studio UX surface" +description = "Headless LightPlayer Studio application core" [dependencies] lpa-client = { path = "../lpa-client", default-features = false } lpa-link = { path = "../lpa-link" } lpc-model = { path = "../../lp-core/lpc-model" } +lpc-view = { path = "../../lp-core/lpc-view" } lpc-wire = { path = "../../lp-core/lpc-wire" } async-trait = { version = "0.1", optional = true } js-sys = { version = "0.3", optional = true } diff --git a/lp-app/lpa-studio-ux/README.md b/lp-app/lpa-studio-core/README.md similarity index 62% rename from lp-app/lpa-studio-ux/README.md rename to lp-app/lpa-studio-core/README.md index 0c77f48b7..c828aef6b 100644 --- a/lp-app/lpa-studio-ux/README.md +++ b/lp-app/lpa-studio-core/README.md @@ -1,11 +1,16 @@ -# lpa-studio-ux +# lpa-studio-core -`lpa-studio-ux` is the UI-independent Studio UX layer. +`lpa-studio-core` is the headless LightPlayer Studio application core. -`Ux` means a resource-owning product surface. This crate sits above lower-level -services such as `lpa-link` and `lpa-client`, owns those services for Studio, -and exposes a user-shaped language of views, actions, progress, issues, logs, -and project summaries. +This crate sits above lower-level services such as `lpa-link` and +`lpa-client`, owns those services for Studio, and exposes the user-shaped +language consumed by renderers: views, actions, progress, issues, logs, and +project summaries. + +The current stateful controller types still use the `*Ux` suffix +(`StudioUx`, `DeviceUx`, `ProjectUx`) for compatibility with the existing app +surface. The architectural role is "controller": these types own Studio app +state and policy, accept typed operations, and produce inert view data. The UI layer should render this language and dispatch actions back into `StudioUx`. It should not own provider runtimes, drain service effects, @@ -14,7 +19,7 @@ correlate protocol responses, or implement project attach/load policy. ```text lpa-link / lpa-client / lp-server protocol owned by -lpa-studio-ux +lpa-studio-core rendered by lpa-studio-web, future CLI, future desktop, tests, and agents ``` @@ -25,12 +30,27 @@ lpa-studio-web, future CLI, future desktop, tests, and agents identity, and device/runtime management. - `lpa-client` owns server protocol request ids, response correlation, typed project operations, and side-channel protocol events. -- `lpa-studio-ux` owns Studio product state, the `LinkProviderRegistry`, the +- `lpa-studio-core` owns Studio product state, the `LinkProviderRegistry`, the connected server client, project attach/load policy, and async action execution above those services. - `lpa-studio-web` renders `StudioView` panes, stack sections, terminal output, and available actions. +## Source Layout + +- `core/` contains reusable data-driven app substrate: action metadata, generic + pane/stack/activity/status view data, and UX node routing primitives. +- `app/` contains the actual Studio product ownership areas: `studio`, + `device`, `link`, `server`, and `project`. +- A future `base/` layer can hold truly primitive app-core concepts if one + emerges. It is intentionally not present until there is a clean need for it. + +The long-term naming direction is role-based: render data should move toward +`*View` names, stateful owners toward `*Controller` names, snapshots remain +`*Snapshot`, and command payloads remain `*Op`. This crate keeps the legacy +`Ui*` and `*Ux` public names for now so the high-level crate/layer refactor +does not blur into a larger API rename. + ## Public Model - `StudioUx` is the top-level controller. It owns `DeviceUx` and `ProjectUx`. @@ -48,16 +68,33 @@ lpa-studio-web, future CLI, future desktop, tests, and agents - `ServerUx` owns the connected `lpa-client` protocol client once a link exposes server I/O. It remains an implementation detail below `DeviceUx`. - `ProjectUx` owns Studio's view of the loaded project and is shown only after a - project is loaded. + project is loaded. It keeps the internal `lpc-view::ProjectView` mirror in + sync with server project reads and exposes semantic readonly project-editor + views to UI code. The web UI does not own or inspect the raw `ProjectView`. +- `UxNodeId` is a path-shaped UX address with dotted display compatibility. + Static ids such as `studio.device` still compare and render as strings, while + dynamic editor ids can be built structurally with child segments. +- The UX ownership tree and address tree are related but not identical. A + dynamic address such as `studio.project.node_tree` or + `studio.project.node.4.slot.brightness` does not imply that Studio owns a + separate boxed node object for that target. +- Dispatch is hierarchical. `StudioUx` routes top-level device actions to + `DeviceUx` and routes `studio.project` plus `studio.project.*` actions to + project ownership. `ProjectUx` owns interpretation of project-local targets + such as `node_tree`, `node`, `slot`, `asset`, `changes`, and `bus`. - `UiAction` is an in-process action offering: target `UxNodeId`, boxed typed operation, and metadata such as label, summary, priority, icon, enablement, and confirmation. - `DeviceOp` and `ProjectOp` are the typed user-facing operations. Operation identity is the enum type and variant, not a parallel string action kind. +- `ProjectEditorTarget` and `ProjectEditorOp` are the first project-editor + dynamic target/op pair. They prove dynamic routing while staying deliberately + small; real node, slot, binding, bus, and asset behavior belongs to later + editor milestones. - `StudioView` is the semantic render surface. It contains a Device `UiPaneView`, an optional loaded Project `UiPaneView`, and recent logs. - `UiBody` is intentionally small: text, progress/activity, issue, metrics, - stack, or empty. It is not a generic component schema. + stack, project editor, or empty. It is not a generic component schema. - `UiStackView` / `UiStackSection` model reusable multi-step product workflows. Device uses them for connection, LightPlayer attach, provisioning, and project opening. Section-local actions are the action surface. @@ -75,7 +112,25 @@ real `lp-server` protocol through `lpa-client`, attaches to a running project when one is already loaded, can load the demo project, and reads project inventory. -Project attach behavior is UX-owned: +Project data sync is also core-owned. After Studio attaches to a running project +or loads the demo project, `ProjectUx` performs a shape-registry sync followed +by a normal project read for node detail, initial slot roots, resource +summaries, and runtime status. The loaded Project pane shows a compact summary +of the synced mirror alongside a readonly node workspace and exposes +`Refresh project` for explicit action-driven refreshes. `ProjectSync` keeps the +raw `lpc_view::ProjectView` private and translates it into `ProjectEditorView`, +`ProjectNodeTreeView`, `ProjectNodeView`, and `ProjectSlotRowView` data before +anything reaches a UI. Sync failures are treated as project-pane issues rather +than generic action failures so the attached project can stay visible while +Studio explains what needs attention. + +The first editor view renders every synced node in stable tree order rather +than requiring a selected-node detail view. Node bodies show headers, status, +prominent `input`/`output` slots, config/state slot rows, compact bindings when +available, and secondary project/runtime stats. Editing, overlay dirty-state, +binding authoring, bus views, probes, and asset editing are later milestones. + +Project attach behavior is core-owned: - zero loaded projects: once LightPlayer is connected, offer to load the demo project in the Device open-project step; @@ -175,16 +230,23 @@ but it is not a stable wire protocol and does not serialize operations. Interactive shells can use `dispatch_with_updates` to show progress/terminal updates during long actions without owning provider resources themselves. -## Removed Old Split +There is intentionally no central `UxRegistry` object yet. The current Studio +tree is naturally hierarchical, so each owner consumes its own target subtree. +If Studio later needs non-tree mounting, plugin-style routes, or cross-cutting +introspection, a registry can be introduced on top of the path-shaped +`UxNodeId` model without changing action identity. + +## Naming Note -The old `lpa-studio-core` / `lpa-studio-runtime` split has been removed from -the active workspace. The UX crate owns the controller logic directly instead -of routing application work through a separate effect/event executor. +An earlier archived design used a separate core/runtime split. The active +workspace now uses this single `lpa-studio-core` crate for Studio controller +logic, app policy, view data, and live updates instead of routing application +work through a separate effect/event executor. ## Validation ```bash -cargo check -p lpa-studio-ux -cargo test -p lpa-studio-ux -cargo check -p lpa-studio-ux --target wasm32-unknown-unknown --features browser-worker,browser-serial-esp32 +cargo check -p lpa-studio-core +cargo test -p lpa-studio-core +cargo check -p lpa-studio-core --target wasm32-unknown-unknown --features browser-worker,browser-serial-esp32 ``` diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs b/lp-app/lpa-studio-core/src/app/device/device_controller.rs similarity index 76% rename from lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs rename to lp-app/lpa-studio-core/src/app/device/device_controller.rs index abcd84603..6b8c5445a 100644 --- a/lp-app/lpa-studio-ux/src/nodes/device/device_ux.rs +++ b/lp-app/lpa-studio-core/src/app/device/device_controller.rs @@ -1,18 +1,19 @@ +use crate::core::view::steps_view::{UiStepState, UiStepView}; use crate::{ - ConnectedDeviceSummary, DeviceOp, DeviceSnapshot, EndpointChoice, LinkOp, LinkState, LinkUx, - ProjectOp, ProjectState, ProviderChoice, ServerFailureKind, ServerState, ServerUx, UiAction, - UiBody, UiMetric, UiPaneView, UiStackSection, UiStackView, UiStatus, UiStepState, - UiTerminalLine, UxLogEntry, UxNode, UxNodeId, + ConnectedDeviceSummary, Controller, ControllerId, DeviceOp, DeviceSnapshot, EndpointChoice, + LinkController, LinkOp, LinkState, ProjectOp, ProjectState, ProviderChoice, ServerController, + ServerFailureKind, ServerState, UiAction, UiLogEntry, UiMetric, UiPaneView, UiStatus, + UiStepsView, UiTerminalLine, UiViewContent, }; -pub struct DeviceUx { - pub(crate) link: LinkUx, - pub(crate) server: ServerUx, +pub struct DeviceController { + pub(crate) link: LinkController, + pub(crate) server: ServerController, terminal: Vec, } -impl DeviceUx { - pub const NODE_ID: &'static str = "studio.device"; +impl DeviceController { + pub const NODE_ID: &'static str = "studio|device"; pub const SECTION_SELECT_CONNECTION: &'static str = "select-connection"; pub const SECTION_CONNECT_DEVICE: &'static str = "connect-device"; pub const SECTION_CONNECT_LIGHTPLAYER: &'static str = "connect-lightplayer"; @@ -20,8 +21,8 @@ impl DeviceUx { pub fn new() -> Self { Self { - link: LinkUx::new(), - server: ServerUx::new(), + link: LinkController::new(), + server: ServerController::new(), terminal: Vec::new(), } } @@ -52,7 +53,7 @@ impl DeviceUx { !matches!(self.link.state(), LinkState::SelectingProvider { .. }) } - pub fn record_logs(&mut self, logs: &[UxLogEntry]) { + pub fn record_logs(&mut self, logs: &[UiLogEntry]) { self.terminal.extend( logs.iter() .filter(|log| is_device_log_source(&log.source)) @@ -65,7 +66,7 @@ impl DeviceUx { } pub fn view(&self, project_state: &ProjectState, project_actions: Vec) -> UiPaneView { - let stack = UiStackView::new(self.sections(project_state, project_actions)).with_terminal( + let stack = UiStepsView::new(self.sections(project_state, project_actions)).with_terminal( if self.has_meaningful_terminal() { self.terminal.clone() } else { @@ -77,7 +78,7 @@ impl DeviceUx { Self::NODE_ID, "Device", self.status(), - UiBody::Stack(Box::new(stack)), + UiViewContent::Stack(Box::new(stack)), Vec::new(), ) } @@ -86,7 +87,7 @@ impl DeviceUx { &self, project_state: &ProjectState, project_actions: Vec, - ) -> Vec { + ) -> Vec { let mut sections = vec![self.select_connection_section()]; if self.should_show_connect_device() { sections.push(self.connect_device_section()); @@ -153,10 +154,10 @@ impl DeviceUx { } } - fn select_connection_section(&self) -> UiStackSection { + fn select_connection_section(&self) -> UiStepView { match self.link.state() { LinkState::SelectingProvider { providers, issue } => { - let section = UiStackSection::new( + let section = UiStepView::new( Self::SECTION_SELECT_CONNECTION, "Select connection", if issue.is_some() { @@ -167,64 +168,73 @@ impl DeviceUx { ) .with_actions(provider_actions(providers, self.node_id())); match issue { - Some(issue) => section.with_body(UiBody::Issue(issue.clone())), - None => section.with_body(UiBody::text("Choose how Studio should connect.")), + Some(issue) => section.with_body(UiViewContent::Issue(issue.clone())), + None => { + section.with_body(UiViewContent::text("Choose how Studio should connect.")) + } } } - LinkState::Failed { .. } => UiStackSection::new( + LinkState::Failed { .. } => UiStepView::new( Self::SECTION_SELECT_CONNECTION, "Select connection", UiStepState::NeedsAttention, ) - .with_body(UiBody::text("Refresh connections to try again.")) + .with_body(UiViewContent::text("Refresh connections to try again.")) .with_actions(vec![self.action(DeviceOp::RefreshConnections)]), - _ => UiStackSection::new( + _ => UiStepView::new( Self::SECTION_SELECT_CONNECTION, "Select connection", UiStepState::Complete, ) - .with_body(UiBody::text(selected_connection_label(self.link.state()))), + .with_body(UiViewContent::text(selected_connection_label( + self.link.state(), + ))), } } - fn connect_device_section(&self) -> UiStackSection { + fn connect_device_section(&self) -> UiStepView { match self.link.state() { - LinkState::SelectingProvider { .. } => UiStackSection::new( + LinkState::SelectingProvider { .. } => UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::Pending, ) - .with_body(UiBody::text("Choose a connection first.")), + .with_body(UiViewContent::text("Choose a connection first.")), LinkState::DiscoveringEndpoints { provider_id, progress, - } => UiStackSection::new( + } => UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::Active, ) - .with_body(UiBody::Progress(progress.clone().with_detail(format!( - "Discovering endpoints from {}.", - provider_id.label() - )))), + .with_body(UiViewContent::Progress( + progress + .clone() + .with_detail(format!( + "Discovering endpoints from {}.", + provider_id.label() + )) + .into(), + )), LinkState::SelectingEndpoint { provider_id, endpoints, - } => UiStackSection::new( + } => UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::Active, ) - .with_body(UiBody::text("Choose the device endpoint to open.")) + .with_body(UiViewContent::text("Choose the device endpoint to open.")) .with_actions(endpoint_actions(*provider_id, endpoints, self.node_id())), - LinkState::Connecting { progress, .. } => UiStackSection::new( + LinkState::Connecting { progress, .. } => UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::Active, ) - .with_body(UiBody::Progress(progress.clone())), + .with_body(UiViewContent::Progress(progress.clone().into())), LinkState::Connected { device } | LinkState::Managing { device, .. } => { - let section = UiStackSection::new( + let section = UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::Complete, @@ -236,47 +246,45 @@ impl DeviceUx { section } } - LinkState::Failed { issue } => UiStackSection::new( + LinkState::Failed { issue } => UiStepView::new( Self::SECTION_CONNECT_DEVICE, "Connect device", UiStepState::NeedsAttention, ) - .with_body(UiBody::Issue(issue.clone())) + .with_body(UiViewContent::Issue(issue.clone())) .with_actions(vec![self.action(DeviceOp::RefreshConnections)]), } } - fn connect_lightplayer_section(&self) -> UiStackSection { + fn connect_lightplayer_section(&self) -> UiStepView { match (self.link.state(), &self.server.snapshot().state) { - (LinkState::Connected { .. }, ServerState::Disconnected) => UiStackSection::new( + (LinkState::Connected { .. }, ServerState::Disconnected) => UiStepView::new( Self::SECTION_CONNECT_LIGHTPLAYER, "Connect LightPlayer", UiStepState::Active, ) - .with_body(UiBody::text( + .with_body(UiViewContent::text( "Attach Studio to LightPlayer on the connected device.", )) .with_actions(self.connect_lightplayer_actions()), - (LinkState::Connected { .. }, ServerState::Connecting { progress }) => { - UiStackSection::new( - Self::SECTION_CONNECT_LIGHTPLAYER, - "Connect LightPlayer", - UiStepState::Active, - ) - .with_body(UiBody::Progress(progress.clone())) - } - (LinkState::Connected { .. }, ServerState::Connected { protocol }) => { - UiStackSection::new( - Self::SECTION_CONNECT_LIGHTPLAYER, - "Connect LightPlayer", - UiStepState::Complete, - ) - .with_body(UiBody::Metrics(vec![UiMetric::new("Protocol", protocol)])) - .with_actions(vec![self.action(DeviceOp::DisconnectLightPlayer)]) - } + (LinkState::Connected { .. }, ServerState::Connecting { progress }) => UiStepView::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Active, + ) + .with_body(UiViewContent::Progress(progress.clone().into())), + (LinkState::Connected { .. }, ServerState::Connected { protocol }) => UiStepView::new( + Self::SECTION_CONNECT_LIGHTPLAYER, + "Connect LightPlayer", + UiStepState::Complete, + ) + .with_body(UiViewContent::Metrics(vec![UiMetric::new( + "Protocol", protocol, + )])) + .with_actions(self.connected_lightplayer_actions()), (LinkState::Connected { .. }, ServerState::Failed { issue, kind }) => { let no_firmware = *kind == ServerFailureKind::NoFirmware; - UiStackSection::new( + UiStepView::new( Self::SECTION_CONNECT_LIGHTPLAYER, if no_firmware { "LightPlayer unavailable" @@ -290,9 +298,9 @@ impl DeviceUx { }, ) .with_body(if no_firmware { - UiBody::text("No LightPlayer firmware is running on this ESP32.") + UiViewContent::text("No LightPlayer firmware is running on this ESP32.") } else { - UiBody::Issue(issue.clone()) + UiViewContent::Issue(issue.clone()) }) .with_actions(if no_firmware { Vec::new() @@ -300,18 +308,18 @@ impl DeviceUx { self.connect_lightplayer_actions() }) } - (LinkState::Managing { progress, .. }, _) => UiStackSection::new( + (LinkState::Managing { progress, .. }, _) => UiStepView::new( Self::SECTION_CONNECT_LIGHTPLAYER, progress.label.clone(), UiStepState::Active, ) - .with_body(UiBody::Progress(progress.clone())), - _ => UiStackSection::new( + .with_body(UiViewContent::Progress(progress.clone().into())), + _ => UiStepView::new( Self::SECTION_CONNECT_LIGHTPLAYER, "Connect LightPlayer", UiStepState::Pending, ) - .with_body(UiBody::text("Connect a device first.")), + .with_body(UiViewContent::text("Connect a device first.")), } } @@ -319,61 +327,63 @@ impl DeviceUx { &self, project_state: &ProjectState, actions: Vec, - ) -> UiStackSection { + ) -> UiStepView { if !self.has_lightplayer_state() { if self.needs_firmware() { - return UiStackSection::new( + return UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Pending, ) - .with_body(UiBody::text("Flash firmware before opening a project.")); + .with_body(UiViewContent::text( + "Flash firmware before opening a project.", + )); } - return UiStackSection::new( + return UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Pending, ) - .with_body(UiBody::text("Connect LightPlayer first.")); + .with_body(UiViewContent::text("Connect LightPlayer first.")); } match project_state { - ProjectState::NotLoaded => UiStackSection::new( + ProjectState::NotLoaded => UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Active, ) - .with_body(UiBody::text(not_loaded_project_prompt(&actions))) + .with_body(UiViewContent::text(not_loaded_project_prompt(&actions))) .with_actions(actions), - ProjectState::SelectingLoadedProject { projects } => UiStackSection::new( + ProjectState::SelectingLoadedProject { projects } => UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Active, ) - .with_body(UiBody::text(format!( + .with_body(UiViewContent::text(format!( "{} projects are running. Choose one to open.", projects.len() ))) .with_actions(actions), ProjectState::ConnectingRunningProject { progress } - | ProjectState::LoadingDemoProject { progress } => UiStackSection::new( + | ProjectState::LoadingDemoProject { progress } => UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Active, ) - .with_body(UiBody::Progress(progress.clone())), - ProjectState::Ready { project_id, .. } => UiStackSection::new( + .with_body(UiViewContent::Progress(progress.clone().into())), + ProjectState::Ready { project_id, .. } => UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::Complete, ) - .with_body(UiBody::text(format!("{project_id} is loaded."))), - ProjectState::Failed { issue } => UiStackSection::new( + .with_body(UiViewContent::text(format!("{project_id} is loaded."))), + ProjectState::Failed { issue } => UiStepView::new( Self::SECTION_OPEN_PROJECT, "Open project", UiStepState::NeedsAttention, ) - .with_body(UiBody::Issue(issue.clone())) + .with_body(UiViewContent::Issue(issue.clone())) .with_actions(actions), } } @@ -398,6 +408,16 @@ impl DeviceUx { .collect() } + fn connected_lightplayer_actions(&self) -> Vec { + let mut actions = self + .lightplayer_actions(false) + .into_iter() + .filter(|action| matches!(action.op_as::(), Some(DeviceOp::ResetDevice))) + .collect::>(); + actions.push(self.action(DeviceOp::DisconnectLightPlayer)); + actions + } + fn device_control_actions(&self) -> Vec { self.lightplayer_actions(false) .into_iter() @@ -424,21 +444,21 @@ fn not_loaded_project_prompt(actions: &[UiAction]) -> &'static str { } } -impl UxNode for DeviceUx { +impl Controller for DeviceController { type Op = DeviceOp; - fn node_id(&self) -> UxNodeId { - UxNodeId::new(Self::NODE_ID) + fn node_id(&self) -> ControllerId { + ControllerId::new(Self::NODE_ID) } } -impl Default for DeviceUx { +impl Default for DeviceController { fn default() -> Self { Self::new() } } -fn provider_actions(providers: &[ProviderChoice], node_id: UxNodeId) -> Vec { +fn provider_actions(providers: &[ProviderChoice], node_id: ControllerId) -> Vec { providers .iter() .map(|provider| { @@ -460,7 +480,7 @@ fn provider_actions(providers: &[ProviderChoice], node_id: UxNodeId) -> Vec Vec { endpoints .iter() @@ -478,11 +498,12 @@ fn endpoint_actions( .collect() } -fn map_link_action(action: UiAction, node_id: UxNodeId) -> Option { +fn map_link_action(action: UiAction, node_id: ControllerId) -> Option { let meta = action.meta().clone(); let op = match action.op_as::()? { LinkOp::RefreshProviders => DeviceOp::RefreshConnections, LinkOp::ConnectServer => DeviceOp::ConnectLightPlayer, + LinkOp::ResetDevice => DeviceOp::ResetDevice, LinkOp::ProvisionFirmware => DeviceOp::ProvisionFirmware, LinkOp::ResetToBlank => DeviceOp::ResetToBlank, LinkOp::DisconnectLink => DeviceOp::DisconnectDevice, @@ -518,8 +539,8 @@ fn map_link_action(action: UiAction, node_id: UxNodeId) -> Option { } } -fn device_summary_body(device: &ConnectedDeviceSummary) -> UiBody { - UiBody::Metrics(vec![ +fn device_summary_body(device: &ConnectedDeviceSummary) -> UiViewContent { + UiViewContent::Metrics(vec![ UiMetric::new("Provider", device.provider_id.label()), UiMetric::new("Endpoint", &device.endpoint_id), UiMetric::new("Session", &device.session_id), diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_op.rs b/lp-app/lpa-studio-core/src/app/device/device_op.rs similarity index 86% rename from lp-app/lpa-studio-ux/src/nodes/device/device_op.rs rename to lp-app/lpa-studio-core/src/app/device/device_op.rs index e64327038..a02448cbb 100644 --- a/lp-app/lpa-studio-ux/src/nodes/device/device_op.rs +++ b/lp-app/lpa-studio-core/src/app/device/device_op.rs @@ -2,7 +2,7 @@ use core::any::Any; use lpa_link::{LinkEndpointId, LinkProviderKind}; -use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxOp}; +use crate::{ActionConfirmation, ActionMeta, ActionPriority, ControllerOp}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum DeviceOp { @@ -15,13 +15,14 @@ pub enum DeviceOp { }, ConnectLightPlayer, DisconnectLightPlayer, + ResetDevice, ProvisionFirmware, ResetToBlank, DisconnectDevice, RefreshConnections, } -impl UxOp for DeviceOp { +impl ControllerOp for DeviceOp { fn default_action_meta(&self) -> ActionMeta { match self { Self::OpenProvider { .. } => ActionMeta::new( @@ -44,6 +45,11 @@ impl UxOp for DeviceOp { "Detach Studio from LightPlayer while keeping the device connected.", ActionPriority::Tertiary, ), + Self::ResetDevice => ActionMeta::new( + "Reset device", + "Reboot the connected device without erasing firmware or data.", + ActionPriority::Tertiary, + ), Self::ProvisionFirmware => ActionMeta::new( "Flash firmware", "Flash the packaged LightPlayer firmware onto this ESP32.", @@ -77,11 +83,11 @@ impl UxOp for DeviceOp { } } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } diff --git a/lp-app/lpa-studio-ux/src/nodes/device/device_snapshot.rs b/lp-app/lpa-studio-core/src/app/device/device_snapshot.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/device/device_snapshot.rs rename to lp-app/lpa-studio-core/src/app/device/device_snapshot.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/device/mod.rs b/lp-app/lpa-studio-core/src/app/device/mod.rs similarity index 61% rename from lp-app/lpa-studio-ux/src/nodes/device/mod.rs rename to lp-app/lpa-studio-core/src/app/device/mod.rs index 23efcf9f1..efe7ee1de 100644 --- a/lp-app/lpa-studio-ux/src/nodes/device/mod.rs +++ b/lp-app/lpa-studio-core/src/app/device/mod.rs @@ -1,7 +1,7 @@ +pub mod device_controller; pub mod device_op; pub mod device_snapshot; -pub mod device_ux; +pub use device_controller::DeviceController; pub use device_op::DeviceOp; pub use device_snapshot::DeviceSnapshot; -pub use device_ux::DeviceUx; diff --git a/lp-app/lpa-studio-ux/src/nodes/link/connected_device_summary.rs b/lp-app/lpa-studio-core/src/app/link/connected_device_summary.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/link/connected_device_summary.rs rename to lp-app/lpa-studio-core/src/app/link/connected_device_summary.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/link/endpoint_choice.rs b/lp-app/lpa-studio-core/src/app/link/endpoint_choice.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/link/endpoint_choice.rs rename to lp-app/lpa-studio-core/src/app/link/endpoint_choice.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs b/lp-app/lpa-studio-core/src/app/link/link_controller.rs similarity index 85% rename from lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs rename to lp-app/lpa-studio-core/src/app/link/link_controller.rs index 846215126..287b01511 100644 --- a/lp-app/lpa-studio-ux/src/nodes/link/link_ux.rs +++ b/lp-app/lpa-studio-core/src/app/link/link_controller.rs @@ -11,15 +11,16 @@ use lpa_link::{ use lpc_model::DEFAULT_SERIAL_BAUD_RATE; use crate::{ - ActionPriority, ConnectedDeviceSummary, EndpointChoice, LinkOp, LinkSnapshot, LinkState, - ProgressState, ProviderChoice, UiAction, UiActivity, UiBody, UiMetric, UiPaneView, UiProgress, - UiStatus, UxError, UxIssue, UxLogEntry, UxLogLevel, UxNode, UxNodeId, UxUpdate, UxUpdateSink, + ActionPriority, ConnectedDeviceSummary, Controller, ControllerId, EndpointChoice, LinkOp, + LinkSnapshot, LinkState, ProgressState, ProviderChoice, UiAction, UiActivityView, UiError, + UiIssue, UiLogEntry, UiLogLevel, UiMetric, UiPaneView, UiProgress, UiStatus, UiViewContent, + UxUpdate, UxUpdateSink, }; use lpa_link::{LinkManagementEvent, LinkManagementEventSink}; pub type SharedLinkRegistry = Rc>; -pub struct LinkUx { +pub struct LinkController { state: LinkState, registry: SharedLinkRegistry, active_provider: Option, @@ -28,8 +29,8 @@ pub struct LinkUx { active_connection: Option, } -impl LinkUx { - pub const NODE_ID: &'static str = "studio.link"; +impl LinkController { + pub const NODE_ID: &'static str = "studio|link"; pub fn new() -> Self { Self::with_env(LinkEnv::default()) @@ -120,6 +121,9 @@ impl LinkUx { actions.push(self.action(LinkOp::ProvisionFirmware)); } actions.push(self.action(LinkOp::ConnectServer)); + if self.active_supports(LinkOperation::Reset) { + actions.push(self.action(LinkOp::ResetDevice)); + } if self.active_supports(LinkOperation::EraseDeviceFlash) { actions.push(self.action(LinkOp::ResetToBlank)); } @@ -143,7 +147,7 @@ impl LinkUx { self.reset_to_provider_selection(None); } - fn reset_to_provider_selection(&mut self, issue: Option) { + fn reset_to_provider_selection(&mut self, issue: Option) { self.active_provider = None; self.active_endpoint = None; self.active_session = None; @@ -153,10 +157,10 @@ impl LinkUx { } fn recover_to_provider_selection(&mut self, message: impl Into) { - self.reset_to_provider_selection(Some(UxIssue::new(message))); + self.reset_to_provider_selection(Some(UiIssue::new(message))); } - pub async fn disconnect(&mut self) -> Result<(), UxError> { + pub async fn disconnect(&mut self) -> Result<(), UiError> { let provider_id = self.active_provider; let session_id = self .active_session @@ -187,7 +191,7 @@ impl LinkUx { pub async fn open_provider( &mut self, provider_id: LinkProviderKind, - ) -> Result { + ) -> Result { if provider_id == LinkProviderKind::BrowserSerialEsp32 { return self.open_browser_serial_provider().await; } @@ -210,7 +214,7 @@ impl LinkUx { pub async fn discover_provider_endpoints( &mut self, provider_id: LinkProviderKind, - ) -> Result<(), UxError> { + ) -> Result<(), UiError> { self.active_provider = Some(provider_id); self.active_endpoint = None; self.active_session = None; @@ -237,7 +241,7 @@ impl LinkUx { if endpoints.is_empty() { let message = format!("{} did not report any endpoints", provider_id.label()); self.recover_to_provider_selection(message.clone()); - return Err(UxError::Link(message)); + return Err(UiError::Link(message)); } self.state = LinkState::SelectingEndpoint { @@ -251,7 +255,7 @@ impl LinkUx { } #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] - async fn open_browser_serial_provider(&mut self) -> Result { + async fn open_browser_serial_provider(&mut self) -> Result { self.active_provider = Some(LinkProviderKind::BrowserSerialEsp32); self.active_endpoint = None; self.active_session = None; @@ -267,7 +271,7 @@ impl LinkUx { Some(LinkProviderInstance::BrowserSerialEsp32(provider)) => { provider.request_access().await.map_err(map_link_error) } - Some(_) => Err(UxError::Link( + Some(_) => Err(UiError::Link( "browser serial registry entry has the wrong provider type".to_string(), )), None => Err(missing_provider(LinkProviderKind::BrowserSerialEsp32)), @@ -275,7 +279,7 @@ impl LinkUx { }; let endpoint = match result { Ok(endpoint) => endpoint, - Err(UxError::Cancelled(message)) => { + Err(UiError::Cancelled(message)) => { self.reset_to_provider_selection(None); return Ok(LinkOpenOutcome::Cancelled { message }); } @@ -296,8 +300,8 @@ impl LinkUx { } #[cfg(not(all(feature = "browser-serial-esp32", target_arch = "wasm32")))] - async fn open_browser_serial_provider(&mut self) -> Result { - Err(UxError::UnsupportedFeature( + async fn open_browser_serial_provider(&mut self) -> Result { + Err(UiError::UnsupportedFeature( "browser serial ESP32 access requires the browser-serial-esp32 feature on wasm" .to_string(), )) @@ -307,7 +311,7 @@ impl LinkUx { &mut self, provider_id: LinkProviderKind, endpoint_id: LinkEndpointId, - ) -> Result { + ) -> Result { let endpoint = self .endpoint_choice(provider_id, &endpoint_id) .unwrap_or_else(|| EndpointChoice { @@ -361,7 +365,7 @@ impl LinkUx { &mut self, request: LinkManagementRequest, progress_label: impl Into, - ) -> Result { + ) -> Result { self.manage_with_updates(request, progress_label, UxUpdateSink::noop()) .await } @@ -371,15 +375,15 @@ impl LinkUx { request: LinkManagementRequest, progress_label: impl Into, updates: UxUpdateSink, - ) -> Result { + ) -> Result { let provider_id = self .active_provider - .ok_or_else(|| UxError::MissingSession("link provider is not selected".to_string()))?; + .ok_or_else(|| UiError::MissingSession("link provider is not selected".to_string()))?; let session_id = self .active_session .as_ref() .map(|session| session.id.clone()) - .ok_or_else(|| UxError::MissingSession("link session is not open".to_string()))?; + .ok_or_else(|| UiError::MissingSession("link session is not open".to_string()))?; let device = self.connected_device_summary()?; let progress_label = progress_label.into(); self.active_connection = None; @@ -389,7 +393,7 @@ impl LinkUx { }; let node_id = self.node_id(); let activity = Rc::new(RefCell::new( - UiActivity::new(progress_label.clone()) + UiActivityView::new(progress_label.clone()) .with_progress(UiProgress::indeterminate(progress_label.clone())), )); updates.emit(UxUpdate::Activity { @@ -415,15 +419,15 @@ impl LinkUx { Ok(LinkManagementOutcome { result, logs }) } - pub async fn reopen_active_connection(&mut self) -> Result { + pub async fn reopen_active_connection(&mut self) -> Result { let provider_id = self .active_provider - .ok_or_else(|| UxError::MissingSession("link provider is not selected".to_string()))?; + .ok_or_else(|| UiError::MissingSession("link provider is not selected".to_string()))?; let session_id = self .active_session .as_ref() .map(|session| session.id.clone()) - .ok_or_else(|| UxError::MissingSession("link session is not open".to_string()))?; + .ok_or_else(|| UiError::MissingSession("link session is not open".to_string()))?; let (connection, logs) = { let mut registry = self.registry.borrow_mut(); let provider = registry @@ -443,7 +447,7 @@ impl LinkUx { pub fn fail(&mut self, message: impl Into) { self.state = LinkState::Failed { - issue: UxIssue::new(message), + issue: UiIssue::new(message), }; } @@ -475,29 +479,29 @@ impl LinkUx { .is_some_and(|session| session.capabilities.supports(operation)) } - fn connected_device_summary(&self) -> Result { + fn connected_device_summary(&self) -> Result { match &self.state { LinkState::Connected { device } | LinkState::Managing { device, .. } => { Ok(device.clone()) } - _ => Err(UxError::MissingSession( + _ => Err(UiError::MissingSession( "link is not connected to a device".to_string(), )), } } } -impl UxNode for LinkUx { +impl Controller for LinkController { type Op = LinkOp; - fn node_id(&self) -> UxNodeId { - UxNodeId::new(Self::NODE_ID) + fn node_id(&self) -> ControllerId { + ControllerId::new(Self::NODE_ID) } } pub struct ConnectedLink { pub connection: LinkConnection, - pub logs: Vec, + pub logs: Vec, } pub enum LinkOpenOutcome { @@ -508,10 +512,10 @@ pub enum LinkOpenOutcome { pub struct LinkManagementOutcome { pub result: LinkManagementResult, - pub logs: Vec, + pub logs: Vec, } -impl Default for LinkUx { +impl Default for LinkController { fn default() -> Self { Self::new() } @@ -547,47 +551,54 @@ fn link_status(state: &LinkState) -> UiStatus { } } -fn link_body(state: &LinkState) -> UiBody { +fn link_body(state: &LinkState) -> UiViewContent { match state { LinkState::SelectingProvider { issue: Some(issue), .. - } => UiBody::Issue(issue.clone()), + } => UiViewContent::Issue(issue.clone()), LinkState::SelectingProvider { providers, .. } => providers .first() - .map(|provider| UiBody::text(provider.summary.clone())) - .unwrap_or_else(|| UiBody::text("No link providers are available.")), + .map(|provider| UiViewContent::text(provider.summary.clone())) + .unwrap_or_else(|| UiViewContent::text("No link providers are available.")), LinkState::DiscoveringEndpoints { provider_id, progress, - } => UiBody::Progress(progress.clone().with_detail(format!( - "Discovering endpoints from {}.", - provider_id.label() - ))), + } => UiViewContent::Progress( + progress + .clone() + .with_detail(format!( + "Discovering endpoints from {}.", + provider_id.label() + )) + .into(), + ), LinkState::SelectingEndpoint { endpoints, .. } => endpoints .first() - .map(|endpoint| UiBody::text(endpoint.summary.clone())) - .unwrap_or_else(|| UiBody::text("No endpoints are available for this provider.")), - LinkState::Connecting { progress, .. } => UiBody::Progress(progress.clone()), - LinkState::Managing { progress, .. } => UiBody::Progress(progress.clone()), - LinkState::Connected { device } => UiBody::Metrics(vec![ + .map(|endpoint| UiViewContent::text(endpoint.summary.clone())) + .unwrap_or_else(|| { + UiViewContent::text("No endpoints are available for this provider.") + }), + LinkState::Connecting { progress, .. } => UiViewContent::Progress(progress.clone().into()), + LinkState::Managing { progress, .. } => UiViewContent::Progress(progress.clone().into()), + LinkState::Connected { device } => UiViewContent::Metrics(vec![ UiMetric::new("Provider", device.provider_id.label()), UiMetric::new("Endpoint", &device.endpoint_id), UiMetric::new("Session", &device.session_id), ]), - LinkState::Failed { issue } => UiBody::Issue(issue.clone()), + LinkState::Failed { issue } => UiViewContent::Issue(issue.clone()), } } -fn management_result_logs(result: &LinkManagementResult) -> Vec { +fn management_result_logs(result: &LinkManagementResult) -> Vec { match result { LinkManagementResult::FlashFirmware(result) => { let mut logs = result .logs .iter() - .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .map(|message| UiLogEntry::new(UiLogLevel::Info, "lpa-link", message.clone())) .collect::>(); logs.extend(result.progress.iter().map(|progress| { - UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + UiLogEntry::new(UiLogLevel::Info, "lpa-link", progress.label.clone()) })); logs } @@ -595,10 +606,10 @@ fn management_result_logs(result: &LinkManagementResult) -> Vec { let mut logs = result .logs .iter() - .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .map(|message| UiLogEntry::new(UiLogLevel::Info, "lpa-link", message.clone())) .collect::>(); logs.extend(result.progress.iter().map(|progress| { - UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + UiLogEntry::new(UiLogLevel::Info, "lpa-link", progress.label.clone()) })); logs } @@ -606,16 +617,16 @@ fn management_result_logs(result: &LinkManagementResult) -> Vec { let mut logs = result .logs .iter() - .map(|message| UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone())) + .map(|message| UiLogEntry::new(UiLogLevel::Info, "lpa-link", message.clone())) .collect::>(); logs.extend(result.progress.iter().map(|progress| { - UxLogEntry::new(UxLogLevel::Info, "lpa-link", progress.label.clone()) + UiLogEntry::new(UiLogLevel::Info, "lpa-link", progress.label.clone()) })); logs } LinkManagementResult::ResetRuntime => { - vec![UxLogEntry::new( - UxLogLevel::Info, + vec![UiLogEntry::new( + UiLogLevel::Info, "lpa-link", "runtime reset completed", )] @@ -624,8 +635,8 @@ fn management_result_logs(result: &LinkManagementResult) -> Vec { } fn management_activity_sink( - node_id: UxNodeId, - activity: Rc>, + node_id: ControllerId, + activity: Rc>, updates: UxUpdateSink, ) -> LinkManagementEventSink { LinkManagementEventSink::new(move |event| { @@ -643,7 +654,7 @@ fn management_activity_sink( }) } -fn apply_management_event(activity: &mut UiActivity, event: LinkManagementEvent) { +fn apply_management_event(activity: &mut UiActivityView, event: LinkManagementEvent) { match event { LinkManagementEvent::Log { .. } => {} LinkManagementEvent::Progress(progress) => { @@ -663,10 +674,10 @@ fn apply_management_event(activity: &mut UiActivity, event: LinkManagementEvent) } } -fn management_event_log(event: &LinkManagementEvent) -> Option { +fn management_event_log(event: &LinkManagementEvent) -> Option { match event { LinkManagementEvent::Log { message } if !message.trim().is_empty() => Some( - UxLogEntry::new(UxLogLevel::Info, "lpa-link", message.clone()), + UiLogEntry::new(UiLogLevel::Info, "lpa-link", message.clone()), ), _ => None, } @@ -724,7 +735,7 @@ async fn open_connected_provider( provider_id: LinkProviderKind, provider: &mut LinkProviderInstance, endpoint_id: &LinkEndpointId, -) -> Result<(LinkSession, LinkConnection, Vec), UxError> { +) -> Result<(LinkSession, LinkConnection, Vec), UiError> { let session = provider .connect(endpoint_id) .await @@ -760,12 +771,12 @@ async fn open_provider_protocol_if_needed( provider_id: LinkProviderKind, provider: &mut LinkProviderInstance, session_id: &LinkSessionId, -) -> Result<(), UxError> { +) -> Result<(), UiError> { if provider_id != LinkProviderKind::BrowserSerialEsp32 { return Ok(()); } let LinkProviderInstance::BrowserSerialEsp32(provider) = provider else { - return Err(UxError::Link( + return Err(UiError::Link( "browser serial registry entry has the wrong provider type".to_string(), )); }; @@ -780,7 +791,7 @@ async fn open_provider_protocol_if_needed( provider_id: LinkProviderKind, _provider: &mut LinkProviderInstance, _session_id: &LinkSessionId, -) -> Result<(), UxError> { +) -> Result<(), UiError> { let _ = provider_id; Ok(()) } @@ -798,12 +809,12 @@ fn provider_action_priority(kind: LinkProviderKind) -> ActionPriority { fn link_session_logs( provider: &lpa_link::providers::LinkProviderInstance, session_id: &lpa_link::LinkSessionId, -) -> Result, UxError> { +) -> Result, UiError> { let mut logs = provider .logs(session_id) .map_err(map_link_error)? .into_iter() - .map(|entry| UxLogEntry::new(map_link_log_level(entry.level), "lpa-link", entry.message)) + .map(|entry| UiLogEntry::new(map_link_log_level(entry.level), "lpa-link", entry.message)) .collect::>(); logs.extend( provider @@ -811,7 +822,7 @@ fn link_session_logs( .map_err(map_link_error)? .into_iter() .map(|diagnostic| { - UxLogEntry::new( + UiLogEntry::new( map_diagnostic_level(diagnostic.severity), "lpa-link", diagnostic.message, @@ -821,31 +832,31 @@ fn link_session_logs( Ok(logs) } -fn missing_provider(provider_id: LinkProviderKind) -> UxError { - UxError::Link(format!("provider {} is not available", provider_id.key())) +fn missing_provider(provider_id: LinkProviderKind) -> UiError { + UiError::Link(format!("provider {} is not available", provider_id.key())) } -fn map_link_error(error: LinkError) -> UxError { +fn map_link_error(error: LinkError) -> UiError { match error { - LinkError::Cancelled { message } => UxError::Cancelled(message), - _ => UxError::Link(error.to_string()), + LinkError::Cancelled { message } => UiError::Cancelled(message), + _ => UiError::Link(error.to_string()), } } -fn map_link_log_level(level: LinkLogLevel) -> UxLogLevel { +fn map_link_log_level(level: LinkLogLevel) -> UiLogLevel { match level { - LinkLogLevel::Trace | LinkLogLevel::Debug => UxLogLevel::Debug, - LinkLogLevel::Info => UxLogLevel::Info, - LinkLogLevel::Warn => UxLogLevel::Warn, - LinkLogLevel::Error => UxLogLevel::Error, + LinkLogLevel::Trace | LinkLogLevel::Debug => UiLogLevel::Debug, + LinkLogLevel::Info => UiLogLevel::Info, + LinkLogLevel::Warn => UiLogLevel::Warn, + LinkLogLevel::Error => UiLogLevel::Error, } } -fn map_diagnostic_level(level: LinkDiagnosticSeverity) -> UxLogLevel { +fn map_diagnostic_level(level: LinkDiagnosticSeverity) -> UiLogLevel { match level { - LinkDiagnosticSeverity::Info => UxLogLevel::Info, - LinkDiagnosticSeverity::Warning => UxLogLevel::Warn, - LinkDiagnosticSeverity::Error => UxLogLevel::Error, + LinkDiagnosticSeverity::Info => UiLogLevel::Info, + LinkDiagnosticSeverity::Warning => UiLogLevel::Warn, + LinkDiagnosticSeverity::Error => UiLogLevel::Error, } } @@ -866,7 +877,7 @@ mod tests { #[test] fn selecting_provider_offers_registry_provider_actions() { - let link = LinkUx::with_registry(registry_with_fake_endpoint()); + let link = LinkController::with_registry(registry_with_fake_endpoint()); let actions = link.actions(false); @@ -877,13 +888,13 @@ mod tests { provider_id: LinkProviderKind::Fake }) ); - assert_eq!(actions[0].node_id().as_str(), LinkUx::NODE_ID); + assert_eq!(actions[0].node_id().as_str(), LinkController::NODE_ID); assert_eq!(actions[0].meta().label, "Select fake provider"); } #[test] fn connected_link_without_server_offers_server_attach() { - let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + let mut link = LinkController::with_registry(registry_with_fake_endpoint()); link.set_state(LinkState::Connected { device: ConnectedDeviceSummary::new( LinkProviderKind::Fake, @@ -902,7 +913,7 @@ mod tests { #[test] fn connected_link_with_server_hides_link_level_actions() { - let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + let mut link = LinkController::with_registry(registry_with_fake_endpoint()); link.set_state(LinkState::Connected { device: ConnectedDeviceSummary::new( LinkProviderKind::Fake, @@ -919,7 +930,7 @@ mod tests { #[test] fn connected_management_capable_link_offers_provision_and_reset_without_server() { - let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + let mut link = LinkController::with_registry(registry_with_fake_endpoint()); link.active_session = Some(management_capable_session()); link.set_state(LinkState::Connected { device: ConnectedDeviceSummary::new( @@ -932,19 +943,20 @@ mod tests { let actions = link.actions(false); - assert_eq!(actions.len(), 4); + assert_eq!(actions.len(), 5); assert_eq!( actions[0].op_as::(), Some(&LinkOp::ProvisionFirmware) ); assert_eq!(actions[1].op_as::(), Some(&LinkOp::ConnectServer)); - assert_eq!(actions[2].op_as::(), Some(&LinkOp::ResetToBlank)); - assert_eq!(actions[3].op_as::(), Some(&LinkOp::DisconnectLink)); + assert_eq!(actions[2].op_as::(), Some(&LinkOp::ResetDevice)); + assert_eq!(actions[3].op_as::(), Some(&LinkOp::ResetToBlank)); + assert_eq!(actions[4].op_as::(), Some(&LinkOp::DisconnectLink)); } #[test] fn connected_management_capable_link_hides_management_actions_with_server() { - let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + let mut link = LinkController::with_registry(registry_with_fake_endpoint()); link.active_session = Some(management_capable_session()); link.set_state(LinkState::Connected { device: ConnectedDeviceSummary::new( @@ -962,7 +974,7 @@ mod tests { #[test] fn failed_management_returns_to_recoverable_connected_state() { - let mut link = LinkUx::with_registry(registry_with_fake_endpoint()); + let mut link = LinkController::with_registry(registry_with_fake_endpoint()); link.active_provider = Some(LinkProviderKind::Fake); link.active_session = Some(management_capable_session()); link.set_state(LinkState::Connected { @@ -977,14 +989,14 @@ mod tests { let result = block_on_ready(link.manage(LinkManagementRequest::EraseDeviceFlash, "Wiping device")); - assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(result, Err(UiError::Link(_)))); assert!(matches!(link.state(), LinkState::Connected { .. })); assert!(!link.actions(false).is_empty()); } #[test] fn management_log_events_are_ux_logs_not_activity_terminal_lines() { - let mut activity = UiActivity::new("Flashing firmware"); + let mut activity = UiActivityView::new("Flashing firmware"); let event = LinkManagementEvent::log("Writing at 0x10000... (42%)"); let log = management_event_log(&event).expect("log event should produce a UX log"); @@ -1001,18 +1013,19 @@ mod tests { assert_eq!( error, - UxError::Cancelled("Port selection canceled".to_string()) + UiError::Cancelled("Port selection canceled".to_string()) ); } #[test] fn failed_endpoint_discovery_returns_to_provider_selection_with_issue() { - let mut link = - LinkUx::with_registry(registry_with_fake_discover_error("serial discovery failed")); + let mut link = LinkController::with_registry(registry_with_fake_discover_error( + "serial discovery failed", + )); let result = block_on_ready(link.open_provider(LinkProviderKind::Fake)); - assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(result, Err(UiError::Link(_)))); assert!(matches!( link.state(), LinkState::SelectingProvider { @@ -1031,7 +1044,7 @@ mod tests { #[test] fn failed_endpoint_connect_returns_to_provider_selection_with_issue() { - let mut link = LinkUx::with_registry(registry_with_fake_connect_error( + let mut link = LinkController::with_registry(registry_with_fake_connect_error( "Failed to open serial port.", )); @@ -1039,7 +1052,7 @@ mod tests { link.connect_endpoint(LinkProviderKind::Fake, LinkEndpointId::new("fake-runtime")), ); - assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(result, Err(UiError::Link(_)))); assert!(matches!( link.state(), LinkState::SelectingProvider { @@ -1058,14 +1071,15 @@ mod tests { #[test] fn failed_connection_handoff_returns_to_provider_selection_with_issue() { - let mut link = - LinkUx::with_registry(registry_with_fake_connection_error("server handoff failed")); + let mut link = LinkController::with_registry(registry_with_fake_connection_error( + "server handoff failed", + )); let result = block_on_ready( link.connect_endpoint(LinkProviderKind::Fake, LinkEndpointId::new("fake-runtime")), ); - assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(result, Err(UiError::Link(_)))); assert!(matches!( link.state(), LinkState::SelectingProvider { @@ -1084,7 +1098,7 @@ mod tests { #[test] fn snapshot_projects_provider_catalog_from_registry() { - let link = LinkUx::with_registry(registry_with_fake_endpoint()); + let link = LinkController::with_registry(registry_with_fake_endpoint()); assert!(matches!( link.snapshot().state, diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_op.rs b/lp-app/lpa-studio-core/src/app/link/link_op.rs similarity index 85% rename from lp-app/lpa-studio-ux/src/nodes/link/link_op.rs rename to lp-app/lpa-studio-core/src/app/link/link_op.rs index 1e7c6f439..143f44c23 100644 --- a/lp-app/lpa-studio-ux/src/nodes/link/link_op.rs +++ b/lp-app/lpa-studio-core/src/app/link/link_op.rs @@ -2,12 +2,13 @@ use core::any::Any; use lpa_link::{LinkEndpointId, LinkProviderKind}; -use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxOp}; +use crate::{ActionConfirmation, ActionMeta, ActionPriority, ControllerOp}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum LinkOp { RefreshProviders, ConnectServer, + ResetDevice, ProvisionFirmware, ResetToBlank, DisconnectLink, @@ -20,7 +21,7 @@ pub enum LinkOp { }, } -impl UxOp for LinkOp { +impl ControllerOp for LinkOp { fn default_action_meta(&self) -> ActionMeta { match self { Self::ProvisionFirmware => ActionMeta::new( @@ -43,6 +44,11 @@ impl UxOp for LinkOp { "This erases firmware and device data from the selected ESP32.", "Wipe device", )), + Self::ResetDevice => ActionMeta::new( + "Reset device", + "Reboot the connected device without erasing firmware or data.", + ActionPriority::Tertiary, + ), Self::ConnectServer => ActionMeta::new( "Connect server", "Attach Studio to the server protocol over the open link session.", @@ -71,11 +77,11 @@ impl UxOp for LinkOp { } } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_snapshot.rs b/lp-app/lpa-studio-core/src/app/link/link_snapshot.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/link/link_snapshot.rs rename to lp-app/lpa-studio-core/src/app/link/link_snapshot.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/link/link_state.rs b/lp-app/lpa-studio-core/src/app/link/link_state.rs similarity index 89% rename from lp-app/lpa-studio-ux/src/nodes/link/link_state.rs rename to lp-app/lpa-studio-core/src/app/link/link_state.rs index 314d95e38..1332d1fac 100644 --- a/lp-app/lpa-studio-ux/src/nodes/link/link_state.rs +++ b/lp-app/lpa-studio-core/src/app/link/link_state.rs @@ -1,11 +1,11 @@ -use crate::{ConnectedDeviceSummary, EndpointChoice, ProgressState, ProviderChoice, UxIssue}; +use crate::{ConnectedDeviceSummary, EndpointChoice, ProgressState, ProviderChoice, UiIssue}; use lpa_link::LinkProviderKind; #[derive(Clone, Debug, Eq, PartialEq)] pub enum LinkState { SelectingProvider { providers: Vec, - issue: Option, + issue: Option, }, DiscoveringEndpoints { provider_id: LinkProviderKind, @@ -27,6 +27,6 @@ pub enum LinkState { device: ConnectedDeviceSummary, }, Failed { - issue: UxIssue, + issue: UiIssue, }, } diff --git a/lp-app/lpa-studio-ux/src/nodes/link/mod.rs b/lp-app/lpa-studio-core/src/app/link/mod.rs similarity index 70% rename from lp-app/lpa-studio-ux/src/nodes/link/mod.rs rename to lp-app/lpa-studio-core/src/app/link/mod.rs index b478d3639..5ebac3ed2 100644 --- a/lp-app/lpa-studio-ux/src/nodes/link/mod.rs +++ b/lp-app/lpa-studio-core/src/app/link/mod.rs @@ -1,21 +1,20 @@ pub mod connected_device_summary; pub mod endpoint_choice; +pub mod link_controller; pub mod link_op; pub mod link_snapshot; pub mod link_state; -pub mod link_ux; pub mod progress_state; pub mod provider_choice; -pub mod ux_issue; +pub use crate::core::issue::UiIssue; pub use connected_device_summary::ConnectedDeviceSummary; pub use endpoint_choice::EndpointChoice; +pub use link_controller::{ + ConnectedLink, LinkController, LinkManagementOutcome, LinkOpenOutcome, SharedLinkRegistry, +}; pub use link_op::LinkOp; pub use link_snapshot::LinkSnapshot; pub use link_state::LinkState; -pub use link_ux::{ - ConnectedLink, LinkManagementOutcome, LinkOpenOutcome, LinkUx, SharedLinkRegistry, -}; pub use progress_state::ProgressState; pub use provider_choice::ProviderChoice; -pub use ux_issue::UxIssue; diff --git a/lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs b/lp-app/lpa-studio-core/src/app/link/progress_state.rs similarity index 55% rename from lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs rename to lp-app/lpa-studio-core/src/app/link/progress_state.rs index d68b38937..db43f513c 100644 --- a/lp-app/lpa-studio-ux/src/nodes/link/progress_state.rs +++ b/lp-app/lpa-studio-core/src/app/link/progress_state.rs @@ -1,3 +1,5 @@ +use crate::UiProgress; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProgressState { pub label: String, @@ -17,3 +19,13 @@ impl ProgressState { self } } + +impl From for UiProgress { + fn from(progress: ProgressState) -> Self { + let mut ui_progress = UiProgress::indeterminate(progress.label); + if let Some(detail) = progress.detail { + ui_progress = ui_progress.with_detail(detail); + } + ui_progress + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/provider_choice.rs b/lp-app/lpa-studio-core/src/app/link/provider_choice.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/link/provider_choice.rs rename to lp-app/lpa-studio-core/src/app/link/provider_choice.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/mod.rs b/lp-app/lpa-studio-core/src/app/mod.rs similarity index 84% rename from lp-app/lpa-studio-ux/src/nodes/mod.rs rename to lp-app/lpa-studio-core/src/app/mod.rs index 27e55cff4..550b7a953 100644 --- a/lp-app/lpa-studio-ux/src/nodes/mod.rs +++ b/lp-app/lpa-studio-core/src/app/mod.rs @@ -1,5 +1,6 @@ pub mod device; pub mod link; +pub mod node; pub mod project; pub mod server; pub mod studio; diff --git a/lp-app/lpa-studio-core/src/app/node/mod.rs b/lp-app/lpa-studio-core/src/app/node/mod.rs new file mode 100644 index 000000000..c451cf415 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/mod.rs @@ -0,0 +1,97 @@ +//! Data models for Studio node panes. +//! +//! The node UI is intentionally data-driven. Studio controllers project +//! LightPlayer model, slot, binding, and asset state into these `Ui*` structs, +//! and renderers consume the DTOs without needing to understand the runtime +//! model directly. +//! +//! This module owns the **DTO tree** in the project editor architecture: +//! `UiNodeView`, tabs, sections, config slots, produced values, produced +//! products, and slot detail aspects. DTOs carry renderable data and stable +//! identifiers, but they do not own reconciliation, editing, server sync, or +//! browser-local state. Those belong to the project controller tree and the web +//! component tree respectively. + +mod ui_config_slot; +mod ui_node_binding; +mod ui_node_child; +mod ui_node_dirty_state; +mod ui_node_header; +mod ui_node_section; +mod ui_node_tab; +mod ui_node_view; +mod ui_produced_product; +mod ui_produced_value; +mod ui_slot_aspect; +mod ui_slot_asset; +mod ui_slot_editor_hint; +mod ui_slot_field_state; +mod ui_slot_record; +mod ui_slot_shape; +mod ui_slot_source_state; +mod ui_slot_unit; +mod ui_slot_value; + +pub use ui_config_slot::{UiConfigSlot, UiConfigSlotBody, UiSlotOptionality}; +pub use ui_node_binding::{UiBindingEndpoint, UiProducedBinding, UiProducedBindings}; +pub use ui_node_child::UiNodeChild; +pub use ui_node_dirty_state::UiNodeDirtyState; +pub use ui_node_header::UiNodeHeader; +pub use ui_node_section::UiNodeSection; +pub use ui_node_tab::{UiNodeTab, UiNodeTabBody}; +pub use ui_node_view::UiNodeView; +pub use ui_produced_product::{ + UiControlProductPreview, UiControlSampleFormat, UiProducedProduct, UiProductKind, + UiProductPreview, UiProductPreviewFrame, UiProductRef, UiProductTrackingState, +}; +pub use ui_produced_value::UiProducedValue; +pub use ui_slot_aspect::{UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow}; +pub use ui_slot_asset::{UiAssetEditorKind, UiSlotAsset}; +pub use ui_slot_editor_hint::{UiSlotEditorHint, UiSlotOption}; +pub use ui_slot_field_state::UiSlotFieldState; +pub use ui_slot_record::UiSlotRecord; +pub use ui_slot_shape::{UiSlotShape, UiSlotShapeField}; +pub use ui_slot_source_state::UiSlotSourceState; +pub use ui_slot_unit::UiSlotUnit; +pub use ui_slot_value::{UiSlotValue, UiSlotValueKind}; + +#[cfg(test)] +mod tests { + use crate::{ + UiConfigSlot, UiNodeChild, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, + UiNodeView, UiProducedProduct, UiProducedValue, UiSlotValue, UiStatus, + }; + + #[test] + fn node_view_reports_sections_and_children() { + let view = UiNodeView::new( + UiNodeHeader::new("Playlist", "Playlist", "/show/playlist") + .with_status(UiStatus::good("Running")), + vec![UiNodeTab::main(vec![ + UiNodeSection::ProducedProducts(vec![UiProducedProduct::visual("output")]), + UiNodeSection::ProducedValues(vec![UiProducedValue::new("Entry time", "3.333")]), + UiNodeSection::ConfigSlots(vec![UiConfigSlot::value( + "default_fade", + "Default fade", + UiSlotValue::string("0.35 s"), + )]), + ])], + ) + .with_children(vec![UiNodeChild::new("blast", "Shader", "./blast.toml")]); + + assert!(view.has_sections()); + assert!(view.has_children()); + assert_eq!(view.tabs[0].label, "main"); + } + + #[test] + fn node_view_empty_tab_body_has_no_sections() { + let view = UiNodeView::new( + UiNodeHeader::new("Clock", "Clock", "/show/clock"), + vec![UiNodeTab::new("main", UiNodeTabBody::Sections(Vec::new()))], + ); + + assert!(!view.has_sections()); + assert!(!view.has_children()); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_config_slot.rs b/lp-app/lpa-studio-core/src/app/node/ui_config_slot.rs new file mode 100644 index 000000000..ce192f9fb --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_config_slot.rs @@ -0,0 +1,379 @@ +//! Typed config slot rows for the Studio node editor. + +use crate::{ + UiBindingEndpoint, UiNodeDirtyState, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, + UiSlotAspectRow, UiSlotAsset, UiSlotFieldState, UiSlotRecord, UiSlotShape, UiSlotShapeField, + UiSlotSourceState, UiSlotValue, +}; + +/// The renderable body of a config slot row. +#[derive(Clone, Debug, PartialEq)] +pub enum UiConfigSlotBody { + /// A placeholder or unit slot with no authored value body. + Empty, + /// A scalar or vector value rendered by `SlotValueEditor`. + Value(UiSlotValue), + /// A structured slot rendered by `SlotRecordEditor`. + Record(UiSlotRecord), + /// An asset slot rendered by an editor-like expansion. + Asset(UiSlotAsset), +} + +/// Optional inclusion state for a config slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UiSlotOptionality { + /// Whether the optional slot currently includes its value. + pub included: bool, + /// Whether the current user flow may toggle the inclusion state. + pub can_toggle: bool, +} + +impl UiSlotOptionality { + /// Create an included optional slot state. + pub fn included(can_toggle: bool) -> Self { + Self { + included: true, + can_toggle, + } + } + + /// Create an excluded optional slot state. + pub fn excluded(can_toggle: bool) -> Self { + Self { + included: false, + can_toggle, + } + } +} + +/// A config slot row projected from the LightPlayer slot tree. +#[derive(Clone, Debug, PartialEq)] +pub struct UiConfigSlot { + /// Stable controller-owned field key. + pub key: String, + /// Human-readable field label. + pub label: String, + /// Optional explanatory copy for info popovers and docs. + pub description: Option, + /// Optional type, unit, shape, path, or revision detail. + pub detail: Option, + /// Optional inclusion state when this row represents an `OptionSlot`. + pub optionality: Option, + /// Whether the visible value is direct, bound, or unset. + pub source: UiSlotSourceState, + /// Value or record body for the row. + pub body: UiConfigSlotBody, + /// Interaction, dirty, and validation state for the field. + pub state: UiSlotFieldState, + /// Projection or validation issues associated with this slot. + pub issues: Vec, + /// Stable popup sections and row-level presentation affordances. + pub aspects: Vec, +} + +impl UiConfigSlot { + /// Create a scalar or vector config slot. + pub fn value(key: impl Into, label: impl Into, value: UiSlotValue) -> Self { + Self::new(key, label, UiConfigSlotBody::Value(value)) + } + + /// Create a record-shaped config slot. + pub fn record( + key: impl Into, + label: impl Into, + fields: Vec, + ) -> Self { + Self::new( + key, + label, + UiConfigSlotBody::Record(UiSlotRecord::new(fields)), + ) + } + + /// Create an empty config slot row. + pub fn empty(key: impl Into, label: impl Into) -> Self { + Self::new(key, label, UiConfigSlotBody::Empty) + } + + /// Create an asset config slot. + pub fn asset(key: impl Into, label: impl Into, asset: UiSlotAsset) -> Self { + Self::new(key, label, UiConfigSlotBody::Asset(asset)) + } + + /// Create a config slot with an explicit body. + pub fn new(key: impl Into, label: impl Into, body: UiConfigSlotBody) -> Self { + Self { + key: key.into(), + label: label.into(), + description: None, + detail: None, + optionality: None, + source: UiSlotSourceState::Direct, + body, + state: UiSlotFieldState::editable(), + issues: Vec::new(), + aspects: Vec::new(), + } + } + + /// Add an explanatory description. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + /// Add compact secondary detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Set optional inclusion metadata. + pub fn with_optionality(mut self, optionality: UiSlotOptionality) -> Self { + self.optionality = Some(optionality); + self + } + + /// Set the visible value source state. + pub fn with_source(mut self, source: UiSlotSourceState) -> Self { + self.source = source; + self + } + + /// Set the interaction and validation state. + pub fn with_state(mut self, state: UiSlotFieldState) -> Self { + self.state = state; + self + } + + /// Add a projection or validation issue. + pub fn with_issue(mut self, issue: impl Into) -> Self { + self.issues.push(issue.into()); + self + } + + /// Add one explicit aspect section. + pub fn with_aspect(mut self, aspect: UiSlotAspect) -> Self { + self.aspects.push(aspect); + self + } + + /// Set explicit aspect sections. + pub fn with_aspects(mut self, aspects: Vec) -> Self { + self.aspects = aspects; + self + } + + /// Return explicit aspects, or transitional aspects derived from legacy fields. + pub fn visible_aspects(&self) -> Vec { + if self.aspects.is_empty() { + default_aspects(self) + } else { + self.aspects.clone() + } + } + + /// Return the most important row-level affordance for this slot. + pub fn primary_affordance(&self) -> UiSlotAffordance { + self.visible_aspects() + .iter() + .filter_map(|aspect| aspect.affordance) + .max() + .unwrap_or(UiSlotAffordance::Info) + } +} + +fn default_aspects(slot: &UiConfigSlot) -> Vec { + let mut aspects = vec![type_info_aspect(slot)]; + if let Some(optionality) = slot.optionality { + aspects.push(optionality_aspect(optionality)); + } + aspects.extend([ + validation_aspect(slot), + edit_state_aspect(&slot.state), + binding_aspect(&slot.source), + ]); + aspects +} + +fn optionality_aspect(optionality: UiSlotOptionality) -> UiSlotAspect { + let state = if optionality.included { + "Enabled" + } else { + "Disabled" + }; + UiSlotAspect::new(UiSlotAspectKind::Optionality, "Optional") + .with_row(UiSlotAspectRow::new(state, "")) +} + +fn validation_aspect(slot: &UiConfigSlot) -> UiSlotAspect { + let mut aspect = UiSlotAspect::new(UiSlotAspectKind::Validation, "Validation"); + if let Some(invalid) = slot.state.invalid.as_ref() { + aspect = aspect + .with_row(UiSlotAspectRow::new("Invalid", invalid.clone())) + .with_affordance(UiSlotAffordance::Invalid); + } + for issue in &slot.issues { + aspect = aspect.with_row(UiSlotAspectRow::new("Issue", issue.clone())); + } + if !slot.issues.is_empty() { + aspect = aspect.with_affordance(UiSlotAffordance::Error); + } + if aspect.rows.is_empty() { + aspect = aspect.with_row(UiSlotAspectRow::new("Valid", "")); + } + aspect +} + +fn edit_state_aspect(state: &UiSlotFieldState) -> UiSlotAspect { + match state.dirty { + UiNodeDirtyState::Clean => UiSlotAspect::new(UiSlotAspectKind::EditState, "Edit state") + .with_row(UiSlotAspectRow::new("No changes", "")), + UiNodeDirtyState::Dirty => UiSlotAspect::new(UiSlotAspectKind::EditState, "Edit state") + .with_row(UiSlotAspectRow::new("Edited", "Pending local change.")) + .with_affordance(UiSlotAffordance::Edited), + UiNodeDirtyState::Saving => UiSlotAspect::new(UiSlotAspectKind::EditState, "Edit state") + .with_row(UiSlotAspectRow::new("Saving", "Value is being written.")) + .with_affordance(UiSlotAffordance::Saving), + UiNodeDirtyState::Error => UiSlotAspect::new(UiSlotAspectKind::EditState, "Edit state") + .with_row(UiSlotAspectRow::new( + "Write failed", + "The edited value is still preserved.", + )) + .with_affordance(UiSlotAffordance::Error), + } +} + +fn binding_aspect(source: &UiSlotSourceState) -> UiSlotAspect { + match source { + UiSlotSourceState::Direct => UiSlotAspect::new(UiSlotAspectKind::Binding, "Binding") + .with_row(UiSlotAspectRow::new("Unbound", "")), + UiSlotSourceState::Bound(endpoint) => bound_binding_aspect(endpoint), + UiSlotSourceState::Unset => UiSlotAspect::new(UiSlotAspectKind::Binding, "Binding") + .with_row(UiSlotAspectRow::new("Unbound", "")), + } +} + +fn bound_binding_aspect(endpoint: &UiBindingEndpoint) -> UiSlotAspect { + let mut row = UiSlotAspectRow::new("Bound", endpoint.label.clone()); + if let Some(detail) = endpoint.detail.as_ref() { + row = row.with_detail(detail.clone()); + } + UiSlotAspect::new(UiSlotAspectKind::Binding, "Binding") + .with_row(row) + .with_affordance(UiSlotAffordance::Bound) +} + +fn type_info_aspect(slot: &UiConfigSlot) -> UiSlotAspect { + let mut aspect = UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Info") + .with_row(UiSlotAspectRow::new("Name", slot.key.clone())); + + aspect = aspect.with_row(UiSlotAspectRow::shape(body_shape(&slot.body))); + + aspect = match &slot.body { + UiConfigSlotBody::Value(value) => { + if let Some(unit) = value.display_unit() { + aspect.with_row(UiSlotAspectRow::unit(unit)) + } else { + aspect + } + } + UiConfigSlotBody::Empty | UiConfigSlotBody::Record(_) => aspect, + UiConfigSlotBody::Asset(asset) => { + aspect.with_row(UiSlotAspectRow::new("Source", asset.source.clone())) + } + }; + + aspect +} + +fn body_shape(body: &UiConfigSlotBody) -> UiSlotShape { + match body { + UiConfigSlotBody::Empty => UiSlotShape::Empty, + UiConfigSlotBody::Value(value) => UiSlotShape::from_value_kind(&value.kind), + UiConfigSlotBody::Record(record) => UiSlotShape::Record( + record + .fields + .iter() + .map(|field| UiSlotShapeField::new(field.label.clone(), body_shape(&field.body))) + .collect(), + ), + UiConfigSlotBody::Asset(asset) => UiSlotShape::Asset(asset.editor_label().to_string()), + } +} + +#[cfg(test)] +mod tests { + use crate::{ + UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, UiConfigSlotBody, UiNodeDirtyState, + UiSlotAffordance, UiSlotAsset, UiSlotFieldState, UiSlotSourceState, UiSlotValue, + }; + + #[test] + fn value_slot_keeps_typed_value() { + let slot = UiConfigSlot::value("fade", "Fade", UiSlotValue::f32(0.35)); + + let UiConfigSlotBody::Value(value) = slot.body else { + panic!("expected value slot"); + }; + assert_eq!(value.display, "0.35"); + } + + #[test] + fn record_slot_keeps_child_fields() { + let slot = UiConfigSlot::record( + "entry", + "Entry", + vec![UiConfigSlot::value( + "duration", + "Duration", + UiSlotValue::f32(2.0), + )], + ); + + let UiConfigSlotBody::Record(record) = slot.body else { + panic!("expected record slot"); + }; + assert_eq!(record.fields[0].label, "Duration"); + } + + #[test] + fn asset_slot_keeps_editor_data() { + let slot = UiConfigSlot::asset( + "shader", + "Shader", + UiSlotAsset::new("./shader.glsl", UiAssetEditorKind::Glsl) + .with_content("void mainImage(out vec4 color, in vec2 uv) {}"), + ); + + let UiConfigSlotBody::Asset(asset) = slot.body else { + panic!("expected asset slot"); + }; + assert_eq!(asset.source, "./shader.glsl"); + assert_eq!(asset.editor_label(), "GLSL asset"); + } + + #[test] + fn primary_affordance_uses_highest_aspect_affordance() { + let slot = UiConfigSlot::value("fade", "Fade", UiSlotValue::f32(-1.0)) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#time.seconds", + ))) + .with_state( + UiSlotFieldState::editable() + .with_dirty(UiNodeDirtyState::Dirty) + .with_invalid("value must be non-negative"), + ); + + assert_eq!(slot.primary_affordance(), UiSlotAffordance::Invalid); + } + + #[test] + fn bound_source_provides_bound_affordance() { + let slot = UiConfigSlot::value("time", "Time", UiSlotValue::f32(3.333)).with_source( + UiSlotSourceState::Bound(UiBindingEndpoint::new("bus#time.seconds")), + ); + + assert_eq!(slot.primary_affordance(), UiSlotAffordance::Bound); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs new file mode 100644 index 000000000..fb47fa24e --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_binding.rs @@ -0,0 +1,121 @@ +//! Binding summaries for node data. + +use crate::{UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow}; + +/// A human-readable binding endpoint shown in node binding popovers. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiBindingEndpoint { + /// Compact label for the endpoint, such as `bus#time.seconds`. + pub label: String, + /// Optional detail, usually the owning node or slot path. + pub detail: Option, +} + +impl UiBindingEndpoint { + /// Create a binding endpoint with no detail. + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + detail: None, + } + } + + /// Add secondary detail to the endpoint. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} + +/// Binding state for a produced product or value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProducedBindings { + /// Optional bus target published by this output. + pub bus_target: Option, + /// Explicit target bindings authored from this output. + pub target_bindings: Vec, + /// Read-only consumers discovered from the project graph. + pub consumers: Vec, +} + +impl UiProducedBindings { + /// Create an empty produced-binding summary. + pub fn none() -> Self { + Self { + bus_target: None, + target_bindings: Vec::new(), + consumers: Vec::new(), + } + } + + /// Returns true when there is any route worth surfacing in the UI. + pub fn has_any(&self) -> bool { + self.bus_target.is_some() || !self.target_bindings.is_empty() || !self.consumers.is_empty() + } +} + +impl Default for UiProducedBindings { + fn default() -> Self { + Self::none() + } +} + +/// How a produced item participates in the graph. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProducedBinding { + /// Binding state associated with the produced item. + pub bindings: UiProducedBindings, + /// Optional slot revision for debugging freshness. + pub revision: Option, +} + +impl UiProducedBinding { + /// Create binding metadata with no routes. + pub fn none() -> Self { + Self { + bindings: UiProducedBindings::none(), + revision: None, + } + } + + /// Build the shared detail aspect for produced output routing. + pub fn output_aspect(&self) -> UiSlotAspect { + let mut aspect = UiSlotAspect::new(UiSlotAspectKind::Binding, "Output"); + + if let Some(bus_target) = self.bindings.bus_target.as_ref() { + aspect = aspect.with_row(endpoint_row("Published", bus_target)); + } + for target in &self.bindings.target_bindings { + aspect = aspect.with_row(endpoint_row("Bound to", target)); + } + for consumer in &self.bindings.consumers { + aspect = aspect.with_row(endpoint_row("Consumed by", consumer)); + } + + if let Some(revision) = self.revision.as_ref() { + aspect = aspect.with_row(UiSlotAspectRow::new("Revision", revision.clone())); + } + + if self.bindings.has_any() { + aspect.with_affordance(UiSlotAffordance::Bound) + } else if aspect.rows.is_empty() { + aspect.with_row(UiSlotAspectRow::new("Unbound", "")) + } else { + aspect + } + } +} + +impl Default for UiProducedBinding { + fn default() -> Self { + Self::none() + } +} + +fn endpoint_row(label: &'static str, endpoint: &UiBindingEndpoint) -> UiSlotAspectRow { + let mut row = UiSlotAspectRow::new(label, endpoint.label.clone()); + if let Some(detail) = endpoint.detail.as_ref() { + row = row.with_detail(detail.clone()); + } + row +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs new file mode 100644 index 000000000..0a6f5dc14 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_child.rs @@ -0,0 +1,73 @@ +//! Child nodes extracted from config slots. + +use crate::{UiAction, UiNodeDirtyState, UiNodeSection, UiStatus}; + +/// A child node rendered outside its parent node pane. +#[derive(Clone, Debug, PartialEq)] +pub struct UiNodeChild { + /// Child use label. + pub label: String, + /// Child node kind. + pub kind: String, + /// Slot, source, or invocation detail. + pub detail: String, + /// Runtime status for the child. + pub status: UiStatus, + /// Optional active-state or timing summary. + pub summary: Option, + /// Whether this child is the active branch for its parent. + pub active: bool, + /// Whether this child node is the focused/selected Studio node. + pub focused: bool, + /// Action that focuses this child node as the current Studio selection. + pub action: Option, + /// Compact body sections for expanded child display. + pub sections: Vec, + /// Nested child nodes extracted below this child. + pub children: Vec, + /// Edited-state affordance for child invocation metadata. + pub dirty: UiNodeDirtyState, +} + +impl UiNodeChild { + /// Create a child node summary. + pub fn new( + label: impl Into, + kind: impl Into, + detail: impl Into, + ) -> Self { + Self { + label: label.into(), + kind: kind.into(), + detail: detail.into(), + status: UiStatus::neutral("Idle"), + summary: None, + active: false, + focused: false, + action: None, + sections: Vec::new(), + children: Vec::new(), + dirty: UiNodeDirtyState::Clean, + } + } + + /// Mark the child as active. + pub fn active(mut self, summary: impl Into) -> Self { + self.active = true; + self.status = UiStatus::good("Active"); + self.summary = Some(summary.into()); + self + } + + /// Add compact body sections. + pub fn with_sections(mut self, sections: Vec) -> Self { + self.sections = sections; + self + } + + /// Add nested child nodes. + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_dirty_state.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_dirty_state.rs new file mode 100644 index 000000000..83ccbbaf6 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_dirty_state.rs @@ -0,0 +1,21 @@ +//! Edited-state affordances for node anatomy data. + +/// Whether a UI datum matches the persisted project state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiNodeDirtyState { + /// The value is in sync with the loaded project. + Clean, + /// The value has local edits that are not yet committed. + Dirty, + /// The value is being written or refreshed. + Saving, + /// The last write failed and the UI should preserve the edited value. + Error, +} + +impl UiNodeDirtyState { + /// Returns true when the value needs a visible edited-state affordance. + pub fn needs_attention(self) -> bool { + matches!(self, Self::Dirty | Self::Saving | Self::Error) + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs new file mode 100644 index 000000000..bd8af921b --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_header.rs @@ -0,0 +1,61 @@ +//! Header metadata for a node pane. + +use crate::UiStatus; + +/// Identity and runtime summary shown at the top of a node pane. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiNodeHeader { + /// Display name, usually the node use name. + pub title: String, + /// Node kind or definition family. + pub kind: String, + /// Stable path shown for orientation and debugging. + pub path: String, + /// Optional file or asset source associated with the node. + pub source: Option, + /// Compact runtime status for the node. + pub status: UiStatus, + /// Optional performance or runtime summary. + pub summary: Option, + /// Optional expanded status detail or error text. + pub detail: Option, +} + +impl UiNodeHeader { + /// Create a header with neutral status. + pub fn new(title: impl Into, kind: impl Into, path: impl Into) -> Self { + Self { + title: title.into(), + kind: kind.into(), + path: path.into(), + source: None, + status: UiStatus::neutral("Idle"), + summary: None, + detail: None, + } + } + + /// Set the compact status. + pub fn with_status(mut self, status: UiStatus) -> Self { + self.status = status; + self + } + + /// Set the file or asset source label. + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Set the runtime summary. + pub fn with_summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + /// Set the expanded status detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs new file mode 100644 index 000000000..2b33416b1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_section.rs @@ -0,0 +1,31 @@ +//! Typed sections inside a node tab. + +use crate::{UiConfigSlot, UiNodeChild, UiProducedProduct, UiProducedValue}; + +/// A semantic section in a node tab body. +#[derive(Clone, Debug, PartialEq)] +pub enum UiNodeSection { + /// Product outputs that power the main visual section. + ProducedProducts(Vec), + /// Non-product outputs, such as time or progress values. + ProducedValues(Vec), + /// Normal configurable input slots. + ConfigSlots(Vec), + /// Asset slots promoted to editor-level treatment. + AssetSlots(Vec), + /// Children shown inline for small compositions or story isolation. + Children(Vec), +} + +impl UiNodeSection { + /// Returns true when the section has no items. + pub fn is_empty(&self) -> bool { + match self { + Self::ProducedProducts(items) => items.is_empty(), + Self::ProducedValues(items) => items.is_empty(), + Self::ConfigSlots(items) => items.is_empty(), + Self::AssetSlots(items) => items.is_empty(), + Self::Children(items) => items.is_empty(), + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_tab.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_tab.rs new file mode 100644 index 000000000..3eda0a66c --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_tab.rs @@ -0,0 +1,51 @@ +//! Tabs for node pane bodies. + +use crate::UiNodeSection; + +/// A node pane tab. +#[derive(Clone, Debug, PartialEq)] +pub struct UiNodeTab { + /// Short tab label. + pub label: String, + /// Tab body. + pub body: UiNodeTabBody, +} + +impl UiNodeTab { + /// Create a tab with an explicit body. + pub fn new(label: impl Into, body: UiNodeTabBody) -> Self { + Self { + label: label.into(), + body, + } + } + + /// Create the conventional main tab from typed sections. + pub fn main(sections: Vec) -> Self { + Self::new("main", UiNodeTabBody::Sections(sections)) + } +} + +/// Content rendered inside a node tab. +#[derive(Clone, Debug, PartialEq)] +pub enum UiNodeTabBody { + /// Domain-aware node anatomy sections. + Sections(Vec), + /// Read-only text, useful for raw JSON or diagnostics. + Text { + /// Heading for the text block. + title: String, + /// Text body. + body: String, + }, +} + +impl UiNodeTabBody { + /// Returns true when the body has no sections or text. + pub fn is_empty(&self) -> bool { + match self { + Self::Sections(sections) => sections.iter().all(UiNodeSection::is_empty), + Self::Text { body, .. } => body.is_empty(), + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_node_view.rs b/lp-app/lpa-studio-core/src/app/node/ui_node_view.rs new file mode 100644 index 000000000..c079be689 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_node_view.rs @@ -0,0 +1,66 @@ +//! Complete node pane data. + +use crate::{UiAction, UiNodeChild, UiNodeHeader, UiNodeTab, UiNodeTabBody}; + +/// The full data model for a Studio node pane. +#[derive(Clone, Debug, PartialEq)] +pub struct UiNodeView { + /// Stable id used by renderers for keys and future actions. + pub node_id: String, + /// Header identity and status metadata. + pub header: UiNodeHeader, + /// Tabs rendered inside the node pane. + pub tabs: Vec, + /// Child nodes extracted from the config slot tree. + pub children: Vec, + /// Whether this node is the focused/selected node. + pub focused: bool, + /// Action that focuses this node as the current Studio selection. + pub action: Option, + /// Whether the pane starts collapsed. + pub collapsed: bool, + /// Projection or runtime issues for the whole node. + pub issues: Vec, +} + +impl UiNodeView { + /// Create a node pane view. + pub fn new(header: UiNodeHeader, tabs: Vec) -> Self { + let node_id = header.path.clone(); + Self { + node_id, + header, + tabs, + children: Vec::new(), + focused: false, + action: None, + collapsed: false, + issues: Vec::new(), + } + } + + /// Override the stable id. + pub fn with_node_id(mut self, node_id: impl Into) -> Self { + self.node_id = node_id.into(); + self + } + + /// Set extracted child nodes. + pub fn with_children(mut self, children: Vec) -> Self { + self.children = children; + self + } + + /// Returns true when any tab contains node anatomy sections. + pub fn has_sections(&self) -> bool { + self.tabs.iter().any(|tab| match &tab.body { + UiNodeTabBody::Sections(sections) => sections.iter().any(|section| !section.is_empty()), + UiNodeTabBody::Text { .. } => false, + }) + } + + /// Returns true when this node has extracted children. + pub fn has_children(&self) -> bool { + !self.children.is_empty() + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_produced_product.rs b/lp-app/lpa-studio-core/src/app/node/ui_produced_product.rs new file mode 100644 index 000000000..316fbbbd6 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_produced_product.rs @@ -0,0 +1,321 @@ +//! Produced product data for primary node output surfaces. + +use lpc_model::{ + ControlDisplayLayout, ControlExtent, ControlProduct, ControlSampleLayout, NodeId, ProductRef, + VisualProduct, +}; + +use crate::{ + UiNodeDirtyState, UiProducedBinding, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, + UiSlotShape, +}; + +/// The family of product a node emits. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiProductKind { + /// No product has been resolved for this output yet. + Empty, + /// A visual image, shader result, or other displayable surface. + Visual, + /// A control stream, fixture map, or nonvisual device output. + Control, + /// A product whose presentation is not known by Studio yet. + Other, +} + +/// Whether Studio is actively requesting previews for this product. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiProductTrackingState { + /// The product has not been watched in this Studio session. + Untracked, + /// Studio is actively requesting preview updates for the product. + Tracking, + /// Studio has preview data, but this product is not the active watch target. + Paused, +} + +/// Stable frame geometry for preview surfaces. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UiProductPreviewFrame { + /// Preview frame width in logical sample units. + pub width: u32, + /// Preview frame height in logical sample units. + pub height: u32, +} + +impl UiProductPreviewFrame { + /// Default visual-product probe frame. + pub const VISUAL_DEFAULT: Self = Self::new(32, 32); + + /// Create a preview frame with a nonzero fallback. + #[must_use] + pub const fn new(width: u32, height: u32) -> Self { + Self { + width: if width == 0 { 1 } else { width }, + height: if height == 0 { 1 } else { height }, + } + } +} + +/// Stable UI-facing identity for a lazy graph product. +/// +/// The Studio DTO keeps this separate from rendering state so controllers can +/// request previews and stories can still hand-build product rows. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum UiProductRef { + /// Renderable visual material produced by a node output. + Visual { node_id: u32, output: u32 }, + /// Device-control material produced by a node output. + Control { + node_id: u32, + output: u32, + rows: u32, + samples_per_row: u32, + }, +} + +impl UiProductRef { + /// Convert a model product ref into the UI identity used for preview state. + #[must_use] + pub fn from_product_ref(product: ProductRef) -> Self { + match product { + ProductRef::Visual(product) => Self::from_visual_product(product), + ProductRef::Control(product) => Self::from_control_product(product), + } + } + + /// Convert a visual product into a UI identity. + #[must_use] + pub fn from_visual_product(product: VisualProduct) -> Self { + Self::Visual { + node_id: product.node().0, + output: product.output(), + } + } + + /// Convert a control product into a UI identity. + #[must_use] + pub fn from_control_product(product: ControlProduct) -> Self { + let extent = product.preferred_extent(); + Self::Control { + node_id: product.node().0, + output: product.output(), + rows: extent.rows, + samples_per_row: extent.samples_per_row, + } + } + + /// Convert this identity back into a visual product when possible. + #[must_use] + pub fn visual_product(self) -> Option { + match self { + Self::Visual { node_id, output } => { + Some(VisualProduct::new(NodeId::new(node_id), output)) + } + Self::Control { .. } => None, + } + } + + /// Convert this identity back into a control product when possible. + #[must_use] + pub fn control_product(self) -> Option { + match self { + Self::Control { + node_id, + output, + rows, + samples_per_row, + } => Some(ControlProduct::new( + NodeId::new(node_id), + output, + ControlExtent::new(rows, samples_per_row), + )), + Self::Visual { .. } => None, + } + } +} + +/// Native control sample format carried by a Studio preview DTO. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiControlSampleFormat { + U16, +} + +/// Data-driven preview for a native control product. +#[derive(Clone, Debug, PartialEq)] +pub struct UiControlProductPreview { + /// Project revision that produced this sample payload. + pub revision: i64, + /// Native control sample extent. + pub extent: ControlExtent, + /// Native sample format. + pub sample_format: UiControlSampleFormat, + /// How to interpret the native sample buffer. + pub sample_layout: ControlSampleLayout, + /// Optional human-facing display layout for the sample data. + pub display_layout: Option, + /// Native sample bytes, little-endian for `U16`. + pub bytes: Vec, +} + +/// Small, serializable-enough preview state for a produced product. +/// +/// Browser-specific DOM/canvas state belongs in the web crate. This DTO only +/// carries bounded preview bytes and durable error/loading state. +#[derive(Clone, Debug, PartialEq)] +pub enum UiProductPreview { + /// The product slot has no product value yet. + Empty, + /// A probe has been requested or the product is waiting for its first probe. + Pending, + /// RGB8 visual preview bytes in row-major order. + VisualSrgb8 { + width: u32, + height: u32, + revision: i64, + bytes: Vec, + }, + /// Native control samples plus optional display layout. + ControlNative(UiControlProductPreview), + /// The product is represented by metadata only in this slice. + MetadataOnly, + /// The runtime explicitly does not support this preview. + Unsupported { reason: String }, + /// The runtime failed while producing this preview. + Error { message: String }, +} + +impl UiProductPreview { + /// Default preview state for a product family. + #[must_use] + pub fn for_kind(kind: UiProductKind) -> Self { + match kind { + UiProductKind::Empty => Self::Empty, + UiProductKind::Visual => Self::Pending, + UiProductKind::Control => Self::Pending, + UiProductKind::Other => Self::MetadataOnly, + } + } +} + +/// A produced output that deserves primary visual treatment in the node pane. +#[derive(Clone, Debug, PartialEq)] +pub struct UiProducedProduct { + /// Product slot or friendly output name. + pub name: String, + /// Product family for presentation and labeling. + pub kind: UiProductKind, + /// Concrete product identity used by controllers to attach preview state. + pub product: Option, + /// Current preview state for this product. + pub preview: UiProductPreview, + /// Whether Studio is watching this product now. + pub tracking: UiProductTrackingState, + /// Stable preview frame used even before bytes are available. + pub frame: UiProductPreviewFrame, + /// Optional size, shape, or sample-count detail. + pub detail: Option, + /// Binding and revision metadata for the product. + pub binding: UiProducedBinding, + /// Edited-state affordance for authored product metadata. + pub dirty: UiNodeDirtyState, +} + +impl UiProducedProduct { + /// Create a produced product of the requested kind. + pub fn new(name: impl Into, kind: UiProductKind) -> Self { + Self { + name: name.into(), + kind, + product: None, + preview: UiProductPreview::for_kind(kind), + tracking: UiProductTrackingState::Untracked, + frame: UiProductPreviewFrame::VISUAL_DEFAULT, + detail: None, + binding: UiProducedBinding::none(), + dirty: UiNodeDirtyState::Clean, + } + } + + /// Create a visual product. + pub fn visual(name: impl Into) -> Self { + Self::new(name, UiProductKind::Visual) + } + + /// Create an empty product placeholder. + pub fn empty(name: impl Into) -> Self { + Self::new(name, UiProductKind::Empty) + } + + /// Create a control product. + pub fn control(name: impl Into) -> Self { + Self::new(name, UiProductKind::Control) + } + + /// Add size or shape detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Attach concrete product identity. + #[must_use] + pub fn with_product(mut self, product: UiProductRef) -> Self { + self.product = Some(product); + self + } + + /// Attach current preview state. + #[must_use] + pub fn with_preview(mut self, preview: UiProductPreview) -> Self { + self.preview = preview; + self + } + + /// Attach the current tracking state. + #[must_use] + pub fn with_tracking(mut self, tracking: UiProductTrackingState) -> Self { + self.tracking = tracking; + self + } + + /// Attach stable preview frame geometry. + #[must_use] + pub fn with_frame(mut self, frame: UiProductPreviewFrame) -> Self { + self.frame = frame; + self + } + + /// Shared detail aspects for produced product popups. + pub fn visible_aspects(&self) -> Vec { + vec![ + produced_product_info_aspect(self), + self.binding.output_aspect(), + ] + } +} + +impl UiProductKind { + /// Compact label for product detail rows. + pub fn detail_label(self) -> &'static str { + match self { + Self::Empty => "Empty product", + Self::Visual => "Visual product", + Self::Control => "Control product", + Self::Other => "Product", + } + } +} + +fn produced_product_info_aspect(product: &UiProducedProduct) -> UiSlotAspect { + let mut shape_row = UiSlotAspectRow::shape(UiSlotShape::Product( + product.kind.detail_label().to_string(), + )); + if let Some(detail) = product.detail.as_ref() { + shape_row = shape_row.with_detail(detail.clone()); + } + + UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Info") + .with_row(UiSlotAspectRow::new("Name", product.name.clone())) + .with_row(shape_row) +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_produced_value.rs b/lp-app/lpa-studio-core/src/app/node/ui_produced_value.rs new file mode 100644 index 000000000..b2449dffa --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_produced_value.rs @@ -0,0 +1,85 @@ +//! Produced scalar or structured values. + +use crate::{ + UiNodeDirtyState, UiProducedBinding, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, + UiSlotShape, UiSlotUnit, +}; + +/// A non-product output rendered as a compact value box. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiProducedValue { + /// Human-readable value label. + pub label: String, + /// Current formatted value. + pub value: String, + /// Optional type, unit, or runtime detail. + pub detail: Option, + /// Structured unit metadata for value presentation. + pub unit: Option, + /// Binding and revision metadata for the value. + pub binding: UiProducedBinding, + /// Edited-state affordance for authored produced-value metadata. + pub dirty: UiNodeDirtyState, +} + +impl UiProducedValue { + /// Create a produced value. + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + detail: None, + unit: None, + binding: UiProducedBinding::none(), + dirty: UiNodeDirtyState::Clean, + } + } + + /// Add type, unit, or runtime detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Add structured unit metadata. + pub fn with_unit(mut self, unit: UiSlotUnit) -> Self { + self.unit = Some(unit); + self + } + + /// Return structured unit metadata, recognizing legacy detail labels. + pub fn display_unit(&self) -> Option { + self.unit.clone().or_else(|| { + self.detail + .as_deref() + .and_then(UiSlotUnit::from_known_label) + }) + } + + /// Shared detail aspects for produced value popups. + pub fn visible_aspects(&self) -> Vec { + vec![ + produced_value_info_aspect(self), + self.binding.output_aspect(), + ] + } +} + +fn produced_value_info_aspect(value: &UiProducedValue) -> UiSlotAspect { + let mut display = value.value.clone(); + if let Some(unit) = value.display_unit() { + display.push(' '); + display.push_str(&unit.short); + } + + let mut aspect = UiSlotAspect::new(UiSlotAspectKind::TypeInfo, "Info") + .with_row(UiSlotAspectRow::new("Name", value.label.clone())) + .with_row(UiSlotAspectRow::shape(UiSlotShape::ProducedValue)) + .with_row(UiSlotAspectRow::new("Value", display)); + + if let Some(unit) = value.display_unit() { + aspect = aspect.with_row(UiSlotAspectRow::unit(unit)); + } + + aspect +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs new file mode 100644 index 000000000..dc366bdee --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_aspect.rs @@ -0,0 +1,162 @@ +//! Stable popup sections and summary affordances for config slots. + +use super::{ui_slot_shape::UiSlotShape, ui_slot_unit::UiSlotUnit}; + +/// Compact row-level summary emitted by a slot aspect. +/// +/// The enum order is intentional: later variants are more important and win +/// the visible row treatment when multiple aspects provide affordances. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum UiSlotAffordance { + /// Quiet fallback when no aspect needs special treatment. + Info, + /// The slot is currently being written or refreshed. + Saving, + /// The slot value comes from a binding. + Bound, + /// The slot has a local edit that has not been saved. + Edited, + /// The slot value violates validation rules. + Invalid, + /// The slot has an operation, projection, or write failure. + Error, +} + +/// The stable categories shown in a slot detail popup. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiSlotAspectKind { + /// Optional inclusion state for a slot whose value may be absent. + Optionality, + /// Validation rules and validation results. + Validation, + /// Local edit and persistence state. + EditState, + /// Direct value, binding, or unset source state. + Binding, + /// Slot value family and type metadata. + TypeInfo, +} + +/// A stable popup section for one slot concern. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotAspect { + /// Stable aspect category. + pub kind: UiSlotAspectKind, + /// Human-readable section title. + pub title: String, + /// Rows of detail shown inside the popup section. + pub rows: Vec, + /// Optional row-level visual summary provided by this aspect. + pub affordance: Option, +} + +impl UiSlotAspect { + /// Create an aspect section. + pub fn new(kind: UiSlotAspectKind, title: impl Into) -> Self { + Self { + kind, + title: title.into(), + rows: Vec::new(), + affordance: None, + } + } + + /// Add a detail row. + pub fn with_row(mut self, row: UiSlotAspectRow) -> Self { + self.rows.push(row); + self + } + + /// Mark the aspect with a row-level affordance. + pub fn with_affordance(mut self, affordance: UiSlotAffordance) -> Self { + self.affordance = Some(affordance); + self + } +} + +/// One label/value line inside a slot aspect section. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotAspectRow { + /// Compact row label. + pub label: String, + /// Main row value. + pub value: String, + /// Optional supporting detail. + pub detail: Option, + /// Typed shape metadata for rich renderers. + pub shape: Option, + /// Typed unit metadata for rich renderers. + pub unit: Option, +} + +impl UiSlotAspectRow { + /// Create a detail row. + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + detail: None, + shape: None, + unit: None, + } + } + + /// Create a typed shape detail row. + pub fn shape(shape: UiSlotShape) -> Self { + let value = shape.summary_label(); + let detail = shape.summary_detail(); + Self { + label: "Shape".to_string(), + value, + detail, + shape: Some(shape), + unit: None, + } + } + + /// Create a typed unit detail row. + pub fn unit(unit: UiSlotUnit) -> Self { + Self { + label: "Unit".to_string(), + value: unit.long.clone(), + detail: Some(unit.short.clone()), + shape: None, + unit: Some(unit), + } + } + + /// Add supporting detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Add typed shape metadata. + pub fn with_shape(mut self, shape: UiSlotShape) -> Self { + self.value = shape.summary_label(); + self.detail = shape.summary_detail(); + self.shape = Some(shape); + self + } + + /// Add typed unit metadata. + pub fn with_unit(mut self, unit: UiSlotUnit) -> Self { + self.value = unit.long.clone(); + self.detail = Some(unit.short.clone()); + self.unit = Some(unit); + self + } +} + +#[cfg(test)] +mod tests { + use crate::UiSlotAffordance; + + #[test] + fn enum_order_is_presentation_priority() { + assert!(UiSlotAffordance::Error > UiSlotAffordance::Invalid); + assert!(UiSlotAffordance::Invalid > UiSlotAffordance::Edited); + assert!(UiSlotAffordance::Edited > UiSlotAffordance::Bound); + assert!(UiSlotAffordance::Bound > UiSlotAffordance::Info); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_asset.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_asset.rs new file mode 100644 index 000000000..d3e4442e5 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_asset.rs @@ -0,0 +1,61 @@ +//! Asset editor data embedded in config slot rows. + +/// Preferred editor treatment for an asset slot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiAssetEditorKind { + /// Plain text or unknown source. + Text, + /// GLSL shader source. + Glsl, + /// SVG document or fixture map. + Svg, + /// Binary or opaque asset. + Binary, +} + +/// Editor-ready asset content carried by a config slot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotAsset { + /// Asset path, inline label, or source reference. + pub source: String, + /// Content/editor family. + pub editor: UiAssetEditorKind, + /// Optional language, size, revision, or load-state detail. + pub detail: Option, + /// Optional preview or full inline content. + pub content: Option, +} + +impl UiSlotAsset { + /// Create asset slot data. + pub fn new(source: impl Into, editor: UiAssetEditorKind) -> Self { + Self { + source: source.into(), + editor, + detail: None, + content: None, + } + } + + /// Add secondary detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Add preview or inline editor content. + pub fn with_content(mut self, content: impl Into) -> Self { + self.content = Some(content.into()); + self + } + + /// Compact editor label for slot detail popups. + pub fn editor_label(&self) -> &'static str { + match self.editor { + UiAssetEditorKind::Text => "Text asset", + UiAssetEditorKind::Glsl => "GLSL asset", + UiAssetEditorKind::Svg => "SVG asset", + UiAssetEditorKind::Binary => "Binary asset", + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_editor_hint.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_editor_hint.rs new file mode 100644 index 000000000..a94ad3cdf --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_editor_hint.rs @@ -0,0 +1,105 @@ +//! Presentation hints for config slot value editors. + +/// A selectable value for enum-like or constrained slot editors. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotOption { + /// Serialized or controller-owned value key. + pub value: String, + /// Human-readable option label. + pub label: String, +} + +impl UiSlotOption { + /// Create an option with an explicit value and label. + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + } + } +} + +/// A light UI hint for choosing the field component for a slot value. +#[derive(Clone, Debug, PartialEq)] +pub enum UiSlotEditorHint { + /// Let the renderer choose from the value kind. + Auto, + /// Render as a single-line text field. + Text, + /// Render as a numeric field with optional constraints. + Number { + /// Optional minimum accepted value. + min: Option, + /// Optional maximum accepted value. + max: Option, + /// Optional preferred input step. + step: Option, + }, + /// Render as a slider-like numeric control. + Slider { + /// Minimum slider value. + min: f32, + /// Maximum slider value. + max: f32, + /// Optional preferred slider step. + step: Option, + }, + /// Render as a dropdown using the provided options. + Dropdown(Vec), + /// Render a two-dimensional value with an XY affordance. + Xy, +} + +impl UiSlotEditorHint { + /// Numeric editor with no explicit constraints. + pub fn number() -> Self { + Self::Number { + min: None, + max: None, + step: None, + } + } + + /// Slider editor with a minimum and maximum. + pub fn slider(min: f32, max: f32) -> Self { + Self::Slider { + min, + max, + step: None, + } + } + + /// Dropdown editor from `(value, label)` pairs. + pub fn dropdown( + options: impl IntoIterator, impl Into)>, + ) -> Self { + Self::Dropdown( + options + .into_iter() + .map(|(value, label)| UiSlotOption::new(value, label)) + .collect(), + ) + } +} + +impl Default for UiSlotEditorHint { + fn default() -> Self { + Self::Auto + } +} + +#[cfg(test)] +mod tests { + use super::UiSlotEditorHint; + + #[test] + fn dropdown_collects_options() { + let hint = UiSlotEditorHint::dropdown([("idle", "Idle"), ("blast", "Blast")]); + + let UiSlotEditorHint::Dropdown(options) = hint else { + panic!("expected dropdown hint"); + }; + assert_eq!(options[0].value, "idle"); + assert_eq!(options[1].label, "Blast"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs new file mode 100644 index 000000000..0314ccecb --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_field_state.rs @@ -0,0 +1,76 @@ +//! UI state shared by config slot field components. + +use crate::UiNodeDirtyState; + +/// Interaction and validation state for a config slot field. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotFieldState { + /// Whether Studio should present the value as editable. + pub editable: bool, + /// Edited-state affordance for local value changes. + pub dirty: UiNodeDirtyState, + /// Validation error shown near the field when present. + pub invalid: Option, +} + +impl UiSlotFieldState { + /// Clean editable state for ordinary authorable slots. + pub fn editable() -> Self { + Self { + editable: true, + dirty: UiNodeDirtyState::Clean, + invalid: None, + } + } + + /// Clean read-only state for projected or derived values. + pub fn readonly() -> Self { + Self { + editable: false, + dirty: UiNodeDirtyState::Clean, + invalid: None, + } + } + + /// Mark the field with an edited-state affordance. + pub fn with_dirty(mut self, dirty: UiNodeDirtyState) -> Self { + self.dirty = dirty; + self + } + + /// Mark the field invalid with a human-readable reason. + pub fn with_invalid(mut self, invalid: impl Into) -> Self { + self.invalid = Some(invalid.into()); + self + } + + /// Returns true when the field has visible state chrome. + pub fn needs_attention(&self) -> bool { + self.dirty.needs_attention() || self.invalid.is_some() + } +} + +impl Default for UiSlotFieldState { + fn default() -> Self { + Self::editable() + } +} + +#[cfg(test)] +mod tests { + use crate::{UiNodeDirtyState, UiSlotFieldState}; + + #[test] + fn invalid_state_needs_attention() { + let state = UiSlotFieldState::editable().with_invalid("expected a finite value"); + + assert!(state.needs_attention()); + } + + #[test] + fn dirty_state_needs_attention() { + let state = UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty); + + assert!(state.needs_attention()); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_record.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_record.rs new file mode 100644 index 000000000..f2cd37b06 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_record.rs @@ -0,0 +1,48 @@ +//! Record-shaped config slot data. + +use crate::UiConfigSlot; + +/// A structured slot value rendered as nested config rows. +#[derive(Clone, Debug, PartialEq)] +pub struct UiSlotRecord { + /// Ordered child fields projected from a structured slot. + pub fields: Vec, +} + +impl UiSlotRecord { + /// Create a record from ordered child fields. + pub fn new(fields: Vec) -> Self { + Self { fields } + } + + /// Returns true when the record has no visible fields. + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } +} + +impl From> for UiSlotRecord { + fn from(fields: Vec) -> Self { + Self::new(fields) + } +} + +#[cfg(test)] +mod tests { + use crate::{UiConfigSlot, UiSlotRecord, UiSlotValue}; + + #[test] + fn reports_empty_records() { + assert!(UiSlotRecord::new(Vec::new()).is_empty()); + } + + #[test] + fn keeps_field_order() { + let record = UiSlotRecord::new(vec![ + UiConfigSlot::value("time", "Time", UiSlotValue::f32(1.0)), + UiConfigSlot::value("trigger", "Trigger", UiSlotValue::bool(false)), + ]); + + assert_eq!(record.fields[1].key, "trigger"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_shape.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_shape.rs new file mode 100644 index 000000000..c0bcfe412 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_shape.rs @@ -0,0 +1,197 @@ +//! Renderable slot shape metadata for Studio node surfaces. + +use super::ui_slot_value::UiSlotValueKind; + +/// One named field inside a renderable record shape. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotShapeField { + /// Human-readable field label. + pub label: String, + /// Field value shape. + pub shape: UiSlotShape, +} + +impl UiSlotShapeField { + /// Create a record shape field. + pub fn new(label: impl Into, shape: UiSlotShape) -> Self { + Self { + label: label.into(), + shape, + } + } +} + +/// Studio-facing shape vocabulary for slots, products, and produced values. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UiSlotShape { + /// No authored value body. + Empty, + /// Text or resource-like scalar value. + Text, + /// Signed 32-bit integer value. + Int32, + /// Unsigned 32-bit integer value. + UInt32, + /// 32-bit floating point value. + Float32, + /// Boolean value. + Bool, + /// Two-component floating point vector. + Vec2, + /// Three-component floating point vector. + Vec3, + /// Four-component floating point vector. + Vec4, + /// Two-component signed integer vector. + IVec2, + /// Three-component signed integer vector. + IVec3, + /// Four-component signed integer vector. + IVec4, + /// Two-component unsigned integer vector. + UVec2, + /// Three-component unsigned integer vector. + UVec3, + /// Four-component unsigned integer vector. + UVec4, + /// Two-component boolean vector. + BVec2, + /// Three-component boolean vector. + BVec3, + /// Four-component boolean vector. + BVec4, + /// 2x2 floating point matrix. + Mat2x2, + /// 3x3 floating point matrix. + Mat3x3, + /// 4x4 floating point matrix. + Mat4x4, + /// Homogeneous or wire-provided array/list payload. + Array, + /// Atomic enum payload. + Enum, + /// Store-backed resource reference. + Resource, + /// Named field collection. + Record(Vec), + /// File-backed or resource-backed authored content. + Asset(String), + /// Produced product family. + Product(String), + /// Non-product produced value. + ProducedValue, +} + +impl UiSlotShape { + /// Build a shape from a slot value kind. + pub fn from_value_kind(kind: &UiSlotValueKind) -> Self { + match kind { + UiSlotValueKind::Unset => Self::Empty, + UiSlotValueKind::String(_) => Self::Text, + UiSlotValueKind::I32(_) => Self::Int32, + UiSlotValueKind::U32(_) => Self::UInt32, + UiSlotValueKind::F32(_) => Self::Float32, + UiSlotValueKind::Bool(_) => Self::Bool, + UiSlotValueKind::Vec2(_) => Self::Vec2, + UiSlotValueKind::Vec3(_) => Self::Vec3, + UiSlotValueKind::Vec4(_) => Self::Vec4, + UiSlotValueKind::IVec2(_) => Self::IVec2, + UiSlotValueKind::IVec3(_) => Self::IVec3, + UiSlotValueKind::IVec4(_) => Self::IVec4, + UiSlotValueKind::UVec2(_) => Self::UVec2, + UiSlotValueKind::UVec3(_) => Self::UVec3, + UiSlotValueKind::UVec4(_) => Self::UVec4, + UiSlotValueKind::BVec2(_) => Self::BVec2, + UiSlotValueKind::BVec3(_) => Self::BVec3, + UiSlotValueKind::BVec4(_) => Self::BVec4, + UiSlotValueKind::Mat2x2(_) => Self::Mat2x2, + UiSlotValueKind::Mat3x3(_) => Self::Mat3x3, + UiSlotValueKind::Mat4x4(_) => Self::Mat4x4, + UiSlotValueKind::Array(_) => Self::Array, + UiSlotValueKind::Struct { fields, .. } => Self::Record( + fields + .iter() + .map(|(label, value)| { + UiSlotShapeField::new(label.clone(), Self::from_value_kind(&value.kind)) + }) + .collect(), + ), + UiSlotValueKind::Enum { .. } => Self::Enum, + UiSlotValueKind::Resource(_) => Self::Resource, + UiSlotValueKind::Product(_) => Self::Product("Product".to_string()), + } + } + + /// Stable compact label used as a text fallback outside rich Studio UI. + pub fn summary_label(&self) -> String { + match self { + Self::Empty => "Empty".to_string(), + Self::Text => "Text".to_string(), + Self::Int32 => "Int32".to_string(), + Self::UInt32 => "UInt32".to_string(), + Self::Float32 => "Float32".to_string(), + Self::Bool => "Bool".to_string(), + Self::Vec2 => "Vec2".to_string(), + Self::Vec3 => "Vec3".to_string(), + Self::Vec4 => "Vec4".to_string(), + Self::IVec2 => "IVec2".to_string(), + Self::IVec3 => "IVec3".to_string(), + Self::IVec4 => "IVec4".to_string(), + Self::UVec2 => "UVec2".to_string(), + Self::UVec3 => "UVec3".to_string(), + Self::UVec4 => "UVec4".to_string(), + Self::BVec2 => "BVec2".to_string(), + Self::BVec3 => "BVec3".to_string(), + Self::BVec4 => "BVec4".to_string(), + Self::Mat2x2 => "Mat2x2".to_string(), + Self::Mat3x3 => "Mat3x3".to_string(), + Self::Mat4x4 => "Mat4x4".to_string(), + Self::Array => "Array".to_string(), + Self::Enum => "Enum".to_string(), + Self::Resource => "Resource".to_string(), + Self::Record(_) => "Record".to_string(), + Self::Asset(label) | Self::Product(label) => label.clone(), + Self::ProducedValue => "Produced value".to_string(), + } + } + + /// Stable compact detail used as a text fallback outside rich Studio UI. + pub fn summary_detail(&self) -> Option { + match self { + Self::Record(fields) => { + let count = fields.len(); + Some(if count == 1 { + "1 field".to_string() + } else { + format!("{count} fields") + }) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{UiSlotShape, UiSlotShapeField, UiSlotValue}; + + #[test] + fn value_kind_shapes_use_friendly_integer_names() { + assert_eq!( + UiSlotShape::from_value_kind(&UiSlotValue::i32(-4).kind).summary_label(), + "Int32" + ); + assert_eq!( + UiSlotShape::from_value_kind(&UiSlotValue::u32(4).kind).summary_label(), + "UInt32" + ); + } + + #[test] + fn record_shape_summarizes_field_count() { + let shape = UiSlotShape::Record(vec![UiSlotShapeField::new("Time", UiSlotShape::Float32)]); + + assert_eq!(shape.summary_label(), "Record"); + assert_eq!(shape.summary_detail().as_deref(), Some("1 field")); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_source_state.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_source_state.rs new file mode 100644 index 000000000..3cc1650de --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_source_state.rs @@ -0,0 +1,39 @@ +//! Value-source metadata for config slots. + +use crate::UiBindingEndpoint; + +/// Where a config slot currently receives its value. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UiSlotSourceState { + /// The value is authored directly on this slot. + Direct, + /// The value is provided by a binding endpoint. + Bound(UiBindingEndpoint), + /// The slot is unset and has no resolved value. + Unset, +} + +impl UiSlotSourceState { + /// Returns true when the slot is currently backed by a binding. + pub fn is_bound(&self) -> bool { + matches!(self, Self::Bound(_)) + } +} + +impl Default for UiSlotSourceState { + fn default() -> Self { + Self::Direct + } +} + +#[cfg(test)] +mod tests { + use crate::{UiBindingEndpoint, UiSlotSourceState}; + + #[test] + fn reports_bound_source() { + let source = UiSlotSourceState::Bound(UiBindingEndpoint::new("bus#time.seconds")); + + assert!(source.is_bound()); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_unit.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_unit.rs new file mode 100644 index 000000000..6660f5ddf --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_unit.rs @@ -0,0 +1,84 @@ +//! Renderable unit metadata for Studio node surfaces. + +/// Studio-facing physical or semantic unit labels. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiSlotUnit { + /// Compact unit label used near values. + pub short: String, + /// Verbose unit label used in detail popups. + pub long: String, +} + +impl UiSlotUnit { + /// Create a unit label pair. + pub fn new(short: impl Into, long: impl Into) -> Self { + Self { + short: short.into(), + long: long.into(), + } + } + + /// Seconds. + pub fn seconds() -> Self { + Self::new("s", "seconds") + } + + /// Milliseconds. + pub fn milliseconds() -> Self { + Self::new("ms", "milliseconds") + } + + /// Hertz. + pub fn hertz() -> Self { + Self::new("Hz", "hertz") + } + + /// Radians. + pub fn radians() -> Self { + Self::new("rad", "radians") + } + + /// Degrees. + pub fn degrees() -> Self { + Self::new("deg", "degrees") + } + + /// Percent. + pub fn percent() -> Self { + Self::new("%", "percent") + } + + /// Recognize common existing unit labels while older DTOs are migrated. + pub fn from_known_label(label: &str) -> Option { + match label.trim().to_ascii_lowercase().as_str() { + "s" | "sec" | "secs" | "second" | "seconds" => Some(Self::seconds()), + "ms" | "millisecond" | "milliseconds" => Some(Self::milliseconds()), + "hz" | "hertz" => Some(Self::hertz()), + "rad" | "radian" | "radians" => Some(Self::radians()), + "deg" | "degree" | "degrees" => Some(Self::degrees()), + "%" | "percent" | "percentage" => Some(Self::percent()), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use crate::UiSlotUnit; + + #[test] + fn recognizes_short_and_long_labels() { + assert_eq!( + UiSlotUnit::from_known_label("s"), + Some(UiSlotUnit::seconds()) + ); + assert_eq!( + UiSlotUnit::from_known_label("seconds"), + Some(UiSlotUnit::seconds()) + ); + assert_eq!( + UiSlotUnit::from_known_label("Hz"), + Some(UiSlotUnit::hertz()) + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/node/ui_slot_value.rs b/lp-app/lpa-studio-core/src/app/node/ui_slot_value.rs new file mode 100644 index 000000000..d668a321b --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/node/ui_slot_value.rs @@ -0,0 +1,618 @@ +//! Typed display data for config slot values. + +use crate::{UiSlotEditorHint, UiSlotUnit}; +use lpc_model::{LpValue, ProductRef, ResourceDomain, ResourceRef}; + +/// The typed value family that a slot field should render. +#[derive(Clone, Debug, PartialEq)] +pub enum UiSlotValueKind { + /// An explicitly unset value. + Unset, + /// Text or resource-like scalar value. + String(String), + /// Signed integer value. + I32(i32), + /// Unsigned integer value. + U32(u32), + /// Floating point value. + F32(f32), + /// Boolean value. + Bool(bool), + /// Two-component floating point vector. + Vec2([f32; 2]), + /// Three-component floating point vector. + Vec3([f32; 3]), + /// Four-component floating point vector. + Vec4([f32; 4]), + /// Two-component signed integer vector. + IVec2([i32; 2]), + /// Three-component signed integer vector. + IVec3([i32; 3]), + /// Four-component signed integer vector. + IVec4([i32; 4]), + /// Two-component unsigned integer vector. + UVec2([u32; 2]), + /// Three-component unsigned integer vector. + UVec3([u32; 3]), + /// Four-component unsigned integer vector. + UVec4([u32; 4]), + /// Two-component boolean vector. + BVec2([bool; 2]), + /// Three-component boolean vector. + BVec3([bool; 3]), + /// Four-component boolean vector. + BVec4([bool; 4]), + /// 2x2 floating point matrix. + Mat2x2([[f32; 2]; 2]), + /// 3x3 floating point matrix. + Mat3x3([[f32; 3]; 3]), + /// 4x4 floating point matrix. + Mat4x4([[f32; 4]; 4]), + /// Homogeneous or wire-provided array/list payload. + Array(Vec), + /// Structured value payload that is not independently addressable as slots. + Struct { + /// Optional type/name metadata. + name: Option, + /// Named fields inside the atomic payload. + fields: Vec<(String, UiSlotValue)>, + }, + /// Atomic enum value payload. + Enum { + /// Active variant index. + variant: u32, + /// Optional variant payload. + payload: Option>, + }, + /// Store-backed resource reference. + Resource(ResourceRef), + /// Lazy graph product reference. + Product(ProductRef), +} + +impl UiSlotValueKind { + /// Compact type label for slot metadata. + pub fn type_label(&self) -> &'static str { + match self { + Self::Unset => "Unset", + Self::String(_) => "String", + Self::I32(_) => "Int32", + Self::U32(_) => "UInt32", + Self::F32(_) => "Float32", + Self::Bool(_) => "Bool", + Self::Vec2(_) => "Vec2", + Self::Vec3(_) => "Vec3", + Self::Vec4(_) => "Vec4", + Self::IVec2(_) => "IVec2", + Self::IVec3(_) => "IVec3", + Self::IVec4(_) => "IVec4", + Self::UVec2(_) => "UVec2", + Self::UVec3(_) => "UVec3", + Self::UVec4(_) => "UVec4", + Self::BVec2(_) => "BVec2", + Self::BVec3(_) => "BVec3", + Self::BVec4(_) => "BVec4", + Self::Mat2x2(_) => "Mat2x2", + Self::Mat3x3(_) => "Mat3x3", + Self::Mat4x4(_) => "Mat4x4", + Self::Array(_) => "Array", + Self::Struct { .. } => "Struct", + Self::Enum { .. } => "Enum", + Self::Resource(_) => "Resource", + Self::Product(_) => "Product", + } + } + + /// Short type description for slot metadata. + pub fn type_description(&self) -> &'static str { + match self { + Self::Unset => "Unset value.", + Self::String(_) => "Text or resource-like scalar value.", + Self::I32(_) => "Signed integer value.", + Self::U32(_) => "Unsigned integer value.", + Self::F32(_) => "Floating point value.", + Self::Bool(_) => "Boolean value.", + Self::Vec2(_) => "Two-component floating point vector.", + Self::Vec3(_) => "Three-component floating point vector.", + Self::Vec4(_) => "Four-component floating point vector.", + Self::IVec2(_) => "Two-component signed integer vector.", + Self::IVec3(_) => "Three-component signed integer vector.", + Self::IVec4(_) => "Four-component signed integer vector.", + Self::UVec2(_) => "Two-component unsigned integer vector.", + Self::UVec3(_) => "Three-component unsigned integer vector.", + Self::UVec4(_) => "Four-component unsigned integer vector.", + Self::BVec2(_) => "Two-component boolean vector.", + Self::BVec3(_) => "Three-component boolean vector.", + Self::BVec4(_) => "Four-component boolean vector.", + Self::Mat2x2(_) => "2x2 floating point matrix.", + Self::Mat3x3(_) => "3x3 floating point matrix.", + Self::Mat4x4(_) => "4x4 floating point matrix.", + Self::Array(_) => "Array or list value payload.", + Self::Struct { .. } => "Structured value payload.", + Self::Enum { .. } => "Enum value payload.", + Self::Resource(_) => "Store-backed resource reference.", + Self::Product(_) => "Lazy graph product reference.", + } + } +} + +/// A typed slot value plus display and editor metadata. +#[derive(Clone, Debug, PartialEq)] +pub struct UiSlotValue { + /// Value family consumed by field components. + pub kind: UiSlotValueKind, + /// Compact formatted value for read-only and summary display. + pub display: String, + /// Optional unit, shape, or secondary detail. + pub detail: Option, + /// Structured unit metadata for value presentation. + pub unit: Option, + /// Preferred editor treatment for this value. + pub editor: UiSlotEditorHint, +} + +impl UiSlotValue { + /// Create an unset slot value. + pub fn unset() -> Self { + Self::new(UiSlotValueKind::Unset, "unset") + } + + /// Create a text slot value. + pub fn string(value: impl Into) -> Self { + let value = value.into(); + Self::new(UiSlotValueKind::String(value.clone()), value) + } + + /// Create a signed integer slot value. + pub fn i32(value: i32) -> Self { + Self::new(UiSlotValueKind::I32(value), value.to_string()) + } + + /// Create an unsigned integer slot value. + pub fn u32(value: u32) -> Self { + Self::new(UiSlotValueKind::U32(value), value.to_string()) + } + + /// Create a floating point slot value. + pub fn f32(value: f32) -> Self { + Self::new(UiSlotValueKind::F32(value), format_float(value)) + } + + /// Create a boolean slot value. + pub fn bool(value: bool) -> Self { + Self::new(UiSlotValueKind::Bool(value), value.to_string()) + } + + /// Create a two-component vector value. + pub fn vec2(value: [f32; 2]) -> Self { + Self::new( + UiSlotValueKind::Vec2(value), + format!("({}, {})", format_float(value[0]), format_float(value[1])), + ) + } + + /// Create a three-component vector value. + pub fn vec3(value: [f32; 3]) -> Self { + Self::new( + UiSlotValueKind::Vec3(value), + format!( + "({}, {}, {})", + format_float(value[0]), + format_float(value[1]), + format_float(value[2]) + ), + ) + } + + /// Create a four-component vector value. + pub fn vec4(value: [f32; 4]) -> Self { + Self::new(UiSlotValueKind::Vec4(value), format_float_array(&value)) + } + + /// Create a two-component signed integer vector value. + pub fn ivec2(value: [i32; 2]) -> Self { + Self::new(UiSlotValueKind::IVec2(value), format_array(&value)) + } + + /// Create a three-component signed integer vector value. + pub fn ivec3(value: [i32; 3]) -> Self { + Self::new(UiSlotValueKind::IVec3(value), format_array(&value)) + } + + /// Create a four-component signed integer vector value. + pub fn ivec4(value: [i32; 4]) -> Self { + Self::new(UiSlotValueKind::IVec4(value), format_array(&value)) + } + + /// Create a two-component unsigned integer vector value. + pub fn uvec2(value: [u32; 2]) -> Self { + Self::new(UiSlotValueKind::UVec2(value), format_array(&value)) + } + + /// Create a three-component unsigned integer vector value. + pub fn uvec3(value: [u32; 3]) -> Self { + Self::new(UiSlotValueKind::UVec3(value), format_array(&value)) + } + + /// Create a four-component unsigned integer vector value. + pub fn uvec4(value: [u32; 4]) -> Self { + Self::new(UiSlotValueKind::UVec4(value), format_array(&value)) + } + + /// Create a two-component boolean vector value. + pub fn bvec2(value: [bool; 2]) -> Self { + Self::new(UiSlotValueKind::BVec2(value), format_array(&value)) + } + + /// Create a three-component boolean vector value. + pub fn bvec3(value: [bool; 3]) -> Self { + Self::new(UiSlotValueKind::BVec3(value), format_array(&value)) + } + + /// Create a four-component boolean vector value. + pub fn bvec4(value: [bool; 4]) -> Self { + Self::new(UiSlotValueKind::BVec4(value), format_array(&value)) + } + + /// Create a 2x2 matrix value. + pub fn mat2x2(value: [[f32; 2]; 2]) -> Self { + Self::new(UiSlotValueKind::Mat2x2(value), format_matrix(&value)) + } + + /// Create a 3x3 matrix value. + pub fn mat3x3(value: [[f32; 3]; 3]) -> Self { + Self::new(UiSlotValueKind::Mat3x3(value), format_matrix(&value)) + } + + /// Create a 4x4 matrix value. + pub fn mat4x4(value: [[f32; 4]; 4]) -> Self { + Self::new(UiSlotValueKind::Mat4x4(value), format_matrix(&value)) + } + + /// Create an array/list value. + pub fn array(values: Vec) -> Self { + let display = format!("[{}]", join_displays(values.iter())); + Self::new(UiSlotValueKind::Array(values), display) + } + + /// Create a structured atomic value. + pub fn struct_value(name: Option, fields: Vec<(String, UiSlotValue)>) -> Self { + let display = format_struct_value(name.as_deref(), &fields); + Self::new(UiSlotValueKind::Struct { name, fields }, display) + } + + /// Create an enum value. + pub fn enum_value(variant: u32, payload: Option) -> Self { + let display = match payload.as_ref() { + Some(payload) => format!("variant {variant}({})", payload.display), + None => format!("variant {variant}"), + }; + Self::new( + UiSlotValueKind::Enum { + variant, + payload: payload.map(Box::new), + }, + display, + ) + } + + /// Create a resource value. + pub fn resource(value: ResourceRef) -> Self { + Self::new(UiSlotValueKind::Resource(value), format_resource_ref(value)) + } + + /// Create a product value. + pub fn product(value: ProductRef) -> Self { + Self::new(UiSlotValueKind::Product(value), format_product_ref(value)) + } + + /// Create a UI value from a synced LightPlayer value payload. + pub fn from_lp_value(value: &LpValue) -> Self { + match value { + LpValue::Unset => Self::unset(), + LpValue::String(value) => Self::string(value.clone()), + LpValue::I32(value) => Self::i32(*value), + LpValue::U32(value) => Self::u32(*value), + LpValue::F32(value) => Self::f32(*value), + LpValue::Bool(value) => Self::bool(*value), + LpValue::Vec2(value) => Self::vec2(*value), + LpValue::Vec3(value) => Self::vec3(*value), + LpValue::Vec4(value) => Self::vec4(*value), + LpValue::IVec2(value) => Self::ivec2(*value), + LpValue::IVec3(value) => Self::ivec3(*value), + LpValue::IVec4(value) => Self::ivec4(*value), + LpValue::UVec2(value) => Self::uvec2(*value), + LpValue::UVec3(value) => Self::uvec3(*value), + LpValue::UVec4(value) => Self::uvec4(*value), + LpValue::BVec2(value) => Self::bvec2(*value), + LpValue::BVec3(value) => Self::bvec3(*value), + LpValue::BVec4(value) => Self::bvec4(*value), + LpValue::Mat2x2(value) => Self::mat2x2(*value), + LpValue::Mat3x3(value) => Self::mat3x3(*value), + LpValue::Mat4x4(value) => Self::mat4x4(*value), + LpValue::Array(values) => Self::array(values.iter().map(Self::from_lp_value).collect()), + LpValue::Struct { name, fields } => Self::struct_value( + name.clone(), + fields + .iter() + .map(|(name, value)| (name.clone(), Self::from_lp_value(value))) + .collect(), + ), + LpValue::Enum { variant, payload } => { + Self::enum_value(*variant, payload.as_deref().map(Self::from_lp_value)) + } + LpValue::Resource(value) => Self::resource(*value), + LpValue::Product(value) => Self::product(*value), + } + } + + /// Create a typed slot value with an explicit display string. + pub fn new(kind: UiSlotValueKind, display: impl Into) -> Self { + Self { + kind, + display: display.into(), + detail: None, + unit: None, + editor: UiSlotEditorHint::Auto, + } + } + + /// Add secondary detail. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Add structured unit metadata. + pub fn with_unit(mut self, unit: UiSlotUnit) -> Self { + self.unit = Some(unit); + self + } + + /// Return structured unit metadata, recognizing legacy detail labels. + pub fn display_unit(&self) -> Option { + self.unit.clone().or_else(|| { + self.detail + .as_deref() + .and_then(UiSlotUnit::from_known_label) + }) + } + + /// Add an editor hint. + pub fn with_editor(mut self, editor: UiSlotEditorHint) -> Self { + self.editor = editor; + self + } +} + +fn format_float(value: f32) -> String { + if !value.is_finite() { + value.to_string() + } else if value.fract() == 0.0 { + format!("{value:.0}") + } else { + let formatted = format!("{value:.3}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} + +fn format_float_array(value: &[f32; N]) -> String { + format_array_with(value, |value| format_float(*value)) +} + +fn format_array(value: &[T; N]) -> String { + format_array_with(value, ToString::to_string) +} + +fn format_array_with(value: &[T; N], format: impl Fn(&T) -> String) -> String { + let values = value.iter().map(format).collect::>().join(", "); + format!("({values})") +} + +fn format_matrix(value: &[[f32; C]; R]) -> String { + let rows = value + .iter() + .map(format_float_array) + .collect::>() + .join(", "); + format!("[{rows}]") +} + +fn join_displays<'a>(values: impl IntoIterator) -> String { + values + .into_iter() + .map(|value| value.display.clone()) + .collect::>() + .join(", ") +} + +fn format_struct_value(name: Option<&str>, fields: &[(String, UiSlotValue)]) -> String { + let fields = fields + .iter() + .map(|(name, value)| format!("{name}: {}", value.display)) + .collect::>() + .join(", "); + match name { + Some(name) => format!("{name} {{ {fields} }}"), + None => format!("{{ {fields} }}"), + } +} + +fn format_resource_ref(value: ResourceRef) -> String { + format!( + "resource {}:{}", + resource_domain_label(value.domain), + value.id + ) +} + +fn resource_domain_label(domain: ResourceDomain) -> &'static str { + match domain { + ResourceDomain::Unset => "unset", + ResourceDomain::RuntimeBuffer => "runtime_buffer", + } +} + +fn format_product_ref(value: ProductRef) -> String { + match value { + ProductRef::Visual(product) => { + format!( + "visual product node {} output {}", + product.node(), + product.output() + ) + } + ProductRef::Control(product) => { + let extent = product.preferred_extent(); + format!( + "control product node {} output {} ({}x{})", + product.node(), + product.output(), + extent.rows, + extent.samples_per_row + ) + } + } +} + +#[cfg(test)] +mod tests { + use crate::UiSlotValue; + use lpc_model::{ + ControlExtent, ControlProduct, LpValue, NodeId, ProductRef, ResourceRef, RuntimeBufferId, + VisualProduct, + }; + + #[test] + fn trims_float_display() { + assert_eq!(UiSlotValue::f32(0.350).display, "0.35"); + assert_eq!(UiSlotValue::f32(2.0).display, "2"); + } + + #[test] + fn formats_vector_display() { + assert_eq!(UiSlotValue::vec2([0.5, 1.0]).display, "(0.5, 1)"); + } + + #[test] + fn covers_all_scalar_value_families() { + let cases = [ + (LpValue::Unset, "Unset", "unset"), + (LpValue::String("idle".to_string()), "String", "idle"), + (LpValue::I32(-4), "Int32", "-4"), + (LpValue::U32(4), "UInt32", "4"), + (LpValue::F32(0.35), "Float32", "0.35"), + (LpValue::Bool(true), "Bool", "true"), + ]; + + for (value, label, display) in cases { + let ui = UiSlotValue::from_lp_value(&value); + + assert_eq!(ui.kind.type_label(), label); + assert_eq!(ui.display, display); + } + } + + #[test] + fn covers_vector_and_matrix_value_families() { + let cases = [ + ( + UiSlotValue::from_lp_value(&LpValue::Vec4([1.0, 2.0, 3.0, 4.0])), + "Vec4", + ), + ( + UiSlotValue::from_lp_value(&LpValue::IVec2([-1, 2])), + "IVec2", + ), + ( + UiSlotValue::from_lp_value(&LpValue::IVec3([-1, 2, 3])), + "IVec3", + ), + ( + UiSlotValue::from_lp_value(&LpValue::IVec4([-1, 2, 3, 4])), + "IVec4", + ), + (UiSlotValue::from_lp_value(&LpValue::UVec2([1, 2])), "UVec2"), + ( + UiSlotValue::from_lp_value(&LpValue::UVec3([1, 2, 3])), + "UVec3", + ), + ( + UiSlotValue::from_lp_value(&LpValue::UVec4([1, 2, 3, 4])), + "UVec4", + ), + ( + UiSlotValue::from_lp_value(&LpValue::BVec2([true, false])), + "BVec2", + ), + ( + UiSlotValue::from_lp_value(&LpValue::BVec3([true, false, true])), + "BVec3", + ), + ( + UiSlotValue::from_lp_value(&LpValue::BVec4([true, false, true, false])), + "BVec4", + ), + ( + UiSlotValue::from_lp_value(&LpValue::Mat2x2([[1.0, 0.0], [0.0, 1.0]])), + "Mat2x2", + ), + ( + UiSlotValue::from_lp_value(&LpValue::Mat3x3([ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ])), + "Mat3x3", + ), + ( + UiSlotValue::from_lp_value(&LpValue::Mat4x4([ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ])), + "Mat4x4", + ), + ]; + + for (value, label) in cases { + assert_eq!(value.kind.type_label(), label); + assert!(!value.display.is_empty()); + } + } + + #[test] + fn covers_structural_resource_and_product_value_families() { + let values = [ + UiSlotValue::from_lp_value(&LpValue::Array(vec![LpValue::I32(1), LpValue::I32(2)])), + UiSlotValue::from_lp_value(&LpValue::Struct { + name: Some("Pair".to_string()), + fields: vec![("x".to_string(), LpValue::F32(1.0))], + }), + UiSlotValue::from_lp_value(&LpValue::Enum { + variant: 7, + payload: Some(Box::new(LpValue::String("ready".to_string()))), + }), + UiSlotValue::from_lp_value(&LpValue::Resource(ResourceRef::runtime_buffer( + RuntimeBufferId::new(4), + ))), + UiSlotValue::from_lp_value(&LpValue::Product(ProductRef::visual(VisualProduct::new( + NodeId::new(3), + 0, + )))), + UiSlotValue::from_lp_value(&LpValue::Product(ProductRef::control( + ControlProduct::new(NodeId::new(4), 1, ControlExtent::new(2, 24)), + ))), + ]; + let labels = ["Array", "Struct", "Enum", "Resource", "Product", "Product"]; + + for (value, label) in values.into_iter().zip(labels) { + assert_eq!(value.kind.type_label(), label); + assert!(!value.display.is_empty()); + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/demo_project.rs b/lp-app/lpa-studio-core/src/app/project/demo_project.rs new file mode 100644 index 000000000..b2fcadee9 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/demo_project.rs @@ -0,0 +1,86 @@ +use lpa_client::ProjectDeployFile; + +use crate::STUDIO_DEMO_PROJECT_ID; + +pub const DEMO_PROJECT_ID: &str = STUDIO_DEMO_PROJECT_ID; +pub const DEMO_PROJECT_STORAGE_ID: &str = "studio"; + +pub struct DemoProjectFile { + pub relative_path: &'static str, + pub bytes: &'static [u8], +} + +pub fn demo_project_files() -> &'static [DemoProjectFile] { + &[ + DemoProjectFile { + relative_path: "clock.toml", + bytes: include_bytes!("../../../../../examples/basic/clock.toml"), + }, + DemoProjectFile { + relative_path: "fixture.toml", + bytes: include_bytes!("../../../../../examples/basic/fixture.toml"), + }, + DemoProjectFile { + relative_path: "output.toml", + bytes: include_bytes!("../../../../../examples/basic/output.toml"), + }, + DemoProjectFile { + relative_path: "project.toml", + bytes: include_bytes!("../../../../../examples/basic/project.toml"), + }, + DemoProjectFile { + relative_path: "shader.glsl", + bytes: include_bytes!("../../../../../examples/basic/shader.glsl"), + }, + DemoProjectFile { + relative_path: "shader.toml", + bytes: include_bytes!("../../../../../examples/basic/shader.toml"), + }, + ] +} + +pub fn demo_project_deploy_files() -> Vec { + demo_project_files() + .iter() + .map(|file| ProjectDeployFile::new(file.relative_path, file.bytes.to_vec())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn demo_project_identity_uses_examples_basic() { + assert_eq!(DEMO_PROJECT_ID, "examples/basic"); + assert_eq!(DEMO_PROJECT_STORAGE_ID, "studio"); + } + + #[test] + fn demo_project_files_are_the_basic_example() { + let files = demo_project_files(); + + assert_eq!( + files + .iter() + .map(|file| file.relative_path) + .collect::>(), + vec![ + "clock.toml", + "fixture.toml", + "output.toml", + "project.toml", + "shader.glsl", + "shader.toml", + ] + ); + assert_eq!( + files + .iter() + .find(|file| file.relative_path == "project.toml") + .unwrap() + .bytes, + include_bytes!("../../../../../examples/basic/project.toml") + ); + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/loaded_project_choice.rs b/lp-app/lpa-studio-core/src/app/project/loaded_project_choice.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/project/loaded_project_choice.rs rename to lp-app/lpa-studio-core/src/app/project/loaded_project_choice.rs diff --git a/lp-app/lpa-studio-core/src/app/project/mod.rs b/lp-app/lpa-studio-core/src/app/project/mod.rs new file mode 100644 index 000000000..aba5bd1de --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/mod.rs @@ -0,0 +1,71 @@ +//! Studio project editor controller and sync model. +//! +//! The project editor intentionally keeps several trees separate: +//! +//! - The **mirror tree** is `lpc_view::ProjectView`, updated from LightPlayer +//! project sync responses. It is a client/protocol model and knows nothing +//! about Studio UI concepts. +//! - The **controller tree** is the Studio business-logic layer that reconciles +//! against the mirror tree. `ProjectController` is the synthetic root, +//! owning recursive node controllers, which in turn own recursive slot +//! controllers. The tree owns project editor identity, actions, local +//! interaction state, and future slot/product behavior without depending on a +//! particular UI framework. +//! - The **DTO tree** is the data-driven render model emitted by controllers, +//! primarily `UiNodeView` and its child `Ui*` structs. +//! - The **component tree** lives in `lpa-studio-web`; Dioxus components own +//! browser-specific view state such as popovers, animation, and transient +//! layout mechanics. +//! +//! `ProjectSync` owns the protocol mirror lifecycle: sync phase, shape cursor, +//! read requests, response application, and `ProjectView`. It does not own +//! Studio controller state. + +pub mod demo_project; +pub mod loaded_project_choice; +pub mod node; +pub mod project_connect_result; +pub mod project_controller; +pub mod project_editor_op; +pub mod project_editor_target; +pub mod project_editor_view; +pub mod project_inventory_summary; +pub mod project_node_tree_view; +pub mod project_op; +pub mod project_runtime_summary; +pub mod project_snapshot; +pub mod project_state; +pub mod project_sync; +pub mod project_sync_phase; +pub mod project_sync_run; +pub mod project_sync_summary; +pub mod project_target_encoding; +pub mod project_value_format; +pub mod slot; + +pub use loaded_project_choice::LoadedProjectChoice; +pub use node::{ + NodeController, NodeControllerState, ProjectNodeAddress, ProjectNodeTarget, + ProjectProductSubscriptionIntent, +}; +pub use project_connect_result::ProjectConnectResult; +pub use project_controller::ProjectController; +pub use project_editor_op::ProjectEditorOp; +pub use project_editor_target::ProjectEditorTarget; +pub use project_editor_view::ProjectEditorView; +pub use project_inventory_summary::ProjectInventorySummary; +pub use project_node_tree_view::{ + ProjectNodeStatusTone, ProjectNodeStatusView, ProjectNodeTreeItem, ProjectNodeTreeView, +}; +pub use project_op::ProjectOp; +pub use project_runtime_summary::ProjectRuntimeSummary; +pub use project_snapshot::ProjectSnapshot; +pub use project_state::ProjectState; +pub use project_sync::ProjectSync; +pub use project_sync_phase::ProjectSyncPhase; +pub use project_sync_run::ProjectSyncRun; +pub use project_sync_summary::ProjectSyncSummary; +pub use project_value_format::{format_lp_value, format_slot_map_key}; +pub use slot::{ + ProjectSlotAddress, ProjectSlotRoot, SlotController, SlotControllerState, SlotKind, +}; diff --git a/lp-app/lpa-studio-core/src/app/project/node/mod.rs b/lp-app/lpa-studio-core/src/app/project/node/mod.rs new file mode 100644 index 000000000..942b1aa7b --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/mod.rs @@ -0,0 +1,14 @@ +//! Project node controller-domain types. +//! +//! A project node has two identities in Studio. [`ProjectNodeAddress`] is the +//! stable authored address used to preserve controller state across syncs. +//! [`ProjectNodeTarget`] adds the current runtime `NodeId` for actions that +//! need to talk back to the server. + +pub mod node_controller; +pub mod project_node_address; +pub mod project_node_target; + +pub use node_controller::{NodeController, NodeControllerState, ProjectProductSubscriptionIntent}; +pub use project_node_address::ProjectNodeAddress; +pub use project_node_target::ProjectNodeTarget; diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs new file mode 100644 index 000000000..ec516e65d --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs @@ -0,0 +1,641 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use lpc_model::{NodeId, SlotData, SlotShapeLookup, SlotShapeView, TreePath}; +use lpc_view::{ProjectView, SlotMirrorView, TreeEntryView}; +use lpc_wire::{NodeRuntimeStatus, WireEntryState}; + +use crate::{ + ProjectEditorOp, ProjectEditorTarget, ProjectNodeAddress, ProjectNodeStatusTone, + ProjectNodeStatusView, ProjectNodeTarget, ProjectSlotAddress, ProjectSlotRoot, SlotController, + UiAction, UiNodeChild, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, UiProductPreview, + UiProductRef, UiProductTrackingState, UiStatus, +}; + +/// User/controller intent for product subscriptions owned by a node. +/// +/// M2a does not implement product subscription transport. This state exists so +/// reconciliation has a durable place to preserve that future intent. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ProjectProductSubscriptionIntent { + #[default] + Default, + Subscribed, + Unsubscribed, +} + +/// Local Studio state owned by a project node controller. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NodeControllerState { + pub collapsed: bool, + pub focused: bool, + pub product_subscription_intent: ProjectProductSubscriptionIntent, +} + +impl NodeControllerState { + /// Default expanded, unfocused node state. + pub fn new() -> Self { + Self::default() + } +} + +impl Default for NodeControllerState { + fn default() -> Self { + Self { + collapsed: false, + focused: false, + product_subscription_intent: ProjectProductSubscriptionIntent::Default, + } + } +} + +/// UI-framework agnostic controller for one project node. +/// +/// Node controllers form an owned tree under `ProjectController`. Each node +/// owns its child node controllers and its root slot controllers. +#[derive(Clone, Debug, PartialEq)] +pub struct NodeController { + address: ProjectNodeAddress, + target: ProjectNodeTarget, + parent: Option, + child_addresses: Vec, + label: String, + kind: String, + status: ProjectNodeStatusView, + issues: Vec, + state: NodeControllerState, + children: Vec, + slots: Vec, +} + +impl NodeController { + pub(in crate::app::project) fn from_tree_entry( + entry: &TreeEntryView, + view: &ProjectView, + ) -> Self { + let address = ProjectNodeAddress::new(entry.path.clone()); + let target = ProjectNodeTarget::new(address.clone(), entry.id); + let mut controller = Self { + address, + target, + parent: None, + child_addresses: Vec::new(), + label: String::new(), + kind: String::new(), + status: ProjectNodeStatusView::new("Unknown", None, ProjectNodeStatusTone::Neutral), + issues: Vec::new(), + state: NodeControllerState::new(), + children: Vec::new(), + slots: Vec::new(), + }; + controller.apply_tree_entry(entry, view); + controller + } + + /// Stable node address used as the controller key. + pub fn address(&self) -> &ProjectNodeAddress { + &self.address + } + + /// Current action target for this node. + pub fn target(&self) -> &ProjectNodeTarget { + &self.target + } + + /// Stable parent node address, if this node currently has one. + pub fn parent(&self) -> Option<&ProjectNodeAddress> { + self.parent.as_ref() + } + + /// Stable child node addresses in mirror order. + pub fn child_addresses(&self) -> &[ProjectNodeAddress] { + &self.child_addresses + } + + /// Human-readable node label. + pub fn label(&self) -> &str { + &self.label + } + + /// Human-readable node kind. + pub fn kind(&self) -> &str { + &self.kind + } + + /// Current node status. + pub fn status(&self) -> &ProjectNodeStatusView { + &self.status + } + + /// Mirror/application issues attached to this node controller. + pub fn issues(&self) -> &[String] { + &self.issues + } + + /// Local node controller state. + pub fn state(&self) -> &NodeControllerState { + &self.state + } + + /// Mutable local node controller state. + pub fn state_mut(&mut self) -> &mut NodeControllerState { + &mut self.state + } + + /// Child node controllers in mirror order. + pub fn children(&self) -> &[NodeController] { + &self.children + } + + /// Mutable child node controllers in mirror order. + pub(in crate::app::project) fn children_mut(&mut self) -> &mut [NodeController] { + &mut self.children + } + + /// Root slot controllers in mirror order. + pub fn slots(&self) -> &[SlotController] { + &self.slots + } + + /// Project this controller and its slot controllers into the node-pane DTO. + pub fn ui_node(&self) -> UiNodeView { + self.ui_node_with_product_previews(&|_| None) + } + + /// Project this controller into a node-pane DTO with product preview state. + pub(in crate::app::project) fn ui_node_with_product_previews( + &self, + product_preview: &impl Fn(&UiProductRef) -> Option, + ) -> UiNodeView { + let header = UiNodeHeader::new( + self.label.clone(), + self.kind.clone(), + self.address.to_string(), + ) + .with_status(self.ui_status()); + + let mut view = UiNodeView::new( + header, + vec![UiNodeTab::main( + self.ui_sections_with_product_previews(product_preview), + )], + ) + .with_node_id(self.address.to_string()) + .with_children(self.ui_children_with_product_previews(product_preview)); + view.focused = self.state.focused; + view.action = Some(node_focus_action(self)); + view.collapsed = self.state.collapsed; + view.issues = self.issues.clone(); + view + } + + /// Find a descendant node controller by stable address. + pub fn node(&self, address: &ProjectNodeAddress) -> Option<&NodeController> { + if self.address() == address { + return Some(self); + } + self.children.iter().find_map(|child| child.node(address)) + } + + /// Find a mutable descendant node controller by stable address. + pub fn node_mut(&mut self, address: &ProjectNodeAddress) -> Option<&mut NodeController> { + if self.address() == address { + return Some(self); + } + self.children + .iter_mut() + .find_map(|child| child.node_mut(address)) + } + + /// Find a mutable slot controller by address. + pub fn slot_mut(&mut self, address: &ProjectSlotAddress) -> Option<&mut SlotController> { + self.slots.iter_mut().find_map(|slot| { + if slot.address() == address { + Some(slot) + } else { + slot.slot_mut(address) + } + }) + } + + pub(in crate::app::project) fn apply_tree_entry( + &mut self, + entry: &TreeEntryView, + view: &ProjectView, + ) { + self.address = ProjectNodeAddress::new(entry.path.clone()); + self.target = ProjectNodeTarget::new(self.address.clone(), entry.id); + self.label = node_label(entry); + self.kind = node_kind_label(&entry.path); + self.status = node_status_view(entry); + self.issues.clear(); + self.parent = parent_address(entry, view, &mut self.issues); + + let desired_children = child_entries(entry, view, &mut self.issues); + self.child_addresses = desired_children + .iter() + .map(|child| ProjectNodeAddress::new(child.path.clone())) + .collect(); + self.reconcile_children(desired_children, view); + self.reconcile_slots( + root_slot_applies(entry, &self.address, &view.slots), + &view.slots, + ); + } + + fn reconcile_children(&mut self, children: Vec<&TreeEntryView>, view: &ProjectView) { + let mut previous = self + .children + .drain(..) + .map(|child| (child.address().clone(), child)) + .collect::>(); + + self.children = children + .into_iter() + .map(|entry| { + let address = ProjectNodeAddress::new(entry.path.clone()); + if let Some(mut controller) = previous.remove(&address) { + controller.apply_tree_entry(entry, view); + controller + } else { + Self::from_tree_entry(entry, view) + } + }) + .collect(); + } + + fn reconcile_slots(&mut self, slots: Vec>, mirror: &SlotMirrorView) { + let mut previous = self + .slots + .drain(..) + .map(|slot| (slot.address().clone(), slot)) + .collect::>(); + + self.slots = slots + .into_iter() + .map(|slot| { + let address = slot.address().clone(); + if let Some(mut controller) = previous.remove(&address) { + apply_root_slot(&mut controller, slot, mirror); + controller + } else { + root_slot_controller(slot, mirror) + } + }) + .collect(); + } + + /// Collect produced product identities emitted by this node. + pub(in crate::app::project) fn collect_produced_product_refs( + &self, + products: &mut Vec, + ) { + for slot in &self.slots { + if matches!(slot.address().root, ProjectSlotRoot::State) { + slot.collect_produced_product_refs(products); + } + } + } + + fn ui_sections_with_product_previews( + &self, + product_preview: &impl Fn(&UiProductRef) -> Option, + ) -> Vec { + let mut products = Vec::new(); + let mut produced_values = Vec::new(); + let mut config_slots = Vec::new(); + let mut asset_slots = Vec::new(); + + for slot in &self.slots { + match slot.address().root { + ProjectSlotRoot::State => { + slot.collect_produced(&mut products, &mut produced_values); + } + ProjectSlotRoot::Def | ProjectSlotRoot::Other(_) => { + slot.collect_config(&mut config_slots, &mut asset_slots); + } + } + } + + let mut sections = Vec::new(); + if !products.is_empty() { + let base_tracking = self.product_tracking_state(); + for product in &mut products { + let mut has_cached_preview = false; + if let Some(product_ref) = product.product + && let Some(preview) = product_preview(&product_ref) + { + product.preview = preview; + has_cached_preview = true; + } + product.tracking = + if base_tracking == UiProductTrackingState::Untracked && has_cached_preview { + UiProductTrackingState::Paused + } else { + base_tracking + } + } + sections.push(UiNodeSection::ProducedProducts(products)); + } + if !produced_values.is_empty() { + sections.push(UiNodeSection::ProducedValues(produced_values)); + } + if !asset_slots.is_empty() { + sections.push(UiNodeSection::AssetSlots(asset_slots)); + } + if !config_slots.is_empty() { + sections.push(UiNodeSection::ConfigSlots(config_slots)); + } + sections + } + + fn ui_children_with_product_previews( + &self, + product_preview: &impl Fn(&UiProductRef) -> Option, + ) -> Vec { + self.children + .iter() + .map(|child| { + let mut view = UiNodeChild::new( + child.label.clone(), + child.kind.clone(), + child.address.to_string(), + ); + view.status = child.ui_status(); + view.summary = child.status.detail.clone(); + view.focused = child.state.focused; + view.action = Some(node_focus_action(child)); + view.sections = child.ui_sections_with_product_previews(product_preview); + view.children = child.ui_children_with_product_previews(product_preview); + view + }) + .collect() + } + + fn ui_status(&self) -> UiStatus { + match self.status.tone { + ProjectNodeStatusTone::Neutral => UiStatus::neutral(self.status.label.clone()), + ProjectNodeStatusTone::Good => UiStatus::good(self.status.label.clone()), + ProjectNodeStatusTone::Warning => UiStatus::warning(self.status.label.clone()), + ProjectNodeStatusTone::Error => UiStatus::error(self.status.label.clone()), + } + } + + fn product_tracking_state(&self) -> UiProductTrackingState { + match self.state.product_subscription_intent { + ProjectProductSubscriptionIntent::Default if self.state.focused => { + UiProductTrackingState::Tracking + } + ProjectProductSubscriptionIntent::Default => UiProductTrackingState::Untracked, + ProjectProductSubscriptionIntent::Subscribed => UiProductTrackingState::Tracking, + ProjectProductSubscriptionIntent::Unsubscribed => UiProductTrackingState::Paused, + } + } +} + +fn node_focus_action(node: &NodeController) -> UiAction { + UiAction::from_op( + ProjectEditorTarget::addressed_node(node.target().clone()).node_id(), + ProjectEditorOp::Focus, + ) + .with_label(format!("Focus {}", node.label())) + .with_summary(format!("Focus node {}.", node.address())) +} + +enum RootSlotApply<'a> { + Data { + address: ProjectSlotAddress, + label: String, + data: &'a SlotData, + shape: SlotShapeView<'a>, + }, + Issue { + address: ProjectSlotAddress, + label: String, + message: String, + }, +} + +impl RootSlotApply<'_> { + fn address(&self) -> &ProjectSlotAddress { + match self { + Self::Data { address, .. } | Self::Issue { address, .. } => address, + } + } +} + +fn apply_root_slot( + controller: &mut SlotController, + slot: RootSlotApply<'_>, + mirror: &SlotMirrorView, +) { + match slot { + RootSlotApply::Data { + address, + label, + data, + shape, + } => { + controller.apply_root_data(address, label, data, shape, &mirror.registry); + } + RootSlotApply::Issue { + address, + label, + message, + } => { + controller.apply_root_issue(address, label, message); + } + } +} + +fn root_slot_controller(slot: RootSlotApply<'_>, mirror: &SlotMirrorView) -> SlotController { + match slot { + RootSlotApply::Data { + address, + label, + data, + shape, + } => SlotController::from_slot_data(address, label, data, shape, &mirror.registry), + RootSlotApply::Issue { + address, + label, + message, + } => SlotController::issue(address, label, message), + } +} + +fn root_slot_applies<'a>( + entry: &TreeEntryView, + node: &ProjectNodeAddress, + slots: &'a SlotMirrorView, +) -> Vec> { + root_slot_names(entry.id, slots) + .into_iter() + .map(|root_name| root_slot_apply(entry.id, node, slots, root_name)) + .collect() +} + +fn root_slot_apply<'a>( + node_id: NodeId, + node: &ProjectNodeAddress, + slots: &'a SlotMirrorView, + root_name: String, +) -> RootSlotApply<'a> { + let key = root_slot_key(node_id, &root_name); + let root = ProjectSlotRoot::from_name(&root_name); + let address = ProjectSlotAddress::root(node.clone(), root); + let label = human_label(&root_name); + let Some(shape_id) = slots.root_shapes.get(&key).copied() else { + return RootSlotApply::Issue { + address, + label, + message: format!("{key} shape is missing"), + }; + }; + let Some(data) = slots.roots.get(&key) else { + return RootSlotApply::Issue { + address, + label, + message: format!("{key} data is missing"), + }; + }; + let Some(shape) = slots.registry.get_shape(shape_id) else { + return RootSlotApply::Issue { + address, + label, + message: format!("shape {shape_id} is missing"), + }; + }; + RootSlotApply::Data { + address, + label, + data, + shape, + } +} + +fn root_slot_names(node_id: NodeId, slots: &SlotMirrorView) -> Vec { + let prefix = format!("node.{node_id}."); + let mut names = BTreeSet::new(); + for key in slots.root_shapes.keys().chain(slots.roots.keys()) { + if let Some(root_name) = key.strip_prefix(&prefix) { + names.insert(root_name.to_string()); + } + } + let mut names = names.into_iter().collect::>(); + names.sort_by(|left, right| root_name_sort_key(left).cmp(&root_name_sort_key(right))); + names +} + +fn root_name_sort_key(name: &str) -> (u8, &str) { + match name { + "def" => (0, name), + "state" => (1, name), + _ => (2, name), + } +} + +fn root_slot_key(node_id: NodeId, root_name: &str) -> String { + format!("node.{node_id}.{root_name}") +} + +fn parent_address( + entry: &TreeEntryView, + view: &ProjectView, + issues: &mut Vec, +) -> Option { + let parent_id = entry.parent?; + match view.tree.get(parent_id) { + Some(parent) => Some(ProjectNodeAddress::new(parent.path.clone())), + None => { + issues.push(format!("parent node {parent_id} is missing")); + None + } + } +} + +fn child_entries<'a>( + entry: &TreeEntryView, + view: &'a ProjectView, + issues: &mut Vec, +) -> Vec<&'a TreeEntryView> { + entry + .children + .iter() + .filter_map(|child_id| match view.tree.get(*child_id) { + Some(child) => Some(child), + None => { + issues.push(format!("child node {child_id} is missing")); + None + } + }) + .collect() +} + +fn node_label(entry: &TreeEntryView) -> String { + entry + .path + .0 + .last() + .map(|segment| human_label(segment.name.as_str())) + .unwrap_or_else(|| format!("Node {}", entry.id)) +} + +fn node_kind_label(path: &TreePath) -> String { + let Some(segment) = path.0.last() else { + return "Node".to_string(); + }; + match segment.ty.as_str() { + "project" | "show" => "Project".to_string(), + "vis" | "visual" => "Visual".to_string(), + "shader" | "shader_node" => "Shader".to_string(), + "compute" | "compute_shader" => "Compute".to_string(), + "fixture" => "Fixture".to_string(), + "output" => "Output".to_string(), + "clock" => "Clock".to_string(), + "playlist" => "Playlist".to_string(), + other => human_label(other), + } +} + +fn node_status_view(entry: &TreeEntryView) -> ProjectNodeStatusView { + match &entry.state { + WireEntryState::Failed { reason } => { + ProjectNodeStatusView::new("Failed", Some(reason.clone()), ProjectNodeStatusTone::Error) + } + WireEntryState::Pending => { + ProjectNodeStatusView::new("Pending", None, ProjectNodeStatusTone::Neutral) + } + WireEntryState::Alive => match &entry.status { + NodeRuntimeStatus::Created => { + ProjectNodeStatusView::new("Created", None, ProjectNodeStatusTone::Neutral) + } + NodeRuntimeStatus::Ok => { + ProjectNodeStatusView::new("Running", None, ProjectNodeStatusTone::Good) + } + NodeRuntimeStatus::Warn(message) => ProjectNodeStatusView::new( + "Warning", + Some(message.clone()), + ProjectNodeStatusTone::Warning, + ), + NodeRuntimeStatus::InitError(message) => ProjectNodeStatusView::new( + "Init error", + Some(message.clone()), + ProjectNodeStatusTone::Error, + ), + NodeRuntimeStatus::Error(message) => ProjectNodeStatusView::new( + "Error", + Some(message.clone()), + ProjectNodeStatusTone::Error, + ), + }, + } +} + +fn human_label(raw: &str) -> String { + let normalized = raw.replace(['_', '-'], " "); + let mut chars = normalized.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_uppercase().collect::() + chars.as_str() +} diff --git a/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs b/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs new file mode 100644 index 000000000..eda376add --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/project_node_address.rs @@ -0,0 +1,54 @@ +use core::fmt; + +use lpc_model::{PathError, TreePath}; + +/// Stable authored address for a node inside a LightPlayer project. +/// +/// Studio uses this as the node controller key. Runtime `NodeId`s can change +/// when the project reconnects or reloads, but a stable `TreePath` lets local +/// UI/controller state survive ordinary mirror updates. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ProjectNodeAddress { + path: TreePath, +} + +impl ProjectNodeAddress { + /// Create an address from an already parsed tree path. + pub fn new(path: TreePath) -> Self { + Self { path } + } + + /// Parse an authored tree path. + pub fn parse(path: &str) -> Result { + TreePath::parse(path).map(Self::new) + } + + /// Return the underlying model path. + pub fn path(&self) -> &TreePath { + &self.path + } +} + +impl fmt::Display for ProjectNodeAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.path) + } +} + +impl From for ProjectNodeAddress { + fn from(path: TreePath) -> Self { + Self::new(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn address_displays_canonical_tree_path() { + let address = ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(); + + assert_eq!(address.to_string(), "/demo.project/orbit.shader"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs b/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs new file mode 100644 index 000000000..f3c9923ed --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/node/project_node_target.rs @@ -0,0 +1,34 @@ +use lpc_model::NodeId; + +use super::ProjectNodeAddress; + +/// Runtime action target for a project node. +/// +/// The `address` is the stable controller key. The `node_id` is the current +/// server/runtime handle carried on action targets for efficient dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectNodeTarget { + pub address: ProjectNodeAddress, + pub node_id: NodeId, +} + +impl ProjectNodeTarget { + /// Create a node target from a stable address and current runtime id. + pub fn new(address: ProjectNodeAddress, node_id: NodeId) -> Self { + Self { address, node_id } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_keeps_address_and_runtime_id_separate() { + let address = ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(); + let target = ProjectNodeTarget::new(address.clone(), NodeId::new(7)); + + assert_eq!(target.address, address); + assert_eq!(target.node_id, NodeId::new(7)); + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs b/lp-app/lpa-studio-core/src/app/project/project_connect_result.rs similarity index 58% rename from lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs rename to lp-app/lpa-studio-core/src/app/project/project_connect_result.rs index eecff45ab..4abc1e268 100644 --- a/lp-app/lpa-studio-ux/src/nodes/project/project_connect_result.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_connect_result.rs @@ -1,14 +1,14 @@ -use crate::UxLogEntry; +use crate::UiLogEntry; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ProjectConnectResult { - Connected { logs: Vec }, - SelectionRequired { logs: Vec }, - NotFound { logs: Vec }, + Connected { logs: Vec }, + SelectionRequired { logs: Vec }, + NotFound { logs: Vec }, } impl ProjectConnectResult { - pub fn logs(self) -> Vec { + pub fn logs(self) -> Vec { match self { Self::Connected { logs } | Self::SelectionRequired { logs } diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs new file mode 100644 index 000000000..cc09693ac --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -0,0 +1,2112 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::core::notice::UiNotices; +use crate::{ + Controller, ControllerId, LoadedProjectChoice, ProgressState, ProjectConnectResult, + ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, ProjectInventorySummary, + ProjectNodeAddress, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, ProjectSnapshot, + ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, ProjectSyncSummary, + StudioServerClient, UiAction, UiError, UiIssue, UiLogEntry, UiLogLevel, UiMetric, UiNodeView, + UiPaneView, UiProductRef, UiResult, UiStatus, UiViewContent, UxUpdateSink, +}; +use lpc_model::{NodeId, TreePath}; +use lpc_view::ProjectView; + +use super::{NodeController, ProjectProductSubscriptionIntent}; + +/// Project-level Studio controller and synthetic root for node controllers. +/// +/// `ProjectSync` owns the protocol mirror lifecycle. `ProjectController` owns +/// the UI-independent controller tree that applies that mirror and preserves +/// local Studio state for stable node/slot addresses. +pub struct ProjectController { + state: ProjectState, + running_project_status: RunningProjectStatus, + active_editor_target: Option, + sync: Option, + root_nodes: Vec, +} + +impl ProjectController { + pub const NODE_ID: &'static str = "studio|project"; + + pub fn new() -> Self { + Self { + state: ProjectState::NotLoaded, + running_project_status: RunningProjectStatus::Unknown, + active_editor_target: None, + sync: None, + root_nodes: Vec::new(), + } + } + + pub fn set_state(&mut self, state: ProjectState) { + if !matches!(state, ProjectState::Ready { .. }) { + self.clear_loaded_project_state(); + } + self.state = state; + } + + pub fn snapshot(&self) -> ProjectSnapshot { + ProjectSnapshot::new(self.state.clone(), self.sync_summary()) + } + + pub fn active_editor_target(&self) -> Option<&ProjectEditorTarget> { + self.active_editor_target.as_ref() + } + + pub fn sync_summary(&self) -> Option { + self.sync.as_ref().map(ProjectSync::summary) + } + + /// Root node controllers in project tree order. + pub fn root_nodes(&self) -> &[NodeController] { + &self.root_nodes + } + + /// Project root node controllers into node-pane DTOs in project tree order. + pub fn ui_nodes(&self) -> Vec { + let product_preview = + |product: &UiProductRef| self.sync.as_ref()?.product_preview(product).cloned(); + self.root_nodes + .iter() + .map(|node| node.ui_node_with_product_previews(&product_preview)) + .collect() + } + + /// Find a node controller by stable address. + pub fn node(&self, address: &ProjectNodeAddress) -> Option<&NodeController> { + self.root_nodes.iter().find_map(|node| node.node(address)) + } + + /// Find a mutable node controller by stable address. + pub fn node_mut(&mut self, address: &ProjectNodeAddress) -> Option<&mut NodeController> { + self.root_nodes + .iter_mut() + .find_map(|node| node.node_mut(address)) + } + + /// Apply the latest project mirror into the owned controller tree. + pub fn apply_project_view(&mut self, view: &ProjectView) -> Result<(), UiError> { + reconcile_root_nodes(&mut self.root_nodes, view); + ensure_default_node_focus(&mut self.root_nodes); + Ok(()) + } + + pub fn actions(&self, server_connected: bool) -> Vec { + if !server_connected { + return Vec::new(); + } + match self.state { + ProjectState::NotLoaded => { + let mut actions = Vec::new(); + if self.running_project_status != RunningProjectStatus::NoneKnown { + actions.push(self.action(ProjectOp::ConnectRunningProject)); + } + actions.push(self.action(ProjectOp::LoadDemoProject)); + actions + } + ProjectState::Failed { .. } => vec![ + self.action(ProjectOp::ConnectRunningProject), + self.action(ProjectOp::LoadDemoProject), + ], + ProjectState::SelectingLoadedProject { ref projects } => projects + .iter() + .map(|project| { + self.action(ProjectOp::ConnectLoadedProject { + handle_id: project.handle_id, + }) + .with_label(format!("Connect {}", project.project_id)) + .with_summary(format!( + "Attach to running project handle {}.", + project.handle_id + )) + }) + .collect(), + ProjectState::ConnectingRunningProject { .. } + | ProjectState::LoadingDemoProject { .. } => Vec::new(), + ProjectState::Ready { .. } => vec![ + self.action(ProjectOp::RefreshProject), + self.action(ProjectOp::DisconnectProject), + ], + } + } + + pub fn view(&self, server_connected: bool) -> UiPaneView { + UiPaneView::new( + Self::NODE_ID, + "Project", + project_status(&self.state, self.sync.as_ref()), + self.body(), + self.actions(server_connected), + ) + } + + /// Project the synced controller tree into the project editor shell DTO. + pub fn editor_view( + &self, + project_id: &str, + handle_id: u32, + inventory: &ProjectInventorySummary, + ) -> ProjectEditorView { + let summary = self.sync_summary().unwrap_or_default(); + ProjectEditorView::new( + project_id, + handle_id, + summary.clone(), + project_editor_stats(project_id, handle_id, inventory, &summary), + self.node_tree_view(), + self.ui_nodes(), + ) + } + + pub fn mark_connecting_running(&mut self) { + self.clear_loaded_project_state(); + self.state = ProjectState::ConnectingRunningProject { + progress: ProgressState::new("Connecting running project"), + }; + } + + pub fn mark_selecting_loaded_project(&mut self, projects: Vec) { + self.clear_loaded_project_state(); + self.running_project_status = RunningProjectStatus::Available; + self.state = ProjectState::SelectingLoadedProject { projects }; + } + + pub fn mark_loading_demo(&mut self) { + self.clear_loaded_project_state(); + self.state = ProjectState::LoadingDemoProject { + progress: ProgressState::new("Loading demo project"), + }; + } + + pub fn mark_ready( + &mut self, + project_id: impl Into, + handle_id: u32, + inventory: ProjectInventorySummary, + ) { + self.running_project_status = RunningProjectStatus::Available; + self.state = ProjectState::Ready { + project_id: project_id.into(), + handle_id, + inventory, + }; + self.sync = Some(ProjectSync::new()); + self.root_nodes.clear(); + } + + pub fn fail(&mut self, message: impl Into) { + self.running_project_status = RunningProjectStatus::Unknown; + self.state = ProjectState::Failed { + issue: UiIssue::new(message), + }; + self.clear_loaded_project_state(); + } + + pub fn disconnect(&mut self) { + self.running_project_status = if matches!(self.state, ProjectState::Ready { .. }) { + RunningProjectStatus::Available + } else { + RunningProjectStatus::Unknown + }; + self.state = ProjectState::NotLoaded; + self.active_editor_target = None; + self.clear_loaded_project_state(); + } + + pub fn reset(&mut self) { + self.running_project_status = RunningProjectStatus::Unknown; + self.state = ProjectState::NotLoaded; + self.active_editor_target = None; + self.clear_loaded_project_state(); + } + + pub fn mark_no_running_project(&mut self) { + self.running_project_status = RunningProjectStatus::NoneKnown; + self.state = ProjectState::NotLoaded; + self.clear_loaded_project_state(); + } + + pub async fn load_demo_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result, UiError> { + self.mark_loading_demo(); + let loaded = server.load_demo_project().await?; + self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); + Ok(loaded.logs) + } + + pub async fn connect_running_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + self.mark_connecting_running(); + let catalog = server.list_loaded_projects().await?; + self.connect_from_catalog(server, catalog.projects, catalog.logs) + .await + } + + pub async fn connect_running_project_if_available( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + let catalog = server.list_loaded_projects().await?; + self.connect_from_catalog(server, catalog.projects, catalog.logs) + .await + } + + pub async fn connect_loaded_project( + &mut self, + server: &mut StudioServerClient, + handle_id: u32, + ) -> Result, UiError> { + let choice = self.loaded_project_choice(handle_id)?; + self.mark_connecting_running(); + let project = server.connect_loaded_project(choice).await?; + let logs = server.take_pending_logs(); + self.mark_ready(project.project_id, project.handle_id, project.inventory); + Ok(logs) + } + + pub async fn sync_loaded_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + let handle_id = self.ready_handle_id()?; + self.sync + .get_or_insert_with(ProjectSync::new) + .begin_initial_sync(); + match self.run_initial_sync(server, handle_id).await { + Ok(logs) => Ok(ProjectSyncRun::synced(logs)), + Err(error) => Ok(self.record_sync_failure(server, error)), + } + } + + pub async fn refresh_project( + &mut self, + server: &mut StudioServerClient, + ) -> Result { + let handle_id = self.ready_handle_id()?; + self.sync + .get_or_insert_with(ProjectSync::new) + .begin_refresh(); + match self.run_refresh(server, handle_id).await { + Ok(logs) => Ok(ProjectSyncRun::synced(logs)), + Err(error) => Ok(self.record_sync_failure(server, error)), + } + } + + pub async fn dispatch_editor_action( + &mut self, + action: UiAction, + _updates: UxUpdateSink, + ) -> UiResult { + let target = ProjectEditorTarget::parse(action.node_id())?; + let op = action.into_op::()?; + self.execute_editor_op(target, op).await + } + + async fn connect_from_catalog( + &mut self, + server: &mut StudioServerClient, + projects: Vec, + mut logs: Vec, + ) -> Result { + match projects.as_slice() { + [] => { + self.mark_no_running_project(); + Ok(ProjectConnectResult::NotFound { logs }) + } + [project] => { + let loaded = server.connect_loaded_project(project.clone()).await?; + logs.extend(server.take_pending_logs()); + self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); + Ok(ProjectConnectResult::Connected { logs }) + } + _ => { + self.mark_selecting_loaded_project(projects); + Ok(ProjectConnectResult::SelectionRequired { logs }) + } + } + } + + async fn execute_editor_op( + &mut self, + target: ProjectEditorTarget, + op: ProjectEditorOp, + ) -> UiResult { + match op { + ProjectEditorOp::Focus => { + self.focus_editor_target(&target); + self.active_editor_target = Some(target); + Ok(UiNotices::new()) + } + } + } + + fn body(&self) -> UiViewContent { + match &self.state { + ProjectState::NotLoaded + if self.running_project_status == RunningProjectStatus::NoneKnown => + { + UiViewContent::text( + "No running project is loaded. Load the demo project when you're ready.", + ) + } + ProjectState::NotLoaded => { + UiViewContent::text("Connect to a running project or load the demo project.") + } + ProjectState::SelectingLoadedProject { projects } => UiViewContent::text(format!( + "{} projects are running. Choose one to attach.", + projects.len() + )), + ProjectState::ConnectingRunningProject { progress } + | ProjectState::LoadingDemoProject { progress } => { + UiViewContent::Progress(progress.clone().into()) + } + ProjectState::Ready { + project_id, + handle_id, + inventory, + } => { + if self.sync.is_some() { + UiViewContent::ProjectEditor(Box::new( + self.editor_view(project_id, *handle_id, inventory), + )) + } else { + ready_project_metrics(project_id, *handle_id, inventory) + } + } + ProjectState::Failed { issue } => UiViewContent::Issue(issue.clone()), + } + } + + fn node_tree_view(&self) -> ProjectNodeTreeView { + ProjectNodeTreeView::new( + self.root_nodes + .iter() + .map(|node| self.node_tree_item(node)) + .collect(), + self.root_nodes.iter().map(count_nodes).sum(), + ) + } + + fn node_tree_item(&self, node: &NodeController) -> ProjectNodeTreeItem { + ProjectNodeTreeItem::new( + node.address().to_string(), + node.label(), + node.kind(), + node.status().clone(), + self.is_focused_node(node), + node_focus_action(node), + node.children() + .iter() + .map(|child| self.node_tree_item(child)) + .collect(), + ) + } + + fn is_focused_node(&self, node: &NodeController) -> bool { + if node.state().focused { + return true; + } + match self.active_editor_target.as_ref() { + Some(ProjectEditorTarget::AddressedNode { target }) => { + target.address == *node.address() + } + Some(ProjectEditorTarget::AddressedSlot { target, .. }) => { + target.address == *node.address() + } + _ => false, + } + } + + fn node_subscribes_products(&self, node: &NodeController) -> bool { + match node.state().product_subscription_intent { + ProjectProductSubscriptionIntent::Default => self.is_focused_node(node), + ProjectProductSubscriptionIntent::Subscribed => true, + ProjectProductSubscriptionIntent::Unsubscribed => false, + } + } + + fn subscribed_products(&self) -> Vec { + let mut product_refs = BTreeSet::new(); + for node in &self.root_nodes { + self.collect_subscribed_products(node, &mut product_refs); + } + product_refs.into_iter().collect() + } + + fn collect_subscribed_products( + &self, + node: &NodeController, + products: &mut BTreeSet, + ) { + if self.node_subscribes_products(node) { + let mut node_products = Vec::new(); + node.collect_produced_product_refs(&mut node_products); + products.extend(node_products); + } + for child in node.children() { + self.collect_subscribed_products(child, products); + } + } + + fn focus_editor_target(&mut self, target: &ProjectEditorTarget) { + clear_node_focus(&mut self.root_nodes); + match target { + ProjectEditorTarget::AddressedNode { target } + | ProjectEditorTarget::AddressedSlot { target, .. } => { + if let Some(node) = self.node_mut(&target.address) { + node.state_mut().focused = true; + } + } + _ => {} + } + } + + fn loaded_project_choice(&self, handle_id: u32) -> Result { + match &self.state { + ProjectState::SelectingLoadedProject { projects } => projects + .iter() + .find(|project| project.handle_id == handle_id) + .cloned() + .ok_or_else(|| { + UiError::Project(format!( + "loaded project handle {handle_id} is not available" + )) + }), + _ => Err(UiError::Project( + "loaded project selection is not active".to_string(), + )), + } + } + + fn ready_handle_id(&self) -> Result { + match &self.state { + ProjectState::Ready { handle_id, .. } => Ok(*handle_id), + _ => Err(UiError::Project( + "project sync requires a loaded project".to_string(), + )), + } + } + + async fn run_initial_sync( + &mut self, + server: &mut StudioServerClient, + handle_id: u32, + ) -> Result, UiError> { + let mut logs = Vec::new(); + loop { + let request = { + let sync = self.sync_mut()?; + if !sync.needs_shape_sync() { + break; + } + sync.shape_sync_request()? + }; + let read = server.project_read(handle_id, request).await?; + logs.extend(read.logs); + self.sync_mut()?.apply_shape_sync_response(read.response)?; + } + + let products = self.subscribed_products(); + let request = self.sync_mut()?.initial_project_read_request(products); + let read = server.project_read(handle_id, request).await?; + logs.extend(read.logs); + self.sync_mut()? + .apply_project_read_response(read.response)?; + self.apply_synced_project_view()?; + Ok(logs) + } + + async fn run_refresh( + &mut self, + server: &mut StudioServerClient, + handle_id: u32, + ) -> Result, UiError> { + let products = self.subscribed_products(); + let request = self.sync_mut()?.refresh_project_read_request(products); + let read = server.project_read(handle_id, request).await?; + let logs = read.logs; + self.sync_mut()? + .apply_project_read_response(read.response)?; + self.apply_synced_project_view()?; + Ok(logs) + } + + fn sync_mut(&mut self) -> Result<&mut ProjectSync, UiError> { + self.sync + .as_mut() + .ok_or_else(|| UiError::Project("project sync is not initialized".to_string())) + } + + fn clear_loaded_project_state(&mut self) { + self.sync = None; + self.root_nodes.clear(); + } + + fn apply_synced_project_view(&mut self) -> Result<(), UiError> { + let sync = self + .sync + .as_ref() + .ok_or_else(|| UiError::Project("project sync is not initialized".to_string()))?; + reconcile_root_nodes(&mut self.root_nodes, sync.project_view()); + if let Some(target) = self.active_editor_target.clone() { + self.focus_editor_target(&target); + } + ensure_default_node_focus(&mut self.root_nodes); + Ok(()) + } + + fn record_sync_failure( + &mut self, + server: &mut StudioServerClient, + error: UiError, + ) -> ProjectSyncRun { + let mut logs = server.take_pending_logs(); + logs.push(UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", + format!("project sync failed: {error}"), + )); + if let Some(sync) = &mut self.sync { + sync.fail(error.to_string()); + } + ProjectSyncRun::failed(logs) + } +} + +impl Controller for ProjectController { + type Op = ProjectOp; + + fn node_id(&self) -> ControllerId { + ControllerId::new(Self::NODE_ID) + } +} + +impl Default for ProjectController { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RunningProjectStatus { + Unknown, + NoneKnown, + Available, +} + +fn reconcile_root_nodes(root_nodes: &mut Vec, view: &ProjectView) { + let mut previous = root_nodes + .drain(..) + .map(|node| (node.address().clone(), node)) + .collect::>(); + + *root_nodes = root_node_ids(view) + .into_iter() + .filter_map(|node_id| view.tree.get(node_id)) + .map(|entry| { + let address = ProjectNodeAddress::new(entry.path.clone()); + if let Some(mut controller) = previous.remove(&address) { + controller.apply_tree_entry(entry, view); + controller + } else { + NodeController::from_tree_entry(entry, view) + } + }) + .collect(); +} + +fn root_node_ids(view: &ProjectView) -> Vec { + let mut roots = view + .tree + .nodes + .values() + .filter(|entry| entry.parent.is_none()) + .map(|entry| entry.id) + .collect::>(); + roots.sort_by(|a, b| tree_path_sort_key(view, *a).cmp(&tree_path_sort_key(view, *b))); + roots +} + +fn count_nodes(node: &NodeController) -> usize { + 1 + node.children().iter().map(count_nodes).sum::() +} + +fn node_focus_action(node: &NodeController) -> UiAction { + UiAction::from_op( + ProjectEditorTarget::addressed_node(node.target().clone()).node_id(), + ProjectEditorOp::Focus, + ) + .with_label(format!("Focus {}", node.label())) + .with_summary(format!("Focus node {}.", node.address())) +} + +fn clear_node_focus(nodes: &mut [NodeController]) { + for node in nodes { + node.state_mut().focused = false; + clear_node_focus(node.children_mut()); + } +} + +fn ensure_default_node_focus(nodes: &mut [NodeController]) { + if has_focused_node(nodes) { + return; + } + if let Some(node) = default_focus_node_mut(nodes) { + node.state_mut().focused = true; + } +} + +fn has_focused_node(nodes: &[NodeController]) -> bool { + nodes + .iter() + .any(|node| node.state().focused || has_focused_node(node.children())) +} + +fn default_focus_node_mut(nodes: &mut [NodeController]) -> Option<&mut NodeController> { + let root = nodes.first_mut()?; + let index = { + root.children() + .iter() + .enumerate() + .min_by_key(|(index, node)| (default_focus_kind_priority(node.kind()), *index)) + .map(|(index, _)| index) + }?; + root.children_mut().get_mut(index) +} + +fn default_focus_kind_priority(kind: &str) -> u8 { + match kind { + "Fixture" => 0, + "Shader" => 1, + _ => 2, + } +} + +fn tree_path_sort_key(view: &ProjectView, node_id: NodeId) -> TreePath { + view.tree + .get(node_id) + .map(|entry| entry.path.clone()) + .unwrap_or_else(|| TreePath(Vec::new())) +} + +fn project_status(state: &ProjectState, sync: Option<&ProjectSync>) -> UiStatus { + match state { + ProjectState::NotLoaded => UiStatus::neutral("Not loaded"), + ProjectState::SelectingLoadedProject { .. } => UiStatus::neutral("Choose project"), + ProjectState::ConnectingRunningProject { .. } => UiStatus::working("Connecting"), + ProjectState::LoadingDemoProject { .. } => UiStatus::working("Loading"), + ProjectState::Ready { .. } if sync.is_some_and(ProjectSync::is_syncing) => { + UiStatus::working("Syncing") + } + ProjectState::Ready { .. } if sync.is_some_and(ProjectSync::is_failed) => { + UiStatus::error("Sync issue") + } + ProjectState::Ready { .. } => UiStatus::good("Ready"), + ProjectState::Failed { .. } => UiStatus::error("Failed"), + } +} + +fn ready_project_metrics( + project_id: &str, + handle_id: u32, + inventory: &ProjectInventorySummary, +) -> UiViewContent { + let mut metrics = vec![ + UiMetric::new("Project", project_id), + UiMetric::new("Handle", handle_id), + UiMetric::new("Inventory nodes", inventory.node_count), + UiMetric::new("Definitions", inventory.definition_count), + UiMetric::new("Assets", inventory.asset_count), + ]; + + metrics.push(UiMetric::new("Sync", "Not synced")); + + UiViewContent::Metrics(metrics) +} + +fn project_editor_stats( + project_id: &str, + handle_id: u32, + inventory: &ProjectInventorySummary, + summary: &ProjectSyncSummary, +) -> Vec { + let mut stats = vec![ + UiMetric::new("Project", project_id), + UiMetric::new("Handle", handle_id), + UiMetric::new("Revision", summary.revision), + UiMetric::new("Sync", sync_phase_label(summary.phase)), + UiMetric::new("Nodes", summary.node_count), + UiMetric::new("Assets", inventory.asset_count), + UiMetric::new("Definitions", inventory.definition_count), + UiMetric::new("Shapes", summary.shape_count), + ]; + if let Some(runtime) = &summary.runtime { + stats.push(UiMetric::new("Frame", runtime.frame_num)); + if runtime.frame_delta_ms > 0 { + stats.push(UiMetric::new( + "FPS", + 1000_u32.saturating_div(runtime.frame_delta_ms), + )); + } + stats.push(UiMetric::new("Buffers", runtime.runtime_buffer_count)); + if let Some(free_bytes) = runtime.free_bytes { + stats.push(UiMetric::new("Memory free", format_bytes(free_bytes))); + } + } + stats +} + +fn sync_phase_label(phase: ProjectSyncPhase) -> &'static str { + match phase { + ProjectSyncPhase::Empty => "Not synced", + ProjectSyncPhase::SyncingShapes | ProjectSyncPhase::SyncingProject => "Syncing", + ProjectSyncPhase::Ready => "Synced", + ProjectSyncPhase::Failed => "Needs attention", + } +} + +fn format_bytes(bytes: u64) -> String { + if bytes >= 1024 { + format!("{} KB", bytes / 1024) + } else { + format!("{bytes} B") + } +} + +#[cfg(test)] +mod tests { + use lpc_model::{ + ControlExtent, ControlProduct, LpType, LpValue, NodeId, ProductKind, ProductRef, Revision, + SlotData, SlotEnum, SlotEnumEncoding, SlotFieldShape, SlotMapDyn, SlotMapKey, + SlotMapKeyShape, SlotMeta, SlotName, SlotOptionDyn, SlotPath, SlotRecord, SlotShape, + SlotShapeId, SlotVariantShape, TreePath, VisualProduct, WithRevision, + }; + use lpc_view::{ProjectView, TreeEntryView}; + use lpc_wire::{ + NodeRuntimeStatus, ProjectProbeRequest, ProjectProbeResult, ProjectReadResponse, + ProjectReadResult, RenderProductProbeRequest, RenderProductProbeResult, WireEntryState, + WireTextureFormat, + }; + + use crate::{ + ActionPriority, ProjectNodeTarget, ProjectOp, ProjectProductSubscriptionIntent, + ProjectSlotAddress, ProjectSlotRoot, ProjectSyncPhase, SlotKind, UiAssetEditorKind, + UiConfigSlotBody, UiNodeSection, UiNodeTabBody, UiProductKind, UiProductPreview, + UiProductPreviewFrame, UiProductRef, UiProductTrackingState, UiSlotOptionality, + UiSlotSourceState, + }; + + use super::*; + + #[test] + fn disconnected_project_has_no_actions() { + let project = ProjectController::new(); + + assert!(project.actions(false).is_empty()); + } + + #[test] + fn connected_not_loaded_project_offers_attach_and_demo_actions() { + let project = ProjectController::new(); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::ConnectRunningProject) + ); + assert_eq!(actions[0].meta().priority, ActionPriority::Primary); + assert_eq!( + actions[1].op_as::(), + Some(&ProjectOp::LoadDemoProject) + ); + assert_eq!(actions[1].meta().priority, ActionPriority::Secondary); + } + + #[test] + fn connected_project_with_no_running_project_only_offers_demo_load() { + let mut project = ProjectController::new(); + project.mark_no_running_project(); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::LoadDemoProject) + ); + } + + #[test] + fn multiple_loaded_projects_offer_project_specific_actions() { + let mut project = ProjectController::new(); + project.mark_selecting_loaded_project(vec![ + LoadedProjectChoice::new("/projects/a", 1), + LoadedProjectChoice::new("/projects/b", 2), + ]); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::ConnectLoadedProject { handle_id: 1 }) + ); + assert_eq!(actions[0].meta().label, "Connect /projects/a"); + assert_eq!( + actions[1].op_as::(), + Some(&ProjectOp::ConnectLoadedProject { handle_id: 2 }) + ); + } + + #[test] + fn ready_project_offers_refresh_and_disconnect_actions() { + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + + let actions = project.actions(true); + + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].op_as::(), + Some(&ProjectOp::RefreshProject) + ); + assert_eq!(actions[0].meta().priority, ActionPriority::Secondary); + assert_eq!( + actions[1].op_as::(), + Some(&ProjectOp::DisconnectProject) + ); + assert_eq!(actions[1].meta().priority, ActionPriority::Tertiary); + } + + #[test] + fn ready_project_initializes_sync_summary() { + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + + assert_eq!( + project.sync_summary().map(|summary| summary.phase), + Some(ProjectSyncPhase::Empty) + ); + } + + #[test] + fn disconnect_clears_sync_summary() { + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + + project.disconnect(); + + assert!(project.sync_summary().is_none()); + } + + #[test] + fn empty_project_view_yields_empty_controller_tree() { + let mut project = ProjectController::new(); + + project.apply_project_view(&ProjectView::new()).unwrap(); + + assert!(project.root_nodes().is_empty()); + } + + #[test] + fn project_view_creates_owned_node_tree_in_order() { + let mut project = ProjectController::new(); + + project.apply_project_view(&tree_view()).unwrap(); + + assert_eq!(project.root_nodes().len(), 1); + let root = &project.root_nodes()[0]; + assert_eq!(root.label(), "Demo"); + assert_eq!( + root.children() + .iter() + .map(|child| child.label()) + .collect::>(), + vec!["Clock", "Orbit"] + ); + } + + #[test] + fn project_view_focuses_first_shader_when_no_fixture_by_default() { + let mut project = ProjectController::new(); + + project.apply_project_view(&tree_view()).unwrap(); + + let root = &project.root_nodes()[0]; + assert!(!root.state().focused); + assert!(!root.children()[0].state().focused); + assert!(root.children()[1].state().focused); + } + + #[test] + fn project_view_prefers_fixture_for_default_focus() { + let mut project = ProjectController::new(); + + project.apply_project_view(&fixture_tree_view()).unwrap(); + + let root = &project.root_nodes()[0]; + assert_eq!( + root.children() + .iter() + .filter(|node| node.state().focused) + .map(|node| node.label()) + .collect::>(), + vec!["Pixels"] + ); + } + + #[test] + fn project_view_focuses_first_child_when_no_fixture_or_shader() { + let mut project = ProjectController::new(); + + project + .apply_project_view(&clock_output_tree_view()) + .unwrap(); + + let root = &project.root_nodes()[0]; + assert!(root.children()[0].state().focused); + assert!(!root.children()[1].state().focused); + } + + #[test] + fn project_view_keeps_existing_focus_when_syncing() { + let mut project = ProjectController::new(); + project.apply_project_view(&tree_view()).unwrap(); + let orbit = node_address("/demo.project/orbit.shader"); + + clear_node_focus(&mut project.root_nodes); + project.node_mut(&orbit).unwrap().state_mut().focused = true; + project.apply_project_view(&tree_view()).unwrap(); + + assert!(project.node(&orbit).unwrap().state().focused); + assert!( + !project + .node(&node_address("/demo.project/clock.clock")) + .unwrap() + .state() + .focused + ); + } + + #[test] + fn node_update_preserves_local_state_and_refreshes_runtime_id() { + let address = node_address("/demo.project/orbit.shader"); + let mut project = ProjectController::new(); + project + .apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok)) + .unwrap(); + let node = project.node_mut(&address).unwrap(); + node.state_mut().collapsed = true; + node.state_mut().focused = true; + node.state_mut().product_subscription_intent = ProjectProductSubscriptionIntent::Subscribed; + + project + .apply_project_view(&single_node_view( + 42, + NodeRuntimeStatus::Warn("low fps".to_string()), + )) + .unwrap(); + + let node = project.node(&address).unwrap(); + assert_eq!(node.target().node_id, NodeId::new(42)); + assert_eq!(node.status().label, "Warning"); + assert!(node.state().collapsed); + assert!(node.state().focused); + assert_eq!( + node.state().product_subscription_intent, + ProjectProductSubscriptionIntent::Subscribed + ); + } + + #[test] + fn node_add_remove_and_reorder_follow_project_view() { + let mut project = ProjectController::new(); + project + .apply_project_view(&root_view(&[ + (1, "/demo.project/a.shader"), + (2, "/demo.project/b.shader"), + ])) + .unwrap(); + + project + .apply_project_view(&root_view(&[ + (3, "/demo.project/c.shader"), + (1, "/demo.project/a.shader"), + ])) + .unwrap(); + + assert_eq!( + project + .root_nodes() + .iter() + .map(|node| node.label()) + .collect::>(), + vec!["A", "C"] + ); + assert!( + project + .node(&node_address("/demo.project/b.shader")) + .is_none() + ); + } + + #[test] + fn disconnect_and_reset_clear_controller_tree() { + let mut project = ProjectController::new(); + project + .apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok)) + .unwrap(); + + project.disconnect(); + + assert!(project.root_nodes().is_empty()); + + project + .apply_project_view(&single_node_view(1, NodeRuntimeStatus::Ok)) + .unwrap(); + project.reset(); + + assert!(project.root_nodes().is_empty()); + } + + #[test] + fn synced_project_view_applies_to_controller_tree() { + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + project + .sync_mut() + .unwrap() + .apply_project_read_response(ProjectReadResponse { + revision: Revision::new(12), + results: vec![ProjectReadResult::Nodes(lpc_wire::NodeReadResult { + level: lpc_wire::ReadLevel::Detail, + tree_deltas: vec![lpc_wire::WireTreeDelta::Created { + id: NodeId::new(1), + path: TreePath::parse("/demo.project").unwrap(), + parent: None, + child_kind: None, + children: Vec::new(), + status: NodeRuntimeStatus::Ok, + state: WireEntryState::Alive, + created_frame: Revision::new(1), + change_frame: Revision::new(1), + children_ver: Revision::new(1), + }], + slots: None, + })], + probes: Vec::new(), + }) + .unwrap(); + + project.apply_synced_project_view().unwrap(); + + assert_eq!(project.root_nodes()[0].label(), "Demo"); + } + + #[test] + fn def_and_state_slot_roots_create_slot_controller_roots() { + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_test_slots(&mut view, 1, Revision::new(2), false); + let mut project = ProjectController::new(); + + project.apply_project_view(&view).unwrap(); + + let node = project + .node(&node_address("/demo.project/orbit.shader")) + .unwrap(); + assert_eq!( + node.slots() + .iter() + .map(|slot| slot.label()) + .collect::>(), + vec!["Def", "State"] + ); + assert_eq!(node.slots()[0].children()[1].label(), "Brightness"); + } + + #[test] + fn slot_update_preserves_local_state() { + let node = node_address("/demo.project/orbit.shader"); + let brightness = ProjectSlotAddress::new( + node.clone(), + ProjectSlotRoot::def(), + SlotPath::parse("brightness").unwrap(), + ); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_test_slots(&mut view, 1, Revision::new(2), false); + let mut project = ProjectController::new(); + project.apply_project_view(&view).unwrap(); + project + .node_mut(&node) + .unwrap() + .slot_mut(&brightness) + .unwrap() + .state_mut() + .expanded = true; + + install_test_slots(&mut view, 1, Revision::new(3), false); + project.apply_project_view(&view).unwrap(); + + let slot = project + .node_mut(&node) + .unwrap() + .slot_mut(&brightness) + .unwrap(); + assert_eq!(slot.revision(), Some(Revision::new(3))); + assert!(slot.state().expanded); + } + + #[test] + fn record_to_scalar_shape_change_removes_stale_slot_children() { + let node = node_address("/demo.project/orbit.shader"); + let root = ProjectSlotAddress::root(node.clone(), ProjectSlotRoot::def()); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_test_slots(&mut view, 1, Revision::new(2), false); + let mut project = ProjectController::new(); + project.apply_project_view(&view).unwrap(); + assert_eq!(project.node(&node).unwrap().slots()[0].children().len(), 3); + + install_test_slots(&mut view, 1, Revision::new(3), true); + project.apply_project_view(&view).unwrap(); + + let slot = &project.node(&node).unwrap().slots()[0]; + assert_eq!(slot.address(), &root); + assert_eq!(slot.kind(), SlotKind::Value); + assert!(slot.children().is_empty()); + } + + #[test] + fn map_entry_changes_reconcile_keyed_slot_children() { + let node = node_address("/demo.project/orbit.shader"); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_map_slot(&mut view, 1, Revision::new(2), &["a", "b"]); + let mut project = ProjectController::new(); + project.apply_project_view(&view).unwrap(); + + assert_eq!( + project.node(&node).unwrap().slots()[0] + .children() + .iter() + .map(|slot| slot.label()) + .collect::>(), + vec!["a", "b"] + ); + + install_map_slot(&mut view, 1, Revision::new(3), &["b", "c"]); + project.apply_project_view(&view).unwrap(); + + assert_eq!( + project.node(&node).unwrap().slots()[0] + .children() + .iter() + .map(|slot| slot.label()) + .collect::>(), + vec!["b", "c"] + ); + } + + #[test] + fn ui_nodes_project_header_state_and_child_summaries() { + let mut project = ProjectController::new(); + let mut view = tree_view(); + install_ui_projection_slots(&mut view, 2, Revision::new(4)); + project.apply_project_view(&view).unwrap(); + let node = node_address("/demo.project"); + project.node_mut(&node).unwrap().state_mut().focused = true; + project.node_mut(&node).unwrap().state_mut().collapsed = true; + + let nodes = project.ui_nodes(); + + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].header.title, "Demo"); + assert_eq!(nodes[0].header.kind, "Project"); + assert_eq!(nodes[0].header.path, "/demo.project"); + assert_eq!(nodes[0].header.status.label, "Running"); + assert!(nodes[0].focused); + assert!(nodes[0].collapsed); + let action_target = + ProjectEditorTarget::parse(nodes[0].action.as_ref().unwrap().node_id()).unwrap(); + assert_eq!( + action_target, + ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( + node.clone(), + NodeId::new(1), + )) + ); + assert_eq!( + nodes[0] + .children + .iter() + .map(|child| child.label.as_str()) + .collect::>(), + vec!["Clock", "Orbit"] + ); + assert_eq!(nodes[0].children[0].detail, "/demo.project/clock.clock"); + assert!(!nodes[0].children[0].sections.is_empty()); + } + + #[test] + fn ui_child_nodes_keep_focus_action_and_state() { + let mut project = ProjectController::new(); + let mut view = tree_view(); + install_ui_projection_slots(&mut view, 3, Revision::new(4)); + project.apply_project_view(&view).unwrap(); + let child_address = node_address("/demo.project/orbit.shader"); + project + .node_mut(&child_address) + .unwrap() + .state_mut() + .focused = true; + + let nodes = project.ui_nodes(); + let child = &nodes[0].children[1]; + + assert!(child.focused); + let action_target = ProjectEditorTarget::parse(child.action.as_ref().unwrap().node_id()) + .expect("child action should be typed"); + assert_eq!( + action_target, + ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( + child_address, + NodeId::new(3), + )) + ); + } + + #[test] + fn editor_view_uses_controller_nodes_and_navigation_targets() { + let mut project = ProjectController::new(); + let inventory = ProjectInventorySummary { + node_count: 3, + definition_count: 2, + asset_count: 1, + }; + project.mark_ready("studio-demo", 7, inventory.clone()); + project.apply_project_view(&tree_view()).unwrap(); + + let view = project.editor_view("studio-demo", 7, &inventory); + + assert_eq!(view.project_id, "studio-demo"); + assert_eq!(view.handle_id, 7); + assert_eq!(view.tree.total_count, 3); + assert_eq!(view.tree.roots[0].label, "Demo"); + assert_eq!(view.tree.roots[0].children[1].label, "Orbit"); + assert_eq!(view.nodes.len(), 1); + assert_eq!(view.nodes[0].header.title, "Demo"); + + let target = ProjectEditorTarget::parse(&view.tree.roots[0].children[1].action.node_id()) + .expect("tree action should be typed"); + assert_eq!( + target, + ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( + node_address("/demo.project/orbit.shader"), + NodeId::new(3), + )) + ); + } + + #[test] + fn ui_node_projection_classifies_products_values_assets_and_config() { + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_ui_projection_slots(&mut view, 1, Revision::new(4)); + let mut project = ProjectController::new(); + + project.apply_project_view(&view).unwrap(); + + let nodes = project.ui_nodes(); + let sections = node_sections(&nodes[0]); + + let products = section_products(sections); + assert_eq!(products.len(), 2); + assert_eq!(products[0].name, "Output"); + assert_eq!(products[0].kind, UiProductKind::Visual); + assert_eq!(products[0].preview, UiProductPreview::Pending); + assert_eq!(products[0].tracking, UiProductTrackingState::Untracked); + assert_eq!( + products[0].product, + Some(UiProductRef::from_visual_product(VisualProduct::new( + NodeId::new(1), + 0, + ))) + ); + assert_eq!(products[1].name, "Control"); + assert_eq!(products[1].kind, UiProductKind::Control); + assert_eq!(products[1].preview, UiProductPreview::Pending); + assert_eq!(products[1].tracking, UiProductTrackingState::Untracked); + assert_eq!( + products[1].product, + Some(UiProductRef::from_control_product(ControlProduct::new( + NodeId::new(1), + 1, + ControlExtent::new(2, 16), + ))) + ); + + let produced_values = section_produced_values(sections); + assert_eq!(produced_values.len(), 1); + assert_eq!(produced_values[0].label, "Seconds"); + assert_eq!(produced_values[0].value, "3.333"); + assert_eq!(produced_values[0].unit, Some(crate::UiSlotUnit::seconds())); + + let assets = section_asset_slots(sections); + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].label, "Shader"); + let UiConfigSlotBody::Asset(asset) = &assets[0].body else { + panic!("expected asset slot body"); + }; + assert_eq!(asset.editor, UiAssetEditorKind::Glsl); + assert!(asset.content.as_deref().unwrap().contains("void mainImage")); + + let config = section_config_slots(sections); + assert_eq!( + config + .iter() + .map(|slot| slot.label.as_str()) + .collect::>(), + vec!["Brightness", "Palette"] + ); + let UiConfigSlotBody::Value(value) = &config[0].body else { + panic!("expected brightness value body"); + }; + assert_eq!(value.display, "0.72"); + let UiConfigSlotBody::Record(record) = &config[1].body else { + panic!("expected palette record body"); + }; + assert_eq!( + record + .fields + .iter() + .map(|field| field.label.as_str()) + .collect::>(), + vec!["Primary", "Secondary"] + ); + } + + #[test] + fn focused_default_node_subscribes_product_preview_probes() { + let node = node_address("/demo.project/orbit.shader"); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_ui_projection_slots(&mut view, 1, Revision::new(4)); + let mut project = ProjectController::new(); + project.apply_project_view(&view).unwrap(); + + assert!(project.subscribed_products().is_empty()); + + project.node_mut(&node).unwrap().state_mut().focused = true; + assert_eq!( + project.subscribed_products(), + vec![ + UiProductRef::from_visual_product(VisualProduct::new(NodeId::new(1), 0)), + UiProductRef::from_control_product(ControlProduct::new( + NodeId::new(1), + 1, + ControlExtent::new(2, 16), + )), + ] + ); + + project + .node_mut(&node) + .unwrap() + .state_mut() + .product_subscription_intent = ProjectProductSubscriptionIntent::Unsubscribed; + assert!(project.subscribed_products().is_empty()); + + let state = project.node_mut(&node).unwrap().state_mut(); + state.focused = false; + state.product_subscription_intent = ProjectProductSubscriptionIntent::Subscribed; + assert_eq!( + project.subscribed_products(), + vec![ + UiProductRef::from_visual_product(VisualProduct::new(NodeId::new(1), 0)), + UiProductRef::from_control_product(ControlProduct::new( + NodeId::new(1), + 1, + ControlExtent::new(2, 16), + )), + ] + ); + } + + #[test] + fn ui_nodes_project_cached_visual_preview() { + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_ui_projection_slots(&mut view, 1, Revision::new(4)); + let mut project = ProjectController::new(); + project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + project.apply_project_view(&view).unwrap(); + let product = VisualProduct::new(NodeId::new(1), 0); + let bytes = vec![10, 20, 30, 40, 50, 60]; + let request = project + .sync_mut() + .unwrap() + .refresh_project_read_request(vec![UiProductRef::from_visual_product(product)]); + assert_eq!( + request.probes, + vec![ProjectProbeRequest::RenderProduct( + RenderProductProbeRequest { + product, + width: UiProductPreviewFrame::VISUAL_DEFAULT.width, + height: UiProductPreviewFrame::VISUAL_DEFAULT.height, + format: WireTextureFormat::Srgb8, + }, + )] + ); + project + .sync_mut() + .unwrap() + .apply_project_read_response(ProjectReadResponse { + revision: Revision::new(8), + results: Vec::new(), + probes: vec![ProjectProbeResult::RenderProduct( + RenderProductProbeResult::Texture { + product, + revision: Revision::new(8), + width: 1, + height: 2, + format: WireTextureFormat::Srgb8, + bytes: bytes.clone(), + }, + )], + }) + .unwrap(); + + let nodes = project.ui_nodes(); + let products = section_products(node_sections(&nodes[0])); + assert_eq!(products[0].tracking, UiProductTrackingState::Paused); + assert_eq!( + products[0].preview, + UiProductPreview::VisualSrgb8 { + width: 1, + height: 2, + revision: 8, + bytes, + } + ); + } + + #[test] + fn ui_config_projection_handles_enum_option_and_map_shapes() { + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_structural_config_slots(&mut view, 1, Revision::new(8)); + let mut project = ProjectController::new(); + + project.apply_project_view(&view).unwrap(); + + let nodes = project.ui_nodes(); + let config = section_config_slots(node_sections(&nodes[0])); + assert_eq!( + config + .iter() + .map(|slot| slot.label.as_str()) + .collect::>(), + vec!["Mode", "Optional", "Entries"] + ); + + let UiConfigSlotBody::Record(mode) = &config[0].body else { + panic!("expected enum as record body"); + }; + assert_eq!(mode.fields[0].label, "Manual"); + + assert!(matches!(config[1].body, UiConfigSlotBody::Empty)); + assert_eq!( + config[1].optionality, + Some(UiSlotOptionality::excluded(true)) + ); + assert_eq!(config[1].detail, None); + assert_eq!(config[1].source, UiSlotSourceState::Unset); + + let UiConfigSlotBody::Record(entries) = &config[2].body else { + panic!("expected map as record body"); + }; + assert_eq!( + entries + .fields + .iter() + .map(|field| field.label.as_str()) + .collect::>(), + vec!["a", "b"] + ); + + let root = view + .slots + .roots + .get_mut("node.1.def") + .expect("def root exists"); + let SlotData::Record(record) = root else { + panic!("expected def record"); + }; + record.fields[1] = SlotData::Option(SlotOptionDyn::some_with_version( + Revision::new(9), + SlotData::Value(WithRevision::new(Revision::new(9), LpValue::F32(0.25))), + )); + + project.apply_project_view(&view).unwrap(); + + let nodes = project.ui_nodes(); + let config = section_config_slots(node_sections(&nodes[0])); + assert_eq!( + config[1].optionality, + Some(UiSlotOptionality::included(true)) + ); + assert_eq!(config[1].detail.as_deref(), Some("Float32")); + let UiConfigSlotBody::Value(value) = &config[1].body else { + panic!("expected included option as value body"); + }; + assert_eq!(value.display, "0.25"); + } + + #[test] + fn ui_config_projection_keeps_slot_issues() { + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + view.slots.root_shapes.clear(); + view.slots.roots.clear(); + view.slots + .root_shapes + .insert("node.1.def".to_string(), SlotShapeId::new(999)); + let mut project = ProjectController::new(); + + project.apply_project_view(&view).unwrap(); + + let nodes = project.ui_nodes(); + let config = section_config_slots(node_sections(&nodes[0])); + assert_eq!(config.len(), 1); + assert_eq!(config[0].label, "Def"); + assert_eq!(config[0].issues, vec!["node.1.def data is missing"]); + assert_eq!( + config[0].state.invalid.as_deref(), + Some("node.1.def data is missing") + ); + } + + #[test] + fn projected_ui_value_updates_while_slot_state_is_preserved() { + let node = node_address("/demo.project/orbit.shader"); + let brightness = ProjectSlotAddress::new( + node.clone(), + ProjectSlotRoot::def(), + SlotPath::parse("brightness").unwrap(), + ); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_test_slots(&mut view, 1, Revision::new(2), false); + let mut project = ProjectController::new(); + project.apply_project_view(&view).unwrap(); + project + .node_mut(&node) + .unwrap() + .slot_mut(&brightness) + .unwrap() + .state_mut() + .expanded = true; + + install_test_slots(&mut view, 1, Revision::new(3), false); + set_brightness(&mut view, 1, Revision::new(3), 0.25); + project.apply_project_view(&view).unwrap(); + + let ui_nodes = project.ui_nodes(); + let config = section_config_slots(node_sections(&ui_nodes[0])); + let UiConfigSlotBody::Value(value) = &config[1].body else { + panic!("expected brightness value"); + }; + assert_eq!(value.display, "0.25"); + assert!( + project + .node_mut(&node) + .unwrap() + .slot_mut(&brightness) + .unwrap() + .state() + .expanded + ); + } + + fn node_sections(node: &crate::UiNodeView) -> &[UiNodeSection] { + let UiNodeTabBody::Sections(sections) = &node.tabs[0].body else { + panic!("expected node sections"); + }; + sections + } + + fn section_products(sections: &[UiNodeSection]) -> &[crate::UiProducedProduct] { + sections + .iter() + .find_map(|section| match section { + UiNodeSection::ProducedProducts(items) => Some(items.as_slice()), + _ => None, + }) + .unwrap_or(&[]) + } + + fn section_produced_values(sections: &[UiNodeSection]) -> &[crate::UiProducedValue] { + sections + .iter() + .find_map(|section| match section { + UiNodeSection::ProducedValues(items) => Some(items.as_slice()), + _ => None, + }) + .unwrap_or(&[]) + } + + fn section_asset_slots(sections: &[UiNodeSection]) -> &[crate::UiConfigSlot] { + sections + .iter() + .find_map(|section| match section { + UiNodeSection::AssetSlots(items) => Some(items.as_slice()), + _ => None, + }) + .unwrap_or(&[]) + } + + fn section_config_slots(sections: &[UiNodeSection]) -> &[crate::UiConfigSlot] { + sections + .iter() + .find_map(|section| match section { + UiNodeSection::ConfigSlots(items) => Some(items.as_slice()), + _ => None, + }) + .unwrap_or(&[]) + } + + fn tree_view() -> ProjectView { + let mut view = ProjectView::new(); + let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + root.children = vec![NodeId::new(2), NodeId::new(3)]; + view.tree.insert(root); + view.tree.insert(node_entry( + 2, + "/demo.project/clock.clock", + Some(1), + NodeRuntimeStatus::Ok, + )); + view.tree.insert(node_entry( + 3, + "/demo.project/orbit.shader", + Some(1), + NodeRuntimeStatus::Ok, + )); + view + } + + fn fixture_tree_view() -> ProjectView { + let mut view = ProjectView::new(); + let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + root.children = vec![NodeId::new(2), NodeId::new(3), NodeId::new(4)]; + view.tree.insert(root); + view.tree.insert(node_entry( + 2, + "/demo.project/clock.clock", + Some(1), + NodeRuntimeStatus::Ok, + )); + view.tree.insert(node_entry( + 3, + "/demo.project/orbit.shader", + Some(1), + NodeRuntimeStatus::Ok, + )); + view.tree.insert(node_entry( + 4, + "/demo.project/pixels.fixture", + Some(1), + NodeRuntimeStatus::Ok, + )); + view + } + + fn clock_output_tree_view() -> ProjectView { + let mut view = ProjectView::new(); + let mut root = node_entry(1, "/demo.project", None, NodeRuntimeStatus::Ok); + root.children = vec![NodeId::new(2), NodeId::new(3)]; + view.tree.insert(root); + view.tree.insert(node_entry( + 2, + "/demo.project/clock.clock", + Some(1), + NodeRuntimeStatus::Ok, + )); + view.tree.insert(node_entry( + 3, + "/demo.project/dmx.output", + Some(1), + NodeRuntimeStatus::Ok, + )); + view + } + + fn single_node_view(id: u32, status: NodeRuntimeStatus) -> ProjectView { + let mut view = ProjectView::new(); + view.tree + .insert(node_entry(id, "/demo.project/orbit.shader", None, status)); + view + } + + fn root_view(nodes: &[(u32, &str)]) -> ProjectView { + let mut view = ProjectView::new(); + for (id, path) in nodes { + view.tree + .insert(node_entry(*id, path, None, NodeRuntimeStatus::Ok)); + } + view + } + + fn node_entry( + id: u32, + path: &str, + parent: Option, + status: NodeRuntimeStatus, + ) -> TreeEntryView { + TreeEntryView::new( + NodeId::new(id), + TreePath::parse(path).unwrap(), + parent.map(NodeId::new), + None, + status, + WireEntryState::Alive, + Revision::new(1), + Revision::new(1), + Revision::new(1), + ) + } + + fn install_test_slots( + view: &mut ProjectView, + node_id: u32, + revision: Revision, + scalar_def_root: bool, + ) { + view.slots.root_shapes.clear(); + view.slots.roots.clear(); + let def_shape = SlotShapeId::new(100); + let state_shape = SlotShapeId::new(101); + view.slots.registry = Default::default(); + view.slots + .registry + .register_dynamic_shape( + def_shape, + if scalar_def_root { + SlotShape::value(LpType::F32) + } else { + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new("input", SlotShape::value(LpType::F32)).unwrap(), + SlotFieldShape::new("brightness", SlotShape::value(LpType::F32)) + .unwrap(), + SlotFieldShape::new( + "bindings", + SlotShape::Record { + meta: SlotMeta::empty(), + fields: Vec::new(), + }, + ) + .unwrap(), + ], + } + }, + ) + .unwrap(); + view.slots + .registry + .register_dynamic_shape( + state_shape, + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new("output", SlotShape::value(LpType::F32)).unwrap(), + ], + }, + ) + .unwrap(); + view.slots + .root_shapes + .insert(format!("node.{node_id}.def"), def_shape); + view.slots.roots.insert( + format!("node.{node_id}.def"), + if scalar_def_root { + SlotData::Value(WithRevision::new(revision, LpValue::F32(0.75))) + } else { + SlotData::Record(SlotRecord::with_revision( + revision, + vec![ + SlotData::Value(WithRevision::new(revision, LpValue::F32(0.5))), + SlotData::Value(WithRevision::new(revision, LpValue::F32(0.75))), + SlotData::Record(SlotRecord::with_revision(revision, Vec::new())), + ], + )) + }, + ); + view.slots + .root_shapes + .insert(format!("node.{node_id}.state"), state_shape); + view.slots.roots.insert( + format!("node.{node_id}.state"), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![SlotData::Value(WithRevision::new( + revision, + LpValue::F32(1.0), + ))], + )), + ); + } + + fn install_ui_projection_slots(view: &mut ProjectView, node_id: u32, revision: Revision) { + view.slots.root_shapes.clear(); + view.slots.roots.clear(); + view.slots.registry = Default::default(); + let def_shape = SlotShapeId::new(300); + let state_shape = SlotShapeId::new(301); + + view.slots + .registry + .register_dynamic_shape( + def_shape, + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new("brightness", SlotShape::value(LpType::F32)).unwrap(), + SlotFieldShape::new("shader", SlotShape::value(LpType::String)).unwrap(), + SlotFieldShape::new( + "palette", + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new("primary", SlotShape::value(LpType::Vec3)) + .unwrap(), + SlotFieldShape::new( + "secondary", + SlotShape::value(LpType::Vec3), + ) + .unwrap(), + ], + }, + ) + .unwrap(), + SlotFieldShape::new( + "bindings", + SlotShape::Record { + meta: SlotMeta::empty(), + fields: Vec::new(), + }, + ) + .unwrap(), + ], + }, + ) + .unwrap(); + view.slots + .registry + .register_dynamic_shape( + state_shape, + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new( + "output", + SlotShape::value(LpType::Product(ProductKind::Visual)), + ) + .unwrap(), + SlotFieldShape::new( + "control", + SlotShape::value(LpType::Product(ProductKind::Control)), + ) + .unwrap(), + SlotFieldShape::new("seconds", SlotShape::value(LpType::F32)).unwrap(), + ], + }, + ) + .unwrap(); + + view.slots + .root_shapes + .insert(format!("node.{node_id}.def"), def_shape); + view.slots.roots.insert( + format!("node.{node_id}.def"), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![ + SlotData::Value(WithRevision::new(revision, LpValue::F32(0.72))), + SlotData::Value(WithRevision::new( + revision, + LpValue::String( + "void mainImage(out vec4 color, in vec2 uv) {}".to_string(), + ), + )), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![ + SlotData::Value(WithRevision::new( + revision, + LpValue::Vec3([1.0, 0.2, 0.1]), + )), + SlotData::Value(WithRevision::new( + revision, + LpValue::Vec3([0.1, 0.2, 1.0]), + )), + ], + )), + SlotData::Record(SlotRecord::with_revision(revision, Vec::new())), + ], + )), + ); + view.slots + .root_shapes + .insert(format!("node.{node_id}.state"), state_shape); + view.slots.roots.insert( + format!("node.{node_id}.state"), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![ + SlotData::Value(WithRevision::new( + revision, + LpValue::Product(ProductRef::visual(VisualProduct::new( + NodeId::new(node_id), + 0, + ))), + )), + SlotData::Value(WithRevision::new( + revision, + LpValue::Product(ProductRef::control(ControlProduct::new( + NodeId::new(node_id), + 1, + ControlExtent::new(2, 16), + ))), + )), + SlotData::Value(WithRevision::new(revision, LpValue::F32(3.333))), + ], + )), + ); + } + + fn install_structural_config_slots(view: &mut ProjectView, node_id: u32, revision: Revision) { + view.slots.root_shapes.clear(); + view.slots.roots.clear(); + view.slots.registry = Default::default(); + let shape = SlotShapeId::new(400); + view.slots + .registry + .register_dynamic_shape( + shape, + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new( + "mode", + SlotShape::Enum { + meta: SlotMeta::empty(), + encoding: SlotEnumEncoding::default(), + variants: vec![ + SlotVariantShape::new("manual", SlotShape::value(LpType::F32)) + .unwrap(), + ], + }, + ) + .unwrap(), + SlotFieldShape::new( + "optional", + SlotShape::Option { + meta: SlotMeta::empty(), + some: Box::new(SlotShape::value(LpType::F32)), + }, + ) + .unwrap(), + SlotFieldShape::new( + "entries", + SlotShape::Map { + meta: SlotMeta::empty(), + key: SlotMapKeyShape::String, + value: Box::new(SlotShape::value(LpType::F32)), + }, + ) + .unwrap(), + ], + }, + ) + .unwrap(); + view.slots + .root_shapes + .insert(format!("node.{node_id}.def"), shape); + + let mut map = SlotMapDyn::with_revision(revision, Default::default()); + map.entries.insert( + SlotMapKey::String("a".to_string()), + SlotData::Value(WithRevision::new(revision, LpValue::F32(1.0))), + ); + map.entries.insert( + SlotMapKey::String("b".to_string()), + SlotData::Value(WithRevision::new(revision, LpValue::F32(2.0))), + ); + + view.slots.roots.insert( + format!("node.{node_id}.def"), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![ + SlotData::Enum(SlotEnum::with_version( + revision, + SlotName::parse("manual").unwrap(), + SlotData::Value(WithRevision::new(revision, LpValue::F32(0.5))), + )), + SlotData::Option(SlotOptionDyn::none_with_version(revision)), + SlotData::Map(map), + ], + )), + ); + } + + fn set_brightness(view: &mut ProjectView, node_id: u32, revision: Revision, brightness: f32) { + let root = view + .slots + .roots + .get_mut(&format!("node.{node_id}.def")) + .expect("def root exists"); + let SlotData::Record(record) = root else { + panic!("expected def record"); + }; + record.fields[1] = SlotData::Value(WithRevision::new(revision, LpValue::F32(brightness))); + } + + fn install_map_slot(view: &mut ProjectView, node_id: u32, revision: Revision, keys: &[&str]) { + view.slots.root_shapes.clear(); + view.slots.roots.clear(); + view.slots.registry = Default::default(); + let shape = SlotShapeId::new(200); + view.slots + .registry + .register_dynamic_shape( + shape, + SlotShape::Map { + meta: SlotMeta::empty(), + key: SlotMapKeyShape::String, + value: Box::new(SlotShape::value(LpType::F32)), + }, + ) + .unwrap(); + view.slots + .root_shapes + .insert(format!("node.{node_id}.def"), shape); + + let mut map = SlotMapDyn::with_revision(revision, Default::default()); + for (index, key) in keys.iter().enumerate() { + map.entries.insert( + SlotMapKey::String((*key).to_string()), + SlotData::Value(WithRevision::new(revision, LpValue::F32(index as f32))), + ); + } + view.slots + .roots + .insert(format!("node.{node_id}.def"), SlotData::Map(map)); + } + + fn node_address(path: &str) -> ProjectNodeAddress { + ProjectNodeAddress::parse(path).unwrap() + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs new file mode 100644 index 000000000..34ed87e0d --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_op.rs @@ -0,0 +1,36 @@ +use core::any::Any; + +use crate::{ActionMeta, ActionPriority, ControllerOp}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectEditorOp { + Focus, +} + +impl ControllerOp for ProjectEditorOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::Focus => ActionMeta::new( + "Focus", + "Focus this project editor surface.", + ActionPriority::Secondary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs new file mode 100644 index 000000000..afa20228a --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_target.rs @@ -0,0 +1,206 @@ +use crate::{ControllerId, UiError}; + +use super::project_target_encoding::{ + DecodedProjectTarget, decode_typed_project_target, node_target_id, slot_target_id, +}; +use super::{ProjectController, ProjectNodeTarget, ProjectSlotAddress}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectEditorTarget { + NodeTree, + /// Typed node target used by the reconciled project editor model. + AddressedNode { + target: ProjectNodeTarget, + }, + /// Typed slot target used by the reconciled project editor model. + AddressedSlot { + target: ProjectNodeTarget, + slot: ProjectSlotAddress, + }, + Asset { + asset_id: String, + }, + Changes, + Bus, +} + +impl ProjectEditorTarget { + pub fn node_tree() -> Self { + Self::NodeTree + } + + pub fn addressed_node(target: ProjectNodeTarget) -> Self { + Self::AddressedNode { target } + } + + pub fn addressed_slot(target: ProjectNodeTarget, slot: ProjectSlotAddress) -> Self { + Self::AddressedSlot { target, slot } + } + + pub fn asset(asset_id: impl Into) -> Self { + Self::Asset { + asset_id: asset_id.into(), + } + } + + pub fn changes() -> Self { + Self::Changes + } + + pub fn bus() -> Self { + Self::Bus + } + + pub fn node_id(&self) -> ControllerId { + let root = project_node_id(); + match self { + Self::NodeTree => root.child("node_tree"), + Self::AddressedNode { target } => node_target_id(&root, target), + Self::AddressedSlot { target, slot } => slot_target_id(&root, target, slot), + Self::Asset { asset_id } => root.child("asset").child(asset_id.clone()), + Self::Changes => root.child("changes"), + Self::Bus => root.child("bus"), + } + } + + pub fn parse(node_id: &ControllerId) -> Result { + let root = project_node_id(); + let Some(tail) = node_id.strip_prefix(&root) else { + return Err(unsupported_project_target(node_id)); + }; + let segments = tail.iter().collect::>(); + if let Some(target) = decode_typed_project_target(&segments)? { + return Ok(match target { + DecodedProjectTarget::Node(target) => Self::addressed_node(target), + DecodedProjectTarget::Slot { node, slot } => Self::addressed_slot(node, slot), + }); + } + match segments.as_slice() { + ["node_tree"] => Ok(Self::NodeTree), + ["asset", asset_id] => Ok(Self::asset(*asset_id)), + ["changes"] => Ok(Self::Changes), + ["bus"] => Ok(Self::Bus), + _ => Err(unsupported_project_target(node_id)), + } + } +} + +fn project_node_id() -> ControllerId { + ControllerId::new(ProjectController::NODE_ID) +} + +fn unsupported_project_target(node_id: &ControllerId) -> UiError { + UiError::UnsupportedAction(format!("unknown project editor target {node_id}")) +} + +#[cfg(test)] +mod tests { + use lpc_model::{NodeId, SlotPath}; + + use super::*; + use crate::{ProjectNodeAddress, ProjectSlotRoot}; + + #[test] + fn constructors_build_expected_node_ids() { + assert_eq!( + ProjectEditorTarget::node_tree().node_id().as_str(), + "studio|project|node_tree" + ); + assert_eq!( + ProjectEditorTarget::addressed_node(node_target()) + .node_id() + .as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader" + ); + assert_eq!( + ProjectEditorTarget::addressed_slot(node_target(), slot_address()) + .node_id() + .as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|config.brightness" + ); + assert_eq!( + ProjectEditorTarget::asset("shader_main").node_id().as_str(), + "studio|project|asset|shader_main" + ); + assert_eq!( + ProjectEditorTarget::changes().node_id().as_str(), + "studio|project|changes" + ); + assert_eq!( + ProjectEditorTarget::bus().node_id().as_str(), + "studio|project|bus" + ); + } + + #[test] + fn parser_accepts_expected_project_targets() { + assert_eq!( + ProjectEditorTarget::parse(&ControllerId::new("studio|project|node_tree")).unwrap(), + ProjectEditorTarget::NodeTree + ); + assert_eq!( + ProjectEditorTarget::parse(&ControllerId::new("studio|project|asset|shader_main")) + .unwrap(), + ProjectEditorTarget::asset("shader_main") + ); + assert_eq!( + ProjectEditorTarget::parse(&ControllerId::new("studio|project|changes")).unwrap(), + ProjectEditorTarget::Changes + ); + assert_eq!( + ProjectEditorTarget::parse(&ControllerId::new("studio|project|bus")).unwrap(), + ProjectEditorTarget::Bus + ); + } + + #[test] + fn parser_accepts_typed_project_targets() { + assert_eq!( + ProjectEditorTarget::parse( + &ProjectEditorTarget::addressed_node(node_target()).node_id() + ) + .unwrap(), + ProjectEditorTarget::addressed_node(node_target()) + ); + assert_eq!( + ProjectEditorTarget::parse( + &ProjectEditorTarget::addressed_slot(node_target(), slot_address()).node_id() + ) + .unwrap(), + ProjectEditorTarget::addressed_slot(node_target(), slot_address()) + ); + } + + #[test] + fn parser_rejects_unknown_project_targets() { + let error = ProjectEditorTarget::parse(&ControllerId::new("studio|project|unknown")) + .expect_err("target should be rejected"); + + assert!(matches!(error, UiError::UnsupportedAction(_))); + assert!(error.message().contains("studio|project|unknown")); + } + + #[test] + fn parser_rejects_malformed_slot_target() { + let error = ProjectEditorTarget::parse(&ControllerId::new("studio|project|node|4|slot")) + .expect_err("target should be rejected"); + + assert!(matches!(error, UiError::UnsupportedAction(_))); + assert!(error.message().contains("studio|project|node|4|slot")); + } + + fn node_target() -> ProjectNodeTarget { + ProjectNodeTarget::new( + ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + NodeId::new(3), + ) + } + + fn slot_address() -> ProjectSlotAddress { + ProjectSlotAddress::new( + ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + ProjectSlotRoot::def(), + SlotPath::parse("config.brightness").unwrap(), + ) + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs new file mode 100644 index 000000000..9e93229d5 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_editor_view.rs @@ -0,0 +1,35 @@ +use crate::{ProjectNodeTreeView, ProjectSyncSummary, UiMetric, UiNodeView}; + +#[derive(Clone, Debug, PartialEq)] +pub struct ProjectEditorView { + pub project_id: String, + pub handle_id: u32, + pub sync: ProjectSyncSummary, + pub stats: Vec, + pub tree: ProjectNodeTreeView, + pub nodes: Vec, +} + +impl ProjectEditorView { + pub fn new( + project_id: impl Into, + handle_id: u32, + sync: ProjectSyncSummary, + stats: Vec, + tree: ProjectNodeTreeView, + nodes: Vec, + ) -> Self { + Self { + project_id: project_id.into(), + handle_id, + sync, + stats, + tree, + nodes, + } + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_inventory_summary.rs b/lp-app/lpa-studio-core/src/app/project/project_inventory_summary.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/project/project_inventory_summary.rs rename to lp-app/lpa-studio-core/src/app/project/project_inventory_summary.rs diff --git a/lp-app/lpa-studio-core/src/app/project/project_node_tree_view.rs b/lp-app/lpa-studio-core/src/app/project/project_node_tree_view.rs new file mode 100644 index 000000000..12ed24e22 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_node_tree_view.rs @@ -0,0 +1,79 @@ +use crate::UiAction; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectNodeTreeView { + pub roots: Vec, + pub total_count: usize, +} + +impl ProjectNodeTreeView { + pub fn new(roots: Vec, total_count: usize) -> Self { + Self { roots, total_count } + } + + pub fn is_empty(&self) -> bool { + self.roots.is_empty() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectNodeTreeItem { + pub node_id: String, + pub label: String, + pub kind: String, + pub status: ProjectNodeStatusView, + pub focused: bool, + pub action: UiAction, + pub children: Vec, +} + +impl ProjectNodeTreeItem { + pub fn new( + node_id: impl Into, + label: impl Into, + kind: impl Into, + status: ProjectNodeStatusView, + focused: bool, + action: UiAction, + children: Vec, + ) -> Self { + Self { + node_id: node_id.into(), + label: label.into(), + kind: kind.into(), + status, + focused, + action, + children, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectNodeStatusView { + pub label: String, + pub detail: Option, + pub tone: ProjectNodeStatusTone, +} + +impl ProjectNodeStatusView { + pub fn new( + label: impl Into, + detail: Option, + tone: ProjectNodeStatusTone, + ) -> Self { + Self { + label: label.into(), + detail, + tone, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectNodeStatusTone { + Neutral, + Good, + Warning, + Error, +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_op.rs b/lp-app/lpa-studio-core/src/app/project/project_op.rs similarity index 77% rename from lp-app/lpa-studio-ux/src/nodes/project/project_op.rs rename to lp-app/lpa-studio-core/src/app/project/project_op.rs index ea96a406f..7fefe9d52 100644 --- a/lp-app/lpa-studio-ux/src/nodes/project/project_op.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_op.rs @@ -1,16 +1,17 @@ use core::any::Any; -use crate::{ActionMeta, ActionPriority, UxOp}; +use crate::{ActionMeta, ActionPriority, ControllerOp}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ProjectOp { ConnectRunningProject, ConnectLoadedProject { handle_id: u32 }, LoadDemoProject, + RefreshProject, DisconnectProject, } -impl UxOp for ProjectOp { +impl ControllerOp for ProjectOp { fn default_action_meta(&self) -> ActionMeta { match self { Self::ConnectRunningProject => ActionMeta::new( @@ -28,6 +29,11 @@ impl UxOp for ProjectOp { "Upload and run the built-in demo project.", ActionPriority::Secondary, ), + Self::RefreshProject => ActionMeta::new( + "Refresh project", + "Refresh Studio's synced project view.", + ActionPriority::Secondary, + ), Self::DisconnectProject => ActionMeta::new( "Disconnect project", "Detach Studio from the current project without stopping it on the device.", @@ -36,11 +42,11 @@ impl UxOp for ProjectOp { } } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } diff --git a/lp-app/lpa-studio-core/src/app/project/project_runtime_summary.rs b/lp-app/lpa-studio-core/src/app/project/project_runtime_summary.rs new file mode 100644 index 000000000..7894005f6 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_runtime_summary.rs @@ -0,0 +1,28 @@ +use lpc_wire::RuntimeReadResult; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectRuntimeSummary { + pub frame_num: u64, + pub frame_delta_ms: u32, + pub runtime_buffer_count: u32, + pub free_bytes: Option, + pub used_bytes: Option, + pub total_bytes: Option, +} + +impl From<&RuntimeReadResult> for ProjectRuntimeSummary { + fn from(runtime: &RuntimeReadResult) -> Self { + let memory = runtime + .server + .as_ref() + .and_then(|server| server.memory.as_ref()); + Self { + frame_num: runtime.project.frame_num, + frame_delta_ms: runtime.project.frame_delta_ms, + runtime_buffer_count: runtime.project.runtime_buffer_count, + free_bytes: memory.map(|memory| u64::from(memory.free_bytes)), + used_bytes: memory.map(|memory| u64::from(memory.used_bytes)), + total_bytes: memory.map(|memory| u64::from(memory.total_bytes)), + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_snapshot.rs b/lp-app/lpa-studio-core/src/app/project/project_snapshot.rs new file mode 100644 index 000000000..7ecdf6e3a --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_snapshot.rs @@ -0,0 +1,13 @@ +use crate::{ProjectState, ProjectSyncSummary}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectSnapshot { + pub state: ProjectState, + pub sync: Option, +} + +impl ProjectSnapshot { + pub fn new(state: ProjectState, sync: Option) -> Self { + Self { state, sync } + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_state.rs b/lp-app/lpa-studio-core/src/app/project/project_state.rs similarity index 92% rename from lp-app/lpa-studio-ux/src/nodes/project/project_state.rs rename to lp-app/lpa-studio-core/src/app/project/project_state.rs index 9dcf3e3a3..fc1ca7500 100644 --- a/lp-app/lpa-studio-ux/src/nodes/project/project_state.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_state.rs @@ -1,4 +1,4 @@ -use crate::{LoadedProjectChoice, ProgressState, ProjectInventorySummary, UxIssue}; +use crate::{LoadedProjectChoice, ProgressState, ProjectInventorySummary, UiIssue}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ProjectState { @@ -18,6 +18,6 @@ pub enum ProjectState { inventory: ProjectInventorySummary, }, Failed { - issue: UxIssue, + issue: UiIssue, }, } diff --git a/lp-app/lpa-studio-core/src/app/project/project_sync.rs b/lp-app/lpa-studio-core/src/app/project/project_sync.rs new file mode 100644 index 000000000..1c6434afd --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_sync.rs @@ -0,0 +1,693 @@ +use std::collections::BTreeMap; + +use lpc_model::{ControlDisplayLayout, Revision, SlotShapeId}; +use lpc_view::{ProjectView, apply_project_read_response}; +use lpc_wire::{ + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, NodeReadQuery, NodeReadSelection, ProjectProbeRequest, + ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, + ProjectReadResult, ReadLevel, RenderProductProbeRequest, RenderProductProbeResult, + ResourcePayloadRead, ResourceReadQuery, RuntimeReadQuery, ShapeReadQuery, + WireChannelSampleFormat, WireTextureFormat, +}; + +use crate::{ + ProjectRuntimeSummary, ProjectSyncPhase, ProjectSyncSummary, UiControlProductPreview, + UiControlSampleFormat, UiError, UiIssue, UiProductPreview, UiProductPreviewFrame, UiProductRef, +}; + +// Keep shape pages small. Some shape definitions include other shapes and can +// overflow the firmware's 16KB internal JSON buffer, which has caused project +// sync parse errors/crashes. Raise this only after the server buffer/streaming +// limitation is fixed. +const SHAPE_SYNC_PAGE_LIMIT: u32 = 4; +const SHAPE_SYNC_MAX_PAGES: u32 = 256; +const VISUAL_PRODUCT_PREVIEW_FRAME: UiProductPreviewFrame = UiProductPreviewFrame::VISUAL_DEFAULT; + +pub struct ProjectSync { + view: ProjectView, + phase: ProjectSyncPhase, + shape_cursor: Option, + shape_page_count: u32, + shapes_complete: bool, + product_previews: BTreeMap, + requested_product_previews: Vec, + issue: Option, +} + +impl ProjectSync { + pub fn new() -> Self { + Self { + view: ProjectView::new(), + phase: ProjectSyncPhase::Empty, + shape_cursor: None, + shape_page_count: 0, + shapes_complete: false, + product_previews: BTreeMap::new(), + requested_product_previews: Vec::new(), + issue: None, + } + } + + pub fn begin_initial_sync(&mut self) { + *self = Self { + phase: ProjectSyncPhase::SyncingShapes, + ..Self::new() + }; + } + + pub fn begin_refresh(&mut self) { + self.phase = ProjectSyncPhase::SyncingProject; + self.issue = None; + } + + pub fn summary(&self) -> ProjectSyncSummary { + ProjectSyncSummary { + phase: self.phase, + revision: self.view.revision.0, + node_count: self.view.tree.nodes.len(), + root_node_count: self + .view + .tree + .nodes + .values() + .filter(|entry| entry.parent.is_none()) + .count(), + slot_root_count: self.view.slots.roots.len(), + resource_count: self.view.resource_cache.summary_count(), + shape_count: self.view.slots.registry.iter().count(), + shapes_complete: self.shapes_complete, + runtime: self.view.runtime.as_ref().map(ProjectRuntimeSummary::from), + issue: self.issue.clone(), + } + } + + /// Latest protocol/client project mirror. + pub fn project_view(&self) -> &ProjectView { + &self.view + } + + /// Latest cached preview state for a produced product. + pub fn product_preview(&self, product: &UiProductRef) -> Option<&UiProductPreview> { + self.product_previews.get(product) + } + + pub fn is_ready(&self) -> bool { + self.phase == ProjectSyncPhase::Ready + } + + pub fn is_failed(&self) -> bool { + self.phase == ProjectSyncPhase::Failed + } + + pub fn is_syncing(&self) -> bool { + matches!( + self.phase, + ProjectSyncPhase::SyncingShapes | ProjectSyncPhase::SyncingProject + ) + } + + pub fn needs_shape_sync(&self) -> bool { + !self.shapes_complete + } + + pub fn shape_sync_request(&self) -> Result { + if self.shape_page_count >= SHAPE_SYNC_MAX_PAGES { + return Err(UiError::Protocol(format!( + "shape sync exceeded {SHAPE_SYNC_MAX_PAGES} pages" + ))); + } + Ok(shape_sync_request(self.shape_cursor)) + } + + pub fn initial_project_read_request( + &mut self, + products: Vec, + ) -> ProjectReadRequest { + self.phase = ProjectSyncPhase::SyncingProject; + project_read_request(None, true, self.product_probe_requests(products)) + } + + pub fn refresh_project_read_request( + &mut self, + products: Vec, + ) -> ProjectReadRequest { + self.begin_refresh(); + let since = (self.view.revision != Revision::default()).then_some(self.view.revision); + // Runtime state slots carry live values/products, so refresh snapshots + // include slots even when the tree itself only changes by revision. + let include_slots = true; + project_read_request(since, include_slots, self.product_probe_requests(products)) + } + + pub fn apply_shape_sync_response( + &mut self, + response: ProjectReadResponse, + ) -> Result<(), UiError> { + let mut saw_shapes = false; + for result in response.results { + if let ProjectReadResult::Shapes(shapes) = result { + saw_shapes = true; + if let Some(registry) = shapes.registry { + self.view.slots.apply_registry_page(registry); + } + self.shapes_complete = shapes.complete; + self.shape_cursor = shapes.next; + } + } + if !saw_shapes { + return Err(UiError::Protocol( + "shape sync response did not include shapes".to_string(), + )); + } + self.shape_page_count = self.shape_page_count.saturating_add(1); + Ok(()) + } + + pub fn apply_project_read_response( + &mut self, + response: ProjectReadResponse, + ) -> Result<(), UiError> { + self.apply_product_probe_results(&response.probes); + apply_project_read_response(&mut self.view, response) + .map_err(|error| UiError::Protocol(error.to_string()))?; + self.phase = ProjectSyncPhase::Ready; + self.issue = None; + Ok(()) + } + + pub fn fail(&mut self, issue: impl Into) { + self.phase = ProjectSyncPhase::Failed; + self.issue = Some(UiIssue::new(issue)); + } + + fn product_probe_requests(&mut self, products: Vec) -> Vec { + self.requested_product_previews = products; + for product in &self.requested_product_previews { + self.product_previews + .entry(*product) + .or_insert(UiProductPreview::Pending); + } + self.requested_product_previews + .iter() + .copied() + .filter_map(|product| match product { + UiProductRef::Visual { .. } => product.visual_product().map(|product| { + ProjectProbeRequest::RenderProduct(RenderProductProbeRequest { + product, + width: VISUAL_PRODUCT_PREVIEW_FRAME.width, + height: VISUAL_PRODUCT_PREVIEW_FRAME.height, + format: WireTextureFormat::Srgb8, + }) + }), + UiProductRef::Control { .. } => product.control_product().map(|control| { + ProjectProbeRequest::ControlProduct(ControlProductProbeRequest { + product: control, + sample_format: WireChannelSampleFormat::U16, + display_layout: self.display_layout_read_for(product), + }) + }), + }) + .collect() + } + + fn apply_product_probe_results(&mut self, probes: &[ProjectProbeResult]) { + let requested = core::mem::take(&mut self.requested_product_previews); + for (index, probe) in probes.iter().enumerate() { + let fallback_key = requested.get(index).copied(); + if let Some((product, preview)) = self.product_preview_from_probe(probe, fallback_key) { + self.product_previews.insert(product, preview); + } + } + } + + fn display_layout_read_for(&self, product: UiProductRef) -> ControlDisplayLayoutRead { + match self + .product_previews + .get(&product) + .and_then(control_preview_display_layout) + .map(ControlDisplayLayout::revision) + { + Some(revision) => ControlDisplayLayoutRead::IfChanged { + known_revision: Some(revision), + }, + None => ControlDisplayLayoutRead::Always, + } + } + + fn product_preview_from_probe( + &self, + probe: &ProjectProbeResult, + fallback_key: Option, + ) -> Option<(UiProductRef, UiProductPreview)> { + match probe { + ProjectProbeResult::ControlProduct(ControlProductProbeResult::Preview { + product, + revision, + extent, + sample_format: WireChannelSampleFormat::U16, + sample_layout, + display_layout, + bytes, + }) => { + let product_ref = UiProductRef::from_control_product(*product); + let cached = self.product_previews.get(&product_ref); + let display_layout = + display_layout_from_probe_result(display_layout, cached).cloned(); + Some(( + product_ref, + UiProductPreview::ControlNative(UiControlProductPreview { + revision: revision.0, + extent: *extent, + sample_format: UiControlSampleFormat::U16, + sample_layout: sample_layout.clone(), + display_layout, + bytes: bytes.clone(), + }), + )) + } + ProjectProbeResult::ControlProduct(ControlProductProbeResult::Preview { + product, + sample_format, + .. + }) => Some(( + UiProductRef::from_control_product(*product), + UiProductPreview::Unsupported { + reason: format!( + "control preview sample format {sample_format:?} is not supported by Studio" + ), + }, + )), + ProjectProbeResult::ControlProduct(ControlProductProbeResult::Unsupported { + product, + reason, + }) => Some(( + UiProductRef::from_control_product(*product), + UiProductPreview::Unsupported { + reason: reason.clone(), + }, + )), + ProjectProbeResult::ControlProduct(ControlProductProbeResult::Error { + product, + message, + }) => Some(( + UiProductRef::from_control_product(*product), + UiProductPreview::Error { + message: message.clone(), + }, + )), + _ => product_preview_from_probe(probe, fallback_key), + } + } +} + +impl Default for ProjectSync { + fn default() -> Self { + Self::new() + } +} + +pub fn shape_sync_request(after: Option) -> ProjectReadRequest { + ProjectReadRequest { + since: None, + queries: Vec::from([ProjectReadQuery::Shapes(ShapeReadQuery { + level: ReadLevel::Detail, + after, + limit: Some(SHAPE_SYNC_PAGE_LIMIT), + })]), + probes: Vec::new(), + } +} + +pub fn project_read_request( + since: Option, + include_slots: bool, + probes: Vec, +) -> ProjectReadRequest { + ProjectReadRequest { + since, + queries: Vec::from([ + ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: NodeReadSelection::All, + include_slots, + }), + ProjectReadQuery::Resources(ResourceReadQuery { + level: ReadLevel::Summary, + payloads: ResourcePayloadRead::None, + }), + ProjectReadQuery::Runtime(RuntimeReadQuery), + ]), + probes, + } +} + +fn product_preview_from_probe( + probe: &ProjectProbeResult, + fallback_key: Option, +) -> Option<(UiProductRef, UiProductPreview)> { + match probe { + ProjectProbeResult::RenderProduct(RenderProductProbeResult::Texture { + product, + revision, + width, + height, + format: WireTextureFormat::Srgb8, + bytes, + }) => Some(( + UiProductRef::from_visual_product(*product), + UiProductPreview::VisualSrgb8 { + width: *width, + height: *height, + revision: revision.0, + bytes: bytes.clone(), + }, + )), + ProjectProbeResult::RenderProduct(RenderProductProbeResult::Texture { + product, + format, + .. + }) => Some(( + UiProductRef::from_visual_product(*product), + UiProductPreview::Unsupported { + reason: format!("visual preview format {format:?} is not supported by Studio"), + }, + )), + ProjectProbeResult::RenderProduct(RenderProductProbeResult::Unsupported { reason }) => { + fallback_key.map(|product| { + ( + product, + UiProductPreview::Unsupported { + reason: reason.clone(), + }, + ) + }) + } + ProjectProbeResult::RenderProduct(RenderProductProbeResult::Error { message }) => { + fallback_key.map(|product| { + ( + product, + UiProductPreview::Error { + message: message.clone(), + }, + ) + }) + } + ProjectProbeResult::ControlProduct(_) => None, + ProjectProbeResult::ExplainSlot(_) => None, + } +} + +fn control_preview_display_layout(preview: &UiProductPreview) -> Option<&ControlDisplayLayout> { + match preview { + UiProductPreview::ControlNative(preview) => preview.display_layout.as_ref(), + _ => None, + } +} + +fn display_layout_from_probe_result<'a>( + result: &'a ControlDisplayLayoutProbeResult, + cached: Option<&'a UiProductPreview>, +) -> Option<&'a ControlDisplayLayout> { + match result { + ControlDisplayLayoutProbeResult::Layout(layout) => Some(layout), + ControlDisplayLayoutProbeResult::Unchanged { revision } => cached + .and_then(control_preview_display_layout) + .filter(|layout| layout.revision() == *revision), + ControlDisplayLayoutProbeResult::Omitted => cached.and_then(control_preview_display_layout), + ControlDisplayLayoutProbeResult::Unsupported { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_model::{ + ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlProduct, + ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, NodeId, VisualProduct, + }; + + #[test] + fn shape_sync_request_uses_safe_page_limit_and_cursor() { + let after = SlotShapeId::new(7); + let request = shape_sync_request(Some(after)); + + assert_eq!(request.since, None); + assert!(request.probes.is_empty()); + assert_eq!(request.queries.len(), 1); + assert_eq!( + request.queries[0], + ProjectReadQuery::Shapes(ShapeReadQuery { + level: ReadLevel::Detail, + after: Some(after), + limit: Some(4), + }) + ); + } + + #[test] + fn project_read_request_includes_nodes_resources_and_runtime() { + let request = project_read_request(Some(Revision::new(12)), true, Vec::new()); + + assert_eq!(request.since, Some(Revision::new(12))); + assert_eq!(request.queries.len(), 3); + assert_eq!( + request.queries[0], + ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: NodeReadSelection::All, + include_slots: true, + }) + ); + assert_eq!( + request.queries[1], + ProjectReadQuery::Resources(ResourceReadQuery { + level: ReadLevel::Summary, + payloads: ResourcePayloadRead::None, + }) + ); + assert_eq!( + request.queries[2], + ProjectReadQuery::Runtime(RuntimeReadQuery) + ); + assert!(request.probes.is_empty()); + } + + #[test] + fn refresh_request_includes_slots_when_roots_are_missing() { + let mut sync = ProjectSync::new(); + sync.view.revision = Revision::new(9); + + let request = sync.refresh_project_read_request(Vec::new()); + + assert_eq!(request.since, Some(Revision::new(9))); + assert_eq!( + request.queries[0], + ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: NodeReadSelection::All, + include_slots: true, + }) + ); + } + + #[test] + fn refresh_request_includes_slots_after_roots_exist() { + let mut sync = ProjectSync::new(); + sync.view.revision = Revision::new(9); + sync.view.slots.roots.insert( + "node.1.state".to_string(), + lpc_model::SlotData::Unit { + revision: Revision::new(9), + }, + ); + + let request = sync.refresh_project_read_request(Vec::new()); + + assert_eq!( + request.queries[0], + ProjectReadQuery::Nodes(NodeReadQuery { + level: ReadLevel::Detail, + nodes: NodeReadSelection::All, + include_slots: true, + }) + ); + } + + #[test] + fn refresh_request_includes_visual_product_probes() { + let mut sync = ProjectSync::new(); + let product = VisualProduct::new(NodeId::new(7), 2); + + let request = + sync.refresh_project_read_request(vec![UiProductRef::from_visual_product(product)]); + + assert_eq!(request.probes.len(), 1); + assert_eq!( + request.probes[0], + ProjectProbeRequest::RenderProduct(RenderProductProbeRequest { + product, + width: VISUAL_PRODUCT_PREVIEW_FRAME.width, + height: VISUAL_PRODUCT_PREVIEW_FRAME.height, + format: WireTextureFormat::Srgb8, + }) + ); + assert_eq!( + sync.product_preview(&UiProductRef::from_visual_product(product)), + Some(&UiProductPreview::Pending) + ); + } + + #[test] + fn project_read_response_caches_visual_product_preview() { + let mut sync = ProjectSync::new(); + let product = VisualProduct::new(NodeId::new(7), 2); + let _ = sync.refresh_project_read_request(vec![UiProductRef::from_visual_product(product)]); + let bytes = vec![1, 2, 3, 4, 5, 6]; + + sync.apply_project_read_response(ProjectReadResponse { + revision: Revision::new(9), + results: Vec::new(), + probes: vec![ProjectProbeResult::RenderProduct( + RenderProductProbeResult::Texture { + product, + revision: Revision::new(8), + width: 1, + height: 2, + format: WireTextureFormat::Srgb8, + bytes: bytes.clone(), + }, + )], + }) + .unwrap(); + + assert_eq!( + sync.product_preview(&UiProductRef::from_visual_product(product)), + Some(&UiProductPreview::VisualSrgb8 { + width: 1, + height: 2, + revision: 8, + bytes, + }) + ); + } + + #[test] + fn refresh_request_includes_control_product_probes() { + let mut sync = ProjectSync::new(); + let product = ControlProduct::new(NodeId::new(7), 2, ControlExtent::new(1, 3)); + let product_ref = UiProductRef::from_control_product(product); + + let request = sync.refresh_project_read_request(vec![product_ref]); + + assert_eq!( + request.probes, + vec![ProjectProbeRequest::ControlProduct( + ControlProductProbeRequest { + product, + sample_format: WireChannelSampleFormat::U16, + display_layout: ControlDisplayLayoutRead::Always, + }, + )] + ); + assert_eq!( + sync.product_preview(&product_ref), + Some(&UiProductPreview::Pending) + ); + } + + #[test] + fn control_product_preview_reuses_cached_display_layout_revision() { + let mut sync = ProjectSync::new(); + let product = ControlProduct::new(NodeId::new(7), 2, ControlExtent::new(1, 3)); + let product_ref = UiProductRef::from_control_product(product); + let sample_layout = ControlSampleLayout { + spans: vec![ControlSampleSpan { + row: 0, + start: 0, + len: 3, + encoding: ControlSampleEncoding::RgbPixels { + count: 1, + color_order: lpc_model::ColorOrder::Rgb, + }, + }], + }; + let display_layout = ControlDisplayLayout::Layout2d(ControlLayout2d::new( + Revision::new(12), + 16, + 16, + vec![ControlLamp2d { + lamp_index: 0, + sample_start: 0, + center: [0.5, 0.5], + radius: 0.1, + }], + )); + let first_bytes = vec![0, 0, 255, 255, 0, 0]; + let _ = sync.refresh_project_read_request(vec![product_ref]); + + sync.apply_project_read_response(ProjectReadResponse { + revision: Revision::new(9), + results: Vec::new(), + probes: vec![ProjectProbeResult::ControlProduct( + ControlProductProbeResult::Preview { + product, + revision: Revision::new(9), + extent: product.preferred_extent(), + sample_format: WireChannelSampleFormat::U16, + sample_layout: sample_layout.clone(), + display_layout: ControlDisplayLayoutProbeResult::Layout(display_layout.clone()), + bytes: first_bytes, + }, + )], + }) + .unwrap(); + + let request = sync.refresh_project_read_request(vec![product_ref]); + + assert_eq!( + request.probes, + vec![ProjectProbeRequest::ControlProduct( + ControlProductProbeRequest { + product, + sample_format: WireChannelSampleFormat::U16, + display_layout: ControlDisplayLayoutRead::IfChanged { + known_revision: Some(Revision::new(12)), + }, + }, + )] + ); + + let second_bytes = vec![255, 255, 0, 0, 0, 0]; + sync.apply_project_read_response(ProjectReadResponse { + revision: Revision::new(10), + results: Vec::new(), + probes: vec![ProjectProbeResult::ControlProduct( + ControlProductProbeResult::Preview { + product, + revision: Revision::new(10), + extent: product.preferred_extent(), + sample_format: WireChannelSampleFormat::U16, + sample_layout: sample_layout.clone(), + display_layout: ControlDisplayLayoutProbeResult::Unchanged { + revision: Revision::new(12), + }, + bytes: second_bytes.clone(), + }, + )], + }) + .unwrap(); + + assert_eq!( + sync.product_preview(&product_ref), + Some(&UiProductPreview::ControlNative(UiControlProductPreview { + revision: 10, + extent: product.preferred_extent(), + sample_format: UiControlSampleFormat::U16, + sample_layout, + display_layout: Some(display_layout), + bytes: second_bytes, + })) + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_sync_phase.rs b/lp-app/lpa-studio-core/src/app/project/project_sync_phase.rs new file mode 100644 index 000000000..55f435e2b --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_sync_phase.rs @@ -0,0 +1,9 @@ +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum ProjectSyncPhase { + #[default] + Empty, + SyncingShapes, + SyncingProject, + Ready, + Failed, +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_sync_run.rs b/lp-app/lpa-studio-core/src/app/project/project_sync_run.rs new file mode 100644 index 000000000..662fd52f1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_sync_run.rs @@ -0,0 +1,19 @@ +use crate::UiLogEntry; + +pub struct ProjectSyncRun { + pub logs: Vec, + pub synced: bool, +} + +impl ProjectSyncRun { + pub fn synced(logs: Vec) -> Self { + Self { logs, synced: true } + } + + pub fn failed(logs: Vec) -> Self { + Self { + logs, + synced: false, + } + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_sync_summary.rs b/lp-app/lpa-studio-core/src/app/project/project_sync_summary.rs new file mode 100644 index 000000000..24b1721c1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_sync_summary.rs @@ -0,0 +1,15 @@ +use crate::{ProjectRuntimeSummary, ProjectSyncPhase, UiIssue}; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectSyncSummary { + pub phase: ProjectSyncPhase, + pub revision: i64, + pub node_count: usize, + pub root_node_count: usize, + pub slot_root_count: usize, + pub resource_count: usize, + pub shape_count: usize, + pub shapes_complete: bool, + pub runtime: Option, + pub issue: Option, +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs b/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs new file mode 100644 index 000000000..d3a7ce0a8 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_target_encoding.rs @@ -0,0 +1,323 @@ +//! Readable `ControllerId` encoding for project editor action targets. + +use lpc_model::{NodeId, SlotPath, TreePath}; + +use crate::{ + ControllerId, ProjectNodeAddress, ProjectNodeTarget, ProjectSlotAddress, ProjectSlotRoot, + UiError, +}; + +/// Typed project target decoded from a controller id tail. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DecodedProjectTarget { + Node(ProjectNodeTarget), + Slot { + node: ProjectNodeTarget, + slot: ProjectSlotAddress, + }, +} + +/// Build a controller id for a typed node target. +pub fn node_target_id(root: &ControllerId, target: &ProjectNodeTarget) -> ControllerId { + root.child("node") + .child("nid") + .child(target.node_id.to_string()) + .child("path") + .child(encode_payload(&target.address.path().to_string())) +} + +/// Build a controller id for a typed slot target. +pub fn slot_target_id( + root: &ControllerId, + target: &ProjectNodeTarget, + slot: &ProjectSlotAddress, +) -> ControllerId { + debug_assert_eq!(&target.address, &slot.node); + let id = node_target_id(root, target) + .child("slot") + .child(encode_slot_root(&slot.root)); + if slot.path.is_root() { + id.child("root") + } else { + id.child("path") + .child(encode_payload(&slot.path.to_string())) + } +} + +/// Decode a typed project target from segments after `studio|project`. +pub fn decode_typed_project_target( + segments: &[&str], +) -> Result, UiError> { + if !matches!(segments, ["node", "nid", ..]) { + return Ok(None); + } + + let (node, consumed) = decode_node_prefix(segments)?; + match &segments[consumed..] { + [] => Ok(Some(DecodedProjectTarget::Node(node))), + ["slot", root, "root"] => Ok(Some(DecodedProjectTarget::Slot { + slot: ProjectSlotAddress::root(node.address.clone(), decode_slot_root(root)?), + node, + })), + ["slot", root, "path", path] => { + let path = decode_payload(path)?; + let path = SlotPath::parse(&path).map_err(|error| { + project_target_error(format!("invalid project slot path `{path}`: {error}")) + })?; + Ok(Some(DecodedProjectTarget::Slot { + slot: ProjectSlotAddress::new(node.address.clone(), decode_slot_root(root)?, path), + node, + })) + } + _ => Err(project_target_error("malformed typed project target")), + } +} + +fn decode_node_prefix(segments: &[&str]) -> Result<(ProjectNodeTarget, usize), UiError> { + let ["node", "nid", node_id, "path", path, ..] = segments else { + return Err(project_target_error("malformed typed project node target")); + }; + let node_id = node_id.parse::().map_err(|error| { + project_target_error(format!("invalid project node id `{node_id}`: {error}")) + })?; + let path = decode_payload(path)?; + let path = TreePath::parse(&path).map_err(|error| { + project_target_error(format!("invalid project node path `{path}`: {error}")) + })?; + Ok(( + ProjectNodeTarget::new(ProjectNodeAddress::new(path), NodeId::new(node_id)), + 5, + )) +} + +fn encode_slot_root(root: &ProjectSlotRoot) -> String { + match root { + ProjectSlotRoot::Def => "def".to_string(), + ProjectSlotRoot::State => "state".to_string(), + ProjectSlotRoot::Other(name) => format!("other:{}", encode_payload(name)), + } +} + +fn decode_slot_root(value: &str) -> Result { + match value { + "def" => Ok(ProjectSlotRoot::Def), + "state" => Ok(ProjectSlotRoot::State), + value if value.starts_with("other:") => { + let encoded = value.trim_start_matches("other:"); + Ok(ProjectSlotRoot::Other(decode_payload(encoded)?)) + } + value => Err(project_target_error(format!( + "unknown project slot root `{value}`" + ))), + } +} + +fn encode_payload(value: &str) -> String { + let mut output = String::new(); + for byte in value.as_bytes() { + let byte = *byte; + if byte == b'|' || byte == b'%' || !byte.is_ascii() || byte < 0x20 { + output.push('%'); + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } else { + output.push(byte as char); + } + } + output +} + +fn decode_payload(value: &str) -> Result { + let mut bytes = Vec::new(); + let input = value.as_bytes(); + let mut index = 0; + while index < input.len() { + if input[index] != b'%' { + bytes.push(input[index]); + index += 1; + continue; + } + if index + 2 >= input.len() { + return Err(project_target_error(format!( + "invalid percent escape in `{value}`" + ))); + } + let high = decode_hex(input[index + 1]) + .ok_or_else(|| project_target_error(format!("invalid percent escape in `{value}`")))?; + let low = decode_hex(input[index + 2]) + .ok_or_else(|| project_target_error(format!("invalid percent escape in `{value}`")))?; + bytes.push((high << 4) | low); + index += 3; + } + String::from_utf8(bytes) + .map_err(|error| project_target_error(format!("invalid utf-8 target payload: {error}"))) +} + +fn decode_hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn project_target_error(message: impl Into) -> UiError { + UiError::UnsupportedAction(message.into()) +} + +const HEX: &[u8; 16] = b"0123456789ABCDEF"; + +#[cfg(test)] +mod tests { + use lpc_model::{NodeId, SlotMapKey, SlotPath, SlotPathSegment}; + + use super::*; + + #[test] + fn node_target_round_trips_with_node_id_and_path() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let id = node_target_id(&root, &target); + + assert_eq!( + id.as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader" + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Node(target)) + ); + } + + #[test] + fn root_slot_target_round_trips() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let slot = ProjectSlotAddress::root(target.address.clone(), ProjectSlotRoot::def()); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + id.as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|root" + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn field_slot_path_target_round_trips() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let slot = ProjectSlotAddress::new( + target.address.clone(), + ProjectSlotRoot::def(), + SlotPath::parse("config.brightness").unwrap(), + ); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + id.as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|config.brightness" + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn string_map_key_with_dots_round_trips() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let path = SlotPath::parse(r#"params["phase.offset"].label"#).unwrap(); + let slot = ProjectSlotAddress::new(target.address.clone(), ProjectSlotRoot::def(), path); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + id.as_str(), + r#"studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|params["phase.offset"].label"# + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn payload_escaping_round_trips_pipe_and_percent() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let path = SlotPath::parse("params") + .unwrap() + .child_segment(SlotPathSegment::Key(SlotMapKey::String("a|b%".to_string()))); + let slot = ProjectSlotAddress::new(target.address.clone(), ProjectSlotRoot::def(), path); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + id.as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|def|path|params[a%7Cb%25]" + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn numeric_map_key_round_trips() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let path = SlotPath::parse("touches") + .unwrap() + .child_segment(SlotPathSegment::Key(SlotMapKey::U32(2))); + let slot = ProjectSlotAddress::new(target.address.clone(), ProjectSlotRoot::state(), path); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn other_slot_root_round_trips() { + let root = ControllerId::new("studio|project"); + let target = node_target(3, "/demo.project/orbit.shader"); + let slot = ProjectSlotAddress::root( + target.address.clone(), + ProjectSlotRoot::Other("runtime|debug%root".to_string()), + ); + let id = slot_target_id(&root, &target, &slot); + + assert_eq!( + id.as_str(), + "studio|project|node|nid|3|path|/demo.project/orbit.shader|slot|other:runtime%7Cdebug%25root|root" + ); + assert_eq!( + decode_typed_project_target(&tail(&id)).unwrap(), + Some(DecodedProjectTarget::Slot { node: target, slot }) + ); + } + + #[test] + fn malformed_target_is_rejected() { + let error = + decode_typed_project_target(&["node", "nid", "3", "path"]).expect_err("invalid"); + + assert!(matches!(error, UiError::UnsupportedAction(_))); + } + + fn tail(id: &ControllerId) -> Vec<&str> { + id.strip_prefix(&ControllerId::new("studio|project")) + .unwrap() + .iter() + .collect() + } + + fn node_target(id: u32, path: &str) -> ProjectNodeTarget { + ProjectNodeTarget::new(ProjectNodeAddress::parse(path).unwrap(), NodeId::new(id)) + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/project_value_format.rs b/lp-app/lpa-studio-core/src/app/project/project_value_format.rs new file mode 100644 index 000000000..9005392f5 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/project_value_format.rs @@ -0,0 +1,158 @@ +use lpc_model::{LpValue, ProductRef, ResourceRef, SlotMapKey}; + +pub fn format_lp_value(value: &LpValue) -> String { + match value { + LpValue::Unset => "unset".to_string(), + LpValue::String(value) => value.clone(), + LpValue::I32(value) => value.to_string(), + LpValue::U32(value) => value.to_string(), + LpValue::F32(value) => format_float(*value), + LpValue::Bool(value) => value.to_string(), + LpValue::Vec2(value) => format_float_array(value), + LpValue::Vec3(value) => format_float_array(value), + LpValue::Vec4(value) => format_float_array(value), + LpValue::IVec2(value) => format_int_array(value), + LpValue::IVec3(value) => format_int_array(value), + LpValue::IVec4(value) => format_int_array(value), + LpValue::UVec2(value) => format_int_array(value), + LpValue::UVec3(value) => format_int_array(value), + LpValue::UVec4(value) => format_int_array(value), + LpValue::BVec2(value) => format_int_array(value), + LpValue::BVec3(value) => format_int_array(value), + LpValue::BVec4(value) => format_int_array(value), + LpValue::Mat2x2(value) => format_matrix(value), + LpValue::Mat3x3(value) => format_matrix(value), + LpValue::Mat4x4(value) => format_matrix(value), + LpValue::Array(values) => { + let values = values + .iter() + .map(format_lp_value) + .collect::>() + .join(", "); + format!("[{values}]") + } + LpValue::Struct { name, fields } => { + let fields = fields + .iter() + .map(|(name, value)| format!("{name}: {}", format_lp_value(value))) + .collect::>() + .join(", "); + match name { + Some(name) => format!("{name} {{ {fields} }}"), + None => format!("{{ {fields} }}"), + } + } + LpValue::Enum { variant, payload } => match payload { + Some(payload) => format!("variant {variant}({})", format_lp_value(payload)), + None => format!("variant {variant}"), + }, + LpValue::Resource(resource) => format_resource_ref(*resource), + LpValue::Product(product) => format_product_ref(*product), + } +} + +pub fn format_slot_map_key(key: &SlotMapKey) -> String { + match key { + SlotMapKey::String(value) => value.clone(), + SlotMapKey::I32(value) => value.to_string(), + SlotMapKey::U32(value) => value.to_string(), + } +} + +fn format_resource_ref(resource: ResourceRef) -> String { + format!("resource {:?}:{}", resource.domain, resource.id) +} + +fn format_product_ref(product: ProductRef) -> String { + match product { + ProductRef::Visual(product) => { + format!( + "visual product node {} output {}", + product.node(), + product.output() + ) + } + ProductRef::Control(product) => { + let extent = product.preferred_extent(); + format!( + "control product node {} output {} ({}x{})", + product.node(), + product.output(), + extent.rows, + extent.samples_per_row + ) + } + } +} + +fn format_float(value: f32) -> String { + if value.is_finite() { + let rounded = (value * 1000.0).round() / 1000.0; + if rounded.fract() == 0.0 { + format!("{rounded:.1}") + } else { + rounded.to_string() + } + } else { + value.to_string() + } +} + +fn format_float_array(value: &[f32; N]) -> String { + let values = value + .iter() + .map(|value| format_float(*value)) + .collect::>() + .join(", "); + format!("({values})") +} + +fn format_int_array(value: &[T; N]) -> String { + let values = value + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + format!("({values})") +} + +fn format_matrix(value: &[[f32; C]; R]) -> String { + let rows = value + .iter() + .map(format_float_array) + .collect::>() + .join(", "); + format!("[{rows}]") +} + +#[cfg(test)] +mod tests { + use lpc_model::{ControlExtent, ControlProduct, LpValue, NodeId, ProductRef, VisualProduct}; + + use super::*; + + #[test] + fn formats_scalars_vectors_and_products() { + assert_eq!(format_lp_value(&LpValue::Bool(true)), "true"); + assert_eq!(format_lp_value(&LpValue::F32(0.33333334)), "0.333"); + assert_eq!( + format_lp_value(&LpValue::Vec3([1.0, 2.5, 3.0])), + "(1.0, 2.5, 3.0)" + ); + assert_eq!( + format_lp_value(&LpValue::Product(ProductRef::visual(VisualProduct::new( + NodeId::new(4), + 1, + )))), + "visual product node 4 output 1" + ); + assert_eq!( + format_lp_value(&LpValue::Product(ProductRef::control(ControlProduct::new( + NodeId::new(5), + 2, + ControlExtent::new(3, 12), + )))), + "control product node 5 output 2 (3x12)" + ); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/slot/mod.rs b/lp-app/lpa-studio-core/src/app/project/slot/mod.rs new file mode 100644 index 000000000..4cc23fcd0 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/slot/mod.rs @@ -0,0 +1,14 @@ +//! Project slot controller-domain types. +//! +//! Slots are addressed under a project node, a slot root such as `def` or +//! `state`, and a structured [`lpc_model::SlotPath`]. Studio creates recursive +//! slot controllers for containers and leaves so expansion, binding, dirty +//! state, DTO projection, and future edits have addressable homes. + +pub mod project_slot_address; +pub mod project_slot_root; +pub mod slot_controller; + +pub use project_slot_address::ProjectSlotAddress; +pub use project_slot_root::ProjectSlotRoot; +pub use slot_controller::{SlotController, SlotControllerState, SlotKind}; diff --git a/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs new file mode 100644 index 000000000..b6b8ebe23 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_address.rs @@ -0,0 +1,44 @@ +use lpc_model::SlotPath; + +use crate::{ProjectNodeAddress, ProjectSlotRoot}; + +/// Stable address for any node-owned slot, including root/container slots. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ProjectSlotAddress { + pub node: ProjectNodeAddress, + pub root: ProjectSlotRoot, + pub path: SlotPath, +} + +impl ProjectSlotAddress { + /// Create a slot address from a node, root, and path. + pub fn new(node: ProjectNodeAddress, root: ProjectSlotRoot, path: SlotPath) -> Self { + Self { node, root, path } + } + + /// Create an address for a slot root itself. + pub fn root(node: ProjectNodeAddress, root: ProjectSlotRoot) -> Self { + Self::new(node, root, SlotPath::root()) + } + + /// True when this address points at the slot root rather than a child path. + pub fn is_root(&self) -> bool { + self.path.is_root() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn root_slot_address_uses_root_path() { + let address = ProjectSlotAddress::root( + ProjectNodeAddress::parse("/demo.project/orbit.shader").unwrap(), + ProjectSlotRoot::def(), + ); + + assert!(address.is_root()); + assert_eq!(address.root.name(), "def"); + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/slot/project_slot_root.rs b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_root.rs new file mode 100644 index 000000000..2cb1e410d --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/slot/project_slot_root.rs @@ -0,0 +1,54 @@ +use core::fmt; + +/// Named root of a node-owned slot tree. +/// +/// Current project sync uses roots such as `def` and `state`; `Other` keeps the +/// address model open for future or custom roots without turning roots back +/// into untyped strings everywhere. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ProjectSlotRoot { + Def, + State, + Other(String), +} + +impl ProjectSlotRoot { + /// The authored/configuration root. + pub fn def() -> Self { + Self::Def + } + + /// The runtime state root. + pub fn state() -> Self { + Self::State + } + + /// A non-standard or future root name. + pub fn other(name: impl Into) -> Self { + Self::Other(name.into()) + } + + /// Build a root from the mirror root suffix. + pub fn from_name(name: &str) -> Self { + match name { + "def" => Self::Def, + "state" => Self::State, + name => Self::Other(name.to_string()), + } + } + + /// Human-readable root name. + pub fn name(&self) -> &str { + match self { + Self::Def => "def", + Self::State => "state", + Self::Other(name) => name, + } + } +} + +impl fmt::Display for ProjectSlotRoot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs new file mode 100644 index 000000000..d35326119 --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_controller.rs @@ -0,0 +1,1067 @@ +use std::collections::BTreeMap; + +use lpc_model::slot::SlotFieldShapeView; +use lpc_model::{ + LpType, LpValue, ProductRef, Revision, SlotData, SlotDirection, SlotMapKey, SlotName, + SlotPathSegment, SlotPolicy, SlotSemantics, SlotShapeLookup, SlotShapeRegistry, SlotShapeView, + SlotValueShape, SlotValueShapeView, ValueEditorHint, +}; + +use crate::{ + ProjectSlotAddress, ProjectSlotRoot, UiAssetEditorKind, UiConfigSlot, UiConfigSlotBody, + UiProducedProduct, UiProducedValue, UiProductRef, UiSlotAsset, UiSlotEditorHint, + UiSlotFieldState, UiSlotOptionality, UiSlotRecord, UiSlotSourceState, UiSlotUnit, UiSlotValue, + app::project::format_slot_map_key, +}; + +/// Compact structural family for a project slot controller. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SlotKind { + Unit, + Value, + Record, + Map, + Enum, + Option, + Asset, + Issue, +} + +/// Latest render-relevant body facts for a project slot controller. +#[derive(Clone, Debug, PartialEq)] +enum SlotControllerBody { + Empty, + Value { value: LpValue }, + Record, + Map, + Enum { variant: String }, + Option { present: bool }, + Issue, +} + +/// Local Studio state owned by a project slot controller. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SlotControllerState { + pub expanded: bool, +} + +impl SlotControllerState { + /// Default collapsed slot state. + pub fn new() -> Self { + Self::default() + } +} + +impl Default for SlotControllerState { + fn default() -> Self { + Self { expanded: false } + } +} + +/// UI-framework agnostic controller for one slot tree node. +/// +/// Slot controllers are recursive. Containers and leaves both get controllers +/// so future editing, binding, validation, and expansion state have stable +/// addressable homes. Each controller also retains the latest mirror-derived +/// value, shape, semantics, and policy facts needed to project node DTOs without +/// walking the project mirror a second time. +#[derive(Clone, Debug, PartialEq)] +pub struct SlotController { + address: ProjectSlotAddress, + label: String, + kind: SlotKind, + body: SlotControllerBody, + revision: Option, + semantics: SlotSemantics, + policy: SlotPolicy, + value_shape: Option, + source: UiSlotSourceState, + issues: Vec, + state: SlotControllerState, + children: Vec, +} + +impl SlotController { + pub(in crate::app::project) fn from_slot_data( + address: ProjectSlotAddress, + label: String, + data: &SlotData, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) -> Self { + let mut controller = Self::empty(address, label); + controller.apply_slot_data(data, shape, registry); + controller + } + + pub(in crate::app::project) fn issue( + address: ProjectSlotAddress, + label: impl Into, + issue: impl Into, + ) -> Self { + let mut controller = Self::empty(address, label.into()); + controller.apply_issue(issue); + controller + } + + pub(in crate::app::project) fn apply_root_data( + &mut self, + address: ProjectSlotAddress, + label: String, + data: &SlotData, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) { + self.address = address; + self.label = label; + self.apply_context(SlotApplyContext::for_root(&self.address.root)); + self.apply_slot_data(data, shape, registry); + } + + pub(in crate::app::project) fn apply_root_issue( + &mut self, + address: ProjectSlotAddress, + label: String, + issue: String, + ) { + self.address = address; + self.label = label; + self.apply_context(SlotApplyContext::for_root(&self.address.root)); + self.revision = None; + self.apply_issue(issue); + } + + fn empty(address: ProjectSlotAddress, label: String) -> Self { + let context = SlotApplyContext::for_root(&address.root); + Self { + address, + label, + kind: SlotKind::Issue, + body: SlotControllerBody::Issue, + revision: None, + semantics: context.semantics, + policy: context.policy, + value_shape: None, + source: UiSlotSourceState::Direct, + issues: Vec::new(), + state: SlotControllerState::new(), + children: Vec::new(), + } + } + + /// Stable slot address used as the controller key. + pub fn address(&self) -> &ProjectSlotAddress { + &self.address + } + + /// Human-readable slot label. + pub fn label(&self) -> &str { + &self.label + } + + /// Latest structural slot kind observed in the mirror. + pub fn kind(&self) -> SlotKind { + self.kind + } + + /// Latest known revision for this slot, if the mirror supplied one. + pub fn revision(&self) -> Option { + self.revision + } + + /// Mirror/application issues attached to this slot controller. + pub fn issues(&self) -> &[String] { + &self.issues + } + + /// Local slot controller state. + pub fn state(&self) -> &SlotControllerState { + &self.state + } + + /// Mutable local slot controller state. + pub fn state_mut(&mut self) -> &mut SlotControllerState { + &mut self.state + } + + /// Reconciled child slot controllers in mirror order. + pub fn children(&self) -> &[SlotController] { + &self.children + } + + /// Project this slot and its descendants as a config slot row. + pub(in crate::app::project) fn ui_config_slot(&self) -> UiConfigSlot { + let mut slot = UiConfigSlot::new( + self.ui_key(), + self.label.clone(), + self.ui_config_slot_body(), + ) + .with_source(self.ui_source()) + .with_state(self.ui_field_state()); + + if let Some(detail) = self.ui_detail() { + slot = slot.with_detail(detail); + } + if let Some(optionality) = self.ui_optionality() { + slot = slot.with_optionality(optionality); + } + for issue in &self.issues { + slot = slot.with_issue(issue.clone()); + } + slot + } + + /// Project this slot as an asset row if it looks asset-like. + pub(in crate::app::project) fn ui_asset_slot(&self) -> Option { + let asset = self.ui_slot_asset()?; + let mut slot = UiConfigSlot::asset(self.ui_key(), self.label.clone(), asset) + .with_source(self.ui_source()) + .with_state(self.ui_field_state()); + if let Some(detail) = self.ui_detail() { + slot = slot.with_detail(detail); + } + if let Some(optionality) = self.ui_optionality() { + slot = slot.with_optionality(optionality); + } + for issue in &self.issues { + slot = slot.with_issue(issue.clone()); + } + Some(slot) + } + + /// Project this slot as a produced product if it carries product output. + pub(in crate::app::project) fn ui_produced_product(&self) -> Option { + if !self.is_produced_slot() { + return None; + } + match self.value() { + Some(LpValue::Product(ProductRef::Visual(product))) => { + let product_ref = UiProductRef::from_visual_product(*product); + Some( + UiProducedProduct::visual(self.label.clone()) + .with_product(product_ref) + .with_detail(format!( + "node {} output {}", + product.node(), + product.output() + )), + ) + } + Some(LpValue::Product(ProductRef::Control(product))) => { + let extent = product.preferred_extent(); + let product_ref = UiProductRef::from_control_product(*product); + Some( + UiProducedProduct::control(self.label.clone()) + .with_product(product_ref) + .with_detail(format!( + "node {} output {} {}x{}", + product.node(), + product.output(), + extent.rows, + extent.samples_per_row + )), + ) + } + Some(LpValue::Unset) if self.value_shape_is_product() => { + Some(UiProducedProduct::empty(self.label.clone())) + } + None if self.value_shape_is_product() => { + Some(UiProducedProduct::empty(self.label.clone())) + } + _ => None, + } + } + + /// Collect concrete produced products under this slot. + pub(in crate::app::project) fn collect_produced_product_refs( + &self, + products: &mut Vec, + ) { + if let Some(product) = self + .ui_produced_product() + .and_then(|product| product.product) + { + products.push(product); + return; + } + for child in &self.children { + child.collect_produced_product_refs(products); + } + } + + /// Project this slot as a compact produced value if it is produced but not a product. + pub(in crate::app::project) fn ui_produced_value(&self) -> Option { + if !self.is_produced_slot() || self.ui_produced_product().is_some() { + return None; + } + let value = self.value()?; + let ui_value = UiSlotValue::from_lp_value(value); + let mut produced = UiProducedValue::new(self.label.clone(), ui_value.display); + produced.detail = Some(ui_value.kind.type_label().to_string()); + produced.unit = self.ui_unit(); + Some(produced) + } + + /// Collect produced section items under this slot. + pub(in crate::app::project) fn collect_produced( + &self, + products: &mut Vec, + values: &mut Vec, + ) { + if let Some(product) = self.ui_produced_product() { + products.push(product); + return; + } + if let Some(value) = self.ui_produced_value() { + values.push(value); + return; + } + for child in &self.children { + child.collect_produced(products, values); + } + } + + /// Collect config and asset rows under this slot. + pub(in crate::app::project) fn collect_config( + &self, + config_slots: &mut Vec, + asset_slots: &mut Vec, + ) { + if self.is_internal_config_slot() { + return; + } + if let Some(asset) = self.ui_asset_slot() { + asset_slots.push(asset); + return; + } + if self.address.is_root() && self.children_are_top_level_rows() { + for child in &self.children { + child.collect_config(config_slots, asset_slots); + } + return; + } + config_slots.push(self.ui_config_slot()); + } + + /// Find a mutable descendant slot controller by address. + pub fn slot_mut(&mut self, address: &ProjectSlotAddress) -> Option<&mut SlotController> { + if self.address() == address { + return Some(self); + } + self.children + .iter_mut() + .find_map(|child| child.slot_mut(address)) + } + + pub(super) fn apply_slot_data( + &mut self, + data: &SlotData, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) { + self.revision = data_revision(data); + self.issues.clear(); + self.body = SlotControllerBody::Issue; + self.value_shape = None; + + let Ok(shape) = resolve_shape(shape, registry) else { + self.apply_issue("slot shape could not be resolved"); + return; + }; + + if shape.is_unit() { + self.apply_unit(data); + } else if let Some(value_shape) = shape.value_shape() { + self.apply_value(data, value_shape); + } else if let Some(field_count) = shape.record_fields_len() { + self.apply_record(data, shape, field_count, registry); + } else if let Some(value_shape) = shape.map_value() { + self.apply_map(data, value_shape, registry); + } else if shape.is_enum() { + self.apply_enum(data, shape, registry); + } else if let Some(some_shape) = shape.option_some() { + self.apply_option(data, some_shape, registry); + } else { + self.apply_issue("unsupported slot shape"); + } + } + + fn apply_unit(&mut self, data: &SlotData) { + match data { + SlotData::Unit { .. } => { + self.kind = SlotKind::Unit; + self.body = SlotControllerBody::Empty; + self.children.clear(); + } + _ => self.apply_issue("expected unit data"), + } + } + + fn apply_value(&mut self, data: &SlotData, shape: SlotValueShapeView<'_>) { + match data { + SlotData::Value(value) => { + self.kind = SlotKind::Value; + self.body = SlotControllerBody::Value { + value: value.get().clone(), + }; + self.value_shape = Some(owned_value_shape(shape)); + self.children.clear(); + } + _ => self.apply_issue("expected value data"), + } + } + + fn apply_record( + &mut self, + data: &SlotData, + shape: SlotShapeView<'_>, + field_count: usize, + registry: &SlotShapeRegistry, + ) { + let SlotData::Record(record) = data else { + self.apply_issue("expected record data"); + return; + }; + + self.kind = SlotKind::Record; + self.body = SlotControllerBody::Record; + let children = (0..field_count) + .map(|index| { + let Some(field) = shape.record_field(index) else { + return SlotChildApply::Issue { + address: self.address.clone(), + label: format!("field {index}"), + message: "field shape is missing".to_string(), + context: self.context(), + }; + }; + let label = human_label(field.name_str()); + let address = self.address_with_field(field.name_str()); + let context = self.field_context(field); + match record.fields.get(index) { + Some(data) => SlotChildApply::Data { + address, + label, + data, + shape: field.shape(), + context, + }, + None => SlotChildApply::Issue { + address, + label, + message: "field data is missing".to_string(), + context, + }, + } + }) + .collect(); + self.reconcile_children(children, registry); + } + + fn apply_map( + &mut self, + data: &SlotData, + value_shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) { + let SlotData::Map(map) = data else { + self.apply_issue("expected map data"); + return; + }; + + self.kind = SlotKind::Map; + self.body = SlotControllerBody::Map; + let children = map + .entries + .iter() + .map(|(key, data)| SlotChildApply::Data { + address: ProjectSlotAddress::new( + self.address.node.clone(), + self.address.root.clone(), + self.address.path.child_key(key.clone()), + ), + label: map_key_label(key), + data, + shape: value_shape, + context: self.context(), + }) + .collect(); + self.reconcile_children(children, registry); + } + + fn apply_enum( + &mut self, + data: &SlotData, + shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) { + let SlotData::Enum(value) = data else { + self.apply_issue("expected enum data"); + return; + }; + + self.kind = SlotKind::Enum; + self.body = SlotControllerBody::Enum { + variant: value.variant.as_str().to_string(), + }; + let Some(variant_shape) = shape.enum_variant_by_name(&value.variant) else { + self.apply_issue(format!( + "enum variant {} is missing from shape", + value.variant.as_str() + )); + return; + }; + + let children = vec![SlotChildApply::Data { + address: ProjectSlotAddress::new( + self.address.node.clone(), + self.address.root.clone(), + self.address.path.child(value.variant.clone()), + ), + label: human_label(value.variant.as_str()), + data: &value.data, + shape: variant_shape.shape(), + context: self.context(), + }]; + self.reconcile_children(children, registry); + } + + fn apply_option( + &mut self, + data: &SlotData, + some_shape: SlotShapeView<'_>, + registry: &SlotShapeRegistry, + ) { + let SlotData::Option(value) = data else { + self.apply_issue("expected optional data"); + return; + }; + + self.kind = SlotKind::Option; + self.body = SlotControllerBody::Option { + present: value.data.is_some(), + }; + let Some(data) = &value.data else { + self.children.clear(); + return; + }; + + let children = vec![SlotChildApply::Data { + address: ProjectSlotAddress::new( + self.address.node.clone(), + self.address.root.clone(), + self.address + .path + .child(SlotName::parse("some").expect("valid slot name")), + ), + label: "Value".to_string(), + data, + shape: some_shape, + context: self.context(), + }]; + self.reconcile_children(children, registry); + } + + fn apply_issue(&mut self, issue: impl Into) { + self.kind = SlotKind::Issue; + self.body = SlotControllerBody::Issue; + self.value_shape = None; + self.issues.clear(); + self.issues.push(issue.into()); + self.children.clear(); + } + + fn reconcile_children( + &mut self, + children: Vec>, + registry: &SlotShapeRegistry, + ) { + let mut previous = self + .children + .drain(..) + .map(|child| (child.address().clone(), child)) + .collect::>(); + + self.children = children + .into_iter() + .map(|child| { + let address = child.address().clone(); + if let Some(mut controller) = previous.remove(&address) { + controller.apply_child(child, registry); + controller + } else { + Self::from_child(child, registry) + } + }) + .collect(); + } + + fn apply_child(&mut self, child: SlotChildApply<'_>, registry: &SlotShapeRegistry) { + match child { + SlotChildApply::Data { + address, + label, + data, + shape, + context, + } => { + self.address = address; + self.label = label; + self.apply_context(context); + self.apply_slot_data(data, shape, registry); + } + SlotChildApply::Issue { + address, + label, + message, + context, + } => { + self.address = address; + self.label = label; + self.apply_context(context); + self.revision = None; + self.apply_issue(message); + } + } + } + + fn from_child(child: SlotChildApply<'_>, registry: &SlotShapeRegistry) -> Self { + match child { + SlotChildApply::Data { + address, + label, + data, + shape, + context, + } => { + let mut controller = Self::empty(address, label); + controller.apply_context(context); + controller.apply_slot_data(data, shape, registry); + controller + } + SlotChildApply::Issue { + address, + label, + message, + context, + } => { + let mut controller = Self::empty(address, label); + controller.apply_context(context); + controller.apply_issue(message); + controller + } + } + } + + fn address_with_field(&self, field_name: &str) -> ProjectSlotAddress { + ProjectSlotAddress::new( + self.address.node.clone(), + self.address.root.clone(), + self.address + .path + .child(SlotName::parse(field_name).expect("shape field name is valid")), + ) + } + + fn context(&self) -> SlotApplyContext { + SlotApplyContext { + semantics: self.semantics, + policy: self.policy, + } + } + + fn apply_context(&mut self, context: SlotApplyContext) { + self.semantics = context.semantics; + self.policy = context.policy; + } + + fn field_context(&self, field: SlotFieldShapeView<'_>) -> SlotApplyContext { + let semantics = field.semantics(); + let policy = field.policy(); + let default_semantics = semantics == SlotSemantics::default(); + let default_policy = policy == SlotPolicy::default(); + let mut context = + if self.address.root == ProjectSlotRoot::State && default_semantics && default_policy { + self.context() + } else { + SlotApplyContext { semantics, policy } + }; + if context.semantics.direction == SlotDirection::Produced && default_policy { + context.policy = SlotPolicy::read_only_transient(); + } + context + } + + fn ui_config_slot_body(&self) -> UiConfigSlotBody { + match &self.body { + SlotControllerBody::Empty => UiConfigSlotBody::Empty, + SlotControllerBody::Value { value } => { + UiConfigSlotBody::Value(self.ui_slot_value(value)) + } + SlotControllerBody::Record + | SlotControllerBody::Map + | SlotControllerBody::Enum { .. } => UiConfigSlotBody::Record(UiSlotRecord::new( + self.children + .iter() + .filter(|child| !child.is_internal_config_slot()) + .map(Self::ui_config_slot) + .collect(), + )), + SlotControllerBody::Option { present } if *present => self.ui_present_option_body(), + SlotControllerBody::Option { .. } | SlotControllerBody::Issue => { + UiConfigSlotBody::Empty + } + } + } + + fn ui_present_option_body(&self) -> UiConfigSlotBody { + let Some(child) = self.children.first() else { + return UiConfigSlotBody::Empty; + }; + if let Some(asset) = child.ui_slot_asset() { + return UiConfigSlotBody::Asset(asset); + } + child.ui_config_slot_body() + } + + fn ui_slot_value(&self, value: &LpValue) -> UiSlotValue { + let mut value = UiSlotValue::from_lp_value(value); + if let Some(shape) = &self.value_shape { + value.editor = ui_editor_hint(&shape.editor); + if let Some(description) = shape.meta.description.as_ref() { + value = value.with_detail(description.clone()); + } + } + value + } + + fn ui_slot_asset(&self) -> Option { + if !self.is_asset_like() { + return None; + } + let value = self.value()?; + let (source, content) = match value { + LpValue::String(value) if looks_like_inline_glsl(value) => { + ("inline.glsl".to_string(), Some(value.clone())) + } + LpValue::String(value) if looks_like_inline_svg(value) => { + ("inline.svg".to_string(), Some(value.clone())) + } + LpValue::String(value) => (value.clone(), None), + LpValue::Resource(resource) => ( + format!("resource {:?}:{}", resource.domain, resource.id), + None, + ), + other => (UiSlotValue::from_lp_value(other).display, None), + }; + let editor = asset_editor_kind(&source, content.as_deref(), self.value_shape.as_ref()); + let mut asset = UiSlotAsset::new(source, editor); + if let Some(content) = content { + asset = asset.with_content(content); + } + Some(asset) + } + + fn ui_key(&self) -> String { + if self.address.path.is_root() { + self.address.root.name().to_string() + } else { + self.address.path.to_string() + } + } + + fn ui_source(&self) -> UiSlotSourceState { + if matches!(&self.body, SlotControllerBody::Option { present: false }) { + return UiSlotSourceState::Unset; + } + match self.value() { + Some(LpValue::Unset) => UiSlotSourceState::Unset, + _ => self.source.clone(), + } + } + + fn ui_optionality(&self) -> Option { + let SlotControllerBody::Option { present } = &self.body else { + return None; + }; + Some(if *present { + UiSlotOptionality::included(self.policy.writable) + } else { + UiSlotOptionality::excluded(self.policy.writable) + }) + } + + fn ui_unit(&self) -> Option { + UiSlotUnit::from_known_label(&self.label) + } + + fn ui_field_state(&self) -> UiSlotFieldState { + let state = if self.policy.writable { + UiSlotFieldState::editable() + } else { + UiSlotFieldState::readonly() + }; + if let Some(issue) = self.issues.first() { + state.with_invalid(issue.clone()) + } else { + state + } + } + + fn ui_detail(&self) -> Option { + match &self.body { + SlotControllerBody::Value { value } => Some( + UiSlotValue::from_lp_value(value) + .kind + .type_label() + .to_string(), + ), + SlotControllerBody::Record => Some(child_count_detail(self.children.len(), "field")), + SlotControllerBody::Map => Some(child_count_detail(self.children.len(), "entry")), + SlotControllerBody::Enum { variant } => Some(format!("variant {variant}")), + SlotControllerBody::Option { present: true } => { + self.children.first().and_then(|child| child.ui_detail()) + } + SlotControllerBody::Option { present: false } => None, + SlotControllerBody::Empty | SlotControllerBody::Issue => None, + } + } + + fn value(&self) -> Option<&LpValue> { + match &self.body { + SlotControllerBody::Value { value } => Some(value), + _ => None, + } + } + + fn is_produced_slot(&self) -> bool { + self.address.root == ProjectSlotRoot::State + || self.semantics.direction == SlotDirection::Produced + } + + fn value_shape_is_product(&self) -> bool { + matches!( + self.value_shape.as_ref().map(|shape| &shape.ty), + Some(LpType::Product(_)) + ) || matches!( + self.value_shape.as_ref().map(|shape| &shape.editor), + Some(ValueEditorHint::VisualProduct | ValueEditorHint::ControlProduct) + ) + } + + fn is_asset_like(&self) -> bool { + if self.is_produced_slot() { + return false; + } + if matches!( + self.value_shape.as_ref().map(|shape| &shape.editor), + Some(ValueEditorHint::Resource | ValueEditorHint::RuntimeBufferResource) + ) { + return true; + } + let key = self.ui_key().to_ascii_lowercase(); + if matches!(key.as_str(), "source" | "shader" | "glsl" | "svg") + || key.ends_with(".source") + || key.ends_with(".shader") + || key.ends_with(".glsl") + || key.ends_with(".svg") + { + return matches!( + self.value(), + Some(LpValue::String(_) | LpValue::Resource(_)) + ); + } + matches!( + self.value(), + Some(LpValue::String(value)) + if value.ends_with(".glsl") + || value.ends_with(".svg") + || looks_like_inline_glsl(value) + || looks_like_inline_svg(value) + ) + } + + fn is_internal_config_slot(&self) -> bool { + matches!( + self.address.path.segments().first(), + Some(SlotPathSegment::Field(name)) if name.as_str() == "bindings" + ) + } + + fn children_are_top_level_rows(&self) -> bool { + matches!( + self.body, + SlotControllerBody::Record + | SlotControllerBody::Map + | SlotControllerBody::Enum { .. } + | SlotControllerBody::Option { present: true } + ) + } +} + +enum SlotChildApply<'a> { + Data { + address: ProjectSlotAddress, + label: String, + data: &'a SlotData, + shape: SlotShapeView<'a>, + context: SlotApplyContext, + }, + Issue { + address: ProjectSlotAddress, + label: String, + message: String, + context: SlotApplyContext, + }, +} + +impl SlotChildApply<'_> { + fn address(&self) -> &ProjectSlotAddress { + match self { + Self::Data { address, .. } | Self::Issue { address, .. } => address, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SlotApplyContext { + semantics: SlotSemantics, + policy: SlotPolicy, +} + +impl SlotApplyContext { + fn for_root(root: &ProjectSlotRoot) -> Self { + match root { + ProjectSlotRoot::Def => Self { + semantics: SlotSemantics::local(), + policy: SlotPolicy::writable_persisted(), + }, + ProjectSlotRoot::State => Self { + semantics: SlotSemantics::produced(), + policy: SlotPolicy::read_only_transient(), + }, + ProjectSlotRoot::Other(_) => Self { + semantics: SlotSemantics::local(), + policy: SlotPolicy::read_only_persisted(), + }, + } + } +} + +fn resolve_shape<'a>( + mut shape: SlotShapeView<'a>, + registry: &'a SlotShapeRegistry, +) -> Result, ()> { + for _ in 0..32 { + if let Some(id) = shape.ref_id() { + shape = registry.get_shape(id).ok_or(())?; + continue; + } + if let Some(inner) = shape.custom_shape() { + shape = inner; + continue; + } + return Ok(shape); + } + Err(()) +} + +fn data_revision(data: &SlotData) -> Option { + match data { + SlotData::Unit { revision } => Some(*revision), + SlotData::Value(value) => Some(value.changed_at()), + SlotData::Record(record) => Some(record.fields_revision), + SlotData::Map(map) => Some(map.keys_revision), + SlotData::Enum(value) => Some(value.variant_revision), + SlotData::Option(value) => Some(value.presence_revision), + } +} + +fn map_key_label(key: &SlotMapKey) -> String { + format_slot_map_key(key) +} + +fn human_label(raw: &str) -> String { + let normalized = raw.replace(['_', '-'], " "); + let mut chars = normalized.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + first.to_uppercase().collect::() + chars.as_str() +} + +fn owned_value_shape(shape: SlotValueShapeView<'_>) -> SlotValueShape { + match shape { + SlotValueShapeView::Static(shape) => shape.to_owned_value_shape(), + SlotValueShapeView::Dynamic(shape) => shape.clone(), + } +} + +fn ui_editor_hint(editor: &ValueEditorHint) -> UiSlotEditorHint { + match editor { + ValueEditorHint::Plain + | ValueEditorHint::NodeRef + | ValueEditorHint::Path + | ValueEditorHint::Dimensions + | ValueEditorHint::Affine2d + | ValueEditorHint::Resource + | ValueEditorHint::RuntimeBufferResource + | ValueEditorHint::VisualProduct + | ValueEditorHint::ControlProduct => UiSlotEditorHint::Auto, + ValueEditorHint::Number { min, max, step } => UiSlotEditorHint::Number { + min: min.map(|value| value.0), + max: max.map(|value| value.0), + step: step.map(|value| value.0), + }, + ValueEditorHint::Slider { min, max, step } => UiSlotEditorHint::Slider { + min: min.0, + max: max.0, + step: step.map(|value| value.0), + }, + ValueEditorHint::Xy => UiSlotEditorHint::Xy, + ValueEditorHint::Dropdown { options } => UiSlotEditorHint::dropdown( + options + .iter() + .map(|option| (option.value.clone(), option.label.clone())), + ), + } +} + +fn asset_editor_kind( + source: &str, + content: Option<&str>, + shape: Option<&SlotValueShape>, +) -> UiAssetEditorKind { + let lower = source.to_ascii_lowercase(); + if lower.ends_with(".glsl") || content.is_some_and(looks_like_inline_glsl) { + UiAssetEditorKind::Glsl + } else if lower.ends_with(".svg") || content.is_some_and(looks_like_inline_svg) { + UiAssetEditorKind::Svg + } else if matches!( + shape.map(|shape| &shape.editor), + Some(ValueEditorHint::RuntimeBufferResource) + ) { + UiAssetEditorKind::Binary + } else { + UiAssetEditorKind::Text + } +} + +fn looks_like_inline_glsl(value: &str) -> bool { + value.contains("#version") + || value.contains("void main") + || value.contains("void mainImage") + || value.contains("gl_FragColor") +} + +fn looks_like_inline_svg(value: &str) -> bool { + value.trim_start().starts_with(" String { + if count == 1 { + format!("1 {noun}") + } else { + format!("{count} {noun}s") + } +} diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs b/lp-app/lpa-studio-core/src/app/server/browser_serial_client_io.rs similarity index 92% rename from lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs rename to lp-app/lpa-studio-core/src/app/server/browser_serial_client_io.rs index 2bbd6df66..5d45f9bb7 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_client_io.rs +++ b/lp-app/lpa-studio-core/src/app/server/browser_serial_client_io.rs @@ -15,9 +15,10 @@ use wasm_bindgen_futures::JsFuture; use super::browser_serial_readiness::{ BrowserSerialReadinessClassifier, BrowserSerialReadinessFailure, }; +use crate::core::view::activity_view::{UiActivityStep, UiActivityStepState}; use crate::{ - ServerUx, SharedLinkRegistry, UiActivity, UiActivityStep, UiActivityStepState, UiStatus, - UxActivityTarget, UxLogEntry, UxLogLevel, UxNodeId, UxUpdate, UxUpdateSink, + ControllerId, ServerController, SharedLinkRegistry, UiActivityView, UiLogEntry, UiLogLevel, + UiStatus, UxActivityTarget, UxUpdate, UxUpdateSink, }; const RESPONSE_POLL_LIMIT: usize = 500; @@ -38,7 +39,7 @@ impl BrowserSerialClientIo { pub fn new( registry: SharedLinkRegistry, session_id: LinkSessionId, - logs: Rc>>, + logs: Rc>>, updates: UxUpdateSink, ) -> Self { let readiness_activity = initial_readiness_activity(); @@ -112,7 +113,7 @@ impl BrowserSerialClientIo { state.protocol_ready = true; state.mark_protocol_ready(); state.push_log( - UxLogLevel::Info, + UiLogLevel::Info, "browser-serial", "server protocol stream is ready", ); @@ -155,7 +156,7 @@ impl BrowserSerialClientIo { )); self.state .borrow() - .push_log(UxLogLevel::Warn, "browser-serial", message); + .push_log(UiLogLevel::Warn, "browser-serial", message); self.state .borrow_mut() .mark_protocol_failed(&no_firmware_message, true); @@ -165,7 +166,7 @@ impl BrowserSerialClientIo { console_error(&format!("[browser-serial] {message}")); self.state .borrow() - .push_log(UxLogLevel::Error, "browser-serial", message.clone()); + .push_log(UiLogLevel::Error, "browser-serial", message.clone()); self.state .borrow_mut() .mark_protocol_failed(&message, false); @@ -215,7 +216,7 @@ impl BrowserSerialClientIo { console_warn(&format!("[browser-serial] {message}")); let mut state = self.state.borrow_mut(); state.last_protocol_issue = Some(issue); - state.push_log(UxLogLevel::Warn, "browser-serial", message); + state.push_log(UiLogLevel::Warn, "browser-serial", message); } fn wait_context(&self) -> String { @@ -286,7 +287,7 @@ impl ClientIo for BrowserSerialClientIo { console_error(&format!("[browser-serial] {message}")); self.state .borrow() - .push_log(UxLogLevel::Error, "browser-serial", message.clone()); + .push_log(UiLogLevel::Error, "browser-serial", message.clone()); return Err(TransportError::Other(message)); } @@ -326,9 +327,9 @@ impl ClientIo for BrowserSerialClientIo { struct BrowserSerialClientState { registry: SharedLinkRegistry, session_id: LinkSessionId, - logs: Rc>>, + logs: Rc>>, updates: UxUpdateSink, - readiness_activity: UiActivity, + readiness_activity: UiActivityView, readiness_classifier: BrowserSerialReadinessClassifier, boot_output_seen: bool, last_request: Option, @@ -337,15 +338,15 @@ struct BrowserSerialClientState { } impl BrowserSerialClientState { - fn push_log(&self, level: UxLogLevel, source: impl Into, message: impl Into) { + fn push_log(&self, level: UiLogLevel, source: impl Into, message: impl Into) { self.logs .borrow_mut() - .push(UxLogEntry::new(level, source, message)); + .push(UiLogEntry::new(level, source, message)); } - fn record_readiness_device_line(&mut self, level: UxLogLevel, message: String) { + fn record_readiness_device_line(&mut self, level: UiLogLevel, message: String) { self.readiness_classifier.observe_line(message.clone()); - let entry = UxLogEntry::new(level, "fw-esp32", message.clone()); + let entry = UiLogEntry::new(level, "fw-esp32", message.clone()); self.logs.borrow_mut().push(entry.clone()); self.updates.emit(UxUpdate::Log(entry)); if !self.boot_output_seen { @@ -408,8 +409,8 @@ impl BrowserSerialClientState { } } -fn initial_readiness_activity() -> UiActivity { - UiActivity::new("Connecting ESP32 server") +fn initial_readiness_activity() -> UiActivityView { + UiActivityView::new("Connecting ESP32 server") .with_detail("Waiting for LightPlayer boot output and protocol frames.") .with_steps(vec![ UiActivityStep::new(STEP_SERIAL_ACCESS, "Serial access") @@ -425,8 +426,8 @@ fn initial_readiness_activity() -> UiActivity { ]) } -fn server_node_id() -> UxNodeId { - UxNodeId::new(ServerUx::NODE_ID) +fn server_node_id() -> ControllerId { + ControllerId::new(ServerController::NODE_ID) } #[derive(Clone, Copy)] @@ -453,25 +454,25 @@ fn link_error_to_transport(error: lpa_link::LinkError) -> TransportError { TransportError::Other(error.to_string()) } -fn device_line_level(line: &str) -> UxLogLevel { +fn device_line_level(line: &str) -> UiLogLevel { if line.starts_with("[ERROR]") { - UxLogLevel::Error + UiLogLevel::Error } else if line.starts_with("[WARN]") { - UxLogLevel::Warn + UiLogLevel::Warn } else if line.starts_with("[DEBUG]") || line.starts_with("[TRACE]") { - UxLogLevel::Debug + UiLogLevel::Debug } else { - UxLogLevel::Info + UiLogLevel::Info } } -fn log_device_line(level: UxLogLevel, message: &str) { +fn log_device_line(level: UiLogLevel, message: &str) { let message = format!("[fw-esp32] {message}"); match level { - UxLogLevel::Error => console_error(&message), - UxLogLevel::Warn => console_warn(&message), - UxLogLevel::Debug => console_debug(&message), - UxLogLevel::Info => console_log(&message), + UiLogLevel::Error => console_error(&message), + UiLogLevel::Warn => console_warn(&message), + UiLogLevel::Debug => console_debug(&message), + UiLogLevel::Info => console_log(&message), } } diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs b/lp-app/lpa-studio-core/src/app/server/browser_serial_readiness.rs similarity index 84% rename from lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs rename to lp-app/lpa-studio-core/src/app/server/browser_serial_readiness.rs index 7b20b99d5..e143db2fe 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/browser_serial_readiness.rs +++ b/lp-app/lpa-studio-core/src/app/server/browser_serial_readiness.rs @@ -10,12 +10,14 @@ pub const NO_FIRMWARE_DETECTED_PREFIX: &str = "no LightPlayer firmware detected" const RECENT_LINE_LIMIT: usize = 80; const FAILURE_SNIPPET_LINE_LIMIT: usize = 6; +const SAFE_TO_REPLACE_FIRMWARE_BOOT_STRINGS: &[&str] = &["hello from seeed studio xiao esp32-c6"]; #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct BrowserSerialReadinessClassifier { recent_lines: Vec, invalid_blank_header_count: usize, rom_download_mode_count: usize, + safe_to_replace_firmware_count: usize, server_started: bool, } @@ -33,6 +35,12 @@ impl BrowserSerialReadinessClassifier { if normalized.contains("waiting for download") || normalized.contains("(download(") { self.rom_download_mode_count += 1; } + if SAFE_TO_REPLACE_FIRMWARE_BOOT_STRINGS + .iter() + .any(|known_boot_string| normalized.contains(known_boot_string)) + { + self.safe_to_replace_firmware_count += 1; + } if normalized.contains("fw-esp32 initialized, starting server loop") { self.server_started = true; } @@ -59,7 +67,9 @@ impl BrowserSerialReadinessClassifier { } pub fn no_firmware_detected(&self) -> bool { - self.invalid_blank_header_count > 0 || self.rom_download_mode_count > 0 + self.invalid_blank_header_count > 0 + || self.rom_download_mode_count > 0 + || self.safe_to_replace_firmware_count > 0 } pub fn server_started(&self) -> bool { @@ -73,6 +83,8 @@ impl BrowserSerialReadinessClassifier { fn no_firmware_reason(&self) -> NoFirmwareReason { if self.rom_download_mode_count > 0 { NoFirmwareReason::RomDownloadMode + } else if self.safe_to_replace_firmware_count > 0 { + NoFirmwareReason::SafeToReplaceFirmware } else { NoFirmwareReason::BlankOrErasedFlash } @@ -95,6 +107,7 @@ pub enum BrowserSerialReadinessFailure { pub enum NoFirmwareReason { BlankOrErasedFlash, RomDownloadMode, + SafeToReplaceFirmware, } impl BrowserSerialReadinessFailure { @@ -112,6 +125,9 @@ impl BrowserSerialReadinessFailure { NoFirmwareReason::RomDownloadMode => format!( "{NO_FIRMWARE_DETECTED_PREFIX}; ESP32 is waiting in ROM download mode" ), + NoFirmwareReason::SafeToReplaceFirmware => format!( + "{NO_FIRMWARE_DETECTED_PREFIX}; ESP32 is running known replaceable non-LightPlayer firmware" + ), }; append_recent_lines(&mut message, recent_lines); message @@ -196,6 +212,27 @@ mod tests { ); } + #[test] + fn known_replaceable_firmware_classifies_as_no_firmware() { + let mut classifier = BrowserSerialReadinessClassifier::new(); + + classifier.observe_line("Hello from Seeed Studio XIAO ESP32-C6"); + + assert_eq!( + classifier.classify_timeout(), + BrowserSerialReadinessFailure::NoFirmwareDetected { + recent_lines: vec!["Hello from Seeed Studio XIAO ESP32-C6".to_string()], + reason: NoFirmwareReason::SafeToReplaceFirmware, + } + ); + assert!( + classifier + .classify_timeout() + .message() + .contains("known replaceable non-LightPlayer firmware") + ); + } + #[test] fn unrelated_boot_output_classifies_as_protocol_timeout() { let mut classifier = BrowserSerialReadinessClassifier::new(); diff --git a/lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs b/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs similarity index 92% rename from lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs rename to lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs index b2fa63686..cda6beb44 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/browser_worker_client_io.rs +++ b/lp-app/lpa-studio-core/src/app/server/browser_worker_client_io.rs @@ -13,7 +13,7 @@ use lpc_wire::{ClientMessage, TransportError, WireServerMessage, json}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::JsFuture; -use crate::{SharedLinkRegistry, UxLogEntry, UxLogLevel}; +use crate::{SharedLinkRegistry, UiLogEntry, UiLogLevel}; const RESPONSE_POLL_LIMIT: usize = 240; @@ -25,7 +25,7 @@ impl BrowserWorkerClientIo { pub fn new( registry: SharedLinkRegistry, session_id: LinkSessionId, - logs: Rc>>, + logs: Rc>>, ) -> Self { Self { state: Rc::new(RefCell::new(BrowserWorkerClientState { @@ -88,7 +88,7 @@ impl ClientIo for BrowserWorkerClientIo { struct BrowserWorkerClientState { registry: SharedLinkRegistry, session_id: LinkSessionId, - logs: Rc>>, + logs: Rc>>, } impl BrowserWorkerClientState { @@ -127,12 +127,12 @@ fn browser_worker_provider_mut( } } -fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { +fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { match output { BrowserOutputEnvelope::Status { status, message, .. - } => Some(UxLogEntry::new( - UxLogLevel::Info, + } => Some(UiLogEntry::new( + UiLogLevel::Info, "fw-browser", message.unwrap_or(status), )), @@ -141,7 +141,7 @@ fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { target, message, .. - } => Some(UxLogEntry::new( + } => Some(UiLogEntry::new( parse_worker_log_level(&level), target, message, @@ -150,12 +150,12 @@ fn worker_output_to_log(output: BrowserOutputEnvelope) -> Option { } } -fn parse_worker_log_level(level: &str) -> UxLogLevel { +fn parse_worker_log_level(level: &str) -> UiLogLevel { match level { - "trace" | "debug" => UxLogLevel::Debug, - "warn" => UxLogLevel::Warn, - "error" => UxLogLevel::Error, - _ => UxLogLevel::Info, + "trace" | "debug" => UiLogLevel::Debug, + "warn" => UiLogLevel::Warn, + "error" => UiLogLevel::Error, + _ => UiLogLevel::Info, } } diff --git a/lp-app/lpa-studio-ux/src/nodes/server/mod.rs b/lp-app/lpa-studio-core/src/app/server/mod.rs similarity index 82% rename from lp-app/lpa-studio-ux/src/nodes/server/mod.rs rename to lp-app/lpa-studio-core/src/app/server/mod.rs index 0468e66b4..2cb6acaa0 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/mod.rs +++ b/lp-app/lpa-studio-core/src/app/server/mod.rs @@ -3,16 +3,17 @@ mod browser_serial_client_io; mod browser_serial_readiness; #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] mod browser_worker_client_io; +pub mod server_controller; pub mod server_op; pub mod server_snapshot; pub mod server_state; -pub mod server_ux; pub mod studio_server_client; +pub use server_controller::ServerController; pub use server_op::ServerOp; pub use server_snapshot::ServerSnapshot; pub use server_state::{ServerFailureKind, ServerState}; -pub use server_ux::ServerUx; pub use studio_server_client::{ - LoadedDemoProject, LoadedProjectCatalog, LoadedRunningProject, StudioServerClient, + LoadedDemoProject, LoadedProjectCatalog, LoadedRunningProject, StudioProjectRead, + StudioServerClient, }; diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs b/lp-app/lpa-studio-core/src/app/server/server_controller.rs similarity index 70% rename from lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs rename to lp-app/lpa-studio-core/src/app/server/server_controller.rs index a9078ada8..9952ae46c 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/server_ux.rs +++ b/lp-app/lpa-studio-core/src/app/server/server_controller.rs @@ -1,18 +1,18 @@ use lpa_link::LinkConnection; use crate::{ - ProgressState, ServerFailureKind, ServerOp, ServerSnapshot, ServerState, SharedLinkRegistry, - StudioServerClient, UiAction, UiBody, UiMetric, UiPaneView, UiStatus, UxError, UxIssue, UxNode, - UxNodeId, UxUpdateSink, + Controller, ControllerId, ProgressState, ServerFailureKind, ServerOp, ServerSnapshot, + ServerState, SharedLinkRegistry, StudioServerClient, UiAction, UiError, UiIssue, UiMetric, + UiPaneView, UiStatus, UiViewContent, UxUpdateSink, }; -pub struct ServerUx { +pub struct ServerController { state: ServerState, client: Option, } -impl ServerUx { - pub const NODE_ID: &'static str = "studio.server"; +impl ServerController { + pub const NODE_ID: &'static str = "studio|server"; pub fn new() -> Self { Self { @@ -25,6 +25,13 @@ impl ServerUx { self.state = state; } + #[cfg(test)] + pub(crate) fn set_client_for_test(&mut self, client: StudioServerClient) { + let protocol = client.protocol().to_string(); + self.client = Some(client); + self.state = ServerState::Connected { protocol }; + } + pub fn snapshot(&self) -> ServerSnapshot { ServerSnapshot::new(self.state.clone()) } @@ -63,7 +70,7 @@ impl ServerUx { registry: SharedLinkRegistry, connection: &LinkConnection, updates: UxUpdateSink, - ) -> Result<(), UxError> { + ) -> Result<(), UiError> { self.mark_connecting("Opening server protocol"); let client = StudioServerClient::from_link_connection(registry, connection, updates)?; let protocol = client.protocol().to_string(); @@ -72,13 +79,13 @@ impl ServerUx { Ok(()) } - pub fn client_mut(&mut self) -> Result<&mut StudioServerClient, UxError> { + pub fn client_mut(&mut self) -> Result<&mut StudioServerClient, UiError> { self.client .as_mut() - .ok_or_else(|| UxError::MissingSession("server client is not connected".to_string())) + .ok_or_else(|| UiError::MissingSession("server client is not connected".to_string())) } - pub fn take_pending_logs(&mut self) -> Vec { + pub fn take_pending_logs(&mut self) -> Vec { self.client .as_mut() .map(StudioServerClient::take_pending_logs) @@ -99,7 +106,7 @@ impl ServerUx { pub fn fail_with_kind(&mut self, message: impl Into, kind: ServerFailureKind) { self.client = None; self.state = ServerState::Failed { - issue: UxIssue::new(message), + issue: UiIssue::new(message), kind, }; } @@ -110,15 +117,15 @@ impl ServerUx { } } -impl UxNode for ServerUx { +impl Controller for ServerController { type Op = ServerOp; - fn node_id(&self) -> UxNodeId { - UxNodeId::new(Self::NODE_ID) + fn node_id(&self) -> ControllerId { + ControllerId::new(Self::NODE_ID) } } -impl Default for ServerUx { +impl Default for ServerController { fn default() -> Self { Self::new() } @@ -133,15 +140,15 @@ fn server_status(state: &ServerState) -> UiStatus { } } -fn server_body(state: &ServerState) -> UiBody { +fn server_body(state: &ServerState) -> UiViewContent { match state { ServerState::Disconnected => { - UiBody::text("Open a link endpoint to attach the server protocol.") + UiViewContent::text("Open a link endpoint to attach the server protocol.") } - ServerState::Connecting { progress } => UiBody::Progress(progress.clone()), + ServerState::Connecting { progress } => UiViewContent::Progress(progress.clone().into()), ServerState::Connected { protocol } => { - UiBody::Metrics(vec![UiMetric::new("Protocol", protocol)]) + UiViewContent::Metrics(vec![UiMetric::new("Protocol", protocol)]) } - ServerState::Failed { issue, .. } => UiBody::Issue(issue.clone()), + ServerState::Failed { issue, .. } => UiViewContent::Issue(issue.clone()), } } diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_op.rs b/lp-app/lpa-studio-core/src/app/server/server_op.rs similarity index 77% rename from lp-app/lpa-studio-ux/src/nodes/server/server_op.rs rename to lp-app/lpa-studio-core/src/app/server/server_op.rs index 21abd8d62..ee8ae2eef 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/server_op.rs +++ b/lp-app/lpa-studio-core/src/app/server/server_op.rs @@ -1,13 +1,13 @@ use core::any::Any; -use crate::{ActionMeta, ActionPriority, UxOp}; +use crate::{ActionMeta, ActionPriority, ControllerOp}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ServerOp { DisconnectServer, } -impl UxOp for ServerOp { +impl ControllerOp for ServerOp { fn default_action_meta(&self) -> ActionMeta { match self { Self::DisconnectServer => ActionMeta::new( @@ -18,11 +18,11 @@ impl UxOp for ServerOp { } } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_snapshot.rs b/lp-app/lpa-studio-core/src/app/server/server_snapshot.rs similarity index 100% rename from lp-app/lpa-studio-ux/src/nodes/server/server_snapshot.rs rename to lp-app/lpa-studio-core/src/app/server/server_snapshot.rs diff --git a/lp-app/lpa-studio-ux/src/nodes/server/server_state.rs b/lp-app/lpa-studio-core/src/app/server/server_state.rs similarity index 85% rename from lp-app/lpa-studio-ux/src/nodes/server/server_state.rs rename to lp-app/lpa-studio-core/src/app/server/server_state.rs index 8f092a2f0..a5bb3bdcc 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/server_state.rs +++ b/lp-app/lpa-studio-core/src/app/server/server_state.rs @@ -1,4 +1,4 @@ -use crate::{ProgressState, UxIssue}; +use crate::{ProgressState, UiIssue}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ServerState { @@ -10,7 +10,7 @@ pub enum ServerState { protocol: String, }, Failed { - issue: UxIssue, + issue: UiIssue, kind: ServerFailureKind, }, } diff --git a/lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs similarity index 72% rename from lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs rename to lp-app/lpa-studio-core/src/app/server/studio_server_client.rs index ccc8d57ef..432f109f0 100644 --- a/lp-app/lpa-studio-ux/src/nodes/server/studio_server_client.rs +++ b/lp-app/lpa-studio-core/src/app/server/studio_server_client.rs @@ -3,26 +3,37 @@ use std::rc::Rc; use lpa_client::{ClientError, ClientEvent, ClientIo, LpClient}; use lpa_link::{LinkConnection, LinkConnectionKind}; -use lpc_wire::WireProjectHandle; +use lpc_wire::{ProjectReadRequest, ProjectReadResponse, WireProjectHandle}; -use crate::nodes::project::demo_project::{DEMO_PROJECT_ID, demo_project_deploy_files}; +use crate::app::project::demo_project::{ + DEMO_PROJECT_ID, DEMO_PROJECT_STORAGE_ID, demo_project_deploy_files, +}; use crate::{ - LoadedProjectChoice, ProjectInventorySummary, SharedLinkRegistry, UxError, UxLogEntry, - UxLogLevel, UxUpdateSink, + LoadedProjectChoice, ProjectInventorySummary, SharedLinkRegistry, UiError, UiLogEntry, + UiLogLevel, UxUpdateSink, }; pub struct StudioServerClient { client: LpClient>, protocol: String, - pending_logs: Rc>>, + pending_logs: Rc>>, } impl StudioServerClient { + #[cfg(test)] + pub(crate) fn from_io_for_test(protocol: impl Into, io: Box) -> Self { + Self { + client: LpClient::new(io), + protocol: protocol.into(), + pending_logs: Rc::new(RefCell::new(Vec::new())), + } + } + pub fn from_link_connection( registry: SharedLinkRegistry, connection: &LinkConnection, updates: UxUpdateSink, - ) -> Result { + ) -> Result { let pending_logs = Rc::new(RefCell::new(Vec::new())); let protocol = connection_protocol(&connection.kind); let io = server_io_from_link_connection( @@ -42,10 +53,10 @@ impl StudioServerClient { &self.protocol } - pub async fn load_demo_project(&mut self) -> Result { + pub async fn load_demo_project(&mut self) -> Result { let deploy = self .client - .deploy_project_files(DEMO_PROJECT_ID, demo_project_deploy_files()) + .deploy_project_files(DEMO_PROJECT_STORAGE_ID, demo_project_deploy_files()) .await .map_err(map_client_error)?; let handle = deploy.value; @@ -66,7 +77,7 @@ impl StudioServerClient { }) } - pub fn take_pending_logs(&mut self) -> Vec { + pub fn take_pending_logs(&mut self) -> Vec { core::mem::take(&mut *self.pending_logs.borrow_mut()) } } @@ -75,12 +86,12 @@ pub struct LoadedDemoProject { pub project_id: String, pub handle_id: u32, pub inventory: ProjectInventorySummary, - pub logs: Vec, + pub logs: Vec, } pub struct LoadedProjectCatalog { pub projects: Vec, - pub logs: Vec, + pub logs: Vec, } pub struct LoadedRunningProject { @@ -89,8 +100,13 @@ pub struct LoadedRunningProject { pub inventory: ProjectInventorySummary, } +pub struct StudioProjectRead { + pub response: ProjectReadResponse, + pub logs: Vec, +} + impl StudioServerClient { - pub async fn list_loaded_projects(&mut self) -> Result { + pub async fn list_loaded_projects(&mut self) -> Result { let loaded = self .client .project_list_loaded() @@ -111,7 +127,7 @@ impl StudioServerClient { pub async fn connect_loaded_project( &mut self, choice: LoadedProjectChoice, - ) -> Result { + ) -> Result { let inventory = self .client .project_inventory_read(WireProjectHandle::new(choice.handle_id)) @@ -126,14 +142,32 @@ impl StudioServerClient { inventory: ProjectInventorySummary::from(&inventory.value), }) } + + pub async fn project_read( + &mut self, + handle_id: u32, + request: ProjectReadRequest, + ) -> Result { + let read = self + .client + .project_read(WireProjectHandle::new(handle_id), request) + .await + .map_err(map_client_error)?; + let mut logs = map_client_events(read.events); + logs.extend(self.take_pending_logs()); + Ok(StudioProjectRead { + response: read.value, + logs, + }) + } } fn server_io_from_link_connection( _registry: SharedLinkRegistry, connection: &LinkConnection, - _pending_logs: Rc>>, + _pending_logs: Rc>>, _updates: UxUpdateSink, -) -> Result, UxError> { +) -> Result, UiError> { match &connection.kind { #[cfg(all(feature = "browser-worker", target_arch = "wasm32"))] LinkConnectionKind::BrowserWorker { .. } => Ok(Box::new( @@ -144,7 +178,7 @@ fn server_io_from_link_connection( ), )), #[cfg(not(all(feature = "browser-worker", target_arch = "wasm32")))] - LinkConnectionKind::BrowserWorker { .. } => Err(UxError::UnsupportedFeature( + LinkConnectionKind::BrowserWorker { .. } => Err(UiError::UnsupportedFeature( "browser worker server I/O requires the browser-worker feature on wasm".to_string(), )), #[cfg(all(feature = "browser-serial-esp32", target_arch = "wasm32"))] @@ -157,16 +191,16 @@ fn server_io_from_link_connection( ), )), #[cfg(not(all(feature = "browser-serial-esp32", target_arch = "wasm32")))] - LinkConnectionKind::BrowserSerialEsp32 { .. } => Err(UxError::UnsupportedFeature( + LinkConnectionKind::BrowserSerialEsp32 { .. } => Err(UiError::UnsupportedFeature( "browser serial ESP32 server I/O requires the browser-serial-esp32 feature on wasm" .to_string(), )), - LinkConnectionKind::Fake => Err(UxError::UnsupportedFeature( + LinkConnectionKind::Fake => Err(UiError::UnsupportedFeature( "fake links do not expose a server protocol".to_string(), )), LinkConnectionKind::HostProcess | LinkConnectionKind::HostSerialEsp32 - | LinkConnectionKind::PendingImplementation { .. } => Err(UxError::UnsupportedFeature( + | LinkConnectionKind::PendingImplementation { .. } => Err(UiError::UnsupportedFeature( format!("server I/O is not implemented for {:?}", connection.kind), )), } @@ -183,7 +217,7 @@ fn connection_protocol(kind: &LinkConnectionKind) -> String { } } -fn map_client_events(events: Vec) -> Vec { +fn map_client_events(events: Vec) -> Vec { events .into_iter() .map(|event| match event { @@ -191,19 +225,19 @@ fn map_client_events(events: Vec) -> Vec { frame_count, uptime_ms, .. - } => UxLogEntry::new( - UxLogLevel::Debug, + } => UiLogEntry::new( + UiLogLevel::Debug, "lp-server", format!("heartbeat frame={frame_count} uptime_ms={uptime_ms}"), ), ClientEvent::Log { level, message } => { - UxLogEntry::new(map_server_log_level(level), "lp-server", message) + UiLogEntry::new(map_server_log_level(level), "lp-server", message) } ClientEvent::UncorrelatedResponse { response_id, expected_id, - } => UxLogEntry::new( - UxLogLevel::Warn, + } => UiLogEntry::new( + UiLogLevel::Warn, "lp-server", format!("uncorrelated response {response_id}; expected {expected_id}"), ), @@ -211,28 +245,28 @@ fn map_client_events(events: Vec) -> Vec { .collect() } -fn map_client_error(error: ClientError) -> UxError { +fn map_client_error(error: ClientError) -> UiError { match error { ClientError::Transport(message) if super::browser_serial_readiness::is_no_firmware_detected_message(&message) => { - UxError::NoFirmwareDetected(message) + UiError::NoFirmwareDetected(message) } - ClientError::Transport(message) => UxError::Transport(message), - ClientError::Server(message) | ClientError::Protocol(message) => UxError::Protocol(message), + ClientError::Transport(message) => UiError::Transport(message), + ClientError::Server(message) | ClientError::Protocol(message) => UiError::Protocol(message), ClientError::UnexpectedResponse { operation, response, - } => UxError::Protocol(format!("unexpected response for {operation}: {response}")), + } => UiError::Protocol(format!("unexpected response for {operation}: {response}")), } } -fn map_server_log_level(level: lpc_wire::server::api::LogLevel) -> UxLogLevel { +fn map_server_log_level(level: lpc_wire::server::api::LogLevel) -> UiLogLevel { match level { - lpc_wire::server::api::LogLevel::Debug => UxLogLevel::Debug, - lpc_wire::server::api::LogLevel::Info => UxLogLevel::Info, - lpc_wire::server::api::LogLevel::Warn => UxLogLevel::Warn, - lpc_wire::server::api::LogLevel::Error => UxLogLevel::Error, + lpc_wire::server::api::LogLevel::Debug => UiLogLevel::Debug, + lpc_wire::server::api::LogLevel::Info => UiLogLevel::Info, + lpc_wire::server::api::LogLevel::Warn => UiLogLevel::Warn, + lpc_wire::server::api::LogLevel::Error => UiLogLevel::Error, } } @@ -247,6 +281,6 @@ mod tests { "Transport error: {NO_FIRMWARE_DETECTED_PREFIX}; recent serial output: invalid header" ))); - assert!(matches!(error, UxError::NoFirmwareDetected(_))); + assert!(matches!(error, UiError::NoFirmwareDetected(_))); } } diff --git a/lp-app/lpa-studio-core/src/app/studio/mod.rs b/lp-app/lpa-studio-core/src/app/studio/mod.rs new file mode 100644 index 000000000..2a9abd3ab --- /dev/null +++ b/lp-app/lpa-studio-core/src/app/studio/mod.rs @@ -0,0 +1,14 @@ +pub mod studio_controller; +pub mod studio_snapshot; +pub mod ui_studio_view; +pub mod ux_update; +pub mod ux_update_sink; + +pub use crate::core::error::{UiError, UiResult}; +pub use crate::core::log::{UiLogEntry, UiLogLevel}; +pub use crate::core::notice::UiNotices; +pub use crate::core::notice::{UiNotice, UiNoticeLevel}; +pub use studio_controller::StudioController; +pub use studio_snapshot::StudioSnapshot; +pub use ux_update::{UxActivityTarget, UxUpdate}; +pub use ux_update_sink::UxUpdateSink; diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs similarity index 52% rename from lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs rename to lp-app/lpa-studio-core/src/app/studio/studio_controller.rs index e62729daf..59c91b41e 100644 --- a/lp-app/lpa-studio-ux/src/nodes/studio/studio_ux.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_controller.rs @@ -7,24 +7,25 @@ use lpa_link::{ LinkProviderKind, }; +use crate::core::notice::UiNotices; use crate::{ - ConnectedLink, DeviceOp, DeviceUx, LinkOpenOutcome, ProjectConnectResult, ProjectOp, - ProjectState, ProjectUx, StudioSnapshot, StudioView, UiAction, UiActions, UiActivity, UiBody, - UiStatus, UxActivityTarget, UxContext, UxError, UxLogEntry, UxLogLevel, UxNode, UxNotice, - UxOutcome, UxResult, UxUpdate, UxUpdateSink, + ConnectedLink, Controller, ControllerContext, DeviceController, DeviceOp, LinkOpenOutcome, + ProjectConnectResult, ProjectController, ProjectOp, ProjectState, ProjectSyncRun, + StudioSnapshot, UiAction, UiActions, UiActivityView, UiError, UiLogEntry, UiLogLevel, UiNotice, + UiResult, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, UxUpdate, UxUpdateSink, }; -pub struct StudioUx { - device: DeviceUx, - project: ProjectUx, - logs: Vec, +pub struct StudioController { + device: DeviceController, + project: ProjectController, + logs: Vec, } -impl StudioUx { +impl StudioController { pub fn new() -> Self { Self { - device: DeviceUx::new(), - project: ProjectUx::new(), + device: DeviceController::new(), + project: ProjectController::new(), logs: Vec::new(), } } @@ -42,7 +43,7 @@ impl StudioUx { UiActions::new(view_actions(&self.view())) } - pub fn view(&self) -> StudioView { + pub fn view(&self) -> UiStudioView { let project_snapshot = self.project.snapshot(); let project_actions = self.project.actions(self.device.has_lightplayer_state()); let device_view = self.device.view(&project_snapshot.state, project_actions); @@ -54,10 +55,10 @@ impl StudioUx { } else { vec![device_view] }; - StudioView::new(panes, self.logs.clone()) + UiStudioView::new(panes, self.logs.clone()) } - pub async fn dispatch(&mut self, action: UiAction) -> UxResult { + pub async fn dispatch(&mut self, action: UiAction) -> UiResult { self.dispatch_with_updates(action, UxUpdateSink::noop()) .await } @@ -66,32 +67,69 @@ impl StudioUx { &mut self, action: UiAction, updates: UxUpdateSink, - ) -> UxResult { + ) -> UiResult { updates.emit(UxUpdate::View(self.view())); let result = self.dispatch_inner(action, updates.clone()).await; updates.emit(UxUpdate::View(self.view())); result } - async fn dispatch_inner(&mut self, action: UiAction, updates: UxUpdateSink) -> UxResult { - if action.node_id() == &self.device.node_id() { + /// Refresh a loaded project for passive UI updates. + /// + /// This bypasses the generic action activity/notice path so the web shell can + /// keep selected visual-product previews fresh without showing a user action + /// as running. + pub async fn refresh_loaded_project_tick(&mut self) -> Result, UiError> { + if !self.project_is_loaded() || !self.device.has_lightplayer_state() { + return Ok(None); + } + let sync = { + let server = self.device.server.client_mut()?; + self.project.refresh_project(server).await? + }; + self.record_project_sync_run(&sync); + Ok(Some(sync)) + } + + async fn dispatch_inner(&mut self, action: UiAction, updates: UxUpdateSink) -> UiResult { + let node_id = action.node_id().clone(); + let device_node_id = self.device.node_id(); + let project_node_id = self.project.node_id(); + + if node_id == device_node_id { let op = action.into_op::()?; return self.execute_device_op(op, updates).await; } - if action.node_id() == &self.project.node_id() { + if node_id == project_node_id { let op = action.into_op::()?; return self.execute_project_op(op, updates).await; } - Err(crate::UxError::UnsupportedAction(format!( - "unknown UX node {}", - action.node_id() + if node_id.is_descendant_of(&project_node_id) { + let outcome = self + .project + .dispatch_editor_action(action, updates.clone()) + .await?; + updates.emit(UxUpdate::View(self.view())); + if self.project_is_loaded() && self.device.is_lightplayer_connected() { + let sync = { + let server = self.device.server.client_mut()?; + self.project.refresh_project(server).await? + }; + self.record_project_sync_run(&sync); + updates.emit(UxUpdate::View(self.view())); + } + return Ok(outcome); + } + Err(crate::UiError::UnsupportedAction(format!( + "unknown UX node {node_id}", ))) } - async fn execute_device_op(&mut self, op: DeviceOp, updates: UxUpdateSink) -> UxResult { + async fn execute_device_op(&mut self, op: DeviceOp, updates: UxUpdateSink) -> UiResult { match op { DeviceOp::DisconnectDevice => self.disconnect_device().await, DeviceOp::DisconnectLightPlayer => self.disconnect_lightplayer().await, + DeviceOp::ResetDevice => self.reset_device(updates).await, DeviceOp::ConnectLightPlayer => self.connect_server_from_link(updates).await, DeviceOp::ProvisionFirmware => self.provision_firmware(updates).await, DeviceOp::ResetToBlank => self.reset_to_blank(updates).await, @@ -99,22 +137,22 @@ impl StudioUx { self.device.link.refresh_provider_catalog(); self.device.server.disconnect(); self.project.reset(); - Ok(UxOutcome::new().with_notice(UxNotice::info("Connection catalog refreshed"))) + Ok(UiNotices::new().with_notice(UiNotice::info("Connection catalog refreshed"))) } DeviceOp::OpenProvider { provider_id } => { if provider_id != LinkProviderKind::BrowserSerialEsp32 { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_DEVICE), + device_section_target(DeviceController::SECTION_CONNECT_DEVICE), "Opening device", "Opening", format!("Opening {}", provider_id.label()), ); } match self.device.link.open_provider(provider_id).await? { - LinkOpenOutcome::Opened => Ok(UxOutcome::new()), + LinkOpenOutcome::Opened => Ok(UiNotices::new()), LinkOpenOutcome::Cancelled { message } => { - Ok(UxOutcome::new().with_notice(UxNotice::info(message))) + Ok(UiNotices::new().with_notice(UiNotice::info(message))) } LinkOpenOutcome::Connected(connected) => { self.attach_connected_link(connected, updates).await @@ -127,7 +165,7 @@ impl StudioUx { } => { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_DEVICE), + device_section_target(DeviceController::SECTION_CONNECT_DEVICE), "Opening device session", "Connecting", "Opening device endpoint", @@ -142,13 +180,14 @@ impl StudioUx { } } - async fn execute_project_op(&mut self, op: ProjectOp, updates: UxUpdateSink) -> UxResult { + async fn execute_project_op(&mut self, op: ProjectOp, updates: UxUpdateSink) -> UiResult { match op { ProjectOp::ConnectRunningProject => self.connect_running_project(updates).await, ProjectOp::ConnectLoadedProject { handle_id } => { self.connect_loaded_project(handle_id, updates).await } ProjectOp::LoadDemoProject => self.load_demo_project(updates).await, + ProjectOp::RefreshProject => self.refresh_project(updates).await, ProjectOp::DisconnectProject => self.disconnect_project().await, } } @@ -157,24 +196,24 @@ impl StudioUx { &mut self, connected: ConnectedLink, updates: UxUpdateSink, - ) -> UxResult { + ) -> UiResult { self.device.record_logs(&connected.logs); self.logs.extend(connected.logs); self.connect_server_connection(&connected.connection, updates) .await } - async fn connect_server_from_link(&mut self, updates: UxUpdateSink) -> UxResult { + async fn connect_server_from_link(&mut self, updates: UxUpdateSink) -> UiResult { let connection = self.device.link.active_connection().ok_or_else(|| { - UxError::MissingSession("link connection is not open".to_string()) + UiError::MissingSession("link connection is not open".to_string()) })?; if should_reopen_before_server_connect(&connection) { self.project.reset(); self.device.server.disconnect(); emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), "Reopening device", "Connecting", "Resetting device before server connect", @@ -189,17 +228,17 @@ impl StudioUx { &mut self, connection: &LinkConnection, updates: UxUpdateSink, - ) -> UxResult { + ) -> UiResult { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), "Connecting LightPlayer", "Connecting", "Opening server protocol", ); let server_updates = retarget_activity_updates( updates.clone(), - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), ); match self.device.server.attach_link_connection( self.device.link.registry_handle(), @@ -208,11 +247,11 @@ impl StudioUx { ) { Ok(()) => { let mut outcome = - UxOutcome::new().with_notice(UxNotice::info("Server protocol connected")); + UiNotices::new().with_notice(UiNotice::info("Server protocol connected")); updates.emit(UxUpdate::View(self.view())); emit_activity( &updates, - device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + device_section_target(DeviceController::SECTION_OPEN_PROJECT), "Checking running projects", "Checking", "Checking server response", @@ -227,20 +266,20 @@ impl StudioUx { self.device.record_logs(&pending_logs); self.logs.extend(pending_logs); self.project.reset(); - if matches!(error, UxError::NoFirmwareDetected(_)) { - self.logs.push(UxLogEntry::new( - UxLogLevel::Info, - "lpa-studio-ux", + if matches!(error, UiError::NoFirmwareDetected(_)) { + self.logs.push(UiLogEntry::new( + UiLogLevel::Info, + "lpa-studio-core", "No LightPlayer firmware detected during server readiness", )); self.device.server.fail_no_firmware(); - return Ok(UxOutcome::new().with_notice(UxNotice::info( + return Ok(UiNotices::new().with_notice(UiNotice::info( "No LightPlayer firmware detected; flash firmware onto the selected ESP32", ))); } - self.logs.push(UxLogEntry::new( - UxLogLevel::Error, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", format!("server readiness probe failed: {error}"), )); self.device.server.fail(error.to_string()); @@ -248,11 +287,15 @@ impl StudioUx { } }; match auto_connect { - AutoProjectConnect::Connected => { - outcome = outcome.with_notice(UxNotice::info("Connected running project")); + AutoProjectConnect::Connected { synced } => { + outcome = outcome.with_notice(project_sync_notice( + synced, + "Connected running project", + "Connected running project; project sync needs attention", + )); } AutoProjectConnect::SelectionRequired => { - outcome = outcome.with_notice(UxNotice::info("Choose running project")); + outcome = outcome.with_notice(UiNotice::info("Choose running project")); } AutoProjectConnect::NotFound if should_auto_load_demo_project(connection) => { let demo_outcome = self.load_demo_project(updates).await?; @@ -269,10 +312,10 @@ impl StudioUx { } } - async fn connect_running_project(&mut self, updates: UxUpdateSink) -> UxResult { + async fn connect_running_project(&mut self, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + device_section_target(DeviceController::SECTION_OPEN_PROJECT), "Connecting project", "Connecting", "Checking loaded projects", @@ -285,22 +328,27 @@ impl StudioUx { Ok(ProjectConnectResult::Connected { logs }) => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(UxOutcome::new().with_notice(UxNotice::info("Connected running project"))) + let sync = self.sync_project_after_attach(updates).await?; + Ok(UiNotices::new().with_notice(project_sync_notice( + sync.synced, + "Connected running project", + "Connected running project; project sync needs attention", + ))) } Ok(ProjectConnectResult::SelectionRequired { logs }) => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(UxOutcome::new().with_notice(UxNotice::info("Choose running project"))) + Ok(UiNotices::new().with_notice(UiNotice::info("Choose running project"))) } Ok(ProjectConnectResult::NotFound { logs }) => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(UxOutcome::new().with_notice(UxNotice::info("No running project found"))) + Ok(UiNotices::new().with_notice(UiNotice::info("No running project found"))) } Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Error, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", error.to_string(), )); self.project.fail(error.to_string()); @@ -312,10 +360,10 @@ impl StudioUx { async fn connect_running_project_if_available( &mut self, updates: UxUpdateSink, - ) -> Result { + ) -> Result { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + device_section_target(DeviceController::SECTION_OPEN_PROJECT), "Checking running projects", "Checking", "Checking loaded projects", @@ -330,7 +378,10 @@ impl StudioUx { ProjectConnectResult::Connected { logs } => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(AutoProjectConnect::Connected) + let sync = self.sync_project_after_attach(updates).await?; + Ok(AutoProjectConnect::Connected { + synced: sync.synced, + }) } ProjectConnectResult::SelectionRequired { logs } => { self.device.record_logs(&logs); @@ -345,10 +396,10 @@ impl StudioUx { } } - async fn connect_loaded_project(&mut self, handle_id: u32, updates: UxUpdateSink) -> UxResult { + async fn connect_loaded_project(&mut self, handle_id: u32, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + device_section_target(DeviceController::SECTION_OPEN_PROJECT), "Connecting project", "Connecting", "Loading project shape", @@ -361,12 +412,17 @@ impl StudioUx { Ok(logs) => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(UxOutcome::new().with_notice(UxNotice::info("Connected running project"))) + let sync = self.sync_project_after_attach(updates).await?; + Ok(UiNotices::new().with_notice(project_sync_notice( + sync.synced, + "Connected running project", + "Connected running project; project sync needs attention", + ))) } Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Error, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", error.to_string(), )); self.project.fail(error.to_string()); @@ -375,10 +431,10 @@ impl StudioUx { } } - async fn load_demo_project(&mut self, updates: UxUpdateSink) -> UxResult { + async fn load_demo_project(&mut self, updates: UxUpdateSink) -> UiResult { emit_activity( &updates, - device_section_target(DeviceUx::SECTION_OPEN_PROJECT), + device_section_target(DeviceController::SECTION_OPEN_PROJECT), "Loading demo project", "Loading", "Uploading demo project", @@ -391,12 +447,17 @@ impl StudioUx { Ok(logs) => { self.device.record_logs(&logs); self.logs.extend(logs); - Ok(UxOutcome::new().with_notice(UxNotice::info("Demo project loaded"))) + let sync = self.sync_project_after_attach(updates).await?; + Ok(UiNotices::new().with_notice(project_sync_notice( + sync.synced, + "Demo project loaded", + "Demo project loaded; project sync needs attention", + ))) } Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Error, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", error.to_string(), )); self.project.fail(error.to_string()); @@ -405,32 +466,151 @@ impl StudioUx { } } - async fn disconnect_project(&mut self) -> UxResult { + async fn disconnect_project(&mut self) -> UiResult { self.project.disconnect(); - Ok(UxOutcome::new().with_notice(UxNotice::info("Project disconnected"))) + Ok(UiNotices::new().with_notice(UiNotice::info("Project disconnected"))) + } + + async fn refresh_project(&mut self, updates: UxUpdateSink) -> UiResult { + emit_activity( + &updates, + UxActivityTarget::pane(ProjectController::NODE_ID), + "Refreshing project", + "Refreshing", + "Reading project state", + ); + updates.emit(UxUpdate::View(self.view())); + let sync = { + let server = self.device.server.client_mut()?; + self.project.refresh_project(server).await? + }; + self.record_project_sync_run(&sync); + updates.emit(UxUpdate::View(self.view())); + Ok(UiNotices::new().with_notice(project_sync_notice( + sync.synced, + "Project refreshed", + "Project refresh needs attention", + ))) + } + + async fn sync_project_after_attach( + &mut self, + updates: UxUpdateSink, + ) -> Result { + emit_activity( + &updates, + UxActivityTarget::pane(ProjectController::NODE_ID), + "Syncing project", + "Syncing", + "Reading project state", + ); + updates.emit(UxUpdate::View(self.view())); + let sync = { + let server = self.device.server.client_mut()?; + self.project.sync_loaded_project(server).await? + }; + self.record_project_sync_run(&sync); + updates.emit(UxUpdate::View(self.view())); + Ok(sync) + } + + fn record_project_sync_run(&mut self, sync: &ProjectSyncRun) { + self.device.record_logs(&sync.logs); + self.logs.extend(sync.logs.clone()); } - async fn disconnect_device(&mut self) -> UxResult { + async fn disconnect_device(&mut self) -> UiResult { self.project.reset(); self.device.server.disconnect(); self.device.link.disconnect().await?; - Ok(UxOutcome::new().with_notice(UxNotice::info("Device disconnected"))) + Ok(UiNotices::new().with_notice(UiNotice::info("Device disconnected"))) } - async fn disconnect_lightplayer(&mut self) -> UxResult { + async fn disconnect_lightplayer(&mut self) -> UiResult { self.project.reset(); self.device.server.disconnect(); - Ok(UxOutcome::new().with_notice(UxNotice::info("LightPlayer disconnected"))) + Ok(UiNotices::new().with_notice(UiNotice::info("LightPlayer disconnected"))) } - async fn provision_firmware(&mut self, updates: UxUpdateSink) -> UxResult { + async fn reset_device(&mut self, updates: UxUpdateSink) -> UiResult { self.project.reset(); self.device.server.disconnect(); let captured_logs = Rc::new(RefCell::new(Vec::new())); let management_updates = capture_log_updates( retarget_activity_updates( updates.clone(), - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_DEVICE), + ), + Rc::clone(&captured_logs), + ); + let management = match self + .device + .link + .manage_with_updates( + LinkManagementRequest::ResetRuntime, + "Resetting device", + management_updates, + ) + .await + { + Ok(management) => management, + Err(error) => { + self.record_logs(core::mem::take(&mut *captured_logs.borrow_mut())); + return Err(error); + } + }; + self.record_logs(core::mem::take(&mut *captured_logs.borrow_mut())); + self.device.record_logs(&management.logs); + self.logs.extend(management.logs); + + let mut outcome = UiNotices::new().with_notice(UiNotice::info("Device reset")); + emit_activity( + &updates, + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), + "Reconnecting device", + "Connecting", + "Waiting for device boot", + ); + match self.device.link.reopen_active_connection().await { + Ok(connected) => match self.attach_connected_link(connected, updates).await { + Ok(mut attach_outcome) => { + outcome.notices.append(&mut attach_outcome.notices); + Ok(outcome) + } + Err(error) => { + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", + format!("device reset but server reconnect failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UiNotice::info( + "Device reset; reconnect after it finishes booting", + ))) + } + }, + Err(error) => { + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", + format!("device reset but serial reopen failed: {error}"), + )); + self.device.server.fail(error.to_string()); + Ok(outcome.with_notice(UiNotice::info( + "Device reset; reconnect the device after it finishes booting", + ))) + } + } + } + + async fn provision_firmware(&mut self, updates: UxUpdateSink) -> UiResult { + self.project.reset(); + self.device.server.disconnect(); + let captured_logs = Rc::new(RefCell::new(Vec::new())); + let management_updates = capture_log_updates( + retarget_activity_updates( + updates.clone(), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), ), Rc::clone(&captured_logs), ); @@ -452,10 +632,10 @@ impl StudioUx { }; self.device.record_logs(&management.logs); self.logs.extend(management.logs); - let mut outcome = UxOutcome::new().with_notice(provision_notice(&management.result)); + let mut outcome = UiNotices::new().with_notice(provision_notice(&management.result)); emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), "Reconnecting device", "Connecting", "Waiting for firmware boot", @@ -467,39 +647,39 @@ impl StudioUx { Ok(outcome) } Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", format!("firmware flashed but server reconnect failed: {error}"), )); self.device.server.fail(error.to_string()); - Ok(outcome.with_notice(UxNotice::info( + Ok(outcome.with_notice(UiNotice::info( "Firmware flashed; reconnect the server after the device finishes booting", ))) } }, Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", format!("firmware flashed but serial reopen failed: {error}"), )); self.device.server.fail(error.to_string()); - Ok(outcome.with_notice(UxNotice::info( + Ok(outcome.with_notice(UiNotice::info( "Firmware flashed; reconnect the device after it finishes booting", ))) } } } - async fn reset_to_blank(&mut self, updates: UxUpdateSink) -> UxResult { + async fn reset_to_blank(&mut self, updates: UxUpdateSink) -> UiResult { self.project.reset(); self.device.server.disconnect(); let captured_logs = Rc::new(RefCell::new(Vec::new())); let management_updates = capture_log_updates( retarget_activity_updates( updates.clone(), - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), ), Rc::clone(&captured_logs), ); @@ -521,10 +701,10 @@ impl StudioUx { }; self.device.record_logs(&management.logs); self.logs.extend(management.logs); - let mut outcome = UxOutcome::new().with_notice(reset_notice(&management.result)); + let mut outcome = UiNotices::new().with_notice(reset_notice(&management.result)); emit_activity( &updates, - device_section_target(DeviceUx::SECTION_CONNECT_LIGHTPLAYER), + device_section_target(DeviceController::SECTION_CONNECT_LIGHTPLAYER), "Reconnecting device", "Connecting", "Checking for LightPlayer firmware", @@ -536,25 +716,25 @@ impl StudioUx { Ok(outcome) } Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", format!("device wiped but server reconnect failed: {error}"), )); self.device.server.fail(error.to_string()); - Ok(outcome.with_notice(UxNotice::info( + Ok(outcome.with_notice(UiNotice::info( "Device wiped; reconnect after the device finishes booting", ))) } }, Err(error) => { - self.logs.push(UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", + self.logs.push(UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", format!("device wiped but serial reopen failed: {error}"), )); self.device.server.fail(error.to_string()); - Ok(outcome.with_notice(UxNotice::info( + Ok(outcome.with_notice(UiNotice::info( "Device wiped; reconnect the device after it finishes booting", ))) } @@ -565,7 +745,7 @@ impl StudioUx { matches!(self.project.snapshot().state, ProjectState::Ready { .. }) } - fn record_logs(&mut self, logs: Vec) { + fn record_logs(&mut self, logs: Vec) { if logs.is_empty() { return; } @@ -574,28 +754,36 @@ impl StudioUx { } } -impl Default for StudioUx { +impl Default for StudioController { fn default() -> Self { Self::new() } } -impl UxContext for StudioUx { +impl ControllerContext for StudioController { fn dispatch( &mut self, action: UiAction, - ) -> core::pin::Pin + '_>> { - Box::pin(StudioUx::dispatch(self, action)) + ) -> core::pin::Pin + '_>> { + Box::pin(StudioController::dispatch(self, action)) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AutoProjectConnect { - Connected, + Connected { synced: bool }, SelectionRequired, NotFound, } +fn project_sync_notice(synced: bool, success: &str, needs_attention: &str) -> UiNotice { + if synced { + UiNotice::info(success) + } else { + UiNotice::warning(needs_attention) + } +} + fn should_auto_load_demo_project(connection: &LinkConnection) -> bool { matches!(connection.kind, LinkConnectionKind::BrowserWorker { .. }) } @@ -610,12 +798,12 @@ fn emit_activity( updates.emit(UxUpdate::Activity { target, status: UiStatus::working(status), - activity: UiActivity::new(title).with_detail(detail), + activity: UiActivityView::new(title).with_detail(detail), }); } fn device_section_target(section_id: &'static str) -> UxActivityTarget { - UxActivityTarget::stack_section(DeviceUx::NODE_ID, section_id) + UxActivityTarget::stack_section(DeviceController::NODE_ID, section_id) } fn retarget_activity_updates(updates: UxUpdateSink, target: UxActivityTarget) -> UxUpdateSink { @@ -633,7 +821,7 @@ fn retarget_activity_updates(updates: UxUpdateSink, target: UxActivityTarget) -> fn capture_log_updates( updates: UxUpdateSink, - captured_logs: Rc>>, + captured_logs: Rc>>, ) -> UxUpdateSink { UxUpdateSink::new(move |update| { if let UxUpdate::Log(log) = &update { @@ -643,7 +831,7 @@ fn capture_log_updates( }) } -fn view_actions(view: &StudioView) -> Vec { +fn view_actions(view: &UiStudioView) -> Vec { let mut actions = Vec::new(); for pane in &view.panes { actions.extend(pane.actions.clone()); @@ -652,9 +840,9 @@ fn view_actions(view: &StudioView) -> Vec { actions } -fn body_actions(body: &UiBody) -> Vec { +fn body_actions(body: &UiViewContent) -> Vec { match body { - UiBody::Stack(stack) => stack + UiViewContent::Stack(stack) => stack .sections .iter() .flat_map(|section| { @@ -663,15 +851,30 @@ fn body_actions(body: &UiBody) -> Vec { actions }) .collect(), - UiBody::Empty - | UiBody::Text(_) - | UiBody::Progress(_) - | UiBody::Activity(_) - | UiBody::Issue(_) - | UiBody::Metrics(_) => Vec::new(), + UiViewContent::Empty + | UiViewContent::Text(_) + | UiViewContent::Progress(_) + | UiViewContent::Activity(_) + | UiViewContent::Issue(_) + | UiViewContent::Metrics(_) => Vec::new(), + UiViewContent::ProjectEditor(editor) => editor + .tree + .roots + .iter() + .flat_map(project_tree_item_actions) + .collect(), } } +fn project_tree_item_actions( + item: &crate::ProjectNodeTreeItem, +) -> Box + '_> { + Box::new( + core::iter::once(item.action.clone()) + .chain(item.children.iter().flat_map(project_tree_item_actions)), + ) +} + fn should_reopen_before_server_connect(connection: &LinkConnection) -> bool { matches!( connection.kind, @@ -679,52 +882,68 @@ fn should_reopen_before_server_connect(connection: &LinkConnection) -> bool { ) } -fn provision_notice(result: &LinkManagementResult) -> UxNotice { +fn provision_notice(result: &LinkManagementResult) -> UiNotice { match result { LinkManagementResult::FlashFirmware(result) => { - UxNotice::info(format!("Flashed {}", result.manifest.display_name)) + UiNotice::info(format!("Flashed {}", result.manifest.display_name)) } - _ => UxNotice::info("Firmware flashed"), + _ => UiNotice::info("Firmware flashed"), } } -fn reset_notice(result: &LinkManagementResult) -> UxNotice { +fn reset_notice(result: &LinkManagementResult) -> UiNotice { match result { LinkManagementResult::EraseDeviceFlash(result) => { let label = result.chip_name.as_deref().unwrap_or("selected ESP32"); - UxNotice::info(format!("{label} wiped")) + UiNotice::info(format!("{label} wiped")) } - _ => UxNotice::info("Device wiped"), + _ => UiNotice::info("Device wiped"), } } #[cfg(test)] mod tests { + use std::collections::VecDeque; use std::future::Future; + use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll, Wake, Waker}; use std::cell::RefCell; use std::rc::Rc; + use lpa_client::ClientIo; use lpa_link::providers::LinkProviderRegistry; use lpa_link::providers::fake::FakeProvider; use lpa_link::{ LinkCapabilities, LinkConnection, LinkConnectionKind, LinkEndpoint, LinkEndpointId, LinkProviderKind, LinkSession, }; - - use crate::{ - ConnectedDeviceSummary, LinkState, LinkUx, ProjectInventorySummary, ProjectState, - ProjectUx, ServerFailureKind, ServerState, ServerUx, UiStatusKind, UiStepState, UxIssue, - UxNodeId, + use lpc_model::{ + LpType, LpValue, NodeId, ProductKind, ProductRef, Revision, SlotData, SlotFieldShape, + SlotMeta, SlotRecord, SlotShape, SlotShapeId, TreePath, VisualProduct, WithRevision, + }; + use lpc_view::{ProjectView, TreeEntryView}; + use lpc_wire::{ + ClientMessage, ClientRequest, MemoryStats, NodeRuntimeStatus, ProjectProbeRequest, + ProjectReadResponse, ProjectReadResult, ProjectRuntimeStatus, RenderProductProbeRequest, + RuntimeReadResult, ServerRuntimeStatus, TransportError, WireEntryState, WireServerMessage, + WireServerMsgBody, WireTextureFormat, }; use super::*; + use crate::core::status::UiStatusKind; + use crate::core::view::steps_view::UiStepState; + use crate::{ + ConnectedDeviceSummary, ControllerId, LinkController, LinkState, ProjectController, + ProjectEditorOp, ProjectEditorTarget, ProjectInventorySummary, ProjectNodeAddress, + ProjectNodeTarget, ProjectState, ProjectSyncPhase, ServerController, ServerFailureKind, + ServerState, StudioServerClient, UiIssue, UiProductPreviewFrame, + }; #[test] fn initial_snapshot_selects_provider() { - let studio = StudioUx::new(); + let studio = StudioController::new(); assert!(matches!( studio.snapshot().link.state, @@ -734,25 +953,25 @@ mod tests { #[test] fn initial_actions_target_device_node() { - let studio = StudioUx::new(); + let studio = StudioController::new(); let actions = studio.actions(); assert!( actions .iter() - .all(|action| action.node_id().as_str() == DeviceUx::NODE_ID) + .all(|action| action.node_id().as_str() == DeviceController::NODE_ID) ); } #[test] fn initial_view_exposes_device_pane() { - let studio = StudioUx::new(); + let studio = StudioController::new(); let view = studio.view(); assert_eq!(view.panes.len(), 1); - assert_eq!(view.panes[0].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!(view.panes[0].node_id.as_str(), DeviceController::NODE_ID); assert_eq!(device_section_ids(&view), vec!["select-connection"]); } @@ -765,7 +984,7 @@ mod tests { let actions = view_actions(&view); assert_eq!(view.panes.len(), 1); - assert_eq!(view.panes[0].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!(view.panes[0].node_id.as_str(), DeviceController::NODE_ID); assert_eq!( device_section_ids(&view), vec![ @@ -825,7 +1044,7 @@ mod tests { .link .set_active_session_for_test(management_capable_session()); studio.device.server.set_state(ServerState::Failed { - issue: UxIssue::new("No LightPlayer firmware detected."), + issue: UiIssue::new("No LightPlayer firmware detected."), kind: ServerFailureKind::NoFirmware, }); @@ -838,7 +1057,7 @@ mod tests { device_section_ids(&view), vec!["select-connection", "connect-device", "connect-lightplayer"] ); - let UiBody::Stack(stack) = &view.panes[0].body else { + let UiViewContent::Stack(stack) = &view.panes[0].body else { panic!("device pane should render a stack view"); }; let lightplayer_section = stack @@ -848,7 +1067,7 @@ mod tests { .expect("connect lightplayer section should exist"); assert_eq!(lightplayer_section.title, "LightPlayer unavailable"); assert_eq!(lightplayer_section.state, UiStepState::Active); - assert!(matches!(lightplayer_section.body, UiBody::Text(_))); + assert!(matches!(lightplayer_section.body, UiViewContent::Text(_))); let device_section = stack .sections .iter() @@ -887,8 +1106,8 @@ mod tests { let actions = view_actions(&view); assert_eq!(view.panes.len(), 2); - assert_eq!(view.panes[0].node_id.as_str(), ProjectUx::NODE_ID); - assert_eq!(view.panes[1].node_id.as_str(), DeviceUx::NODE_ID); + assert_eq!(view.panes[0].node_id.as_str(), ProjectController::NODE_ID); + assert_eq!(view.panes[1].node_id.as_str(), DeviceController::NODE_ID); assert_eq!( device_section_ids(&view), vec![ @@ -912,6 +1131,27 @@ mod tests { ))); } + #[test] + fn connected_lightplayer_offers_non_destructive_device_reset() { + let mut studio = connected_studio(); + studio + .device + .link + .set_active_session_for_test(management_capable_session()); + + let actions = view_actions(&studio.view()); + + assert!( + actions + .iter() + .any(|action| matches!(action.op_as::(), Some(DeviceOp::ResetDevice))) + ); + assert!(actions.iter().any(|action| matches!( + action.op_as::(), + Some(DeviceOp::DisconnectLightPlayer) + ))); + } + #[test] fn project_disconnect_leaves_server_and_link_connected() { let mut studio = connected_studio(); @@ -983,10 +1223,204 @@ mod tests { )); } + #[test] + fn device_action_dispatch_routes_exact_device_target() { + let mut studio = connected_studio(); + let action = UiAction::from_op( + ControllerId::new(DeviceController::NODE_ID), + DeviceOp::DisconnectDevice, + ); + + block_on_ready(studio.dispatch(action)).unwrap(); + + assert!(matches!( + studio.project.snapshot().state, + ProjectState::NotLoaded + )); + assert!(matches!( + studio.device.server.snapshot().state, + ServerState::Disconnected + )); + assert!(matches!( + studio.device.link.snapshot().state, + LinkState::SelectingProvider { .. } + )); + } + + #[test] + fn project_action_dispatch_routes_exact_project_target() { + let mut studio = connected_studio(); + let action = UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + ProjectOp::DisconnectProject, + ); + + block_on_ready(studio.dispatch(action)).unwrap(); + + assert!(matches!( + studio.project.snapshot().state, + ProjectState::NotLoaded + )); + assert!(matches!( + studio.device.server.snapshot().state, + ServerState::Connected { .. } + )); + assert!(matches!( + studio.device.link.snapshot().state, + LinkState::Connected { .. } + )); + } + + #[test] + fn refresh_project_dispatch_reads_project_and_updates_sync_summary() { + let sent = Rc::new(RefCell::new(Vec::new())); + let io = ScriptedClientIo::new( + Rc::clone(&sent), + vec![project_read_response_with_runtime(1, Revision::new(13))], + ); + let mut studio = connected_studio_with_client(io); + let action = UiAction::from_op( + ControllerId::new(ProjectController::NODE_ID), + ProjectOp::RefreshProject, + ); + + let outcome = block_on_ready(studio.dispatch(action)).unwrap(); + + assert!( + outcome + .notices + .iter() + .any(|notice| notice.message == "Project refreshed") + ); + let sent = sent.borrow(); + assert_eq!(sent.len(), 1); + let ClientRequest::ProjectRequest { handle, request } = &sent[0].msg else { + panic!("refresh should send a project read request"); + }; + assert_eq!(sent[0].id, 1); + assert_eq!(handle.id(), 7); + assert_eq!(request.since, None); + assert_eq!(request.queries.len(), 3); + + let sync = studio + .project + .snapshot() + .sync + .expect("refresh should leave a sync summary"); + assert_eq!(sync.phase, ProjectSyncPhase::Ready); + assert_eq!(sync.revision, 13); + assert_eq!( + sync.runtime.as_ref().map(|runtime| runtime.frame_num), + Some(77) + ); + assert_eq!( + sync.runtime.as_ref().and_then(|runtime| runtime.free_bytes), + Some(4096) + ); + } + + #[test] + fn project_descendant_action_dispatch_routes_to_project_ux() { + let mut studio = StudioController::new(); + let target = ProjectEditorTarget::node_tree(); + let action = UiAction::from_op(target.node_id(), ProjectEditorOp::Focus); + + block_on_ready(studio.dispatch(action)).unwrap(); + + assert_eq!(studio.project.active_editor_target(), Some(&target)); + } + + #[test] + fn project_node_focus_dispatch_requests_visual_product_preview() { + let sent = Rc::new(RefCell::new(Vec::new())); + let io = ScriptedClientIo::new( + Rc::clone(&sent), + vec![project_read_response_with_runtime(1, Revision::new(13))], + ); + let mut studio = connected_studio_with_client(io); + studio + .project + .apply_project_view(&single_product_project_view(3)) + .unwrap(); + let product = VisualProduct::new(NodeId::new(3), 0); + let target = ProjectEditorTarget::addressed_node(ProjectNodeTarget::new( + ProjectNodeAddress::new(TreePath::parse("/demo.project/orbit.shader").unwrap()), + NodeId::new(3), + )); + let action = UiAction::from_op(target.node_id(), ProjectEditorOp::Focus); + + block_on_ready(studio.dispatch(action)).unwrap(); + + let sent = sent.borrow(); + assert_eq!(sent.len(), 1); + let ClientRequest::ProjectRequest { request, .. } = &sent[0].msg else { + panic!("node focus should send a project read request"); + }; + assert_eq!( + request.probes, + vec![ProjectProbeRequest::RenderProduct( + RenderProductProbeRequest { + product, + width: UiProductPreviewFrame::VISUAL_DEFAULT.width, + height: UiProductPreviewFrame::VISUAL_DEFAULT.height, + format: WireTextureFormat::Srgb8, + }, + )] + ); + } + + #[test] + fn unknown_top_level_dispatch_fails_clearly() { + let mut studio = StudioController::new(); + let action = UiAction::from_op(ControllerId::new("studio|unknown"), ProjectEditorOp::Focus); + + let result = block_on_ready(studio.dispatch(action)); + + assert!(matches!( + result, + Err(UiError::UnsupportedAction(message)) + if message.contains("unknown UX node studio|unknown") + )); + } + + #[test] + fn unknown_project_descendant_dispatch_fails_as_project_target() { + let mut studio = StudioController::new(); + let action = UiAction::from_op( + ControllerId::new("studio|project|unknown"), + ProjectEditorOp::Focus, + ); + + let result = block_on_ready(studio.dispatch(action)); + + assert!(matches!( + result, + Err(UiError::UnsupportedAction(message)) + if message.contains("unknown project editor target studio|project|unknown") + )); + } + + #[test] + fn project_descendant_dispatch_rejects_wrong_op_type() { + let mut studio = StudioController::new(); + let action = UiAction::from_op( + ProjectEditorTarget::node_tree().node_id(), + ProjectOp::LoadDemoProject, + ); + + let result = block_on_ready(studio.dispatch(action)); + + assert!(matches!( + result, + Err(UiError::UnsupportedAction(message)) + if message.contains("ProjectEditorOp") + )); + } + #[test] fn failed_link_dispatch_emits_final_failed_view_after_activity() { - let mut studio = StudioUx::new(); - studio.device.link = LinkUx::with_registry(registry_with_fake_connect_error( + let mut studio = StudioController::new(); + studio.device.link = LinkController::with_registry(registry_with_fake_connect_error( "Failed to open serial port.", )); let updates = Rc::new(RefCell::new(Vec::new())); @@ -997,7 +1431,7 @@ mod tests { } }); let action = UiAction::from_op( - UxNodeId::new(DeviceUx::NODE_ID), + ControllerId::new(DeviceController::NODE_ID), DeviceOp::ConnectEndpoint { provider_id: LinkProviderKind::Fake, endpoint_id: LinkEndpointId::new("fake-runtime"), @@ -1006,7 +1440,7 @@ mod tests { let result = block_on_ready(studio.dispatch_with_updates(action, sink)); - assert!(matches!(result, Err(UxError::Link(_)))); + assert!(matches!(result, Err(UiError::Link(_)))); assert!(updates.borrow().iter().any(|update| { matches!( update, @@ -1017,8 +1451,8 @@ mod tests { }, activity, .. - } if pane_node_id.as_str() == DeviceUx::NODE_ID - && section_id == DeviceUx::SECTION_CONNECT_DEVICE + } if pane_node_id.as_str() == DeviceController::NODE_ID + && section_id == DeviceController::SECTION_CONNECT_DEVICE && activity.title == "Opening device session" ) })); @@ -1054,15 +1488,15 @@ mod tests { } }); let target = UxActivityTarget::stack_section( - DeviceUx::NODE_ID, - DeviceUx::SECTION_CONNECT_LIGHTPLAYER, + DeviceController::NODE_ID, + DeviceController::SECTION_CONNECT_LIGHTPLAYER, ); let retargeted = retarget_activity_updates(sink, target.clone()); retargeted.emit(UxUpdate::Activity { - target: UxActivityTarget::pane(ServerUx::NODE_ID), + target: UxActivityTarget::pane(ServerController::NODE_ID), status: UiStatus::working("Connecting"), - activity: UiActivity::new("Connecting ESP32 server"), + activity: UiActivityView::new("Connecting ESP32 server"), }); assert!(matches!( @@ -1074,7 +1508,7 @@ mod tests { )); } - fn connected_studio() -> StudioUx { + fn connected_studio() -> StudioController { let mut studio = link_connected_studio(); studio.device.server.set_state(ServerState::Connected { protocol: "fake-protocol".to_string(), @@ -1085,8 +1519,23 @@ mod tests { studio } - fn link_connected_studio() -> StudioUx { - let mut studio = StudioUx::new(); + fn connected_studio_with_client(io: ScriptedClientIo) -> StudioController { + let mut studio = link_connected_studio(); + studio + .device + .server + .set_client_for_test(StudioServerClient::from_io_for_test( + "fake-protocol", + Box::new(io), + )); + studio + .project + .mark_ready("loaded-project", 7, ProjectInventorySummary::default()); + studio + } + + fn link_connected_studio() -> StudioController { + let mut studio = StudioController::new(); studio.device.link.set_state(LinkState::Connected { device: ConnectedDeviceSummary::new( LinkProviderKind::Fake, @@ -1098,13 +1547,64 @@ mod tests { studio } - fn device_section_ids(view: &StudioView) -> Vec<&str> { + fn single_product_project_view(node_id: u32) -> ProjectView { + let revision = Revision::new(1); + let path = TreePath::parse("/demo.project/orbit.shader").unwrap(); + let state_shape = SlotShapeId::new(700); + let mut view = ProjectView::new(); + view.tree.insert(TreeEntryView::new( + NodeId::new(node_id), + path, + None, + None, + NodeRuntimeStatus::Ok, + WireEntryState::Alive, + revision, + revision, + revision, + )); + view.slots + .registry + .register_dynamic_shape( + state_shape, + SlotShape::Record { + meta: SlotMeta::empty(), + fields: vec![ + SlotFieldShape::new( + "output", + SlotShape::value(LpType::Product(ProductKind::Visual)), + ) + .unwrap(), + ], + }, + ) + .unwrap(); + view.slots + .root_shapes + .insert(format!("node.{node_id}.state"), state_shape); + view.slots.roots.insert( + format!("node.{node_id}.state"), + SlotData::Record(SlotRecord::with_revision( + revision, + vec![SlotData::Value(WithRevision::new( + revision, + LpValue::Product(ProductRef::visual(VisualProduct::new( + NodeId::new(node_id), + 0, + ))), + ))], + )), + ); + view + } + + fn device_section_ids(view: &UiStudioView) -> Vec<&str> { let device_pane = view .panes .iter() - .find(|pane| pane.node_id.as_str() == DeviceUx::NODE_ID) + .find(|pane| pane.node_id.as_str() == DeviceController::NODE_ID) .expect("device pane should exist"); - let UiBody::Stack(stack) = &device_pane.body else { + let UiViewContent::Stack(stack) = &device_pane.body else { panic!("device pane should render stack"); }; stack @@ -1140,6 +1640,89 @@ mod tests { ) } + fn project_read_response_with_runtime(id: u64, revision: Revision) -> WireServerMessage { + WireServerMessage { + id, + msg: WireServerMsgBody::ProjectRequest { + response: ProjectReadResponse { + revision, + results: vec![ProjectReadResult::Runtime(RuntimeReadResult { + project: ProjectRuntimeStatus { + revision, + frame_num: 77, + frame_delta_ms: 16, + frame_total_ms: 17, + demand_root_count: 2, + runtime_buffer_count: 3, + }, + server: Some(ServerRuntimeStatus { + theoretical_fps: Some(60.0), + last_frame_time_us: Some(16_000), + memory: Some(MemoryStats { + free_bytes: 4096, + used_bytes: 2048, + total_bytes: 6144, + }), + }), + })], + probes: Vec::new(), + }, + }, + } + } + + struct ScriptedClientIo { + sent: Rc>>, + responses: Rc>>, + } + + impl ScriptedClientIo { + fn new(sent: Rc>>, responses: Vec) -> Self { + Self { + sent, + responses: Rc::new(RefCell::new(responses.into())), + } + } + } + + impl ClientIo for ScriptedClientIo { + fn send<'life0, 'async_trait>( + &'life0 mut self, + msg: ClientMessage, + ) -> Pin> + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + self.sent.borrow_mut().push(msg); + Box::pin(async { Ok(()) }) + } + + fn receive<'life0, 'async_trait>( + &'life0 mut self, + ) -> Pin> + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + let response = + self.responses.borrow_mut().pop_front().ok_or_else(|| { + TransportError::Other("scripted client io exhausted".to_string()) + }); + Box::pin(async move { response }) + } + + fn close<'life0, 'async_trait>( + &'life0 mut self, + ) -> Pin> + 'async_trait>> + where + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async { Ok(()) }) + } + } + fn block_on_ready(future: F) -> F::Output where F: Future, diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs b/lp-app/lpa-studio-core/src/app/studio/studio_snapshot.rs similarity index 77% rename from lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs rename to lp-app/lpa-studio-core/src/app/studio/studio_snapshot.rs index 0f1ac6b7e..b699d8c7c 100644 --- a/lp-app/lpa-studio-ux/src/nodes/studio/studio_snapshot.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_snapshot.rs @@ -1,11 +1,11 @@ -use crate::{LinkSnapshot, ProjectSnapshot, ServerSnapshot, UxLogEntry}; +use crate::{LinkSnapshot, ProjectSnapshot, ServerSnapshot, UiLogEntry}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct StudioSnapshot { pub link: LinkSnapshot, pub server: ServerSnapshot, pub project: ProjectSnapshot, - pub logs: Vec, + pub logs: Vec, } impl StudioSnapshot { @@ -13,7 +13,7 @@ impl StudioSnapshot { link: LinkSnapshot, server: ServerSnapshot, project: ProjectSnapshot, - logs: Vec, + logs: Vec, ) -> Self { Self { link, diff --git a/lp-app/lpa-studio-ux/src/ui/studio_view.rs b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs similarity index 87% rename from lp-app/lpa-studio-ux/src/ui/studio_view.rs rename to lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs index 733e9da13..c3f71d24b 100644 --- a/lp-app/lpa-studio-ux/src/ui/studio_view.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ui_studio_view.rs @@ -1,15 +1,15 @@ use core::fmt::Write; -use crate::{ActionPriority, UiPaneView, UxLogEntry}; +use crate::{ActionPriority, UiLogEntry, UiPaneView}; -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StudioView { +#[derive(Clone, Debug, PartialEq)] +pub struct UiStudioView { pub panes: Vec, - pub logs: Vec, + pub logs: Vec, } -impl StudioView { - pub fn new(panes: Vec, logs: Vec) -> Self { +impl UiStudioView { + pub fn new(panes: Vec, logs: Vec) -> Self { Self { panes, logs } } diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs b/lp-app/lpa-studio-core/src/app/studio/ux_update.rs similarity index 56% rename from lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs rename to lp-app/lpa-studio-core/src/app/studio/ux_update.rs index c1bd01401..23ad1f3e2 100644 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ux_update.rs @@ -1,42 +1,45 @@ -use crate::{StudioView, UiActivity, UiStatus, UxLogEntry, UxNodeId}; +use crate::{ControllerId, UiActivityView, UiLogEntry, UiStatus, UiStudioView}; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub enum UxUpdate { - View(StudioView), + View(UiStudioView), Activity { target: UxActivityTarget, status: UiStatus, - activity: UiActivity, + activity: UiActivityView, }, - Log(UxLogEntry), + Log(UiLogEntry), } #[derive(Clone, Debug, Eq, PartialEq)] pub enum UxActivityTarget { Pane { - node_id: UxNodeId, + node_id: ControllerId, }, StackSection { - pane_node_id: UxNodeId, + pane_node_id: ControllerId, section_id: String, }, } impl UxActivityTarget { - pub fn pane(node_id: impl Into) -> Self { + pub fn pane(node_id: impl Into) -> Self { Self::Pane { node_id: node_id.into(), } } - pub fn stack_section(pane_node_id: impl Into, section_id: impl Into) -> Self { + pub fn stack_section( + pane_node_id: impl Into, + section_id: impl Into, + ) -> Self { Self::StackSection { pane_node_id: pane_node_id.into(), section_id: section_id.into(), } } - pub fn pane_node_id(&self) -> &UxNodeId { + pub fn pane_node_id(&self) -> &ControllerId { match self { Self::Pane { node_id } => node_id, Self::StackSection { pane_node_id, .. } => pane_node_id, diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs b/lp-app/lpa-studio-core/src/app/studio/ux_update_sink.rs similarity index 82% rename from lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs rename to lp-app/lpa-studio-core/src/app/studio/ux_update_sink.rs index 702d4a3d1..90a537579 100644 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_update_sink.rs +++ b/lp-app/lpa-studio-core/src/app/studio/ux_update_sink.rs @@ -29,7 +29,7 @@ mod tests { use std::cell::RefCell; use std::rc::Rc; - use crate::{StudioView, UxUpdate}; + use crate::{UiStudioView, UxUpdate}; use super::*; @@ -43,8 +43,8 @@ mod tests { } }); - sink.emit(UxUpdate::View(StudioView::new(Vec::new(), Vec::new()))); - sink.emit(UxUpdate::View(StudioView::new(Vec::new(), Vec::new()))); + sink.emit(UxUpdate::View(UiStudioView::new(Vec::new(), Vec::new()))); + sink.emit(UxUpdate::View(UiStudioView::new(Vec::new(), Vec::new()))); assert_eq!(*count.borrow(), 2); } diff --git a/lp-app/lpa-studio-ux/src/node/ux_context.rs b/lp-app/lpa-studio-core/src/controller/context.rs similarity index 55% rename from lp-app/lpa-studio-ux/src/node/ux_context.rs rename to lp-app/lpa-studio-core/src/controller/context.rs index 7a792354e..b2f1d87a9 100644 --- a/lp-app/lpa-studio-ux/src/node/ux_context.rs +++ b/lp-app/lpa-studio-core/src/controller/context.rs @@ -1,8 +1,8 @@ use core::future::Future; use core::pin::Pin; -use crate::{UiAction, UxResult}; +use crate::{UiAction, UiResult}; -pub trait UxContext { - fn dispatch(&mut self, action: UiAction) -> Pin + '_>>; +pub trait ControllerContext { + fn dispatch(&mut self, action: UiAction) -> Pin + '_>>; } diff --git a/lp-app/lpa-studio-core/src/controller/controller_id.rs b/lp-app/lpa-studio-core/src/controller/controller_id.rs new file mode 100644 index 000000000..7fb86ea4e --- /dev/null +++ b/lp-app/lpa-studio-core/src/controller/controller_id.rs @@ -0,0 +1,209 @@ +use core::fmt; + +const CONTROLLER_ID_SEPARATOR: char = '|'; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ControllerId { + value: String, + segments: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct UxNodePath<'a> { + segments: &'a [String], +} + +impl ControllerId { + pub fn new(value: impl Into) -> Self { + let value = value.into(); + let segments = parse_segment_path(&value); + Self { value, segments } + } + + pub fn from_segments(segments: impl IntoIterator>) -> Self { + let segments = segments.into_iter().map(Into::into).collect::>(); + validate_segments(&segments); + let value = segments.join(&CONTROLLER_ID_SEPARATOR.to_string()); + Self { value, segments } + } + + pub fn as_str(&self) -> &str { + &self.value + } + + pub fn segments(&self) -> UxNodePath<'_> { + UxNodePath { + segments: &self.segments, + } + } + + pub fn child(&self, segment: impl Into) -> Self { + let segment = segment.into(); + validate_segment(&segment); + let mut segments = self.segments.clone(); + segments.push(segment); + Self::from_segments(segments) + } + + pub fn is_descendant_of(&self, parent: &ControllerId) -> bool { + self.segments.len() > parent.segments.len() && self.segments.starts_with(&parent.segments) + } + + pub fn is_self_or_descendant_of(&self, parent: &ControllerId) -> bool { + self.segments.starts_with(&parent.segments) + } + + pub fn strip_prefix<'a>(&'a self, parent: &ControllerId) -> Option> { + self.is_self_or_descendant_of(parent).then(|| UxNodePath { + segments: &self.segments[parent.segments.len()..], + }) + } +} + +impl fmt::Display for ControllerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From for ControllerId { + fn from(value: String) -> Self { + Self::new(value) + } +} + +impl From<&str> for ControllerId { + fn from(value: &str) -> Self { + Self::new(value) + } +} + +impl<'a> UxNodePath<'a> { + pub fn is_empty(&self) -> bool { + self.segments.is_empty() + } + + pub fn len(&self) -> usize { + self.segments.len() + } + + pub fn get(&self, index: usize) -> Option<&'a str> { + self.segments.get(index).map(String::as_str) + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.segments.iter().map(String::as_str) + } + + pub fn as_slice(&self) -> &'a [String] { + self.segments + } +} + +fn parse_segment_path(value: &str) -> Vec { + let segments = value + .split(CONTROLLER_ID_SEPARATOR) + .map(ToString::to_string) + .collect::>(); + validate_segments(&segments); + segments +} + +fn validate_segments(segments: &[String]) { + assert!( + !segments.is_empty(), + "UX node id must have at least one segment" + ); + for segment in segments { + validate_segment(segment); + } +} + +fn validate_segment(segment: &str) { + assert!(!segment.is_empty(), "UX node id segments must not be empty"); + assert!( + !segment.contains(CONTROLLER_ID_SEPARATOR), + "UX node id segments must not contain separators" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn static_id_keeps_display_and_segments() { + let node_id = ControllerId::new("studio|project"); + + assert_eq!(node_id.as_str(), "studio|project"); + assert_eq!(node_id.to_string(), "studio|project"); + assert_eq!( + node_id.segments().iter().collect::>(), + vec!["studio", "project"] + ); + } + + #[test] + fn from_segments_builds_display() { + let node_id = ControllerId::from_segments(["studio", "project", "node_tree"]); + + assert_eq!(node_id.as_str(), "studio|project|node_tree"); + } + + #[test] + fn child_appends_one_segment() { + let node_id = ControllerId::new("studio|project").child("node_tree"); + + assert_eq!(node_id.as_str(), "studio|project|node_tree"); + } + + #[test] + fn child_accepts_model_path_punctuation() { + let node_id = ControllerId::new("studio|project") + .child("path") + .child("/demo.project/orbit.shader") + .child("slot") + .child(r#"params["phase.offset"].label"#); + + assert_eq!( + node_id.as_str(), + r#"studio|project|path|/demo.project/orbit.shader|slot|params["phase.offset"].label"# + ); + } + + #[test] + fn descendant_checks_are_strict() { + let project = ControllerId::new("studio|project"); + let node_tree = project.child("node_tree"); + let device = ControllerId::new("studio|device"); + + assert!(node_tree.is_descendant_of(&project)); + assert!(node_tree.is_self_or_descendant_of(&project)); + assert!(project.is_self_or_descendant_of(&project)); + assert!(!project.is_descendant_of(&project)); + assert!(!device.is_descendant_of(&project)); + } + + #[test] + fn strip_prefix_returns_tail_segments() { + let project = ControllerId::new("studio|project"); + let slot = project + .child("node") + .child("4") + .child("slot") + .child("brightness"); + + let tail = slot.strip_prefix(&project).unwrap(); + + assert_eq!( + tail.iter().collect::>(), + vec!["node", "4", "slot", "brightness"] + ); + } + + #[test] + #[should_panic(expected = "UX node id segments must not contain separators")] + fn child_rejects_separator_segment() { + let _ = ControllerId::new("studio|project").child("node|tree"); + } +} diff --git a/lp-app/lpa-studio-ux/src/node/ux_node.rs b/lp-app/lpa-studio-core/src/controller/controller_op.rs similarity index 64% rename from lp-app/lpa-studio-ux/src/node/ux_node.rs rename to lp-app/lpa-studio-core/src/controller/controller_op.rs index 97cc9cfcd..d787a3feb 100644 --- a/lp-app/lpa-studio-ux/src/node/ux_node.rs +++ b/lp-app/lpa-studio-core/src/controller/controller_op.rs @@ -1,9 +1,11 @@ -use crate::{UiAction, UxNodeId, UxOp}; +use crate::{ControllerId, ControllerOp, UiAction}; -pub trait UxNode { - type Op: UxOp; +// +// +pub trait Controller { + type Op: ControllerOp; - fn node_id(&self) -> UxNodeId; + fn node_id(&self) -> ControllerId; fn action(&self, op: Self::Op) -> UiAction { UiAction::from_op(self.node_id(), op) diff --git a/lp-app/lpa-studio-core/src/controller/mod.rs b/lp-app/lpa-studio-core/src/controller/mod.rs new file mode 100644 index 000000000..d5183e148 --- /dev/null +++ b/lp-app/lpa-studio-core/src/controller/mod.rs @@ -0,0 +1,15 @@ +pub mod context; +pub mod controller_id; +pub mod controller_op; +pub mod operation; + +pub use crate::core::action::action::UiAction; +pub use crate::core::action::action_confirmation::ActionConfirmation; +pub use crate::core::action::action_enablement::ActionEnablement; +pub use crate::core::action::action_meta::ActionMeta; +pub use crate::core::action::action_priority::ActionPriority; +pub use crate::core::action::actions::UiActions; +pub use context::ControllerContext; +pub use controller_id::{ControllerId, UxNodePath}; +pub use controller_op::Controller; +pub use operation::ControllerOp; diff --git a/lp-app/lpa-studio-ux/src/node/ux_op.rs b/lp-app/lpa-studio-core/src/controller/operation.rs similarity index 57% rename from lp-app/lpa-studio-ux/src/node/ux_op.rs rename to lp-app/lpa-studio-core/src/controller/operation.rs index 699c81dcb..5cb6de981 100644 --- a/lp-app/lpa-studio-ux/src/node/ux_op.rs +++ b/lp-app/lpa-studio-core/src/controller/operation.rs @@ -3,15 +3,15 @@ use core::fmt; use crate::ActionMeta; -pub trait UxOp: fmt::Debug + 'static { +pub trait ControllerOp: fmt::Debug + 'static { fn default_action_meta(&self) -> ActionMeta; - fn clone_box(&self) -> Box; - fn eq_op(&self, other: &dyn UxOp) -> bool; + fn clone_box(&self) -> Box; + fn eq_op(&self, other: &dyn ControllerOp) -> bool; fn as_any(&self) -> &dyn Any; fn into_any(self: Box) -> Box; } -impl Clone for Box { +impl Clone for Box { fn clone(&self) -> Self { self.clone_box() } diff --git a/lp-app/lpa-studio-ux/src/ui/action/ui_action.rs b/lp-app/lpa-studio-core/src/core/action/action.rs similarity index 60% rename from lp-app/lpa-studio-ux/src/ui/action/ui_action.rs rename to lp-app/lpa-studio-core/src/core/action/action.rs index 7ee0c323d..9027f04d3 100644 --- a/lp-app/lpa-studio-ux/src/ui/action/ui_action.rs +++ b/lp-app/lpa-studio-core/src/core/action/action.rs @@ -1,9 +1,15 @@ -use crate::{ActionConfirmation, ActionMeta, ActionPriority, UxError, UxNodeId, UxOp}; - +use crate::{ActionConfirmation, ActionMeta, ActionPriority, ControllerId, ControllerOp, UiError}; + +/// A user-invokable controller operation with render metadata. +/// +/// `UiAction` is the bridge between controller state and UI controls. The +/// operation remains typed behind `ControllerOp`, while `ActionMeta` carries the +/// label, summary, icon, priority, enablement, and confirmation data that a +/// component needs to render the button. #[derive(Clone, Debug)] pub struct UiAction { - node_id: UxNodeId, - op: Box, + node_id: ControllerId, + op: Box, meta: ActionMeta, } @@ -16,7 +22,11 @@ impl PartialEq for UiAction { impl Eq for UiAction {} impl UiAction { - pub fn from_op(node_id: impl Into, op: impl UxOp) -> Self { + /// Create an action from a controller id and operation. + /// + /// The action metadata starts with `ControllerOp::default_action_meta` and + /// can be refined with the builder-style methods below. + pub fn from_op(node_id: impl Into, op: impl ControllerOp) -> Self { let meta = op.default_action_meta(); Self { node_id: node_id.into(), @@ -25,28 +35,37 @@ impl UiAction { } } - pub fn node_id(&self) -> &UxNodeId { + /// Return the controller id this action targets. + pub fn node_id(&self) -> &ControllerId { &self.node_id } + /// Return the render metadata for this action. pub fn meta(&self) -> &ActionMeta { &self.meta } + /// Return whether this action targets the given controller id string. pub fn is_for_node(&self, node_id: &str) -> bool { self.node_id.as_str() == node_id } + /// Borrow the operation as a concrete operation type. pub fn op_as(&self) -> Option<&T> where - T: UxOp, + T: ControllerOp, { self.op.as_any().downcast_ref::() } - pub fn into_op(self) -> Result + /// Consume the action and recover a concrete operation type. + /// + /// Controllers use this when dispatch has already routed the action to the + /// expected controller. A type mismatch means the action was routed + /// incorrectly or constructed with the wrong operation type. + pub fn into_op(self) -> Result where - T: UxOp, + T: ControllerOp, { let node_id = self.node_id; self.op @@ -54,52 +73,61 @@ impl UiAction { .downcast::() .map(|op| *op) .map_err(|_| { - UxError::UnsupportedAction(format!( + UiError::UnsupportedAction(format!( "action for node {node_id} did not contain operation {}", core::any::type_name::() )) }) } - pub async fn execute(self, ctx: &mut impl crate::UxContext) -> crate::UxResult { + /// Dispatch this action through a controller context. + pub async fn execute(self, ctx: &mut impl crate::ControllerContext) -> crate::UiResult { ctx.dispatch(self).await } + /// Override the visible action label. pub fn with_label(mut self, label: impl Into) -> Self { self.meta = self.meta.with_label(label); self } + /// Override the action summary, usually used as tooltip/help text. pub fn with_summary(mut self, summary: impl Into) -> Self { self.meta = self.meta.with_summary(summary); self } + /// Add a shorter label for compact layouts. pub fn with_short_label(mut self, short_label: impl Into) -> Self { self.meta = self.meta.with_short_label(short_label); self } + /// Attach an icon token understood by the renderer. pub fn with_icon(mut self, icon: impl Into) -> Self { self.meta = self.meta.with_icon(icon); self } + /// Override the visual/action priority. pub fn with_priority(mut self, priority: ActionPriority) -> Self { self.meta.priority = priority; self } + /// Disable the action with a user-facing reason. pub fn disabled(mut self, reason: impl Into) -> Self { self.meta = self.meta.disabled(reason); self } + /// Require confirmation before the action is dispatched. pub fn with_confirmation(mut self, confirmation: ActionConfirmation) -> Self { self.meta = self.meta.with_confirmation(confirmation); self } + /// Replace all render metadata for the action. pub fn with_meta(mut self, meta: ActionMeta) -> Self { self.meta = meta; self @@ -110,11 +138,11 @@ impl UiAction { mod tests { use core::any::Any; - use crate::{ActionMeta, ActionPriority, UiAction, UxNodeId, UxOp}; + use crate::{ActionMeta, ActionPriority, ControllerId, ControllerOp, UiAction}; #[test] fn cloned_action_clones_boxed_op() { - let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + let action = UiAction::from_op(ControllerId::new("test|node"), TestOp::Run); let cloned = action.clone(); @@ -123,7 +151,7 @@ mod tests { #[test] fn into_op_downcasts_matching_type() { - let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + let action = UiAction::from_op(ControllerId::new("test|node"), TestOp::Run); let op = action.into_op::().unwrap(); @@ -132,14 +160,14 @@ mod tests { #[test] fn into_op_rejects_wrong_type() { - let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run); + let action = UiAction::from_op(ControllerId::new("test|node"), TestOp::Run); assert!(action.into_op::().is_err()); } #[test] fn metadata_overrides_change_only_metadata() { - let action = UiAction::from_op(UxNodeId::new("test.node"), TestOp::Run) + let action = UiAction::from_op(ControllerId::new("test|node"), TestOp::Run) .with_label("Go") .with_summary("Run it") .with_short_label("Go") @@ -157,16 +185,16 @@ mod tests { Run, } - impl UxOp for TestOp { + impl ControllerOp for TestOp { fn default_action_meta(&self) -> ActionMeta { ActionMeta::new("Run", "Run the test operation.", ActionPriority::Primary) } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } @@ -182,16 +210,16 @@ mod tests { #[derive(Clone, Debug, Eq, PartialEq)] struct OtherOp; - impl UxOp for OtherOp { + impl ControllerOp for OtherOp { fn default_action_meta(&self) -> ActionMeta { ActionMeta::new("Other", "Run the other operation.", ActionPriority::Primary) } - fn clone_box(&self) -> Box { + fn clone_box(&self) -> Box { Box::new(self.clone()) } - fn eq_op(&self, other: &dyn UxOp) -> bool { + fn eq_op(&self, other: &dyn ControllerOp) -> bool { other.as_any().downcast_ref::() == Some(self) } diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs b/lp-app/lpa-studio-core/src/core/action/action_confirmation.rs similarity index 56% rename from lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs rename to lp-app/lpa-studio-core/src/core/action/action_confirmation.rs index 4cdc103f2..93fb59949 100644 --- a/lp-app/lpa-studio-ux/src/ui/action/action_confirmation.rs +++ b/lp-app/lpa-studio-core/src/core/action/action_confirmation.rs @@ -1,11 +1,19 @@ +/// Confirmation copy for an action that needs explicit user approval. +/// +/// Use this for destructive, expensive, or surprising actions. The web renderer +/// decides how to present the confirmation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ActionConfirmation { + /// Confirmation dialog title. pub title: String, + /// Confirmation body copy. pub message: String, + /// Label for the confirmation button. pub confirm_label: String, } impl ActionConfirmation { + /// Create confirmation copy for an action. pub fn new( title: impl Into, message: impl Into, diff --git a/lp-app/lpa-studio-core/src/core/action/action_enablement.rs b/lp-app/lpa-studio-core/src/core/action/action_enablement.rs new file mode 100644 index 000000000..1f2e43a46 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/action/action_enablement.rs @@ -0,0 +1,18 @@ +/// Whether an action can currently be invoked. +/// +/// Disabled actions keep their metadata visible while explaining why the user +/// cannot run them yet. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ActionEnablement { + /// The action can be invoked. + Enabled, + /// The action is visible but blocked for the given reason. + Disabled { reason: String }, +} + +impl ActionEnablement { + /// Return whether the action is currently invokable. + pub fn is_enabled(&self) -> bool { + matches!(self, Self::Enabled) + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_meta.rs b/lp-app/lpa-studio-core/src/core/action/action_meta.rs similarity index 62% rename from lp-app/lpa-studio-ux/src/ui/action/action_meta.rs rename to lp-app/lpa-studio-core/src/core/action/action_meta.rs index 379b9b532..a54f933b4 100644 --- a/lp-app/lpa-studio-ux/src/ui/action/action_meta.rs +++ b/lp-app/lpa-studio-core/src/core/action/action_meta.rs @@ -1,17 +1,31 @@ use crate::{ActionConfirmation, ActionEnablement, ActionPriority}; +/// Render metadata for a `UiAction`. +/// +/// This is the part of an action that a component can display without knowing +/// the concrete controller operation. Keep operation-specific behavior in the +/// operation type and use metadata for labels, help text, icon hints, priority, +/// enablement, and confirmation. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ActionMeta { + /// Primary visible label. pub label: String, + /// Optional compact label for constrained layouts. pub short_label: Option, + /// Help text or tooltip copy. pub summary: String, + /// Optional icon token understood by the renderer. pub icon: Option, + /// Visual hierarchy for the action. pub priority: ActionPriority, + /// Whether the action can currently be invoked. pub enablement: ActionEnablement, + /// Optional confirmation required before dispatch. pub confirmation: Option, } impl ActionMeta { + /// Create metadata for an enabled action. pub fn new( label: impl Into, summary: impl Into, @@ -28,26 +42,31 @@ impl ActionMeta { } } + /// Override the primary visible label. pub fn with_label(mut self, label: impl Into) -> Self { self.label = label.into(); self } + /// Override the help text or tooltip copy. pub fn with_summary(mut self, summary: impl Into) -> Self { self.summary = summary.into(); self } + /// Add a compact label for constrained layouts. pub fn with_short_label(mut self, short_label: impl Into) -> Self { self.short_label = Some(short_label.into()); self } + /// Attach an icon token. pub fn with_icon(mut self, icon: impl Into) -> Self { self.icon = Some(icon.into()); self } + /// Mark the action as visible but not invokable. pub fn disabled(mut self, reason: impl Into) -> Self { self.enablement = ActionEnablement::Disabled { reason: reason.into(), @@ -55,6 +74,7 @@ impl ActionMeta { self } + /// Attach confirmation copy to require approval before dispatch. pub fn with_confirmation(mut self, confirmation: ActionConfirmation) -> Self { self.confirmation = Some(confirmation); self diff --git a/lp-app/lpa-studio-core/src/core/action/action_priority.rs b/lp-app/lpa-studio-core/src/core/action/action_priority.rs new file mode 100644 index 000000000..6ffdfca03 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/action/action_priority.rs @@ -0,0 +1,10 @@ +/// Visual/action hierarchy for a `UiAction`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ActionPriority { + /// The main action for the current surface or state. + Primary, + /// A normal supporting action. + Secondary, + /// A lower-emphasis action. + Tertiary, +} diff --git a/lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs b/lp-app/lpa-studio-core/src/core/action/actions.rs similarity index 65% rename from lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs rename to lp-app/lpa-studio-core/src/core/action/actions.rs index 591849ceb..fcb370ffa 100644 --- a/lp-app/lpa-studio-ux/src/ui/action/ui_actions.rs +++ b/lp-app/lpa-studio-core/src/core/action/actions.rs @@ -1,36 +1,47 @@ -use crate::{UiAction, UxNodeId}; +use crate::{ControllerId, UiAction}; +/// A collection of user-invokable actions. +/// +/// Use this when actions need to be gathered, filtered by target controller, +/// and passed through view construction before they are rendered. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct UiActions { actions: Vec, } impl UiActions { + /// Create an action collection from a vector. pub fn new(actions: Vec) -> Self { Self { actions } } + /// Return whether the collection has no actions. pub fn is_empty(&self) -> bool { self.actions.is_empty() } + /// Return the number of actions in the collection. pub fn len(&self) -> usize { self.actions.len() } + /// Iterate over actions in display order. pub fn iter(&self) -> impl Iterator { self.actions.iter() } + /// Append an action. pub fn push(&mut self, action: UiAction) { self.actions.push(action); } + /// Append multiple actions. pub fn extend(&mut self, actions: impl IntoIterator) { self.actions.extend(actions); } - pub fn for_node(&self, node_id: &UxNodeId) -> Vec { + /// Return actions targeting a controller id. + pub fn for_node(&self, node_id: &ControllerId) -> Vec { self.actions .iter() .filter(|action| action.node_id() == node_id) @@ -38,6 +49,7 @@ impl UiActions { .collect() } + /// Return actions targeting a controller id string. pub fn for_node_id(&self, node_id: &str) -> Vec { self.actions .iter() @@ -46,6 +58,7 @@ impl UiActions { .collect() } + /// Consume the collection and return the underlying vector. pub fn into_vec(self) -> Vec { self.actions } diff --git a/lp-app/lpa-studio-core/src/core/action/mod.rs b/lp-app/lpa-studio-core/src/core/action/mod.rs new file mode 100644 index 000000000..e67f9c0b2 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/action/mod.rs @@ -0,0 +1,12 @@ +//! Data-driven action models for controller operations. +//! +//! `UiAction` pairs a controller operation with render metadata. Controllers +//! create actions from their own operation enums, and web components render the +//! metadata without needing to know the operation type. + +pub mod action; +pub mod action_confirmation; +pub mod action_enablement; +pub mod action_meta; +pub mod action_priority; +pub mod actions; diff --git a/lp-app/lpa-studio-core/src/core/error.rs b/lp-app/lpa-studio-core/src/core/error.rs new file mode 100644 index 000000000..969e23296 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/error.rs @@ -0,0 +1,81 @@ +//! Result and error types for UI-controller operations. +//! +//! Errors in this module describe failed operations. They are control-flow for +//! commands and actions, not persistent view state. Use `UiIssue` when a +//! problem should remain visible inside a pane until the controller state +//! changes. + +use core::fmt; + +/// Standard result returned by a dispatched UI action. +/// +/// Successful actions may return transient `UiNotice` values for logs, toasts, +/// or other shell-level feedback. Failed actions return a `UiError`. +pub type UiResult = Result; + +/// A failed UI or controller operation. +/// +/// Use this for operations that could not complete: missing sessions, +/// unsupported actions, transport/protocol failures, and user cancellation. A +/// renderer may turn a `UiError` into a log line, but controllers should map it +/// into `UiIssue` if the problem needs to be part of the current view state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum UiError { + /// The current build or runtime cannot provide the requested feature. + UnsupportedFeature(String), + /// The action could not be routed or did not contain the expected op. + UnsupportedAction(String), + /// The action requires a connection/session that is not currently present. + MissingSession(String), + /// A link provider or link session failed. + Link(String), + /// A project operation failed. + Project(String), + /// A transport-level read/write operation failed. + Transport(String), + /// The server protocol returned an unexpected or invalid response. + Protocol(String), + /// A browser API failed or was unavailable. + Browser(String), + /// The user cancelled an operation, such as browser serial port selection. + Cancelled(String), + /// The device is reachable but is not currently running LightPlayer. + NoFirmwareDetected(String), +} + +impl UiError { + /// Return the user-facing message carried by the error. + pub fn message(&self) -> &str { + match self { + Self::UnsupportedFeature(message) + | Self::UnsupportedAction(message) + | Self::MissingSession(message) + | Self::Link(message) + | Self::Project(message) + | Self::Transport(message) + | Self::Protocol(message) + | Self::Browser(message) + | Self::Cancelled(message) + | Self::NoFirmwareDetected(message) => message, + } + } +} + +impl fmt::Display for UiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnsupportedFeature(message) => write!(f, "unsupported feature: {message}"), + Self::UnsupportedAction(message) => write!(f, "unsupported action: {message}"), + Self::MissingSession(message) => write!(f, "missing session: {message}"), + Self::Link(message) => write!(f, "link error: {message}"), + Self::Project(message) => write!(f, "project error: {message}"), + Self::Transport(message) => write!(f, "transport error: {message}"), + Self::Protocol(message) => write!(f, "protocol error: {message}"), + Self::Browser(message) => write!(f, "browser error: {message}"), + Self::Cancelled(message) => f.write_str(message), + Self::NoFirmwareDetected(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for UiError {} diff --git a/lp-app/lpa-studio-core/src/core/issue.rs b/lp-app/lpa-studio-core/src/core/issue.rs new file mode 100644 index 000000000..d14b710e1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/issue.rs @@ -0,0 +1,33 @@ +//! Inline problems shown as part of the current UI state. +//! +//! Issues differ from `UiError`: an error is returned from a failed operation, +//! while an issue is stored in controller/view state until the user retries, +//! changes input, or the controller reaches a resolved state. + +/// A persistent, user-visible problem inside a pane or view body. +/// +/// Use an issue when the UI should explain what needs attention in the current +/// surface, such as a failed project sync or unavailable link provider. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiIssue { + /// Short problem summary suitable for inline display. + pub message: String, + /// Optional detail with recovery hints or lower-level context. + pub detail: Option, +} + +impl UiIssue { + /// Create an issue with a summary message. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + detail: None, + } + } + + /// Attach supporting detail to the issue. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} diff --git a/lp-app/lpa-studio-core/src/core/log.rs b/lp-app/lpa-studio-core/src/core/log.rs new file mode 100644 index 000000000..d606abb34 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/log.rs @@ -0,0 +1,40 @@ +//! Chronological log entries surfaced by Studio UI shells. + +/// A single log line with source and severity. +/// +/// Use log entries for chronological diagnostic output. Unlike `UiNotice`, a +/// log entry is part of a durable stream that can be shown in a console-like +/// surface. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiLogEntry { + /// Severity used for visual treatment and filtering. + pub level: UiLogLevel, + /// Short subsystem label, such as `studio`, `lpa-link`, or `fw-esp32`. + pub source: String, + /// The log message body. + pub message: String, +} + +/// Severity for a Studio log line. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiLogLevel { + /// Verbose diagnostic output. + Debug, + /// Normal informational output. + Info, + /// Recoverable issue or attention-worthy output. + Warn, + /// Failed operation or serious problem. + Error, +} + +impl UiLogEntry { + /// Create a log line with a level, source, and message. + pub fn new(level: UiLogLevel, source: impl Into, message: impl Into) -> Self { + Self { + level, + source: source.into(), + message: message.into(), + } + } +} diff --git a/lp-app/lpa-studio-core/src/core/metric.rs b/lp-app/lpa-studio-core/src/core/metric.rs new file mode 100644 index 000000000..21e377212 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/metric.rs @@ -0,0 +1,23 @@ +//! Small label/value facts for summary surfaces. + +/// A compact label/value pair for scannable UI summaries. +/// +/// Use metrics for facts that should be compared quickly, such as protocol, +/// runtime, frame rate, memory, or connection details. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiMetric { + /// Human-readable metric label. + pub label: String, + /// Human-readable metric value. + pub value: String, +} + +impl UiMetric { + /// Create a metric from a label and any displayable value. + pub fn new(label: impl Into, value: impl ToString) -> Self { + Self { + label: label.into(), + value: value.to_string(), + } + } +} diff --git a/lp-app/lpa-studio-core/src/core/mod.rs b/lp-app/lpa-studio-core/src/core/mod.rs new file mode 100644 index 000000000..b7586fb82 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/mod.rs @@ -0,0 +1,35 @@ +//! Data-driven UI contracts shared by Studio controllers and renderers. +//! +//! Types in this module describe what the UI should render, not how a specific +//! frontend should render it. Controllers produce `Ui*` data, while web +//! components in `lpa-studio-web/src/core` consume the same data. + +pub mod action; +pub mod error; +pub mod issue; +pub mod log; +pub mod metric; +pub mod notice; +pub mod progress; +pub mod status; +pub mod terminal_line; +pub mod view; + +pub use crate::app::studio::ui_studio_view::UiStudioView; +pub use crate::controller::{ + ActionConfirmation, ActionEnablement, ActionMeta, ActionPriority, Controller, + ControllerContext, ControllerId, ControllerOp, UiAction, UiActions, UxNodePath, +}; +pub use metric::UiMetric; +pub use progress::UiProgress; +pub use status::UiStatus; +pub use status::UiStatusKind; +pub use terminal_line::UiTerminalLine; +pub use view::activity_view::UiActivityStep; +pub use view::activity_view::UiActivityStepState; +pub use view::activity_view::UiActivityView; +pub use view::pane_view::UiPaneView; +pub use view::steps_view::UiStepState; +pub use view::steps_view::UiStepView; +pub use view::steps_view::UiStepsView; +pub use view::view_content::UiViewContent; diff --git a/lp-app/lpa-studio-core/src/core/notice.rs b/lp-app/lpa-studio-core/src/core/notice.rs new file mode 100644 index 000000000..f93259e22 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/notice.rs @@ -0,0 +1,75 @@ +//! Transient feedback emitted by successful or non-fatal actions. +//! +//! Notices are action outcomes, not persistent controller state. The shell can +//! render them as logs, toasts, banners, or any other short-lived feedback +//! surface. + +/// Display level for a transient notice. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiNoticeLevel { + /// Informational outcome. + Info, + /// Outcome that succeeded but needs attention or follow-up. + Warning, + /// Non-fatal error feedback emitted as an action outcome. + Error, +} + +/// A short-lived message produced by a UI action. +/// +/// Use notices to report what just happened after an action finishes. Use +/// `UiIssue` instead when the problem is part of current view state, and use +/// `UiError` when the action itself failed. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiNotice { + /// How strongly the shell should present the notice. + pub level: UiNoticeLevel, + /// User-facing notice text. + pub message: String, +} + +impl UiNotice { + /// Create an informational notice. + pub fn info(message: impl Into) -> Self { + Self { + level: UiNoticeLevel::Info, + message: message.into(), + } + } + + /// Create a warning notice for successful outcomes that still need attention. + pub fn warning(message: impl Into) -> Self { + Self { + level: UiNoticeLevel::Warning, + message: message.into(), + } + } + + /// Create an error notice for non-fatal action feedback. + pub fn error(message: impl Into) -> Self { + Self { + level: UiNoticeLevel::Error, + message: message.into(), + } + } +} + +/// The transient notices returned by a dispatched action. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct UiNotices { + /// Notices emitted by the action in display order. + pub notices: Vec, +} + +impl UiNotices { + /// Create an empty notice collection. + pub fn new() -> Self { + Self::default() + } + + /// Append a notice and return the updated collection. + pub fn with_notice(mut self, notice: UiNotice) -> Self { + self.notices.push(notice); + self + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/progress.rs b/lp-app/lpa-studio-core/src/core/progress.rs similarity index 56% rename from lp-app/lpa-studio-ux/src/ui/progress.rs rename to lp-app/lpa-studio-core/src/core/progress.rs index 5dac519bf..f0013be73 100644 --- a/lp-app/lpa-studio-ux/src/ui/progress.rs +++ b/lp-app/lpa-studio-core/src/core/progress.rs @@ -1,12 +1,24 @@ +//! Render-ready progress state for ongoing work. + +/// Progress metadata for a visible operation. +/// +/// Use this when a view should show ongoing work. `percent` means determinate +/// progress; `timeout_ms` means a known countdown/window; neither means +/// indeterminate progress. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UiProgress { + /// Short label for the operation. pub label: String, + /// Optional supporting context. pub detail: Option, + /// Optional determinate completion percentage, clamped to 100. pub percent: Option, + /// Optional timeout/countdown duration in milliseconds. pub timeout_ms: Option, } impl UiProgress { + /// Create indeterminate progress with a label. pub fn new(label: impl Into) -> Self { Self { label: label.into(), @@ -16,28 +28,34 @@ impl UiProgress { } } + /// Create indeterminate progress with a label. pub fn indeterminate(label: impl Into) -> Self { Self::new(label) } + /// Create determinate progress with a percentage. pub fn determinate(label: impl Into, percent: u32) -> Self { Self::new(label).with_percent(percent) } + /// Create timeout progress for an operation with a known wait window. pub fn timeout(label: impl Into, timeout_ms: u32) -> Self { Self::new(label).with_timeout_ms(timeout_ms) } + /// Attach supporting detail. pub fn with_detail(mut self, detail: impl Into) -> Self { self.detail = Some(detail.into()); self } + /// Attach a determinate percentage, clamped to 100. pub fn with_percent(mut self, percent: u32) -> Self { self.percent = Some(percent.min(100)); self } + /// Attach a timeout/countdown duration in milliseconds. pub fn with_timeout_ms(mut self, timeout_ms: u32) -> Self { self.timeout_ms = Some(timeout_ms); self diff --git a/lp-app/lpa-studio-core/src/core/status.rs b/lp-app/lpa-studio-core/src/core/status.rs new file mode 100644 index 000000000..040f21d57 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/status.rs @@ -0,0 +1,63 @@ +//! Compact state summaries for pane and workflow chrome. + +/// A short current-state label with a visual kind. +/// +/// Use status for the chrome-level answer to "where is this surface right +/// now?" Keep it compact; put explanations in `UiViewContent` or `UiIssue`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiStatus { + /// Short label shown in status chrome. + pub label: String, + /// Visual treatment for the label. + pub kind: UiStatusKind, +} + +impl UiStatus { + /// Create a status with an explicit kind. + pub fn new(label: impl Into, kind: UiStatusKind) -> Self { + Self { + label: label.into(), + kind, + } + } + + /// Create a neutral status for inactive or selection states. + pub fn neutral(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Neutral) + } + + /// Create a working status for in-progress states. + pub fn working(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Working) + } + + /// Create a good status for ready or successful states. + pub fn good(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Good) + } + + /// Create a warning status for states that need attention but can continue. + pub fn warning(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Warning) + } + + /// Create an error status for failed states. + pub fn error(label: impl Into) -> Self { + Self::new(label, UiStatusKind::Error) + } +} + +/// Visual kind for a `UiStatus`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiStatusKind { + /// Idle, inactive, or awaiting user choice. + Neutral, + /// Work is currently running. + Working, + /// Ready, connected, or successful. + Good, + /// Attention needed, but not a hard failure. + Warning, + /// Failed or blocked. + Error, +} diff --git a/lp-app/lpa-studio-core/src/core/terminal_line.rs b/lp-app/lpa-studio-core/src/core/terminal_line.rs new file mode 100644 index 000000000..135af47b1 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/terminal_line.rs @@ -0,0 +1,18 @@ +//! Terminal-like text output used inside activity and workflow views. + +/// A single terminal-style output line. +/// +/// Use terminal lines for preformatted process output where ordering and exact +/// text matter more than structured fields. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiTerminalLine { + /// Text to display for this output line. + pub text: String, +} + +impl UiTerminalLine { + /// Create a terminal output line. + pub fn new(text: impl Into) -> Self { + Self { text: text.into() } + } +} diff --git a/lp-app/lpa-studio-core/src/core/view/activity_view.rs b/lp-app/lpa-studio-core/src/core/view/activity_view.rs new file mode 100644 index 000000000..60c3642eb --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/view/activity_view.rs @@ -0,0 +1,138 @@ +use crate::{UiProgress, UiTerminalLine}; + +/// Render data for a multi-step activity currently in progress. +/// +/// Use an activity when a pane body needs to show a named operation, optional +/// progress, step-by-step state, and optional terminal output. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiActivityView { + /// Activity title. + pub title: String, + /// Optional supporting detail. + pub detail: Option, + /// Optional progress indicator for the activity as a whole. + pub progress: Option, + /// Ordered activity steps. + pub steps: Vec, + /// Recent terminal-like output associated with the activity. + pub terminal: Vec, +} + +impl UiActivityView { + /// Create an activity with a title and no detail, progress, steps, or output. + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + detail: None, + progress: None, + steps: Vec::new(), + terminal: Vec::new(), + } + } + + /// Attach supporting detail to the activity. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Attach progress for the activity as a whole. + pub fn with_progress(mut self, progress: UiProgress) -> Self { + self.progress = Some(progress); + self + } + + /// Replace the activity steps. + pub fn with_steps(mut self, steps: Vec) -> Self { + self.steps = steps; + self + } + + /// Replace the terminal output lines. + pub fn with_terminal(mut self, terminal: Vec) -> Self { + self.terminal = terminal; + self + } + + /// Update a step by id if it exists. + pub fn set_step_state(&mut self, id: &str, state: UiActivityStepState) { + if let Some(step) = self.steps.iter_mut().find(|step| step.id == id) { + step.state = state; + } + } + + /// Append a terminal output line. + pub fn push_terminal_line(&mut self, line: impl Into) { + self.terminal.push(UiTerminalLine::new(line)); + } + + /// Keep only the most recent terminal output lines. + pub fn retain_recent_terminal_lines(&mut self, max_lines: usize) { + if self.terminal.len() > max_lines { + let remove_count = self.terminal.len() - max_lines; + self.terminal.drain(0..remove_count); + } + } +} + +/// One step inside a `UiActivityView`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UiActivityStep { + /// Stable step id used for updates. + pub id: String, + /// Visible step label. + pub label: String, + /// Current step state. + pub state: UiActivityStepState, + /// Optional step detail. + pub detail: Option, +} + +impl UiActivityStep { + /// Create a pending activity step. + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + state: UiActivityStepState::Pending, + detail: None, + } + } + + /// Set the step state. + pub fn with_state(mut self, state: UiActivityStepState) -> Self { + self.state = state; + self + } + + /// Attach supporting detail to the step. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } +} + +/// State for an activity step. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiActivityStepState { + /// The step has not started. + Pending, + /// The step is currently running. + Active, + /// The step completed successfully. + Complete, + /// The step failed. + Failed, +} + +impl UiActivityStepState { + /// Return a plain-text marker for non-visual renderers and logs. + pub fn text_marker(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::Active => "[*]", + Self::Complete => "[x]", + Self::Failed => "[!]", + } + } +} diff --git a/lp-app/lpa-studio-core/src/core/view/mod.rs b/lp-app/lpa-studio-core/src/core/view/mod.rs new file mode 100644 index 000000000..4c1acebe4 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/view/mod.rs @@ -0,0 +1,11 @@ +//! Composed render data for reusable Studio view surfaces. +//! +//! View models in this module are larger than individual controls. They describe +//! pane bodies, workflows, activities, and other reusable surfaces that web +//! components can render without knowing the Studio app domain that produced +//! them. + +pub mod activity_view; +pub mod pane_view; +pub mod steps_view; +pub mod view_content; diff --git a/lp-app/lpa-studio-core/src/core/view/pane_view.rs b/lp-app/lpa-studio-core/src/core/view/pane_view.rs new file mode 100644 index 000000000..09d555805 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/view/pane_view.rs @@ -0,0 +1,39 @@ +use crate::{ControllerId, UiAction, UiStatus, UiViewContent}; + +/// Render data for an addressable workspace region. +/// +/// A pane is the Studio-level surface with title, status, body content, and +/// pane-level actions. It is not the visual frame itself; web components decide +/// how to render the pane chrome. +#[derive(Clone, Debug, PartialEq)] +pub struct UiPaneView { + /// Controller id that owns this pane. + pub node_id: ControllerId, + /// Visible pane title. + pub title: String, + /// Compact state summary for pane chrome. + pub status: UiStatus, + /// Main pane body. + pub body: UiViewContent, + /// Pane-level actions. + pub actions: Vec, +} + +impl UiPaneView { + /// Create a pane view from its owner, title, status, body, and actions. + pub fn new( + node_id: impl Into, + title: impl Into, + status: UiStatus, + body: UiViewContent, + actions: Vec, + ) -> Self { + Self { + node_id: node_id.into(), + title: title.into(), + status, + body, + actions, + } + } +} diff --git a/lp-app/lpa-studio-core/src/core/view/steps_view.rs b/lp-app/lpa-studio-core/src/core/view/steps_view.rs new file mode 100644 index 000000000..4eb290706 --- /dev/null +++ b/lp-app/lpa-studio-core/src/core/view/steps_view.rs @@ -0,0 +1,95 @@ +use crate::{UiAction, UiTerminalLine, UiViewContent}; + +/// Render data for a multi-section workflow body. +/// +/// Use steps when a pane or another view needs to show ordered workflow +/// sections, each with its own body and actions. The pane that contains the +/// workflow still owns pane title/status/actions. +#[derive(Clone, Debug, PartialEq)] +pub struct UiStepsView { + /// Ordered workflow sections. + pub sections: Vec, + /// Optional terminal-like output associated with the workflow. + pub terminal: Vec, +} + +impl UiStepsView { + /// Create a workflow from ordered sections. + pub fn new(sections: Vec) -> Self { + Self { + sections, + terminal: Vec::new(), + } + } + + /// Attach terminal-like output to the workflow. + pub fn with_terminal(mut self, terminal: Vec) -> Self { + self.terminal = terminal; + self + } +} + +/// One section inside a `UiStepsView`. +#[derive(Clone, Debug, PartialEq)] +pub struct UiStepView { + /// Stable section id. + pub id: String, + /// Visible section title. + pub title: String, + /// Current section state. + pub state: UiStepState, + /// Section body content. + pub body: UiViewContent, + /// Section-level actions. + pub actions: Vec, +} + +impl UiStepView { + /// Create a step section with empty body and no actions. + pub fn new(id: impl Into, title: impl Into, state: UiStepState) -> Self { + Self { + id: id.into(), + title: title.into(), + state, + body: UiViewContent::Empty, + actions: Vec::new(), + } + } + + /// Replace the section body. + pub fn with_body(mut self, body: UiViewContent) -> Self { + self.body = body; + self + } + + /// Replace the section actions. + pub fn with_actions(mut self, actions: Vec) -> Self { + self.actions = actions; + self + } +} + +/// State for a workflow step section. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UiStepState { + /// The section is not ready to run yet. + Pending, + /// The section is the current active work. + Active, + /// The section completed successfully. + Complete, + /// The section needs user attention. + NeedsAttention, +} + +impl UiStepState { + /// Return a plain-text marker for non-visual renderers and logs. + pub fn text_marker(self) -> &'static str { + match self { + Self::Pending => "[ ]", + Self::Active => "[*]", + Self::Complete => "[x]", + Self::NeedsAttention => "[!]", + } + } +} diff --git a/lp-app/lpa-studio-ux/src/ui/body.rs b/lp-app/lpa-studio-core/src/core/view/view_content.rs similarity index 52% rename from lp-app/lpa-studio-ux/src/ui/body.rs rename to lp-app/lpa-studio-core/src/core/view/view_content.rs index 650210f59..819a8be60 100644 --- a/lp-app/lpa-studio-ux/src/ui/body.rs +++ b/lp-app/lpa-studio-core/src/core/view/view_content.rs @@ -1,21 +1,37 @@ -use crate::{ProgressState, UiActivity, UiMetric, UiStackView, UxIssue}; +use crate::{ProjectEditorView, UiActivityView, UiIssue, UiMetric, UiProgress, UiStepsView}; -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UiBody { +/// Generic body content for panes and workflow steps. +/// +/// This enum lets controllers describe common renderable content without +/// choosing web components directly. Keep app-specific surfaces in app view +/// DTOs and use these variants for reusable body shapes. +#[derive(Clone, Debug, PartialEq)] +pub enum UiViewContent { + /// No visible body content. Empty, + /// A single paragraph of text. Text(String), - Progress(ProgressState), - Activity(UiActivity), - Issue(UxIssue), + /// Progress for ongoing work. + Progress(UiProgress), + /// A multi-step activity. + Activity(UiActivityView), + /// An inline problem that needs attention. + Issue(UiIssue), + /// A compact label/value metric grid. Metrics(Vec), - Stack(Box), + /// A composed workflow with ordered steps. + Stack(Box), + /// Project editor surface. + ProjectEditor(Box), } -impl UiBody { +impl UiViewContent { + /// Create text body content. pub fn text(value: impl Into) -> Self { Self::Text(value.into()) } + /// Render the body as plain text lines for fallback renderers and tests. pub fn render_text_lines(&self) -> Vec { match self { Self::Empty => Vec::new(), @@ -87,6 +103,43 @@ impl UiBody { } lines } + Self::ProjectEditor(editor) => { + let mut lines = vec![ + format!("Project: {}", editor.project_id), + format!("Nodes: {}", editor.nodes.len()), + ]; + for node in &editor.nodes { + lines.push(format!( + "{} {} {}", + node.node_id, node.header.kind, node.header.path + )); + for tab in &node.tabs { + if let crate::UiNodeTabBody::Sections(sections) = &tab.body { + lines.extend(sections.iter().map(|section| { + let label = match section { + crate::UiNodeSection::ProducedProducts(items) => { + format!("produced products: {}", items.len()) + } + crate::UiNodeSection::ProducedValues(items) => { + format!("produced values: {}", items.len()) + } + crate::UiNodeSection::ConfigSlots(items) => { + format!("config slots: {}", items.len()) + } + crate::UiNodeSection::AssetSlots(items) => { + format!("asset slots: {}", items.len()) + } + crate::UiNodeSection::Children(items) => { + format!("children: {}", items.len()) + } + }; + format!(" {label}") + })); + } + } + } + lines + } } } } diff --git a/lp-app/lpa-studio-core/src/lib.rs b/lp-app/lpa-studio-core/src/lib.rs new file mode 100644 index 000000000..5cd01eac8 --- /dev/null +++ b/lp-app/lpa-studio-core/src/lib.rs @@ -0,0 +1,58 @@ +//! Headless LightPlayer Studio application core. + +pub use lpa_link::{LinkEndpointId, LinkEndpointStatus, LinkProviderKind}; +pub use lpc_model::{ + ColorOrder, ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, + ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, Revision, +}; + +pub mod app; +pub mod controller; +pub mod core; + +pub use self::core::status::UiStatusKind; +pub use app::device::{DeviceController, DeviceOp, DeviceSnapshot}; +pub use app::link::{ + ConnectedDeviceSummary, ConnectedLink, EndpointChoice, LinkController, LinkManagementOutcome, + LinkOp, LinkOpenOutcome, LinkSnapshot, LinkState, ProgressState, ProviderChoice, + SharedLinkRegistry, UiIssue, +}; +pub use app::node::{ + UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, UiConfigSlotBody, UiControlProductPreview, + UiControlSampleFormat, UiNodeChild, UiNodeDirtyState, UiNodeHeader, UiNodeSection, UiNodeTab, + UiNodeTabBody, UiNodeView, UiProducedBinding, UiProducedBindings, UiProducedProduct, + UiProducedValue, UiProductKind, UiProductPreview, UiProductPreviewFrame, UiProductRef, + UiProductTrackingState, UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow, + UiSlotAsset, UiSlotEditorHint, UiSlotFieldState, UiSlotOption, UiSlotOptionality, UiSlotRecord, + UiSlotShape, UiSlotShapeField, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiSlotValueKind, +}; +pub use app::project::{ + LoadedProjectChoice, NodeController, NodeControllerState, ProjectConnectResult, + ProjectController, ProjectEditorOp, ProjectEditorTarget, ProjectEditorView, + ProjectInventorySummary, ProjectNodeAddress, ProjectNodeStatusTone, ProjectNodeStatusView, + ProjectNodeTarget, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, + ProjectProductSubscriptionIntent, ProjectRuntimeSummary, ProjectSlotAddress, ProjectSlotRoot, + ProjectSnapshot, ProjectState, ProjectSync, ProjectSyncPhase, ProjectSyncRun, + ProjectSyncSummary, SlotController, SlotControllerState, SlotKind, +}; +pub use app::server::{ + LoadedDemoProject, LoadedProjectCatalog, ServerController, ServerFailureKind, ServerOp, + ServerSnapshot, ServerState, StudioProjectRead, StudioServerClient, +}; +pub use app::studio::{ + StudioController, StudioSnapshot, UiError, UiLogEntry, UiLogLevel, UiNotice, UiNoticeLevel, + UiResult, UxActivityTarget, UxUpdate, UxUpdateSink, +}; +pub use core::notice::UiNotices; +pub use core::view::activity_view::UiActivityStep; +pub use core::view::activity_view::UiActivityStepState; +pub use core::view::steps_view::UiStepState; +pub use core::view::steps_view::UiStepView; +pub use core::{ + ActionConfirmation, ActionEnablement, ActionMeta, ActionPriority, Controller, + ControllerContext, ControllerId, ControllerOp, UiAction, UiActions, UiActivityView, UiMetric, + UiPaneView, UiProgress, UiStatus, UiStepsView, UiStudioView, UiTerminalLine, UiViewContent, + UxNodePath, +}; + +pub const STUDIO_DEMO_PROJECT_ID: &str = "examples/basic"; diff --git a/lp-app/lpa-studio-ux/src/lib.rs b/lp-app/lpa-studio-ux/src/lib.rs deleted file mode 100644 index bfe2cd022..000000000 --- a/lp-app/lpa-studio-ux/src/lib.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! UI-independent LightPlayer Studio UX surface. - -pub use lpa_link::{LinkEndpointId, LinkEndpointStatus, LinkProviderKind}; - -pub mod node; - -pub mod nodes; -pub mod ui; - -pub use node::{ - ActionConfirmation, ActionEnablement, ActionMeta, ActionPriority, UiAction, UiActions, - UxContext, UxNode, UxNodeId, UxOp, -}; -pub use nodes::device::{DeviceOp, DeviceSnapshot, DeviceUx}; -pub use nodes::link::{ - ConnectedDeviceSummary, ConnectedLink, EndpointChoice, LinkManagementOutcome, LinkOp, - LinkOpenOutcome, LinkSnapshot, LinkState, LinkUx, ProgressState, ProviderChoice, - SharedLinkRegistry, UxIssue, -}; -pub use nodes::project::{ - LoadedProjectChoice, ProjectConnectResult, ProjectInventorySummary, ProjectOp, ProjectSnapshot, - ProjectState, ProjectUx, -}; -pub use nodes::server::{ - LoadedDemoProject, LoadedProjectCatalog, ServerFailureKind, ServerOp, ServerSnapshot, - ServerState, ServerUx, StudioServerClient, -}; -pub use nodes::studio::{ - StudioSnapshot, StudioUx, UxActivityTarget, UxError, UxLogEntry, UxLogLevel, UxNotice, - UxNoticeLevel, UxOutcome, UxResult, UxUpdate, UxUpdateSink, -}; -pub use ui::{ - StudioView, UiActivity, UiActivityStep, UiActivityStepState, UiBody, UiMetric, UiPaneView, - UiProgress, UiStackSection, UiStackView, UiStatus, UiStatusKind, UiStepState, UiTerminalLine, -}; - -pub const STUDIO_DEMO_PROJECT_ID: &str = "studio-demo"; diff --git a/lp-app/lpa-studio-ux/src/node/mod.rs b/lp-app/lpa-studio-ux/src/node/mod.rs deleted file mode 100644 index 71702fb4f..000000000 --- a/lp-app/lpa-studio-ux/src/node/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod ux_context; -pub mod ux_node; -pub mod ux_node_id; -pub mod ux_op; - -pub use crate::ui::action::action_confirmation::ActionConfirmation; -pub use crate::ui::action::action_enablement::ActionEnablement; -pub use crate::ui::action::action_meta::ActionMeta; -pub use crate::ui::action::action_priority::ActionPriority; -pub use crate::ui::action::ui_action::UiAction; -pub use crate::ui::action::ui_actions::UiActions; -pub use ux_context::UxContext; -pub use ux_node::UxNode; -pub use ux_node_id::UxNodeId; -pub use ux_op::UxOp; diff --git a/lp-app/lpa-studio-ux/src/node/ux_node_id.rs b/lp-app/lpa-studio-ux/src/node/ux_node_id.rs deleted file mode 100644 index e77af7b28..000000000 --- a/lp-app/lpa-studio-ux/src/node/ux_node_id.rs +++ /dev/null @@ -1,32 +0,0 @@ -use core::fmt; - -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct UxNodeId(String); - -impl UxNodeId { - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for UxNodeId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl From for UxNodeId { - fn from(value: String) -> Self { - Self::new(value) - } -} - -impl From<&str> for UxNodeId { - fn from(value: &str) -> Self { - Self::new(value) - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs b/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs deleted file mode 100644 index 942277217..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/link/ux_issue.rs +++ /dev/null @@ -1,19 +0,0 @@ -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UxIssue { - pub message: String, - pub detail: Option, -} - -impl UxIssue { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - detail: None, - } - } - - pub fn with_detail(mut self, detail: impl Into) -> Self { - self.detail = Some(detail.into()); - self - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs b/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs deleted file mode 100644 index 4c396ff99..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/project/demo_project.rs +++ /dev/null @@ -1,46 +0,0 @@ -use lpa_client::ProjectDeployFile; - -use crate::STUDIO_DEMO_PROJECT_ID; - -pub const DEMO_PROJECT_ID: &str = STUDIO_DEMO_PROJECT_ID; - -pub struct DemoProjectFile { - pub relative_path: &'static str, - pub bytes: &'static [u8], -} - -pub fn demo_project_files() -> &'static [DemoProjectFile] { - &[ - DemoProjectFile { - relative_path: "clock.toml", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/clock.toml"), - }, - DemoProjectFile { - relative_path: "fixture.toml", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/fixture.toml"), - }, - DemoProjectFile { - relative_path: "output.toml", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/output.toml"), - }, - DemoProjectFile { - relative_path: "project.toml", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/project.toml"), - }, - DemoProjectFile { - relative_path: "shader.glsl", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/shader.glsl"), - }, - DemoProjectFile { - relative_path: "shader.toml", - bytes: include_bytes!("../../../../../lp-fw/fw-browser/www/smoke-project/shader.toml"), - }, - ] -} - -pub fn demo_project_deploy_files() -> Vec { - demo_project_files() - .iter() - .map(|file| ProjectDeployFile::new(file.relative_path, file.bytes.to_vec())) - .collect() -} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/mod.rs b/lp-app/lpa-studio-ux/src/nodes/project/mod.rs deleted file mode 100644 index 8213c5257..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/project/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -pub mod demo_project; -pub mod loaded_project_choice; -pub mod project_connect_result; -pub mod project_inventory_summary; -pub mod project_op; -pub mod project_snapshot; -pub mod project_state; -pub mod project_ux; - -pub use loaded_project_choice::LoadedProjectChoice; -pub use project_connect_result::ProjectConnectResult; -pub use project_inventory_summary::ProjectInventorySummary; -pub use project_op::ProjectOp; -pub use project_snapshot::ProjectSnapshot; -pub use project_state::ProjectState; -pub use project_ux::ProjectUx; diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs deleted file mode 100644 index 171fc362b..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/project/project_snapshot.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::ProjectState; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ProjectSnapshot { - pub state: ProjectState, -} - -impl ProjectSnapshot { - pub fn new(state: ProjectState) -> Self { - Self { state } - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs b/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs deleted file mode 100644 index a6e115126..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/project/project_ux.rs +++ /dev/null @@ -1,360 +0,0 @@ -use crate::{ - LoadedProjectChoice, ProgressState, ProjectConnectResult, ProjectInventorySummary, ProjectOp, - ProjectSnapshot, ProjectState, StudioServerClient, UiAction, UiBody, UiMetric, UiPaneView, - UiStatus, UxError, UxIssue, UxLogEntry, UxNode, UxNodeId, -}; - -pub struct ProjectUx { - state: ProjectState, - running_project_status: RunningProjectStatus, -} - -impl ProjectUx { - pub const NODE_ID: &'static str = "studio.project"; - - pub fn new() -> Self { - Self { - state: ProjectState::NotLoaded, - running_project_status: RunningProjectStatus::Unknown, - } - } - - pub fn set_state(&mut self, state: ProjectState) { - self.state = state; - } - - pub fn snapshot(&self) -> ProjectSnapshot { - ProjectSnapshot::new(self.state.clone()) - } - - pub fn actions(&self, server_connected: bool) -> Vec { - if !server_connected { - return Vec::new(); - } - match self.state { - ProjectState::NotLoaded => { - let mut actions = Vec::new(); - if self.running_project_status != RunningProjectStatus::NoneKnown { - actions.push(self.action(ProjectOp::ConnectRunningProject)); - } - actions.push(self.action(ProjectOp::LoadDemoProject)); - actions - } - ProjectState::Failed { .. } => vec![ - self.action(ProjectOp::ConnectRunningProject), - self.action(ProjectOp::LoadDemoProject), - ], - ProjectState::SelectingLoadedProject { ref projects } => projects - .iter() - .map(|project| { - self.action(ProjectOp::ConnectLoadedProject { - handle_id: project.handle_id, - }) - .with_label(format!("Connect {}", project.project_id)) - .with_summary(format!( - "Attach to running project handle {}.", - project.handle_id - )) - }) - .collect(), - ProjectState::ConnectingRunningProject { .. } - | ProjectState::LoadingDemoProject { .. } => Vec::new(), - ProjectState::Ready { .. } => vec![self.action(ProjectOp::DisconnectProject)], - } - } - - pub fn view(&self, server_connected: bool) -> UiPaneView { - UiPaneView::new( - Self::NODE_ID, - "Project", - project_status(&self.state), - project_body(&self.state, self.running_project_status), - self.actions(server_connected), - ) - } - - pub fn mark_connecting_running(&mut self) { - self.state = ProjectState::ConnectingRunningProject { - progress: ProgressState::new("Connecting running project"), - }; - } - - pub fn mark_selecting_loaded_project(&mut self, projects: Vec) { - self.running_project_status = RunningProjectStatus::Available; - self.state = ProjectState::SelectingLoadedProject { projects }; - } - - pub fn mark_loading_demo(&mut self) { - self.state = ProjectState::LoadingDemoProject { - progress: ProgressState::new("Loading demo project"), - }; - } - - pub fn mark_ready( - &mut self, - project_id: impl Into, - handle_id: u32, - inventory: ProjectInventorySummary, - ) { - self.running_project_status = RunningProjectStatus::Available; - self.state = ProjectState::Ready { - project_id: project_id.into(), - handle_id, - inventory, - }; - } - - pub fn fail(&mut self, message: impl Into) { - self.running_project_status = RunningProjectStatus::Unknown; - self.state = ProjectState::Failed { - issue: UxIssue::new(message), - }; - } - - pub fn disconnect(&mut self) { - self.running_project_status = if matches!(self.state, ProjectState::Ready { .. }) { - RunningProjectStatus::Available - } else { - RunningProjectStatus::Unknown - }; - self.state = ProjectState::NotLoaded; - } - - pub fn reset(&mut self) { - self.running_project_status = RunningProjectStatus::Unknown; - self.state = ProjectState::NotLoaded; - } - - pub fn mark_no_running_project(&mut self) { - self.running_project_status = RunningProjectStatus::NoneKnown; - self.state = ProjectState::NotLoaded; - } - - pub async fn load_demo_project( - &mut self, - server: &mut StudioServerClient, - ) -> Result, UxError> { - self.mark_loading_demo(); - let loaded = server.load_demo_project().await?; - self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); - Ok(loaded.logs) - } - - pub async fn connect_running_project( - &mut self, - server: &mut StudioServerClient, - ) -> Result { - self.mark_connecting_running(); - let catalog = server.list_loaded_projects().await?; - self.connect_from_catalog(server, catalog.projects, catalog.logs) - .await - } - - pub async fn connect_running_project_if_available( - &mut self, - server: &mut StudioServerClient, - ) -> Result { - let catalog = server.list_loaded_projects().await?; - self.connect_from_catalog(server, catalog.projects, catalog.logs) - .await - } - - pub async fn connect_loaded_project( - &mut self, - server: &mut StudioServerClient, - handle_id: u32, - ) -> Result, UxError> { - let choice = self.loaded_project_choice(handle_id)?; - self.mark_connecting_running(); - let project = server.connect_loaded_project(choice).await?; - let logs = server.take_pending_logs(); - self.mark_ready(project.project_id, project.handle_id, project.inventory); - Ok(logs) - } - - async fn connect_from_catalog( - &mut self, - server: &mut StudioServerClient, - projects: Vec, - mut logs: Vec, - ) -> Result { - match projects.as_slice() { - [] => { - self.mark_no_running_project(); - Ok(ProjectConnectResult::NotFound { logs }) - } - [project] => { - let loaded = server.connect_loaded_project(project.clone()).await?; - logs.extend(server.take_pending_logs()); - self.mark_ready(loaded.project_id, loaded.handle_id, loaded.inventory); - Ok(ProjectConnectResult::Connected { logs }) - } - _ => { - self.mark_selecting_loaded_project(projects); - Ok(ProjectConnectResult::SelectionRequired { logs }) - } - } - } - - fn loaded_project_choice(&self, handle_id: u32) -> Result { - match &self.state { - ProjectState::SelectingLoadedProject { projects } => projects - .iter() - .find(|project| project.handle_id == handle_id) - .cloned() - .ok_or_else(|| { - UxError::Project(format!( - "loaded project handle {handle_id} is not available" - )) - }), - _ => Err(UxError::Project( - "loaded project selection is not active".to_string(), - )), - } - } -} - -impl UxNode for ProjectUx { - type Op = ProjectOp; - - fn node_id(&self) -> UxNodeId { - UxNodeId::new(Self::NODE_ID) - } -} - -impl Default for ProjectUx { - fn default() -> Self { - Self::new() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RunningProjectStatus { - Unknown, - NoneKnown, - Available, -} - -fn project_status(state: &ProjectState) -> UiStatus { - match state { - ProjectState::NotLoaded => UiStatus::neutral("Not loaded"), - ProjectState::SelectingLoadedProject { .. } => UiStatus::neutral("Choose project"), - ProjectState::ConnectingRunningProject { .. } => UiStatus::working("Connecting"), - ProjectState::LoadingDemoProject { .. } => UiStatus::working("Loading"), - ProjectState::Ready { .. } => UiStatus::good("Ready"), - ProjectState::Failed { .. } => UiStatus::error("Failed"), - } -} - -fn project_body(state: &ProjectState, running_project_status: RunningProjectStatus) -> UiBody { - match state { - ProjectState::NotLoaded if running_project_status == RunningProjectStatus::NoneKnown => { - UiBody::text("No running project is loaded. Load the demo project when you're ready.") - } - ProjectState::NotLoaded => { - UiBody::text("Connect to a running project or load the demo project.") - } - ProjectState::SelectingLoadedProject { projects } => UiBody::text(format!( - "{} projects are running. Choose one to attach.", - projects.len() - )), - ProjectState::ConnectingRunningProject { progress } - | ProjectState::LoadingDemoProject { progress } => UiBody::Progress(progress.clone()), - ProjectState::Ready { - project_id, - handle_id, - inventory, - } => UiBody::Metrics(vec![ - UiMetric::new("Project", project_id), - UiMetric::new("Handle", *handle_id), - UiMetric::new("Nodes", inventory.node_count), - UiMetric::new("Definitions", inventory.definition_count), - UiMetric::new("Assets", inventory.asset_count), - ]), - ProjectState::Failed { issue } => UiBody::Issue(issue.clone()), - } -} - -#[cfg(test)] -mod tests { - use crate::{ActionPriority, ProjectOp}; - - use super::*; - - #[test] - fn disconnected_project_has_no_actions() { - let project = ProjectUx::new(); - - assert!(project.actions(false).is_empty()); - } - - #[test] - fn connected_not_loaded_project_offers_attach_and_demo_actions() { - let project = ProjectUx::new(); - - let actions = project.actions(true); - - assert_eq!(actions.len(), 2); - assert_eq!( - actions[0].op_as::(), - Some(&ProjectOp::ConnectRunningProject) - ); - assert_eq!(actions[0].meta().priority, ActionPriority::Primary); - assert_eq!( - actions[1].op_as::(), - Some(&ProjectOp::LoadDemoProject) - ); - assert_eq!(actions[1].meta().priority, ActionPriority::Secondary); - } - - #[test] - fn connected_project_with_no_running_project_only_offers_demo_load() { - let mut project = ProjectUx::new(); - project.mark_no_running_project(); - - let actions = project.actions(true); - - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].op_as::(), - Some(&ProjectOp::LoadDemoProject) - ); - } - - #[test] - fn multiple_loaded_projects_offer_project_specific_actions() { - let mut project = ProjectUx::new(); - project.mark_selecting_loaded_project(vec![ - LoadedProjectChoice::new("/projects/a", 1), - LoadedProjectChoice::new("/projects/b", 2), - ]); - - let actions = project.actions(true); - - assert_eq!(actions.len(), 2); - assert_eq!( - actions[0].op_as::(), - Some(&ProjectOp::ConnectLoadedProject { handle_id: 1 }) - ); - assert_eq!(actions[0].meta().label, "Connect /projects/a"); - assert_eq!( - actions[1].op_as::(), - Some(&ProjectOp::ConnectLoadedProject { handle_id: 2 }) - ); - } - - #[test] - fn ready_project_offers_disconnect_action() { - let mut project = ProjectUx::new(); - project.mark_ready("loaded-project", 7, ProjectInventorySummary::default()); - - let actions = project.actions(true); - - assert_eq!(actions.len(), 1); - assert_eq!( - actions[0].op_as::(), - Some(&ProjectOp::DisconnectProject) - ); - assert_eq!(actions[0].meta().priority, ActionPriority::Tertiary); - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs b/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs deleted file mode 100644 index 7669216cb..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/studio/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -pub mod studio_snapshot; -pub mod studio_ux; -pub mod ux_error; -pub mod ux_log_entry; -pub mod ux_notice; -pub mod ux_outcome; -pub mod ux_update; -pub mod ux_update_sink; - -pub use studio_snapshot::StudioSnapshot; -pub use studio_ux::StudioUx; -pub use ux_error::{UxError, UxResult}; -pub use ux_log_entry::{UxLogEntry, UxLogLevel}; -pub use ux_notice::{UxNotice, UxNoticeLevel}; -pub use ux_outcome::UxOutcome; -pub use ux_update::{UxActivityTarget, UxUpdate}; -pub use ux_update_sink::UxUpdateSink; diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs deleted file mode 100644 index 0612604be..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_error.rs +++ /dev/null @@ -1,53 +0,0 @@ -use core::fmt; - -pub type UxResult = Result; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum UxError { - UnsupportedFeature(String), - UnsupportedAction(String), - MissingSession(String), - Link(String), - Project(String), - Transport(String), - Protocol(String), - Browser(String), - Cancelled(String), - NoFirmwareDetected(String), -} - -impl UxError { - pub fn message(&self) -> &str { - match self { - Self::UnsupportedFeature(message) - | Self::UnsupportedAction(message) - | Self::MissingSession(message) - | Self::Link(message) - | Self::Project(message) - | Self::Transport(message) - | Self::Protocol(message) - | Self::Browser(message) - | Self::Cancelled(message) - | Self::NoFirmwareDetected(message) => message, - } - } -} - -impl fmt::Display for UxError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::UnsupportedFeature(message) => write!(f, "unsupported feature: {message}"), - Self::UnsupportedAction(message) => write!(f, "unsupported action: {message}"), - Self::MissingSession(message) => write!(f, "missing session: {message}"), - Self::Link(message) => write!(f, "link error: {message}"), - Self::Project(message) => write!(f, "project error: {message}"), - Self::Transport(message) => write!(f, "transport error: {message}"), - Self::Protocol(message) => write!(f, "protocol error: {message}"), - Self::Browser(message) => write!(f, "browser error: {message}"), - Self::Cancelled(message) => f.write_str(message), - Self::NoFirmwareDetected(message) => write!(f, "{message}"), - } - } -} - -impl std::error::Error for UxError {} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs deleted file mode 100644 index b61fc5fb2..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_log_entry.rs +++ /dev/null @@ -1,24 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UxLogLevel { - Debug, - Info, - Warn, - Error, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UxLogEntry { - pub level: UxLogLevel, - pub source: String, - pub message: String, -} - -impl UxLogEntry { - pub fn new(level: UxLogLevel, source: impl Into, message: impl Into) -> Self { - Self { - level, - source: source.into(), - message: message.into(), - } - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs deleted file mode 100644 index 0826ab858..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_notice.rs +++ /dev/null @@ -1,21 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UxNoticeLevel { - Info, - Warning, - Error, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UxNotice { - pub level: UxNoticeLevel, - pub message: String, -} - -impl UxNotice { - pub fn info(message: impl Into) -> Self { - Self { - level: UxNoticeLevel::Info, - message: message.into(), - } - } -} diff --git a/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs b/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs deleted file mode 100644 index dfdee2350..000000000 --- a/lp-app/lpa-studio-ux/src/nodes/studio/ux_outcome.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::UxNotice; - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct UxOutcome { - pub notices: Vec, -} - -impl UxOutcome { - pub fn new() -> Self { - Self::default() - } - - pub fn with_notice(mut self, notice: UxNotice) -> Self { - self.notices.push(notice); - self - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs b/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs deleted file mode 100644 index cfd489852..000000000 --- a/lp-app/lpa-studio-ux/src/ui/action/action_enablement.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ActionEnablement { - Enabled, - Disabled { reason: String }, -} - -impl ActionEnablement { - pub fn is_enabled(&self) -> bool { - matches!(self, Self::Enabled) - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs b/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs deleted file mode 100644 index d12fdf923..000000000 --- a/lp-app/lpa-studio-ux/src/ui/action/action_priority.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ActionPriority { - Primary, - Secondary, - Tertiary, -} diff --git a/lp-app/lpa-studio-ux/src/ui/action/mod.rs b/lp-app/lpa-studio-ux/src/ui/action/mod.rs deleted file mode 100644 index 69be68774..000000000 --- a/lp-app/lpa-studio-ux/src/ui/action/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod action_confirmation; -pub mod action_enablement; -pub mod action_meta; -pub mod action_priority; -pub mod ui_action; -pub mod ui_actions; diff --git a/lp-app/lpa-studio-ux/src/ui/activity.rs b/lp-app/lpa-studio-ux/src/ui/activity.rs deleted file mode 100644 index 4ad04f833..000000000 --- a/lp-app/lpa-studio-ux/src/ui/activity.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::{UiActivityStep, UiActivityStepState, UiProgress, UiTerminalLine}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiActivity { - pub title: String, - pub detail: Option, - pub progress: Option, - pub steps: Vec, - pub terminal: Vec, -} - -impl UiActivity { - pub fn new(title: impl Into) -> Self { - Self { - title: title.into(), - detail: None, - progress: None, - steps: Vec::new(), - terminal: Vec::new(), - } - } - - pub fn with_detail(mut self, detail: impl Into) -> Self { - self.detail = Some(detail.into()); - self - } - - pub fn with_progress(mut self, progress: UiProgress) -> Self { - self.progress = Some(progress); - self - } - - pub fn with_steps(mut self, steps: Vec) -> Self { - self.steps = steps; - self - } - - pub fn set_step_state(&mut self, id: &str, state: UiActivityStepState) { - if let Some(step) = self.steps.iter_mut().find(|step| step.id == id) { - step.state = state; - } - } - - pub fn push_terminal_line(&mut self, line: impl Into) { - self.terminal.push(UiTerminalLine::new(line)); - } - - pub fn retain_recent_terminal_lines(&mut self, max_lines: usize) { - if self.terminal.len() > max_lines { - let remove_count = self.terminal.len() - max_lines; - self.terminal.drain(0..remove_count); - } - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/activity_step.rs b/lp-app/lpa-studio-ux/src/ui/activity_step.rs deleted file mode 100644 index 49b688baa..000000000 --- a/lp-app/lpa-studio-ux/src/ui/activity_step.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::UiActivityStepState; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiActivityStep { - pub id: String, - pub label: String, - pub state: UiActivityStepState, - pub detail: Option, -} - -impl UiActivityStep { - pub fn new(id: impl Into, label: impl Into) -> Self { - Self { - id: id.into(), - label: label.into(), - state: UiActivityStepState::Pending, - detail: None, - } - } - - pub fn with_state(mut self, state: UiActivityStepState) -> Self { - self.state = state; - self - } - - pub fn with_detail(mut self, detail: impl Into) -> Self { - self.detail = Some(detail.into()); - self - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs b/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs deleted file mode 100644 index 20cbe011f..000000000 --- a/lp-app/lpa-studio-ux/src/ui/activity_step_state.rs +++ /dev/null @@ -1,18 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UiActivityStepState { - Pending, - Active, - Complete, - Failed, -} - -impl UiActivityStepState { - pub fn text_marker(self) -> &'static str { - match self { - Self::Pending => "[ ]", - Self::Active => "[*]", - Self::Complete => "[x]", - Self::Failed => "[!]", - } - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/metric.rs b/lp-app/lpa-studio-ux/src/ui/metric.rs deleted file mode 100644 index 60adbeb67..000000000 --- a/lp-app/lpa-studio-ux/src/ui/metric.rs +++ /dev/null @@ -1,14 +0,0 @@ -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiMetric { - pub label: String, - pub value: String, -} - -impl UiMetric { - pub fn new(label: impl Into, value: impl ToString) -> Self { - Self { - label: label.into(), - value: value.to_string(), - } - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/mod.rs b/lp-app/lpa-studio-ux/src/ui/mod.rs deleted file mode 100644 index d596a390e..000000000 --- a/lp-app/lpa-studio-ux/src/ui/mod.rs +++ /dev/null @@ -1,30 +0,0 @@ -pub mod action; -pub mod activity; -pub mod activity_step; -pub mod activity_step_state; -pub mod body; -pub mod metric; -pub mod pane_view; -pub mod progress; -pub mod stack_section; -pub mod stack_view; -pub mod status; -pub mod status_kind; -pub mod step_state; -pub mod studio_view; -pub mod terminal_line; - -pub use activity::UiActivity; -pub use activity_step::UiActivityStep; -pub use activity_step_state::UiActivityStepState; -pub use body::UiBody; -pub use metric::UiMetric; -pub use pane_view::UiPaneView; -pub use progress::UiProgress; -pub use stack_section::UiStackSection; -pub use stack_view::UiStackView; -pub use status::UiStatus; -pub use status_kind::UiStatusKind; -pub use step_state::UiStepState; -pub use studio_view::StudioView; -pub use terminal_line::UiTerminalLine; diff --git a/lp-app/lpa-studio-ux/src/ui/pane_view.rs b/lp-app/lpa-studio-ux/src/ui/pane_view.rs deleted file mode 100644 index 6d06ecbf5..000000000 --- a/lp-app/lpa-studio-ux/src/ui/pane_view.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::{UiAction, UiBody, UiStatus, UxNodeId}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiPaneView { - pub node_id: UxNodeId, - pub title: String, - pub status: UiStatus, - pub body: UiBody, - pub actions: Vec, -} - -impl UiPaneView { - pub fn new( - node_id: impl Into, - title: impl Into, - status: UiStatus, - body: UiBody, - actions: Vec, - ) -> Self { - Self { - node_id: node_id.into(), - title: title.into(), - status, - body, - actions, - } - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/stack_section.rs b/lp-app/lpa-studio-ux/src/ui/stack_section.rs deleted file mode 100644 index 57f1bc54e..000000000 --- a/lp-app/lpa-studio-ux/src/ui/stack_section.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{UiAction, UiBody, UiStepState}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiStackSection { - pub id: String, - pub title: String, - pub state: UiStepState, - pub body: UiBody, - pub actions: Vec, -} - -impl UiStackSection { - pub fn new(id: impl Into, title: impl Into, state: UiStepState) -> Self { - Self { - id: id.into(), - title: title.into(), - state, - body: UiBody::Empty, - actions: Vec::new(), - } - } - - pub fn with_body(mut self, body: UiBody) -> Self { - self.body = body; - self - } - - pub fn with_actions(mut self, actions: Vec) -> Self { - self.actions = actions; - self - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/stack_view.rs b/lp-app/lpa-studio-ux/src/ui/stack_view.rs deleted file mode 100644 index 35913396d..000000000 --- a/lp-app/lpa-studio-ux/src/ui/stack_view.rs +++ /dev/null @@ -1,21 +0,0 @@ -use crate::{UiStackSection, UiTerminalLine}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiStackView { - pub sections: Vec, - pub terminal: Vec, -} - -impl UiStackView { - pub fn new(sections: Vec) -> Self { - Self { - sections, - terminal: Vec::new(), - } - } - - pub fn with_terminal(mut self, terminal: Vec) -> Self { - self.terminal = terminal; - self - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/status.rs b/lp-app/lpa-studio-ux/src/ui/status.rs deleted file mode 100644 index 930a74fc3..000000000 --- a/lp-app/lpa-studio-ux/src/ui/status.rs +++ /dev/null @@ -1,36 +0,0 @@ -use crate::UiStatusKind; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiStatus { - pub label: String, - pub kind: UiStatusKind, -} - -impl UiStatus { - pub fn new(label: impl Into, kind: UiStatusKind) -> Self { - Self { - label: label.into(), - kind, - } - } - - pub fn neutral(label: impl Into) -> Self { - Self::new(label, UiStatusKind::Neutral) - } - - pub fn working(label: impl Into) -> Self { - Self::new(label, UiStatusKind::Working) - } - - pub fn good(label: impl Into) -> Self { - Self::new(label, UiStatusKind::Good) - } - - pub fn warning(label: impl Into) -> Self { - Self::new(label, UiStatusKind::Warning) - } - - pub fn error(label: impl Into) -> Self { - Self::new(label, UiStatusKind::Error) - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/status_kind.rs b/lp-app/lpa-studio-ux/src/ui/status_kind.rs deleted file mode 100644 index b0b1d726d..000000000 --- a/lp-app/lpa-studio-ux/src/ui/status_kind.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UiStatusKind { - Neutral, - Working, - Good, - Warning, - Error, -} diff --git a/lp-app/lpa-studio-ux/src/ui/step_state.rs b/lp-app/lpa-studio-ux/src/ui/step_state.rs deleted file mode 100644 index 8b591af8c..000000000 --- a/lp-app/lpa-studio-ux/src/ui/step_state.rs +++ /dev/null @@ -1,18 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UiStepState { - Pending, - Active, - Complete, - NeedsAttention, -} - -impl UiStepState { - pub fn text_marker(self) -> &'static str { - match self { - Self::Pending => "[ ]", - Self::Active => "[*]", - Self::Complete => "[x]", - Self::NeedsAttention => "[!]", - } - } -} diff --git a/lp-app/lpa-studio-ux/src/ui/terminal_line.rs b/lp-app/lpa-studio-ux/src/ui/terminal_line.rs deleted file mode 100644 index c1aad3604..000000000 --- a/lp-app/lpa-studio-ux/src/ui/terminal_line.rs +++ /dev/null @@ -1,10 +0,0 @@ -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct UiTerminalLine { - pub text: String, -} - -impl UiTerminalLine { - pub fn new(text: impl Into) -> Self { - Self { text: text.into() } - } -} diff --git a/lp-app/lpa-studio-web-story-macros/Cargo.toml b/lp-app/lpa-studio-web-story-macros/Cargo.toml new file mode 100644 index 000000000..e72f2822a --- /dev/null +++ b/lp-app/lpa-studio-web-story-macros/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "lpa-studio-web-story-macros" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "Proc macros for LightPlayer Studio web stories" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "parsing"] } + +[lints] +workspace = true diff --git a/lp-app/lpa-studio-web-story-macros/src/lib.rs b/lp-app/lpa-studio-web-story-macros/src/lib.rs new file mode 100644 index 000000000..3c0654683 --- /dev/null +++ b/lp-app/lpa-studio-web-story-macros/src/lib.rs @@ -0,0 +1,134 @@ +//! Attribute macros for Studio web stories. +//! +//! The `#[story]` macro deliberately keeps runtime behavior small. The Studio +//! web build script reads story metadata from source files and generates the +//! registry; this macro validates the story function and makes it callable from +//! that generated registry. + +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::Parser; +use syn::{Error, FnArg, ItemFn, LitStr, ReturnType, Visibility, parse_macro_input, parse_quote}; + +/// Mark a zero-argument function as a Studio story. +/// +/// ```ignore +/// #[story] +/// fn example() -> Element { +/// /* ... */ +/// } +/// ``` +/// +/// `label` and `description` can be passed as optional display metadata, but +/// ordinary stories should let the build script derive the label from the +/// function name. +/// +/// Story route identity is inferred by `lpa-studio-web/build.rs` from the file +/// path plus function name, so this macro intentionally does not accept an id, +/// family, category, or component. +#[proc_macro_attribute] +pub fn story(args: TokenStream, item: TokenStream) -> TokenStream { + let mut function = parse_macro_input!(item as ItemFn); + parse_macro_input!(args with StoryArgs::parse); + + if let Err(error) = validate_story_function(&function) { + return error.to_compile_error().into(); + } + + if matches!(function.vis, Visibility::Inherited) { + function.vis = parse_quote!(pub(crate)); + } + + quote!(#function).into() +} + +struct StoryArgs { + label: Option, + description: Option, +} + +impl StoryArgs { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let mut args = Self { + label: None, + description: None, + }; + if input.is_empty() { + return Ok(args); + } + + let parser = syn::meta::parser(|meta| args.parse_meta(meta)); + let tokens = input.parse()?; + parser.parse2(tokens)?; + + Ok(args) + } + + fn parse_meta(&mut self, meta: syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> { + if meta.path.is_ident("label") { + let value = meta.value()?; + self.set_once("label", value.parse()?)?; + return Ok(()); + } + + if meta.path.is_ident("description") { + let value = meta.value()?; + self.set_once("description", value.parse()?)?; + return Ok(()); + } + + let path = &meta.path; + let name = path + .get_ident() + .map(ToString::to_string) + .unwrap_or_else(|| quote!(#path).to_string()); + Err(meta.error(format!( + "unsupported story argument `{name}`; use `#[story]`, `label = \"...\"`, or `description = \"...\"`" + ))) + } + + fn set_once(&mut self, key: &'static str, value: LitStr) -> syn::Result<()> { + let slot = match key { + "label" => &mut self.label, + "description" => &mut self.description, + _ => unreachable!("story arg key is fixed by parser"), + }; + if slot.is_some() { + return Err(Error::new( + value.span(), + format!("duplicate `{key}` argument in #[story]"), + )); + } + *slot = Some(value); + Ok(()) + } +} + +fn validate_story_function(function: &ItemFn) -> syn::Result<()> { + if let Some(input) = function.sig.inputs.first() { + let input_label = match input { + FnArg::Receiver(_) => "self parameter", + FnArg::Typed(_) => "parameter", + }; + return Err(Error::new_spanned( + input, + format!("story functions must take no arguments; remove this {input_label}"), + )); + } + + if !function.sig.generics.params.is_empty() || function.sig.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &function.sig.generics, + "story functions must not be generic", + )); + } + + if matches!(function.sig.output, ReturnType::Default) { + return Err(Error::new_spanned( + &function.sig.ident, + "story functions must return `Element`", + )); + } + + Ok(()) +} diff --git a/lp-app/lpa-studio-web/Cargo.toml b/lp-app/lpa-studio-web/Cargo.toml index 0489a6e0e..bf0a9876f 100644 --- a/lp-app/lpa-studio-web/Cargo.toml +++ b/lp-app/lpa-studio-web/Cargo.toml @@ -9,12 +9,19 @@ publish = false description = "Static Dioxus web shell for LightPlayer Studio" [features] -stories = [] +stories = ["dep:lpa-studio-web-story-macros"] [dependencies] dioxus = { version = "0.7", features = ["web"] } -lpa-studio-ux = { path = "../lpa-studio-ux", features = ["browser-worker", "browser-serial-esp32"] } -web-sys = { version = "0.3", features = ["Location", "Window"] } +dioxus-icons = "0.1" +gloo-timers = { version = "0.3", features = ["futures"] } +lpa-studio-core = { path = "../lpa-studio-core", features = ["browser-worker", "browser-serial-esp32"] } +lpa-studio-web-story-macros = { path = "../lpa-studio-web-story-macros", optional = true } +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["Event", "EventTarget", "History", "HtmlElement", "Location", "Window"] } + +[build-dependencies] +syn = { version = "2.0", features = ["full", "parsing"] } [lints] workspace = true diff --git a/lp-app/lpa-studio-web/Dioxus.toml b/lp-app/lpa-studio-web/Dioxus.toml index 32a9dca36..91152c586 100644 --- a/lp-app/lpa-studio-web/Dioxus.toml +++ b/lp-app/lpa-studio-web/Dioxus.toml @@ -1,9 +1,16 @@ [application] name = "lpa-studio-web" default_platform = "web" +sub_package = "lpa-studio-web" asset_dir = "public" out_dir = "dist" [web.app] title = "LightPlayer Studio" base_path = "/" + +[web.watcher] +watch_path = ["lp-app/lpa-studio-web/src", "lp-app/lpa-studio-web/public", "lp-app/lpa-studio-web/tailwind.css"] + +[web.wasm_opt] +level = "z" diff --git a/lp-app/lpa-studio-web/README.md b/lp-app/lpa-studio-web/README.md index 51a9cd6bb..031c2da3d 100644 --- a/lp-app/lpa-studio-web/README.md +++ b/lp-app/lpa-studio-web/README.md @@ -1,23 +1,23 @@ # lpa-studio-web -`lpa-studio-web` is the static browser shell for the `lpa-studio-ux` slice. +`lpa-studio-web` is the static browser shell for `lpa-studio-core`. The web app owns Dioxus presentation. It renders `StudioView` panes and contextual `UiAction` controls, then dispatches those actions back into `StudioUx`. It also applies live `UxUpdate` values while long async actions are running. Browser-worker lifecycle, provider routing, protocol request correlation, running-project attach, demo project deployment, and project -inventory reads belong below the UI in `lpa-studio-ux`, `lpa-link`, and +inventory reads belong below the UI in `lpa-studio-core`, `lpa-link`, and `lpa-client`. ## Current Surface The active first screen is the Device pane, rendered from stack sections and -actions owned by the UX layer. In the browser build it starts with simulator +actions owned by the core layer. In the browser build it starts with simulator and ESP32 connection actions: ```text -lpa-studio-web -> lpa-studio-ux -> DeviceUx -> LinkProviderRegistry -> browser-worker -> fw-browser -> lp-server +lpa-studio-web -> lpa-studio-core -> DeviceUx -> LinkProviderRegistry -> browser-worker -> fw-browser -> lp-server ``` `DeviceUx` is the user-facing workflow for selecting a connection, opening the @@ -37,9 +37,9 @@ The current surface can launch the browser-local firmware runtime with the demo project, connect browser serial hardware, open the LightPlayer server protocol, attach to an already-loaded running project, explicitly load the built-in demo project on hardware, provision a blank ESP32-C6 with packaged LightPlayer -firmware, reset a provisioned ESP32-C6 back to blank, and display a small -project inventory summary. Project attach/load choices appear in the Device -pane. The Project pane appears once a project is loaded. +firmware, reset a provisioned ESP32-C6 back to blank, and render a readonly +project workspace once a project is loaded. Project attach/load choices appear +in the Device pane. The Project pane appears once a project is loaded. ## Run @@ -48,12 +48,17 @@ just studio-dev ``` `studio-dev` builds debug wasm artifacts for `lpa-studio-web` and `fw-browser`, -packages them with wasm-bindgen, prepares the wasm sidecar assets, and serves -`http://127.0.0.1:2820/`. +packages `fw-browser` with wasm-bindgen, prepares the wasm sidecar assets, and +serves `http://127.0.0.1:2820/` through `dx serve`. Use `just studio-web-build` or `just studio-web` for the release/static build -path. The release build still packages ESP32-C6 firmware assets for future -browser flashing work. +path. `dx build` writes Studio app assets under +`target/dx/lpa-studio-web/{debug,release}/web/public/`, while `public/` +contains only hand-authored static files that are copied into that output. +Generated runtime sidecars are built under `target/studio-web-assets/` and then +mirrored into the generated Dioxus public directory. Release app assets are +hash-named under `assets/`. The release build still packages ESP32-C6 firmware +assets for future browser flashing work. ## Deploy @@ -66,18 +71,20 @@ just studio-web-smoke target/pages/studio ``` The deploy artifact is staged under `target/pages/studio` and includes -`version.json`, `.nojekyll`, and `CNAME`. It is built from release wasm outputs -so stale debug artifacts left by `studio-dev` are not uploaded. +`version.json`, `.nojekyll`, and `CNAME`. It is built from the release `dx` +output so stale debug artifacts left by `studio-dev` are not uploaded. Manual beta deployment uses the same artifact recipe with `beta.lightplayer.app` and is published by the `Deploy Pages Channel` workflow. Operational setup, DNS records, and GitHub Pages HTTPS steps are documented in [`docs/deploy/studio-pages.md`](../../docs/deploy/studio-pages.md). -Browser-worker assets are served from `public/pkg/`. The UX boot path resolves -those paths to page-absolute URLs before sending them into the embedded blob -worker, which lets worker import/init failures surface as actionable link -errors instead of silent boot timeouts. +Browser-worker assets are served from `pkg/` in the generated site. The source +sidecar files are generated under `target/studio-web-assets/{debug,release}/pkg/` +and copied into `target/dx/lpa-studio-web/.../public/pkg/` after `dx` builds +the Studio app. The app-core boot path resolves those paths to page-absolute URLs +before sending them into the embedded blob worker, which lets worker import/init +failures surface as actionable link errors instead of silent boot timeouts. Browser ESP32 Web Serial uses the shared app-served controller at `public/lpa-link/browser_esp32_device_controller.js`. Both Studio's wasm-bound @@ -85,9 +92,10 @@ Browser ESP32 Web Serial uses the shared app-served controller at module, so normal connect/reset/read debugging exercises the same Web Serial lifecycle code that Studio uses. -ESP32-C6 firmware assets are served from -`public/firmware/esp32c6/manifest.json`. Browser serial provisioning imports a -pinned browser ESM `esptool-js` module from +ESP32-C6 firmware assets are generated under +`target/studio-web-assets/firmware/esp32c6/` and served from +`firmware/esp32c6/manifest.json` in the generated site. Browser serial +provisioning imports a pinned browser ESM `esptool-js` module from `https://cdn.jsdelivr.net/npm/esptool-js@0.6.0/+esm` by default; deployments can override the `BrowserSerialEsp32Options` path if they want to serve that module themselves. The CDN ESM endpoint avoids raw package bare imports such as `pako`, @@ -129,11 +137,52 @@ The page can select a Web Serial port, run the same normal reset/read path as Studio, exercise explicit USB-JTAG downloader reset experiments, and show raw serial output without involving the full Studio UX. +## Theme And Layout + +Studio web styling is Tailwind-first. Components should prefer semantic +Tailwind utilities in their Dioxus markup, using the existing `tw:` prefix while +legacy `ux-*` classes still exist. Theme values are defined as Studio CSS +variables in `src/style.css` and exposed to Tailwind from `tailwind.css` with +semantic names such as `background`, `card`, `border`, `muted-foreground`, +`accent`, and `status-warning-bg`. + +Use direct utility strings for simple static styling. Use small Rust helper +functions for repeated stateful variants such as status tones, action priority, +step state, pane emphasis, and project node status. Avoid adding broad new +selector families to `src/style.css`; that file should stay limited to theme +variables, base rules, keyframes, browser/measurement behavior, and explicitly +transitional story or exploration surfaces. + +Reusable Dioxus surfaces live under `src/base`, `src/core`, and `src/app`: + +- `ActionButton` and `ActionStrip` render `UiAction` controls. +- `PaneFrame`, `StatusChip`, and `MetricGrid` provide shared pane structure. +- `ProjectSidebar` renders the Project rail with compact node tree, project + stats, and project actions. +- `ProjectNodeWorkspace` renders all synced node bodies in tree order as the + transparent center workspace. +- `FieldRow` and `Tabs` remain editor-foundation primitives used by stories and + future editing surfaces. +- `StudioShell`, `UxPane`, and `RuntimeLog` render the active `StudioView`. + +The project editor layout target is: + +```text +lg: [ node tree ] [ nodes/editor ] [ device/secondary ] +md: [ nodes/editor ] [ tabs: node tree / device / bus / console ] +sm: [ tabs: nodes / node tree / device / bus / console ] +``` + +The active Project pane currently renders readonly synced node data. Slot +editing, overlay dirty-state, binding authoring, bus modeling, probes, and +asset editing belong to later milestones. + ## Stories -The storybook covers the active UX shell, connection action strip, Device stack -states, loaded Project pane state, browser-serial blank-firmware readiness, -provision-ready/provisioning/provision-failed, and wipe states. +The storybook covers the active Studio shell, connection action strip, Device stack +states, loaded Project pane state with readonly node workspace, +browser-serial blank-firmware readiness, provision-ready/provisioning/ +provision-failed, wipe states, and editor-foundation primitives. Run the dev server and open: ```text @@ -146,12 +195,36 @@ Generate or update visual baselines with: just studio-story-baselines-if-needed ``` -The baseline set intentionally reflects the active view-driven UX surface rather -than the old provisioning journey fixtures. +Baselines are captured for `sm`, `md`, and `lg` viewports. Files are named as a +story id plus viewport suffix, for example: + +```text +studio__editor-shell__sm.png +studio__editor-shell__md.png +studio__editor-shell__lg.png +``` + +Useful commands: + +```bash +just studio-story-pngs # scratch captures under story-images/.scratch +just studio-story-baselines # update committed sm/md/lg baselines +just studio-story-check # compare fresh captures with committed baselines +``` + +Baseline and check modes require `oxipng` so committed and fresh PNGs are +losslessly normalized. Install it with `brew install oxipng` or +`cargo install oxipng`. The capture script defaults to one Chrome page for +stable baseline/check output; set `STUDIO_STORY_PNGS_CONCURRENCY` for faster +scratch runs when needed. + +The baseline set intentionally reflects the active view-driven UX surface, +including the semantic project workspace, rather than the old provisioning +journey fixtures alone. ## Boundary -- `lpa-studio-ux` owns Studio product state, `StudioView` panes, stack views, +- `lpa-studio-core` owns Studio product state, `StudioView` panes, stack views, snapshots, actions, live `UxUpdate` activity, async dispatch, UX node ids, the link provider registry, and the connected server client. - `lpa-link` owns provider implementations, provider resources, sessions, and diff --git a/lp-app/lpa-studio-web/assets/tailwind.css b/lp-app/lpa-studio-web/assets/tailwind.css new file mode 100644 index 000000000..bd18742ad --- /dev/null +++ b/lp-app/lpa-studio-web/assets/tailwind.css @@ -0,0 +1,1346 @@ +/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --tw-font-mono: var(--studio-font-mono); + --tw-spacing: 0.25rem; + --tw-text-xs: 0.75rem; + --tw-text-xs--line-height: calc(1 / 0.75); + --tw-text-sm: 0.875rem; + --tw-text-sm--line-height: calc(1.25 / 0.875); + --tw-text-base: 1rem; + --tw-text-base--line-height: calc(1.5 / 1); + --tw-text-lg: 1.125rem; + --tw-text-lg--line-height: calc(1.75 / 1.125); + --tw-text-xl: 1.25rem; + --tw-text-xl--line-height: calc(1.75 / 1.25); + --tw-font-weight-medium: 500; + --tw-font-weight-bold: 700; + --tw-font-weight-extrabold: 800; + --tw-leading-tight: 1.25; + --tw-leading-snug: 1.375; + --tw-leading-normal: 1.5; + --tw-radius-xs: var(--studio-radius-xs); + --tw-radius-sm: var(--studio-radius-sm); + --tw-radius-md: var(--studio-radius-md); + --tw-default-transition-duration: 150ms; + --tw-default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + --tw-color-card: var(--studio-color-surface); + --tw-color-card-subtle: var(--studio-color-surface-subtle); + --tw-color-card-muted: var(--studio-color-surface-muted); + --tw-color-card-raised: var(--studio-color-surface-raised); + --tw-color-card-raised-strong: var(--studio-color-surface-raised-strong); + --tw-color-panel-primary: var(--studio-color-panel-primary); + --tw-color-terminal: var(--studio-color-terminal); + --tw-color-track: var(--studio-color-track); + --tw-color-border: var(--studio-color-border); + --tw-color-border-muted: var(--studio-color-border-muted); + --tw-color-border-subtle: var(--studio-color-border-subtle); + --tw-color-border-strong: var(--studio-color-border-strong); + --tw-color-panel-primary-border: var(--studio-color-panel-primary-border); + --tw-color-strong-foreground: var(--studio-color-text-strong); + --tw-color-soft-foreground: var(--studio-color-text-soft); + --tw-color-muted-foreground: var(--studio-color-text-muted); + --tw-color-subtle-foreground: var(--studio-color-text-subtle); + --tw-color-dim-foreground: var(--studio-color-text-dim); + --tw-color-heading: var(--studio-color-heading); + --tw-color-accent: var(--studio-color-accent); + --tw-color-accent-border: var(--studio-color-accent-border); + --tw-color-accent-hover: var(--studio-color-accent-hover); + --tw-color-accent-foreground: var(--studio-color-accent-text-on-fill); + --tw-color-status-neutral-bg: var(--studio-status-neutral-bg); + --tw-color-status-neutral-border: var(--studio-status-neutral-border); + --tw-color-status-neutral-foreground: var(--studio-status-neutral-text); + --tw-color-status-working-bg: var(--studio-status-working-bg); + --tw-color-status-working-border: var(--studio-status-working-border); + --tw-color-status-working-foreground: var(--studio-status-working-text); + --tw-color-status-good-bg: var(--studio-status-good-bg); + --tw-color-status-good-border: var(--studio-status-good-border); + --tw-color-status-good-foreground: var(--studio-status-good-text); + --tw-color-status-warning-bg: var(--studio-status-warning-bg); + --tw-color-status-warning-border: var(--studio-status-warning-border); + --tw-color-status-warning-foreground: var(--studio-status-warning-text); + --tw-color-status-error-bg: var(--studio-status-error-bg); + --tw-color-status-error-border: var(--studio-status-error-border); + --tw-color-status-error-foreground: var(--studio-status-error-text); + --tw-color-step-active: var(--studio-step-active-bg); + --tw-radius-pill: var(--studio-radius-pill); + } +} +@layer utilities { + .tw\:invisible { + visibility: hidden; + } + .tw\:absolute { + position: absolute; + } + .tw\:fixed { + position: fixed; + } + .tw\:relative { + position: relative; + } + .tw\:inset-0 { + inset: calc(var(--tw-spacing) * 0); + } + .tw\:top-0 { + top: calc(var(--tw-spacing) * 0); + } + .tw\:top-1\/2 { + top: calc(1/2 * 100%); + } + .tw\:left-0 { + left: calc(var(--tw-spacing) * 0); + } + .tw\:left-1\/2 { + left: calc(1/2 * 100%); + } + .tw\:z-\[70\] { + z-index: 70; + } + .tw\:order-1 { + order: 1; + } + .tw\:order-2 { + order: 2; + } + .tw\:order-3 { + order: 3; + } + .tw\:col-span-2 { + grid-column: span 2 / span 2; + } + .tw\:m-0 { + margin: calc(var(--tw-spacing) * 0); + } + .tw\:-mx-4 { + margin-inline: calc(var(--tw-spacing) * -4); + } + .tw\:mx-auto { + margin-inline: auto; + } + .tw\:-mt-4 { + margin-top: calc(var(--tw-spacing) * -4); + } + .tw\:mt-1 { + margin-top: calc(var(--tw-spacing) * 1); + } + .tw\:mt-2 { + margin-top: calc(var(--tw-spacing) * 2); + } + .tw\:-mb-4 { + margin-bottom: calc(var(--tw-spacing) * -4); + } + .tw\:mb-0\.5 { + margin-bottom: calc(var(--tw-spacing) * 0.5); + } + .tw\:mb-1\.5 { + margin-bottom: calc(var(--tw-spacing) * 1.5); + } + .tw\:mb-3 { + margin-bottom: calc(var(--tw-spacing) * 3); + } + .tw\:mb-4 { + margin-bottom: calc(var(--tw-spacing) * 4); + } + .tw\:mb-\[18px\] { + margin-bottom: 18px; + } + .tw\:box-content { + box-sizing: content-box; + } + .tw\:block { + display: block; + } + .tw\:flex { + display: flex; + } + .tw\:flow-root { + display: flow-root; + } + .tw\:grid { + display: grid; + } + .tw\:hidden { + display: none; + } + .tw\:inline-flex { + display: inline-flex; + } + .tw\:inline-grid { + display: inline-grid; + } + .tw\:h-2 { + height: calc(var(--tw-spacing) * 2); + } + .tw\:h-6 { + height: calc(var(--tw-spacing) * 6); + } + .tw\:h-14 { + height: calc(var(--tw-spacing) * 14); + } + .tw\:h-\[15px\] { + height: 15px; + } + .tw\:h-full { + height: 100%; + } + .tw\:h-px { + height: 1px; + } + .tw\:h-screen { + height: 100vh; + } + .tw\:max-h-48 { + max-height: calc(var(--tw-spacing) * 48); + } + .tw\:max-h-60 { + max-height: calc(var(--tw-spacing) * 60); + } + .tw\:max-h-80 { + max-height: calc(var(--tw-spacing) * 80); + } + .tw\:min-h-0 { + min-height: calc(var(--tw-spacing) * 0); + } + .tw\:min-h-6 { + min-height: calc(var(--tw-spacing) * 6); + } + .tw\:min-h-7 { + min-height: calc(var(--tw-spacing) * 7); + } + .tw\:min-h-8 { + min-height: calc(var(--tw-spacing) * 8); + } + .tw\:min-h-9 { + min-height: calc(var(--tw-spacing) * 9); + } + .tw\:min-h-32 { + min-height: calc(var(--tw-spacing) * 32); + } + .tw\:min-h-48 { + min-height: calc(var(--tw-spacing) * 48); + } + .tw\:min-h-56 { + min-height: calc(var(--tw-spacing) * 56); + } + .tw\:min-h-80 { + min-height: calc(var(--tw-spacing) * 80); + } + .tw\:min-h-\[46px\] { + min-height: 46px; + } + .tw\:min-h-full { + min-height: 100%; + } + .tw\:min-h-screen { + min-height: 100vh; + } + .tw\:w-2 { + width: calc(var(--tw-spacing) * 2); + } + .tw\:w-6 { + width: calc(var(--tw-spacing) * 6); + } + .tw\:w-14 { + width: calc(var(--tw-spacing) * 14); + } + .tw\:w-\[15px\] { + width: 15px; + } + .tw\:w-\[34px\] { + width: 34px; + } + .tw\:w-\[35\%\] { + width: 35%; + } + .tw\:w-\[min\(320px\,calc\(100vw-24px\)\)\] { + width: min(320px, calc(100vw - 24px)); + } + .tw\:w-\[min\(1520px\,100\%\)\] { + width: min(1520px, 100%); + } + .tw\:w-full { + width: 100%; + } + .tw\:w-max { + width: max-content; + } + .tw\:w-px { + width: 1px; + } + .tw\:max-w-\[360px\] { + max-width: 360px; + } + .tw\:max-w-\[420px\] { + max-width: 420px; + } + .tw\:max-w-\[520px\] { + max-width: 520px; + } + .tw\:max-w-full { + max-width: 100%; + } + .tw\:min-w-0 { + min-width: calc(var(--tw-spacing) * 0); + } + .tw\:min-w-\[2ch\] { + min-width: 2ch; + } + .tw\:min-w-\[58px\] { + min-width: 58px; + } + .tw\:flex-none { + flex: none; + } + .tw\:flex-shrink { + flex-shrink: 1; + } + .tw\:shrink-0 { + flex-shrink: 0; + } + .tw\:origin-left { + transform-origin: left; + } + .tw\:-translate-x-1\/2 { + --tw-translate-x: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .tw\:-translate-y-1\/2 { + --tw-translate-y: calc(calc(1/2 * 100%) * -1); + translate: var(--tw-translate-x) var(--tw-translate-y); + } + .tw\:rotate-90 { + rotate: 90deg; + } + .tw\:list-none { + list-style-type: none; + } + .tw\:appearance-none { + appearance: none; + } + .tw\:grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + .tw\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .tw\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .tw\:grid-cols-\[28px_minmax\(0\,1fr\)\] { + grid-template-columns: 28px minmax(0,1fr); + } + .tw\:grid-cols-\[32px_minmax\(0\,1fr\)\] { + grid-template-columns: 32px minmax(0,1fr); + } + .tw\:grid-cols-\[34px_minmax\(0\,1fr\)_auto\] { + grid-template-columns: 34px minmax(0,1fr) auto; + } + .tw\:grid-cols-\[52px_72px_minmax\(0\,1fr\)\] { + grid-template-columns: 52px 72px minmax(0,1fr); + } + .tw\:grid-cols-\[72px_minmax\(0\,1fr\)\] { + grid-template-columns: 72px minmax(0,1fr); + } + .tw\:grid-cols-\[72px_repeat\(3\,44px\)\] { + grid-template-columns: 72px repeat(3,44px); + } + .tw\:grid-cols-\[80px_minmax\(0\,1fr\)\] { + grid-template-columns: 80px minmax(0,1fr); + } + .tw\:grid-cols-\[260px_minmax\(0\,1fr\)\] { + grid-template-columns: 260px minmax(0,1fr); + } + .tw\:grid-cols-\[minmax\(0\,1fr\)_auto_auto\] { + grid-template-columns: minmax(0,1fr) auto auto; + } + .tw\:grid-cols-\[minmax\(0\,1fr\)_minmax\(300px\,380px\)\] { + grid-template-columns: minmax(0,1fr) minmax(300px,380px); + } + .tw\:grid-cols-\[minmax\(80px\,0\.35fr\)_minmax\(0\,1fr\)\] { + grid-template-columns: minmax(80px,0.35fr) minmax(0,1fr); + } + .tw\:grid-cols-\[minmax\(120px\,0\.4fr\)_minmax\(0\,1fr\)_24px\] { + grid-template-columns: minmax(120px,0.4fr) minmax(0,1fr) 24px; + } + .tw\:grid-cols-\[minmax\(120px\,0\.35fr\)_minmax\(0\,1fr\)\] { + grid-template-columns: minmax(120px,0.35fr) minmax(0,1fr); + } + .tw\:grid-cols-\[minmax\(170px\,240px\)_minmax\(0\,1fr\)\] { + grid-template-columns: minmax(170px,240px) minmax(0,1fr); + } + .tw\:grid-cols-\[minmax\(220px\,280px\)_minmax\(0\,1fr\)_minmax\(300px\,360px\)\] { + grid-template-columns: minmax(220px,280px) minmax(0,1fr) minmax(300px,360px); + } + .tw\:grid-cols-\[repeat\(auto-fit\,minmax\(130px\,1fr\)\)\] { + grid-template-columns: repeat(auto-fit,minmax(130px,1fr)); + } + .tw\:grid-cols-\[repeat\(auto-fit\,minmax\(140px\,1fr\)\)\] { + grid-template-columns: repeat(auto-fit,minmax(140px,1fr)); + } + .tw\:grid-rows-\[0fr\] { + grid-template-rows: 0fr; + } + .tw\:grid-rows-\[1fr\] { + grid-template-rows: 1fr; + } + .tw\:flex-wrap { + flex-wrap: wrap; + } + .tw\:place-items-center { + place-items: center; + } + .tw\:content-between { + align-content: space-between; + } + .tw\:content-start { + align-content: flex-start; + } + .tw\:items-baseline { + align-items: baseline; + } + .tw\:items-center { + align-items: center; + } + .tw\:items-start { + align-items: flex-start; + } + .tw\:items-stretch { + align-items: stretch; + } + .tw\:justify-between { + justify-content: space-between; + } + .tw\:justify-center { + justify-content: center; + } + .tw\:justify-end { + justify-content: flex-end; + } + .tw\:justify-start { + justify-content: flex-start; + } + .tw\:gap-0 { + gap: calc(var(--tw-spacing) * 0); + } + .tw\:gap-0\.5 { + gap: calc(var(--tw-spacing) * 0.5); + } + .tw\:gap-1 { + gap: calc(var(--tw-spacing) * 1); + } + .tw\:gap-1\.5 { + gap: calc(var(--tw-spacing) * 1.5); + } + .tw\:gap-2 { + gap: calc(var(--tw-spacing) * 2); + } + .tw\:gap-2\.5 { + gap: calc(var(--tw-spacing) * 2.5); + } + .tw\:gap-3 { + gap: calc(var(--tw-spacing) * 3); + } + .tw\:gap-3\.5 { + gap: calc(var(--tw-spacing) * 3.5); + } + .tw\:gap-4 { + gap: calc(var(--tw-spacing) * 4); + } + .tw\:gap-5 { + gap: calc(var(--tw-spacing) * 5); + } + .tw\:gap-\[18px\] { + gap: 18px; + } + .tw\:gap-\[26px\] { + gap: 26px; + } + .tw\:gap-px { + gap: 1px; + } + .tw\:gap-x-1\.5 { + column-gap: calc(var(--tw-spacing) * 1.5); + } + .tw\:gap-x-2 { + column-gap: calc(var(--tw-spacing) * 2); + } + .tw\:gap-x-3 { + column-gap: calc(var(--tw-spacing) * 3); + } + .tw\:gap-y-0\.5 { + row-gap: calc(var(--tw-spacing) * 0.5); + } + .tw\:gap-y-1 { + row-gap: calc(var(--tw-spacing) * 1); + } + .tw\:gap-y-2 { + row-gap: calc(var(--tw-spacing) * 2); + } + .tw\:divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .tw\:divide-border-muted { + :where(& > :not(:last-child)) { + border-color: var(--tw-color-border-muted); + } + } + .tw\:self-center { + align-self: center; + } + .tw\:truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tw\:overflow-auto { + overflow: auto; + } + .tw\:overflow-hidden { + overflow: hidden; + } + .tw\:overflow-visible { + overflow: visible; + } + .tw\:overflow-y-auto { + overflow-y: auto; + } + .tw\:rounded-full { + border-radius: calc(infinity * 1px); + } + .tw\:rounded-md { + border-radius: var(--tw-radius-md); + } + .tw\:rounded-pill { + border-radius: var(--tw-radius-pill); + } + .tw\:rounded-sm { + border-radius: var(--tw-radius-sm); + } + .tw\:rounded-xs { + border-radius: var(--tw-radius-xs); + } + .tw\:rounded-t-md { + border-top-left-radius: var(--tw-radius-md); + border-top-right-radius: var(--tw-radius-md); + } + .tw\:border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .tw\:border-0 { + border-style: var(--tw-border-style); + border-width: 0px; + } + .tw\:border-4 { + border-style: var(--tw-border-style); + border-width: 4px; + } + .tw\:border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .tw\:border-r { + border-right-style: var(--tw-border-style); + border-right-width: 1px; + } + .tw\:border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .tw\:border-b-4 { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 4px; + } + .tw\:border-l { + border-left-style: var(--tw-border-style); + border-left-width: 1px; + } + .tw\:border-l-2 { + border-left-style: var(--tw-border-style); + border-left-width: 2px; + } + .tw\:border-accent-border { + border-color: var(--tw-color-accent-border); + } + .tw\:border-border { + border-color: var(--tw-color-border); + } + .tw\:border-border-muted { + border-color: var(--tw-color-border-muted); + } + .tw\:border-border-strong { + border-color: var(--tw-color-border-strong); + } + .tw\:border-border-subtle { + border-color: var(--tw-color-border-subtle); + } + .tw\:border-current { + border-color: currentcolor; + } + .tw\:border-panel-primary-border { + border-color: var(--tw-color-panel-primary-border); + } + .tw\:border-status-error-border { + border-color: var(--tw-color-status-error-border); + } + .tw\:border-status-error-foreground { + border-color: var(--tw-color-status-error-foreground); + } + .tw\:border-status-good-border { + border-color: var(--tw-color-status-good-border); + } + .tw\:border-status-good-foreground { + border-color: var(--tw-color-status-good-foreground); + } + .tw\:border-status-neutral-border { + border-color: var(--tw-color-status-neutral-border); + } + .tw\:border-status-warning-border { + border-color: var(--tw-color-status-warning-border); + } + .tw\:border-status-warning-foreground { + border-color: var(--tw-color-status-warning-foreground); + } + .tw\:border-status-working-border { + border-color: var(--tw-color-status-working-border); + } + .tw\:border-status-working-foreground { + border-color: var(--tw-color-status-working-foreground); + } + .tw\:border-transparent { + border-color: transparent; + } + .tw\:bg-\[color-mix\(in_oklab\,var\(--color-accent-bg\)_60\%\,var\(--color-card\)\)\] { + background-color: var(--color-accent-bg); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab,var(--color-accent-bg) 60%,var(--color-card)); + } + } + .tw\:bg-\[color-mix\(in_oklab\,var\(--color-status-good-bg\)_55\%\,var\(--color-card\)\)\] { + background-color: var(--color-status-good-bg); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab,var(--color-status-good-bg) 55%,var(--color-card)); + } + } + .tw\:bg-accent { + background-color: var(--tw-color-accent); + } + .tw\:bg-border-muted { + background-color: var(--tw-color-border-muted); + } + .tw\:bg-card { + background-color: var(--tw-color-card); + } + .tw\:bg-card-muted { + background-color: var(--tw-color-card-muted); + } + .tw\:bg-card-raised { + background-color: var(--tw-color-card-raised); + } + .tw\:bg-card-subtle { + background-color: var(--tw-color-card-subtle); + } + .tw\:bg-panel-primary { + background-color: var(--tw-color-panel-primary); + } + .tw\:bg-status-error-bg { + background-color: var(--tw-color-status-error-bg); + } + .tw\:bg-status-good-bg { + background-color: var(--tw-color-status-good-bg); + } + .tw\:bg-status-neutral-bg { + background-color: var(--tw-color-status-neutral-bg); + } + .tw\:bg-status-warning-bg { + background-color: var(--tw-color-status-warning-bg); + } + .tw\:bg-status-working-bg { + background-color: var(--tw-color-status-working-bg); + } + .tw\:bg-step-active { + background-color: var(--tw-color-step-active); + } + .tw\:bg-terminal { + background-color: var(--tw-color-terminal); + } + .tw\:bg-track { + background-color: var(--tw-color-track); + } + .tw\:bg-transparent { + background-color: transparent; + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-error-bg\)\,transparent_66\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-error-bg),transparent 66%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-error-bg\)\,transparent_74\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-error-bg),transparent 74%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-error-bg\)_0\%\,transparent_72\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-error-bg) 0%,transparent 72%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-good-bg\)\,transparent_62\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-good-bg),transparent 62%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-good-bg\)\,transparent_74\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-good-bg),transparent 74%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-good-bg\)\,transparent_90\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-good-bg),transparent 90%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-good-bg\)_0\%\,transparent_72\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-good-bg) 0%,transparent 72%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-neutral-bg\)\,transparent_62\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-neutral-bg),transparent 62%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-neutral-bg\)\,transparent_74\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-neutral-bg),transparent 74%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-warning-bg\)\,transparent_62\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-warning-bg),transparent 62%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-warning-bg\)\,transparent_74\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-warning-bg),transparent 74%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-warning-bg\)_0\%\,transparent_72\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-warning-bg) 0%,transparent 72%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-working-bg\)\,transparent_62\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-working-bg),transparent 62%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-working-bg\)\,transparent_74\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-working-bg),transparent 74%); + } + .tw\:bg-\[linear-gradient\(90deg\,var\(--studio-status-working-bg\)_0\%\,transparent_72\%\)\] { + background-image: linear-gradient(90deg,var(--studio-status-working-bg) 0%,transparent 72%); + } + .tw\:bg-\[linear-gradient\(135deg\,var\(--studio-color-surface-muted\)\,var\(--studio-status-good-bg\)_54\%\,var\(--studio-color-surface-subtle\)\)\] { + background-image: linear-gradient(135deg,var(--studio-color-surface-muted),var(--studio-status-good-bg) 54%,var(--studio-color-surface-subtle)); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-color-surface-muted\)_0\%\,var\(--studio-color-surface-muted\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-color-surface-muted) 0%,var(--studio-color-surface-muted) 34%,transparent 100%); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-color-surface-subtle\)_0\%\,var\(--studio-color-surface-subtle\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-color-surface-subtle) 0%,var(--studio-color-surface-subtle) 34%,transparent 100%); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-error-bg\)_0\%\,var\(--studio-status-error-bg\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-status-error-bg) 0%,var(--studio-status-error-bg) 34%,transparent 100%); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-good-bg\)_0\%\,var\(--studio-status-good-bg\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-status-good-bg) 0%,var(--studio-status-good-bg) 34%,transparent 100%); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-warning-bg\)_0\%\,var\(--studio-status-warning-bg\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-status-warning-bg) 0%,var(--studio-status-warning-bg) 34%,transparent 100%); + } + .tw\:bg-\[linear-gradient\(270deg\,var\(--studio-status-working-bg\)_0\%\,var\(--studio-status-working-bg\)_34\%\,transparent_100\%\)\] { + background-image: linear-gradient(270deg,var(--studio-status-working-bg) 0%,var(--studio-status-working-bg) 34%,transparent 100%); + } + .tw\:p-0 { + padding: calc(var(--tw-spacing) * 0); + } + .tw\:p-2 { + padding: calc(var(--tw-spacing) * 2); + } + .tw\:p-3 { + padding: calc(var(--tw-spacing) * 3); + } + .tw\:p-4 { + padding: calc(var(--tw-spacing) * 4); + } + .tw\:p-\[18px\] { + padding: 18px; + } + .tw\:p-\[22px\] { + padding: 22px; + } + .tw\:px-2 { + padding-inline: calc(var(--tw-spacing) * 2); + } + .tw\:px-2\.5 { + padding-inline: calc(var(--tw-spacing) * 2.5); + } + .tw\:px-3 { + padding-inline: calc(var(--tw-spacing) * 3); + } + .tw\:px-4 { + padding-inline: calc(var(--tw-spacing) * 4); + } + .tw\:px-7 { + padding-inline: calc(var(--tw-spacing) * 7); + } + .tw\:px-\[18px\] { + padding-inline: 18px; + } + .tw\:py-1 { + padding-block: calc(var(--tw-spacing) * 1); + } + .tw\:py-1\.5 { + padding-block: calc(var(--tw-spacing) * 1.5); + } + .tw\:py-2 { + padding-block: calc(var(--tw-spacing) * 2); + } + .tw\:py-3 { + padding-block: calc(var(--tw-spacing) * 3); + } + .tw\:py-4 { + padding-block: calc(var(--tw-spacing) * 4); + } + .tw\:pt-0\.5 { + padding-top: calc(var(--tw-spacing) * 0.5); + } + .tw\:pt-1\.5 { + padding-top: calc(var(--tw-spacing) * 1.5); + } + .tw\:pt-7 { + padding-top: calc(var(--tw-spacing) * 7); + } + .tw\:pt-8 { + padding-top: calc(var(--tw-spacing) * 8); + } + .tw\:pt-16 { + padding-top: calc(var(--tw-spacing) * 16); + } + .tw\:pr-24 { + padding-right: calc(var(--tw-spacing) * 24); + } + .tw\:pb-1\.5 { + padding-bottom: calc(var(--tw-spacing) * 1.5); + } + .tw\:pb-3 { + padding-bottom: calc(var(--tw-spacing) * 3); + } + .tw\:pb-6 { + padding-bottom: calc(var(--tw-spacing) * 6); + } + .tw\:pb-16 { + padding-bottom: calc(var(--tw-spacing) * 16); + } + .tw\:pl-2 { + padding-left: calc(var(--tw-spacing) * 2); + } + .tw\:pl-2\.5 { + padding-left: calc(var(--tw-spacing) * 2.5); + } + .tw\:pl-4 { + padding-left: calc(var(--tw-spacing) * 4); + } + .tw\:pl-\[18px\] { + padding-left: 18px; + } + .tw\:text-center { + text-align: center; + } + .tw\:text-left { + text-align: left; + } + .tw\:text-right { + text-align: right; + } + .tw\:font-mono { + font-family: var(--tw-font-mono); + } + .tw\:text-base { + font-size: var(--tw-text-base); + line-height: var(--tw-leading, var(--tw-text-base--line-height)); + } + .tw\:text-lg { + font-size: var(--tw-text-lg); + line-height: var(--tw-leading, var(--tw-text-lg--line-height)); + } + .tw\:text-sm { + font-size: var(--tw-text-sm); + line-height: var(--tw-leading, var(--tw-text-sm--line-height)); + } + .tw\:text-xl { + font-size: var(--tw-text-xl); + line-height: var(--tw-leading, var(--tw-text-xl--line-height)); + } + .tw\:text-xs { + font-size: var(--tw-text-xs); + line-height: var(--tw-leading, var(--tw-text-xs--line-height)); + } + .tw\:text-\[0\.64rem\] { + font-size: 0.64rem; + } + .tw\:text-\[0\.66rem\] { + font-size: 0.66rem; + } + .tw\:text-\[0\.68rem\] { + font-size: 0.68rem; + } + .tw\:text-\[0\.78rem\] { + font-size: 0.78rem; + } + .tw\:text-\[1\.04rem\] { + font-size: 1.04rem; + } + .tw\:leading-none { + --tw-leading: 1; + line-height: 1; + } + .tw\:leading-normal { + --tw-leading: var(--tw-leading-normal); + line-height: var(--tw-leading-normal); + } + .tw\:leading-snug { + --tw-leading: var(--tw-leading-snug); + line-height: var(--tw-leading-snug); + } + .tw\:leading-tight { + --tw-leading: var(--tw-leading-tight); + line-height: var(--tw-leading-tight); + } + .tw\:font-bold { + --tw-font-weight: var(--tw-font-weight-bold); + font-weight: var(--tw-font-weight-bold); + } + .tw\:font-extrabold { + --tw-font-weight: var(--tw-font-weight-extrabold); + font-weight: var(--tw-font-weight-extrabold); + } + .tw\:font-medium { + --tw-font-weight: var(--tw-font-weight-medium); + font-weight: var(--tw-font-weight-medium); + } + .tw\:break-words { + overflow-wrap: break-word; + } + .tw\:text-ellipsis { + text-overflow: ellipsis; + } + .tw\:whitespace-nowrap { + white-space: nowrap; + } + .tw\:text-accent { + color: var(--tw-color-accent); + } + .tw\:text-accent-foreground { + color: var(--tw-color-accent-foreground); + } + .tw\:text-dim-foreground { + color: var(--tw-color-dim-foreground); + } + .tw\:text-heading { + color: var(--tw-color-heading); + } + .tw\:text-muted-foreground { + color: var(--tw-color-muted-foreground); + } + .tw\:text-soft-foreground { + color: var(--tw-color-soft-foreground); + } + .tw\:text-status-error-foreground { + color: var(--tw-color-status-error-foreground); + } + .tw\:text-status-good-foreground { + color: var(--tw-color-status-good-foreground); + } + .tw\:text-status-neutral-foreground { + color: var(--tw-color-status-neutral-foreground); + } + .tw\:text-status-warning-foreground { + color: var(--tw-color-status-warning-foreground); + } + .tw\:text-status-working-foreground { + color: var(--tw-color-status-working-foreground); + } + .tw\:text-strong-foreground { + color: var(--tw-color-strong-foreground); + } + .tw\:text-subtle-foreground { + color: var(--tw-color-subtle-foreground); + } + .tw\:uppercase { + text-transform: uppercase; + } + .tw\:no-underline { + text-decoration-line: none; + } + .tw\:opacity-0 { + opacity: 0%; + } + .tw\:opacity-100 { + opacity: 100%; + } + .tw\:shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .tw\:transition-\[grid-template-rows\,opacity\] { + transition-property: grid-template-rows,opacity; + transition-timing-function: var(--tw-ease, var(--tw-default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--tw-default-transition-duration)); + } + .tw\:transition-colors { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to; + transition-timing-function: var(--tw-ease, var(--tw-default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--tw-default-transition-duration)); + } + .tw\:transition-transform { + transition-property: transform, translate, scale, rotate; + transition-timing-function: var(--tw-ease, var(--tw-default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--tw-default-transition-duration)); + } + .tw\:duration-150 { + --tw-duration: 150ms; + transition-duration: 150ms; + } + .tw\:first\:border-t-0 { + &:first-child { + border-top-style: var(--tw-border-style); + border-top-width: 0px; + } + } + .tw\:last\:border-b-0 { + &:last-child { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 0px; + } + } + .tw\:last\:pb-0 { + &:last-child { + padding-bottom: calc(var(--tw-spacing) * 0); + } + } + .tw\:hover\:border-accent-border { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-accent-border); + } + } + } + .tw\:hover\:border-border-strong { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-border-strong); + } + } + } + .tw\:hover\:border-status-error-foreground { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-status-error-foreground); + } + } + } + .tw\:hover\:border-status-good-foreground { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-status-good-foreground); + } + } + } + .tw\:hover\:border-status-warning-foreground { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-status-warning-foreground); + } + } + } + .tw\:hover\:border-status-working-foreground { + &:hover { + @media (hover: hover) { + border-color: var(--tw-color-status-working-foreground); + } + } + } + .tw\:hover\:bg-accent-hover { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-accent-hover); + } + } + } + .tw\:hover\:bg-card-muted { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-card-muted); + } + } + } + .tw\:hover\:bg-card-raised { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-card-raised); + } + } + } + .tw\:hover\:bg-card-raised-strong { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-card-raised-strong); + } + } + } + .tw\:hover\:bg-card-subtle { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-card-subtle); + } + } + } + .tw\:hover\:bg-card-subtle\/60 { + &:hover { + @media (hover: hover) { + background-color: var(--tw-color-card-subtle); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--tw-color-card-subtle) 60%, transparent); + } + } + } + } + .tw\:hover\:text-accent { + &:hover { + @media (hover: hover) { + color: var(--tw-color-accent); + } + } + } + .tw\:hover\:text-muted-foreground { + &:hover { + @media (hover: hover) { + color: var(--tw-color-muted-foreground); + } + } + } + .tw\:hover\:text-status-good-foreground { + &:hover { + @media (hover: hover) { + color: var(--tw-color-status-good-foreground); + } + } + } + .tw\:hover\:text-strong-foreground { + &:hover { + @media (hover: hover) { + color: var(--tw-color-strong-foreground); + } + } + } + .tw\:focus-visible\:outline { + &:focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + } + .tw\:focus-visible\:outline-1 { + &:focus-visible { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } + } + .tw\:focus-visible\:outline-border-strong { + &:focus-visible { + outline-color: var(--tw-color-border-strong); + } + } + .tw\:disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .tw\:disabled\:opacity-60 { + &:disabled { + opacity: 60%; + } + } + .tw\:max-\[960px\]\:order-1 { + @media (width < 960px) { + order: 1; + } + } + .tw\:max-\[960px\]\:order-2 { + @media (width < 960px) { + order: 2; + } + } + .tw\:max-\[960px\]\:grid-cols-1 { + @media (width < 960px) { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + .tw\:max-\[880px\]\:grid-cols-1 { + @media (width < 880px) { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } + .tw\:max-\[880px\]\:border-r-0 { + @media (width < 880px) { + border-right-style: var(--tw-border-style); + border-right-width: 0px; + } + } + .tw\:max-\[880px\]\:border-b { + @media (width < 880px) { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + } + .tw\:max-\[880px\]\:px-\[18px\] { + @media (width < 880px) { + padding-inline: 18px; + } + } + .tw\:max-\[880px\]\:pt-\[18px\] { + @media (width < 880px) { + padding-top: 18px; + } + } + .tw\:max-\[880px\]\:pb-\[72px\] { + @media (width < 880px) { + padding-bottom: 72px; + } + } + .tw\:max-\[640px\]\:grid-cols-1 { + @media (width < 640px) { + grid-template-columns: repeat(1, minmax(0, 1fr)); + } + } +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-duration: initial; + --tw-outline-style: solid; + } + } +} diff --git a/lp-app/lpa-studio-web/build.rs b/lp-app/lpa-studio-web/build.rs new file mode 100644 index 000000000..4884cb484 --- /dev/null +++ b/lp-app/lpa-studio-web/build.rs @@ -0,0 +1,498 @@ +use std::collections::HashMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use syn::{Attribute, Item, Meta}; + +fn main() { + println!("cargo:rerun-if-changed=src"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest dir")); + let src_dir = manifest_dir.join("src"); + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("out dir")); + let generated_path = out_dir.join("story_registry.generated.rs"); + + let story_files = discover_story_files(&src_dir).unwrap_or_else(|error| { + panic!("failed to discover Studio story files under {src_dir:?}: {error}") + }); + let story_modules = story_files + .iter() + .map(|story_file| { + StoryModule::read(&src_dir, story_file).unwrap_or_else(|error| { + panic!( + "failed to parse Studio story file {}:\n{error}", + story_file.display() + ) + }) + }) + .collect::>(); + + validate_story_ids(&story_modules); + fs::write(generated_path, generate_registry(&story_modules)) + .expect("write generated story registry"); +} + +fn discover_story_files(src_dir: &Path) -> io::Result> { + let mut story_files = Vec::new(); + collect_story_files(src_dir, &mut story_files)?; + story_files.sort(); + Ok(story_files) +} + +fn collect_story_files(dir: &Path, story_files: &mut Vec) -> io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_story_files(&path, story_files)?; + continue; + } + + let Some(file_name) = path.file_name().and_then(|file_name| file_name.to_str()) else { + continue; + }; + if file_name.ends_with("_stories.rs") { + story_files.push(path); + } + } + Ok(()) +} + +#[derive(Debug)] +struct StoryModule { + path: PathBuf, + module_path: String, + stories: Vec, +} + +impl StoryModule { + fn read(src_dir: &Path, story_file: &Path) -> Result { + let source = fs::read_to_string(story_file) + .map_err(|error| format!("could not read story file: {error}"))?; + let parsed = syn::parse_file(&source) + .map_err(|error| format!("Rust parse error before story discovery: {error}"))?; + let path_info = StoryPathInfo::from_path(src_dir, story_file)?; + let module_path = story_module_path(src_dir, story_file)?; + let source_path = story_source_path(src_dir, story_file)?; + + let mut stories = Vec::new(); + for item in parsed.items { + let Item::Fn(function) = item else { + continue; + }; + let Some(attribute) = function.attrs.iter().find(|attr| is_story_attr(attr)) else { + continue; + }; + let metadata = + StoryMetadata::from_attribute(attribute, &function.sig.ident.to_string())?; + let story_segment = route_segment_from_ident(&function.sig.ident.to_string()); + let id = path_info.story_id(&story_segment); + stories.push(StorySpec { + id, + source_path: source_path.clone(), + family: path_info.family.clone(), + category: path_info.category.clone(), + component: path_info.component.clone(), + story: story_segment, + function_name: function.sig.ident.to_string(), + label: metadata.label, + description: metadata.description, + }); + } + + if stories.is_empty() { + return Err(format!( + "story file matched `*_stories.rs` but contains no `#[story]` functions.\n\ + Add functions like `#[story] fn example() -> Element {{ ... }}`,\n\ + or rename the file so it does not end with `_stories.rs`." + )); + } + + Ok(Self { + path: story_file.to_path_buf(), + module_path, + stories, + }) + } +} + +#[derive(Debug)] +struct StoryPathInfo { + family: String, + category: Option, + component: String, +} + +impl StoryPathInfo { + fn from_path(src_dir: &Path, story_file: &Path) -> Result { + let relative = story_file + .strip_prefix(src_dir) + .map_err(|_| "story file is not under src".to_string())?; + let segments = relative + .iter() + .map(|segment| segment.to_string_lossy().into_owned()) + .collect::>(); + + match segments.as_slice() { + [source_root, file_name] => Ok(Self { + family: story_family_from_source_root(source_root)?, + category: None, + component: component_from_story_file(file_name)?, + }), + [source_root, category, file_name] => Ok(Self { + family: story_family_from_source_root(source_root)?, + category: Some(route_segment_from_ident(category)), + component: component_from_story_file(file_name)?, + }), + _ => Err(format!( + "unsupported story path `{}`.\n\ + Expected a story file under `src/base`, `src/core`, \ + `src/app`, or `src/exploration`, using either \ + `_stories.rs` or `/_stories.rs`.", + relative.display() + )), + } + } + + fn story_id(&self, story: &str) -> String { + let mut id = self.family.clone(); + id.push('/'); + if let Some(category) = &self.category { + id.push_str(category); + id.push('/'); + } + id.push_str(&self.component); + id.push('/'); + id.push_str(story); + id + } +} + +#[derive(Debug)] +struct StoryMetadata { + label: String, + description: String, +} + +impl StoryMetadata { + fn from_attribute(attribute: &Attribute, function_name: &str) -> Result { + let mut label = None; + let mut description = None; + let mut errors = Vec::new(); + + match &attribute.meta { + Meta::Path(_) => {} + Meta::List(_) => { + attribute + .parse_nested_meta(|meta| { + if meta.path.is_ident("label") { + let value = meta.value()?; + let literal: syn::LitStr = value.parse()?; + if label.replace(literal.value()).is_some() { + errors.push(format!( + "`{function_name}` has duplicate `label` entries in #[story]" + )); + } + return Ok(()); + } + + if meta.path.is_ident("description") { + let value = meta.value()?; + let literal: syn::LitStr = value.parse()?; + if description.replace(literal.value()).is_some() { + errors.push(format!( + "`{function_name}` has duplicate `description` entries in #[story]" + )); + } + return Ok(()); + } + + let name = meta + .path + .get_ident() + .map(ToString::to_string) + .unwrap_or_else(|| "".to_string()); + errors.push(format!( + "`{function_name}` uses unsupported #[story] argument `{name}`; \ + use `#[story]`, `label = \"...\"`, or `description = \"...\"`" + )); + Ok(()) + }) + .map_err(|error| { + format!("could not parse #[story(...)] on `{function_name}`: {error}") + })?; + } + Meta::NameValue(_) => { + errors.push(format!( + "`{function_name}` uses unsupported #[story = ...] syntax; \ + use `#[story]` or `#[story(label = \"...\")]`" + )); + } + } + + if !errors.is_empty() { + return Err(errors.join("\n")); + } + + Ok(Self { + label: label.unwrap_or_else(|| story_label_from_ident(function_name)), + description: description.unwrap_or_default(), + }) + } +} + +fn story_label_from_ident(function_name: &str) -> String { + let mut label = String::with_capacity(function_name.len()); + let mut previous_was_space = false; + for ch in function_name.chars() { + if ch.is_ascii_alphanumeric() { + if label.is_empty() { + label.push(ch.to_ascii_uppercase()); + } else { + label.push(ch.to_ascii_lowercase()); + } + previous_was_space = false; + } else if !label.is_empty() && !previous_was_space { + label.push(' '); + previous_was_space = true; + } + } + if label.ends_with(' ') { + label.pop(); + } + label +} + +#[derive(Debug)] +struct StorySpec { + id: String, + source_path: String, + family: String, + category: Option, + component: String, + story: String, + function_name: String, + label: String, + description: String, +} + +fn validate_story_ids(story_modules: &[StoryModule]) { + let mut seen = HashMap::<&str, &Path>::new(); + let mut duplicates = Vec::new(); + for module in story_modules { + for story in &module.stories { + if let Some(existing_path) = seen.insert(&story.id, &module.path) { + duplicates.push(format!( + "`{}` is declared in both `{}` and `{}`", + story.id, + existing_path.display(), + module.path.display() + )); + } + } + } + + if !duplicates.is_empty() { + panic!( + "duplicate Studio story ids detected:\n{}", + duplicates.join("\n") + ); + } +} + +fn generate_registry(story_modules: &[StoryModule]) -> String { + let mut generated = String::new(); + generated.push_str("// @generated by lpa-studio-web/build.rs\n\n"); + generated.push_str("pub const GENERATED_AT_UTC: &str = \""); + generated.push_str(&rust_string_literal(&build_timestamp_utc())); + generated.push_str("\";\n\n"); + + generated.push_str( + "\npub fn all_generated_stories() -> Vec {\n", + ); + generated.push_str(" vec![\n"); + for story_module in story_modules { + for story in &story_module.stories { + generated.push_str(" crate::stories::story::StoryDescriptor::new(\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.id)); + generated.push_str("\",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.source_path)); + generated.push_str("\",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.family)); + generated.push_str("\",\n"); + generated.push_str(" "); + match &story.category { + Some(category) => { + generated.push_str("Some(\""); + generated.push_str(&rust_string_literal(category)); + generated.push_str("\")"); + } + None => generated.push_str("None"), + } + generated.push_str(",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.component)); + generated.push_str("\",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.story)); + generated.push_str("\",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.label)); + generated.push_str("\",\n"); + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.description)); + generated.push_str("\",\n"); + generated.push_str(" ),\n"); + } + } + generated.push_str(" ]\n"); + generated.push_str("}\n"); + + generated.push_str( + "\npub fn render_generated_story(id: &str) -> Option {\n", + ); + generated.push_str(" match id {\n"); + for story_module in story_modules { + for story in &story_module.stories { + generated.push_str(" \""); + generated.push_str(&rust_string_literal(&story.id)); + generated.push_str("\" => Some("); + generated.push_str(&story_module.module_path); + generated.push_str("::"); + generated.push_str(&story.function_name); + generated.push_str("()),\n"); + } + } + generated.push_str(" _ => None,\n"); + generated.push_str(" }\n"); + generated.push_str("}\n"); + + generated +} + +fn build_timestamp_utc() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after Unix epoch") + .as_secs(); + format_unix_timestamp_utc(seconds) +} + +fn format_unix_timestamp_utc(seconds: u64) -> String { + let days = (seconds / 86_400) as i64; + let day_seconds = seconds % 86_400; + let (year, month, day) = civil_from_days(days); + let hour = day_seconds / 3_600; + let minute = (day_seconds % 3_600) / 60; + let second = day_seconds % 60; + + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02} UTC") +} + +fn civil_from_days(days_since_epoch: i64) -> (i64, u32, u32) { + let days = days_since_epoch + 719_468; + let era = if days >= 0 { days } else { days - 146_096 } / 146_097; + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += if month <= 2 { 1 } else { 0 }; + + (year, month as u32, day as u32) +} + +fn is_story_attr(attribute: &Attribute) -> bool { + attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == "story") +} + +fn story_family_from_source_root(source_root: &str) -> Result { + match source_root { + "base" => Ok("base".to_string()), + "core" => Ok("core".to_string()), + "app" => Ok("studio".to_string()), + "exploration" => Ok("exploration".to_string()), + _ => Err(format!( + "unsupported story source root `{source_root}`.\n\ + Component stories should live beside their components in `base`, \ + `core`, or `app`. Design spikes may live in `exploration`." + )), + } +} + +fn component_from_story_file(file_name: &str) -> Result { + let Some(component) = file_name.strip_suffix("_stories.rs") else { + return Err(format!( + "story file `{file_name}` should end with `_stories.rs`" + )); + }; + if component.is_empty() { + return Err(format!( + "story file `{file_name}` must include a component name before `_stories.rs`" + )); + } + Ok(route_segment_from_ident(component)) +} + +fn route_segment_from_ident(value: &str) -> String { + let mut segment = String::with_capacity(value.len()); + let mut previous_was_separator = false; + for ch in value.chars() { + let normalized = if ch.is_ascii_alphanumeric() { + previous_was_separator = false; + ch.to_ascii_lowercase() + } else if previous_was_separator { + continue; + } else { + previous_was_separator = true; + '-' + }; + segment.push(normalized); + } + segment.trim_matches('-').to_string() +} + +fn story_module_path(src_dir: &Path, story_file: &Path) -> Result { + let relative = story_file + .strip_prefix(src_dir) + .map_err(|_| "story file is not under src".to_string())?; + let mut module_path = "crate".to_string(); + for component in relative.components() { + let segment = component.as_os_str().to_string_lossy(); + let segment = segment.strip_suffix(".rs").unwrap_or(&segment); + module_path.push_str("::"); + module_path.push_str(segment); + } + Ok(module_path) +} + +fn story_source_path(src_dir: &Path, story_file: &Path) -> Result { + let relative = story_file + .strip_prefix(src_dir) + .map_err(|_| "story file is not under src".to_string())?; + Ok(format!("src/{}", slash_path(relative))) +} + +fn slash_path(path: &Path) -> String { + path.iter() + .map(|segment| segment.to_string_lossy()) + .collect::>() + .join("/") +} + +fn rust_string_literal(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/lp-app/lpa-studio-web/public/index.html b/lp-app/lpa-studio-web/public/index.html deleted file mode 100644 index 91ec0331e..000000000 --- a/lp-app/lpa-studio-web/public/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - LightPlayer Studio - - -
- - - diff --git a/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs index 0a35bd588..ca87cf435 100644 --- a/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs +++ b/lp-app/lpa-studio-web/scripts/studio-story-pngs.mjs @@ -18,16 +18,25 @@ import path from "node:path"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, "../../.."); -const publicDir = path.join(repoRoot, "lp-app/lpa-studio-web/public"); +const publicDir = path.resolve( + repoRoot, + process.env.STUDIO_STORY_SITE_DIR ?? "target/dx/lpa-studio-web/debug/web/public", +); const storyRoot = path.join(repoRoot, "lp-app/lpa-studio-web"); const mode = parseMode(process.argv.slice(2)); const port = process.env.STUDIO_STORY_PNGS_PORT ?? "2822"; const requestedCaptureConcurrency = parseCaptureConcurrency(); +const captureTimeoutMs = parsePositiveIntegerEnv("STUDIO_STORY_CAPTURE_TIMEOUT_MS", 10_000); const baseUrl = `http://127.0.0.1:${port}/`; const chrome = process.env.CHROME_BIN ?? findChrome(); const baselineDir = path.resolve(repoRoot, baselineDirFromEnv()); const outputDir = path.resolve(repoRoot, outputDirForMode(mode)); const captureDir = mode === "baselines" ? path.join(baselineDir, ".new") : outputDir; +const STORY_VIEWPORTS = [ + { id: "sm", width: 390, height: 760 }, + { id: "md", width: 720, height: 760 }, + { id: "lg", width: 1080, height: 760 }, +]; class CdpConnection { static async open(url) { @@ -177,10 +186,14 @@ function baselineDirFromEnv() { } function parseCaptureConcurrency() { - const value = process.env.STUDIO_STORY_PNGS_CONCURRENCY ?? "4"; + return parsePositiveIntegerEnv("STUDIO_STORY_PNGS_CONCURRENCY", 1); +} + +function parsePositiveIntegerEnv(name, defaultValue) { + const value = process.env[name] ?? defaultValue.toString(); const parsed = Number.parseInt(value, 10); if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed.toString() !== value) { - console.error("STUDIO_STORY_PNGS_CONCURRENCY must be a positive integer."); + console.error(`${name} must be a positive integer.`); process.exit(2); } return parsed; @@ -190,23 +203,29 @@ async function discoverStoryIds() { const html = await runChrome([ "--headless=new", "--disable-gpu", + "--disable-application-cache", + "--disk-cache-size=0", "--virtual-time-budget=5000", "--dump-dom", - `${baseUrl}#/stories`, + `${baseUrl}?story-discovery=${Date.now()}#/stories`, ]); return Array.from(html.matchAll(/href="#\/stories\/([^"]+)"/g)) .map((match) => decodeURIComponent(match[1])) + .map((storyId) => storyId.split(/[?#]/, 1)[0]) .filter((value, index, values) => values.indexOf(value) === index) .sort(); } async function captureStories(storyIds, directory) { - const concurrency = Math.min(requestedCaptureConcurrency, storyIds.length); - const files = new Array(storyIds.length); + const targets = storyTargets(storyIds); + const concurrency = Math.min(requestedCaptureConcurrency, targets.length); + const files = new Array(targets.length); const browser = await launchCaptureBrowser(concurrency); - let nextStoryIndex = 0; + let nextTargetIndex = 0; - console.log(`Capturing ${storyIds.length} stories with ${concurrency} Chrome pages...`); + console.log( + `Capturing ${targets.length} story viewports (${storyIds.length} stories x ${STORY_VIEWPORTS.length} sizes) with ${concurrency} Chrome pages...`, + ); try { await Promise.all( @@ -215,9 +234,9 @@ async function captureStories(storyIds, directory) { browser, directory, files, - nextStoryIndex: () => nextStoryIndex++, + nextTargetIndex: () => nextTargetIndex++, pageIndex, - storyIds, + targets, }), ), ); @@ -231,21 +250,27 @@ async function captureStoryWorker({ browser, directory, files, - nextStoryIndex, + nextTargetIndex, pageIndex, - storyIds, + targets, }) { while (true) { - const storyIndex = nextStoryIndex(); - if (storyIndex >= storyIds.length) { + const targetIndex = nextTargetIndex(); + if (targetIndex >= targets.length) { return; } - const storyId = storyIds[storyIndex]; - const file = path.join(directory, storyFileName(storyId)); - await browser.capture(pageIndex, storyPngUrl(storyId), storyId, file); + const target = targets[targetIndex]; + const file = path.join(directory, storyFileName(target.storyId, target.viewport)); + await browser.capture( + pageIndex, + storyPngUrl(target.storyId, target.viewport), + target.storyId, + target.viewport, + file, + ); console.log(`wrote ${path.relative(repoRoot, file)}`); - files[storyIndex] = file; + files[targetIndex] = file; } } @@ -274,8 +299,8 @@ async function launchCaptureBrowser(pageCount) { ); return { - async capture(pageIndex, url, storyId, file) { - await pages[pageIndex].capture(url, storyId, file); + async capture(pageIndex, url, storyId, viewport, file) { + await pages[pageIndex].capture(url, storyId, viewport, file); }, async close() { @@ -301,20 +326,22 @@ async function createCapturePage(cdp) { }); await cdp.send("Page.enable", {}, sessionId); await cdp.send("Runtime.enable", {}, sessionId); - await cdp.send( - "Emulation.setDeviceMetricsOverride", - { - width: 1080, - height: 760, - deviceScaleFactor: 1, - mobile: false, - }, - sessionId, - ); return { - async capture(url, storyId, file) { + async capture(url, storyId, viewport, file) { + await cdp.send( + "Emulation.setDeviceMetricsOverride", + { + width: viewport.width, + height: viewport.height, + deviceScaleFactor: 1, + mobile: viewport.width <= 640, + }, + sessionId, + ); await cdp.send("Page.navigate", { url }, sessionId); + await waitForCaptureBox(cdp, sessionId, storyId); + await waitForStoryReady(cdp, sessionId, storyId); const box = await waitForCaptureBox(cdp, sessionId, storyId); const clip = captureClip(box); const { data } = await cdp.send( @@ -389,7 +416,7 @@ async function waitForCaptureBox(cdp, sessionId, storyId) { })() `; const started = Date.now(); - while (Date.now() - started < 10_000) { + while (Date.now() - started < captureTimeoutMs) { const box = await evaluate(cdp, sessionId, expression); if (box) { return box; @@ -399,6 +426,27 @@ async function waitForCaptureBox(cdp, sessionId, storyId) { throw new Error(`Timed out waiting for story capture target: ${storyId}`); } +async function waitForStoryReady(cdp, sessionId, storyId) { + const expression = ` + (() => { + const el = document.querySelector('[data-story-capture="1"]'); + if (!el || el.getAttribute('data-story-id') !== ${JSON.stringify(storyId)}) { + return false; + } + return !document.querySelector('[data-story-wait="1"]'); + })() + `; + const started = Date.now(); + while (Date.now() - started < 10_000) { + const ready = await evaluate(cdp, sessionId, expression); + if (ready) { + return; + } + await delay(50); + } + throw new Error(`Timed out waiting for story ready state: ${storyId}`); +} + async function evaluate(cdp, sessionId, expression) { const response = await cdp.send( "Runtime.evaluate", @@ -442,14 +490,17 @@ async function optimizePngs(files, { required }) { } async function compareBaselines(storyIds, expectedDir, actualDir) { - const expectedFiles = new Set(storyIds.map(storyFileName)); + const targets = storyTargets(storyIds); + const expectedFiles = new Set( + targets.map((target) => storyFileName(target.storyId, target.viewport)), + ); const baselineFiles = await listPngFiles(expectedDir); const unexpected = baselineFiles.filter((file) => !expectedFiles.has(file)); const missing = []; const changed = []; - for (const storyId of storyIds) { - const fileName = storyFileName(storyId); + for (const target of targets) { + const fileName = storyFileName(target.storyId, target.viewport); const expectedFile = path.join(expectedDir, fileName); const actualFile = path.join(actualDir, fileName); const expected = await readOptionalFile(expectedFile); @@ -560,12 +611,18 @@ function printComparison(label, files) { } } -function storyFileName(storyId) { - return `${storyId.replaceAll("/", "__")}.png`; +function storyTargets(storyIds) { + return storyIds.flatMap((storyId) => + STORY_VIEWPORTS.map((viewport) => ({ storyId, viewport })), + ); +} + +function storyFileName(storyId, viewport) { + return `${storyId.replaceAll("/", "__")}__${viewport.id}.png`; } -function storyPngUrl(storyId) { - return `${baseUrl}?story-png=1&story=${encodeURIComponent(storyId)}#/stories/${storyId}`; +function storyPngUrl(storyId, viewport) { + return `${baseUrl}?story-png=1&story=${encodeURIComponent(storyId)}&viewport=${viewport.id}#/stories/${storyId}`; } function findCommand(command) { diff --git a/lp-app/lpa-studio-web/src/README.md b/lp-app/lpa-studio-web/src/README.md new file mode 100644 index 000000000..06ea03b46 --- /dev/null +++ b/lp-app/lpa-studio-web/src/README.md @@ -0,0 +1,158 @@ +# Studio Web Component Families + +Studio web UI components are organized by dependency direction and domain +knowledge. + +## `base` + +Base building blocks. These are generic controls and display primitives, similar +to components Studio might get from a design-system package. + +Rules: + +- Do not depend on `lpa-studio-core`. +- Do not know about Studio devices, projects, nodes, or panes. +- Prefer stable, reusable props over rendering app-core view models directly. + +Examples: icon, tabs, simple field rows. + +## `core` + +Data-driven controls. These render generic `Ui*` data structs from +`lpa-studio-core` with unprefixed component names in `lpa-studio-web`. + +Rules: + +- May depend on `lpa-studio-core` generic UI types such as `UiAction`, + `UiStatus`, `UiProgress`, `UiIssue`, `UiActivityView`, `UiPaneView`, + and `UiStepsView`. +- May compose `base`. +- Should not own Studio-specific workflows when `app` can compose them. +- Use the `view` submodule for composed render surfaces such as pane bodies, + activities, and step workflows. Smaller controls such as status chips, + progress bars, log lists, and issue views live directly under `core`. + +Examples: action strips, status chips, progress bars, metric grids, issue +views, log lists, terminal output, activity views, steps views, pane views. + +## `app` + +Studio-specific surfaces and workflows. These components understand LightPlayer +Studio concepts and compose page-level UI. + +Rules: + +- May depend on domain-specific ux views such as project, device, and node + views. +- May compose `core` and `base`. +- Owns layout and workflow composition for Studio surfaces. + +Examples: Studio shell, pane frame, project workspace, runtime log chrome, node +UI. + +## Dependency Direction + +```text +base <- core <- app +``` + +Imports should follow the arrows. If a component wants to import “up” the stack, +it probably belongs in the higher family. + +## Stories + +Component stories are colocated with the component family they describe, but +they are not listed in the central story registry by hand. Add `*_stories.rs` +files beside the relevant component family, list them in the nearest `mod.rs` +behind `#[cfg(feature = "stories")]`, and mark story entry functions with +`#[story]`; the generated story registry discovers those files and calls the +normal Rust module path. + +```text +src/base/_stories.rs +src/core/_stories.rs +src/core//_stories.rs +src/app/_stories.rs +src/app//_stories.rs +src/exploration/_stories.rs +``` + +Examples: + +```text +src/base/popover_stories.rs -> base/popover/ +src/core/action/action_strip_stories.rs -> core/action/action-strip/ +src/app/device/device_pane_stories.rs -> studio/device/device-pane/ +src/exploration/node_ui_stories.rs -> exploration/node-ui/ +``` + +Within a story file, define zero-argument functions returning `Element`: + +```rust +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +#[story] +fn edge_placement() -> Element { + rsx! { section { "..." } } +} +``` + +Story ids are inferred from the path plus function name. The example above in +`src/base/popover_stories.rs` becomes: + +```text +base/popover/edge-placement +``` + +Use `snake_case` for Rust filenames and functions; the registry converts those +segments to `kebab-case` for story routes and baseline PNG names. The visible +story label is also derived from the function name, so `edge_placement` renders +as `Edge placement`. Use `#[story(label = "...")]` only when the derived label +would be misleading, and `description = "..."` only when the storybook chrome +needs extra context. + +The storybook creates one synthetic overview route per component, such as: + +```text +base/popover/overview +``` + +Overview pages render every story for that component in one scrollable review +surface. They are storybook UI affordances, not generated story functions. +Individual story pages should keep their own chrome minimal: title, optional +description, and the source file path supplied by the generated descriptor. + +`build.rs` parses `#[story]` metadata with `syn` and generates the central +story registry. If a story is malformed, the build should fail with a concrete +diagnostic telling you which file, function, or route is wrong. Do not recreate +manual `StoryDescriptor` arrays or per-file `render_story` matches. + +Broad fixture modules are allowed during exploration, but story entrypoints +should live in real component-adjacent files. Shared story fixtures should not +end in `_stories.rs`; for example, `app/story_fixtures.rs` can support +stories under `app/device/*_stories.rs`, +`app/project/*_stories.rs`, and `app/layout/*_stories.rs`, while +`core/story_fixtures.rs` can support data-driven core component stories. + +Story source-root guidance: + +- `base` generates the `base` story family for generic building blocks such + as popovers, tabs, buttons, and icons. +- `core` generates the `core` story family for data-driven controls that + render generic `Ui*` values. +- `app` generates the `studio` story family for app/domain surfaces such + as device, project, panes, and shell. +- `exploration` generates the `exploration` story family for spikes and + mockups that are intentionally not production + component stories yet. + +When a change touches Studio web source or story output, follow the repo +baseline workflow: + +```bash +just studio-story-baselines-if-needed +``` + +Include updated files from `lp-app/lpa-studio-web/story-images/` with the same +commit when baselines change. diff --git a/lp-app/lpa-studio-web/src/app.rs b/lp-app/lpa-studio-web/src/app.rs deleted file mode 100644 index bb95ae410..000000000 --- a/lp-app/lpa-studio-web/src/app.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; - -use dioxus::prelude::*; -use lpa_studio_ux::{ - StudioUx, StudioView, UiAction, UiActivity, UiBody, UiStatus, UiStepState, UxActivityTarget, - UxError, UxLogEntry, UxLogLevel, UxNotice, UxNoticeLevel, UxUpdate, UxUpdateSink, -}; - -use crate::components::StudioShell; - -const STYLE: &str = include_str!("style.css"); - -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn App() -> Element { - #[cfg(feature = "stories")] - if crate::stories::story_book::should_show_story_book() { - return rsx! { - style { "{STYLE}" } - crate::stories::story_book::StoryBook {} - }; - } - - let model = use_signal(StudioWebModel::new); - let view = model.read().view.clone(); - let running = model.read().running; - let on_action = move |action: UiAction| { - spawn(async move { - execute_action(model, action).await; - }); - }; - - rsx! { - style { "{STYLE}" } - StudioShell { - view, - running, - on_action, - } - } -} - -struct StudioWebModel { - ux: Option, - view: StudioView, - running: bool, - console_logs: Vec, -} - -impl StudioWebModel { - fn new() -> Self { - let ux = StudioUx::new(); - let view = ux.view(); - Self { - ux: Some(ux), - view, - running: false, - console_logs: Vec::new(), - } - } - - fn refresh_from_ux(&mut self) { - if let Some(ux) = &self.ux { - self.view = ux.view(); - self.append_console_logs_to_view(); - } - } - - fn apply_update(&mut self, update: UxUpdate) { - match update { - UxUpdate::View(mut view) => { - view.logs.extend(self.console_logs.clone()); - self.view = view; - } - UxUpdate::Activity { - target, - status, - activity, - } => { - self.apply_activity_update(target, status, activity); - } - UxUpdate::Log(log) => { - self.view.logs.push(log); - } - } - } - - fn push_console_log(&mut self, log: UxLogEntry) { - self.console_logs.push(log.clone()); - if self.console_logs.len() > 80 { - let remove_count = self.console_logs.len() - 80; - self.console_logs.drain(0..remove_count); - } - self.view.logs.push(log); - } - - fn append_console_logs_to_view(&mut self) { - self.view.logs.extend(self.console_logs.clone()); - } - - fn apply_activity_update( - &mut self, - target: UxActivityTarget, - status: UiStatus, - activity: UiActivity, - ) { - let Some(pane) = self - .view - .panes - .iter_mut() - .find(|pane| pane.node_id.as_str() == target.pane_node_id().as_str()) - else { - return; - }; - pane.status = status; - - match target { - UxActivityTarget::Pane { .. } => { - pane.body = UiBody::Activity(activity); - } - UxActivityTarget::StackSection { section_id, .. } => { - if let UiBody::Stack(stack) = &mut pane.body { - if let Some(section) = stack - .sections - .iter_mut() - .find(|section| section.id == section_id) - { - section.state = UiStepState::Active; - section.body = UiBody::Activity(activity); - section.actions.clear(); - return; - } - } - pane.body = UiBody::Activity(activity); - } - } - } -} - -async fn execute_action(mut model: Signal, action: UiAction) { - let Some(mut ux) = ({ - let mut state = model.write(); - if state.running { - return; - } - state.running = true; - state.ux.take() - }) else { - model.write().push_console_log(UxLogEntry::new( - UxLogLevel::Error, - "studio", - "Studio UX is already busy.", - )); - return; - }; - - let accepting_updates = Rc::new(Cell::new(true)); - let mut update_model = model; - let update_gate = Rc::clone(&accepting_updates); - let updates = UxUpdateSink::new(move |update| { - if update_gate.get() { - update_model.write().apply_update(update); - } - }); - let result = ux.dispatch_with_updates(action, updates).await; - accepting_updates.set(false); - let mut state = model.write(); - state.ux = Some(ux); - state.refresh_from_ux(); - match result { - Ok(outcome) => { - for notice in outcome.notices { - state.push_console_log(log_from_notice(notice)); - } - } - Err(error) => { - state.push_console_log(log_from_error(error)); - } - } - state.running = false; -} - -fn log_from_notice(notice: UxNotice) -> UxLogEntry { - UxLogEntry::new( - log_level_from_notice(notice.level), - "studio", - notice.message, - ) -} - -fn log_level_from_notice(level: UxNoticeLevel) -> UxLogLevel { - match level { - UxNoticeLevel::Info => UxLogLevel::Info, - UxNoticeLevel::Warning => UxLogLevel::Warn, - UxNoticeLevel::Error => UxLogLevel::Error, - } -} - -fn log_from_error(error: UxError) -> UxLogEntry { - let level = if matches!(&error, UxError::Cancelled(_)) { - UxLogLevel::Info - } else { - UxLogLevel::Error - }; - UxLogEntry::new(level, "studio", error.to_string()) -} diff --git a/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs b/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs new file mode 100644 index 000000000..f46da233e --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/device/device_pane_stories.rs @@ -0,0 +1,86 @@ +//! Stories for the Studio device pane. + +use dioxus::prelude::*; +use lpa_studio_core::UiLogLevel; +use lpa_studio_web_story_macros::story; + +use crate::app::story_fixtures::{ + browser_serial_blank_firmware_view, browser_serial_canceled_view, + browser_serial_open_failed_view, idle_device_view, lightplayer_disconnected_view, + provision_failed_view, provision_ready_view, provisioning_view, reset_complete_view, + resetting_to_blank_view, shell_story, studio_log, +}; +use crate::core::PaneView; + +#[story] +pub(crate) fn device_pane() -> Element { + let view = idle_device_view(); + rsx! { + PaneView { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn browser_serial_canceled() -> Element { + shell_story(browser_serial_canceled_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn browser_serial_open_failed() -> Element { + shell_story(browser_serial_open_failed_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn server_disconnected_link_ready() -> Element { + shell_story( + lightplayer_disconnected_view(), + false, + vec![studio_log(UiLogLevel::Info, "LightPlayer disconnected")], + ) +} + +#[story] +pub(crate) fn provision_ready() -> Element { + shell_story(provision_ready_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn browser_serial_blank_firmware() -> Element { + shell_story(browser_serial_blank_firmware_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn provisioning() -> Element { + shell_story(provisioning_view(), true, Vec::new()) +} + +#[story] +pub(crate) fn provision_failed() -> Element { + shell_story( + provision_failed_view(), + false, + vec![studio_log( + UiLogLevel::Error, + "browser serial firmware flashing failed", + )], + ) +} + +#[story] +pub(crate) fn resetting_to_blank() -> Element { + shell_story(resetting_to_blank_view(), true, Vec::new()) +} + +#[story] +pub(crate) fn reset_complete() -> Element { + shell_story( + reset_complete_view(), + false, + vec![studio_log(UiLogLevel::Info, "ESP32-C6 wiped")], + ) +} diff --git a/lp-app/lpa-studio-web/src/app/device/mod.rs b/lp-app/lpa-studio-web/src/app/device/mod.rs new file mode 100644 index 000000000..a4a5f2d21 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/device/mod.rs @@ -0,0 +1,5 @@ +#[cfg(feature = "stories")] +pub(crate) mod device_pane_stories; +pub mod runtime_log; + +pub use runtime_log::RuntimeLog; diff --git a/lp-app/lpa-studio-web/src/app/device/runtime_log.rs b/lp-app/lpa-studio-web/src/app/device/runtime_log.rs new file mode 100644 index 000000000..6d90f3538 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/device/runtime_log.rs @@ -0,0 +1,23 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiLogEntry; + +use crate::core::LogList; + +const LOG_ENTRY_LIMIT: usize = 80; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn RuntimeLog(logs: Vec) -> Element { + rsx! { + section { class: "tw:rounded-md tw:border tw:border-border tw:bg-card", + div { class: "tw:p-[18px] tw:pb-3", + p { class: "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:text-heading", "Console" } + } + LogList { + logs, + max_entries: LOG_ENTRY_LIMIT, + framed: false, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/layout/mod.rs b/lp-app/lpa-studio-web/src/app/layout/mod.rs new file mode 100644 index 000000000..7dcf288e4 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/layout/mod.rs @@ -0,0 +1,7 @@ +pub mod pane_frame; +pub mod studio_shell; +#[cfg(feature = "stories")] +pub(crate) mod studio_shell_stories; + +pub use pane_frame::PaneFrame; +pub use studio_shell::StudioShell; diff --git a/lp-app/lpa-studio-web/src/app/layout/pane_frame.rs b/lp-app/lpa-studio-web/src/app/layout/pane_frame.rs new file mode 100644 index 000000000..810debc5b --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/layout/pane_frame.rs @@ -0,0 +1,31 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiStatus; + +use crate::core::StatusChip; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn PaneFrame( + title: String, + primary: bool, + status: Option, + children: Element, +) -> Element { + let panel_class = if primary { + "tw:rounded-md tw:border tw:border-panel-primary-border tw:bg-panel-primary tw:p-[18px]" + } else { + "tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-[18px]" + }; + + rsx! { + section { class: "{panel_class}", + div { class: "tw:mb-3 tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-3", + p { class: "tw:m-0 tw:text-xs tw:font-extrabold tw:uppercase tw:leading-none tw:text-heading", "{title}" } + if let Some(status) = status { + StatusChip { status } + } + } + {children} + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs b/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs new file mode 100644 index 000000000..25fd59ebd --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/layout/studio_shell.rs @@ -0,0 +1,104 @@ +use dioxus::prelude::*; +use lpa_studio_core::{DeviceController, UiAction, UiPaneView, UiStudioView, UiViewContent}; + +use crate::app::{ProjectNodeWorkspace, RuntimeLog}; +use crate::core::PaneView; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StudioShell( + view: UiStudioView, + running: bool, + on_action: EventHandler, +) -> Element { + let UiStudioView { panes, logs } = view; + let PaneGroups { main, device } = group_panes(panes); + let project_editor = project_editor_view(&main); + let layout_class = if project_editor.is_some() { + "tw:grid tw:grid-cols-[minmax(220px,280px)_minmax(0,1fr)_minmax(300px,360px)] tw:gap-3.5 tw:max-[960px]:grid-cols-1" + } else if main.is_empty() { + "tw:grid tw:grid-cols-1 tw:gap-3.5" + } else { + "tw:grid tw:grid-cols-[minmax(0,1fr)_minmax(300px,380px)] tw:gap-3.5 tw:max-[880px]:grid-cols-1" + }; + let device_is_primary = main.is_empty(); + + rsx! { + main { class: "tw:mx-auto tw:min-h-screen tw:w-[min(1520px,100%)] tw:px-7 tw:pb-16 tw:pt-7 tw:max-[880px]:px-[18px] tw:max-[880px]:pb-[72px] tw:max-[880px]:pt-[18px]", + header { class: "tw:mb-[18px] tw:flex tw:items-center tw:justify-start tw:gap-5", + div { + p { class: "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:text-heading", "LightPlayer Studio" } + } + } + + section { class: "{layout_class}", + if let Some(project_editor) = project_editor { + div { class: "tw:order-1 tw:grid tw:min-w-0 tw:content-start tw:gap-3.5 tw:max-[960px]:order-2", + for (index, pane) in main.into_iter().enumerate() { + PaneView { + key: "{pane.node_id}", + view: pane, + primary: index == 0, + running, + on_action, + } + } + } + div { class: "tw:order-2 tw:grid tw:min-w-0 tw:content-start tw:gap-3.5 tw:max-[960px]:order-1", + ProjectNodeWorkspace { view: project_editor, on_action } + } + } else if !main.is_empty() { + div { class: "tw:grid tw:min-w-0 tw:content-start tw:gap-3.5", + for (index, pane) in main.into_iter().enumerate() { + PaneView { + key: "{pane.node_id}", + view: pane, + primary: index == 0, + running, + on_action, + } + } + } + } + + div { class: "tw:order-3 tw:grid tw:min-w-0 tw:content-start tw:gap-3.5", + if let Some(device) = device { + PaneView { + key: "{device.node_id}", + view: device, + primary: device_is_primary, + running, + on_action, + } + } + RuntimeLog { logs } + } + } + } + } +} + +struct PaneGroups { + main: Vec, + device: Option, +} + +fn group_panes(panes: Vec) -> PaneGroups { + let mut main = Vec::new(); + let mut device = None; + for pane in panes { + if pane.node_id.as_str() == DeviceController::NODE_ID { + device = Some(pane); + } else { + main.push(pane); + } + } + PaneGroups { main, device } +} + +fn project_editor_view(panes: &[UiPaneView]) -> Option { + panes.iter().find_map(|pane| match &pane.body { + UiViewContent::ProjectEditor(editor) => Some((**editor).clone()), + _ => None, + }) +} diff --git a/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs b/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs new file mode 100644 index 000000000..0a8d0c073 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/layout/studio_shell_stories.rs @@ -0,0 +1,54 @@ +//! Stories for whole-Studio shell states. + +use dioxus::prelude::*; +use lpa_studio_core::UiLogLevel; +use lpa_studio_web_story_macros::story; + +use crate::app::story_fixtures::{ + editor_shell_story, endpoint_view, error_view, idle_view, shell_story, simulator_ready_view, + starting_view, studio_log, +}; + +#[story] +pub(crate) fn editor_shell() -> Element { + editor_shell_story() +} + +#[story] +pub(crate) fn simulator_idle() -> Element { + shell_story(idle_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn simulator_endpoint() -> Element { + shell_story(endpoint_view(), false, Vec::new()) +} + +#[story] +pub(crate) fn simulator_starting() -> Element { + shell_story(starting_view(), true, Vec::new()) +} + +#[story] +pub(crate) fn simulator_ready() -> Element { + shell_story( + simulator_ready_view(), + false, + vec![ + studio_log(UiLogLevel::Info, "Simulator is running"), + studio_log(UiLogLevel::Info, "Demo project loaded"), + ], + ) +} + +#[story] +pub(crate) fn action_error() -> Element { + shell_story( + error_view(), + false, + vec![studio_log( + UiLogLevel::Error, + "browser worker boot timed out", + )], + ) +} diff --git a/lp-app/lpa-studio-web/src/app/mod.rs b/lp-app/lpa-studio-web/src/app/mod.rs new file mode 100644 index 000000000..362b5ea7d --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/mod.rs @@ -0,0 +1,17 @@ +//! Studio-specific UI surfaces. +//! +//! These components know about LightPlayer Studio concepts such as devices, +//! projects, nodes, and the overall Studio shell. They compose `core` +//! controls and `base` primitives into app-specific workflows. + +pub mod device; +pub mod layout; +pub mod node; +pub mod project; +#[cfg(feature = "stories")] +pub(crate) mod story_fixtures; + +pub use device::RuntimeLog; +pub use layout::{PaneFrame, StudioShell}; +pub use node::NodePane; +pub use project::{ProjectNodeWorkspace, ProjectSidebar, ProjectWorkspace}; diff --git a/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs new file mode 100644 index 000000000..c18e7a1f9 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/config_slot_row.rs @@ -0,0 +1,183 @@ +//! Table row presentation for a single config slot. + +use dioxus::prelude::*; +use lpa_studio_core::{UiConfigSlot, UiConfigSlotBody, UiSlotFieldState, UiSlotOptionality}; + +use crate::app::node::{ + SlotDetailButton, SlotRecordEditor, SlotValueEditor, primary_affordance, slot_row_class, +}; +use crate::base::{StudioIcon, StudioIconName}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ConfigSlotRow( + slot: UiConfigSlot, + depth: usize, + index: usize, + #[props(default = false)] initially_open: bool, + #[props(default = None)] initially_expanded: Option, +) -> Element { + let child_record = match &slot.body { + UiConfigSlotBody::Record(record) if !record.fields.is_empty() => Some(record.clone()), + _ => None, + }; + let child_asset = match &slot.body { + UiConfigSlotBody::Asset(asset) => Some(asset.clone()), + _ => None, + }; + let has_children = child_record.is_some() || child_asset.is_some(); + let mut expanded = use_signal(|| initially_expanded.unwrap_or(depth > 0 || !has_children)); + let aspects = slot.visible_aspects(); + let primary = primary_affordance(&aspects); + let row_class = slot_row_class(primary, index); + let indent = depth * 14; + + rsx! { + div { class: "tw:grid tw:min-w-0", + div { class: row_class, + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-1.5", style: "padding-left: {indent}px;", + if has_children { + button { + class: "tw:inline-flex tw:h-6 tw:w-6 tw:flex-none tw:appearance-none tw:items-center tw:justify-center tw:rounded-xs tw:border-0 tw:bg-transparent tw:p-0 tw:text-muted-foreground tw:transition-colors tw:hover:text-strong-foreground tw:focus-visible:outline tw:focus-visible:outline-1 tw:focus-visible:outline-border-strong", + style: "appearance: none; -webkit-appearance: none; border: 0; background: transparent; cursor: pointer;", + r#type: "button", + aria_label: if expanded() { "Collapse slot" } else { "Expand slot" }, + title: if expanded() { "Collapse slot" } else { "Expand slot" }, + onclick: move |_| expanded.set(!expanded()), + span { class: expand_chevron_class(expanded()), + style: "stroke-width: 3;", + StudioIcon { + name: StudioIconName::Collapsed, + size: 16, + } + } + } + } else { + span { class: "tw:h-6 tw:w-6 tw:flex-none" } + } + div { class: "tw:min-w-0", + strong { class: "tw:block tw:min-w-0 tw:text-sm tw:leading-tight tw:text-strong-foreground tw:break-words", "{slot.label}" } + if let Some(detail) = slot.detail.as_ref() { + small { class: "tw:block tw:text-xs tw:text-subtle-foreground tw:break-words", "{detail}" } + } + } + } + div { class: "tw:flex tw:min-w-0 tw:items-center tw:justify-end tw:gap-2 tw:text-sm tw:leading-tight tw:text-muted-foreground", + if let Some(optionality) = slot.optionality { + OptionalSlotToggle { optionality } + } + SlotBodyPreview { body: slot.body.clone(), state: slot.state.clone(), expanded: expanded() } + } + SlotDetailButton { + label: slot.label.clone(), + aspects, + initially_open, + } + } + if expanded() { + if let Some(record) = child_record { + SlotRecordEditor { + record, + depth: depth + 1, + separated: true, + } + } + if let Some(asset) = child_asset { + AssetSlotEditor { asset } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn OptionalSlotToggle(optionality: UiSlotOptionality) -> Element { + let title = if optionality.included { + "Optional value enabled" + } else { + "Optional value disabled" + }; + rsx! { + label { class: "ux-slot-optional-toggle", title, + input { + class: "ux-slot-optional-toggle-input", + r#type: "checkbox", + checked: optionality.included, + disabled: !optionality.can_toggle, + aria_label: title, + } + span { class: "ux-slot-optional-toggle-track", + span { class: "ux-slot-optional-toggle-thumb" } + } + span { class: "ux-slot-optional-toggle-label", "enabled" } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn SlotBodyPreview(body: UiConfigSlotBody, state: UiSlotFieldState, expanded: bool) -> Element { + match body { + UiConfigSlotBody::Empty => rsx! { + span { class: "tw:text-subtle-foreground", "unset" } + }, + UiConfigSlotBody::Value(value) => rsx! { + SlotValueEditor { value, state } + }, + UiConfigSlotBody::Record(record) => { + let label = if record.fields.len() == 1 { + "1 field".to_string() + } else { + format!("{} fields", record.fields.len()) + }; + rsx! { + span { class: record_summary_class(expanded), "{label}" } + } + } + UiConfigSlotBody::Asset(asset) => rsx! { + code { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-xs tw:text-muted-foreground", "{asset.source}" } + }, + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn AssetSlotEditor(asset: lpa_studio_core::UiSlotAsset) -> Element { + rsx! { + div { class: "tw:border-t tw:border-border-muted tw:bg-page tw:px-2 tw:py-2", + div { class: "tw:mb-1.5 tw:flex tw:min-w-0 tw:items-center tw:justify-between tw:gap-2", + code { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-xs tw:text-subtle-foreground", "{asset.source}" } + span { class: "tw:flex-none tw:text-xs tw:font-bold tw:text-subtle-foreground", "{asset.editor_label()}" } + } + if let Some(detail) = asset.detail.as_ref() { + p { class: "tw:m-0 tw:mb-1.5 tw:text-xs tw:text-subtle-foreground tw:break-words", "{detail}" } + } + if let Some(content) = asset.content { + pre { class: "tw:m-0 tw:max-h-48 tw:overflow-auto tw:rounded-xs tw:border tw:border-border-subtle tw:bg-terminal tw:p-3 tw:font-mono tw:text-xs tw:leading-normal tw:text-muted-foreground", + code { "{content}" } + } + } else { + pre { class: "tw:m-0 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-terminal tw:p-3 tw:font-mono tw:text-xs tw:leading-normal tw:text-subtle-foreground", + code { "// asset content not loaded" } + } + } + } + } +} + +fn record_summary_class(expanded: bool) -> &'static str { + if expanded { + "tw:text-xs tw:font-bold tw:uppercase tw:text-subtle-foreground" + } else { + "tw:text-xs tw:font-bold tw:uppercase tw:text-muted-foreground" + } +} + +fn expand_chevron_class(expanded: bool) -> &'static str { + if expanded { + "tw:inline-flex tw:rotate-90 tw:transition-transform" + } else { + "tw:inline-flex tw:transition-transform" + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs new file mode 100644 index 000000000..1be462295 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/config_slot_row_stories.rs @@ -0,0 +1,190 @@ +//! Stories for config slot row states. + +use dioxus::prelude::*; +use lpa_studio_core::{ + UiBindingEndpoint, UiConfigSlot, UiNodeDirtyState, UiSlotFieldState, UiSlotOptionality, + UiSlotSourceState, UiSlotUnit, UiSlotValue, +}; +use lpa_studio_web_story_macros::story; + +use crate::app::node::ConfigSlotRow; +use crate::app::node::node_story_fixtures::config_row_states_fixture; + +#[story( + label = "All States", + description = "Representative config rows for direct, bound, edited, invalid, and record slots." +)] +pub(crate) fn all_states() -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:overflow-hidden tw:divide-y tw:divide-border-muted", + for (index, slot) in config_row_states_fixture().into_iter().enumerate() { + ConfigSlotRow { slot, depth: 0, index } + } + } + } +} + +#[story(description = "A directly authored value row.")] +pub(crate) fn direct_value() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value("brightness", "Brightness", UiSlotValue::f32(0.72)), + depth: 0, + index: 0, + } + } +} + +#[story(description = "A row whose visible value comes from a binding endpoint.")] +pub(crate) fn bound_value() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value( + "time", + "Time", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#time.seconds", + ))), + depth: 0, + index: 0, + } + } +} + +#[story(description = "An open slot info popup showing the compact aspect rows.")] +pub(crate) fn info_popup() -> Element { + rsx! { + div { class: "tw:min-h-56", + ConfigSlotRow { + slot: UiConfigSlot::value( + "fade_after", + "Fade after", + UiSlotValue::f32(0.35).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#time.seconds", + ))), + depth: 0, + index: 0, + initially_open: true, + } + } + } +} + +#[story(description = "A row with a local edited-state affordance.")] +pub(crate) fn edited_value() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value("shader", "Shader", UiSlotValue::string("idle.glsl")) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty)), + depth: 0, + index: 0, + } + } +} + +#[story(description = "A row with a validation issue.")] +pub(crate) fn invalid_value() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value("fade_after", "Fade after", UiSlotValue::f32(-1.0)) + .with_state(UiSlotFieldState::editable().with_invalid("value must be non-negative")), + depth: 0, + index: 0, + } + } +} + +#[story(description = "A row preserving an edited value after a failed write.")] +pub(crate) fn write_failed() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value("shader", "Shader", UiSlotValue::string("blast.glsl")) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Error)), + depth: 0, + index: 0, + } + } +} + +#[story(description = "A row with no direct value or binding.")] +pub(crate) fn unset_value() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::empty("optional_trigger", "Optional trigger") + .with_optionality(UiSlotOptionality::excluded(true)) + .with_source(UiSlotSourceState::Unset), + depth: 0, + index: 0, + } + } +} + +#[story( + description = "An included optional scalar renders as a normal value with an enable toggle." +)] +pub(crate) fn optional_scalar_included() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::value("brightness", "Brightness", UiSlotValue::u32(255)) + .with_optionality(UiSlotOptionality::included(true)), + depth: 0, + index: 0, + } + } +} + +#[story( + description = "An excluded optional scalar renders as an unset value with the enable toggle off." +)] +pub(crate) fn optional_scalar_excluded() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::empty("brightness", "Brightness") + .with_optionality(UiSlotOptionality::excluded(true)) + .with_source(UiSlotSourceState::Unset), + depth: 0, + index: 0, + } + } +} + +#[story(description = "An included optional record expands into its real child fields.")] +pub(crate) fn optional_record_included() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::record( + "output_options", + "Output options", + vec![ + UiConfigSlot::value("dither", "Dither", UiSlotValue::bool(true)), + UiConfigSlot::value("interpolate", "Interpolate", UiSlotValue::bool(false)), + ], + ) + .with_optionality(UiSlotOptionality::included(true)), + depth: 0, + index: 0, + } + } +} + +#[story(description = "A collapsed record row.")] +pub(crate) fn record_row() -> Element { + rsx! { + ConfigSlotRow { + slot: UiConfigSlot::record( + "transform", + "Transform", + vec![ + UiConfigSlot::value("origin", "Origin", UiSlotValue::vec2([0.42, 0.58])), + UiConfigSlot::value("scale", "Scale", UiSlotValue::vec2([1.0, 1.0])), + ], + ), + depth: 0, + index: 0, + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/mod.rs b/lp-app/lpa-studio-web/src/app/node/mod.rs new file mode 100644 index 000000000..e2d5c7e9a --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/mod.rs @@ -0,0 +1,56 @@ +//! Studio node UI components and colocated node UI stories. + +mod config_slot_row; +#[cfg(feature = "stories")] +pub(crate) mod config_slot_row_stories; +mod node_children; +mod node_header; +mod node_pane; +#[cfg(feature = "stories")] +pub(crate) mod node_stories; +#[cfg(feature = "stories")] +pub(crate) mod node_story_fixtures; +#[cfg(feature = "stories")] +pub(crate) mod produced_product_stories; +mod produced_product_view; +mod produced_products; +#[cfg(feature = "stories")] +pub(crate) mod produced_value_stories; +mod produced_value_view; +mod produced_values; +mod slot_detail_button; +mod slot_fields; +mod slot_issue_list; +mod slot_record_editor; +#[cfg(feature = "stories")] +pub(crate) mod slot_record_editor_stories; +mod slot_shape_display; +#[cfg(feature = "stories")] +pub(crate) mod slot_shape_display_stories; +mod slot_unit_display; +#[cfg(feature = "stories")] +pub(crate) mod slot_unit_display_stories; +mod slot_value_editor; +#[cfg(feature = "stories")] +pub(crate) mod slot_value_editor_stories; + +pub use config_slot_row::ConfigSlotRow; +pub use node_children::NodeChildren; +pub use node_header::NodeHeader; +pub use node_pane::{NodePane, NodeSection}; +pub use produced_product_view::ProducedProductView; +pub use produced_products::ProducedProducts; +pub use produced_value_view::ProducedValueView; +pub use produced_values::ProducedValues; +pub(crate) use slot_detail_button::{SlotDetailButton, primary_affordance, slot_row_class}; +pub use slot_fields::{ + BoolSlotField, DropdownSlotField, FloatSlotField, IntSlotField, StringSlotField, UIntSlotField, + Vec2SlotField, Vec3SlotField, XySlotField, +}; +pub use slot_issue_list::SlotIssueList; +pub use slot_record_editor::SlotRecordEditor; +pub(crate) use slot_shape_display::{ + SlotShapeDisplay, SlotShapeDisplayMode, legacy_shape_from_parts, +}; +pub(crate) use slot_unit_display::{SlotUnitDisplay, SlotUnitDisplayMode, SlotUnitSuffix}; +pub use slot_value_editor::SlotValueEditor; diff --git a/lp-app/lpa-studio-web/src/app/node/node_children.rs b/lp-app/lpa-studio-web/src/app/node/node_children.rs new file mode 100644 index 000000000..613addd98 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/node_children.rs @@ -0,0 +1,43 @@ +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiNodeChild, UiNodeHeader, UiNodeTab, UiNodeView}; + +use crate::app::node::NodePane; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn NodeChildren( + items: Vec, + #[props(default)] on_action: Option>, +) -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-3 tw:border-l tw:border-border-muted tw:pl-4", + for child in items { + NodePane { + key: "{child.label}", + view: child_node_view(child), + on_action, + } + } + } + } +} + +fn child_node_view(child: UiNodeChild) -> UiNodeView { + let header = UiNodeHeader::new( + child.label.clone(), + child.kind.clone(), + child.detail.clone(), + ) + .with_status(child.status.clone()); + let header = if let Some(summary) = child.summary { + header.with_summary(summary) + } else { + header + }; + let mut view = UiNodeView::new(header, vec![UiNodeTab::main(child.sections)]) + .with_node_id(format!("child:{}", child.label)) + .with_children(child.children); + view.focused = child.focused || child.active; + view.action = child.action; + view +} diff --git a/lp-app/lpa-studio-web/src/app/node/node_header.rs b/lp-app/lpa-studio-web/src/app/node/node_header.rs new file mode 100644 index 000000000..c5c44a947 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/node_header.rs @@ -0,0 +1,179 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiNodeHeader; +use lpa_studio_core::core::status::UiStatusKind; + +use crate::base::{IconPopoverButton, PopoverPlacement, StudioIconName}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn NodeHeader(header: UiNodeHeader) -> Element { + rsx! { + div { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2 tw:px-3", + NodeStatusMenu { header: header.clone() } + h3 { class: "tw:m-0 tw:min-w-0 tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:text-[1.04rem] tw:font-bold tw:leading-tight tw:text-strong-foreground", + "{header.title}" + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeStatusMenu(header: UiNodeHeader) -> Element { + let icon = status_icon(header.status.kind); + let label = format!("{} status details", header.title); + + rsx! { + IconPopoverButton { + class: node_status_button_class(header.status.kind).to_string(), + open_class: node_status_button_open_class(header.status.kind).to_string(), + icon, + icon_size: 13, + label, + title: format!("{} status details", header.title), + popup_class: node_status_popup_class(header.status.kind).to_string(), + chrome_class: node_status_chrome_class(header.status.kind).to_string(), + placement: PopoverPlacement::BottomStart, + div { class: "tw:grid tw:min-w-0 tw:gap-3 tw:p-3", + div { class: "tw:flex tw:min-w-0 tw:items-start tw:justify-between tw:gap-4", + div { class: "tw:grid tw:min-w-0 tw:gap-0.5", + strong { class: "tw:min-w-0 tw:text-sm tw:text-strong-foreground tw:break-words", "{header.title}" } + span { class: "tw:text-xs tw:font-bold tw:text-subtle-foreground", "{header.kind}" } + } + span { class: node_status_label_class(header.status.kind), "{header.status.label}" } + } + dl { class: "tw:m-0 tw:grid tw:min-w-0 tw:gap-2 tw:text-xs", + if let Some(summary) = header.summary.as_ref() { + NodeStatusDetailRow { + label: "summary", + value: summary.clone(), + } + } + if let Some(source) = header.source.as_ref() { + NodeStatusDetailRow { + label: "source", + value: source.clone(), + } + } + NodeStatusDetailRow { + label: "path", + value: header.path.clone(), + } + } + if let Some(detail) = header.detail.as_ref() { + p { class: "tw:m-0 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:p-2 tw:text-xs tw:leading-normal tw:text-muted-foreground tw:break-words", "{detail}" } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeStatusDetailRow(label: &'static str, value: String) -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:grid-cols-[72px_minmax(0,1fr)] tw:gap-2", + dt { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-subtle-foreground", "{label}" } + dd { class: "tw:m-0 tw:min-w-0 tw:font-mono tw:text-muted-foreground tw:break-words", "{value}" } + } + } +} + +fn status_icon(kind: UiStatusKind) -> StudioIconName { + match kind { + UiStatusKind::Neutral => StudioIconName::StatusIdle, + UiStatusKind::Working | UiStatusKind::Good => StudioIconName::StatusRunning, + UiStatusKind::Warning => StudioIconName::StepAttention, + UiStatusKind::Error => StudioIconName::StatusError, + } +} + +fn node_status_button_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-neutral-border tw:bg-status-neutral-bg tw:p-0 tw:text-status-neutral-foreground" + } + UiStatusKind::Working => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-working-border tw:bg-status-working-bg tw:p-0 tw:text-status-working-foreground" + } + UiStatusKind::Good => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-good-border tw:bg-status-good-bg tw:p-0 tw:text-status-good-foreground" + } + UiStatusKind::Warning => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground" + } + UiStatusKind::Error => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-error-border tw:bg-status-error-bg tw:p-0 tw:text-status-error-foreground" + } + } +} + +fn node_status_button_open_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-neutral-border tw:bg-card-raised tw:p-0 tw:text-status-neutral-foreground" + } + UiStatusKind::Working => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-working-border tw:bg-card-raised tw:p-0 tw:text-status-working-foreground" + } + UiStatusKind::Good => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-good-border tw:bg-card-raised tw:p-0 tw:text-status-good-foreground" + } + UiStatusKind::Warning => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-warning-border tw:bg-card-raised tw:p-0 tw:text-status-warning-foreground" + } + UiStatusKind::Error => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-status-error-border tw:bg-card-raised tw:p-0 tw:text-status-error-foreground" + } + } +} + +fn node_status_popup_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:overflow-hidden tw:rounded-md tw:border tw:border-status-neutral-border tw:bg-card tw:bg-[linear-gradient(90deg,var(--studio-status-neutral-bg),transparent_74%)] tw:text-sm tw:text-muted-foreground tw:shadow-lg" + } + UiStatusKind::Working => { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:overflow-hidden tw:rounded-md tw:border tw:border-status-working-border tw:bg-card tw:bg-[linear-gradient(90deg,var(--studio-status-working-bg),transparent_74%)] tw:text-sm tw:text-muted-foreground tw:shadow-lg" + } + UiStatusKind::Good => { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:overflow-hidden tw:rounded-md tw:border tw:border-status-good-border tw:bg-card tw:bg-[linear-gradient(90deg,var(--studio-status-good-bg),transparent_74%)] tw:text-sm tw:text-muted-foreground tw:shadow-lg" + } + UiStatusKind::Warning => { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:overflow-hidden tw:rounded-md tw:border tw:border-status-warning-border tw:bg-card tw:bg-[linear-gradient(90deg,var(--studio-status-warning-bg),transparent_74%)] tw:text-sm tw:text-muted-foreground tw:shadow-lg" + } + UiStatusKind::Error => { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:overflow-hidden tw:rounded-md tw:border tw:border-status-error-border tw:bg-card tw:bg-[linear-gradient(90deg,var(--studio-status-error-bg),transparent_74%)] tw:text-sm tw:text-muted-foreground tw:shadow-lg" + } + } +} + +fn node_status_chrome_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => "ux-popover-chrome-neutral", + UiStatusKind::Working => "ux-popover-chrome-working", + UiStatusKind::Good => "ux-popover-chrome-good", + UiStatusKind::Warning => "ux-popover-chrome-warning", + UiStatusKind::Error => "ux-popover-chrome-error", + } +} + +fn node_status_label_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => { + "tw:shrink-0 tw:rounded-pill tw:border tw:border-status-neutral-border tw:bg-status-neutral-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:leading-none tw:text-status-neutral-foreground" + } + UiStatusKind::Working => { + "tw:shrink-0 tw:rounded-pill tw:border tw:border-status-working-border tw:bg-status-working-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:leading-none tw:text-status-working-foreground" + } + UiStatusKind::Good => { + "tw:shrink-0 tw:rounded-pill tw:border tw:border-status-good-border tw:bg-status-good-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:leading-none tw:text-status-good-foreground" + } + UiStatusKind::Warning => { + "tw:shrink-0 tw:rounded-pill tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:leading-none tw:text-status-warning-foreground" + } + UiStatusKind::Error => { + "tw:shrink-0 tw:rounded-pill tw:border tw:border-status-error-border tw:bg-status-error-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:leading-none tw:text-status-error-foreground" + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/node_pane.rs b/lp-app/lpa-studio-web/src/app/node/node_pane.rs new file mode 100644 index 000000000..3d3467071 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/node_pane.rs @@ -0,0 +1,216 @@ +use dioxus::prelude::*; +use lpa_studio_core::core::status::UiStatusKind; +use lpa_studio_core::{UiAction, UiNodeSection, UiNodeTabBody, UiNodeView, UiSlotRecord}; + +use crate::app::node::{ + NodeChildren, NodeHeader, ProducedProducts, ProducedValues, SlotRecordEditor, +}; +use crate::base::{StudioIcon, StudioIconName}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn NodePane( + view: UiNodeView, + #[props(default)] on_action: Option>, +) -> Element { + let mut active_tab = use_signal(|| 0_usize); + let mut collapsed = use_signal(|| view.collapsed); + let focus_action = view.action.clone(); + let focus_handler = on_action; + let focused_class = if view.focused { + "tw:border-accent-border" + } else { + "tw:border-border" + }; + let article_class = format!( + "tw:grid tw:min-w-0 tw:overflow-hidden tw:rounded-md tw:border {focused_class} tw:bg-card tw:p-4" + ); + let active_index = active_tab().min(view.tabs.len().saturating_sub(1)); + let active_body = view.tabs.get(active_index).map(|tab| tab.body.clone()); + let header_class = node_header_class(view.header.status.kind, collapsed()); + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-3", + article { + class: "{article_class}", + onclick: move |_| { + if let (Some(action), Some(handler)) = (focus_action.clone(), focus_handler) { + handler.call(action); + } + }, + header { class: "{header_class}", + button { + class: "tw:inline-flex tw:h-full tw:min-h-[46px] tw:w-[34px] tw:items-center tw:justify-center tw:border-0 tw:border-r tw:border-border-muted tw:bg-transparent tw:p-0 tw:text-subtle-foreground tw:hover:bg-card-subtle/60", + r#type: "button", + aria_label: if collapsed() { "Expand node" } else { "Collapse node" }, + title: if collapsed() { "Expand node" } else { "Collapse node" }, + onclick: move |event| { + event.stop_propagation(); + collapsed.set(!collapsed()); + }, + StudioIcon { + name: if collapsed() { StudioIconName::Collapsed } else { StudioIconName::Expanded }, + size: 14, + } + } + NodeHeader { header: view.header.clone() } + if view.tabs.len() > 1 { + NodeTabs { + tabs: view.tabs.clone(), + active_index, + on_select: move |index| active_tab.set(index), + } + } + } + if !collapsed() { + if !view.issues.is_empty() { + ul { class: "tw:m-0 tw:grid tw:list-none tw:gap-1 tw:rounded-sm tw:border tw:border-status-error-border tw:bg-status-error-bg tw:p-3", + for issue in view.issues.clone() { + li { class: "tw:text-sm tw:text-status-error-foreground", "{issue}" } + } + } + } + match active_body { + Some(UiNodeTabBody::Sections(sections)) => rsx! { + div { class: "tw:-mx-4 tw:-mb-4 tw:grid tw:min-w-0", + for (index, section) in sections.into_iter().enumerate() { + NodeSection { + section, + first: index == 0, + focus_action: view.action.clone(), + on_action, + } + } + } + }, + Some(UiNodeTabBody::Text { title, body }) => rsx! { + section { class: "tw:grid tw:min-w-0 tw:gap-2", + h4 { class: "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:text-heading", "{title}" } + pre { class: "tw:m-0 tw:max-h-80 tw:overflow-auto tw:rounded-sm tw:border tw:border-border-subtle tw:bg-page tw:p-3 tw:text-xs tw:leading-normal tw:text-muted-foreground", + code { "{body}" } + } + } + }, + None => rsx! { + p { class: "tw:m-0 tw:text-sm tw:text-subtle-foreground", "No node tabs are available." } + }, + } + } + } + if !collapsed() && !view.children.is_empty() { + NodeChildren { + items: view.children.clone(), + on_action, + } + } + } + } +} + +fn node_header_class(kind: UiStatusKind, collapsed: bool) -> String { + let shape_class = if collapsed { + "tw:-mb-4 tw:rounded-md" + } else { + "tw:rounded-t-md tw:border-b tw:border-border-muted" + }; + let status_class = match kind { + UiStatusKind::Neutral => { + "tw:bg-[linear-gradient(90deg,var(--studio-status-neutral-bg),transparent_62%)]" + } + UiStatusKind::Working => { + "tw:bg-[linear-gradient(90deg,var(--studio-status-working-bg),transparent_62%)]" + } + UiStatusKind::Good => { + "tw:bg-[linear-gradient(90deg,var(--studio-status-good-bg),transparent_62%)]" + } + UiStatusKind::Warning => { + "tw:bg-[linear-gradient(90deg,var(--studio-status-warning-bg),transparent_62%)]" + } + UiStatusKind::Error => { + "tw:bg-[linear-gradient(90deg,var(--studio-status-error-bg),transparent_66%)]" + } + }; + + format!( + "tw:-mx-4 tw:-mt-4 tw:grid tw:min-h-[46px] tw:min-w-0 tw:grid-cols-[34px_minmax(0,1fr)_auto] tw:items-stretch tw:overflow-hidden {shape_class} tw:bg-card-subtle {status_class}" + ) +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn NodeSection( + section: UiNodeSection, + #[props(default = false)] first: bool, + #[props(default)] focus_action: Option, + #[props(default)] on_action: Option>, +) -> Element { + match section { + UiNodeSection::ProducedProducts(products) => rsx! { + section { class: section_class("tw:bg-card tw:p-0", first), + ProducedProducts { products, focus_action, on_action } + } + }, + UiNodeSection::ProducedValues(values) => rsx! { + section { class: section_class("tw:bg-card-subtle tw:px-4 tw:py-4", first), + ProducedValues { values } + } + }, + UiNodeSection::ConfigSlots(slots) => rsx! { + section { class: section_class("tw:bg-card tw:p-0", first), + SlotRecordEditor { + record: UiSlotRecord::new(slots), + } + } + }, + UiNodeSection::AssetSlots(assets) => rsx! { + section { class: section_class("tw:bg-card tw:p-0", first), + SlotRecordEditor { + record: UiSlotRecord::new(assets), + } + } + }, + UiNodeSection::Children(children) => rsx! { + section { class: section_class("tw:bg-card tw:px-4 tw:py-4", first), + NodeChildren { items: children, on_action: None } + } + }, + } +} + +fn section_class(body_class: &'static str, first: bool) -> String { + if first { + format!("tw:min-w-0 {body_class}") + } else { + format!("tw:min-w-0 tw:border-t tw:border-border-muted {body_class}") + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeTabs( + tabs: Vec, + active_index: usize, + on_select: EventHandler, +) -> Element { + rsx! { + div { class: "tw:flex tw:h-full tw:items-stretch tw:overflow-hidden tw:border-l tw:border-border-muted tw:bg-card-muted", role: "tablist", + for (index, tab) in tabs.into_iter().enumerate() { + button { + class: if index == active_index { + "tw:min-h-full tw:border-0 tw:border-r tw:border-border-muted tw:bg-card-subtle tw:px-4 tw:text-xs tw:font-bold tw:text-strong-foreground" + } else { + "tw:min-h-full tw:border-0 tw:border-r tw:border-border-muted tw:bg-transparent tw:px-4 tw:text-xs tw:font-bold tw:text-muted-foreground tw:hover:bg-card-subtle tw:hover:text-strong-foreground" + }, + r#type: "button", + role: "tab", + aria_selected: "{index == active_index}", + onclick: move |event| { + event.stop_propagation(); + on_select.call(index); + }, + "{tab.label}" + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/node_stories.rs b/lp-app/lpa-studio-web/src/app/node/node_stories.rs new file mode 100644 index 000000000..6d03044fe --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/node_stories.rs @@ -0,0 +1,30 @@ +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::NodePane; +use crate::app::node::node_story_fixtures::{error_node_view, playlist_node_view}; + +#[story(description = "A composed node pane showing the current node anatomy direction.")] +pub(crate) fn node_pane() -> Element { + rsx! { + NodePane { view: playlist_node_view() } + } +} + +#[story(description = "A selected node pane collapsed down to its header.")] +pub(crate) fn collapsed_node_pane() -> Element { + let mut view = playlist_node_view(); + view.focused = true; + view.collapsed = true; + + rsx! { + NodePane { view } + } +} + +#[story(description = "Node pane with an error status and projection issues.")] +pub(crate) fn error_node() -> Element { + rsx! { + NodePane { view: error_node_view() } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs new file mode 100644 index 000000000..c0b372d14 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/node_story_fixtures.rs @@ -0,0 +1,518 @@ +//! Shared fixtures for Studio node component stories. + +use lpa_studio_core::{ + ColorOrder, ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, + ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, Revision, UiAssetEditorKind, + UiBindingEndpoint, UiConfigSlot, UiControlProductPreview, UiControlSampleFormat, UiNodeChild, + UiNodeDirtyState, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeTabBody, UiNodeView, + UiProducedBinding, UiProducedBindings, UiProducedProduct, UiProducedValue, UiProductPreview, + UiProductTrackingState, UiSlotAsset, UiSlotEditorHint, UiSlotFieldState, UiSlotOptionality, + UiSlotRecord, UiSlotSourceState, UiSlotUnit, UiSlotValue, UiStatus, +}; + +const IDLE_GLSL: &str = r#"vec3 palette(float t) { + return 0.5 + 0.5 * cos(6.28318 * (vec3(0.1, 0.3, 0.6) + t)); +} + +void mainImage(out vec4 color, in vec2 uv) { + float glow = smoothstep(0.9, 0.2, length(uv - 0.5)); + color = vec4(palette(glow), 1.0); +}"#; + +const BLAST_GLSL: &str = r#"void mainImage(out vec4 color, in vec2 uv) { + vec3 base = vec3(1.0, 0.18, 0.05); + float ring = sin(length(uv - 0.5) * 64.0); + color = vec4(base * ring, 1.0); +}"#; + +pub(crate) fn playlist_node_view() -> UiNodeView { + UiNodeView::new( + playlist_header(), + vec![ + UiNodeTab::main(vec![ + UiNodeSection::ProducedProducts(produced_products_fixture()), + UiNodeSection::ProducedValues(produced_values_fixture()), + UiNodeSection::ConfigSlots(config_slots_fixture()), + UiNodeSection::AssetSlots(asset_slots_fixture()), + ]), + UiNodeTab::new( + "raw", + UiNodeTabBody::Text { + title: "Slot extraction notes".to_string(), + body: "def.input.time -> config slot\nstate.output -> produced product\nentries.* -> extracted children".to_string(), + }, + ), + ], + ) + .with_node_id("playlist") + .with_children(children_fixture()) +} + +pub(crate) fn error_node_view() -> UiNodeView { + let mut view = UiNodeView::new( + UiNodeHeader::new("blast", "Shader", "/show/playlist/blast") + .with_source("blast.glsl") + .with_status(UiStatus::error("Error")) + .with_summary("compile failed") + .with_detail("unknown identifier `uv2` at line 18"), + vec![UiNodeTab::main(vec![ + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value("shader", "Shader", UiSlotValue::string("blast.glsl")) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Error)), + ]), + UiNodeSection::AssetSlots(vec![ + UiConfigSlot::asset( + "shader_source", + "Shader", + UiSlotAsset::new("./blast.glsl", UiAssetEditorKind::Glsl) + .with_content("vec3 color = sample(uv2);"), + ) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Error)), + ]), + ])], + ) + .with_node_id("shader-blast"); + view.issues = vec!["Shader compile failed".to_string()]; + view +} + +pub(crate) fn playlist_header() -> UiNodeHeader { + UiNodeHeader::new("Playlist", "Playlist", "/fyeah_sign.show/playlist.playlist") + .with_source("playlist.toml") + .with_status(UiStatus::good("Running")) + .with_summary("entry 1") + .with_detail("Node has run recently with no reported errors.") +} + +pub(crate) fn produced_products_fixture() -> Vec { + vec![ + visual_preview_product("output").with_binding_routes( + Some("bus#visual.out"), + &[], + &["Fixture.visual"], + Some("rev 104"), + ), + control_preview_product("dmx").with_binding_routes( + None, + &["fixture#strip-a"], + &[], + Some("rev 104"), + ), + ] +} + +pub(crate) fn produced_product_variants_fixture() -> Vec { + vec![ + UiProducedProduct::empty("output").with_detail("not resolved"), + UiProducedProduct::visual("output").with_detail("32 x 32 preview"), + UiProducedProduct::visual("output") + .with_detail("32 x 32 preview") + .with_preview(UiProductPreview::Pending) + .with_tracking(UiProductTrackingState::Tracking), + visual_preview_product("output").with_binding_routes( + Some("bus#visual.out"), + &[], + &["Fixture.visual"], + Some("rev 104"), + ), + visual_preview_product("output") + .with_tracking(UiProductTrackingState::Paused) + .with_detail("cached preview"), + visual_error_product("output"), + control_preview_product("dmx").with_binding_routes( + None, + &["fixture#strip-a"], + &[], + Some("rev 104"), + ), + ] +} + +pub(crate) fn visual_preview_product(name: &str) -> UiProducedProduct { + UiProducedProduct::visual(name) + .with_detail("32 x 32") + .with_tracking(UiProductTrackingState::Tracking) + .with_preview(UiProductPreview::VisualSrgb8 { + width: 32, + height: 32, + revision: 104, + bytes: visual_preview_bytes(32, 32), + }) +} + +pub(crate) fn visual_error_product(name: &str) -> UiProducedProduct { + UiProducedProduct::visual(name) + .with_detail("32 x 32") + .with_tracking(UiProductTrackingState::Tracking) + .with_preview(UiProductPreview::Error { + message: "render probe failed".to_string(), + }) +} + +pub(crate) fn control_preview_product(name: &str) -> UiProducedProduct { + UiProducedProduct::control(name) + .with_detail("16 RGB lamps") + .with_tracking(UiProductTrackingState::Tracking) + .with_preview(UiProductPreview::ControlNative(UiControlProductPreview { + revision: 104, + extent: ControlExtent::new(1, 48), + sample_format: UiControlSampleFormat::U16, + sample_layout: ControlSampleLayout { + spans: vec![ControlSampleSpan { + row: 0, + start: 0, + len: 48, + encoding: ControlSampleEncoding::RgbPixels { + count: 16, + color_order: ColorOrder::Rgb, + }, + }], + }, + display_layout: Some(control_layout_2d_fixture()), + bytes: control_preview_bytes(16), + })) +} + +pub(crate) fn control_unsupported_product(name: &str) -> UiProducedProduct { + UiProducedProduct::control(name) + .with_detail("raw samples") + .with_tracking(UiProductTrackingState::Tracking) + .with_preview(UiProductPreview::Unsupported { + reason: "Control product does not expose a 2D display layout.".to_string(), + }) +} + +fn visual_preview_bytes(width: u32, height: u32) -> Vec { + let mut bytes = Vec::with_capacity((width * height * 3) as usize); + for y in 0..height { + for x in 0..width { + let u = x as f32 / width.saturating_sub(1).max(1) as f32; + let v = y as f32 / height.saturating_sub(1).max(1) as f32; + bytes.push((u * 255.0) as u8); + bytes.push(((1.0 - v) * 180.0 + 40.0) as u8); + bytes.push(((u * v) * 220.0 + 24.0) as u8); + } + } + bytes +} + +fn control_layout_2d_fixture() -> ControlDisplayLayout { + let mut lamps = Vec::with_capacity(16); + for index in 0..16_u32 { + let angle = (index as f32 / 16.0) * core::f32::consts::TAU; + lamps.push(ControlLamp2d { + lamp_index: index, + sample_start: index * 3, + center: [0.5 + angle.cos() * 0.34, 0.5 + angle.sin() * 0.34], + radius: 0.045, + }); + } + ControlDisplayLayout::Layout2d(ControlLayout2d::new(Revision::new(104), 1, 1, lamps)) +} + +fn control_preview_bytes(lamps: u32) -> Vec { + let mut bytes = Vec::with_capacity((lamps * 3 * 2) as usize); + for index in 0..lamps { + let phase = index as f32 / lamps.max(1) as f32; + let r = ((phase * core::f32::consts::TAU).sin() * 0.5 + 0.5) * u16::MAX as f32; + let g = (((phase + 0.33) * core::f32::consts::TAU).sin() * 0.5 + 0.5) * u16::MAX as f32; + let b = (((phase + 0.66) * core::f32::consts::TAU).sin() * 0.5 + 0.5) * u16::MAX as f32; + for sample in [r as u16, g as u16, b as u16] { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + } + bytes +} + +pub(crate) fn produced_values_fixture() -> Vec { + vec![ + UiProducedValue::new("Entry time", "3.333") + .with_unit(UiSlotUnit::seconds()) + .with_binding_routes(None, &[], &["idle.Time", "blast.Time"], Some("rev 104")), + ] +} + +pub(crate) fn produced_value_variants_fixture() -> Vec { + vec![ + UiProducedValue::new("Entry time", "3.33").with_unit(UiSlotUnit::seconds()), + UiProducedValue::new("FPS", "447").with_unit(UiSlotUnit::hertz()), + UiProducedValue::new("Peers", "2").with_binding_routes( + Some("bus#radio.peer_count"), + &[], + &["debug.Peers"], + None, + ), + ] +} + +pub(crate) fn config_slots_fixture() -> Vec { + vec![ + UiConfigSlot::value( + "time", + "Time", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#time.seconds", + ))), + UiConfigSlot::value("idle_entry", "Idle entry", UiSlotValue::u32(1)), + UiConfigSlot::value( + "default_fade", + "Default fade", + UiSlotValue::f32(0.35).with_unit(UiSlotUnit::seconds()), + ) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty)), + UiConfigSlot::record( + "entries", + "Entries", + vec![ + UiConfigSlot::value("blast_trigger", "Blast trigger", UiSlotValue::bool(false)) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#trigger", + ))) + .with_detail("optional trigger"), + ], + ) + .with_detail("2 child invocations"), + ] +} + +pub(crate) fn asset_slots_fixture() -> Vec { + vec![ + UiConfigSlot::asset( + "idle_shader", + "Idle shader", + UiSlotAsset::new("./idle.glsl", UiAssetEditorKind::Glsl) + .with_detail("artifact, rev 22") + .with_content(IDLE_GLSL), + ) + .with_detail("artifact, rev 22"), + UiConfigSlot::asset( + "blast_shader", + "Blast shader", + UiSlotAsset::new("./blast.glsl", UiAssetEditorKind::Glsl) + .with_detail("artifact, rev 19") + .with_content(BLAST_GLSL), + ) + .with_detail("artifact, rev 19"), + ] +} + +pub(crate) fn children_fixture() -> Vec { + vec![ + UiNodeChild::new("idle", "Shader", "./idle.toml") + .active("active, fade_after 0.12 s") + .with_sections(vec![ + UiNodeSection::ProducedProducts(vec![ + UiProducedProduct::visual("output").with_detail("32 x 32 preview"), + ]), + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "time", + "Time", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound( + UiBindingEndpoint::new("../playlist#entry_time"), + )), + UiConfigSlot::value("shader", "Shader", UiSlotValue::string("idle.glsl")), + ]), + ]), + UiNodeChild::new("blast", "Shader", "./blast.toml").with_sections(vec![ + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value( + "time", + "Time", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "../playlist#entry_time", + ))), + UiConfigSlot::value("trigger", "Trigger", UiSlotValue::bool(false)).with_source( + UiSlotSourceState::Bound(UiBindingEndpoint::new("bus#trigger")), + ), + UiConfigSlot::value("shader", "Shader", UiSlotValue::string("blast.glsl")), + ]), + ]), + ] +} + +pub(crate) fn config_record_fixture() -> UiSlotRecord { + UiSlotRecord::new(vec![ + UiConfigSlot::value("enabled", "Enabled", UiSlotValue::bool(true)), + UiConfigSlot::value( + "shader", + "Shader", + UiSlotValue::string("./idle.glsl").with_editor(UiSlotEditorHint::Text), + ) + .with_detail("asset ref"), + UiConfigSlot::value( + "fade_after", + "Fade after", + UiSlotValue::f32(0.35) + .with_unit(UiSlotUnit::seconds()) + .with_editor(UiSlotEditorHint::number()), + ) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty)), + UiConfigSlot::value( + "time", + "Time", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound( + UiBindingEndpoint::new("bus#time.seconds").with_detail("global clock"), + )), + UiConfigSlot::record( + "transform", + "Transform", + vec![ + UiConfigSlot::value( + "origin", + "Origin", + UiSlotValue::vec2([0.42, 0.58]).with_editor(UiSlotEditorHint::Xy), + ), + UiConfigSlot::value("scale", "Scale", UiSlotValue::vec2([1.0, 1.0])), + UiConfigSlot::value("tint", "Tint", UiSlotValue::vec3([1.0, 0.42, 0.2])), + ], + ) + .with_detail("record"), + ]) +} + +pub(crate) fn config_row_states_fixture() -> Vec { + vec![ + UiConfigSlot::value("direct", "Direct value", UiSlotValue::f32(0.72)), + UiConfigSlot::value( + "bound", + "Bound value", + UiSlotValue::f32(3.333).with_unit(UiSlotUnit::seconds()), + ) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "bus#time.seconds", + ))), + UiConfigSlot::value("dirty", "Edited value", UiSlotValue::string("idle.glsl")) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Dirty)), + UiConfigSlot::value("invalid", "Invalid value", UiSlotValue::f32(-1.0)) + .with_state(UiSlotFieldState::editable().with_invalid("value must be non-negative")), + UiConfigSlot::value( + "write_failed", + "Write failed", + UiSlotValue::string("blast.glsl"), + ) + .with_state(UiSlotFieldState::editable().with_dirty(UiNodeDirtyState::Error)), + UiConfigSlot::empty("optional_trigger", "Optional trigger") + .with_optionality(UiSlotOptionality::excluded(true)) + .with_source(UiSlotSourceState::Unset), + UiConfigSlot::record( + "record", + "Nested record", + vec![UiConfigSlot::value( + "child", + "Child value", + UiSlotValue::bool(true), + )], + ), + ] +} + +pub(crate) fn slot_value_variants_fixture() -> Vec { + vec![ + UiSlotValue::string("./idle.glsl").with_editor(UiSlotEditorHint::Text), + UiSlotValue::i32(-4), + UiSlotValue::u32(128), + UiSlotValue::f32(0.35).with_unit(UiSlotUnit::seconds()), + UiSlotValue::f32(0.72).with_editor(UiSlotEditorHint::slider(0.0, 1.0)), + UiSlotValue::bool(true), + UiSlotValue::vec2([0.42, 0.58]), + UiSlotValue::vec3([1.0, 0.42, 0.2]), + UiSlotValue::vec4([1.0, 0.42, 0.2, 1.0]), + UiSlotValue::ivec3([-1, 0, 1]), + UiSlotValue::uvec4([1, 2, 3, 4]), + UiSlotValue::bvec3([true, false, true]), + UiSlotValue::mat2x2([[1.0, 0.0], [0.0, 1.0]]), + UiSlotValue::array(vec![UiSlotValue::f32(0.25), UiSlotValue::f32(0.75)]), + UiSlotValue::struct_value( + Some("Envelope".to_string()), + vec![ + ("attack".to_string(), UiSlotValue::f32(0.1)), + ("release".to_string(), UiSlotValue::f32(0.8)), + ], + ), + UiSlotValue::enum_value(1, Some(UiSlotValue::string("Loop"))), + UiSlotValue::unset(), + UiSlotValue::string("blast").with_editor(UiSlotEditorHint::dropdown([ + ("idle", "Idle"), + ("blast", "Blast"), + ("strobe", "Strobe"), + ])), + UiSlotValue::vec2([0.42, 0.58]).with_editor(UiSlotEditorHint::Xy), + ] +} + +trait NodeStoryProductExt { + fn with_binding_routes( + self, + bus_target: Option<&str>, + target_bindings: &[&str], + consumers: &[&str], + revision: Option<&str>, + ) -> Self; +} + +impl NodeStoryProductExt for UiProducedProduct { + fn with_binding_routes( + mut self, + bus_target: Option<&str>, + target_bindings: &[&str], + consumers: &[&str], + revision: Option<&str>, + ) -> Self { + self.binding = produced_binding(bus_target, target_bindings, consumers, revision); + self + } +} + +trait NodeStoryValueExt { + fn with_binding_routes( + self, + bus_target: Option<&str>, + target_bindings: &[&str], + consumers: &[&str], + revision: Option<&str>, + ) -> Self; +} + +impl NodeStoryValueExt for UiProducedValue { + fn with_binding_routes( + mut self, + bus_target: Option<&str>, + target_bindings: &[&str], + consumers: &[&str], + revision: Option<&str>, + ) -> Self { + self.binding = produced_binding(bus_target, target_bindings, consumers, revision); + self + } +} + +fn produced_binding( + bus_target: Option<&str>, + target_bindings: &[&str], + consumers: &[&str], + revision: Option<&str>, +) -> UiProducedBinding { + UiProducedBinding { + bindings: UiProducedBindings { + bus_target: bus_target.map(UiBindingEndpoint::new), + target_bindings: target_bindings + .iter() + .map(|target| UiBindingEndpoint::new(*target)) + .collect(), + consumers: consumers + .iter() + .map(|consumer| UiBindingEndpoint::new(*consumer)) + .collect(), + }, + revision: revision.map(str::to_string), + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_product_stories.rs b/lp-app/lpa-studio-web/src/app/node/produced_product_stories.rs new file mode 100644 index 000000000..f14efbabc --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_product_stories.rs @@ -0,0 +1,139 @@ +//! Stories for produced product views. + +use dioxus::prelude::*; +use lpa_studio_core::{UiProducedProduct, UiProductPreview, UiProductTrackingState}; +use lpa_studio_web_story_macros::story; + +use crate::app::node::node_story_fixtures::{ + control_preview_product, control_unsupported_product, produced_product_variants_fixture, + visual_error_product, visual_preview_product, +}; +use crate::app::node::{ProducedProductView, ProducedProducts}; + +#[story(description = "Produced product variants shown as a node pane section would render them.")] +pub(crate) fn gallery() -> Element { + rsx! { + ProducedProducts { products: produced_product_variants_fixture() } + } +} + +#[story(description = "An output slot that has not resolved to a product yet.")] +pub(crate) fn empty_product() -> Element { + rsx! { + ProducedProductView { product: UiProducedProduct::empty("output").with_detail("not resolved") } + } +} + +#[story(description = "A visual product that exists but is not being tracked.")] +pub(crate) fn visual_untracked() -> Element { + rsx! { + ProducedProductView { product: UiProducedProduct::visual("output").with_detail("32 x 32 preview") } + } +} + +#[story(description = "A visual product waiting for its first tracked preview.")] +pub(crate) fn visual_pending() -> Element { + rsx! { + ProducedProductView { + product: UiProducedProduct::visual("output") + .with_detail("32 x 32 preview") + .with_preview(UiProductPreview::Pending) + .with_tracking(UiProductTrackingState::Tracking) + } + } +} + +#[story(description = "A visual product with loaded RGB preview bytes.")] +pub(crate) fn visual_loaded() -> Element { + rsx! { + ProducedProductView { product: visual_preview_product("output") } + } +} + +#[story(description = "A visual product with cached preview bytes that is not being tracked now.")] +pub(crate) fn visual_paused() -> Element { + rsx! { + ProducedProductView { + product: visual_preview_product("output") + .with_tracking(UiProductTrackingState::Paused) + } + } +} + +#[story(description = "A visual product whose preview probe failed.")] +pub(crate) fn visual_error() -> Element { + rsx! { + ProducedProductView { product: visual_error_product("output") } + } +} + +#[story(description = "An open produced product detail popup.")] +pub(crate) fn detail_popup() -> Element { + let product = produced_product_variants_fixture().remove(3); + + rsx! { + div { class: "tw:min-h-56", + ProducedProductView { + product, + initially_open: true, + } + } + } +} + +#[story(description = "A control product that exists but is not being tracked.")] +pub(crate) fn control_untracked() -> Element { + rsx! { + ProducedProductView { product: UiProducedProduct::control("dmx").with_detail("16 RGB lamps") } + } +} + +#[story(description = "A control product waiting for its first tracked preview.")] +pub(crate) fn control_pending() -> Element { + rsx! { + ProducedProductView { + product: UiProducedProduct::control("dmx") + .with_detail("16 RGB lamps") + .with_preview(UiProductPreview::Pending) + .with_tracking(UiProductTrackingState::Tracking) + } + } +} + +#[story(description = "A control product with native samples and a 2D display layout.")] +pub(crate) fn control_loaded() -> Element { + rsx! { + ProducedProductView { product: control_preview_product("dmx") } + } +} + +#[story(description = "A control product with cached preview bytes that is not being tracked now.")] +pub(crate) fn control_paused() -> Element { + rsx! { + ProducedProductView { + product: control_preview_product("dmx") + .with_tracking(UiProductTrackingState::Paused) + } + } +} + +#[story(description = "A control product whose native samples cannot be shown as a 2D layout.")] +pub(crate) fn control_unsupported() -> Element { + rsx! { + ProducedProductView { product: control_unsupported_product("dmx") } + } +} + +#[story(description = "A control product whose preview probe failed.")] +pub(crate) fn control_error() -> Element { + rsx! { + ProducedProductView { + product: UiProducedProduct::control("dmx") + .with_detail("16 RGB lamps") + .with_tracking(UiProductTrackingState::Tracking) + .with_preview(UiProductPreview::Error { + message: "control probe failed".to_string(), + }) + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_product_view.rs b/lp-app/lpa-studio-web/src/app/node/produced_product_view.rs new file mode 100644 index 000000000..c50a3983a --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_product_view.rs @@ -0,0 +1,508 @@ +//! Leaf presentation for a produced product. + +use dioxus::prelude::*; +use lpa_studio_core::{ + ColorOrder, ControlDisplayLayout, ControlSampleEncoding, UiAction, UiControlProductPreview, + UiProducedProduct, UiProductKind, UiProductPreview, UiProductPreviewFrame, + UiProductTrackingState, +}; + +use crate::app::node::SlotDetailButton; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProducedProductView( + product: UiProducedProduct, + #[props(default = false)] separated: bool, + #[props(default = false)] initially_open: bool, + #[props(default)] focus_action: Option, + #[props(default)] on_action: Option>, +) -> Element { + let class = if separated { + format!( + "{} tw:border-t tw:border-border-muted", + product_view_class(product.kind) + ) + } else { + product_view_class(product.kind).to_string() + }; + let label = product_label(product.kind); + let aspects = product.visible_aspects(); + + rsx! { + article { class, + ProductPreview { + kind: product.kind, + preview: product.preview.clone(), + tracking: product.tracking, + frame: product.frame, + focus_action, + on_action, + } + footer { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-center tw:gap-x-2 tw:gap-y-1 tw:text-xs tw:text-muted-foreground", + strong { class: "tw:min-w-0 tw:text-sm tw:text-strong-foreground tw:break-words", "{product.name}" } + span { "{label}" } + if let Some(detail) = preview_detail(&product.preview, product.tracking) { + span { "{detail}" } + } + if let Some(detail) = tracking_detail(product.tracking) { + span { "{detail}" } + } + if let Some(detail) = product.detail.as_ref() { + span { "{detail}" } + } + SlotDetailButton { + label: product.name.clone(), + aspects, + initially_open, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProductPreview( + kind: UiProductKind, + preview: UiProductPreview, + tracking: UiProductTrackingState, + frame: UiProductPreviewFrame, + focus_action: Option, + on_action: Option>, +) -> Element { + let frame_class = product_frame_class(kind); + let frame_style = preview_frame_style(&preview, frame); + let overlay = product_tracking_overlay(kind, tracking); + + rsx! { + div { class: "{frame_class}", style: "{frame_style}", + match preview { + UiProductPreview::VisualSrgb8 { + width, + height, + bytes, + .. + } => rsx! { + ProductPixelGrid { width, height, bytes } + }, + UiProductPreview::ControlNative(preview) => rsx! { + ControlProductPreview { preview } + }, + UiProductPreview::Pending => rsx! { + ProductSkeleton { + kind, + tone: if tracking == UiProductTrackingState::Tracking { + ProductSkeletonTone::Working + } else { + ProductSkeletonTone::Quiet + }, + title: "Tracking product", + detail: "Waiting for the first preview.", + show_text: tracking == UiProductTrackingState::Tracking, + } + }, + UiProductPreview::Error { message } => rsx! { + ProductMessage { + tone: ProductMessageTone::Error, + message, + } + }, + UiProductPreview::Unsupported { reason } => rsx! { + ProductMessage { + tone: ProductMessageTone::Warning, + message: reason, + } + }, + UiProductPreview::Empty => rsx! { + ProductSkeleton { + kind, + tone: ProductSkeletonTone::Quiet, + title: "No product", + detail: "This output has not resolved to a product.", + show_text: true, + } + }, + UiProductPreview::MetadataOnly => rsx! { + ProductSkeleton { + kind, + tone: ProductSkeletonTone::Quiet, + title: "Metadata only", + detail: "Studio does not render this product type yet.", + show_text: true, + } + }, + } + if let Some(overlay) = overlay { + ProductTrackingOverlay { + title: overlay.title, + detail: overlay.detail, + focus_action, + on_action, + } + } + } + } +} + +fn product_frame_class(kind: UiProductKind) -> &'static str { + match kind { + UiProductKind::Visual | UiProductKind::Control => { + "ux-produced-product-frame ux-produced-product-frame-capped" + } + _ => "ux-produced-product-frame", + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ControlProductPreview(preview: UiControlProductPreview) -> Element { + let Some(ControlDisplayLayout::Layout2d(layout)) = preview.display_layout.as_ref() else { + return rsx! { + ProductMessage { + tone: ProductMessageTone::Warning, + message: "Control product has no display layout.".to_string(), + } + }; + }; + + if !control_sample_layout_has_rgb(&preview) { + return rsx! { + ProductMessage { + tone: ProductMessageTone::Warning, + message: "Control product sample layout is not RGB.".to_string(), + } + }; + } + + let lamps = control_lamp_styles(&preview); + rsx! { + div { class: "ux-produced-product-control-layout", + for (index, style) in lamps.into_iter().enumerate() { + span { + key: "{index}", + class: "ux-produced-product-control-lamp", + style: "{style}", + } + } + if layout.lamps.is_empty() { + ProductMessage { + tone: ProductMessageTone::Warning, + message: "Control product display layout is empty.".to_string(), + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProductPixelGrid(width: u32, height: u32, bytes: Vec) -> Element { + let columns = width.max(1); + let rows = height.max(1); + let grid_style = format!( + "grid-template-columns: repeat({columns}, minmax(0, 1fr)); grid-template-rows: repeat({rows}, minmax(0, 1fr));" + ); + let pixels = rgb_pixel_styles(&bytes); + rsx! { + div { + class: "ux-produced-product-pixel-grid", + style: "{grid_style}", + for (index, style) in pixels.into_iter().enumerate() { + span { + key: "{index}", + class: "tw:block", + style: "{style}", + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProductSkeleton( + kind: UiProductKind, + tone: ProductSkeletonTone, + title: &'static str, + detail: &'static str, + #[props(default = true)] show_text: bool, +) -> Element { + let class = product_skeleton_class(kind, tone); + + rsx! { + div { class, + div { class: "ux-produced-product-skeleton-graphic", aria_hidden: "true", + for index in 0..12 { + span { key: "{index}", class: "ux-produced-product-skeleton-bar" } + } + } + if show_text { + div { class: "tw:grid tw:min-w-0 tw:gap-1 tw:text-center", + strong { class: "tw:text-sm tw:text-strong-foreground", "{title}" } + span { class: "tw:text-xs tw:leading-snug tw:text-muted-foreground", "{detail}" } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProductMessage(tone: ProductMessageTone, message: String) -> Element { + let class = match tone { + ProductMessageTone::Warning => { + "ux-produced-product-message ux-produced-product-message-warning" + } + ProductMessageTone::Error => { + "ux-produced-product-message ux-produced-product-message-error" + } + }; + + rsx! { + div { class, "{message}" } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProductTrackingOverlay( + title: &'static str, + detail: &'static str, + focus_action: Option, + on_action: Option>, +) -> Element { + if let (Some(action), Some(handler)) = (focus_action, on_action) { + return rsx! { + button { + class: "ux-produced-product-overlay ux-produced-product-overlay-button", + r#type: "button", + onclick: move |event| { + event.stop_propagation(); + handler.call(action.clone()); + }, + strong { "{title}" } + span { "{detail}" } + } + }; + } + + rsx! { + div { class: "ux-produced-product-overlay", + strong { "{title}" } + span { "{detail}" } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProductSkeletonTone { + Quiet, + Working, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProductMessageTone { + Warning, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProductOverlayCopy { + title: &'static str, + detail: &'static str, +} + +fn product_view_class(kind: UiProductKind) -> &'static str { + match kind { + UiProductKind::Empty => { + "tw:grid tw:min-h-32 tw:min-w-0 tw:content-between tw:gap-3 tw:bg-card-muted tw:p-3" + } + UiProductKind::Visual => { + "tw:grid tw:min-h-32 tw:min-w-0 tw:content-between tw:gap-3 tw:bg-[color-mix(in_oklab,var(--color-accent-bg)_60%,var(--color-card))] tw:p-3" + } + UiProductKind::Control => { + "tw:grid tw:min-h-32 tw:min-w-0 tw:content-between tw:gap-3 tw:bg-[color-mix(in_oklab,var(--color-status-good-bg)_55%,var(--color-card))] tw:p-3" + } + UiProductKind::Other => { + "tw:grid tw:min-h-32 tw:min-w-0 tw:content-between tw:gap-3 tw:bg-card-muted tw:p-3" + } + } +} + +fn product_label(kind: UiProductKind) -> &'static str { + match kind { + UiProductKind::Empty => "empty product", + UiProductKind::Visual => "visual product", + UiProductKind::Control => "control product", + UiProductKind::Other => "product", + } +} + +fn preview_detail(preview: &UiProductPreview, tracking: UiProductTrackingState) -> Option { + match preview { + UiProductPreview::VisualSrgb8 { + width, + height, + revision, + .. + } => Some(format!("{width} x {height} rev {revision}")), + UiProductPreview::ControlNative(preview) => Some(format!( + "{} x {} samples rev {}", + preview.extent.rows, preview.extent.samples_per_row, preview.revision + )), + UiProductPreview::Pending if tracking == UiProductTrackingState::Tracking => { + Some("preview pending".to_string()) + } + UiProductPreview::Pending => None, + UiProductPreview::MetadataOnly => Some("metadata only".to_string()), + UiProductPreview::Empty + | UiProductPreview::Unsupported { .. } + | UiProductPreview::Error { .. } => None, + } +} + +fn tracking_detail(tracking: UiProductTrackingState) -> Option<&'static str> { + match tracking { + UiProductTrackingState::Untracked => Some("not tracked"), + UiProductTrackingState::Paused => Some("paused"), + UiProductTrackingState::Tracking => None, + } +} + +fn product_tracking_overlay( + kind: UiProductKind, + tracking: UiProductTrackingState, +) -> Option { + let label = match kind { + UiProductKind::Visual => "Visual output", + UiProductKind::Control => "Control output", + _ => return None, + }; + match tracking { + UiProductTrackingState::Untracked => Some(ProductOverlayCopy { + title: if kind == UiProductKind::Visual { + "Visual output not tracked" + } else { + "Control output not tracked" + }, + detail: "Click to view", + }), + UiProductTrackingState::Paused => Some(ProductOverlayCopy { + title: if label == "Visual output" { + "Visual output paused" + } else { + "Control output paused" + }, + detail: "Click to view", + }), + UiProductTrackingState::Tracking => None, + } +} + +fn preview_frame_style(preview: &UiProductPreview, frame: UiProductPreviewFrame) -> String { + if let UiProductPreview::ControlNative(control) = preview + && let Some(ControlDisplayLayout::Layout2d(layout)) = control.display_layout.as_ref() + { + return format!( + "aspect-ratio: {} / {};", + layout.width_hint.max(1), + layout.height_hint.max(1) + ); + } + format!("aspect-ratio: {} / {};", frame.width, frame.height) +} + +fn rgb_pixel_styles(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(3) + .map(|chunk| { + format!( + "background-color: rgb({} {} {});", + chunk[0], chunk[1], chunk[2] + ) + }) + .collect() +} + +fn control_sample_layout_has_rgb(preview: &UiControlProductPreview) -> bool { + preview.sample_layout.spans.iter().any(|span| { + matches!( + span.encoding, + ControlSampleEncoding::RgbPixels { count, .. } if count > 0 + ) + }) +} + +fn control_lamp_styles(preview: &UiControlProductPreview) -> Vec { + let Some(ControlDisplayLayout::Layout2d(layout)) = preview.display_layout.as_ref() else { + return Vec::new(); + }; + layout + .lamps + .iter() + .map(|lamp| { + let [r, g, b] = control_rgb_at_sample(preview, lamp.sample_start).unwrap_or([0, 0, 0]); + let diameter = (lamp.radius.max(0.006) * 96.0).clamp(3.5, 18.0); + format!( + "--lamp-r: {r}; --lamp-g: {g}; --lamp-b: {b}; left: {:.3}%; top: {:.3}%; width: max(5px, {:.3}%); height: max(5px, {:.3}%);", + lamp.center[0].clamp(0.0, 1.0) * 100.0, + lamp.center[1].clamp(0.0, 1.0) * 100.0, + diameter, + diameter, + ) + }) + .collect() +} + +fn control_rgb_at_sample(preview: &UiControlProductPreview, sample_start: u32) -> Option<[u8; 3]> { + let span = preview.sample_layout.spans.iter().find(|span| { + matches!(span.encoding, ControlSampleEncoding::RgbPixels { .. }) + && sample_start >= span.start + && sample_start.saturating_add(3) <= span.start.saturating_add(span.len) + && (sample_start - span.start).is_multiple_of(3) + })?; + let color_order = match span.encoding { + ControlSampleEncoding::RgbPixels { color_order, .. } => color_order, + ControlSampleEncoding::Raw => return None, + }; + let sample = |offset: u32| -> Option { + let index = sample_start.checked_add(offset)? as usize; + let byte_index = index.checked_mul(2)?; + let lo = *preview.bytes.get(byte_index)?; + let hi = *preview.bytes.get(byte_index + 1)?; + Some((u16::from_le_bytes([lo, hi]) >> 8) as u8) + }; + let a = sample(0)?; + let b = sample(1)?; + let c = sample(2)?; + Some(match color_order { + ColorOrder::Rgb => [a, b, c], + ColorOrder::Grb => [b, a, c], + ColorOrder::Rbg => [a, c, b], + ColorOrder::Gbr => [c, a, b], + ColorOrder::Brg => [b, c, a], + ColorOrder::Bgr => [c, b, a], + }) +} + +fn product_skeleton_class(kind: UiProductKind, tone: ProductSkeletonTone) -> &'static str { + match (kind, tone) { + (UiProductKind::Visual, ProductSkeletonTone::Working) => { + "ux-produced-product-skeleton ux-produced-product-skeleton-visual ux-produced-product-skeleton-working" + } + (UiProductKind::Visual, ProductSkeletonTone::Quiet) => { + "ux-produced-product-skeleton ux-produced-product-skeleton-visual" + } + (UiProductKind::Control, ProductSkeletonTone::Working) => { + "ux-produced-product-skeleton ux-produced-product-skeleton-control ux-produced-product-skeleton-working" + } + (UiProductKind::Control, ProductSkeletonTone::Quiet) => { + "ux-produced-product-skeleton ux-produced-product-skeleton-control" + } + (_, ProductSkeletonTone::Working) => { + "ux-produced-product-skeleton ux-produced-product-skeleton-working" + } + (_, ProductSkeletonTone::Quiet) => "ux-produced-product-skeleton", + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_products.rs b/lp-app/lpa-studio-web/src/app/node/produced_products.rs new file mode 100644 index 000000000..fc4e7cd32 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_products.rs @@ -0,0 +1,26 @@ +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiProducedProduct}; + +use crate::app::node::ProducedProductView; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProducedProducts( + products: Vec, + #[props(default)] focus_action: Option, + #[props(default)] on_action: Option>, +) -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-0", + for (index, product) in products.into_iter().enumerate() { + ProducedProductView { + key: "{product.name}", + product, + separated: index > 0, + focus_action: focus_action.clone(), + on_action, + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_value_stories.rs b/lp-app/lpa-studio-web/src/app/node/produced_value_stories.rs new file mode 100644 index 000000000..a6c1c6cfb --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_value_stories.rs @@ -0,0 +1,47 @@ +//! Stories for produced value stat views. + +use dioxus::prelude::*; +use lpa_studio_core::{UiProducedValue, UiSlotUnit}; +use lpa_studio_web_story_macros::story; + +use crate::app::node::node_story_fixtures::produced_value_variants_fixture; +use crate::app::node::{ProducedValueView, ProducedValues}; + +#[story(description = "Produced values rendered as compact stat boxes.")] +pub(crate) fn gallery() -> Element { + rsx! { + ProducedValues { values: produced_value_variants_fixture() } + } +} + +#[story(description = "A numeric produced value with a short unit detail.")] +pub(crate) fn numeric_stat() -> Element { + rsx! { + ProducedValueView { + value: UiProducedValue::new("Seconds", "123.435").with_unit(UiSlotUnit::seconds()) + } + } +} + +#[story(description = "A produced value with binding metadata available from the icon menu.")] +pub(crate) fn bound_stat() -> Element { + let value = produced_value_variants_fixture().remove(2); + + rsx! { + ProducedValueView { value } + } +} + +#[story(description = "An open produced value detail popup.")] +pub(crate) fn detail_popup() -> Element { + let value = produced_value_variants_fixture().remove(2); + + rsx! { + div { class: "tw:min-h-48", + ProducedValueView { + value, + initially_open: true, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_value_view.rs b/lp-app/lpa-studio-web/src/app/node/produced_value_view.rs new file mode 100644 index 000000000..8cc5a34b3 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_value_view.rs @@ -0,0 +1,74 @@ +//! Leaf presentation for a produced value stat. + +use dioxus::prelude::*; +use lpa_studio_core::{UiProducedValue, UiSlotUnit}; + +use crate::app::node::{SlotDetailButton, SlotUnitDisplay, SlotUnitDisplayMode}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProducedValueView( + value: UiProducedValue, + #[props(default = false)] initially_open: bool, +) -> Element { + let aspects = value.visible_aspects(); + let unit = value.display_unit(); + let display_value = produced_value_display(&value.value, unit.as_ref()); + let reading_class = produced_value_reading_class(unit.is_some()); + + rsx! { + div { class: "ux-produced-value-card", + dd { class: "tw:m-0 tw:min-w-0 tw:leading-none", + span { class: "{reading_class}", + strong { class: "ux-produced-value-number", "{display_value}" } + if let Some(unit) = unit { + span { class: "ux-produced-value-unit", + SlotUnitDisplay { + unit, + mode: SlotUnitDisplayMode::Short, + } + } + } + } + } + dt { class: "tw:flex tw:min-w-0 tw:items-center tw:justify-between tw:gap-1.5 tw:text-xs tw:font-bold tw:leading-tight tw:text-subtle-foreground", + span { class: "tw:min-w-0 tw:break-words", "{value.label}" } + SlotDetailButton { + label: value.label.clone(), + aspects, + initially_open, + } + } + } + } +} + +fn produced_value_display(value: &str, unit: Option<&UiSlotUnit>) -> String { + let trimmed = value.trim(); + let Some(decimals) = produced_value_decimal_places(trimmed, unit) else { + return value.to_string(); + }; + let Ok(number) = trimmed.parse::() else { + return value.to_string(); + }; + if number.is_finite() { + format!("{number:.decimals$}") + } else { + value.to_string() + } +} + +fn produced_value_decimal_places(value: &str, unit: Option<&UiSlotUnit>) -> Option { + if unit.is_some_and(|unit| unit.short == "s" || unit.short == "ms") { + return Some(3); + } + (value.contains('.') || value.contains('e') || value.contains('E')).then_some(3) +} + +fn produced_value_reading_class(has_unit: bool) -> &'static str { + if has_unit { + "ux-produced-value-reading ux-produced-value-reading-with-unit" + } else { + "ux-produced-value-reading" + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/produced_values.rs b/lp-app/lpa-studio-web/src/app/node/produced_values.rs new file mode 100644 index 000000000..fb109af4d --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/produced_values.rs @@ -0,0 +1,18 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiProducedValue; + +use crate::app::node::ProducedValueView; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProducedValues(values: Vec) -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-2", + dl { class: "tw:m-0 tw:grid tw:grid-cols-[repeat(auto-fit,minmax(140px,1fr))] tw:gap-2", + for value in values { + ProducedValueView { key: "{value.label}", value } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs b/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs new file mode 100644 index 000000000..ebd5d933c --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_detail_button.rs @@ -0,0 +1,512 @@ +//! Shared slot detail button and row treatment for node slot-like surfaces. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotAffordance, UiSlotAspect, UiSlotAspectKind, UiSlotAspectRow}; + +use crate::app::node::{ + SlotShapeDisplay, SlotShapeDisplayMode, SlotUnitDisplay, SlotUnitDisplayMode, + legacy_shape_from_parts, +}; +use crate::base::{IconMenuButton, IconMenuTone, PopoverPlacement, StudioIcon, StudioIconName}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn SlotDetailButton( + label: String, + aspects: Vec, + #[props(default = false)] initially_open: bool, +) -> Element { + let affordance = primary_affordance(&aspects); + let style = slot_affordance_style(affordance); + let menu_label = format!("{label} details"); + + rsx! { + span { class: "tw:inline-flex tw:w-6 tw:justify-end", + IconMenuButton { + icon: style.icon, + icon_size: 13, + label: menu_label.clone(), + title: menu_label, + tone: style.tone, + placement: PopoverPlacement::BottomEnd, + active: style.active, + initially_open, + popup_class: slot_detail_popup_class().to_string(), + for aspect in aspects { + SlotDetailSection { aspect } + } + } + } + } +} + +pub(crate) fn slot_row_class(affordance: UiSlotAffordance, index: usize) -> &'static str { + match affordance { + UiSlotAffordance::Error | UiSlotAffordance::Invalid => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-status-error-bg)_0%,var(--studio-status-error-bg)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + UiSlotAffordance::Edited => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-status-warning-bg)_0%,var(--studio-status-warning-bg)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + UiSlotAffordance::Saving => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-status-working-bg)_0%,var(--studio-status-working-bg)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + UiSlotAffordance::Bound => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-status-good-bg)_0%,var(--studio-status-good-bg)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + UiSlotAffordance::Info if index % 2 == 0 => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-color-surface-muted)_0%,var(--studio-color-surface-muted)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + UiSlotAffordance::Info => { + "tw:grid tw:min-w-0 tw:grid-cols-[minmax(120px,0.4fr)_minmax(0,1fr)_24px] tw:items-center tw:gap-2 tw:bg-[linear-gradient(270deg,var(--studio-color-surface-subtle)_0%,var(--studio-color-surface-subtle)_34%,transparent_100%)] tw:px-2 tw:py-1.5" + } + } +} + +pub(crate) fn primary_affordance(aspects: &[UiSlotAspect]) -> UiSlotAffordance { + aspects + .iter() + .filter_map(|aspect| aspect.affordance) + .max() + .unwrap_or(UiSlotAffordance::Info) +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn SlotDetailSection(aspect: UiSlotAspect) -> Element { + let summary = aspect_summary(&aspect); + let section_class = aspect_section_class(summary.highlight); + let heading_class = aspect_heading_class(summary.tone); + let icon_class = aspect_icon_class(summary.tone); + let details = aspect_detail_rows(&aspect); + let info_rows = if aspect.kind == UiSlotAspectKind::TypeInfo { + type_info_detail_rows(&aspect) + } else { + Vec::new() + }; + let title = summary.title.clone(); + let code = summary.code.clone(); + let title_is_code = summary.title_is_code; + + rsx! { + section { class: section_class, + header { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-1.5 tw:leading-none", + if aspect.kind != UiSlotAspectKind::TypeInfo { + span { class: icon_class, + StudioIcon { + name: summary.icon, + size: 12, + } + } + } + if title_is_code { + code { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-xs tw:font-bold tw:text-heading", "{title}" } + } else { + h3 { class: heading_class, "{title}" } + } + if let Some(code) = code { + code { class: "tw:min-w-0 tw:truncate tw:font-mono tw:text-xs tw:text-muted-foreground", "{code}" } + } + } + if aspect.kind == UiSlotAspectKind::TypeInfo { + div { class: "tw:grid tw:gap-0.5 tw:pt-0.5", + for row in info_rows { + SlotInfoRow { row } + } + } + } else if !details.is_empty() { + div { class: "tw:grid tw:gap-0.5 tw:pl-[18px] tw:pt-0.5", + for row in details { + SlotDetailRow { row } + } + } + } + } + } +} + +#[derive(Clone, Copy)] +struct SlotAffordanceStyle { + tone: IconMenuTone, + icon: StudioIconName, + active: bool, +} + +#[derive(Clone)] +struct AspectSummary { + title: String, + code: Option, + title_is_code: bool, + icon: StudioIconName, + tone: AspectTone, + highlight: Option, +} + +#[derive(Clone, Copy)] +enum AspectTone { + Quiet, + Good, + Accent, + Working, + Warning, + Error, +} + +fn slot_affordance_style(affordance: UiSlotAffordance) -> SlotAffordanceStyle { + match affordance { + UiSlotAffordance::Info => SlotAffordanceStyle { + tone: IconMenuTone::Quiet, + icon: StudioIconName::InfoBare, + active: true, + }, + UiSlotAffordance::Saving => SlotAffordanceStyle { + tone: IconMenuTone::Working, + icon: StudioIconName::StatusRunning, + active: true, + }, + UiSlotAffordance::Bound => SlotAffordanceStyle { + tone: IconMenuTone::Accent, + icon: StudioIconName::BoundValue, + active: true, + }, + UiSlotAffordance::Edited => SlotAffordanceStyle { + tone: IconMenuTone::Warning, + icon: StudioIconName::Edited, + active: true, + }, + UiSlotAffordance::Invalid => SlotAffordanceStyle { + tone: IconMenuTone::Error, + icon: StudioIconName::StepAttention, + active: true, + }, + UiSlotAffordance::Error => SlotAffordanceStyle { + tone: IconMenuTone::Error, + icon: StudioIconName::StatusError, + active: true, + }, + } +} + +fn slot_detail_popup_class() -> &'static str { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:gap-0 tw:overflow-hidden tw:rounded-md tw:border tw:border-border tw:bg-card tw:text-sm tw:text-muted-foreground tw:shadow-lg" +} + +fn aspect_summary(aspect: &UiSlotAspect) -> AspectSummary { + match aspect.kind { + UiSlotAspectKind::Optionality => optionality_summary(aspect), + UiSlotAspectKind::Validation => validation_summary(aspect), + UiSlotAspectKind::EditState => edit_state_summary(aspect), + UiSlotAspectKind::Binding => binding_summary(aspect), + UiSlotAspectKind::TypeInfo => AspectSummary { + title: type_info_title(aspect), + code: None, + title_is_code: true, + icon: StudioIconName::InfoBare, + tone: AspectTone::Quiet, + highlight: None, + }, + } +} + +fn optionality_summary(aspect: &UiSlotAspect) -> AspectSummary { + if first_row_label_is(aspect, "Enabled") { + AspectSummary { + title: "Enabled".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::AssignedValue, + tone: AspectTone::Good, + highlight: None, + } + } else { + AspectSummary { + title: "Disabled".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::UnboundValue, + tone: AspectTone::Quiet, + highlight: None, + } + } +} + +fn validation_summary(aspect: &UiSlotAspect) -> AspectSummary { + match aspect.affordance { + Some(UiSlotAffordance::Error) => AspectSummary { + title: "Error".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StatusError, + tone: AspectTone::Error, + highlight: Some(UiSlotAffordance::Error), + }, + Some(UiSlotAffordance::Invalid) => AspectSummary { + title: "Invalid".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StepAttention, + tone: AspectTone::Error, + highlight: Some(UiSlotAffordance::Invalid), + }, + _ => AspectSummary { + title: "Valid".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StepComplete, + tone: AspectTone::Good, + highlight: None, + }, + } +} + +fn edit_state_summary(aspect: &UiSlotAspect) -> AspectSummary { + match aspect.affordance { + Some(UiSlotAffordance::Error) => AspectSummary { + title: "Write failed".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StatusError, + tone: AspectTone::Error, + highlight: Some(UiSlotAffordance::Error), + }, + Some(UiSlotAffordance::Edited) => AspectSummary { + title: "Edited".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::Edited, + tone: AspectTone::Warning, + highlight: Some(UiSlotAffordance::Edited), + }, + Some(UiSlotAffordance::Saving) => AspectSummary { + title: "Saving".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StatusRunning, + tone: AspectTone::Working, + highlight: Some(UiSlotAffordance::Saving), + }, + _ => AspectSummary { + title: "No changes".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::StepComplete, + tone: AspectTone::Good, + highlight: None, + }, + } +} + +fn binding_summary(aspect: &UiSlotAspect) -> AspectSummary { + match aspect.affordance { + Some(UiSlotAffordance::Bound) => AspectSummary { + title: binding_title(aspect), + code: first_row_value(aspect), + title_is_code: false, + icon: StudioIconName::BoundValue, + tone: AspectTone::Accent, + highlight: Some(UiSlotAffordance::Bound), + }, + _ if first_row_label_is(aspect, "Unbound") => AspectSummary { + title: "Unbound".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::UnboundValue, + tone: AspectTone::Quiet, + highlight: None, + }, + _ => AspectSummary { + title: "Unbound".to_string(), + code: None, + title_is_code: false, + icon: StudioIconName::AssignedValue, + tone: AspectTone::Quiet, + highlight: None, + }, + } +} + +fn binding_title(aspect: &UiSlotAspect) -> String { + match aspect.rows.first().map(|row| row.label.as_str()) { + Some(label) if label.eq_ignore_ascii_case("Published") => "Published as".to_string(), + Some(label) if label.eq_ignore_ascii_case("Bound to") => "Bound to".to_string(), + Some(label) if label.eq_ignore_ascii_case("Consumed by") => "Consumed by".to_string(), + _ => "Bound from".to_string(), + } +} + +fn first_row_label_is(aspect: &UiSlotAspect, label: &str) -> bool { + aspect + .rows + .first() + .is_some_and(|row| row.label.eq_ignore_ascii_case(label)) +} + +fn aspect_detail_rows(aspect: &UiSlotAspect) -> Vec { + if aspect.kind == UiSlotAspectKind::TypeInfo { + return Vec::new(); + } + let value_is_header_code = aspect.kind == UiSlotAspectKind::Binding + && aspect.affordance == Some(UiSlotAffordance::Bound); + + aspect + .rows + .iter() + .enumerate() + .filter(|(index, row)| { + !(value_is_header_code && *index == 0) + && (!row.value.is_empty() + || row.detail.as_ref().is_some_and(|detail| !detail.is_empty())) + }) + .map(|(_, row)| row.clone()) + .collect() +} + +fn type_info_title(aspect: &UiSlotAspect) -> String { + aspect + .rows + .iter() + .find(|row| row.label.eq_ignore_ascii_case("Path") && !row.value.is_empty()) + .or_else(|| { + aspect + .rows + .iter() + .find(|row| row.label.eq_ignore_ascii_case("Name") && !row.value.is_empty()) + }) + .map(|row| row.value.clone()) + .unwrap_or_else(|| aspect.title.clone()) +} + +fn type_info_detail_rows(aspect: &UiSlotAspect) -> Vec { + aspect + .rows + .iter() + .filter(|row| { + !row.label.eq_ignore_ascii_case("Path") && !row.label.eq_ignore_ascii_case("Name") + }) + .cloned() + .collect() +} + +fn first_row_value(aspect: &UiSlotAspect) -> Option { + aspect + .rows + .first() + .map(|row| row.value.clone()) + .filter(|value| !value.is_empty()) +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn SlotInfoRow(row: UiSlotAspectRow) -> Element { + let shape = row.shape.clone().or_else(|| { + row.label + .eq_ignore_ascii_case("Shape") + .then(|| legacy_shape_from_parts(&row.value, row.detail.as_deref())) + }); + let unit = row.unit.clone(); + + rsx! { + if let Some(shape) = shape { + p { class: "tw:m-0 tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:text-xs tw:leading-snug", + SlotShapeDisplay { + shape, + mode: SlotShapeDisplayMode::CompactFriendly, + } + } + } else if let Some(unit) = unit { + p { class: "tw:m-0 tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:text-xs tw:leading-snug", + span { class: "tw:font-bold tw:text-subtle-foreground", "{row.label}:" } + SlotUnitDisplay { + unit, + mode: SlotUnitDisplayMode::Long, + } + } + } else if !row.value.is_empty() { + p { class: "tw:m-0 tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:text-xs tw:leading-snug", + if !row.label.eq_ignore_ascii_case("Shape") { + span { class: "tw:font-bold tw:text-subtle-foreground", "{row.label}:" } + } + span { class: "tw:text-muted-foreground tw:break-words", "{row.value}" } + if let Some(detail) = row.detail { + if !detail.is_empty() { + span { class: "tw:text-subtle-foreground tw:break-words", "{detail}" } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn SlotDetailRow(row: UiSlotAspectRow) -> Element { + rsx! { + p { class: "tw:m-0 tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:text-xs tw:leading-snug", + if !row.label.is_empty() { + span { class: "tw:font-bold tw:text-subtle-foreground", "{row.label}:" } + } + if !row.value.is_empty() { + span { class: "tw:text-muted-foreground tw:break-words", "{row.value}" } + } + if let Some(detail) = row.detail { + if !detail.is_empty() { + span { class: "tw:text-subtle-foreground tw:break-words", "{detail}" } + } + } + } + } +} + +fn aspect_section_class(highlight: Option) -> &'static str { + match highlight { + Some(UiSlotAffordance::Error | UiSlotAffordance::Invalid) => { + "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-error-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" + } + Some(UiSlotAffordance::Edited) => { + "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-warning-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" + } + Some(UiSlotAffordance::Saving) => { + "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-working-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" + } + Some(UiSlotAffordance::Bound) => { + "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:bg-[linear-gradient(90deg,var(--studio-status-good-bg)_0%,transparent_72%)] tw:px-3 tw:py-1.5 tw:first:border-t-0" + } + Some(UiSlotAffordance::Info) | None => { + "tw:grid tw:gap-0.5 tw:border-t tw:border-border-muted tw:px-3 tw:py-1.5 tw:first:border-t-0" + } + } +} + +fn aspect_icon_class(tone: AspectTone) -> &'static str { + match tone { + AspectTone::Error => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-status-error-foreground" + } + AspectTone::Warning => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-status-warning-foreground" + } + AspectTone::Working => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-status-working-foreground" + } + AspectTone::Good => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-status-good-foreground" + } + AspectTone::Accent => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-accent" + } + AspectTone::Quiet => { + "tw:inline-flex tw:flex-none tw:items-center tw:justify-center tw:text-heading" + } + } +} + +fn aspect_heading_class(tone: AspectTone) -> &'static str { + match tone { + AspectTone::Error => "tw:m-0 tw:text-xs tw:font-bold tw:text-status-error-foreground", + AspectTone::Warning => "tw:m-0 tw:text-xs tw:font-bold tw:text-status-warning-foreground", + AspectTone::Working => "tw:m-0 tw:text-xs tw:font-bold tw:text-status-working-foreground", + AspectTone::Good => "tw:m-0 tw:text-xs tw:font-bold tw:text-status-good-foreground", + AspectTone::Accent => "tw:m-0 tw:text-xs tw:font-bold tw:text-accent", + AspectTone::Quiet => "tw:m-0 tw:text-xs tw:font-bold tw:text-heading", + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_fields.rs b/lp-app/lpa-studio-web/src/app/node/slot_fields.rs new file mode 100644 index 000000000..3088b1c8a --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_fields.rs @@ -0,0 +1,200 @@ +//! Basic field renderers for the first config slot editor slice. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotFieldState, UiSlotOption, UiSlotUnit}; + +use crate::app::node::SlotUnitSuffix; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StringSlotField(value: String, state: UiSlotFieldState) -> Element { + rsx! { + span { class: field_class(&state), "{value}" } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn IntSlotField( + value: i32, + state: UiSlotFieldState, + #[props(default = None)] unit: Option, +) -> Element { + rsx! { + span { class: numeric_field_class(&state), + span { class: "tw:font-mono", "{value}" } + SlotUnitSuffix { unit, reserve: true } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn UIntSlotField( + value: u32, + state: UiSlotFieldState, + #[props(default = None)] unit: Option, +) -> Element { + rsx! { + span { class: numeric_field_class(&state), + span { class: "tw:font-mono", "{value}" } + SlotUnitSuffix { unit, reserve: true } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn FloatSlotField( + value: f32, + state: UiSlotFieldState, + #[props(default = None)] unit: Option, +) -> Element { + rsx! { + span { class: numeric_field_class(&state), + span { class: "tw:font-mono", "{format_float(value)}" } + SlotUnitSuffix { unit, reserve: true } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn BoolSlotField(value: bool, state: UiSlotFieldState) -> Element { + let true_class = bool_option_class(&state, value); + let false_class = bool_option_class(&state, !value); + + rsx! { + span { class: "tw:inline-grid tw:grid-cols-2 tw:overflow-hidden tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:text-xs tw:font-bold", + span { class: true_class, "true" } + span { class: false_class, "false" } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn Vec2SlotField(value: [f32; 2], state: UiSlotFieldState) -> Element { + rsx! { + span { class: "tw:grid tw:min-w-0 tw:grid-cols-2 tw:gap-1", + VectorComponentField { label: "x", value: value[0], state: state.clone() } + VectorComponentField { label: "y", value: value[1], state } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn Vec3SlotField(value: [f32; 3], state: UiSlotFieldState) -> Element { + rsx! { + span { class: "tw:grid tw:min-w-0 tw:grid-cols-3 tw:gap-1", + VectorComponentField { label: "x", value: value[0], state: state.clone() } + VectorComponentField { label: "y", value: value[1], state: state.clone() } + VectorComponentField { label: "z", value: value[2], state } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn DropdownSlotField( + value: String, + options: Vec, + state: UiSlotFieldState, +) -> Element { + let label = options + .iter() + .find(|option| option.value == value) + .map(|option| option.label.as_str()) + .unwrap_or(value.as_str()); + + rsx! { + span { class: field_class(&state), + span { class: "tw:min-w-0 tw:truncate", "{label}" } + span { class: "tw:text-subtle-foreground", "v" } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn XySlotField(value: [f32; 2], state: UiSlotFieldState) -> Element { + let x = value[0].clamp(0.0, 1.0) * 100.0; + let y = (1.0 - value[1].clamp(0.0, 1.0)) * 100.0; + let point_style = format!("left: {x:.1}%; top: {y:.1}%;"); + let pad_class = xy_pad_class(&state); + + rsx! { + span { class: "tw:flex tw:min-w-0 tw:items-center tw:gap-2", + span { class: pad_class, + span { class: "tw:absolute tw:left-1/2 tw:top-0 tw:h-full tw:w-px tw:bg-border-muted" } + span { class: "tw:absolute tw:left-0 tw:top-1/2 tw:h-px tw:w-full tw:bg-border-muted" } + span { class: "tw:absolute tw:h-2 tw:w-2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:rounded-full tw:border tw:border-accent-border tw:bg-accent", style: "{point_style}" } + } + Vec2SlotField { + value, + state, + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn VectorComponentField(label: &'static str, value: f32, state: UiSlotFieldState) -> Element { + rsx! { + span { class: numeric_field_class(&state), + small { class: "tw:text-[0.64rem] tw:font-bold tw:uppercase tw:text-subtle-foreground", "{label}" } + span { class: "tw:font-mono", "{format_float(value)}" } + } + } +} + +fn field_class(state: &UiSlotFieldState) -> &'static str { + if state.invalid.is_some() { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-center tw:justify-between tw:gap-2 tw:rounded-xs tw:border tw:border-status-error-border tw:bg-status-error-bg tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-status-error-foreground" + } else if state.editable { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-center tw:justify-between tw:gap-2 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-muted-foreground" + } else { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-center tw:justify-between tw:gap-2 tw:rounded-xs tw:border tw:border-border-muted tw:bg-card-muted tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-subtle-foreground" + } +} + +fn numeric_field_class(state: &UiSlotFieldState) -> &'static str { + if state.invalid.is_some() { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-baseline tw:justify-end tw:gap-1 tw:rounded-xs tw:border tw:border-status-error-border tw:bg-status-error-bg tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-status-error-foreground" + } else if state.editable { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-baseline tw:justify-end tw:gap-1 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-muted-foreground" + } else { + "tw:inline-flex tw:min-h-7 tw:min-w-0 tw:items-baseline tw:justify-end tw:gap-1 tw:rounded-xs tw:border tw:border-border-muted tw:bg-card-muted tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-subtle-foreground" + } +} + +fn bool_option_class(state: &UiSlotFieldState, active: bool) -> &'static str { + match (state.invalid.is_some(), active) { + (true, true) => "tw:bg-status-error-bg tw:px-2 tw:py-1 tw:text-status-error-foreground", + (true, false) => "tw:bg-page tw:px-2 tw:py-1 tw:text-subtle-foreground", + (false, true) => "tw:bg-accent-bg tw:px-2 tw:py-1 tw:text-accent", + (false, false) => "tw:bg-page tw:px-2 tw:py-1 tw:text-subtle-foreground", + } +} + +fn xy_pad_class(state: &UiSlotFieldState) -> &'static str { + if state.invalid.is_some() { + "tw:relative tw:h-14 tw:w-14 tw:flex-none tw:overflow-hidden tw:rounded-xs tw:border tw:border-status-error-border tw:bg-status-error-bg" + } else { + "tw:relative tw:h-14 tw:w-14 tw:flex-none tw:overflow-hidden tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page" + } +} + +fn format_float(value: f32) -> String { + if value.fract() == 0.0 { + format!("{value:.0}") + } else { + let formatted = format!("{value:.3}"); + formatted + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_issue_list.rs b/lp-app/lpa-studio-web/src/app/node/slot_issue_list.rs new file mode 100644 index 000000000..063b56d3c --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_issue_list.rs @@ -0,0 +1,19 @@ +//! Issue list presentation for config slots. + +use dioxus::prelude::*; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn SlotIssueList(issues: Vec) -> Element { + if issues.is_empty() { + return rsx! {}; + } + + rsx! { + ul { class: "tw:m-0 tw:grid tw:list-none tw:gap-1 tw:p-0", + for issue in issues { + li { class: "tw:text-xs tw:font-medium tw:text-status-error-foreground", "{issue}" } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_record_editor.rs b/lp-app/lpa-studio-web/src/app/node/slot_record_editor.rs new file mode 100644 index 000000000..26cfc8488 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_record_editor.rs @@ -0,0 +1,33 @@ +//! Recursive record editor for config slot fields. + +use dioxus::prelude::*; +use lpa_studio_core::UiSlotRecord; + +use crate::app::node::ConfigSlotRow; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn SlotRecordEditor( + record: UiSlotRecord, + #[props(default = 0)] depth: usize, + #[props(default = false)] separated: bool, +) -> Element { + let class = if separated { + "tw:grid tw:min-w-0 tw:overflow-hidden tw:border-t tw:border-border-muted tw:divide-y tw:divide-border-muted" + } else { + "tw:grid tw:min-w-0 tw:overflow-hidden tw:divide-y tw:divide-border-muted" + }; + + rsx! { + div { class, + for (index, slot) in record.fields.into_iter().enumerate() { + ConfigSlotRow { + key: "{slot.key}", + slot, + depth, + index, + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_record_editor_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_record_editor_stories.rs new file mode 100644 index 000000000..7b20ebb89 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_record_editor_stories.rs @@ -0,0 +1,40 @@ +//! Stories for recursive slot record editors. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::node::node_story_fixtures::{asset_slots_fixture, config_record_fixture}; +use crate::app::node::{ConfigSlotRow, SlotRecordEditor}; + +#[story( + description = "A record editor with scalar fields and one collapsed top-level nested record." +)] +pub(crate) fn gallery() -> Element { + rsx! { + SlotRecordEditor { record: config_record_fixture() } + } +} + +#[story(description = "The same record body rendered as an already-open nested record.")] +pub(crate) fn nested_record() -> Element { + rsx! { + SlotRecordEditor { + record: config_record_fixture(), + depth: 1, + } + } +} + +#[story(description = "An expanded asset slot with an editor-like GLSL preview.")] +pub(crate) fn asset_editor() -> Element { + let asset = asset_slots_fixture().remove(0); + + rsx! { + ConfigSlotRow { + slot: asset, + depth: 0, + index: 0, + initially_expanded: Some(true), + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_shape_display.rs b/lp-app/lpa-studio-web/src/app/node/slot_shape_display.rs new file mode 100644 index 000000000..ff6df5c62 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_shape_display.rs @@ -0,0 +1,225 @@ +//! Shared presentation for Studio slot shape metadata. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotShape, UiSlotShapeField}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow( + dead_code, + reason = "all shape display modes are exercised by story builds and future callers" +)] +pub(crate) enum SlotShapeDisplayMode { + Compact, + CompactFriendly, + Verbose, +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn SlotShapeDisplay(shape: UiSlotShape, mode: SlotShapeDisplayMode) -> Element { + match mode { + SlotShapeDisplayMode::Compact | SlotShapeDisplayMode::CompactFriendly => rsx! { + span { class: compact_shape_class(mode), + code { class: "tw:font-mono tw:font-bold tw:text-heading", "{shape_label(&shape)}" } + if let Some(detail) = compact_shape_detail(&shape, mode) { + span { class: "tw:text-subtle-foreground tw:break-words", "{detail}" } + } + } + }, + SlotShapeDisplayMode::Verbose => { + let hint = shape_hint(&shape); + let fields = record_fields(&shape); + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-1", + span { class: "tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5", + code { class: "tw:font-mono tw:text-xs tw:font-bold tw:text-heading", "{shape_label(&shape)}" } + if let Some(hint) = hint { + span { class: "tw:text-xs tw:text-subtle-foreground tw:break-words", "{hint}" } + } + } + if !fields.is_empty() { + div { class: "tw:grid tw:min-w-0 tw:gap-0.5 tw:border-l tw:border-border-muted tw:pl-2", + for field in fields { + ShapeFieldSummary { field: field.clone() } + } + } + } + } + } + } + } +} + +pub(crate) fn legacy_shape_from_parts(value: &str, detail: Option<&str>) -> UiSlotShape { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "string" | "text" => UiSlotShape::Text, + "i32" | "int32" => UiSlotShape::Int32, + "u32" | "uint32" => UiSlotShape::UInt32, + "f32" | "float32" => UiSlotShape::Float32, + "bool" | "boolean" => UiSlotShape::Bool, + "vec2" => UiSlotShape::Vec2, + "vec3" => UiSlotShape::Vec3, + "vec4" => UiSlotShape::Vec4, + "ivec2" => UiSlotShape::IVec2, + "ivec3" => UiSlotShape::IVec3, + "ivec4" => UiSlotShape::IVec4, + "uvec2" => UiSlotShape::UVec2, + "uvec3" => UiSlotShape::UVec3, + "uvec4" => UiSlotShape::UVec4, + "bvec2" => UiSlotShape::BVec2, + "bvec3" => UiSlotShape::BVec3, + "bvec4" => UiSlotShape::BVec4, + "mat2x2" => UiSlotShape::Mat2x2, + "mat3x3" => UiSlotShape::Mat3x3, + "mat4x4" => UiSlotShape::Mat4x4, + "array" => UiSlotShape::Array, + "enum" => UiSlotShape::Enum, + "resource" => UiSlotShape::Resource, + "record" => UiSlotShape::Record(Vec::new()), + "empty" => UiSlotShape::Empty, + "produced value" => UiSlotShape::ProducedValue, + _ if normalized.ends_with("product") => UiSlotShape::Product(value.to_string()), + _ if normalized.ends_with("asset") => UiSlotShape::Asset(value.to_string()), + _ => detail + .map(|detail| UiSlotShape::Product(format!("{value} {detail}"))) + .unwrap_or_else(|| UiSlotShape::Product(value.to_string())), + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ShapeFieldSummary(field: UiSlotShapeField) -> Element { + let shape = field.shape.clone(); + let detail = shape_inline(&shape, 0); + + rsx! { + p { class: "tw:m-0 tw:flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:text-xs tw:leading-snug", + span { class: "tw:font-bold tw:text-subtle-foreground tw:break-words", "{field.label}:" } + span { class: "tw:text-muted-foreground tw:break-words", "{detail}" } + } + } +} + +fn compact_shape_class(mode: SlotShapeDisplayMode) -> &'static str { + match mode { + SlotShapeDisplayMode::Compact => { + "tw:inline-flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5" + } + SlotShapeDisplayMode::CompactFriendly | SlotShapeDisplayMode::Verbose => { + "tw:inline-flex tw:min-w-0 tw:flex-wrap tw:items-baseline tw:gap-x-1.5 tw:gap-y-0.5" + } + } +} + +fn compact_shape_detail(shape: &UiSlotShape, mode: SlotShapeDisplayMode) -> Option { + match shape { + UiSlotShape::Record(fields) if !fields.is_empty() => Some(record_inline(fields, 0)), + UiSlotShape::Record(fields) => Some(field_count_label(fields.len())), + _ if mode == SlotShapeDisplayMode::CompactFriendly => shape_hint(shape), + _ => None, + } +} + +fn shape_label(shape: &UiSlotShape) -> String { + match shape { + UiSlotShape::Empty => "Empty".to_string(), + UiSlotShape::Text => "Text".to_string(), + UiSlotShape::Int32 => "Int32".to_string(), + UiSlotShape::UInt32 => "UInt32".to_string(), + UiSlotShape::Float32 => "Float32".to_string(), + UiSlotShape::Bool => "Bool".to_string(), + UiSlotShape::Vec2 => "Vec2".to_string(), + UiSlotShape::Vec3 => "Vec3".to_string(), + UiSlotShape::Vec4 => "Vec4".to_string(), + UiSlotShape::IVec2 => "IVec2".to_string(), + UiSlotShape::IVec3 => "IVec3".to_string(), + UiSlotShape::IVec4 => "IVec4".to_string(), + UiSlotShape::UVec2 => "UVec2".to_string(), + UiSlotShape::UVec3 => "UVec3".to_string(), + UiSlotShape::UVec4 => "UVec4".to_string(), + UiSlotShape::BVec2 => "BVec2".to_string(), + UiSlotShape::BVec3 => "BVec3".to_string(), + UiSlotShape::BVec4 => "BVec4".to_string(), + UiSlotShape::Mat2x2 => "Mat2x2".to_string(), + UiSlotShape::Mat3x3 => "Mat3x3".to_string(), + UiSlotShape::Mat4x4 => "Mat4x4".to_string(), + UiSlotShape::Array => "Array".to_string(), + UiSlotShape::Enum => "Enum".to_string(), + UiSlotShape::Resource => "Resource".to_string(), + UiSlotShape::Record(_) => "Record".to_string(), + UiSlotShape::Asset(label) | UiSlotShape::Product(label) => label.clone(), + UiSlotShape::ProducedValue => "Produced value".to_string(), + } +} + +fn shape_hint(shape: &UiSlotShape) -> Option { + match shape { + UiSlotShape::Empty => Some("no authored value".to_string()), + UiSlotShape::Text => Some("text or resource reference".to_string()), + UiSlotShape::Int32 => Some("signed whole number, -2.1B to 2.1B".to_string()), + UiSlotShape::UInt32 => Some("whole number, 0 to 4.29B".to_string()), + UiSlotShape::Float32 => Some("32-bit decimal value".to_string()), + UiSlotShape::Bool => Some("true or false".to_string()), + UiSlotShape::Vec2 => Some("two Float32 values".to_string()), + UiSlotShape::Vec3 => Some("three Float32 values".to_string()), + UiSlotShape::Vec4 => Some("four Float32 values".to_string()), + UiSlotShape::IVec2 => Some("two Int32 values".to_string()), + UiSlotShape::IVec3 => Some("three Int32 values".to_string()), + UiSlotShape::IVec4 => Some("four Int32 values".to_string()), + UiSlotShape::UVec2 => Some("two UInt32 values".to_string()), + UiSlotShape::UVec3 => Some("three UInt32 values".to_string()), + UiSlotShape::UVec4 => Some("four UInt32 values".to_string()), + UiSlotShape::BVec2 => Some("two Bool values".to_string()), + UiSlotShape::BVec3 => Some("three Bool values".to_string()), + UiSlotShape::BVec4 => Some("four Bool values".to_string()), + UiSlotShape::Mat2x2 => Some("2 by 2 Float32 matrix".to_string()), + UiSlotShape::Mat3x3 => Some("3 by 3 Float32 matrix".to_string()), + UiSlotShape::Mat4x4 => Some("4 by 4 Float32 matrix".to_string()), + UiSlotShape::Array => Some("array or list payload".to_string()), + UiSlotShape::Enum => Some("active variant payload".to_string()), + UiSlotShape::Resource => Some("store-backed resource reference".to_string()), + UiSlotShape::Record(fields) => Some(field_count_label(fields.len())), + UiSlotShape::Asset(_) => Some("file-backed authored content".to_string()), + UiSlotShape::Product(_) => Some("node output product".to_string()), + UiSlotShape::ProducedValue => Some("runtime output value".to_string()), + } +} + +fn shape_inline(shape: &UiSlotShape, depth: usize) -> String { + match shape { + UiSlotShape::Record(fields) if depth < 2 && !fields.is_empty() => { + format!("Record {}", record_inline(fields, depth + 1)) + } + _ => shape_label(shape), + } +} + +fn record_inline(fields: &[UiSlotShapeField], depth: usize) -> String { + if fields.is_empty() { + return field_count_label(0); + } + + let entries = fields + .iter() + .map(|field| format!("{}: {}", field.label, shape_inline(&field.shape, depth + 1))) + .collect::>() + .join(", "); + format!("{{ {entries} }}") +} + +fn field_count_label(count: usize) -> String { + if count == 1 { + "1 field".to_string() + } else { + format!("{count} fields") + } +} + +fn record_fields(shape: &UiSlotShape) -> Vec { + match shape { + UiSlotShape::Record(fields) => fields.clone(), + _ => Vec::new(), + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_shape_display_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_shape_display_stories.rs new file mode 100644 index 000000000..bda4cab99 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_shape_display_stories.rs @@ -0,0 +1,71 @@ +//! Stories for slot shape presentation. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotShape, UiSlotShapeField}; +use lpa_studio_web_story_macros::story; + +use crate::app::node::{SlotShapeDisplay, SlotShapeDisplayMode}; + +#[story(description = "Compact and friendly renderings for primitive slot shapes.")] +pub(crate) fn gallery() -> Element { + let shapes = vec![ + UiSlotShape::Int32, + UiSlotShape::UInt32, + UiSlotShape::Float32, + UiSlotShape::Bool, + UiSlotShape::Text, + UiSlotShape::Vec2, + UiSlotShape::Vec3, + ]; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:max-w-[520px] tw:gap-2", + for shape in shapes { + div { class: "tw:grid tw:min-w-0 tw:grid-cols-[minmax(80px,0.35fr)_minmax(0,1fr)] tw:items-baseline tw:gap-3 tw:border-b tw:border-border-muted tw:pb-1.5", + SlotShapeDisplay { + shape: shape.clone(), + mode: SlotShapeDisplayMode::Compact, + } + SlotShapeDisplay { + shape, + mode: SlotShapeDisplayMode::CompactFriendly, + } + } + } + } + } +} + +#[story(description = "A record shape with compact recursive field types.")] +pub(crate) fn record_shape() -> Element { + rsx! { + SlotShapeDisplay { + shape: transform_shape(), + mode: SlotShapeDisplayMode::CompactFriendly, + } + } +} + +#[story(description = "A verbose record shape with nested fields.")] +pub(crate) fn verbose_record() -> Element { + rsx! { + SlotShapeDisplay { + shape: transform_shape(), + mode: SlotShapeDisplayMode::Verbose, + } + } +} + +fn transform_shape() -> UiSlotShape { + UiSlotShape::Record(vec![ + UiSlotShapeField::new("Origin", UiSlotShape::Vec2), + UiSlotShapeField::new("Scale", UiSlotShape::Vec2), + UiSlotShapeField::new( + "Envelope", + UiSlotShape::Record(vec![ + UiSlotShapeField::new("Fade after", UiSlotShape::Float32), + UiSlotShapeField::new("Trigger", UiSlotShape::Bool), + ]), + ), + ]) +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_unit_display.rs b/lp-app/lpa-studio-web/src/app/node/slot_unit_display.rs new file mode 100644 index 000000000..deeafb3c4 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_unit_display.rs @@ -0,0 +1,58 @@ +//! Shared presentation for Studio slot units. + +use dioxus::prelude::*; +use lpa_studio_core::UiSlotUnit; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow( + dead_code, + reason = "short unit mode is exercised by story builds and compact callers" +)] +pub(crate) enum SlotUnitDisplayMode { + Short, + Long, +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn SlotUnitDisplay(unit: UiSlotUnit, mode: SlotUnitDisplayMode) -> Element { + let label = match mode { + SlotUnitDisplayMode::Short => unit.short, + SlotUnitDisplayMode::Long => unit.long, + }; + + rsx! { + span { class: "tw:text-subtle-foreground tw:break-words", "{label}" } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn SlotUnitSuffix( + unit: Option, + #[props(default = false)] reserve: bool, +) -> Element { + let class = unit_suffix_class(unit.is_some(), reserve); + let label = unit + .map(|unit| unit.short) + .unwrap_or_else(|| "xx".to_string()); + + rsx! { + span { class, "{label}" } + } +} + +fn unit_suffix_class(visible: bool, reserve: bool) -> &'static str { + match (visible, reserve) { + (true, true) => { + "tw:inline-flex tw:min-w-[2ch] tw:justify-start tw:text-xs tw:font-bold tw:text-subtle-foreground" + } + (true, false) => { + "tw:inline-flex tw:justify-start tw:text-xs tw:font-bold tw:text-subtle-foreground" + } + (false, true) => { + "tw:invisible tw:inline-flex tw:min-w-[2ch] tw:justify-start tw:text-xs tw:font-bold" + } + (false, false) => "tw:hidden", + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_unit_display_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_unit_display_stories.rs new file mode 100644 index 000000000..57fd17a3b --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_unit_display_stories.rs @@ -0,0 +1,57 @@ +//! Stories for slot unit presentation. + +use dioxus::prelude::*; +use lpa_studio_core::UiSlotUnit; +use lpa_studio_web_story_macros::story; + +use crate::app::node::{SlotUnitDisplay, SlotUnitDisplayMode, SlotUnitSuffix}; + +#[story(description = "Short and long renderings for known slot units.")] +pub(crate) fn gallery() -> Element { + let units = vec![ + UiSlotUnit::seconds(), + UiSlotUnit::milliseconds(), + UiSlotUnit::hertz(), + UiSlotUnit::radians(), + UiSlotUnit::percent(), + ]; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:max-w-[360px] tw:gap-2", + for unit in units { + div { class: "tw:grid tw:min-w-0 tw:grid-cols-[80px_minmax(0,1fr)] tw:items-baseline tw:gap-3 tw:border-b tw:border-border-muted tw:pb-1.5", + SlotUnitDisplay { + unit: unit.clone(), + mode: SlotUnitDisplayMode::Short, + } + SlotUnitDisplay { + unit, + mode: SlotUnitDisplayMode::Long, + } + } + } + } + } +} + +#[story(description = "Reserved unit suffix spacing used inside numeric fields.")] +pub(crate) fn suffix_spacing() -> Element { + rsx! { + div { class: "tw:flex tw:flex-wrap tw:items-center tw:gap-2", + span { class: "tw:inline-flex tw:min-h-7 tw:items-baseline tw:justify-end tw:gap-1 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-muted-foreground", + span { class: "tw:font-mono", "3.33" } + SlotUnitSuffix { + unit: Some(UiSlotUnit::seconds()), + reserve: true, + } + } + span { class: "tw:inline-flex tw:min-h-7 tw:items-baseline tw:justify-end tw:gap-1 tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:px-2 tw:py-1 tw:text-sm tw:font-medium tw:text-muted-foreground", + span { class: "tw:font-mono", "128" } + SlotUnitSuffix { + unit: None, + reserve: true, + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_value_editor.rs b/lp-app/lpa-studio-web/src/app/node/slot_value_editor.rs new file mode 100644 index 000000000..d3b63fa70 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_value_editor.rs @@ -0,0 +1,116 @@ +//! Typed value dispatcher for config slot field components. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotEditorHint, UiSlotFieldState, UiSlotValue, UiSlotValueKind}; + +use crate::app::node::{ + BoolSlotField, DropdownSlotField, FloatSlotField, IntSlotField, StringSlotField, UIntSlotField, + Vec2SlotField, Vec3SlotField, XySlotField, +}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn SlotValueEditor(value: UiSlotValue, state: UiSlotFieldState) -> Element { + let unit = value.display_unit(); + + match value.editor.clone() { + UiSlotEditorHint::Dropdown(options) => rsx! { + DropdownSlotField { + value: slot_value_key(&value), + options, + state, + } + }, + UiSlotEditorHint::Xy => match value.kind.clone() { + UiSlotValueKind::Vec2(value) => rsx! { + XySlotField { value, state } + }, + _ => fallback_value(value, state), + }, + UiSlotEditorHint::Text + | UiSlotEditorHint::Number { .. } + | UiSlotEditorHint::Slider { .. } + | UiSlotEditorHint::Auto => match value.kind.clone() { + UiSlotValueKind::String(value) => rsx! { + StringSlotField { value, state } + }, + UiSlotValueKind::I32(value) => rsx! { + IntSlotField { value, state, unit } + }, + UiSlotValueKind::U32(value) => rsx! { + UIntSlotField { value, state, unit } + }, + UiSlotValueKind::F32(value) => rsx! { + FloatSlotField { value, state, unit } + }, + UiSlotValueKind::Bool(value) => rsx! { + BoolSlotField { value, state } + }, + UiSlotValueKind::Vec2(value) => rsx! { + Vec2SlotField { value, state } + }, + UiSlotValueKind::Vec3(value) => rsx! { + Vec3SlotField { value, state } + }, + UiSlotValueKind::Unset + | UiSlotValueKind::Vec4(_) + | UiSlotValueKind::IVec2(_) + | UiSlotValueKind::IVec3(_) + | UiSlotValueKind::IVec4(_) + | UiSlotValueKind::UVec2(_) + | UiSlotValueKind::UVec3(_) + | UiSlotValueKind::UVec4(_) + | UiSlotValueKind::BVec2(_) + | UiSlotValueKind::BVec3(_) + | UiSlotValueKind::BVec4(_) + | UiSlotValueKind::Mat2x2(_) + | UiSlotValueKind::Mat3x3(_) + | UiSlotValueKind::Mat4x4(_) + | UiSlotValueKind::Array(_) + | UiSlotValueKind::Struct { .. } + | UiSlotValueKind::Enum { .. } + | UiSlotValueKind::Resource(_) + | UiSlotValueKind::Product(_) => fallback_value(value, state), + }, + } +} + +fn fallback_value(value: UiSlotValue, state: UiSlotFieldState) -> Element { + rsx! { + StringSlotField { + value: value.display, + state, + } + } +} + +fn slot_value_key(value: &UiSlotValue) -> String { + match &value.kind { + UiSlotValueKind::String(value) => value.clone(), + UiSlotValueKind::I32(value) => value.to_string(), + UiSlotValueKind::U32(value) => value.to_string(), + UiSlotValueKind::F32(value) => value.to_string(), + UiSlotValueKind::Bool(value) => value.to_string(), + UiSlotValueKind::Unset + | UiSlotValueKind::Vec2(_) + | UiSlotValueKind::Vec3(_) + | UiSlotValueKind::Vec4(_) + | UiSlotValueKind::IVec2(_) + | UiSlotValueKind::IVec3(_) + | UiSlotValueKind::IVec4(_) + | UiSlotValueKind::UVec2(_) + | UiSlotValueKind::UVec3(_) + | UiSlotValueKind::UVec4(_) + | UiSlotValueKind::BVec2(_) + | UiSlotValueKind::BVec3(_) + | UiSlotValueKind::BVec4(_) + | UiSlotValueKind::Mat2x2(_) + | UiSlotValueKind::Mat3x3(_) + | UiSlotValueKind::Mat4x4(_) + | UiSlotValueKind::Array(_) + | UiSlotValueKind::Struct { .. } + | UiSlotValueKind::Enum { .. } + | UiSlotValueKind::Resource(_) + | UiSlotValueKind::Product(_) => value.display.clone(), + } +} diff --git a/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs b/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs new file mode 100644 index 000000000..e5889d7bc --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/node/slot_value_editor_stories.rs @@ -0,0 +1,136 @@ +//! Stories for slot value editor field variants. + +use dioxus::prelude::*; +use lpa_studio_core::{UiSlotEditorHint, UiSlotFieldState, UiSlotUnit, UiSlotValue}; +use lpa_studio_web_story_macros::story; + +use crate::app::node::SlotValueEditor; +use crate::app::node::node_story_fixtures::slot_value_variants_fixture; + +#[story(description = "Slot value editor dispatch across the M1 value types.")] +pub(crate) fn gallery() -> Element { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:max-w-[420px] tw:gap-2", + for value in slot_value_variants_fixture() { + SlotValueEditor { + value, + state: UiSlotFieldState::editable(), + } + } + } + } +} + +#[story(description = "String slot field.")] +pub(crate) fn string_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::string("./idle.glsl").with_editor(UiSlotEditorHint::Text), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Signed integer slot field.")] +pub(crate) fn int_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::i32(-4), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Unsigned integer slot field.")] +pub(crate) fn uint_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::u32(128), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Floating point slot field.")] +pub(crate) fn float_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::f32(0.35).with_unit(UiSlotUnit::seconds()), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Floating point slot field with a slider editor hint.")] +pub(crate) fn slider_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::f32(0.72).with_editor(UiSlotEditorHint::slider(0.0, 1.0)), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Boolean slot field.")] +pub(crate) fn bool_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::bool(true), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Two-component vector slot field.")] +pub(crate) fn vec2_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::vec2([0.42, 0.58]), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Three-component vector slot field.")] +pub(crate) fn vec3_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::vec3([1.0, 0.42, 0.2]), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Dropdown slot field for enum-like values.")] +pub(crate) fn dropdown_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::string("blast").with_editor(UiSlotEditorHint::dropdown([ + ("idle", "Idle"), + ("blast", "Blast"), + ("strobe", "Strobe"), + ])), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "A minimal XY slot field for Vec2 values.")] +pub(crate) fn xy_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::vec2([0.42, 0.58]).with_editor(UiSlotEditorHint::Xy), + state: UiSlotFieldState::editable(), + } + } +} + +#[story(description = "Invalid value field state.")] +pub(crate) fn invalid_field() -> Element { + rsx! { + SlotValueEditor { + value: UiSlotValue::f32(-1.0), + state: UiSlotFieldState::editable().with_invalid("value must be non-negative"), + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/project/editor_fields_stories.rs b/lp-app/lpa-studio-web/src/app/project/editor_fields_stories.rs new file mode 100644 index 000000000..291af2dca --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/project/editor_fields_stories.rs @@ -0,0 +1,11 @@ +//! Stories for project editor field primitives in context. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::app::story_fixtures::editor_primitives_story; + +#[story] +pub(crate) fn editor_fields() -> Element { + editor_primitives_story() +} diff --git a/lp-app/lpa-studio-web/src/app/project/mod.rs b/lp-app/lpa-studio-web/src/app/project/mod.rs new file mode 100644 index 000000000..d6f7c9b11 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/project/mod.rs @@ -0,0 +1,7 @@ +#[cfg(feature = "stories")] +pub(crate) mod editor_fields_stories; +pub mod project_workspace; +#[cfg(feature = "stories")] +pub(crate) mod project_workspace_stories; + +pub use project_workspace::{ProjectNodeWorkspace, ProjectSidebar, ProjectWorkspace}; diff --git a/lp-app/lpa-studio-web/src/app/project/project_workspace.rs b/lp-app/lpa-studio-web/src/app/project/project_workspace.rs new file mode 100644 index 000000000..e1bde8ada --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/project/project_workspace.rs @@ -0,0 +1,165 @@ +use dioxus::prelude::*; +use lpa_studio_core::{ProjectEditorView, ProjectNodeStatusTone, ProjectNodeTreeItem, UiAction}; + +use crate::app::node::NodePane; +use crate::core::MetricGrid; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProjectWorkspace( + view: ProjectEditorView, + running: bool, + on_action: EventHandler, +) -> Element { + rsx! { + div { class: "tw:grid tw:grid-cols-[minmax(170px,240px)_minmax(0,1fr)] tw:gap-3.5 tw:max-[640px]:grid-cols-1", + ProjectSidebar { + view: view.clone(), + running, + on_action, + } + ProjectNodeWorkspace { view, on_action } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProjectSidebar( + view: ProjectEditorView, + running: bool, + on_action: EventHandler, +) -> Element { + let sync_issue = view.sync.issue; + let stats = view.stats; + let roots = view.tree.roots; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:content-start tw:gap-3.5", + div { class: "tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-4", + h3 { class: "tw:m-0 tw:mb-3 tw:text-xs tw:font-bold tw:uppercase tw:text-heading", "Node tree" } + if let Some(issue) = sync_issue.as_ref() { + div { class: "tw:mb-3 tw:grid tw:gap-1 tw:rounded-sm tw:border tw:border-status-error-border tw:bg-status-error-bg tw:p-3 tw:text-sm tw:text-status-error-foreground", + strong { "{issue.message}" } + if let Some(detail) = issue.detail.as_ref() { + p { class: "tw:m-0 tw:text-xs tw:text-status-error-foreground", "{detail}" } + } + } + } + if roots.is_empty() { + p { class: "tw:m-0 tw:text-sm tw:text-subtle-foreground", "Project sync has not returned nodes yet." } + } else { + ol { class: "tw:m-0 tw:grid tw:list-none tw:gap-1 tw:p-0", + for item in roots { + ProjectNodeTreeItemView { + key: "{item.node_id}", + item, + depth: 0, + running, + on_action, + } + } + } + } + } + div { class: "tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-4", + h3 { class: "tw:m-0 tw:mb-3 tw:text-xs tw:font-bold tw:uppercase tw:text-heading", "Project stats" } + MetricGrid { metrics: stats } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProjectNodeWorkspace(view: ProjectEditorView, on_action: EventHandler) -> Element { + let nodes = view.nodes; + + rsx! { + section { class: "tw:grid tw:min-w-0 tw:content-start tw:gap-3.5", + if nodes.is_empty() { + div { class: "tw:grid tw:min-w-0 tw:gap-2 tw:rounded-md tw:border tw:border-border-subtle tw:bg-card-subtle tw:p-4", + h3 { class: "tw:m-0 tw:text-base tw:text-strong-foreground", "Waiting for project data" } + p { class: "tw:m-0 tw:text-sm tw:text-muted-foreground", "Studio will show node bodies here once the project mirror has synced." } + } + } else { + for node in nodes { + NodePane { + key: "{node.node_id}", + view: node, + on_action, + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProjectNodeTreeItemView( + item: ProjectNodeTreeItem, + depth: usize, + running: bool, + on_action: EventHandler, +) -> Element { + let action = item.action.clone(); + let children = item.children; + let class = if item.focused { + "tw:grid tw:w-full tw:grid-cols-[minmax(0,1fr)_auto_auto] tw:items-center tw:gap-2 tw:rounded-sm tw:border tw:border-accent-border tw:bg-status-good-bg tw:px-2 tw:py-1.5 tw:text-left" + } else { + "tw:grid tw:w-full tw:grid-cols-[minmax(0,1fr)_auto_auto] tw:items-center tw:gap-2 tw:rounded-sm tw:border tw:border-transparent tw:bg-transparent tw:px-2 tw:py-1.5 tw:text-left tw:hover:bg-card-muted" + }; + let indent = depth * 14; + let status_class = node_status_class(item.status.tone); + let status_label = item.status.label; + let detail = item.status.detail; + + rsx! { + li { + button { + class, + r#type: "button", + disabled: running, + style: "padding-left: {indent}px;", + onclick: move |_| on_action.call(action.clone()), + span { class: "tw:min-w-0 tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap tw:text-sm tw:text-soft-foreground", "{item.label}" } + span { class: "tw:text-xs tw:text-subtle-foreground", "{item.kind}" } + span { class: "{status_class}", "{status_label}" } + } + if let Some(detail) = detail.as_ref() { + p { class: "tw:m-0 tw:pl-2 tw:text-xs tw:text-subtle-foreground", "{detail}" } + } + if !children.is_empty() { + ol { class: "tw:m-0 tw:grid tw:list-none tw:gap-1 tw:p-0", + for child in children { + ProjectNodeTreeItemView { + key: "{child.node_id}", + item: child, + depth: depth + 1, + running, + on_action, + } + } + } + } + } + } +} + +fn node_status_class(tone: ProjectNodeStatusTone) -> &'static str { + match tone { + ProjectNodeStatusTone::Neutral => { + "tw:rounded-pill tw:border tw:border-status-neutral-border tw:bg-status-neutral-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:text-status-neutral-foreground" + } + ProjectNodeStatusTone::Good => { + "tw:rounded-pill tw:border tw:border-status-good-border tw:bg-status-good-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:text-status-good-foreground" + } + ProjectNodeStatusTone::Warning => { + "tw:rounded-pill tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:text-status-warning-foreground" + } + ProjectNodeStatusTone::Error => { + "tw:rounded-pill tw:border tw:border-status-error-border tw:bg-status-error-bg tw:px-2 tw:py-1 tw:text-xs tw:font-bold tw:text-status-error-foreground" + } + } +} diff --git a/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs new file mode 100644 index 000000000..24c753481 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/project/project_workspace_stories.rs @@ -0,0 +1,81 @@ +//! Stories for loaded-project workspace states. + +use dioxus::prelude::*; +use lpa_studio_core::UiLogLevel; +use lpa_studio_web_story_macros::story; + +use crate::app::story_fixtures::{ + device_project_empty_view, device_project_selection_view, project_ready_state, + project_ready_view, project_sync_failed_view, project_syncing_view, project_view, shell_story, + studio_log, +}; +use crate::core::PaneView; + +#[story] +pub(crate) fn project_pane() -> Element { + let view = project_view(project_ready_state(), true); + rsx! { + PaneView { + view, + primary: false, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn device_project_empty() -> Element { + let view = device_project_empty_view(); + rsx! { + PaneView { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn device_project_selection() -> Element { + let view = device_project_selection_view(); + rsx! { + PaneView { + view, + primary: true, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn project_ready() -> Element { + shell_story( + project_ready_view(), + false, + vec![studio_log(UiLogLevel::Info, "Demo project loaded")], + ) +} + +#[story] +pub(crate) fn project_syncing() -> Element { + shell_story( + project_syncing_view(), + true, + vec![studio_log(UiLogLevel::Info, "Reading project shapes")], + ) +} + +#[story] +pub(crate) fn project_sync_failed() -> Element { + shell_story( + project_sync_failed_view(), + false, + vec![studio_log( + UiLogLevel::Error, + "project sync failed: protocol timeout", + )], + ) +} diff --git a/lp-app/lpa-studio-web/src/app/story_fixtures.rs b/lp-app/lpa-studio-web/src/app/story_fixtures.rs new file mode 100644 index 000000000..3fcc90474 --- /dev/null +++ b/lp-app/lpa-studio-web/src/app/story_fixtures.rs @@ -0,0 +1,1258 @@ +//! Studio story fixtures. +//! +//! This module is compiled only for storybook builds. It keeps broad +//! shell/device/project fixture builders in one place while story entrypoints +//! live next to their component families. + +use crate::app::{PaneFrame, StudioShell}; +use crate::base::{FieldRow, TabItem, Tabs}; +use crate::core::MetricGrid; +use dioxus::prelude::*; +use lpa_studio_core::core::view::activity_view::{UiActivityStep, UiActivityStepState}; +use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; +use lpa_studio_core::{ + ControllerId, DeviceController, DeviceOp, LinkEndpointId, LinkProviderKind, ProjectController, + ProjectEditorOp, ProjectEditorView, ProjectInventorySummary, ProjectNodeStatusTone, + ProjectNodeStatusView, ProjectNodeTreeItem, ProjectNodeTreeView, ProjectOp, + ProjectRuntimeSummary, ProjectState, ProjectSyncPhase, ProjectSyncSummary, UiAction, + UiActivityView, UiAssetEditorKind, UiBindingEndpoint, UiConfigSlot, UiIssue, UiLogEntry, + UiLogLevel, UiMetric, UiNodeChild, UiNodeHeader, UiNodeSection, UiNodeTab, UiNodeView, + UiPaneView, UiProducedProduct, UiProducedValue, UiProgress, UiSlotAsset, UiSlotSourceState, + UiSlotValue, UiStatus, UiStepsView, UiStudioView, UiTerminalLine, UiViewContent, +}; + +pub(crate) fn shell_story( + mut view: UiStudioView, + running: bool, + story_logs: Vec, +) -> Element { + view.logs.extend(story_logs); + rsx! { + StudioShell { + view, + running, + on_action: move |_| {}, + } + } +} + +pub(crate) fn editor_primitives_story() -> Element { + rsx! { + PaneFrame { + title: "Node inspector", + primary: true, + status: Some(UiStatus::good("Overlay active")), + div { class: "ux-editor-inspector", + FieldRow { + label: "Name", + value: "Orbit wash", + changed: false, + detail: None::, + } + FieldRow { + label: "Brightness", + value: "0.72", + changed: true, + detail: Some("overlay value, not committed".to_string()), + } + FieldRow { + label: "Shader", + value: "assets/shaders/orbit.glsl", + changed: false, + detail: Some("resource reference".to_string()), + } + MetricGrid { + metrics: vec![ + UiMetric::new("Inputs", 5), + UiMetric::new("Outputs", 2), + UiMetric::new("Bindings", 1), + UiMetric::new("Preview", "live"), + ], + } + Tabs { + tabs: vec![ + TabItem::new("Values", "Slot values", "Direct values shown from the current overlay."), + TabItem::new("Changes", "Pending changes", "Brightness will be committed with the project overlay."), + TabItem::new("Assets", "Node assets", "Shader and SVG assets will open in editor-specific panes."), + ], + initial: 0, + } + } + } + } +} + +pub(crate) fn editor_shell_story() -> Element { + rsx! { + div { class: "ux-editor-shell", + div { class: "ux-editor-desktop-tree", + NodeTreePane {} + } + div { class: "ux-editor-workspace", + NodeWorkspacePane {} + } + div { class: "ux-editor-side", + DeviceSidePane {} + ConsoleSidePane {} + } + div { class: "ux-editor-compact-side", + SecondaryTabsPane {} + } + div { class: "ux-editor-mobile", + MobileEditorTabsPane {} + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn NodeTreePane() -> Element { + rsx! { + PaneFrame { + title: "Node tree", + primary: false, + status: Some(UiStatus::neutral("Project")), + ol { class: "ux-node-tree", + li { class: "ux-node-tree-item ux-node-tree-item-active", "Scene root" } + li { class: "ux-node-tree-item ux-node-tree-depth-1", "Group: wash" } + li { class: "ux-node-tree-item ux-node-tree-depth-2", "Shader: orbit" } + li { class: "ux-node-tree-item ux-node-tree-depth-2", "Palette: sunrise" } + li { class: "ux-node-tree-item ux-node-tree-depth-1", "Output: strip A" } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn NodeWorkspacePane() -> Element { + rsx! { + PaneFrame { + title: "Shader: orbit", + primary: true, + status: Some(UiStatus::warning("2 changes")), + div { class: "ux-node-workspace", + div { class: "ux-node-preview", + div { class: "ux-node-preview-bars", + span {} + span {} + span {} + span {} + span {} + } + } + div { class: "ux-node-fields", + FieldRow { + label: "Enabled", + value: "true", + changed: false, + detail: None::, + } + FieldRow { + label: "Brightness", + value: "0.72", + changed: true, + detail: Some("overlay".to_string()), + } + FieldRow { + label: "Speed", + value: "bind /bus/audio/energy", + changed: true, + detail: Some("binding".to_string()), + } + FieldRow { + label: "Shader source", + value: "assets/shaders/orbit.glsl", + changed: false, + detail: Some("asset".to_string()), + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn DeviceSidePane() -> Element { + rsx! { + PaneFrame { + title: "Device", + primary: false, + status: Some(UiStatus::good("Connected")), + MetricGrid { + metrics: vec![ + UiMetric::new("Runtime", "ESP32-C6"), + UiMetric::new("Project", "studio-demo"), + UiMetric::new("FPS", "936"), + UiMetric::new("Memory", "207k free"), + ], + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn ConsoleSidePane() -> Element { + rsx! { + PaneFrame { + title: "Console", + primary: false, + status: None::, + ol { class: "ux-terminal ux-editor-terminal", + li { "[lp-server] heartbeat frame=936" } + li { "[studio] overlay has 2 pending changes" } + li { "[fw-esp32] shader backend: native JIT" } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn SecondaryTabsPane() -> Element { + rsx! { + PaneFrame { + title: "Project side panel", + primary: false, + status: Some(UiStatus::good("Connected")), + Tabs { + tabs: vec![ + TabItem::new("Tree", "Node tree", "Scene root / Group wash / Shader orbit / Output strip A"), + TabItem::new("Device", "Device", "ESP32-C6 connected, studio-demo loaded, 936 fps."), + TabItem::new("Bus", "Bus", "audio.energy, tempo.bpm, radio.peer_count"), + TabItem::new("Console", "Console", "[lp-server] heartbeat frame=936"), + ], + initial: 0, + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub(crate) fn MobileEditorTabsPane() -> Element { + rsx! { + PaneFrame { + title: "Project", + primary: true, + status: Some(UiStatus::warning("2 changes")), + Tabs { + tabs: vec![ + TabItem::new("Node", "Shader: orbit", "Brightness 0.72, speed bound to /bus/audio/energy."), + TabItem::new("Tree", "Node tree", "Scene root / Group wash / Shader orbit."), + TabItem::new("Device", "Device", "ESP32-C6 connected, studio-demo loaded."), + TabItem::new("Bus", "Bus", "audio.energy, tempo.bpm, radio.peer_count."), + TabItem::new("Console", "Console", "[fw-esp32] shader backend: native JIT."), + ], + initial: 0, + } + } + } +} + +pub(crate) fn studio_log(level: UiLogLevel, message: impl Into) -> UiLogEntry { + UiLogEntry::new(level, "studio", message) +} + +pub(crate) fn idle_view() -> UiStudioView { + UiStudioView::new(vec![idle_device_view()], Vec::new()) +} + +pub(crate) fn browser_serial_canceled_view() -> UiStudioView { + UiStudioView::new( + vec![idle_device_view()], + vec![studio_log(UiLogLevel::Info, "Port selection canceled")], + ) +} + +pub(crate) fn browser_serial_open_failed_view() -> UiStudioView { + picker_issue_view( + "Failed to open serial port.", + "Failed to execute 'open' on 'SerialPort': Failed to open serial port.", + ) +} + +pub(crate) fn endpoint_view() -> UiStudioView { + UiStudioView::new(vec![endpoint_device_view()], Vec::new()) +} + +pub(crate) fn starting_view() -> UiStudioView { + UiStudioView::new( + vec![starting_device_view()], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-link", + "browser worker session created", + )], + ) +} + +pub(crate) fn simulator_ready_view() -> UiStudioView { + UiStudioView::new( + vec![project_synced_pane_view(), simulator_ready_device_view()], + vec![ + UiLogEntry::new(UiLogLevel::Info, "fw-browser", "ready"), + UiLogEntry::new( + UiLogLevel::Info, + "lpa-link", + "browser worker session owns Worker lifecycle in lpa-link", + ), + UiLogEntry::new(UiLogLevel::Info, "fw-browser", "project loaded"), + ], + ) +} + +pub(crate) fn project_ready_view() -> UiStudioView { + UiStudioView::new( + vec![project_synced_pane_view(), simulator_ready_device_view()], + vec![ + UiLogEntry::new(UiLogLevel::Info, "fw-browser", "project loaded"), + UiLogEntry::new( + UiLogLevel::Debug, + "lp-server", + "heartbeat frame=42 uptime_ms=700", + ), + ], + ) +} + +pub(crate) fn project_syncing_view() -> UiStudioView { + UiStudioView::new( + vec![project_syncing_pane_view(), simulator_ready_device_view()], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-studio-core", + "syncing project", + )], + ) +} + +pub(crate) fn project_sync_failed_view() -> UiStudioView { + UiStudioView::new( + vec![ + project_sync_failed_pane_view(), + simulator_ready_device_view(), + ], + vec![UiLogEntry::new( + UiLogLevel::Error, + "lpa-studio-core", + "project sync failed: protocol timeout", + )], + ) +} + +pub(crate) fn lightplayer_disconnected_view() -> UiStudioView { + UiStudioView::new( + vec![device_view( + UiStatus::good("Simulator connected"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete_with_actions( + browser_worker_metrics(), + vec![disconnect_device_action()], + ), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Active, + UiViewContent::text("Attach Studio to LightPlayer on the connected simulator."), + vec![connect_lightplayer_action()], + ), + ], + vec!["[lpa-studio-core] LightPlayer protocol detached; device session remains open"], + )], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-studio-core", + "LightPlayer protocol detached; device session remains open", + )], + ) +} + +pub(crate) fn provision_ready_view() -> UiStudioView { + UiStudioView::new( + vec![blank_device_view( + UiStatus::warning("Ready to flash"), + UiViewContent::text("No LightPlayer firmware is running on this ESP32."), + false, + )], + vec![UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", + "server protocol is unavailable; firmware flashing is available", + )], + ) +} + +pub(crate) fn browser_serial_blank_firmware_view() -> UiStudioView { + UiStudioView::new( + vec![blank_device_view( + UiStatus::warning("Ready to flash"), + UiViewContent::Activity(blank_firmware_activity()), + false, + )], + vec![ + UiLogEntry::new(UiLogLevel::Info, "fw-esp32", "ESP-ROM:esp32c6-20220919"), + UiLogEntry::new(UiLogLevel::Info, "fw-esp32", "invalid header: 0xffffffff"), + UiLogEntry::new( + UiLogLevel::Warn, + "lpa-studio-core", + "no LightPlayer firmware detected; firmware flashing is available", + ), + ], + ) +} + +pub(crate) fn provisioning_view() -> UiStudioView { + UiStudioView::new( + vec![device_view( + UiStatus::working("Flashing"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Flashing firmware", + UiStepState::Active, + UiViewContent::Activity(provisioning_activity()), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] Writing app image at 0x10000", + "[lpa-link] Progress 42%", + ], + )], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-link", + "Connected to ESP32 bootloader", + )], + ) +} + +pub(crate) fn provision_failed_view() -> UiStudioView { + UiStudioView::new( + vec![device_view( + UiStatus::error("Needs attention"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), + stack_section( + "connect-lightplayer", + "Flashing firmware", + UiStepState::NeedsAttention, + UiViewContent::Issue( + UiIssue::new("firmware flashing failed").with_detail( + "Check the cable, boot mode, and browser serial permission.", + ), + ), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] failed to write firmware image", + ], + )], + vec![UiLogEntry::new( + UiLogLevel::Error, + "lpa-link", + "failed to write firmware image", + )], + ) +} + +pub(crate) fn resetting_to_blank_view() -> UiStudioView { + UiStudioView::new( + vec![device_view( + UiStatus::working("Resetting"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Wiping device", + UiStepState::Active, + UiViewContent::Activity(reset_activity()), + Vec::new(), + ), + ], + vec![ + "[lpa-link] Connected to ESP32 bootloader", + "[lpa-link] Erasing device flash", + ], + )], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-link", + "Erasing device flash", + )], + ) +} + +pub(crate) fn reset_complete_view() -> UiStudioView { + UiStudioView::new( + vec![blank_device_view( + UiStatus::warning("Blank ESP32"), + UiViewContent::text("The device has been erased and can be flashed again."), + true, + )], + vec![UiLogEntry::new( + UiLogLevel::Info, + "lpa-link", + "Chip erase completed successfully", + )], + ) +} + +pub(crate) fn error_view() -> UiStudioView { + picker_issue_view( + "browser worker boot timed out", + "browser worker boot timed out", + ) +} + +pub(crate) fn picker_issue_view(message: &'static str, log_message: &'static str) -> UiStudioView { + UiStudioView::new( + vec![device_view( + UiStatus::error("Needs attention"), + vec![stack_section( + "select-connection", + "Select connection", + UiStepState::NeedsAttention, + UiViewContent::Issue(UiIssue::new(message)), + start_actions(), + )], + Vec::new(), + )], + vec![studio_log(UiLogLevel::Error, log_message)], + ) +} + +pub(crate) fn idle_device_view() -> UiPaneView { + device_view( + UiStatus::neutral("Choose connection"), + vec![stack_section( + "select-connection", + "Select connection", + UiStepState::Active, + UiViewContent::text("Choose how Studio should connect."), + start_actions(), + )], + Vec::new(), + ) +} + +pub(crate) fn endpoint_device_view() -> UiPaneView { + device_view( + UiStatus::working("Connecting"), + vec![ + select_connection_complete("Simulator"), + stack_section( + "connect-device", + "Connect device", + UiStepState::Active, + UiViewContent::text("Choose the device endpoint to open."), + vec![ + device_action(DeviceOp::ConnectEndpoint { + provider_id: LinkProviderKind::BrowserWorker, + endpoint_id: LinkEndpointId::new("browser-worker-worker-1"), + }) + .with_label("Open browser simulator") + .with_summary("Open the browser-local firmware runtime."), + ], + ), + ], + vec!["[lpa-link] Browser worker provider selected"], + ) +} + +pub(crate) fn starting_device_view() -> UiPaneView { + device_view( + UiStatus::working("Connecting"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete(browser_worker_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Active, + UiViewContent::Progress(UiProgress::indeterminate("Opening server protocol")), + Vec::new(), + ), + ], + vec![ + "[lpa-link] browser worker session created", + "[fw-browser] booting firmware runtime", + ], + ) +} + +pub(crate) fn simulator_ready_device_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("Simulator"), + connect_device_complete(browser_worker_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiViewContent::Metrics(vec![UiMetric::new( + "Protocol", + "fw-browser-post-message-v1", + )]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Complete, + UiViewContent::text("Project controls are available in the Project pane."), + Vec::new(), + ), + ], + vec![ + "[fw-browser] ready", + "[lp-server] loaded project studio-demo", + "[fw-browser] heartbeat frame=42", + ], + ) +} + +pub(crate) fn device_project_empty_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiViewContent::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Active, + UiViewContent::text("Connect to a running project or load the demo project."), + vec![ + project_action(ProjectOp::ConnectRunningProject), + project_action(ProjectOp::LoadDemoProject), + ], + ), + ], + vec![ + "[fw-esp32] LightPlayer protocol ready", + "[lp-server] loaded projects: 0", + ], + ) +} + +pub(crate) fn device_project_selection_view() -> UiPaneView { + device_view( + UiStatus::good("LightPlayer ready"), + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete(esp32_metrics()), + stack_section( + "connect-lightplayer", + "Connect LightPlayer", + UiStepState::Complete, + UiViewContent::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), + vec![disconnect_lightplayer_action()], + ), + stack_section( + "open-project", + "Open project", + UiStepState::Active, + UiViewContent::text("2 projects are running. Choose one to open."), + vec![ + project_action(ProjectOp::ConnectLoadedProject { handle_id: 1 }) + .with_label("Connect /projects/ambient") + .with_summary("Attach to running project handle 1."), + project_action(ProjectOp::ConnectLoadedProject { handle_id: 2 }) + .with_label("Connect /projects/palette-test") + .with_summary("Attach to running project handle 2."), + ], + ), + ], + vec![ + "[fw-esp32] LightPlayer protocol ready", + "[lp-server] loaded projects: 2", + ], + ) +} + +pub(crate) fn blank_device_view( + status: UiStatus, + body: UiViewContent, + after_reset: bool, +) -> UiPaneView { + let detail = if after_reset { + vec![ + "[lpa-link] Chip erase completed successfully", + "[fw-esp32] invalid header: 0xffffffff", + ] + } else { + vec![ + "[esp32-reset] Hard resetting via RTS pin...", + "[fw-esp32] ESP-ROM:esp32c6-20220919", + "[fw-esp32] invalid header: 0xffffffff", + ] + }; + device_view( + status, + vec![ + select_connection_complete("ESP32 over USB"), + connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), + stack_section( + "connect-lightplayer", + "LightPlayer unavailable", + UiStepState::Active, + body, + Vec::new(), + ), + ], + detail, + ) +} + +pub(crate) fn blank_firmware_activity() -> UiActivityView { + UiActivityView::new("Connecting ESP32 server") + .with_detail("ESP32 boot output looks like blank or erased flash.") + .with_steps(vec![ + UiActivityStep::new("serial-access", "Serial access") + .with_state(UiActivityStepState::Complete) + .with_detail("Browser serial port is open."), + UiActivityStep::new("reset-device", "Reset device") + .with_state(UiActivityStepState::Complete) + .with_detail("Device reset was requested before protocol attach."), + UiActivityStep::new("boot-output", "Boot output") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("server-protocol", "LightPlayer protocol") + .with_state(UiActivityStepState::Failed), + ]) +} + +pub(crate) fn provisioning_activity() -> UiActivityView { + UiActivityView::new("Flashing firmware") + .with_detail("Writing packaged LightPlayer ESP32-C6 firmware.") + .with_progress(UiProgress::determinate("Writing flash", 42)) + .with_steps(vec![ + UiActivityStep::new("bootloader", "Bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase").with_state(UiActivityStepState::Complete), + UiActivityStep::new("write", "Write firmware").with_state(UiActivityStepState::Active), + UiActivityStep::new("reboot", "Reboot").with_state(UiActivityStepState::Pending), + ]) +} + +pub(crate) fn reset_activity() -> UiActivityView { + UiActivityView::new("Wiping device") + .with_detail("Erasing ESP32 flash through the bootloader.") + .with_progress(UiProgress::determinate("Erasing flash", 58)) + .with_steps(vec![ + UiActivityStep::new("bootloader", "Bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase flash").with_state(UiActivityStepState::Active), + UiActivityStep::new("blank", "Blank device").with_state(UiActivityStepState::Pending), + ]) +} + +pub(crate) fn device_view( + status: UiStatus, + sections: Vec, + terminal: Vec<&'static str>, +) -> UiPaneView { + UiPaneView::new( + DeviceController::NODE_ID, + "Device", + status, + UiViewContent::Stack(Box::new( + UiStepsView::new(sections).with_terminal( + terminal + .into_iter() + .map(UiTerminalLine::new) + .collect::>(), + ), + )), + Vec::new(), + ) +} + +pub(crate) fn stack_section( + id: &'static str, + title: &'static str, + state: UiStepState, + body: UiViewContent, + actions: Vec, +) -> UiStepView { + UiStepView::new(id, title, state) + .with_body(body) + .with_actions(actions) +} + +pub(crate) fn select_connection_complete(label: &'static str) -> UiStepView { + stack_section( + "select-connection", + "Select connection", + UiStepState::Complete, + UiViewContent::text(label), + Vec::new(), + ) +} + +pub(crate) fn connect_device_complete(metrics: Vec) -> UiStepView { + connect_device_complete_with_actions(metrics, Vec::new()) +} + +pub(crate) fn connect_device_complete_with_actions( + metrics: Vec, + actions: Vec, +) -> UiStepView { + stack_section( + "connect-device", + "Connect device", + UiStepState::Complete, + UiViewContent::Metrics(metrics), + actions, + ) +} + +pub(crate) fn browser_worker_metrics() -> Vec { + vec![ + UiMetric::new("Provider", "Browser worker"), + UiMetric::new("Endpoint", "browser-worker-worker-1"), + UiMetric::new("Session", "browser-worker-worker-1:1"), + ] +} + +pub(crate) fn esp32_metrics() -> Vec { + vec![ + UiMetric::new("Provider", "Browser serial ESP32"), + UiMetric::new("Endpoint", "browser-serial-esp32-port-1"), + UiMetric::new("Session", "browser-serial-esp32-port-1:1"), + ] +} + +pub(crate) fn project_synced_pane_view() -> UiPaneView { + UiPaneView::new( + ProjectController::NODE_ID, + "Project", + UiStatus::good("Ready"), + UiViewContent::ProjectEditor(Box::new(project_editor_fixture(ProjectSyncPhase::Ready))), + project_ready_actions(), + ) +} + +pub(crate) fn project_syncing_pane_view() -> UiPaneView { + UiPaneView::new( + ProjectController::NODE_ID, + "Project", + UiStatus::working("Syncing"), + UiViewContent::ProjectEditor(Box::new(project_editor_empty_fixture( + ProjectSyncPhase::SyncingShapes, + ))), + Vec::new(), + ) +} + +pub(crate) fn project_sync_failed_pane_view() -> UiPaneView { + UiPaneView::new( + ProjectController::NODE_ID, + "Project", + UiStatus::error("Sync issue"), + UiViewContent::ProjectEditor(Box::new(project_editor_empty_fixture( + ProjectSyncPhase::Failed, + ))), + project_ready_actions(), + ) +} + +pub(crate) fn project_editor_fixture(phase: ProjectSyncPhase) -> ProjectEditorView { + let running = story_node_status("Running", ProjectNodeStatusTone::Good); + let warning = ProjectNodeStatusView::new( + "Warning", + Some("using fallback palette".to_string()), + ProjectNodeStatusTone::Warning, + ); + let project = tree_item( + 1, + "/demo.project", + "Demo", + "Project", + running.clone(), + false, + vec![ + tree_item( + 2, + "/demo.project/clock.clock", + "Clock", + "Clock", + running.clone(), + false, + Vec::new(), + ), + tree_item( + 3, + "/demo.project/orbit.shader", + "Orbit shader", + "Shader", + running.clone(), + true, + Vec::new(), + ), + tree_item( + 4, + "/demo.project/palette.visual", + "Sunrise palette", + "Visual", + warning.clone(), + false, + Vec::new(), + ), + tree_item( + 5, + "/demo.project/output.output", + "Output", + "Output", + running.clone(), + false, + Vec::new(), + ), + ], + ); + let summary = project_editor_summary(phase); + ProjectEditorView::new( + "studio-demo", + 1, + summary, + project_synced_metrics(), + ProjectNodeTreeView::new(vec![project], 5), + vec![project_root_node()], + ) +} + +pub(crate) fn project_editor_empty_fixture(phase: ProjectSyncPhase) -> ProjectEditorView { + ProjectEditorView::new( + "studio-demo", + 1, + project_editor_summary(phase), + vec![ + UiMetric::new("Project", "studio-demo"), + UiMetric::new("Handle", 1), + UiMetric::new("Revision", 0), + UiMetric::new("Sync", sync_story_label(phase)), + ], + ProjectNodeTreeView::new(Vec::new(), 0), + Vec::new(), + ) +} + +pub(crate) fn project_editor_summary(phase: ProjectSyncPhase) -> ProjectSyncSummary { + ProjectSyncSummary { + phase, + revision: 42, + node_count: 5, + root_node_count: 1, + slot_root_count: 10, + resource_count: 2, + shape_count: 18, + shapes_complete: true, + runtime: Some(ProjectRuntimeSummary { + frame_num: 512, + frame_delta_ms: 16, + runtime_buffer_count: 2, + free_bytes: Some(232 * 1024), + used_bytes: Some(60 * 1024), + total_bytes: Some(292 * 1024), + }), + issue: (phase == ProjectSyncPhase::Failed).then(|| UiIssue::new("protocol timeout")), + } +} + +pub(crate) fn tree_item( + runtime_id: u32, + path: &str, + label: &str, + kind: &str, + status: ProjectNodeStatusView, + focused: bool, + children: Vec, +) -> ProjectNodeTreeItem { + ProjectNodeTreeItem::new( + path, + label, + kind, + status, + focused, + project_focus_action(runtime_id, path, label), + children, + ) +} + +pub(crate) fn project_focus_action(runtime_id: u32, path: &str, label: &str) -> UiAction { + UiAction::from_op( + ControllerId::new(format!("studio|project|node|nid|{runtime_id}|path|{path}")), + ProjectEditorOp::Focus, + ) + .with_label(format!("Focus {label}")) +} + +fn project_root_node() -> UiNodeView { + UiNodeView::new( + UiNodeHeader::new("Demo", "Project", "/demo.project") + .with_status(UiStatus::good("Running")) + .with_summary("4 child nodes") + .with_detail("Root composition node."), + vec![UiNodeTab::main(vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value("name", "Name", UiSlotValue::string("studio-demo")), + UiConfigSlot::value("enabled", "Enabled", UiSlotValue::bool(true)), + ])])], + ) + .with_node_id("/demo.project") + .with_children(vec![ + clock_node_child(), + orbit_shader_child(), + palette_node_child(), + output_node_child(), + ]) +} + +fn clock_node_child() -> UiNodeChild { + node_child( + "Clock", + "Clock", + "/demo.project/clock.clock", + UiStatus::good("Running"), + ) + .with_sections(vec![ + UiNodeSection::ProducedProducts(vec![ + UiProducedProduct::control("time").with_detail("1 channel"), + ]), + UiNodeSection::ProducedValues(vec![ + UiProducedValue::new("Frame", "512").with_detail("rev 42"), + UiProducedValue::new("Time", "3.333").with_detail("s"), + ]), + UiNodeSection::ConfigSlots(vec![UiConfigSlot::value( + "tempo", + "Tempo", + UiSlotValue::f32(120.0), + )]), + ]) +} + +fn orbit_shader_child() -> UiNodeChild { + node_child( + "Orbit shader", + "Shader", + "/demo.project/orbit.shader", + UiStatus::good("Running"), + ) + .active("focused") + .with_sections(vec![ + UiNodeSection::ProducedProducts(vec![ + UiProducedProduct::visual("output").with_detail("32 x 32"), + ]), + UiNodeSection::AssetSlots(vec![ + UiConfigSlot::asset( + "shader_source", + "Shader source", + UiSlotAsset::new("assets/shaders/orbit.glsl", UiAssetEditorKind::Glsl) + .with_content( + "void mainImage(out vec4 color, in vec2 uv) {\n color = vec4(uv, 0.4 + 0.4 * sin(iTime), 1.0);\n}", + ), + ) + .with_detail("glsl, rev 42"), + ]), + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value("time", "Time", UiSlotValue::f32(3.333).with_detail("s")) + .with_source(UiSlotSourceState::Bound(UiBindingEndpoint::new( + "clock#time.seconds", + ))), + UiConfigSlot::record( + "parameters", + "Parameters", + vec![ + UiConfigSlot::value("brightness", "Brightness", UiSlotValue::f32(0.72)), + UiConfigSlot::value("speed", "Speed", UiSlotValue::f32(1.5)), + UiConfigSlot::value("center", "Center", UiSlotValue::vec2([0.5, 0.5])), + ], + ) + .with_detail("3 fields"), + ]), + ]) +} + +fn palette_node_child() -> UiNodeChild { + node_child( + "Sunrise palette", + "Visual", + "/demo.project/palette.visual", + UiStatus::warning("Warning"), + ) + .with_sections(vec![ + UiNodeSection::ProducedProducts(vec![ + UiProducedProduct::visual("output").with_detail("32 x 32"), + ]), + UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::record( + "colors", + "Colors", + vec![ + UiConfigSlot::value("primary", "Primary", UiSlotValue::vec3([1.0, 0.45, 0.18])), + UiConfigSlot::value( + "secondary", + "Secondary", + UiSlotValue::vec3([0.08, 0.18, 0.42]), + ), + UiConfigSlot::value("accent", "Accent", UiSlotValue::vec3([0.95, 0.86, 0.34])), + ], + ) + .with_detail("fallback palette"), + ]), + ]) +} + +fn output_node_child() -> UiNodeChild { + node_child( + "Output", + "Output", + "/demo.project/output.output", + UiStatus::good("Running"), + ) + .with_sections(vec![UiNodeSection::ConfigSlots(vec![ + UiConfigSlot::value("input", "Input", UiSlotValue::string("orbit#output")).with_source( + UiSlotSourceState::Bound(UiBindingEndpoint::new("orbit#visual.output")), + ), + UiConfigSlot::value( + "endpoint", + "Endpoint", + UiSlotValue::string("ws281x:rmt:D10"), + ), + UiConfigSlot::value("samples", "Samples", UiSlotValue::u32(241)), + ])]) +} + +fn node_child(label: &str, kind: &str, detail: &str, status: UiStatus) -> UiNodeChild { + let mut child = UiNodeChild::new(label, kind, detail); + child.status = status; + child +} + +pub(crate) fn story_node_status(label: &str, tone: ProjectNodeStatusTone) -> ProjectNodeStatusView { + ProjectNodeStatusView::new(label, None, tone) +} + +pub(crate) fn sync_story_label(phase: ProjectSyncPhase) -> &'static str { + match phase { + ProjectSyncPhase::Empty => "Not synced", + ProjectSyncPhase::SyncingShapes | ProjectSyncPhase::SyncingProject => "Syncing", + ProjectSyncPhase::Ready => "Synced", + ProjectSyncPhase::Failed => "Needs attention", + } +} + +pub(crate) fn project_synced_metrics() -> Vec { + vec![ + UiMetric::new("Project", "studio-demo"), + UiMetric::new("Handle", 1), + UiMetric::new("Inventory nodes", 4), + UiMetric::new("Definitions", 3), + UiMetric::new("Assets", 1), + UiMetric::new("Sync", "Synced"), + UiMetric::new("Revision", 42), + UiMetric::new("Synced nodes", 7), + UiMetric::new("Root nodes", 1), + UiMetric::new("Slot roots", 12), + UiMetric::new("Resources", 3), + UiMetric::new("Shapes", 18), + UiMetric::new("Frame", 512), + UiMetric::new("Runtime buffers", 2), + UiMetric::new("Memory free", "232 KB"), + ] +} + +pub(crate) fn project_ready_actions() -> Vec { + vec![ + project_action(ProjectOp::RefreshProject), + project_action(ProjectOp::DisconnectProject), + ] +} + +pub(crate) fn project_view(state: ProjectState, server_connected: bool) -> UiPaneView { + let mut project = ProjectController::new(); + let no_running_project = matches!(state, ProjectState::NotLoaded) && server_connected; + project.set_state(state); + if no_running_project { + project.mark_no_running_project(); + } + project.view(server_connected) +} + +pub(crate) fn project_ready_state() -> ProjectState { + ProjectState::Ready { + project_id: "studio-demo".to_string(), + handle_id: 1, + inventory: ProjectInventorySummary { + node_count: 4, + definition_count: 3, + asset_count: 1, + }, + } +} + +pub(crate) fn start_actions() -> Vec { + vec![ + device_action(DeviceOp::OpenProvider { + provider_id: LinkProviderKind::BrowserWorker, + }) + .with_label("Start simulator") + .with_summary("Run LightPlayer locally in a browser worker.") + .with_short_label("Simulator") + .with_icon("play"), + device_action(DeviceOp::OpenProvider { + provider_id: LinkProviderKind::BrowserSerialEsp32, + }) + .with_label("Connect ESP32") + .with_summary("Connect to ESP32 hardware through browser Web Serial.") + .with_short_label("ESP32") + .with_icon("usb"), + ] +} + +pub(crate) fn disconnect_device_action() -> UiAction { + device_action(DeviceOp::DisconnectDevice) +} + +pub(crate) fn disconnect_lightplayer_action() -> UiAction { + device_action(DeviceOp::DisconnectLightPlayer) +} + +pub(crate) fn connect_lightplayer_action() -> UiAction { + device_action(DeviceOp::ConnectLightPlayer) +} + +pub(crate) fn device_management_actions() -> Vec { + vec![ + device_action(DeviceOp::ProvisionFirmware), + device_action(DeviceOp::ResetToBlank), + disconnect_device_action(), + ] +} + +pub(crate) fn device_action(op: DeviceOp) -> UiAction { + UiAction::from_op(ControllerId::new(DeviceController::NODE_ID), op) +} + +pub(crate) fn project_action(op: ProjectOp) -> UiAction { + UiAction::from_op(ControllerId::new(ProjectController::NODE_ID), op) +} diff --git a/lp-app/lpa-studio-web/src/base/field_row.rs b/lp-app/lpa-studio-web/src/base/field_row.rs new file mode 100644 index 000000000..49fa3bcf9 --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/field_row.rs @@ -0,0 +1,28 @@ +use dioxus::prelude::*; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn FieldRow(label: String, value: String, changed: bool, detail: Option) -> Element { + let class = if changed { + "tw:grid tw:grid-cols-[minmax(120px,0.35fr)_minmax(0,1fr)] tw:gap-3 tw:rounded-sm tw:border tw:border-accent-border tw:bg-status-good-bg tw:p-3" + } else { + "tw:grid tw:grid-cols-[minmax(120px,0.35fr)_minmax(0,1fr)] tw:gap-3 tw:rounded-sm tw:border tw:border-border-subtle tw:bg-card-muted tw:p-3" + }; + + rsx! { + div { class, + div { class: "tw:grid tw:min-w-0 tw:gap-1", + span { "{label}" } + if changed { + small { class: "tw:text-xs tw:font-bold tw:uppercase tw:text-accent", "modified" } + } + } + div { class: "tw:grid tw:min-w-0 tw:gap-1 tw:text-right", + span { "{value}" } + if let Some(detail) = detail.as_ref() { + small { class: "tw:text-xs tw:text-subtle-foreground", "{detail}" } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/base/icon.rs b/lp-app/lpa-studio-web/src/base/icon.rs new file mode 100644 index 000000000..2a97d7e8d --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/icon.rs @@ -0,0 +1,67 @@ +use dioxus::prelude::*; +use dioxus_icons::lucide::{ + Asterisk, Check, ChevronDown, ChevronRight, CircleAlert, CircleDot, CircleMinus, FlaskConical, + Info, Link2, Link2Off, Pencil, Play, SquareArrowRight, TriangleAlert, Usb, +}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StudioIcon(name: StudioIconName, size: u32) -> Element { + match name { + StudioIconName::Play => rsx! { Play { size } }, + StudioIconName::Usb => rsx! { Usb { size } }, + StudioIconName::Test => rsx! { FlaskConical { size } }, + StudioIconName::StatusRunning => rsx! { Play { size } }, + StudioIconName::StatusIdle => rsx! { CircleMinus { size } }, + StudioIconName::StatusError => rsx! { CircleAlert { size } }, + StudioIconName::StepComplete => rsx! { Check { size } }, + StudioIconName::StepActive => rsx! { Asterisk { size } }, + StudioIconName::StepAttention => rsx! { TriangleAlert { size } }, + StudioIconName::AssignedValue => rsx! { CircleDot { size } }, + StudioIconName::BoundValue => rsx! { Link2 { size } }, + StudioIconName::ChildValue => rsx! { SquareArrowRight { size } }, + StudioIconName::Edited => rsx! { Pencil { size } }, + StudioIconName::Info => rsx! { Info { size } }, + StudioIconName::InfoBare => rsx! { + span { + class: "tw:inline-flex tw:items-center tw:justify-center tw:font-mono tw:font-bold", + style: "font-size: {size}px; line-height: {size}px;", + "i" + } + }, + StudioIconName::UnboundValue => rsx! { Link2Off { size } }, + StudioIconName::Expanded => rsx! { ChevronDown { size } }, + StudioIconName::Collapsed => rsx! { ChevronRight { size } }, + } +} + +pub fn action_icon_name(icon: Option<&str>) -> Option { + match icon { + Some("play") => Some(StudioIconName::Play), + Some("usb") => Some(StudioIconName::Usb), + Some("test-tube") => Some(StudioIconName::Test), + _ => None, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StudioIconName { + Play, + Usb, + Test, + StatusRunning, + StatusIdle, + StatusError, + StepComplete, + StepActive, + StepAttention, + AssignedValue, + BoundValue, + ChildValue, + Edited, + Info, + InfoBare, + UnboundValue, + Expanded, + Collapsed, +} diff --git a/lp-app/lpa-studio-web/src/base/icon_menu.rs b/lp-app/lpa-studio-web/src/base/icon_menu.rs new file mode 100644 index 000000000..36ed181ae --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/icon_menu.rs @@ -0,0 +1,177 @@ +use dioxus::prelude::*; + +use crate::base::{IconPopoverButton, PopoverPlacement, StudioIconName}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn IconMenuButton( + icon: StudioIconName, + label: String, + #[props(default = label.clone())] title: String, + #[props(default = 14)] icon_size: u32, + #[props(default = IconMenuTone::Neutral)] tone: IconMenuTone, + #[props(default = PopoverPlacement::BottomEnd)] placement: PopoverPlacement, + #[props(default = false)] active: bool, + #[props(default = IconMenuVisualState::Rest)] visual_state: IconMenuVisualState, + #[props(default = false)] initially_open: bool, + #[props(default = default_icon_menu_popup_class().to_string())] popup_class: String, + children: Element, +) -> Element { + let class = icon_menu_visual_class(tone, active, visual_state); + let chrome_class = icon_menu_chrome_class(tone); + + rsx! { + IconPopoverButton { + class: class.to_string(), + open_class: icon_menu_open_class(tone).to_string(), + icon, + icon_size, + label, + title, + popup_class, + chrome_class: chrome_class.to_string(), + placement, + initially_open, + {children} + } + } +} + +fn default_icon_menu_popup_class() -> &'static str { + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:gap-3 tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-3 tw:text-sm tw:text-muted-foreground tw:shadow-lg" +} + +fn icon_menu_chrome_class(tone: IconMenuTone) -> &'static str { + match tone { + IconMenuTone::Quiet => "ux-popover-chrome-quiet", + IconMenuTone::Neutral => "ux-popover-chrome-neutral", + IconMenuTone::Accent => "ux-popover-chrome-accent", + IconMenuTone::Good => "ux-popover-chrome-good", + IconMenuTone::Working => "ux-popover-chrome-working", + IconMenuTone::Warning => "ux-popover-chrome-warning", + IconMenuTone::Error => "ux-popover-chrome-error", + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IconMenuTone { + Quiet, + Neutral, + Accent, + Good, + Working, + Warning, + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IconMenuVisualState { + Rest, + Hover, + Open, +} + +fn icon_menu_visual_class( + tone: IconMenuTone, + active: bool, + state: IconMenuVisualState, +) -> &'static str { + match state { + IconMenuVisualState::Rest => icon_menu_class(tone, active), + IconMenuVisualState::Hover => icon_menu_hover_class(tone, active), + IconMenuVisualState::Open => icon_menu_open_class(tone), + } +} + +fn icon_menu_class(tone: IconMenuTone, active: bool) -> &'static str { + match (tone, active) { + (IconMenuTone::Quiet, false) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-subtle tw:bg-terminal tw:p-0 tw:text-muted-foreground tw:transition-colors tw:hover:border-border-strong tw:hover:text-strong-foreground" + } + (IconMenuTone::Quiet, true) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-subtle tw:bg-terminal tw:p-0 tw:text-muted-foreground tw:transition-colors tw:hover:border-border-strong tw:hover:text-strong-foreground" + } + (IconMenuTone::Neutral, false) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-subtle tw:bg-page tw:p-0 tw:text-subtle-foreground tw:hover:border-border-strong tw:hover:text-muted-foreground" + } + (IconMenuTone::Neutral, true) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-muted tw:p-0 tw:text-muted-foreground tw:transition-colors tw:hover:text-strong-foreground" + } + (IconMenuTone::Accent, false) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-subtle tw:bg-transparent tw:p-0 tw:text-subtle-foreground tw:transition-colors tw:hover:border-accent-border tw:hover:text-accent" + } + (IconMenuTone::Accent, true) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:p-0 tw:text-accent tw:transition-colors tw:hover:border-status-good-foreground tw:hover:text-status-good-foreground" + } + (IconMenuTone::Good, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-good-border tw:bg-status-good-bg tw:p-0 tw:text-status-good-foreground tw:transition-colors tw:hover:border-status-good-foreground" + } + (IconMenuTone::Working, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-working-border tw:bg-status-working-bg tw:p-0 tw:text-status-working-foreground tw:transition-colors tw:hover:border-status-working-foreground" + } + (IconMenuTone::Warning, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground tw:transition-colors tw:hover:border-status-warning-foreground" + } + (IconMenuTone::Error, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-error-border tw:bg-status-error-bg tw:p-0 tw:text-status-error-foreground tw:transition-colors tw:hover:border-status-error-foreground" + } + } +} + +fn icon_menu_hover_class(tone: IconMenuTone, active: bool) -> &'static str { + match (tone, active) { + (IconMenuTone::Quiet, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-terminal tw:p-0 tw:text-strong-foreground tw:transition-colors" + } + (IconMenuTone::Neutral, false) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-page tw:p-0 tw:text-muted-foreground tw:transition-colors" + } + (IconMenuTone::Neutral, true) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-muted tw:p-0 tw:text-strong-foreground tw:transition-colors" + } + (IconMenuTone::Accent, false) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:p-0 tw:text-accent tw:transition-colors" + } + (IconMenuTone::Accent, true) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-good-foreground tw:bg-transparent tw:p-0 tw:text-status-good-foreground tw:transition-colors" + } + (IconMenuTone::Good, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-good-foreground tw:bg-status-good-bg tw:p-0 tw:text-status-good-foreground tw:transition-colors" + } + (IconMenuTone::Working, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-working-foreground tw:bg-status-working-bg tw:p-0 tw:text-status-working-foreground tw:transition-colors" + } + (IconMenuTone::Warning, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-foreground tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground tw:transition-colors" + } + (IconMenuTone::Error, _) => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-error-foreground tw:bg-status-error-bg tw:p-0 tw:text-status-error-foreground tw:transition-colors" + } + } +} + +fn icon_menu_open_class(tone: IconMenuTone) -> &'static str { + match tone { + IconMenuTone::Quiet => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-terminal tw:p-0 tw:text-strong-foreground" + } + IconMenuTone::Neutral => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-border-strong tw:bg-card-subtle tw:p-0 tw:text-strong-foreground" + } + IconMenuTone::Accent => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:p-0 tw:text-accent" + } + IconMenuTone::Good => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-good-border tw:bg-status-good-bg tw:p-0 tw:text-status-good-foreground" + } + IconMenuTone::Working => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-working-border tw:bg-status-working-bg tw:p-0 tw:text-status-working-foreground" + } + IconMenuTone::Warning => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:p-0 tw:text-status-warning-foreground" + } + IconMenuTone::Error => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-status-error-border tw:bg-status-error-bg tw:p-0 tw:text-status-error-foreground" + } + } +} diff --git a/lp-app/lpa-studio-web/src/base/icon_menu_stories.rs b/lp-app/lpa-studio-web/src/base/icon_menu_stories.rs new file mode 100644 index 000000000..91f6eb1ae --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/icon_menu_stories.rs @@ -0,0 +1,237 @@ +//! Base icon-menu stories. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::base::{ + IconMenuButton, IconMenuTone, IconMenuVisualState, PopoverPlacement, StudioIcon, StudioIconName, +}; + +#[story(description = "Standard icon-triggered menus used by dense Studio controls.")] +fn tones() -> Element { + rsx! { + div { class: "tw:flex tw:min-h-56 tw:flex-wrap tw:items-start tw:gap-3 tw:pt-8", + IconMenuStoryButton { + label: "Quiet", + tone: IconMenuTone::Quiet, + icon: StudioIconName::Info, + active: true, + } + IconMenuStoryButton { + label: "Neutral", + tone: IconMenuTone::Neutral, + icon: StudioIconName::AssignedValue, + active: false, + } + IconMenuStoryButton { + label: "Bound", + tone: IconMenuTone::Accent, + icon: StudioIconName::BoundValue, + active: true, + } + IconMenuStoryButton { + label: "Running", + tone: IconMenuTone::Good, + icon: StudioIconName::StatusRunning, + active: true, + } + IconMenuStoryButton { + label: "Warning", + tone: IconMenuTone::Warning, + icon: StudioIconName::StepAttention, + active: true, + } + IconMenuStoryButton { + label: "Error", + tone: IconMenuTone::Error, + icon: StudioIconName::StatusError, + active: true, + } + } + } +} + +#[story(description = "An open icon menu with the popup positioned near its trigger.")] +fn open_menu() -> Element { + let cases = [ + ("Below / Start", "below-start", "below", "start"), + ("Below / Middle", "below-middle", "below", "middle"), + ("Below / End", "below-end", "below", "end"), + ("Above / Start", "above-start", "above", "start"), + ("Above / Middle", "above-middle", "above", "middle"), + ("Above / End", "above-end", "above", "end"), + ]; + + rsx! { + section { class: "ux-attached-popover-story", + for (title, meta, side, align) in cases { + article { class: "ux-attached-popover-story-card ux-attached-popover-story-card-{meta}", + header { class: "ux-attached-popover-story-heading", + strong { "{title}" } + span { "{meta}" } + } + div { class: "ux-attached-popover-story-canvas ux-attached-popover-story-canvas-{meta}", + AttachedIconMenuStoryCase { + side, + align, + } + } + } + } + } + } +} + +#[story(description = "Forced trigger states for the low-level icon menu primitive.")] +fn trigger_states() -> Element { + let states = [ + ("Rest", IconMenuVisualState::Rest), + ("Hover", IconMenuVisualState::Hover), + ("Open", IconMenuVisualState::Open), + ]; + let tones = [ + ("Quiet", IconMenuTone::Quiet, StudioIconName::Info, true), + ( + "Neutral", + IconMenuTone::Neutral, + StudioIconName::AssignedValue, + false, + ), + ( + "Bound", + IconMenuTone::Accent, + StudioIconName::BoundValue, + true, + ), + ( + "Warning", + IconMenuTone::Warning, + StudioIconName::StepAttention, + true, + ), + ( + "Error", + IconMenuTone::Error, + StudioIconName::StatusError, + true, + ), + ]; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-2", + div { class: "tw:grid tw:grid-cols-[72px_repeat(3,44px)] tw:items-center tw:gap-2", + span { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-subtle-foreground", "Tone" } + for (state_label, _) in states { + span { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-subtle-foreground", "{state_label}" } + } + } + for (tone_label, tone, icon, active) in tones { + div { class: "tw:grid tw:grid-cols-[72px_repeat(3,44px)] tw:items-center tw:gap-2", + span { class: "tw:text-xs tw:font-bold tw:text-strong-foreground", "{tone_label}" } + for (_, state) in states { + IconMenuStoryButton { + label: tone_label, + tone, + icon, + active, + visual_state: state, + } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn AttachedIconMenuStoryCase(side: &'static str, align: &'static str) -> Element { + let panel_corner_class = attached_story_panel_corner_class(side, align); + let bridge_corner_class = attached_story_bridge_corner_class(align); + let button_class = format!( + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-xs tw:border tw:border-accent-border tw:bg-transparent tw:p-0 tw:text-accent ux-popover-chrome-accent ux-popover-trigger-attached ux-popover-trigger-attached-{side} ux-attached-popover-story-button ux-attached-popover-story-button-{side} ux-attached-popover-story-{align}" + ); + let panel_class = format!( + "tw:grid tw:w-[min(320px,calc(100vw-24px))] tw:gap-3 tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-3 tw:text-sm tw:text-muted-foreground tw:shadow-lg ux-popover-chrome-accent ux-popover-panel ux-attached-popover-panel ux-attached-popover-panel-{side} ux-attached-popover-story-panel ux-attached-popover-story-panel-{side} ux-attached-popover-story-{align} {panel_corner_class}" + ); + let bridge_class = format!( + "ux-popover-chrome-accent ux-popover-bridge ux-popover-bridge-{side} ux-attached-popover-story-bridge ux-attached-popover-story-bridge-{side} ux-attached-popover-story-{align} {bridge_corner_class}" + ); + + rsx! { + button { + class: "{button_class}", + r#type: "button", + aria_label: "Bound menu", + title: "Bound menu", + aria_expanded: "true", + StudioIcon { + name: StudioIconName::BoundValue, + size: 14, + } + } + aside { + class: "{panel_class}", + role: "dialog", + div { class: "tw:grid tw:gap-1", + span { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-heading", "icon menu" } + strong { class: "tw:text-sm tw:text-strong-foreground", "Bound" } + p { class: "tw:m-0 tw:text-xs tw:text-muted-foreground", "Reusable icon-triggered menu chrome." } + } + } + div { + class: "{bridge_class}", + aria_hidden: "true", + span { class: "ux-popover-bridge-corner ux-popover-bridge-corner-left" } + span { class: "ux-popover-bridge-corner ux-popover-bridge-corner-right" } + } + } +} + +fn attached_story_panel_corner_class(side: &str, align: &str) -> &'static str { + match (side, align) { + ("below", "start") => "ux-attached-popover-panel-square-top-left", + ("below", "end") => "ux-attached-popover-panel-square-top-right", + ("above", "start") => "ux-attached-popover-panel-square-bottom-left", + ("above", "end") => "ux-attached-popover-panel-square-bottom-right", + _ => "", + } +} + +fn attached_story_bridge_corner_class(align: &str) -> &'static str { + match align { + "start" => "ux-popover-bridge-no-left-corner", + "end" => "ux-popover-bridge-no-right-corner", + _ => "", + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn IconMenuStoryButton( + label: &'static str, + tone: IconMenuTone, + icon: StudioIconName, + active: bool, + #[props(default = PopoverPlacement::BottomEnd)] placement: PopoverPlacement, + #[props(default = IconMenuVisualState::Rest)] visual_state: IconMenuVisualState, + #[props(default = false)] initially_open: bool, +) -> Element { + rsx! { + IconMenuButton { + icon, + label: format!("{label} menu"), + title: format!("{label} menu"), + tone, + active, + placement, + visual_state, + initially_open, + div { class: "tw:grid tw:gap-1", + span { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:text-heading", "icon menu" } + strong { class: "tw:text-sm tw:text-strong-foreground", "{label}" } + p { class: "tw:m-0 tw:text-xs tw:text-muted-foreground", "Reusable icon-triggered menu chrome." } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/base/mod.rs b/lp-app/lpa-studio-web/src/base/mod.rs new file mode 100644 index 000000000..1594e2f30 --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/mod.rs @@ -0,0 +1,21 @@ +//! Base UI building blocks. +//! +//! These components should stay independent of `lpa-studio-core`. They are +//! generic controls and display primitives that Studio could plausibly get +//! from a design-system package. + +pub mod field_row; +pub mod icon; +pub mod icon_menu; +#[cfg(feature = "stories")] +pub(crate) mod icon_menu_stories; +pub mod popover; +#[cfg(feature = "stories")] +pub(crate) mod popover_stories; +pub mod tabs; + +pub use field_row::FieldRow; +pub use icon::{StudioIcon, StudioIconName, action_icon_name}; +pub use icon_menu::{IconMenuButton, IconMenuTone, IconMenuVisualState}; +pub use popover::{IconPopoverButton, PopoverPlacement}; +pub use tabs::{TabItem, Tabs}; diff --git a/lp-app/lpa-studio-web/src/base/popover.rs b/lp-app/lpa-studio-web/src/base/popover.rs new file mode 100644 index 000000000..cd99fd81d --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/popover.rs @@ -0,0 +1,966 @@ +use dioxus::prelude::*; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use wasm_bindgen::{JsCast, closure::Closure}; + +use crate::base::{StudioIcon, StudioIconName}; + +static NEXT_POPOVER_ID: AtomicUsize = AtomicUsize::new(1); + +const POPOVER_MARGIN_PX: f64 = 12.0; +const POPOVER_BORDER_WIDTH_PX: f64 = 1.0; +const POPOVER_CORNER_RADIUS_PX: f64 = 8.0; +const FALLBACK_PANEL_WIDTH_PX: f64 = 280.0; +const FALLBACK_PANEL_HEIGHT_PX: f64 = 180.0; +const MEASURE_RETRY_LIMIT: u8 = 3; +const STABILIZE_MEASURE_DELAYS_MS: [i32; 2] = [50, 250]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PopoverPlacement { + TopStart, + TopMiddle, + TopEnd, + BottomStart, + BottomMiddle, + BottomEnd, +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn IconPopoverButton( + class: String, + open_class: String, + icon: StudioIconName, + icon_size: u32, + label: String, + title: String, + popup_class: String, + #[props(default = String::new())] chrome_class: String, + #[props(default = PopoverPlacement::BottomEnd)] placement: PopoverPlacement, + #[props(default = false)] initially_open: bool, + children: Element, +) -> Element { + let mut open = use_signal(|| initially_open); + let trigger_id = use_hook(|| { + let id = NEXT_POPOVER_ID.fetch_add(1, Ordering::Relaxed); + format!("ux-popover-trigger-{id}") + }); + let panel_id = use_hook(|| { + let id = NEXT_POPOVER_ID.fetch_add(1, Ordering::Relaxed); + format!("ux-popover-panel-{id}") + }); + let layer_id = use_hook(|| { + let id = NEXT_POPOVER_ID.fetch_add(1, Ordering::Relaxed); + format!("ux-popover-layer-{id}") + }); + let trigger_rect = use_signal(|| None::); + let mut panel_size = use_signal(|| None::); + let position = use_signal(|| PopoverPosition::hidden(placement)); + let auto_update = use_hook(|| Rc::new(RefCell::new(None::))); + let current_position = position(); + let button_class = + popover_button_class(open(), &class, &open_class, &chrome_class, current_position); + let panel_class = popover_panel_class(&popup_class, &chrome_class, current_position); + let bridge_class = popover_bridge_class(&chrome_class, current_position); + let panel_style = current_position.style(); + let bridge_style = current_position.bridge_style(); + + let trigger_id_for_effect = trigger_id.clone(); + let panel_id_for_effect = panel_id.clone(); + let layer_id_for_effect = layer_id.clone(); + let auto_update_for_effect = auto_update.clone(); + let trigger_id_for_layer_mount = trigger_id.clone(); + let panel_id_for_layer_mount = panel_id.clone(); + let trigger_id_for_panel_mount = trigger_id.clone(); + let panel_id_for_panel_mount = panel_id.clone(); + let layer_id_for_layer_mount = layer_id.clone(); + let layer_id_for_panel_mount = layer_id.clone(); + let layer_id_for_drop = layer_id.clone(); + use_effect(move || { + if open() { + show_popover_layer(&layer_id_for_effect); + measure_trigger_with_stabilization( + trigger_id_for_effect.clone(), + panel_id_for_effect.clone(), + panel_size, + trigger_rect, + position, + placement, + ); + ensure_popover_auto_update( + auto_update_for_effect.clone(), + trigger_id_for_effect.clone(), + panel_id_for_effect.clone(), + layer_id_for_effect.clone(), + panel_size, + trigger_rect, + position, + placement, + ); + } else { + hide_popover_layer(&layer_id_for_effect); + auto_update_for_effect.borrow_mut().take(); + } + }); + use_drop(move || { + hide_popover_layer(&layer_id_for_drop); + auto_update.borrow_mut().take(); + }); + + rsx! { + span { class: "tw:relative tw:inline-grid tw:min-w-0 tw:place-items-center", + button { + id: "{trigger_id}", + class: "{button_class}", + style: "cursor: pointer;", + r#type: "button", + aria_label: "{label}", + title: "{title}", + aria_expanded: "{open()}", + onclick: move |event| { + event.stop_propagation(); + open.toggle(); + }, + StudioIcon { + name: icon, + size: icon_size, + } + } + if open() { + div { + id: "{layer_id}", + class: "ux-popover-layer", + "popover": "manual", + onmounted: move |_| { + show_popover_layer(&layer_id_for_layer_mount); + let trigger_id_for_panel = trigger_id_for_layer_mount.clone(); + let panel_id_for_panel = panel_id_for_layer_mount.clone(); + spawn(async move { + measure_trigger_once( + trigger_id_for_panel, + panel_id_for_panel, + panel_size, + trigger_rect, + position, + placement, + ); + }); + }, + div { + class: "tw:fixed tw:inset-0 tw:z-[70] tw:bg-transparent", + aria_hidden: "true", + onclick: move |event| { + event.stop_propagation(); + open.set(false); + }, + } + div { + id: "{panel_id}", + class: "{panel_class}", + style: "{panel_style}", + role: "dialog", + "data-story-wait": if current_position.visible { "0" } else { "1" }, + onclick: move |event| event.stop_propagation(), + onmounted: move |event| { + show_popover_layer(&layer_id_for_panel_mount); + let trigger_id_for_panel = trigger_id_for_panel_mount.clone(); + let panel_id_for_panel = panel_id_for_panel_mount.clone(); + let panel_element = event.data(); + spawn(async move { + let Ok(rect) = panel_element.get_client_rect().await else { + return; + }; + let size = SizeSnapshot::from_pixels_rect(rect); + panel_size.set(Some(size)); + measure_trigger_once( + trigger_id_for_panel, + panel_id_for_panel, + panel_size, + trigger_rect, + position, + placement, + ); + }); + }, + {children} + } + div { + class: "{bridge_class}", + style: "{bridge_style}", + aria_hidden: "true", + span { class: "ux-popover-bridge-corner ux-popover-bridge-corner-left" } + span { class: "ux-popover-bridge-corner ux-popover-bridge-corner-right" } + } + } + } + } + } +} + +fn measure_trigger_with_stabilization( + trigger_id: String, + panel_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, +) { + measure_trigger_once( + trigger_id.clone(), + panel_id.clone(), + panel_size, + trigger_rect, + position, + placement, + ); + for delay_ms in STABILIZE_MEASURE_DELAYS_MS { + schedule_delayed_measure_trigger( + trigger_id.clone(), + panel_id.clone(), + panel_size, + trigger_rect, + position, + placement, + delay_ms, + ); + } +} + +fn measure_trigger_once( + trigger_id: String, + panel_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, +) { + let current_panel_size = panel_size_by_id(&panel_id).or_else(|| panel_size()); + if let Some(size) = current_panel_size { + let mut panel_size = panel_size; + panel_size.set(Some(size)); + } + measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + ); +} + +fn schedule_delayed_measure_trigger( + trigger_id: String, + panel_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, + delay_ms: i32, +) { + let Some(window) = web_sys::window() else { + return; + }; + + let callback = Closure::once(move || { + measure_trigger_once( + trigger_id, + panel_id, + panel_size, + trigger_rect, + position, + placement, + ); + }); + if window + .set_timeout_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + delay_ms, + ) + .is_ok() + { + callback.forget(); + } +} + +fn measure_trigger_element( + trigger_id: String, + panel_id: String, + current_panel_size: Option, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, +) { + schedule_measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + 0, + ); +} + +fn schedule_measure_trigger_element( + trigger_id: String, + panel_id: String, + current_panel_size: Option, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, + attempt: u8, +) { + let Some(window) = web_sys::window() else { + spawn_measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + attempt, + ); + return; + }; + + let fallback_trigger_id = trigger_id.clone(); + let fallback_panel_id = panel_id.clone(); + let fallback_trigger_rect = trigger_rect; + let fallback_position = position; + let callback = Closure::once(move || { + spawn_measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + attempt, + ); + }); + if window + .request_animation_frame(callback.as_ref().unchecked_ref()) + .is_ok() + { + callback.forget(); + } else { + spawn_measure_trigger_element( + fallback_trigger_id, + fallback_panel_id, + current_panel_size, + fallback_trigger_rect, + fallback_position, + placement, + attempt, + ); + } +} + +fn spawn_measure_trigger_element( + trigger_id: String, + panel_id: String, + current_panel_size: Option, + mut trigger_rect: Signal>, + mut position: Signal, + placement: PopoverPlacement, + attempt: u8, +) { + let Some(anchor) = trigger_rect_by_id(&trigger_id) else { + if attempt < MEASURE_RETRY_LIMIT { + schedule_measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + attempt + 1, + ); + } + return; + }; + if anchor.is_empty() && attempt < MEASURE_RETRY_LIMIT { + schedule_measure_trigger_element( + trigger_id, + panel_id, + current_panel_size, + trigger_rect, + position, + placement, + attempt + 1, + ); + return; + } + if anchor.is_empty() { + return; + } + + let size = current_panel_size.unwrap_or_else(SizeSnapshot::fallback); + trigger_rect.set(Some(anchor)); + position.set(PopoverPosition::from_anchor(anchor, size, placement)); +} + +fn trigger_rect_by_id(trigger_id: &str) -> Option { + let window = web_sys::window()?; + let document = window.document()?; + let element = document.get_element_by_id(trigger_id)?; + Some(RectSnapshot::from_dom_rect( + element.get_bounding_client_rect(), + )) +} + +fn panel_size_by_id(panel_id: &str) -> Option { + let window = web_sys::window()?; + let document = window.document()?; + let element = document.get_element_by_id(panel_id)?; + let size = SizeSnapshot::from_dom_rect(element.get_bounding_client_rect()); + (!size.is_empty()).then_some(size) +} + +fn show_popover_layer(layer_id: &str) { + if let Some(layer) = popover_layer_by_id(layer_id) { + let _ = layer.show_popover(); + } +} + +fn hide_popover_layer(layer_id: &str) { + if let Some(layer) = popover_layer_by_id(layer_id) { + let _ = layer.hide_popover(); + } +} + +fn popover_layer_by_id(layer_id: &str) -> Option { + let window = web_sys::window()?; + let document = window.document()?; + document + .get_element_by_id(layer_id)? + .dyn_into::() + .ok() +} + +fn ensure_popover_auto_update( + auto_update: Rc>>, + trigger_id: String, + panel_id: String, + layer_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, +) { + if auto_update.borrow().is_some() { + return; + } + + let Some(update) = PopoverAutoUpdate::install( + trigger_id, + panel_id, + layer_id, + panel_size, + trigger_rect, + position, + placement, + ) else { + return; + }; + *auto_update.borrow_mut() = Some(update); +} + +struct PopoverAutoUpdate { + window: web_sys::Window, + scroll_callback: Closure, + resize_callback: Closure, +} + +impl PopoverAutoUpdate { + fn install( + trigger_id: String, + panel_id: String, + layer_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, + ) -> Option { + let window = web_sys::window()?; + let pending = Rc::new(Cell::new(false)); + let scroll_callback = make_update_callback( + trigger_id.clone(), + panel_id.clone(), + layer_id.clone(), + panel_size, + trigger_rect, + position, + placement, + pending.clone(), + ); + let resize_callback = make_update_callback( + trigger_id, + panel_id, + layer_id, + panel_size, + trigger_rect, + position, + placement, + pending, + ); + + if window + .add_event_listener_with_callback_and_bool( + "scroll", + scroll_callback.as_ref().unchecked_ref(), + true, + ) + .is_err() + { + return None; + } + if window + .add_event_listener_with_callback("resize", resize_callback.as_ref().unchecked_ref()) + .is_err() + { + let _ = window.remove_event_listener_with_callback_and_bool( + "scroll", + scroll_callback.as_ref().unchecked_ref(), + true, + ); + return None; + } + + Some(Self { + window, + scroll_callback, + resize_callback, + }) + } +} + +impl Drop for PopoverAutoUpdate { + fn drop(&mut self) { + let _ = self.window.remove_event_listener_with_callback_and_bool( + "scroll", + self.scroll_callback.as_ref().unchecked_ref(), + true, + ); + let _ = self.window.remove_event_listener_with_callback( + "resize", + self.resize_callback.as_ref().unchecked_ref(), + ); + } +} + +#[allow( + clippy::too_many_arguments, + reason = "Small DOM listener callback factory" +)] +fn make_update_callback( + trigger_id: String, + panel_id: String, + layer_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, + pending: Rc>, +) -> Closure { + Closure::::wrap(Box::new(move |_| { + request_popover_update( + trigger_id.clone(), + panel_id.clone(), + layer_id.clone(), + panel_size, + trigger_rect, + position, + placement, + pending.clone(), + ); + })) +} + +#[allow( + clippy::too_many_arguments, + reason = "Small DOM listener callback body" +)] +fn request_popover_update( + trigger_id: String, + panel_id: String, + layer_id: String, + panel_size: Signal>, + trigger_rect: Signal>, + position: Signal, + placement: PopoverPlacement, + pending: Rc>, +) { + if pending.replace(true) { + return; + } + + let Some(window) = web_sys::window() else { + pending.set(false); + show_popover_layer(&layer_id); + measure_trigger_once( + trigger_id, + panel_id, + panel_size, + trigger_rect, + position, + placement, + ); + return; + }; + + let callback = Closure::once(move || { + pending.set(false); + show_popover_layer(&layer_id); + measure_trigger_once( + trigger_id, + panel_id, + panel_size, + trigger_rect, + position, + placement, + ); + }); + if window + .request_animation_frame(callback.as_ref().unchecked_ref()) + .is_ok() + { + callback.forget(); + } +} + +fn popover_button_class( + open: bool, + class: &str, + open_class: &str, + chrome_class: &str, + position: PopoverPosition, +) -> String { + if !open { + return class.to_string(); + } + + format!( + "{open_class} {chrome_class} ux-popover-trigger-attached ux-popover-trigger-attached-{}", + position.side.class_token() + ) +} + +fn popover_panel_class(popup_class: &str, chrome_class: &str, position: PopoverPosition) -> String { + format!( + "{popup_class} {chrome_class} ux-popover-panel ux-attached-popover-panel ux-attached-popover-panel-{} {}", + position.side.class_token(), + position.panel_corner_class() + ) +} + +fn popover_bridge_class(chrome_class: &str, position: PopoverPosition) -> String { + format!( + "{chrome_class} ux-popover-bridge ux-popover-bridge-{} {}", + position.side.class_token(), + position.bridge_corner_class() + ) +} + +#[derive(Clone, Copy, Debug)] +struct RectSnapshot { + x: f64, + y: f64, + width: f64, + height: f64, +} + +impl RectSnapshot { + fn from_dom_rect(rect: web_sys::DomRect) -> Self { + Self { + x: rect.x(), + y: rect.y(), + width: rect.width(), + height: rect.height(), + } + } + + fn is_empty(self) -> bool { + self.width < 1.0 || self.height < 1.0 + } +} + +#[derive(Clone, Copy, Debug)] +struct SizeSnapshot { + width: f64, + height: f64, +} + +impl SizeSnapshot { + fn fallback() -> Self { + Self { + width: FALLBACK_PANEL_WIDTH_PX, + height: FALLBACK_PANEL_HEIGHT_PX, + } + } + + fn from_pixels_rect(rect: dioxus::html::geometry::PixelsRect) -> Self { + Self { + width: rect.size.width, + height: rect.size.height, + } + } + + fn from_dom_rect(rect: web_sys::DomRect) -> Self { + Self { + width: rect.width(), + height: rect.height(), + } + } + + fn is_empty(self) -> bool { + self.width < 1.0 || self.height < 1.0 + } +} + +#[derive(Clone, Copy, Debug)] +struct PopoverPosition { + left: f64, + top: f64, + bridge_left: f64, + bridge_top: f64, + bridge_width: f64, + bridge_height: f64, + visible: bool, + side: PopoverSide, + show_left_corner: bool, + show_right_corner: bool, +} + +impl PopoverPosition { + fn hidden(placement: PopoverPlacement) -> Self { + Self { + left: 0.0, + top: 0.0, + bridge_left: 0.0, + bridge_top: 0.0, + bridge_width: 0.0, + bridge_height: POPOVER_BORDER_WIDTH_PX, + visible: false, + side: placement.side(), + show_left_corner: true, + show_right_corner: true, + } + } + + fn from_anchor(anchor: RectSnapshot, panel: SizeSnapshot, placement: PopoverPlacement) -> Self { + let (viewport_width, viewport_height) = viewport_size(); + let side = placement.side().resolve(anchor, panel, viewport_height); + let top = side.panel_top(anchor, panel, viewport_height); + let horizontal = + HorizontalAttachment::from_anchor(anchor, panel, placement.align(), viewport_width); + let bridge_top = side.bridge_top(anchor); + + Self { + left: horizontal.left, + top, + bridge_left: anchor.x, + bridge_top, + bridge_width: anchor.width, + bridge_height: POPOVER_BORDER_WIDTH_PX, + visible: true, + side, + show_left_corner: horizontal.show_left_corner, + show_right_corner: horizontal.show_right_corner, + } + } + + fn style(self) -> String { + let visibility = if self.visible { "visible" } else { "hidden" }; + format!( + "left: {:.1}px; top: {:.1}px; visibility: {visibility};", + self.left, self.top + ) + } + + fn bridge_style(self) -> String { + let visibility = if self.visible { "visible" } else { "hidden" }; + format!( + "left: {:.1}px; top: {:.1}px; width: {:.1}px; height: {:.1}px; visibility: {visibility};", + self.bridge_left, self.bridge_top, self.bridge_width, self.bridge_height + ) + } + + fn panel_corner_class(self) -> &'static str { + match (self.side, self.show_left_corner, self.show_right_corner) { + (PopoverSide::Below, false, true) => "ux-attached-popover-panel-square-top-left", + (PopoverSide::Below, true, false) => "ux-attached-popover-panel-square-top-right", + (PopoverSide::Above, false, true) => "ux-attached-popover-panel-square-bottom-left", + (PopoverSide::Above, true, false) => "ux-attached-popover-panel-square-bottom-right", + _ => "", + } + } + + fn bridge_corner_class(self) -> &'static str { + match (self.show_left_corner, self.show_right_corner) { + (false, true) => "ux-popover-bridge-no-left-corner", + (true, false) => "ux-popover-bridge-no-right-corner", + _ => "", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PopoverSide { + Above, + Below, +} + +impl PopoverSide { + fn class_token(self) -> &'static str { + match self { + Self::Above => "above", + Self::Below => "below", + } + } + + fn resolve(self, anchor: RectSnapshot, panel: SizeSnapshot, viewport_height: f64) -> Self { + let max_top = viewport_height - panel.height - POPOVER_MARGIN_PX; + let below_top = Self::Below.viewport_panel_top(anchor, panel); + let above_top = Self::Above.viewport_panel_top(anchor, panel); + let below_fits = below_top <= max_top; + let above_fits = above_top >= POPOVER_MARGIN_PX; + + match self { + Self::Below if below_fits || !above_fits => Self::Below, + Self::Below => Self::Above, + Self::Above if above_fits || !below_fits => Self::Above, + Self::Above => Self::Below, + } + } + + fn viewport_panel_top(self, anchor: RectSnapshot, panel: SizeSnapshot) -> f64 { + match self { + Self::Below => anchor.y + anchor.height - POPOVER_BORDER_WIDTH_PX, + Self::Above => anchor.y - panel.height + POPOVER_BORDER_WIDTH_PX, + } + } + + fn panel_top(self, anchor: RectSnapshot, panel: SizeSnapshot, viewport_height: f64) -> f64 { + let max_top = (viewport_height - panel.height - POPOVER_MARGIN_PX).max(POPOVER_MARGIN_PX); + self.viewport_panel_top(anchor, panel) + .clamp(POPOVER_MARGIN_PX, max_top) + } + + fn bridge_top(self, anchor: RectSnapshot) -> f64 { + match self { + Self::Below => anchor.y + anchor.height - POPOVER_BORDER_WIDTH_PX, + Self::Above => anchor.y, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PopoverAlign { + Start, + Middle, + End, +} + +impl PopoverPlacement { + fn side(self) -> PopoverSide { + match self { + Self::TopStart | Self::TopMiddle | Self::TopEnd => PopoverSide::Above, + Self::BottomStart | Self::BottomMiddle | Self::BottomEnd => PopoverSide::Below, + } + } + + fn align(self) -> PopoverAlign { + match self { + Self::TopStart | Self::BottomStart => PopoverAlign::Start, + Self::TopMiddle | Self::BottomMiddle => PopoverAlign::Middle, + Self::TopEnd | Self::BottomEnd => PopoverAlign::End, + } + } +} + +#[derive(Clone, Copy, Debug)] +struct HorizontalAttachment { + left: f64, + show_left_corner: bool, + show_right_corner: bool, +} + +impl HorizontalAttachment { + fn from_anchor( + anchor: RectSnapshot, + panel: SizeSnapshot, + align: PopoverAlign, + viewport_width: f64, + ) -> Self { + let desired_viewport_left = match align { + PopoverAlign::Start => anchor.x, + PopoverAlign::Middle => anchor.x + (anchor.width - panel.width) / 2.0, + PopoverAlign::End => anchor.x + anchor.width - panel.width, + }; + let max_left = (viewport_width - panel.width - POPOVER_MARGIN_PX).max(POPOVER_MARGIN_PX); + let viewport_left = desired_viewport_left.clamp(POPOVER_MARGIN_PX, max_left); + Self::from_viewport_left(anchor, panel, viewport_left, desired_viewport_left) + } + + fn from_viewport_left( + anchor: RectSnapshot, + panel: SizeSnapshot, + viewport_left: f64, + desired_viewport_left: f64, + ) -> Self { + let bridge_left_in_panel = anchor.x - viewport_left; + let bridge_right_in_panel = bridge_left_in_panel + anchor.width; + let corner_clearance = (POPOVER_CORNER_RADIUS_PX - POPOVER_BORDER_WIDTH_PX).max(0.0); + let show_left_corner = bridge_left_in_panel >= corner_clearance; + let show_right_corner = panel.width - bridge_right_in_panel >= corner_clearance; + + match (show_left_corner, show_right_corner) { + (true, true) | (false, true) | (true, false) => Self { + left: viewport_left, + show_left_corner, + show_right_corner, + }, + (false, false) => Self::nearest_edge(anchor, panel, desired_viewport_left), + } + } + + fn nearest_edge(anchor: RectSnapshot, panel: SizeSnapshot, desired_viewport_left: f64) -> Self { + let start_viewport_left = anchor.x; + let end_viewport_left = anchor.x + anchor.width - panel.width; + if (desired_viewport_left - start_viewport_left).abs() + <= (desired_viewport_left - end_viewport_left).abs() + { + return Self { + left: start_viewport_left, + show_left_corner: false, + show_right_corner: true, + }; + } + + Self { + left: end_viewport_left, + show_left_corner: true, + show_right_corner: false, + } + } +} + +fn viewport_size() -> (f64, f64) { + let Some(window) = web_sys::window() else { + return (1024.0, 768.0); + }; + let width = window + .inner_width() + .ok() + .and_then(|value| value.as_f64()) + .unwrap_or(1024.0); + let height = window + .inner_height() + .ok() + .and_then(|value| value.as_f64()) + .unwrap_or(768.0); + (width, height) +} diff --git a/lp-app/lpa-studio-web/src/base/popover_stories.rs b/lp-app/lpa-studio-web/src/base/popover_stories.rs new file mode 100644 index 000000000..fe82e1e59 --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/popover_stories.rs @@ -0,0 +1,93 @@ +//! Base popover stories. +//! +//! This file is intentionally small because it is the canonical example of the +//! path-inferred story contract: `base/popover_stories.rs#edge_placement` +//! becomes `base/popover/edge-placement`. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::base::{IconPopoverButton, PopoverPlacement, StudioIconName}; + +#[story] +fn edge_placement() -> Element { + rsx! { + section { class: "ux-popover-story", + div { class: "ux-popover-story-grid", + div { class: "ux-popover-story-cell ux-popover-story-cell-start", + PopoverStoryButton { + label: "Start edge", + placement: PopoverPlacement::BottomStart, + } + } + div { class: "ux-popover-story-cell ux-popover-story-cell-center", + PopoverStoryButton { + label: "Center", + placement: PopoverPlacement::BottomMiddle, + } + } + div { class: "ux-popover-story-cell ux-popover-story-cell-end", + PopoverStoryButton { + label: "End edge", + placement: PopoverPlacement::BottomEnd, + } + } + div { class: "ux-popover-story-cell ux-popover-story-cell-lower-end", + PopoverStoryButton { + label: "Lower edge", + placement: PopoverPlacement::BottomEnd, + } + } + } + } + } +} + +#[story(description = "An open popover positioned near its trigger.")] +fn open_popover() -> Element { + rsx! { + section { class: "tw:min-h-80 tw:pt-16", + div { class: "tw:flex tw:justify-end tw:pr-24", + PopoverStoryButton { + label: "Open", + placement: PopoverPlacement::BottomEnd, + initially_open: true, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn PopoverStoryButton( + label: &'static str, + placement: PopoverPlacement, + #[props(default = false)] initially_open: bool, +) -> Element { + rsx! { + IconPopoverButton { + class: "ux-node-ui-popup-trigger".to_string(), + open_class: "ux-node-ui-popup-trigger ux-node-ui-popup-trigger-open".to_string(), + icon: StudioIconName::BoundValue, + icon_size: 13, + label: format!("{label} details"), + title: format!("{label} details"), + popup_class: "ux-node-ui-popup ux-popover-story-panel".to_string(), + chrome_class: "ux-popover-chrome-accent".to_string(), + placement, + initially_open, + div { class: "ux-node-ui-popup-kicker", "popover" } + strong { "{label}" } + p { "This panel is attached from the browser top layer." } + div { class: "ux-node-ui-binding-section ux-node-ui-bus-binding-section", + div { class: "ux-node-ui-binding-heading", "example binding" } + div { class: "ux-node-ui-bus-binding-row", + span { "bus#" } + code { "visual.out" } + button { r#type: "button", "del" } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/base/tabs.rs b/lp-app/lpa-studio-web/src/base/tabs.rs new file mode 100644 index 000000000..ab83cc65c --- /dev/null +++ b/lp-app/lpa-studio-web/src/base/tabs.rs @@ -0,0 +1,62 @@ +use dioxus::prelude::*; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn Tabs(tabs: Vec, initial: usize) -> Element { + let initial = initial.min(tabs.len().saturating_sub(1)); + let mut active = use_signal(|| initial); + let active_index = active().min(tabs.len().saturating_sub(1)); + let active_tab = tabs.get(active_index).cloned(); + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-3", + div { class: "tw:flex tw:flex-wrap tw:gap-2", role: "tablist", + for (index, tab) in tabs.clone().into_iter().enumerate() { + button { + class: tab_class(index == active_index), + r#type: "button", + role: "tab", + aria_selected: "{index == active_index}", + onclick: move |_| active.set(index), + "{tab.label}" + } + } + } + if let Some(tab) = active_tab { + div { class: "tw:grid tw:min-w-0 tw:gap-2 tw:rounded-sm tw:border tw:border-border-subtle tw:bg-card-muted tw:p-3", role: "tabpanel", + h3 { class: "tw:m-0 tw:text-base tw:font-bold tw:text-strong-foreground", "{tab.title}" } + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-muted-foreground", "{tab.body}" } + } + } + } + } +} + +fn tab_class(active: bool) -> &'static str { + if active { + "tw:min-h-8 tw:rounded-sm tw:border tw:border-accent-border tw:bg-status-good-bg tw:px-3 tw:text-sm tw:font-bold tw:text-strong-foreground" + } else { + "tw:min-h-8 tw:rounded-sm tw:border tw:border-border-strong tw:bg-transparent tw:px-3 tw:text-sm tw:text-muted-foreground tw:hover:bg-card-muted" + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TabItem { + pub label: String, + pub title: String, + pub body: String, +} + +impl TabItem { + pub fn new( + label: impl Into, + title: impl Into, + body: impl Into, + ) -> Self { + Self { + label: label.into(), + title: title.into(), + body: body.into(), + } + } +} diff --git a/lp-app/lpa-studio-web/src/components/action_button.rs b/lp-app/lpa-studio-web/src/components/action_button.rs deleted file mode 100644 index fdb2a79ce..000000000 --- a/lp-app/lpa-studio-web/src/components/action_button.rs +++ /dev/null @@ -1,73 +0,0 @@ -use dioxus::prelude::*; -use lpa_studio_ux::{ActionEnablement, ActionPriority, UiAction}; - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn ActionButton(action: UiAction, running: bool, on_action: EventHandler) -> Element { - let action_to_run = action.clone(); - let meta = action.meta().clone(); - let disabled = running || !meta.enablement.is_enabled(); - let class = action_class(meta.priority); - let disabled_reason = disabled_reason(&meta.enablement).map(ToString::to_string); - let icon_class = action_icon_class(meta.icon.as_deref()); - let confirmation = meta.confirmation.clone(); - let label = meta.label; - let summary = meta.summary; - - rsx! { - div { class: "ux-action-item", - button { - class, - r#type: "button", - disabled, - title: "{summary}", - onclick: move |_| { - if confirmation_confirmed(confirmation.as_ref()) { - on_action.call(action_to_run.clone()); - } - }, - if let Some(icon_class) = icon_class { - span { class: "{icon_class}", aria_hidden: "true" } - } - span { "{label}" } - } - if let Some(reason) = disabled_reason.as_ref() { - p { class: "ux-disabled-reason", "{reason}" } - } - } - } -} - -fn confirmation_confirmed(confirmation: Option<&lpa_studio_ux::ActionConfirmation>) -> bool { - let Some(confirmation) = confirmation else { - return true; - }; - let message = format!("{}\n\n{}", confirmation.title, confirmation.message); - web_sys::window() - .and_then(|window| window.confirm_with_message(&message).ok()) - .unwrap_or(false) -} - -fn action_class(priority: ActionPriority) -> &'static str { - match priority { - ActionPriority::Primary => "ux-action ux-action-primary", - ActionPriority::Secondary => "ux-action ux-action-secondary", - ActionPriority::Tertiary => "ux-action ux-action-tertiary", - } -} - -fn disabled_reason(enablement: &ActionEnablement) -> Option<&str> { - match enablement { - ActionEnablement::Enabled => None, - ActionEnablement::Disabled { reason } => Some(reason.as_str()), - } -} - -fn action_icon_class(icon: Option<&str>) -> Option<&'static str> { - match icon { - Some("play") => Some("ux-action-icon ux-action-icon-play"), - Some("usb") => Some("ux-action-icon ux-action-icon-usb"), - Some("test-tube") => Some("ux-action-icon ux-action-icon-test"), - _ => None, - } -} diff --git a/lp-app/lpa-studio-web/src/components/mod.rs b/lp-app/lpa-studio-web/src/components/mod.rs deleted file mode 100644 index f1a740a49..000000000 --- a/lp-app/lpa-studio-web/src/components/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -//! Dioxus components for the active Studio UX shell. - -mod action_button; -mod action_strip; -mod runtime_log; -mod studio_shell; -mod ux_pane; - -pub use action_button::ActionButton; -pub use action_strip::ActionStrip; -pub use runtime_log::RuntimeLog; -pub use studio_shell::StudioShell; -pub use ux_pane::UxPane; diff --git a/lp-app/lpa-studio-web/src/components/runtime_log.rs b/lp-app/lpa-studio-web/src/components/runtime_log.rs deleted file mode 100644 index f426138dd..000000000 --- a/lp-app/lpa-studio-web/src/components/runtime_log.rs +++ /dev/null @@ -1,102 +0,0 @@ -use dioxus::prelude::*; -use dioxus::{html::geometry::PixelsVector2D, prelude::dioxus_core::use_after_render}; -use lpa_studio_ux::{UxLogEntry, UxLogLevel}; -use std::rc::Rc; - -const LOG_STICKY_THRESHOLD_PX: f64 = 48.0; -const LOG_ENTRY_LIMIT: usize = 80; - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn RuntimeLog(logs: Vec) -> Element { - let visible_logs = log_tail(logs, LOG_ENTRY_LIMIT); - let mut log_element = use_signal(|| None::>); - let mut stick_to_bottom = use_signal(|| true); - - use_after_render(move || { - if !stick_to_bottom() { - return; - } - - let Some(element) = log_element.read().as_ref().cloned() else { - return; - }; - - spawn(async move { - let Ok(scroll_size) = element.get_scroll_size().await else { - return; - }; - let coordinates = PixelsVector2D::new(0.0, scroll_size.height); - let _ = element.scroll(coordinates, ScrollBehavior::Instant).await; - }); - }); - - rsx! { - section { class: "ux-log-panel", - div { class: "ux-log-heading", - p { "Console" } - } - if visible_logs.is_empty() { - ol { - class: "ux-log-list", - onmounted: move |event| { - log_element.set(Some(event.data())); - }, - li { class: "ux-log ux-log-empty", - span { "idle" } - strong { "studio" } - p { "No messages yet." } - } - } - } else { - ol { - class: "ux-log-list", - onmounted: move |event| { - log_element.set(Some(event.data())); - }, - onscroll: move |event| { - stick_to_bottom.set(is_log_near_bottom( - event.scroll_top(), - event.scroll_height(), - event.client_height(), - )); - }, - for entry in visible_logs.iter() { - li { class: log_class(entry.level), - span { "{log_level_label(entry.level)}" } - strong { "{entry.source}" } - p { "{entry.message}" } - } - } - } - } - } - } -} - -fn log_tail(logs: Vec, max_entries: usize) -> Vec { - let skip_count = logs.len().saturating_sub(max_entries); - logs.into_iter().skip(skip_count).collect() -} - -fn is_log_near_bottom(scroll_top: f64, scroll_height: i32, client_height: i32) -> bool { - f64::from(scroll_height) - scroll_top - f64::from(client_height) <= LOG_STICKY_THRESHOLD_PX -} - -fn log_level_label(level: UxLogLevel) -> &'static str { - match level { - UxLogLevel::Debug => "debug", - UxLogLevel::Info => "info", - UxLogLevel::Warn => "warn", - UxLogLevel::Error => "error", - } -} - -fn log_class(level: UxLogLevel) -> &'static str { - match level { - UxLogLevel::Debug => "ux-log ux-log-debug", - UxLogLevel::Info => "ux-log ux-log-info", - UxLogLevel::Warn => "ux-log ux-log-warn", - UxLogLevel::Error => "ux-log ux-log-error", - } -} diff --git a/lp-app/lpa-studio-web/src/components/studio_shell.rs b/lp-app/lpa-studio-web/src/components/studio_shell.rs deleted file mode 100644 index b06cf54e8..000000000 --- a/lp-app/lpa-studio-web/src/components/studio_shell.rs +++ /dev/null @@ -1,74 +0,0 @@ -use dioxus::prelude::*; -use lpa_studio_ux::{DeviceUx, StudioView, UiAction, UiPaneView}; - -use crate::components::{RuntimeLog, UxPane}; - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn StudioShell(view: StudioView, running: bool, on_action: EventHandler) -> Element { - let StudioView { panes, logs } = view; - let PaneGroups { main, device } = group_panes(panes); - let layout_class = if main.is_empty() { - "ux-layout ux-layout-device-only" - } else { - "ux-layout ux-layout-main-device" - }; - let device_is_primary = main.is_empty(); - - rsx! { - main { class: "ux-shell", - header { class: "ux-header", - div { - p { class: "ux-eyebrow", "LightPlayer Studio" } - } - } - - section { class: "{layout_class}", - if !main.is_empty() { - div { class: "ux-main-column", - for (index, pane) in main.into_iter().enumerate() { - UxPane { - key: "{pane.node_id}", - view: pane, - primary: index == 0, - running, - on_action, - } - } - } - } - - div { class: "ux-device-column", - if let Some(device) = device { - UxPane { - key: "{device.node_id}", - view: device, - primary: device_is_primary, - running, - on_action, - } - } - RuntimeLog { logs } - } - } - } - } -} - -struct PaneGroups { - main: Vec, - device: Option, -} - -fn group_panes(panes: Vec) -> PaneGroups { - let mut main = Vec::new(); - let mut device = None; - for pane in panes { - if pane.node_id.as_str() == DeviceUx::NODE_ID { - device = Some(pane); - } else { - main.push(pane); - } - } - PaneGroups { main, device } -} diff --git a/lp-app/lpa-studio-web/src/components/ux_pane.rs b/lp-app/lpa-studio-web/src/components/ux_pane.rs deleted file mode 100644 index 37a8c7dd5..000000000 --- a/lp-app/lpa-studio-web/src/components/ux_pane.rs +++ /dev/null @@ -1,260 +0,0 @@ -use dioxus::prelude::*; -use lpa_studio_ux::{ - UiAction, UiActivity, UiActivityStepState, UiBody, UiPaneView, UiProgress, UiStackView, - UiStatus, UiStatusKind, UiStepState, -}; - -use crate::components::ActionStrip; - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -pub fn UxPane( - view: UiPaneView, - primary: bool, - running: bool, - on_action: EventHandler, -) -> Element { - let UiPaneView { - title, - status, - body, - actions, - .. - } = view; - let panel_class = if primary { - "ux-panel ux-panel-primary" - } else { - "ux-panel" - }; - - rsx! { - section { class: "{panel_class}", - div { class: "ux-panel-heading", - p { "{title}" } - UxStatusChip { status } - } - UxPaneBody { - body, - running, - on_action, - } - if !actions.is_empty() { - ActionStrip { - actions, - running, - on_action, - } - } - } - } -} - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn UxStatusChip(status: UiStatus) -> Element { - rsx! { - span { class: "{status_class(status.kind)}", "{status.label}" } - } -} - -fn status_class(kind: UiStatusKind) -> &'static str { - match kind { - UiStatusKind::Neutral => "ux-status ux-status-neutral", - UiStatusKind::Working => "ux-status ux-status-working", - UiStatusKind::Good => "ux-status ux-status-good", - UiStatusKind::Warning => "ux-status ux-status-warning", - UiStatusKind::Error => "ux-status ux-status-error", - } -} - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn UxPaneBody(body: UiBody, running: bool, on_action: EventHandler) -> Element { - match body { - UiBody::Empty => rsx! {}, - UiBody::Text(text) => rsx! { - p { class: "ux-panel-copy", "{text}" } - }, - UiBody::Progress(progress) => { - let label = progress.label; - let detail = progress.detail; - rsx! { - p { class: "ux-panel-copy", "{label}" } - if let Some(detail) = detail.as_ref() { - p { class: "ux-panel-copy ux-panel-detail", "{detail}" } - } - } - } - UiBody::Activity(activity) => rsx! { - UxActivityBody { activity } - }, - UiBody::Issue(issue) => { - let message = issue.message; - let detail = issue.detail; - rsx! { - p { class: "ux-panel-copy ux-panel-issue", "{message}" } - if let Some(detail) = detail.as_ref() { - p { class: "ux-panel-copy ux-panel-detail", "{detail}" } - } - } - } - UiBody::Metrics(metrics) => rsx! { - dl { class: "ux-metrics", - for metric in metrics { - div { - dt { "{metric.label}" } - dd { "{metric.value}" } - } - } - } - }, - UiBody::Stack(stack) => rsx! { - UxStackBody { - stack: *stack, - running, - on_action, - } - }, - } -} - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn UxStackBody(stack: UiStackView, running: bool, on_action: EventHandler) -> Element { - let sections = stack - .sections - .into_iter() - .enumerate() - .map(|(index, section)| (index + 1, section)) - .collect::>(); - - rsx! { - div { class: "ux-stack", - ol { class: "ux-stack-sections", - for (step_number, section) in sections { - li { class: "{stack_section_class(section.state)}", - div { class: "ux-stack-section-marker", "{step_number}" } - div { class: "ux-stack-section-content", - h3 { "{section.title}" } - div { class: "ux-stack-section-body", - UxPaneBody { - body: section.body, - running, - on_action, - } - } - if !section.actions.is_empty() { - ActionStrip { - actions: section.actions, - running, - on_action, - } - } - } - } - } - } - } - } -} - -fn stack_section_class(state: UiStepState) -> &'static str { - match state { - UiStepState::Pending => "ux-stack-section ux-stack-section-pending", - UiStepState::Active => "ux-stack-section ux-stack-section-active", - UiStepState::Complete => "ux-stack-section ux-stack-section-complete", - UiStepState::NeedsAttention => "ux-stack-section ux-stack-section-attention", - } -} - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn UxActivityBody(activity: UiActivity) -> Element { - let title = activity.title; - let detail = activity.detail; - let progress = activity.progress; - let steps = activity.steps; - - rsx! { - div { class: "ux-activity", - p { class: "ux-panel-copy ux-activity-title", "{title}" } - if let Some(detail) = detail.as_ref() { - p { class: "ux-panel-copy ux-panel-detail", "{detail}" } - } - if let Some(progress) = progress { - UxProgressBar { progress } - } - if !steps.is_empty() { - ol { class: "ux-activity-steps", - for step in steps { - li { class: "{activity_step_class(step.state)}", - span { class: "ux-activity-step-marker", "{activity_step_marker(step.state)}" } - div { class: "ux-activity-step-copy", - span { "{step.label}" } - if let Some(detail) = step.detail.as_ref() { - small { "{detail}" } - } - } - } - } - } - } - } - } -} - -fn activity_step_class(state: UiActivityStepState) -> &'static str { - match state { - UiActivityStepState::Pending => "ux-activity-step ux-activity-step-pending", - UiActivityStepState::Active => "ux-activity-step ux-activity-step-active", - UiActivityStepState::Complete => "ux-activity-step ux-activity-step-complete", - UiActivityStepState::Failed => "ux-activity-step ux-activity-step-failed", - } -} - -fn activity_step_marker(state: UiActivityStepState) -> &'static str { - state.text_marker() -} - -#[component] -#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn UxProgressBar(progress: UiProgress) -> Element { - let label = progress.label; - let detail = progress.detail; - let percent = progress.percent; - let timeout_ms = progress.timeout_ms.unwrap_or(0); - let bar_class = if percent.is_some() { - "ux-progress-fill ux-progress-fill-determinate" - } else if progress.timeout_ms.is_some() { - "ux-progress-fill ux-progress-fill-timeout" - } else { - "ux-progress-fill ux-progress-fill-indeterminate" - }; - let fill_style = match (percent, progress.timeout_ms) { - (Some(percent), _) => format!("width: {}%;", percent.min(100)), - (None, Some(_)) => "width: 100%;".to_string(), - (None, None) => String::new(), - }; - let timeout_style = if timeout_ms > 0 { - format!("animation-duration: {timeout_ms}ms;") - } else { - String::new() - }; - - rsx! { - div { class: "ux-progress", - div { class: "ux-progress-meta", - span { "{label}" } - if let Some(percent) = percent { - strong { "{percent.min(100)}%" } - } - } - div { class: "ux-progress-track", - div { class: "{bar_class}", style: "{fill_style}{timeout_style}" } - } - if let Some(detail) = detail.as_ref() { - p { class: "ux-progress-detail", "{detail}" } - } - } - } -} diff --git a/lp-app/lpa-studio-web/src/core/action/action_button.rs b/lp-app/lpa-studio-web/src/core/action/action_button.rs new file mode 100644 index 000000000..5a5e3b152 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/action/action_button.rs @@ -0,0 +1,77 @@ +use dioxus::prelude::*; +use lpa_studio_core::{ActionEnablement, ActionPriority, UiAction}; + +use crate::base::{StudioIcon, action_icon_name}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ActionButton(action: UiAction, running: bool, on_action: EventHandler) -> Element { + let action_to_run = action.clone(); + let meta = action.meta().clone(); + let disabled = running || !meta.enablement.is_enabled(); + let class = action_class(meta.priority); + let disabled_reason = disabled_reason(&meta.enablement).map(ToString::to_string); + let icon = action_icon_name(meta.icon.as_deref()); + let confirmation = meta.confirmation.clone(); + let label = meta.label; + let summary = meta.summary; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-1", + button { + class, + r#type: "button", + disabled, + title: "{summary}", + onclick: move |_| { + if confirmation_confirmed(confirmation.as_ref()) { + on_action.call(action_to_run.clone()); + } + }, + if let Some(icon) = icon { + span { class: "tw:inline-flex tw:h-[15px] tw:w-[15px] tw:items-center tw:justify-center", aria_hidden: "true", + StudioIcon { + name: icon, + size: 15, + } + } + } + span { "{label}" } + } + if let Some(reason) = disabled_reason.as_ref() { + p { class: "tw:m-0 tw:text-xs tw:leading-snug tw:text-dim-foreground", "{reason}" } + } + } + } +} + +fn confirmation_confirmed(confirmation: Option<&lpa_studio_core::ActionConfirmation>) -> bool { + let Some(confirmation) = confirmation else { + return true; + }; + let message = format!("{}\n\n{}", confirmation.title, confirmation.message); + web_sys::window() + .and_then(|window| window.confirm_with_message(&message).ok()) + .unwrap_or(false) +} + +fn action_class(priority: ActionPriority) -> &'static str { + match priority { + ActionPriority::Primary => { + "tw:inline-flex tw:min-h-9 tw:max-w-full tw:items-center tw:justify-center tw:gap-2 tw:rounded-sm tw:border tw:border-accent-border tw:bg-accent tw:px-3 tw:text-sm tw:font-bold tw:leading-none tw:text-accent-foreground tw:break-words tw:hover:bg-accent-hover tw:disabled:cursor-not-allowed tw:disabled:opacity-60" + } + ActionPriority::Secondary => { + "tw:inline-flex tw:min-h-9 tw:max-w-full tw:items-center tw:justify-center tw:gap-2 tw:rounded-sm tw:border tw:border-border-strong tw:bg-card-raised tw:px-3 tw:text-sm tw:font-bold tw:leading-none tw:text-soft-foreground tw:break-words tw:hover:bg-card-raised-strong tw:disabled:cursor-not-allowed tw:disabled:opacity-60" + } + ActionPriority::Tertiary => { + "tw:inline-flex tw:min-h-9 tw:max-w-full tw:items-center tw:justify-center tw:gap-2 tw:rounded-sm tw:border tw:border-border-strong tw:bg-transparent tw:px-3 tw:text-sm tw:font-bold tw:leading-none tw:text-muted-foreground tw:break-words tw:hover:bg-card-muted tw:disabled:cursor-not-allowed tw:disabled:opacity-60" + } + } +} + +fn disabled_reason(enablement: &ActionEnablement) -> Option<&str> { + match enablement { + ActionEnablement::Enabled => None, + ActionEnablement::Disabled { reason } => Some(reason.as_str()), + } +} diff --git a/lp-app/lpa-studio-web/src/components/action_strip.rs b/lp-app/lpa-studio-web/src/core/action/action_strip.rs similarity index 67% rename from lp-app/lpa-studio-web/src/components/action_strip.rs rename to lp-app/lpa-studio-web/src/core/action/action_strip.rs index 619a5635e..62641e0a7 100644 --- a/lp-app/lpa-studio-web/src/components/action_strip.rs +++ b/lp-app/lpa-studio-web/src/core/action/action_strip.rs @@ -1,7 +1,7 @@ use dioxus::prelude::*; -use lpa_studio_ux::UiAction; +use lpa_studio_core::UiAction; -use crate::components::ActionButton; +use crate::core::ActionButton; #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] @@ -11,9 +11,9 @@ pub fn ActionStrip( on_action: EventHandler, ) -> Element { rsx! { - div { class: "ux-actions", + div { class: "tw:flex tw:flex-wrap tw:items-start tw:gap-2", if actions.is_empty() { - p { class: "ux-panel-copy", "No actions are currently available." } + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-muted-foreground", "No actions are currently available." } } else { for action in actions { ActionButton { diff --git a/lp-app/lpa-studio-web/src/core/action/action_strip_stories.rs b/lp-app/lpa-studio-web/src/core/action/action_strip_stories.rs new file mode 100644 index 000000000..d9c43989e --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/action/action_strip_stories.rs @@ -0,0 +1,51 @@ +//! Stories for generic action rendering. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::core::ActionStrip; +use crate::core::story_fixtures::{confirmation_action, disabled_action, story_actions}; + +#[story] +pub(crate) fn priorities() -> Element { + rsx! { + ActionStrip { + actions: story_actions(), + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn disabled_reason() -> Element { + rsx! { + ActionStrip { + actions: vec![disabled_action()], + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn running_state() -> Element { + rsx! { + ActionStrip { + actions: story_actions(), + running: true, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn confirmation() -> Element { + rsx! { + ActionStrip { + actions: vec![confirmation_action()], + running: false, + on_action: move |_| {}, + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/action/mod.rs b/lp-app/lpa-studio-web/src/core/action/mod.rs new file mode 100644 index 000000000..842112ff2 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/action/mod.rs @@ -0,0 +1,9 @@ +//! Action controls for generic `UiAction` values. + +pub mod action_button; +pub mod action_strip; +#[cfg(feature = "stories")] +pub(crate) mod action_strip_stories; + +pub use action_button::ActionButton; +pub use action_strip::ActionStrip; diff --git a/lp-app/lpa-studio-web/src/core/issue_view.rs b/lp-app/lpa-studio-web/src/core/issue_view.rs new file mode 100644 index 000000000..507ed2432 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/issue_view.rs @@ -0,0 +1,18 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiIssue; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn IssueView(issue: UiIssue) -> Element { + let message = issue.message; + let detail = issue.detail; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-1", + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-status-error-foreground", "{message}" } + if let Some(detail) = detail.as_ref() { + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-subtle-foreground", "{detail}" } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/issue_view_stories.rs b/lp-app/lpa-studio-web/src/core/issue_view_stories.rs new file mode 100644 index 000000000..7e9962325 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/issue_view_stories.rs @@ -0,0 +1,24 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiIssue; +use lpa_studio_web_story_macros::story; + +use crate::core::IssueView; + +#[story] +pub(crate) fn message_only() -> Element { + rsx! { + IssueView { + issue: UiIssue::new("No LightPlayer firmware detected."), + } + } +} + +#[story] +pub(crate) fn with_detail() -> Element { + rsx! { + IssueView { + issue: UiIssue::new("Firmware flashing failed") + .with_detail("Check the cable, boot mode, and browser serial permission."), + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/log_list.rs b/lp-app/lpa-studio-web/src/core/log_list.rs new file mode 100644 index 000000000..2baf14c96 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/log_list.rs @@ -0,0 +1,108 @@ +use std::rc::Rc; + +use dioxus::prelude::*; +use dioxus::{html::geometry::PixelsVector2D, prelude::dioxus_core::use_after_render}; +use lpa_studio_core::{UiLogEntry, UiLogLevel}; + +const LOG_STICKY_THRESHOLD_PX: f64 = 48.0; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn LogList( + logs: Vec, + max_entries: usize, + #[props(default = true)] framed: bool, +) -> Element { + let visible_logs = log_tail(logs, max_entries); + let mut log_element = use_signal(|| None::>); + let mut stick_to_bottom = use_signal(|| true); + let list_class = if framed { + "tw:m-0 tw:grid tw:max-h-80 tw:gap-0 tw:overflow-auto tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-0 tw:list-none" + } else { + "tw:m-0 tw:grid tw:max-h-80 tw:gap-0 tw:overflow-auto tw:bg-transparent tw:p-0 tw:list-none" + }; + + use_after_render(move || { + if !stick_to_bottom() { + return; + } + + let Some(element) = log_element.read().as_ref().cloned() else { + return; + }; + + spawn(async move { + let Ok(scroll_size) = element.get_scroll_size().await else { + return; + }; + let coordinates = PixelsVector2D::new(0.0, scroll_size.height); + let _ = element.scroll(coordinates, ScrollBehavior::Instant).await; + }); + }); + + rsx! { + ol { + class: "{list_class}", + onmounted: move |event| { + log_element.set(Some(event.data())); + }, + onscroll: move |event| { + stick_to_bottom.set(is_log_near_bottom( + event.scroll_top(), + event.scroll_height(), + event.client_height(), + )); + }, + if visible_logs.is_empty() { + li { class: "tw:grid tw:grid-cols-[52px_72px_minmax(0,1fr)] tw:gap-2 tw:border-b tw:border-border-muted tw:px-3 tw:py-2 tw:text-sm tw:text-subtle-foreground", + span { class: "tw:font-mono tw:text-xs tw:uppercase", "idle" } + strong { class: "tw:text-xs tw:text-dim-foreground", "studio" } + p { class: "tw:m-0 tw:min-w-0 tw:break-words", "No messages yet." } + } + } else { + for entry in visible_logs.iter() { + li { class: log_class(entry.level), + span { class: "tw:font-mono tw:text-xs tw:uppercase", "{log_level_label(entry.level)}" } + strong { class: "tw:text-xs tw:text-dim-foreground tw:break-words", "{entry.source}" } + p { class: "tw:m-0 tw:min-w-0 tw:break-words", "{entry.message}" } + } + } + } + } + } +} + +fn log_tail(logs: Vec, max_entries: usize) -> Vec { + let skip_count = logs.len().saturating_sub(max_entries); + logs.into_iter().skip(skip_count).collect() +} + +fn is_log_near_bottom(scroll_top: f64, scroll_height: i32, client_height: i32) -> bool { + f64::from(scroll_height) - scroll_top - f64::from(client_height) <= LOG_STICKY_THRESHOLD_PX +} + +fn log_level_label(level: UiLogLevel) -> &'static str { + match level { + UiLogLevel::Debug => "debug", + UiLogLevel::Info => "info", + UiLogLevel::Warn => "warn", + UiLogLevel::Error => "error", + } +} + +fn log_class(level: UiLogLevel) -> &'static str { + match level { + UiLogLevel::Debug => { + "tw:grid tw:grid-cols-[52px_72px_minmax(0,1fr)] tw:gap-2 tw:border-b tw:border-border-muted tw:px-3 tw:py-2 tw:text-sm tw:text-subtle-foreground" + } + UiLogLevel::Info => { + "tw:grid tw:grid-cols-[52px_72px_minmax(0,1fr)] tw:gap-2 tw:border-b tw:border-border-muted tw:px-3 tw:py-2 tw:text-sm tw:text-muted-foreground" + } + UiLogLevel::Warn => { + "tw:grid tw:grid-cols-[52px_72px_minmax(0,1fr)] tw:gap-2 tw:border-b tw:border-status-warning-border tw:px-3 tw:py-2 tw:text-sm tw:text-status-warning-foreground" + } + UiLogLevel::Error => { + "tw:grid tw:grid-cols-[52px_72px_minmax(0,1fr)] tw:gap-2 tw:border-b tw:border-status-error-border tw:px-3 tw:py-2 tw:text-sm tw:text-status-error-foreground" + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/log_list_stories.rs b/lp-app/lpa-studio-web/src/core/log_list_stories.rs new file mode 100644 index 000000000..6924fb467 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/log_list_stories.rs @@ -0,0 +1,25 @@ +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::core::LogList; +use crate::core::story_fixtures::story_logs; + +#[story] +pub(crate) fn mixed_levels() -> Element { + rsx! { + LogList { + logs: story_logs(), + max_entries: 80, + } + } +} + +#[story] +pub(crate) fn empty() -> Element { + rsx! { + LogList { + logs: Vec::new(), + max_entries: 80, + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/metric_grid.rs b/lp-app/lpa-studio-web/src/core/metric_grid.rs new file mode 100644 index 000000000..2b5974ae7 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/metric_grid.rs @@ -0,0 +1,17 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiMetric; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn MetricGrid(metrics: Vec) -> Element { + rsx! { + dl { class: "tw:grid tw:grid-cols-[repeat(auto-fit,minmax(130px,1fr))] tw:gap-2 tw:m-0", + for metric in metrics { + div { class: "tw:min-w-0 tw:rounded-sm tw:border tw:border-border-subtle tw:bg-card-muted tw:p-2", + dt { class: "tw:text-[0.68rem] tw:font-bold tw:uppercase tw:leading-tight tw:text-subtle-foreground", "{metric.label}" } + dd { class: "tw:m-0 tw:mt-1 tw:text-sm tw:font-bold tw:leading-tight tw:text-status-neutral-foreground tw:break-words", "{metric.value}" } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/metric_grid_stories.rs b/lp-app/lpa-studio-web/src/core/metric_grid_stories.rs new file mode 100644 index 000000000..223db255f --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/metric_grid_stories.rs @@ -0,0 +1,33 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiMetric; +use lpa_studio_web_story_macros::story; + +use crate::core::MetricGrid; +use crate::core::story_fixtures::story_metrics; + +#[story] +pub(crate) fn compact() -> Element { + rsx! { + MetricGrid { + metrics: story_metrics(), + } + } +} + +#[story] +pub(crate) fn dense() -> Element { + rsx! { + MetricGrid { + metrics: vec![ + UiMetric::new("Runtime", "ESP32-C6"), + UiMetric::new("Protocol", "fw-browser-post-message-v1"), + UiMetric::new("Project", "studio-demo"), + UiMetric::new("Nodes", 9), + UiMetric::new("FPS", "936"), + UiMetric::new("Memory", "207k free"), + UiMetric::new("Link", "browser worker"), + UiMetric::new("Session", "worker-1"), + ], + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/mod.rs b/lp-app/lpa-studio-web/src/core/mod.rs new file mode 100644 index 000000000..6ffe38489 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/mod.rs @@ -0,0 +1,40 @@ +//! Data-driven UI controls. +//! +//! These components render generic `Ui*` structs from `lpa-studio-core`. +//! They may use `base` primitives, but should avoid owning Studio domain +//! workflows directly when an `app` component can compose them instead. + +pub mod action; +pub mod issue_view; +#[cfg(feature = "stories")] +pub(crate) mod issue_view_stories; +pub mod log_list; +#[cfg(feature = "stories")] +pub(crate) mod log_list_stories; +pub mod metric_grid; +#[cfg(feature = "stories")] +pub(crate) mod metric_grid_stories; +pub mod progress_bar; +#[cfg(feature = "stories")] +pub(crate) mod progress_bar_stories; +pub mod status_chip; +#[cfg(feature = "stories")] +pub(crate) mod status_chip_stories; +#[cfg(feature = "stories")] +pub(crate) mod story_fixtures; +pub mod terminal_output; +#[cfg(feature = "stories")] +pub(crate) mod terminal_output_stories; +pub mod view; + +pub use action::{ActionButton, ActionStrip}; +pub use issue_view::IssueView; +pub use log_list::LogList; +pub use metric_grid::MetricGrid; +pub use progress_bar::ProgressBar; +pub use status_chip::StatusChip; +pub use terminal_output::TerminalOutput; +pub use view::activity_view::ActivityView; +pub use view::pane_view::PaneView; +pub use view::stack_view::StepsView; +pub use view::view_content::ViewContent; diff --git a/lp-app/lpa-studio-web/src/core/progress_bar.rs b/lp-app/lpa-studio-web/src/core/progress_bar.rs new file mode 100644 index 000000000..9e9cc5319 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/progress_bar.rs @@ -0,0 +1,45 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiProgress; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ProgressBar(progress: UiProgress) -> Element { + let label = progress.label; + let detail = progress.detail; + let percent = progress.percent; + let timeout_ms = progress.timeout_ms.unwrap_or(0); + let bar_class = if percent.is_some() { + "tw:h-full tw:rounded-pill tw:bg-accent" + } else if progress.timeout_ms.is_some() { + "tw:h-full tw:origin-left tw:rounded-pill tw:bg-accent [animation:ux-progress-timeout_var(--ux-progress-timeout-duration)_linear_forwards]" + } else { + "tw:h-full tw:w-[35%] tw:rounded-pill tw:bg-accent [animation:ux-progress-sweep_1.2s_ease-in-out_infinite]" + }; + let fill_style = match (percent, progress.timeout_ms) { + (Some(percent), _) => format!("width: {}%;", percent.min(100)), + (None, Some(_)) => "width: 100%;".to_string(), + (None, None) => String::new(), + }; + let timeout_style = if timeout_ms > 0 { + format!("--ux-progress-timeout-duration: {timeout_ms}ms;") + } else { + String::new() + }; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-2", + div { class: "tw:flex tw:items-center tw:justify-between tw:gap-3 tw:text-sm tw:font-bold tw:text-status-working-foreground", + span { "{label}" } + if let Some(percent) = percent { + strong { "{percent.min(100)}%" } + } + } + div { class: "tw:h-2 tw:overflow-hidden tw:rounded-pill tw:border tw:border-border-strong tw:bg-track", + div { class: "{bar_class}", style: "{fill_style}{timeout_style}" } + } + if let Some(detail) = detail.as_ref() { + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-subtle-foreground", "{detail}" } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/progress_bar_stories.rs b/lp-app/lpa-studio-web/src/core/progress_bar_stories.rs new file mode 100644 index 000000000..c1a8a101e --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/progress_bar_stories.rs @@ -0,0 +1,25 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiProgress; +use lpa_studio_web_story_macros::story; + +use crate::core::ProgressBar; + +#[story] +pub(crate) fn variants() -> Element { + rsx! { + div { class: "tw:grid tw:gap-[18px]", + ProgressBar { + progress: UiProgress::indeterminate("Opening link session") + .with_detail("Waiting for the browser serial provider."), + } + ProgressBar { + progress: UiProgress::determinate("Writing firmware", 42) + .with_detail("app image at 0x10000"), + } + ProgressBar { + progress: UiProgress::timeout("Waiting for boot", 5000) + .with_detail("Studio will retry when the device responds."), + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/status_chip.rs b/lp-app/lpa-studio-web/src/core/status_chip.rs new file mode 100644 index 000000000..6c8eaec61 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/status_chip.rs @@ -0,0 +1,31 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiStatus; +use lpa_studio_core::core::status::UiStatusKind; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StatusChip(status: UiStatus) -> Element { + rsx! { + span { class: "{status_class(status.kind)}", "{status.label}" } + } +} + +pub fn status_class(kind: UiStatusKind) -> &'static str { + match kind { + UiStatusKind::Neutral => { + "tw:inline-flex tw:min-h-6 tw:max-w-full tw:flex-shrink tw:items-center tw:rounded-pill tw:border tw:border-status-neutral-border tw:bg-status-neutral-bg tw:px-2 tw:text-xs tw:font-bold tw:leading-none tw:text-status-neutral-foreground tw:break-words" + } + UiStatusKind::Working => { + "tw:inline-flex tw:min-h-6 tw:max-w-full tw:flex-shrink tw:items-center tw:rounded-pill tw:border tw:border-status-working-border tw:bg-status-working-bg tw:px-2 tw:text-xs tw:font-bold tw:leading-none tw:text-status-working-foreground tw:break-words" + } + UiStatusKind::Good => { + "tw:inline-flex tw:min-h-6 tw:max-w-full tw:flex-shrink tw:items-center tw:rounded-pill tw:border tw:border-status-good-border tw:bg-status-good-bg tw:px-2 tw:text-xs tw:font-bold tw:leading-none tw:text-status-good-foreground tw:break-words" + } + UiStatusKind::Warning => { + "tw:inline-flex tw:min-h-6 tw:max-w-full tw:flex-shrink tw:items-center tw:rounded-pill tw:border tw:border-status-warning-border tw:bg-status-warning-bg tw:px-2 tw:text-xs tw:font-bold tw:leading-none tw:text-status-warning-foreground tw:break-words" + } + UiStatusKind::Error => { + "tw:inline-flex tw:min-h-6 tw:max-w-full tw:flex-shrink tw:items-center tw:rounded-pill tw:border tw:border-status-error-border tw:bg-status-error-bg tw:px-2 tw:text-xs tw:font-bold tw:leading-none tw:text-status-error-foreground tw:break-words" + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/status_chip_stories.rs b/lp-app/lpa-studio-web/src/core/status_chip_stories.rs new file mode 100644 index 000000000..c1dbd1789 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/status_chip_stories.rs @@ -0,0 +1,18 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiStatus; +use lpa_studio_web_story_macros::story; + +use crate::core::StatusChip; + +#[story] +pub(crate) fn kinds() -> Element { + rsx! { + div { class: "tw:flex tw:flex-wrap tw:items-start tw:gap-2", + StatusChip { status: UiStatus::neutral("Choose connection") } + StatusChip { status: UiStatus::working("Connecting") } + StatusChip { status: UiStatus::good("Ready") } + StatusChip { status: UiStatus::warning("Needs sync") } + StatusChip { status: UiStatus::error("Failed") } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/story_fixtures.rs b/lp-app/lpa-studio-web/src/core/story_fixtures.rs new file mode 100644 index 000000000..a0c340df3 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/story_fixtures.rs @@ -0,0 +1,169 @@ +use core::any::Any; + +use lpa_studio_core::core::view::activity_view::{UiActivityStep, UiActivityStepState}; +use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; +use lpa_studio_core::{ + ActionConfirmation, ActionMeta, ActionPriority, ControllerId, ControllerOp, UiAction, + UiActivityView, UiIssue, UiLogEntry, UiLogLevel, UiMetric, UiPaneView, UiProgress, UiStatus, + UiStepsView, UiTerminalLine, UiViewContent, +}; + +pub(crate) fn story_actions() -> Vec { + vec![ + story_action(StoryOp::Primary), + story_action(StoryOp::Secondary), + story_action(StoryOp::Tertiary), + ] +} + +pub(crate) fn disabled_action() -> UiAction { + story_action(StoryOp::Secondary).disabled("Connect a device before running this action.") +} + +pub(crate) fn confirmation_action() -> UiAction { + story_action(StoryOp::Primary) + .with_label("Erase device") + .with_summary("Erase the connected device flash.") + .with_confirmation(ActionConfirmation::new( + "Erase device?", + "This removes the current firmware and project data from the connected device.", + "Erase", + )) +} + +pub(crate) fn story_metrics() -> Vec { + vec![ + UiMetric::new("Runtime", "ESP32-C6"), + UiMetric::new("Project", "studio-demo"), + UiMetric::new("FPS", "936"), + UiMetric::new("Memory", "207k free"), + ] +} + +pub(crate) fn story_logs() -> Vec { + vec![ + UiLogEntry::new(UiLogLevel::Info, "studio", "Simulator is running"), + UiLogEntry::new(UiLogLevel::Debug, "lp-server", "heartbeat frame=42"), + UiLogEntry::new( + UiLogLevel::Warn, + "lpa-link", + "firmware flashing is available", + ), + UiLogEntry::new(UiLogLevel::Error, "studio", "project sync failed"), + ] +} + +pub(crate) fn story_terminal_lines() -> Vec { + vec![ + UiTerminalLine::new("[lpa-link] Connected to ESP32 bootloader"), + UiTerminalLine::new("[lpa-link] Writing app image at 0x10000"), + UiTerminalLine::new("[lpa-link] Progress 42%"), + ] +} + +pub(crate) fn story_issue() -> UiIssue { + UiIssue::new("Project sync failed") + .with_detail("The device timed out while Studio was reading project shape data.") +} + +pub(crate) fn story_activity() -> UiActivityView { + UiActivityView::new("Flashing firmware") + .with_detail("Keep the device connected while Studio writes the image.") + .with_progress(UiProgress::determinate("Writing firmware", 42)) + .with_steps(vec![ + UiActivityStep::new("connect", "Connect bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase flash").with_state(UiActivityStepState::Complete), + UiActivityStep::new("write", "Write firmware") + .with_state(UiActivityStepState::Active) + .with_detail("app image at 0x10000"), + UiActivityStep::new("verify", "Verify image"), + ]) + .with_terminal(story_terminal_lines()) +} + +pub(crate) fn story_steps() -> UiStepsView { + UiStepsView::new(vec![ + UiStepView::new("connection", "Select connection", UiStepState::Complete) + .with_body(UiViewContent::text("Simulator provider selected.")), + UiStepView::new("device", "Connect device", UiStepState::Active) + .with_body(UiViewContent::Progress(UiProgress::indeterminate( + "Opening link session", + ))) + .with_actions(vec![disabled_action()]), + UiStepView::new("project", "Open project", UiStepState::Pending) + .with_body(UiViewContent::text("Connect LightPlayer first.")), + UiStepView::new("sync", "Sync project", UiStepState::NeedsAttention) + .with_body(UiViewContent::Issue(story_issue())) + .with_actions(vec![story_action(StoryOp::Retry)]), + ]) + .with_terminal(story_terminal_lines()) +} + +pub(crate) fn story_pane() -> UiPaneView { + UiPaneView::new( + ControllerId::new("story|core|pane"), + "Device", + UiStatus::working("Connecting"), + UiViewContent::Stack(Box::new(story_steps())), + vec![confirmation_action()], + ) +} + +pub(crate) fn story_action(op: StoryOp) -> UiAction { + UiAction::from_op(ControllerId::new("story|core"), op) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum StoryOp { + Primary, + Secondary, + Tertiary, + Retry, +} + +impl ControllerOp for StoryOp { + fn default_action_meta(&self) -> ActionMeta { + match self { + Self::Primary => ActionMeta::new( + "Start simulator", + "Start the browser-local simulator.", + ActionPriority::Primary, + ) + .with_icon("play"), + Self::Secondary => ActionMeta::new( + "Refresh", + "Refresh the current Studio state.", + ActionPriority::Secondary, + ) + .with_icon("refresh"), + Self::Tertiary => ActionMeta::new( + "Disconnect", + "Disconnect the current session.", + ActionPriority::Tertiary, + ) + .with_icon("disconnect"), + Self::Retry => ActionMeta::new( + "Retry sync", + "Retry the failed project sync.", + ActionPriority::Primary, + ), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn eq_op(&self, other: &dyn ControllerOp) -> bool { + other.as_any().downcast_ref::() == Some(self) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} diff --git a/lp-app/lpa-studio-web/src/core/terminal_output.rs b/lp-app/lpa-studio-web/src/core/terminal_output.rs new file mode 100644 index 000000000..4d3d4fc7d --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/terminal_output.rs @@ -0,0 +1,18 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiTerminalLine; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn TerminalOutput(lines: Vec) -> Element { + if lines.is_empty() { + return rsx! {}; + } + + rsx! { + ol { class: "tw:m-0 tw:grid tw:max-h-60 tw:gap-1 tw:overflow-auto tw:rounded-sm tw:border tw:border-border-subtle tw:bg-terminal tw:p-3 tw:font-mono tw:text-[0.78rem] tw:leading-snug tw:text-muted-foreground", + for line in lines { + li { class: "tw:min-w-0 tw:list-none tw:break-words", "{line.text}" } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/terminal_output_stories.rs b/lp-app/lpa-studio-web/src/core/terminal_output_stories.rs new file mode 100644 index 000000000..72c040cc1 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/terminal_output_stories.rs @@ -0,0 +1,28 @@ +use dioxus::prelude::*; +use lpa_studio_core::UiTerminalLine; +use lpa_studio_web_story_macros::story; + +use crate::core::TerminalOutput; +use crate::core::story_fixtures::story_terminal_lines; + +#[story] +pub(crate) fn short_output() -> Element { + rsx! { + TerminalOutput { + lines: story_terminal_lines(), + } + } +} + +#[story] +pub(crate) fn wrapped_output() -> Element { + rsx! { + TerminalOutput { + lines: vec![ + UiTerminalLine::new("[fw-esp32] ESP-ROM:esp32c6-20220919"), + UiTerminalLine::new("[lp-server] project shape response contained node /demo/shaders/orbit with 6 slots and 2 runtime bindings"), + UiTerminalLine::new("[studio] overlay has 2 pending changes"), + ], + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/activity_view.rs b/lp-app/lpa-studio-web/src/core/view/activity_view.rs new file mode 100644 index 000000000..ced677bd5 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/activity_view.rs @@ -0,0 +1,104 @@ +use crate::base::{StudioIcon, StudioIconName}; +use crate::core::{ProgressBar, TerminalOutput}; +use dioxus::prelude::*; +use lpa_studio_core::UiActivityView; +use lpa_studio_core::core::view::activity_view::UiActivityStepState; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ActivityView(activity: UiActivityView) -> Element { + let title = activity.title; + let detail = activity.detail; + let progress = activity.progress; + let steps = activity.steps; + let terminal = activity.terminal; + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-3", + p { class: "tw:m-0 tw:text-sm tw:font-bold tw:leading-normal tw:text-strong-foreground", "{title}" } + if let Some(detail) = detail.as_ref() { + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-subtle-foreground", "{detail}" } + } + if let Some(progress) = progress { + ProgressBar { progress } + } + if !steps.is_empty() { + ol { class: "tw:m-0 tw:grid tw:list-none tw:gap-2 tw:p-0", + for step in steps { + li { class: "{activity_step_class(step.state)}", + span { class: "{activity_step_marker_class(step.state)}", aria_label: "{activity_step_label(step.state)}", + if let Some(icon) = activity_step_icon(step.state) { + StudioIcon { + name: icon, + size: 14, + } + } + } + div { class: "tw:grid tw:min-w-0 tw:gap-1", + span { "{step.label}" } + if let Some(detail) = step.detail.as_ref() { + small { class: "tw:text-xs tw:text-subtle-foreground", "{detail}" } + } + } + } + } + } + } + TerminalOutput { + lines: terminal, + } + } + } +} + +fn activity_step_class(state: UiActivityStepState) -> &'static str { + match state { + UiActivityStepState::Pending => { + "tw:grid tw:grid-cols-[28px_minmax(0,1fr)] tw:gap-3 tw:text-sm tw:text-subtle-foreground" + } + UiActivityStepState::Active => { + "tw:grid tw:grid-cols-[28px_minmax(0,1fr)] tw:gap-3 tw:text-sm tw:font-bold tw:text-status-working-foreground" + } + UiActivityStepState::Complete => { + "tw:grid tw:grid-cols-[28px_minmax(0,1fr)] tw:gap-3 tw:text-sm tw:text-status-good-foreground" + } + UiActivityStepState::Failed => { + "tw:grid tw:grid-cols-[28px_minmax(0,1fr)] tw:gap-3 tw:text-sm tw:text-status-error-foreground" + } + } +} + +fn activity_step_marker_class(state: UiActivityStepState) -> &'static str { + match state { + UiActivityStepState::Pending => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-current tw:bg-transparent tw:text-subtle-foreground" + } + UiActivityStepState::Active => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-current tw:bg-step-active tw:text-status-working-foreground" + } + UiActivityStepState::Complete => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-current tw:bg-status-good-bg tw:text-status-good-foreground" + } + UiActivityStepState::Failed => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:rounded-full tw:border tw:border-current tw:bg-status-error-bg tw:text-status-error-foreground" + } + } +} + +fn activity_step_icon(state: UiActivityStepState) -> Option { + match state { + UiActivityStepState::Pending => None, + UiActivityStepState::Active => Some(StudioIconName::StepActive), + UiActivityStepState::Complete => Some(StudioIconName::StepComplete), + UiActivityStepState::Failed => Some(StudioIconName::StepAttention), + } +} + +fn activity_step_label(state: UiActivityStepState) -> &'static str { + match state { + UiActivityStepState::Pending => "pending", + UiActivityStepState::Active => "active", + UiActivityStepState::Complete => "complete", + UiActivityStepState::Failed => "failed", + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/activity_view_stories.rs b/lp-app/lpa-studio-web/src/core/view/activity_view_stories.rs new file mode 100644 index 000000000..8d3b9252a --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/activity_view_stories.rs @@ -0,0 +1,38 @@ +use dioxus::prelude::*; +use lpa_studio_core::core::view::activity_view::{UiActivityStep, UiActivityStepState}; +use lpa_studio_core::{UiActivityView, UiProgress}; +use lpa_studio_web_story_macros::story; + +use crate::core::ActivityView; +use crate::core::story_fixtures::{story_activity, story_terminal_lines}; + +#[story] +pub(crate) fn flashing() -> Element { + rsx! { + ActivityView { + activity: story_activity(), + } + } +} + +#[story] +pub(crate) fn failed_step() -> Element { + let activity = UiActivityView::new("Provision firmware") + .with_detail("Studio stopped after the device rejected the write command.") + .with_progress(UiProgress::indeterminate("Waiting for retry")) + .with_steps(vec![ + UiActivityStep::new("connect", "Connect bootloader") + .with_state(UiActivityStepState::Complete), + UiActivityStep::new("erase", "Erase flash").with_state(UiActivityStepState::Complete), + UiActivityStep::new("write", "Write firmware") + .with_state(UiActivityStepState::Failed) + .with_detail("The browser serial write failed."), + ]) + .with_terminal(story_terminal_lines()); + + rsx! { + ActivityView { + activity, + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/mod.rs b/lp-app/lpa-studio-web/src/core/view/mod.rs new file mode 100644 index 000000000..28051b67f --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/mod.rs @@ -0,0 +1,12 @@ +pub mod activity_view; +#[cfg(feature = "stories")] +pub(crate) mod activity_view_stories; +pub mod pane_view; +#[cfg(feature = "stories")] +pub(crate) mod pane_view_stories; +pub mod stack_view; +#[cfg(feature = "stories")] +pub(crate) mod steps_view_stories; +pub mod view_content; +#[cfg(feature = "stories")] +pub(crate) mod view_content_stories; diff --git a/lp-app/lpa-studio-web/src/core/view/pane_view.rs b/lp-app/lpa-studio-web/src/core/view/pane_view.rs new file mode 100644 index 000000000..c86b1b460 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/pane_view.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiPaneView}; + +use crate::app::PaneFrame; +use crate::core::{ActionStrip, ViewContent}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn PaneView( + view: UiPaneView, + primary: bool, + running: bool, + on_action: EventHandler, +) -> Element { + let UiPaneView { + title, + status, + body, + actions, + .. + } = view; + rsx! { + PaneFrame { + title, + primary, + status: Some(status), + ViewContent { + body, + running, + on_action, + } + if !actions.is_empty() { + ActionStrip { + actions, + running, + on_action, + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/pane_view_stories.rs b/lp-app/lpa-studio-web/src/core/view/pane_view_stories.rs new file mode 100644 index 000000000..c5e8d6df1 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/pane_view_stories.rs @@ -0,0 +1,58 @@ +use dioxus::prelude::*; +use lpa_studio_core::{ControllerId, UiPaneView, UiStatus, UiViewContent}; +use lpa_studio_web_story_macros::story; + +use crate::core::PaneView; +use crate::core::story_fixtures::{story_actions, story_issue, story_pane}; + +#[story] +pub(crate) fn workflow_pane() -> Element { + rsx! { + PaneView { + view: story_pane(), + primary: true, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn attention_pane() -> Element { + let pane = UiPaneView::new( + ControllerId::new("story|core|attention-pane"), + "Project", + UiStatus::error("Sync issue"), + UiViewContent::Issue(story_issue()), + story_actions(), + ); + + rsx! { + PaneView { + view: pane, + primary: true, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn quiet_pane() -> Element { + let pane = UiPaneView::new( + ControllerId::new("story|core|quiet-pane"), + "Server", + UiStatus::neutral("Offline"), + UiViewContent::text("Open a link endpoint to attach the server protocol."), + Vec::new(), + ); + + rsx! { + PaneView { + view: pane, + primary: false, + running: false, + on_action: move |_| {}, + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/stack_view.rs b/lp-app/lpa-studio-web/src/core/view/stack_view.rs new file mode 100644 index 000000000..9163ce62f --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/stack_view.rs @@ -0,0 +1,103 @@ +use crate::base::{StudioIcon, StudioIconName}; +use crate::core::{ActionStrip, TerminalOutput, ViewContent}; +use dioxus::prelude::*; +use lpa_studio_core::core::view::steps_view::UiStepState; +use lpa_studio_core::{UiAction, UiStepsView}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn StepsView(stack: UiStepsView, running: bool, on_action: EventHandler) -> Element { + let terminal = stack.terminal; + let sections = stack.sections.into_iter().collect::>(); + + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-4", + ol { class: "tw:m-0 tw:grid tw:list-none tw:gap-0 tw:p-0", + for section in sections { + li { class: "{stack_section_class(section.state)}", + div { class: "{stack_marker_class(section.state)}", aria_label: "{step_state_label(section.state)}", + if let Some(icon) = stack_marker_icon(section.state) { + StudioIcon { + name: icon, + size: 14, + } + } + } + h3 { class: "tw:m-0 tw:self-center tw:text-base tw:font-bold tw:leading-tight tw:text-strong-foreground tw:break-words", "{section.title}" } + div { class: "tw:col-span-2 tw:min-w-0", + ViewContent { + body: section.body, + running, + on_action, + } + } + if !section.actions.is_empty() { + div { class: "tw:col-span-2 tw:min-w-0", + ActionStrip { + actions: section.actions, + running, + on_action, + } + } + } + } + } + } + TerminalOutput { + lines: terminal, + } + } + } +} + +fn stack_section_class(state: UiStepState) -> &'static str { + match state { + UiStepState::Pending => { + "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-subtle-foreground tw:first:border-t-0" + } + UiStepState::Active => { + "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-working-foreground tw:first:border-t-0" + } + UiStepState::Complete => { + "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-good-foreground tw:first:border-t-0" + } + UiStepState::NeedsAttention => { + "tw:grid tw:grid-cols-[32px_minmax(0,1fr)] tw:gap-x-3 tw:gap-y-2 tw:border-t tw:border-border-muted tw:bg-transparent tw:py-3 tw:text-status-error-foreground tw:first:border-t-0" + } + } +} + +fn stack_marker_class(state: UiStepState) -> &'static str { + match state { + UiStepState::Pending => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-transparent tw:text-subtle-foreground" + } + UiStepState::Active => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-step-active tw:text-status-working-foreground" + } + UiStepState::Complete => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-status-good-bg tw:text-status-good-foreground" + } + UiStepState::NeedsAttention => { + "tw:inline-flex tw:h-6 tw:w-6 tw:items-center tw:justify-center tw:self-center tw:rounded-full tw:border tw:border-current tw:bg-status-error-bg tw:text-status-error-foreground" + } + } +} + +fn stack_marker_icon(state: UiStepState) -> Option { + match state { + UiStepState::Pending => None, + UiStepState::Active => Some(StudioIconName::StepActive), + UiStepState::Complete => Some(StudioIconName::StepComplete), + UiStepState::NeedsAttention => Some(StudioIconName::StepAttention), + } +} + +fn step_state_label(state: UiStepState) -> &'static str { + match state { + UiStepState::Pending => "pending", + UiStepState::Active => "active", + UiStepState::Complete => "complete", + UiStepState::NeedsAttention => "needs attention", + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs b/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs new file mode 100644 index 000000000..1b2367904 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/steps_view_stories.rs @@ -0,0 +1,66 @@ +use dioxus::prelude::*; +use lpa_studio_core::core::view::steps_view::{UiStepState, UiStepView}; +use lpa_studio_core::{UiProgress, UiStepsView, UiViewContent}; +use lpa_studio_web_story_macros::story; + +use crate::core::StepsView; +use crate::core::story_fixtures::{ + confirmation_action, story_actions, story_issue, story_metrics, story_steps, + story_terminal_lines, +}; + +#[story] +pub(crate) fn workflow() -> Element { + rsx! { + StepsView { + stack: story_steps(), + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn nested_content() -> Element { + let steps = UiStepsView::new(vec![ + UiStepView::new("text", "Text body", UiStepState::Complete) + .with_body(UiViewContent::text("The simulator provider is selected.")), + UiStepView::new("progress", "Progress body", UiStepState::Active).with_body( + UiViewContent::Progress( + UiProgress::determinate("Reading project", 68) + .with_detail("Fetching node shape metadata."), + ), + ), + UiStepView::new("metrics", "Metrics body", UiStepState::Complete) + .with_body(UiViewContent::Metrics(story_metrics())), + UiStepView::new("issue", "Issue body", UiStepState::NeedsAttention) + .with_body(UiViewContent::Issue(story_issue())) + .with_actions(story_actions()), + ]) + .with_terminal(story_terminal_lines()); + + rsx! { + StepsView { + stack: steps, + running: false, + on_action: move |_| {}, + } + } +} + +#[story] +pub(crate) fn running_actions() -> Element { + let steps = UiStepsView::new(vec![ + UiStepView::new("active", "Flashing firmware", UiStepState::Active) + .with_body(UiViewContent::text("Studio is writing the firmware image.")) + .with_actions(vec![confirmation_action()]), + ]); + + rsx! { + StepsView { + stack: steps, + running: true, + on_action: move |_| {}, + } + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/view_content.rs b/lp-app/lpa-studio-web/src/core/view/view_content.rs new file mode 100644 index 000000000..3350c156c --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/view_content.rs @@ -0,0 +1,46 @@ +use dioxus::prelude::*; +use lpa_studio_core::{UiAction, UiViewContent}; + +use crate::app::ProjectSidebar; +use crate::core::{ActivityView, IssueView, MetricGrid, ProgressBar, StepsView}; + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn ViewContent( + body: UiViewContent, + running: bool, + on_action: EventHandler, +) -> Element { + match body { + UiViewContent::Empty => rsx! {}, + UiViewContent::Text(text) => rsx! { + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-muted-foreground", "{text}" } + }, + UiViewContent::Progress(progress) => rsx! { + ProgressBar { progress } + }, + UiViewContent::Activity(activity) => rsx! { + ActivityView { activity } + }, + UiViewContent::Issue(issue) => rsx! { + IssueView { issue } + }, + UiViewContent::Metrics(metrics) => rsx! { + MetricGrid { metrics } + }, + UiViewContent::Stack(stack) => rsx! { + StepsView { + stack: *stack, + running, + on_action, + } + }, + UiViewContent::ProjectEditor(editor) => rsx! { + ProjectSidebar { + view: *editor, + running, + on_action, + } + }, + } +} diff --git a/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs b/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs new file mode 100644 index 000000000..e7aca72f8 --- /dev/null +++ b/lp-app/lpa-studio-web/src/core/view/view_content_stories.rs @@ -0,0 +1,55 @@ +use dioxus::prelude::*; +use lpa_studio_core::{UiProgress, UiViewContent}; +use lpa_studio_web_story_macros::story; + +use crate::core::ViewContent; +use crate::core::story_fixtures::{story_activity, story_issue, story_metrics, story_steps}; + +#[story] +pub(crate) fn body_variants() -> Element { + rsx! { + div { class: "tw:grid tw:gap-[18px]", + ViewContent { + body: UiViewContent::text("Choose how Studio should connect."), + running: false, + on_action: move |_| {}, + } + ViewContent { + body: UiViewContent::Progress( + UiProgress::determinate("Reading project", 68) + .with_detail("Fetching node shape metadata."), + ), + running: false, + on_action: move |_| {}, + } + ViewContent { + body: UiViewContent::Issue(story_issue()), + running: false, + on_action: move |_| {}, + } + ViewContent { + body: UiViewContent::Metrics(story_metrics()), + running: false, + on_action: move |_| {}, + } + } + } +} + +#[story] +pub(crate) fn composed_variants() -> Element { + rsx! { + div { class: "tw:grid tw:gap-[18px]", + ViewContent { + body: UiViewContent::Activity(story_activity()), + running: false, + on_action: move |_| {}, + } + ViewContent { + body: UiViewContent::Stack(Box::new(story_steps())), + running: false, + on_action: move |_| {}, + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/mod.rs b/lp-app/lpa-studio-web/src/exploration/mod.rs new file mode 100644 index 000000000..6f7072c30 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/mod.rs @@ -0,0 +1,8 @@ +//! Exploratory Studio web UI surfaces. +//! +//! This family is for design spikes and mockups that should be visible in +//! storybook but are not yet production `base`, `core`, or `app` +//! components. + +#[cfg(feature = "stories")] +pub(crate) mod node_ui_stories; diff --git a/lp-app/lpa-studio-web/src/exploration/node_ui_stories.rs b/lp-app/lpa-studio-web/src/exploration/node_ui_stories.rs new file mode 100644 index 000000000..058d41b23 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/node_ui_stories.rs @@ -0,0 +1,1400 @@ +//! Exploratory node UI stories. +//! +//! These stories are grounded in real project shape/slot JSON, but they are +//! still design spike surfaces. Keeping them under `exploration` makes the +//! generated `exploration` story family honest without creating a parallel +//! source tree beside `base`, `core`, and `app`. + +use dioxus::prelude::*; +use lpa_studio_web_story_macros::story; + +use crate::base::{IconPopoverButton, PopoverPlacement, StudioIcon, StudioIconName}; + +const CLOCK_SHAPE_JSON: &str = include_str!("story_data/clock.shape.json"); +const CLOCK_SLOTS_JSON: &str = include_str!("story_data/clock.slots.json"); +const FIXTURE_SHAPE_JSON: &str = include_str!("story_data/fixture.shape.json"); +const FIXTURE_SLOTS_JSON: &str = include_str!("story_data/fixture.slots.json"); +const PLAYLIST_SHAPE_JSON: &str = include_str!("story_data/playlist.shape.json"); +const PLAYLIST_SLOTS_JSON: &str = include_str!("story_data/playlist.slots.json"); +const SHADER_SHAPE_JSON: &str = include_str!("story_data/shader.shape.json"); +const SHADER_SLOTS_JSON: &str = include_str!("story_data/shader.slots.json"); + +#[story(description = "Instrument-window direction for a simple produced-value node.")] +fn clock_instrument() -> Element { + rsx! { + NodeUiStoryCanvas { + NodeWindow { + node: clock_node(), + variant: NodeUiVariant::Instrument, + } + } + } +} + +#[story(description = "Compact-inspector direction with the same Clock data.")] +fn clock_compact() -> Element { + rsx! { + NodeUiStoryCanvas { + NodeWindow { + node: clock_node(), + variant: NodeUiVariant::Compact, + } + } + } +} + +#[story( + description = "Control product node with a rough probed-output box and one-level mapping detail." +)] +fn fixture_control_product() -> Element { + rsx! { + NodeUiStoryCanvas { + NodeWindow { + node: fixture_node(), + variant: NodeUiVariant::Instrument, + } + } + } +} + +#[story(description = "Visual product node with a rough render preview surface.")] +fn shader_visual_product() -> Element { + rsx! { + NodeUiStoryCanvas { + NodeWindow { + node: shader_node(), + variant: NodeUiVariant::Instrument, + } + } + } +} + +#[story( + description = "Fyeah-inspired Playlist node with active child ownership and product output." +)] +fn playlist_children() -> Element { + rsx! { + NodeUiStoryCanvas { + NodeWindow { + node: playlist_node(), + variant: NodeUiVariant::Instrument, + } + } + } +} + +#[story( + description = "Minimal node windows for checking status color, icon, tint, and the details popup." +)] +fn status_indicators() -> Element { + rsx! { + NodeUiStatusStory {} + } +} + +#[story( + description = "The intended hierarchy: project root scopes every ordinary node beneath it." +)] +fn project_context() -> Element { + rsx! { + NodeUiProjectContext {} + } +} + +#[story(description = "All representative node presentations on one review surface.")] +fn gallery() -> Element { + rsx! { + NodeUiGallery {} + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeUiStoryCanvas(children: Element) -> Element { + rsx! { + section { class: "ux-node-ui-story", + {children} + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeUiGallery() -> Element { + let nodes = vec![clock_node(), fixture_node(), shader_node(), playlist_node()]; + rsx! { + NodeUiStoryCanvas { + div { class: "ux-node-ui-gallery", + for node in nodes { + NodeWindow { + key: "{node.path}", + node, + variant: NodeUiVariant::Instrument, + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeUiStatusStory() -> Element { + let nodes = vec![ + status_demo_node( + "Running node", + "Clock", + Some("clock.toml"), + NodeUiStatus::running(), + Some("0.34 ms frame"), + true, + ), + status_demo_node( + "Idle node", + "Fixture", + Some("fixture.toml"), + NodeUiStatus::idle(Some("Last ran at frame 96")), + None, + true, + ), + status_demo_node( + "Error node", + "Shader", + Some("rainbow.glsl"), + NodeUiStatus::error( + Some("Shader compile failed"), + Some( + "error[E_SHADER]: failed to compile rainbow.glsl\n --> rainbow.glsl:18:14\n |\n18 | color = sample(uv2);\n | ^^^ unknown identifier `uv2`", + ), + ), + Some("64 ms compile"), + true, + ), + ]; + rsx! { + NodeUiStoryCanvas { + div { class: "ux-node-ui-status-gallery", + for node in nodes { + NodeWindow { + key: "{node.title}", + node, + variant: NodeUiVariant::Compact, + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeUiProjectContext() -> Element { + rsx! { + NodeUiStoryCanvas { + div { class: "ux-node-ui-project-layout", + aside { class: "ux-node-ui-project-tree", + p { class: "ux-node-ui-tree-heading", "fyeah_sign.show" } + ol { + li { class: "ux-node-ui-tree-item ux-node-ui-tree-root", "Project" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-1", "Clock" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-1", "Playlist" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-2", "idle" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-2 ux-node-ui-tree-active", "blast" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-1", "Fixture" } + li { class: "ux-node-ui-tree-item ux-node-ui-tree-depth-1", "Output" } + } + } + div { class: "ux-node-ui-project-nodes", + NodeWindow { + node: playlist_node(), + variant: NodeUiVariant::Instrument, + } + NodeWindow { + node: fixture_node(), + variant: NodeUiVariant::Compact, + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeWindow(node: NodeUiNode, variant: NodeUiVariant) -> Element { + let mut active_tab = use_signal(|| 0_usize); + let mut collapsed = use_signal(|| false); + let base_class = match variant { + NodeUiVariant::Instrument => "ux-node-ui-window ux-node-ui-window-instrument", + NodeUiVariant::Compact => "ux-node-ui-window ux-node-ui-window-compact", + }; + let collapsed_class = if collapsed() { + " ux-node-ui-window-collapsed" + } else { + "" + }; + let class = format!( + "{base_class} {}{collapsed_class}", + node.status.window_class_name() + ); + let tabs = node.tabs.clone(); + let active_index = active_tab().min(tabs.len().saturating_sub(1)); + let active_content = tabs + .get(active_index) + .map(|tab| tab.content) + .unwrap_or(NodeUiTabContent::None); + let presentation = node.presentation.clone(); + let values = node.values.clone(); + let children = node.children.clone(); + rsx! { + div { class: "ux-node-ui-node-stack", + article { class, + NodeHeader { + title: node.title, + kind: node.kind, + source: node.source, + status: node.status, + initially_open: node.status_details_open, + perf: node.perf, + tabs: tabs.clone(), + active_index, + on_select: move |index| active_tab.set(index), + collapsed: collapsed(), + on_toggle_collapsed: move |_| collapsed.set(!collapsed()), + } + if !collapsed() { + match active_content { + NodeUiTabContent::None => rsx! { + NodeMainTabPanel { + presentation, + values, + variant, + } + }, + NodeUiTabContent::Json { title, body } => rsx! { + NodeJsonTabPanel { + title, + body, + } + }, + } + } + } + if !collapsed() && !children.is_empty() { + NodeChildren { + items: children, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeHeader( + title: &'static str, + kind: &'static str, + source: Option<&'static str>, + status: NodeUiStatus, + initially_open: bool, + perf: Option<&'static str>, + tabs: Vec, + active_index: usize, + on_select: EventHandler, + collapsed: bool, + on_toggle_collapsed: EventHandler, +) -> Element { + rsx! { + header { class: "ux-node-ui-header", + button { + class: "ux-node-ui-collapse-button", + r#type: "button", + aria_label: if collapsed { "Expand node" } else { "Collapse node" }, + title: if collapsed { "Expand node" } else { "Collapse node" }, + onclick: move |event| on_toggle_collapsed.call(event), + StudioIcon { + name: if collapsed { StudioIconName::Collapsed } else { StudioIconName::Expanded }, + size: 14, + } + } + NodeStatusIndicator { + kind, + source, + status, + initially_open, + perf, + } + div { class: "ux-node-ui-title", + h3 { + span { "{title}" } + if let Some(summary) = status.header_summary() { + small { class: "ux-node-ui-status-summary", " - {summary}" } + } + } + } + if !tabs.is_empty() { + NodeTabList { + tabs, + active_index, + on_select, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeStatusIndicator( + kind: &'static str, + source: Option<&'static str>, + status: NodeUiStatus, + initially_open: bool, + perf: Option<&'static str>, +) -> Element { + let open_class = format!( + "{} ux-node-ui-status-button-open", + status.indicator_class_name() + ); + rsx! { + div { class: "ux-node-ui-status-control", + IconPopoverButton { + class: status.indicator_class_name().to_string(), + open_class, + icon: status.icon_name(), + icon_size: 14, + label: format!("{} status details", status.label), + title: format!("{} status details", status.label), + popup_class: status.popup_class_name().to_string(), + placement: PopoverPlacement::BottomStart, + initially_open, + div { class: "ux-node-ui-status-popup-summary", + div { class: "ux-node-ui-status-popup-line", + strong { class: "ux-node-ui-status-popup-kind", "{kind}" } + span { class: "ux-node-ui-status-popup-perf", + if let Some(perf) = perf { + "{perf}" + } else { + "{status.label}" + } + } + } + if let Some(source) = source { + div { class: "ux-node-ui-status-popup-source", "{source}" } + } + } + if let Some(detail) = status.error_detail() { + pre { class: "ux-node-ui-status-popup-error-detail", + code { "{detail}" } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeMainTabPanel( + presentation: Vec, + values: Vec, + variant: NodeUiVariant, +) -> Element { + let (products, metrics) = split_presentation_items(presentation); + rsx! { + if !products.is_empty() || !metrics.is_empty() { + NodeProducedSection { + products, + metrics, + variant, + } + } + if !values.is_empty() { + NodeValueGroups { + groups: values, + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeProducedSection( + products: Vec, + metrics: Vec, + variant: NodeUiVariant, +) -> Element { + let class = match variant { + NodeUiVariant::Instrument => "ux-node-ui-produced ux-node-ui-produced-instrument", + NodeUiVariant::Compact => "ux-node-ui-produced ux-node-ui-produced-compact", + }; + rsx! { + section { class, + if !products.is_empty() { + div { class: "ux-node-ui-products", + for product in products { + NodeProductTile { product } + } + } + } + if !metrics.is_empty() { + div { class: "ux-node-ui-produced-values", + for metric in metrics { + NodePresentationMetric { metric } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodePresentationMetric(metric: NodeUiMetric) -> Element { + let bindings = metric.bindings.clone(); + rsx! { + div { class: "ux-node-ui-metric", + ProducedBindingButton { + label: metric.label, + type_label: "produced value", + bindings, + revision: metric.revision, + } + span { class: "ux-node-ui-metric-label", "{metric.label}" } + strong { "{metric.value}" } + if let Some(detail) = metric.detail { + small { "{detail}" } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeProductTile(product: NodeUiProduct) -> Element { + let class = match product.kind { + NodeUiProductKind::Visual => "ux-node-ui-product ux-node-ui-product-visual", + NodeUiProductKind::Control => "ux-node-ui-product ux-node-ui-product-control", + }; + let bindings = product.bindings.clone(); + rsx! { + NodeProductPreviewBox { product: product.clone() } + div { class, + footer { class: "ux-node-ui-product-meta", + ProducedBindingButton { + label: product.name, + type_label: product.kind.product_label(), + bindings, + revision: product.revision, + } + em { "{product.name}" } + span { "{product.kind.label()}" } + if let Some(size) = product.size { + small { "{size}" } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn ProducedBindingButton( + label: &'static str, + type_label: &'static str, + bindings: NodeUiProducedBindings, + revision: u32, +) -> Element { + let bus_target = bindings.bus_target; + let target_bindings = bindings.target_bindings.clone(); + let consumers = bindings.consumers.clone(); + let trigger_class = if bindings.has_any() { + "ux-node-ui-popup-trigger ux-node-ui-popup-trigger-routed" + } else { + "ux-node-ui-popup-trigger" + }; + let open_class = "ux-node-ui-popup-trigger ux-node-ui-popup-trigger-open"; + rsx! { + IconPopoverButton { + class: trigger_class.to_string(), + open_class: open_class.to_string(), + icon: StudioIconName::BoundValue, + icon_size: 13, + label: format!("{label} bindings"), + title: format!("{label} bindings"), + popup_class: "ux-node-ui-popup ux-node-ui-route-popup".to_string(), + placement: PopoverPlacement::BottomEnd, + div { class: "ux-node-ui-popup-kicker", "{type_label}" } + strong { "{label}" } + div { class: "ux-node-ui-binding-section ux-node-ui-bus-binding-section", + div { class: "ux-node-ui-binding-heading", "bus binding" } + if let Some(bus_target) = bus_target { + div { class: "ux-node-ui-bus-binding-row", + span { "bus#" } + code { "{bus_binding_name(bus_target)}" } + button { r#type: "button", "del" } + } + } else { + div { class: "ux-node-ui-bus-binding-row ux-node-ui-bus-binding-row-empty", + span { "bus#" } + code { "not assigned" } + button { r#type: "button", "add" } + } + } + } + if !target_bindings.is_empty() { + div { class: "ux-node-ui-binding-section", + div { class: "ux-node-ui-binding-heading", "target bindings" } + for target in target_bindings { + div { class: "ux-node-ui-binding-item", + span { class: "ux-node-ui-binding-arrow", "->" } + code { "{target}" } + button { r#type: "button", "del" } + } + } + button { class: "ux-node-ui-binding-add", r#type: "button", "add" } + } + } + if !consumers.is_empty() { + div { class: "ux-node-ui-binding-section", + div { class: "ux-node-ui-binding-heading", "consumed by" } + for consumer in consumers { + div { class: "ux-node-ui-binding-item ux-node-ui-binding-item-readonly", + code { "{consumer}" } + } + } + } + } + small { "rev {revision}" } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeProductPreviewBox(product: NodeUiProduct) -> Element { + let class = match product.kind { + NodeUiProductKind::Visual => "ux-node-ui-preview ux-node-ui-preview-visual", + NodeUiProductKind::Control => "ux-node-ui-preview ux-node-ui-preview-control", + }; + rsx! { + div { class, + if product.kind == NodeUiProductKind::Visual { + div { class: "ux-node-ui-shader-preview", aria_hidden: "true" } + } else { + div { class: "ux-node-ui-preview-grid", + for index in 0..product.preview_cells { + span { key: "{index}" } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeValueGroups(groups: Vec) -> Element { + rsx! { + section { class: "ux-node-ui-values", + for group in groups { + for row in group.rows { + NodeValueRow { + key: "{row.label}", + row, + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeValueRow(row: NodeUiValueRow) -> Element { + let class = if row.source == NodeUiValueSource::Bound { + "ux-node-ui-row ux-node-ui-row-bound" + } else { + "ux-node-ui-row" + }; + rsx! { + div { class, + SlotSourceButton { + source: row.source, + label: row.label, + value: row.value, + binding_target: row.binding_target, + revision: row.revision, + } + span { class: "ux-node-ui-row-label", "{row.label}" } + span { class: "ux-node-ui-row-value", + if let Some(target) = row.binding_target { + span { class: "ux-node-ui-row-binding", "{target}" } + } else { + "{row.value}" + } + } + if let Some(nested) = row.nested { + NodeNestedValue { nested } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn SlotSourceButton( + source: NodeUiValueSource, + label: &'static str, + value: &'static str, + binding_target: Option<&'static str>, + revision: u32, +) -> Element { + let open_class = format!("{} ux-node-ui-popup-trigger-open", source.class_name()); + rsx! { + IconPopoverButton { + class: source.class_name().to_string(), + open_class, + icon: source.icon_name(), + icon_size: 13, + title: source.title().to_string(), + label: format!("{label} source"), + popup_class: "ux-node-ui-popup ux-node-ui-slot-popup".to_string(), + placement: PopoverPlacement::BottomStart, + div { class: "ux-node-ui-popup-kicker", "consumed value" } + strong { "{label}" } + p { + if let Some(target) = binding_target { + "source {target}" + } else { + "assigned value {value}" + } + } + div { class: "ux-node-ui-popup-actions", + button { + class: if source == NodeUiValueSource::Direct { "is-active" } else { "" }, + r#type: "button", + "assigned value" + } + button { + class: if source == NodeUiValueSource::Bound { "is-active" } else { "" }, + r#type: "button", + "source binding" + } + } + small { "rev {revision}" } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeNestedValue(nested: NodeUiNestedValue) -> Element { + rsx! { + div { class: "ux-node-ui-nested", + div { class: "ux-node-ui-nested-heading", + span { "{nested.title}" } + small { "{nested.summary}" } + } + dl { + for item in nested.items { + div { + dt { "{item.label}" } + dd { "{item.value}" } + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeChildren(items: Vec) -> Element { + rsx! { + section { class: "ux-node-ui-children", + h4 { "Children" } + div { class: "ux-node-ui-child-nodes", + for child in items { + NodeChildWindow { child } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeChildWindow(child: NodeUiChild) -> Element { + let mut collapsed = use_signal(|| true); + let status = if child.active { + NodeUiStatus::running() + } else { + NodeUiStatus::idle(Some("Waiting for playlist entry")) + }; + let collapsed_class = if collapsed() { + " ux-node-ui-window-collapsed" + } else { + "" + }; + let class = format!( + "ux-node-ui-window ux-node-ui-child-node {}{collapsed_class}", + status.window_class_name() + ); + rsx! { + article { class, + NodeHeader { + title: child.label, + kind: child.kind, + source: Some(child.detail), + status, + initially_open: false, + perf: Some(child.state), + tabs: Vec::new(), + active_index: 0, + on_select: move |_| {}, + collapsed: collapsed(), + on_toggle_collapsed: move |_| collapsed.set(!collapsed()), + } + if !collapsed() { + NodeMainTabPanel { + presentation: child.presentation, + values: child.values, + variant: NodeUiVariant::Compact, + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeTabList( + tabs: Vec, + active_index: usize, + on_select: EventHandler, +) -> Element { + rsx! { + div { class: "ux-node-ui-header-tabs", role: "tablist", + for (index, tab) in tabs.clone().into_iter().enumerate() { + button { + class: if index == active_index { "ux-node-ui-tab ux-node-ui-tab-active" } else { "ux-node-ui-tab" }, + r#type: "button", + role: "tab", + aria_selected: "{index == active_index}", + onclick: move |_| on_select.call(index), + "{tab.label}" + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn NodeJsonTabPanel(title: &'static str, body: &'static str) -> Element { + rsx! { + div { class: "ux-node-ui-tab-panel", role: "tabpanel", + div { class: "ux-node-ui-json-heading", "{title}" } + pre { class: "ux-node-ui-json", + code { "{body}" } + } + } + } +} + +fn clock_node() -> NodeUiNode { + NodeUiNode { + title: "Clock", + kind: "Clock", + source: Some("clock.toml"), + path: "/fyeah_sign.show/clock.clock", + status: NodeUiStatus::running(), + status_details_open: false, + perf: Some("936 fps"), + presentation: vec![ + NodeUiPresentationItem::Metric(NodeUiMetric { + label: "Seconds", + value: "3.333", + detail: Some("project time"), + bindings: produced_bindings(Some("bus#time.seconds"), &[], &["Playlist.time"]), + revision: 102, + }), + NodeUiPresentationItem::Metric(NodeUiMetric { + label: "Delta", + value: "0.033", + detail: Some("seconds/frame"), + bindings: produced_bindings(None, &[], &[]), + revision: 102, + }), + ], + values: vec![NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Direct, "Running", "true"), + value_row(NodeUiValueSource::Direct, "Rate", "1"), + value_row(NodeUiValueSource::Direct, "Scrub offset", "0.0 s"), + ], + }], + tabs: node_json_tabs(CLOCK_SHAPE_JSON, CLOCK_SLOTS_JSON), + children: Vec::new(), + } +} + +fn shader_node() -> NodeUiNode { + NodeUiNode { + title: "blast", + kind: "Shader", + source: Some("blast.glsl"), + path: "/fyeah_sign.show/playlist.playlist/blast.shader", + status: NodeUiStatus::running(), + status_details_open: false, + perf: Some("64 ms compile"), + presentation: vec![NodeUiPresentationItem::Product(NodeUiProduct { + kind: NodeUiProductKind::Visual, + name: "output", + size: Some("128 x 128"), + preview_cells: 24, + bindings: produced_bindings(None, &[], &["Playlist.entry.visual"]), + revision: 42, + })], + values: vec![ + NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Bound, "Time", "../playlist#entry_time"), + value_row( + NodeUiValueSource::Bound, + "Progress", + "../playlist#entry_progress", + ), + ], + }, + NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Direct, "Shader", "blast.glsl"), + value_row(NodeUiValueSource::Direct, "Render order", "0"), + ], + }, + ], + tabs: node_json_tabs(SHADER_SHAPE_JSON, SHADER_SLOTS_JSON), + children: Vec::new(), + } +} + +fn fixture_node() -> NodeUiNode { + NodeUiNode { + title: "Fixture", + kind: "Fixture", + source: Some("fixture.toml"), + path: "/fyeah_sign.show/fixture.fixture", + status: NodeUiStatus::running(), + status_details_open: false, + perf: Some("657 samples"), + presentation: vec![NodeUiPresentationItem::Product(NodeUiProduct { + kind: NodeUiProductKind::Control, + name: "output", + size: Some("1 x 657"), + preview_cells: 30, + bindings: produced_bindings(Some("bus#control.fixture"), &[], &["Output.main"]), + revision: 44, + })], + values: vec![ + NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Direct, "Render size", "16 x 16"), + value_row(NodeUiValueSource::Direct, "Color order", "RGB"), + value_row(NodeUiValueSource::Direct, "Brightness", "255"), + NodeUiValueRow { + source: NodeUiValueSource::Direct, + label: "Mapping", + value: "SvgPath", + binding_target: None, + revision: 42, + nested: Some(NodeUiNestedValue { + title: "fyeah-mapping.svg", + summary: "sample diameter 2.0", + items: vec![ + NodeUiNestedItem { + label: "source", + value: "./fyeah-mapping.svg", + }, + NodeUiNestedItem { + label: "sampling", + value: "direct", + }, + ], + }), + }, + ], + }, + NodeUiValueGroup { + rows: vec![value_row( + NodeUiValueSource::Bound, + "Visual", + "../playlist#output", + )], + }, + ], + tabs: node_json_tabs(FIXTURE_SHAPE_JSON, FIXTURE_SLOTS_JSON), + children: Vec::new(), + } +} + +fn playlist_node() -> NodeUiNode { + NodeUiNode { + title: "Playlist", + kind: "Playlist", + source: Some("playlist.toml"), + path: "/fyeah_sign.show/playlist.playlist", + status: NodeUiStatus::running(), + status_details_open: false, + perf: Some("entry 1"), + presentation: vec![ + NodeUiPresentationItem::Product(NodeUiProduct { + kind: NodeUiProductKind::Visual, + name: "output", + size: Some("128 x 128"), + preview_cells: 18, + bindings: produced_bindings(Some("bus#visual.out"), &[], &["Fixture.visual"]), + revision: 104, + }), + NodeUiPresentationItem::Metric(NodeUiMetric { + label: "Entry time", + value: "3.333", + detail: Some("seconds"), + bindings: produced_bindings(None, &[], &["idle.Time", "blast.Time"]), + revision: 104, + }), + NodeUiPresentationItem::Metric(NodeUiMetric { + label: "Active", + value: "idle", + detail: Some("entry 1"), + bindings: produced_bindings(None, &[], &[]), + revision: 104, + }), + ], + values: vec![ + NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Bound, "Time", "bus#time.seconds"), + value_row(NodeUiValueSource::Direct, "Idle entry", "1"), + value_row(NodeUiValueSource::Direct, "Default fade", "0.35 s"), + value_row(NodeUiValueSource::Direct, "Active entry", "1"), + ], + }, + NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Child, "idle", "./idle.shader"), + value_row(NodeUiValueSource::Child, "blast", "./blast.shader"), + value_row(NodeUiValueSource::Bound, "blast.trigger", "bus#trigger"), + ], + }, + ], + tabs: node_json_tabs(PLAYLIST_SHAPE_JSON, PLAYLIST_SLOTS_JSON), + children: vec![ + NodeUiChild { + label: "idle", + kind: "Shader", + detail: "./idle.toml", + state: "active, fade_after 0.12 s", + active: true, + presentation: vec![NodeUiPresentationItem::Product(NodeUiProduct { + kind: NodeUiProductKind::Visual, + name: "output", + size: Some("128 x 128"), + preview_cells: 12, + bindings: produced_bindings(None, &["../playlist#output"], &[]), + revision: 103, + })], + values: vec![NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Bound, "Time", "../playlist#entry_time"), + value_row(NodeUiValueSource::Direct, "Shader", "idle.glsl"), + ], + }], + }, + NodeUiChild { + label: "blast", + kind: "Shader", + detail: "./blast.toml", + state: "duration 10 s, trigger bus#trigger", + active: false, + presentation: vec![NodeUiPresentationItem::Product(NodeUiProduct { + kind: NodeUiProductKind::Visual, + name: "output", + size: Some("128 x 128"), + preview_cells: 12, + bindings: produced_bindings(None, &["../playlist#output"], &[]), + revision: 98, + })], + values: vec![NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Bound, "Time", "../playlist#entry_time"), + value_row(NodeUiValueSource::Bound, "Trigger", "bus#trigger"), + value_row(NodeUiValueSource::Direct, "Shader", "blast.glsl"), + ], + }], + }, + ], + } +} + +fn status_demo_node( + title: &'static str, + kind: &'static str, + source: Option<&'static str>, + status: NodeUiStatus, + perf: Option<&'static str>, + status_details_open: bool, +) -> NodeUiNode { + NodeUiNode { + title, + kind, + source, + path: "/status.demo", + status, + status_details_open, + perf, + presentation: Vec::new(), + values: vec![NodeUiValueGroup { + rows: vec![ + value_row(NodeUiValueSource::Direct, "Frame", "128"), + value_row(NodeUiValueSource::Direct, "Last duration", "0.34 ms"), + value_row(NodeUiValueSource::Direct, "Output", "ready"), + ], + }], + tabs: Vec::new(), + children: Vec::new(), + } +} + +fn node_json_tabs(shape_json: &'static str, slots_json: &'static str) -> Vec { + vec![ + NodeUiTab { + label: "main", + content: NodeUiTabContent::None, + }, + NodeUiTab { + label: "shape", + content: NodeUiTabContent::Json { + title: "Shape JSON", + body: shape_json, + }, + }, + NodeUiTab { + label: "slots", + content: NodeUiTabContent::Json { + title: "Slot value JSON", + body: slots_json, + }, + }, + ] +} + +fn value_row( + source: NodeUiValueSource, + label: &'static str, + value: &'static str, +) -> NodeUiValueRow { + NodeUiValueRow { + source, + label, + value, + binding_target: (source == NodeUiValueSource::Bound).then_some(value), + revision: match source { + NodeUiValueSource::Direct => 42, + NodeUiValueSource::Bound => 104, + NodeUiValueSource::Child => 0, + }, + nested: None, + } +} + +fn produced_bindings( + bus_target: Option<&'static str>, + target_bindings: &[&'static str], + consumers: &[&'static str], +) -> NodeUiProducedBindings { + NodeUiProducedBindings { + bus_target, + target_bindings: target_bindings.to_vec(), + consumers: consumers.to_vec(), + } +} + +fn bus_binding_name(binding_ref: &'static str) -> &'static str { + binding_ref.strip_prefix("bus#").unwrap_or(binding_ref) +} + +fn split_presentation_items( + items: Vec, +) -> (Vec, Vec) { + let mut products = Vec::new(); + let mut metrics = Vec::new(); + for item in items { + match item { + NodeUiPresentationItem::Product(product) => products.push(product), + NodeUiPresentationItem::Metric(metric) => metrics.push(metric), + } + } + (products, metrics) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NodeUiVariant { + Instrument, + Compact, +} + +#[derive(Clone, Debug, PartialEq)] +struct NodeUiNode { + title: &'static str, + kind: &'static str, + source: Option<&'static str>, + path: &'static str, + status: NodeUiStatus, + status_details_open: bool, + perf: Option<&'static str>, + presentation: Vec, + values: Vec, + tabs: Vec, + children: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct NodeUiStatus { + label: &'static str, + tone: NodeUiStatusTone, + summary: Option<&'static str>, + detail: Option<&'static str>, +} + +impl NodeUiStatus { + const fn running() -> Self { + Self { + label: "Running", + tone: NodeUiStatusTone::Running, + summary: None, + detail: Some("Node has run recently with no reported errors."), + } + } + + const fn idle(summary: Option<&'static str>) -> Self { + Self { + label: "Idle", + tone: NodeUiStatusTone::Idle, + summary, + detail: Some("Node has no current error, but has not run recently."), + } + } + + const fn error(summary: Option<&'static str>, detail: Option<&'static str>) -> Self { + Self { + label: "Error", + tone: NodeUiStatusTone::Error, + summary, + detail, + } + } + + fn window_class_name(self) -> &'static str { + match self.tone { + NodeUiStatusTone::Running => "ux-node-ui-window-status-running", + NodeUiStatusTone::Idle => "ux-node-ui-window-status-idle", + NodeUiStatusTone::Error => "ux-node-ui-window-status-error", + } + } + + fn indicator_class_name(self) -> &'static str { + match self.tone { + NodeUiStatusTone::Running => { + "ux-node-ui-status-button ux-node-ui-status-button-running" + } + NodeUiStatusTone::Idle => "ux-node-ui-status-button ux-node-ui-status-button-idle", + NodeUiStatusTone::Error => "ux-node-ui-status-button ux-node-ui-status-button-error", + } + } + + fn icon_name(self) -> StudioIconName { + match self.tone { + NodeUiStatusTone::Running => StudioIconName::StatusRunning, + NodeUiStatusTone::Idle => StudioIconName::StatusIdle, + NodeUiStatusTone::Error => StudioIconName::StatusError, + } + } + + fn popup_class_name(self) -> &'static str { + match self.tone { + NodeUiStatusTone::Running => "ux-node-ui-status-popup ux-node-ui-status-popup-running", + NodeUiStatusTone::Idle => "ux-node-ui-status-popup ux-node-ui-status-popup-idle", + NodeUiStatusTone::Error => "ux-node-ui-status-popup ux-node-ui-status-popup-error", + } + } + + fn error_detail(self) -> Option<&'static str> { + match self.tone { + NodeUiStatusTone::Error => self.detail, + NodeUiStatusTone::Running | NodeUiStatusTone::Idle => None, + } + } + + fn header_summary(self) -> Option<&'static str> { + match self.tone { + NodeUiStatusTone::Error => self.summary, + NodeUiStatusTone::Running | NodeUiStatusTone::Idle => None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NodeUiStatusTone { + Running, + Idle, + Error, +} + +#[derive(Clone, Debug, PartialEq)] +enum NodeUiPresentationItem { + Metric(NodeUiMetric), + Product(NodeUiProduct), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NodeUiMetric { + label: &'static str, + value: &'static str, + detail: Option<&'static str>, + bindings: NodeUiProducedBindings, + revision: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NodeUiProduct { + kind: NodeUiProductKind, + name: &'static str, + size: Option<&'static str>, + preview_cells: usize, + bindings: NodeUiProducedBindings, + revision: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct NodeUiProducedBindings { + bus_target: Option<&'static str>, + target_bindings: Vec<&'static str>, + consumers: Vec<&'static str>, +} + +impl NodeUiProducedBindings { + fn has_any(&self) -> bool { + self.bus_target.is_some() || !self.target_bindings.is_empty() || !self.consumers.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NodeUiProductKind { + Visual, + Control, +} + +impl NodeUiProductKind { + fn label(self) -> &'static str { + match self { + Self::Visual => "visual", + Self::Control => "control", + } + } + + fn product_label(self) -> &'static str { + match self { + Self::Visual => "visual product", + Self::Control => "control product", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct NodeUiValueGroup { + rows: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +struct NodeUiValueRow { + source: NodeUiValueSource, + label: &'static str, + value: &'static str, + binding_target: Option<&'static str>, + revision: u32, + nested: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NodeUiValueSource { + Direct, + Bound, + Child, +} + +impl NodeUiValueSource { + fn class_name(self) -> &'static str { + match self { + Self::Direct => "ux-node-ui-source ux-node-ui-source-direct", + Self::Bound => "ux-node-ui-source ux-node-ui-source-bound", + Self::Child => "ux-node-ui-source ux-node-ui-source-child", + } + } + + fn title(self) -> &'static str { + match self { + Self::Direct => "direct value", + Self::Bound => "bound value", + Self::Child => "child node", + } + } + + fn icon_name(self) -> StudioIconName { + match self { + Self::Direct => StudioIconName::AssignedValue, + Self::Bound => StudioIconName::BoundValue, + Self::Child => StudioIconName::ChildValue, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +struct NodeUiNestedValue { + title: &'static str, + summary: &'static str, + items: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct NodeUiNestedItem { + label: &'static str, + value: &'static str, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct NodeUiTab { + label: &'static str, + content: NodeUiTabContent, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NodeUiTabContent { + None, + Json { + title: &'static str, + body: &'static str, + }, +} + +#[derive(Clone, Debug, PartialEq)] +struct NodeUiChild { + label: &'static str, + kind: &'static str, + detail: &'static str, + state: &'static str, + active: bool, + presentation: Vec, + values: Vec, +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/clock.shape.json b/lp-app/lpa-studio-web/src/exploration/story_data/clock.shape.json new file mode 100644 index 000000000..4a2a42704 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/clock.shape.json @@ -0,0 +1,238 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 2, + "title": "Clock", + "path": "/fyeah_sign.show/clock.clock" + }, + "root_shapes": [ + { + "name": "node.2.def", + "shape": 520345680 + }, + { + "name": "node.2.state", + "shape": 3175756068 + } + ], + "registry": { + "ids_revision": 0, + "shapes": { + "520345680": { + "changed_at": 0, + "name": "lpc_model::nodes::clock::clock_def::ClockDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "controls", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "running", + "shape": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + }, + "policy": { + "persistence": "transient" + } + }, + { + "name": "rate", + "shape": { + "value": { + "shape": { + "id": 885023043, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 4.0, + "step": 0.05 + } + } + } + } + }, + "policy": { + "persistence": "transient" + } + }, + { + "name": "scrub_offset_seconds", + "shape": { + "value": { + "shape": { + "id": 3375024484, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": -10.0, + "max": 10.0, + "step": 0.016666668 + } + } + } + } + }, + "policy": { + "persistence": "transient" + } + } + ] + } + } + } + ] + } + } + }, + "1885459118": { + "changed_at": 0, + "name": "lpc_model::binding::binding_def::BindingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3678527952, + "ty": "any", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "source", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + }, + { + "name": "target", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + } + ] + } + } + }, + "3175756068": { + "changed_at": 0, + "name": "lpc_model::nodes::clock::clock_state::ClockState", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "seconds", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + }, + { + "name": "delta_seconds", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + } + ] + } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/clock.slots.json b/lp-app/lpa-studio-web/src/exploration/story_data/clock.slots.json new file mode 100644 index 000000000..fde8a9d23 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/clock.slots.json @@ -0,0 +1,26 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 2, + "title": "Clock", + "path": "/fyeah_sign.show/clock.clock" + }, + "roots": { + "roots": [ + { + "name": "node.2.def", + "shape": 520345680, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"map","keys_revision":0,"entries":[]},{"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":true},{"kind":"value","changed_at":0,"value":1},{"kind":"value","changed_at":0,"value":0}]}]} + }, + { + "name": "node.2.state", + "shape": 3175756068, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":102,"value":3.333},{"kind":"value","changed_at":102,"value":0.032999992}]} + } + ] + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/fixture.shape.json b/lp-app/lpa-studio-web/src/exploration/story_data/fixture.shape.json new file mode 100644 index 000000000..bb0a0e1e6 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/fixture.shape.json @@ -0,0 +1,585 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 3, + "title": "Fixture", + "path": "/fyeah_sign.show/fixture.fixture" + }, + "root_shapes": [ + { + "name": "node.3.def", + "shape": 814168903 + }, + { + "name": "node.3.state", + "shape": 1983594935 + } + ], + "registry": { + "ids_revision": 0, + "shapes": { + "814168903": { + "changed_at": 0, + "name": "lpc_model::nodes::fixture::fixture_def::FixtureDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "render_size", + "shape": { + "value": { + "shape": { + "id": 2973013964, + "ty": { + "struct": { + "name": "Dim2u", + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ] + } + }, + "meta": {}, + "editor": "dimensions" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "sampling", + "shape": { + "value": { + "shape": { + "id": 1216569569, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "diagnostic_mode", + "shape": { + "value": { + "shape": { + "id": 4232169722, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "off", + "label": "Off" + }, + { + "value": "led_index", + "label": "LED index" + }, + { + "value": "groups_10", + "label": "RGB groups of 10" + }, + { + "value": "path_colors", + "label": "Path colors" + }, + { + "value": "chase", + "label": "Chase" + } + ] + } + } + } + } + } + }, + { + "name": "mapping", + "shape": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "Unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "PathPoints", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "paths", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "RingArray", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "center", + "shape": { + "value": { + "shape": { + "id": 1477692922, + "ty": "vec2", + "meta": {}, + "editor": "xy" + } + } + } + }, + { + "name": "diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + }, + { + "name": "start_ring_inclusive", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "end_ring_exclusive", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "ring_lamp_counts", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "offset_angle", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "order", + "shape": { + "value": { + "shape": { + "id": 2101432397, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "inner_first", + "label": "Inner first" + }, + { + "value": "outer_first", + "label": "Outer first" + } + ] + } + } + } + } + } + } + ] + } + } + }, + { + "name": "PointList", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "first_channel", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "points", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "value": { + "shape": { + "id": 1477692922, + "ty": "vec2", + "meta": {}, + "editor": "xy" + } + } + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, + { + "name": "sample_diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + }, + { + "name": "SvgPath", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "meta": {}, + "codec": 2355797192, + "shape": { + "value": { + "shape": { + "id": 177956922, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "sample_diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + } + ] + } + } + }, + { + "name": "color_order", + "shape": { + "value": { + "shape": { + "id": 1235394384, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "rgb", + "label": "RGB" + }, + { + "value": "grb", + "label": "GRB" + }, + { + "value": "rbg", + "label": "RBG" + }, + { + "value": "gbr", + "label": "GBR" + }, + { + "value": "brg", + "label": "BRG" + }, + { + "value": "bgr", + "label": "BGR" + } + ] + } + } + } + } + } + }, + { + "name": "transform", + "shape": { + "value": { + "shape": { + "id": 120141864, + "ty": "mat3x3", + "meta": {}, + "editor": "affine2d" + } + } + } + }, + { + "name": "brightness", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "gamma_correction", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + } + } + } + } + ] + } + } + }, + "1885459118": { + "changed_at": 0, + "name": "lpc_model::binding::binding_def::BindingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3678527952, + "ty": "any", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "source", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + }, + { + "name": "target", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + } + ] + } + } + }, + "1983594935": { + "changed_at": 0, + "name": "lpc_model::nodes::fixture::fixture_state::FixtureState", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "output", + "shape": { + "value": { + "shape": { + "id": 4213724405, + "ty": { + "product": "control" + }, + "meta": {}, + "editor": "control_product" + } + } + } + } + ] + } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/fixture.slots.json b/lp-app/lpa-studio-web/src/exploration/story_data/fixture.slots.json new file mode 100644 index 000000000..e21e193f9 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/fixture.slots.json @@ -0,0 +1,26 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 3, + "title": "Fixture", + "path": "/fyeah_sign.show/fixture.fixture" + }, + "roots": { + "roots": [ + { + "name": "node.3.def", + "shape": 814168903, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":{"width":16,"height":16}},{"kind":"map","keys_revision":0,"entries":[{"key":"input","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"bus#visual.out"}},{"kind":"option","presence_revision":0,"present":false}]}},{"key":"output","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"bus#control.out"}}]}}]},{"kind":"value","changed_at":0,"value":"direct"},{"kind":"value","changed_at":0,"value":"off"},{"kind":"enum","variant_revision":0,"variant":"SvgPath","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":"fyeah-mapping.svg"},{"kind":"value","changed_at":0,"value":2}]}},{"kind":"value","changed_at":0,"value":"rgb"},{"kind":"value","changed_at":0,"value":[[1,0,0],[0,1,0],[0,0,1]]},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":255}},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":false}}]} + }, + { + "name": "node.3.state", + "shape": 1983594935, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":102,"value":{"kind":"control","node":3,"output":0,"preferred_extent":{"rows":1,"samples_per_row":657}}}]} + } + ] + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/playlist.shape.json b/lp-app/lpa-studio-web/src/exploration/story_data/playlist.shape.json new file mode 100644 index 000000000..451de019e --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/playlist.shape.json @@ -0,0 +1,2310 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 5, + "title": "Playlist", + "path": "/fyeah_sign.show/playlist.playlist" + }, + "root_shapes": [ + { + "name": "node.5.def", + "shape": 1520921198 + }, + { + "name": "node.5.state", + "shape": 237636858 + } + ], + "registry": { + "ids_revision": 0, + "shapes": { + "90603770": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "id": 2435360456, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "value", + "shape": { + "value": { + "shape": { + "id": 3864786694, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "key", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1124041669, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "default", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "max", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "mapping", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 4227971207 + } + } + } + } + }, + { + "name": "label", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + }, + "237636858": { + "changed_at": 0, + "name": "lpc_model::nodes::playlist::playlist_state::PlaylistState", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "output", + "shape": { + "value": { + "shape": { + "id": 689649576, + "ty": { + "product": "visual" + }, + "meta": {}, + "editor": "visual_product" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + }, + { + "name": "entry_time", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + }, + { + "name": "entry_progress", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + }, + { + "name": "active_entry", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "produced" + }, + "policy": { + "writable": false, + "persistence": "transient" + } + } + ] + } + } + }, + "520345680": { + "changed_at": 0, + "name": "lpc_model::nodes::clock::clock_def::ClockDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "controls", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "running", + "shape": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + }, + "policy": { + "persistence": "transient" + } + }, + { + "name": "rate", + "shape": { + "value": { + "shape": { + "id": 885023043, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 4.0, + "step": 0.05 + } + } + } + } + }, + "policy": { + "persistence": "transient" + } + }, + { + "name": "scrub_offset_seconds", + "shape": { + "value": { + "shape": { + "id": 3375024484, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": -10.0, + "max": 10.0, + "step": 0.016666668 + } + } + } + } + }, + "policy": { + "persistence": "transient" + } + } + ] + } + } + } + ] + } + } + }, + "770241759": { + "changed_at": 0, + "name": "lpc_model::nodes::output::output_def::OutputDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "id": 397552907, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "options", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 2660578566 + } + } + } + } + } + ] + } + } + }, + "814168903": { + "changed_at": 0, + "name": "lpc_model::nodes::fixture::fixture_def::FixtureDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "render_size", + "shape": { + "value": { + "shape": { + "id": 2973013964, + "ty": { + "struct": { + "name": "Dim2u", + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ] + } + }, + "meta": {}, + "editor": "dimensions" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "sampling", + "shape": { + "value": { + "shape": { + "id": 1216569569, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "diagnostic_mode", + "shape": { + "value": { + "shape": { + "id": 4232169722, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "off", + "label": "Off" + }, + { + "value": "led_index", + "label": "LED index" + }, + { + "value": "groups_10", + "label": "RGB groups of 10" + }, + { + "value": "path_colors", + "label": "Path colors" + }, + { + "value": "chase", + "label": "Chase" + } + ] + } + } + } + } + } + }, + { + "name": "mapping", + "shape": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "Unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "PathPoints", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "paths", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "RingArray", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "center", + "shape": { + "value": { + "shape": { + "id": 1477692922, + "ty": "vec2", + "meta": {}, + "editor": "xy" + } + } + } + }, + { + "name": "diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + }, + { + "name": "start_ring_inclusive", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "end_ring_exclusive", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "ring_lamp_counts", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "offset_angle", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "order", + "shape": { + "value": { + "shape": { + "id": 2101432397, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "inner_first", + "label": "Inner first" + }, + { + "value": "outer_first", + "label": "Outer first" + } + ] + } + } + } + } + } + } + ] + } + } + }, + { + "name": "PointList", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "first_channel", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "points", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "value": { + "shape": { + "id": 1477692922, + "ty": "vec2", + "meta": {}, + "editor": "xy" + } + } + } + } + } + } + ] + } + } + } + ] + } + } + } + } + }, + { + "name": "sample_diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + }, + { + "name": "SvgPath", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "meta": {}, + "codec": 2355797192, + "shape": { + "value": { + "shape": { + "id": 177956922, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "sample_diameter", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + } + ] + } + } + }, + { + "name": "color_order", + "shape": { + "value": { + "shape": { + "id": 1235394384, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "rgb", + "label": "RGB" + }, + { + "value": "grb", + "label": "GRB" + }, + { + "value": "rbg", + "label": "RBG" + }, + { + "value": "gbr", + "label": "GBR" + }, + { + "value": "brg", + "label": "BRG" + }, + { + "value": "bgr", + "label": "BGR" + } + ] + } + } + } + } + } + }, + { + "name": "transform", + "shape": { + "value": { + "shape": { + "id": 120141864, + "ty": "mat3x3", + "meta": {}, + "editor": "affine2d" + } + } + } + }, + { + "name": "brightness", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "gamma_correction", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + } + } + } + } + ] + } + } + }, + "899201012": { + "changed_at": 0, + "name": "lpc_model::nodes::playlist::playlist_entry::PlaylistEntry", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "trigger", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "ref": { + "id": 2014621053 + } + } + } + }, + "semantics": { + "direction": "consumed", + "merge": "by_key" + } + }, + { + "name": "name", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "duration", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + } + }, + { + "name": "fade_after", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + } + }, + { + "name": "node", + "shape": { + "enum": { + "meta": {}, + "encoding": "external", + "variants": [ + { + "name": "unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "ref", + "shape": { + "value": { + "shape": { + "id": 2244609540, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + }, + { + "name": "def", + "shape": { + "ref": { + "id": 3831631647 + } + } + } + ] + } + } + } + ] + } + } + }, + "1018556980": { + "changed_at": 0, + "name": "lpc_model::nodes::button::button_def::ButtonDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "id": 397552907, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "id", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "stable_ms", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + }, + "1520921198": { + "changed_at": 0, + "name": "lpc_model::nodes::playlist::playlist_def::PlaylistDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "time", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "consumed" + } + }, + { + "name": "idle_entry", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "default_fade", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + }, + { + "name": "entries", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "ref": { + "id": 899201012 + } + } + } + } + } + ] + } + } + }, + "1885459118": { + "changed_at": 0, + "name": "lpc_model::binding::binding_def::BindingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3678527952, + "ty": "any", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "source", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + }, + { + "name": "target", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + } + ] + } + } + }, + "2014621053": { + "changed_at": 0, + "name": "lp::control::Message", + "shape": { + "value": { + "shape": { + "id": 2014621053, + "ty": { + "struct": { + "name": "ControlMessage", + "fields": [ + { + "name": "id", + "ty": "u32" + }, + { + "name": "seq", + "ty": "u32" + } + ] + } + }, + "meta": {}, + "editor": "plain" + } + } + } + }, + "2328756067": { + "changed_at": 0, + "name": "lpc_model::nodes::project::project_def::ProjectDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "name", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "nodes", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "enum": { + "meta": {}, + "encoding": "external", + "variants": [ + { + "name": "unset", + "shape": { + "unit": { + "meta": {} + } + } + }, + { + "name": "ref", + "shape": { + "value": { + "shape": { + "id": 2244609540, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + }, + { + "name": "def", + "shape": { + "ref": { + "id": 3831631647 + } + } + } + ] + } + } + } + } + } + ] + } + } + }, + "2405894887": { + "changed_at": 0, + "name": "lp::fluid::Emitter", + "shape": { + "value": { + "shape": { + "id": 2405894887, + "ty": { + "struct": { + "name": "FluidEmitter", + "fields": [ + { + "name": "id", + "ty": "u32" + }, + { + "name": "pos", + "ty": "vec2" + }, + { + "name": "dir", + "ty": "vec2" + }, + { + "name": "radius", + "ty": "f32" + }, + { + "name": "color", + "ty": "vec3" + }, + { + "name": "velocity", + "ty": "f32" + }, + { + "name": "intensity", + "ty": "f32" + } + ] + } + }, + "meta": {}, + "editor": "plain" + } + } + } + }, + "2579233885": { + "changed_at": 0, + "name": "lpc_model::nodes::texture::texture_def::TextureDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "size", + "shape": { + "value": { + "shape": { + "id": 2973013964, + "ty": { + "struct": { + "name": "Dim2u", + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ] + } + }, + "meta": {}, + "editor": "dimensions" + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + } + ] + } + } + }, + "2660578566": { + "changed_at": 0, + "name": "lpc_model::nodes::output::output_def::OutputDriverOptionsConfig", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "white_point", + "shape": { + "value": { + "shape": { + "id": 2618928957, + "ty": "vec3", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "brightness", + "shape": { + "value": { + "shape": { + "id": 1724747812, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + } + } + } + } + }, + { + "name": "interpolation_enabled", + "shape": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "dithering_enabled", + "shape": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "lut_enabled", + "shape": { + "value": { + "shape": { + "id": 1196386242, + "ty": "bool", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + }, + "2887292794": { + "changed_at": 0, + "name": "lpc_model::nodes::fluid::fluid_def::FluidDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "size", + "shape": { + "value": { + "shape": { + "id": 2973013964, + "ty": { + "struct": { + "name": "Dim2u", + "fields": [ + { + "name": "width", + "ty": "u32" + }, + { + "name": "height", + "ty": "u32" + } + ] + } + }, + "meta": {}, + "editor": "dimensions" + } + } + } + }, + { + "name": "solver_iterations", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "step_hz", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + }, + { + "name": "fade_speed", + "shape": { + "value": { + "shape": { + "id": 1724747812, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + } + } + } + } + }, + { + "name": "viscosity", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + }, + { + "name": "time", + "shape": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + }, + "semantics": { + "direction": "consumed" + } + }, + { + "name": "emitters", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "ref": { + "id": 2405894887 + } + } + } + }, + "semantics": { + "direction": "consumed", + "merge": "by_key" + } + } + ] + } + } + }, + "3464771014": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_param_def::ScalarHint", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + }, + "3622644300": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::compute_shader_def::ComputeShaderDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "meta": {}, + "codec": 2355797192, + "shape": { + "value": { + "shape": { + "id": 177956922, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "glsl_opts", + "shape": { + "ref": { + "id": 4084892601 + } + } + }, + { + "name": "consumed", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 90603770 + } + } + } + } + }, + { + "name": "produced", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 90603770 + } + } + } + } + } + ] + } + } + }, + "3712581622": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_param_def::ShaderParamDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "label", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "value_type", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "default", + "shape": { + "value": { + "shape": { + "id": 1724747812, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 3464771014 + } + } + } + } + } + ] + } + } + }, + "3733861171": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_def::ShaderDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "meta": {}, + "codec": 2355797192, + "shape": { + "value": { + "shape": { + "id": 177956922, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "render_order", + "shape": { + "value": { + "shape": { + "id": 1231123999, + "ty": "i32", + "meta": {}, + "editor": { + "number": { + "step": 1.0 + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "glsl_opts", + "shape": { + "ref": { + "id": 4084892601 + } + } + }, + { + "name": "param_defs", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 3712581622 + } + } + } + } + }, + { + "name": "consumed", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 90603770 + } + } + } + } + } + ] + } + } + }, + "3831631647": { + "changed_at": 0, + "name": "lpc_model::nodes::node_def::NodeArtifact", + "shape": { + "enum": { + "meta": {}, + "variants": [ + { + "name": "Project", + "shape": { + "ref": { + "id": 2328756067 + } + } + }, + { + "name": "Button", + "shape": { + "ref": { + "id": 1018556980 + } + } + }, + { + "name": "Clock", + "shape": { + "ref": { + "id": 520345680 + } + } + }, + { + "name": "Texture", + "shape": { + "ref": { + "id": 2579233885 + } + } + }, + { + "name": "Shader", + "shape": { + "ref": { + "id": 3733861171 + } + } + }, + { + "name": "ComputeShader", + "shape": { + "ref": { + "id": 3622644300 + } + } + }, + { + "name": "Fluid", + "shape": { + "ref": { + "id": 2887292794 + } + } + }, + { + "name": "Playlist", + "shape": { + "ref": { + "id": 1520921198 + } + } + }, + { + "name": "ControlRadio", + "shape": { + "ref": { + "id": 4099574392 + } + } + }, + { + "name": "Output", + "shape": { + "ref": { + "id": 770241759 + } + } + }, + { + "name": "Fixture", + "shape": { + "ref": { + "id": 814168903 + } + } + } + ] + } + } + }, + "4084892601": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::glsl_opts::GlslOpts", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "add_sub", + "shape": { + "value": { + "shape": { + "id": 1838283229, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "wrapping", + "label": "Wrapping" + } + ] + } + } + } + } + } + }, + { + "name": "mul", + "shape": { + "value": { + "shape": { + "id": 2912674014, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "wrapping", + "label": "Wrapping" + } + ] + } + } + } + } + } + }, + { + "name": "div", + "shape": { + "value": { + "shape": { + "id": 1295061595, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "reciprocal", + "label": "Reciprocal" + } + ] + } + } + } + } + } + } + ] + } + } + }, + "4099574392": { + "changed_at": 0, + "name": "lpc_model::nodes::radio::control_radio_def::ControlRadioDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "endpoint", + "shape": { + "value": { + "shape": { + "id": 397552907, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "channel", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "repeat_count", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "wifi_channel", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "input", + "shape": { + "map": { + "meta": {}, + "key": "u32", + "value": { + "ref": { + "id": 2014621053 + } + } + } + }, + "semantics": { + "direction": "consumed", + "merge": "by_key" + } + } + ] + } + } + }, + "4227971207": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_slot_mapping::ShaderSlotMappingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "id": 3102355672, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "len", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "key", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "empty_key", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/playlist.slots.json b/lp-app/lpa-studio-web/src/exploration/story_data/playlist.slots.json new file mode 100644 index 000000000..470ad490f --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/playlist.slots.json @@ -0,0 +1,26 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 5, + "title": "Playlist", + "path": "/fyeah_sign.show/playlist.playlist" + }, + "roots": { + "roots": [ + { + "name": "node.5.def", + "shape": 1520921198, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"map","keys_revision":0,"entries":[{"key":"time","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"bus#time.seconds"}},{"kind":"option","presence_revision":0,"present":false}]}}]},{"kind":"value","changed_at":0,"value":0},{"kind":"value","changed_at":0,"value":1},{"kind":"value","changed_at":0,"value":0.35},{"kind":"map","keys_revision":0,"entries":[{"key":1,"data":{"kind":"record","fields_revision":0,"fields":[{"kind":"map","keys_revision":0,"entries":[]},{"kind":"map","keys_revision":0,"entries":[]},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"idle"}},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":0.12}},{"kind":"enum","variant_revision":0,"variant":"ref","data":{"kind":"value","changed_at":0,"value":"./idle.toml"}}]}},{"key":2,"data":{"kind":"record","fields_revision":0,"fields":[{"kind":"map","keys_revision":0,"entries":[{"key":"trigger","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"bus#trigger"}},{"kind":"option","presence_revision":0,"present":false}]}}]},{"kind":"map","keys_revision":0,"entries":[]},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"blast"}},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":10}},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":2}},{"kind":"enum","variant_revision":0,"variant":"ref","data":{"kind":"value","changed_at":0,"value":"./blast.toml"}}]}}]}]} + }, + { + "name": "node.5.state", + "shape": 237636858, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":102,"value":{"kind":"visual","node":5,"output":0}},{"kind":"value","changed_at":102,"value":3.333},{"kind":"value","changed_at":102,"value":-1},{"kind":"value","changed_at":102,"value":1}]} + } + ] + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/shader.shape.json b/lp-app/lpa-studio-web/src/exploration/story_data/shader.shape.json new file mode 100644 index 000000000..6b0cff719 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/shader.shape.json @@ -0,0 +1,625 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 8, + "title": "blast", + "path": "/fyeah_sign.show/playlist.playlist/blast.shader" + }, + "root_shapes": [ + { + "name": "node.8.def", + "shape": 3733861171 + }, + { + "name": "node.8.state", + "shape": 3824465175 + } + ], + "registry": { + "ids_revision": 0, + "shapes": { + "90603770": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_slot_def::ShaderSlotDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "id": 2435360456, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "value", + "shape": { + "value": { + "shape": { + "id": 3864786694, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "key", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1124041669, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "default", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "max", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 2605450937, + "ty": "f32", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "mapping", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 4227971207 + } + } + } + } + }, + { + "name": "label", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + }, + "1885459118": { + "changed_at": 0, + "name": "lpc_model::binding::binding_def::BindingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 3678527952, + "ty": "any", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "source", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + }, + { + "name": "target", + "shape": { + "option": { + "meta": {}, + "some": { + "value": { + "shape": { + "id": 1364391883, + "ty": "string", + "meta": {}, + "editor": "path" + } + } + } + } + } + } + ] + } + } + }, + "3464771014": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_param_def::ScalarHint", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "value", + "shape": { + "value": { + "shape": { + "id": 2960302901, + "ty": "f32", + "meta": {}, + "editor": { + "number": { + "min": 0.0 + } + } + } + } + } + } + ] + } + } + }, + "3712581622": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_param_def::ShaderParamDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "label", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "description", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "value_type", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "default", + "shape": { + "value": { + "shape": { + "id": 1724747812, + "ty": "f32", + "meta": {}, + "editor": { + "slider": { + "min": 0.0, + "max": 1.0, + "step": 0.01 + } + } + } + } + } + }, + { + "name": "min", + "shape": { + "option": { + "meta": {}, + "some": { + "ref": { + "id": 3464771014 + } + } + } + } + } + ] + } + } + }, + "3733861171": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_def::ShaderDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "source", + "shape": { + "custom": { + "meta": {}, + "codec": 2355797192, + "shape": { + "value": { + "shape": { + "id": 177956922, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + } + } + }, + { + "name": "render_order", + "shape": { + "value": { + "shape": { + "id": 1231123999, + "ty": "i32", + "meta": {}, + "editor": { + "number": { + "step": 1.0 + } + } + } + } + } + }, + { + "name": "bindings", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 1885459118 + } + } + } + } + }, + { + "name": "glsl_opts", + "shape": { + "ref": { + "id": 4084892601 + } + } + }, + { + "name": "param_defs", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 3712581622 + } + } + } + } + }, + { + "name": "consumed", + "shape": { + "map": { + "meta": {}, + "key": "string", + "value": { + "ref": { + "id": 90603770 + } + } + } + } + } + ] + } + } + }, + "3824465175": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_state::ShaderState", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "output", + "shape": { + "value": { + "shape": { + "id": 689649576, + "ty": { + "product": "visual" + }, + "meta": {}, + "editor": "visual_product" + } + } + } + } + ] + } + } + }, + "4084892601": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::glsl_opts::GlslOpts", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "add_sub", + "shape": { + "value": { + "shape": { + "id": 1838283229, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "wrapping", + "label": "Wrapping" + } + ] + } + } + } + } + } + }, + { + "name": "mul", + "shape": { + "value": { + "shape": { + "id": 2912674014, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "wrapping", + "label": "Wrapping" + } + ] + } + } + } + } + } + }, + { + "name": "div", + "shape": { + "value": { + "shape": { + "id": 1295061595, + "ty": "string", + "meta": {}, + "editor": { + "dropdown": { + "options": [ + { + "value": "saturating", + "label": "Saturating" + }, + { + "value": "reciprocal", + "label": "Reciprocal" + } + ] + } + } + } + } + } + } + ] + } + } + }, + "4227971207": { + "changed_at": 0, + "name": "lpc_model::nodes::shader::shader_slot_mapping::ShaderSlotMappingDef", + "shape": { + "record": { + "meta": {}, + "fields": [ + { + "name": "kind", + "shape": { + "value": { + "shape": { + "id": 3102355672, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "len", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "key", + "shape": { + "value": { + "shape": { + "id": 2612013983, + "ty": "string", + "meta": {}, + "editor": "plain" + } + } + } + }, + { + "name": "empty_key", + "shape": { + "value": { + "shape": { + "id": 3065358156, + "ty": "u32", + "meta": {}, + "editor": "plain" + } + } + } + } + ] + } + } + } + } + } +} diff --git a/lp-app/lpa-studio-web/src/exploration/story_data/shader.slots.json b/lp-app/lpa-studio-web/src/exploration/story_data/shader.slots.json new file mode 100644 index 000000000..8fc7a2065 --- /dev/null +++ b/lp-app/lpa-studio-web/src/exploration/story_data/shader.slots.json @@ -0,0 +1,26 @@ +{ + "source": { + "project": "projects/test/fyeah-sign", + "read_revision": 102, + "request": "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks" + }, + "node": { + "id": 8, + "title": "blast", + "path": "/fyeah_sign.show/playlist.playlist/blast.shader" + }, + "roots": { + "roots": [ + { + "name": "node.8.def", + "shape": 3733861171, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":"blast.glsl"},{"kind":"value","changed_at":0,"value":0},{"kind":"map","keys_revision":0,"entries":[{"key":"progress","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"..#entry_progress"}},{"kind":"option","presence_revision":0,"present":false}]}},{"key":"time","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":"..#entry_time"}},{"kind":"option","presence_revision":0,"present":false}]}}]},{"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":"wrapping"},{"kind":"value","changed_at":0,"value":"wrapping"},{"kind":"value","changed_at":0,"value":"reciprocal"}]},{"kind":"map","keys_revision":0,"entries":[]},{"kind":"map","keys_revision":0,"entries":[{"key":"progress","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":"value"},{"kind":"value","changed_at":0,"value":"f32"},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":0}},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":false},{"kind":"value","changed_at":0,"value":""},{"kind":"value","changed_at":0,"value":""}]}},{"key":"time","data":{"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":"value"},{"kind":"value","changed_at":0,"value":"f32"},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":true,"data":{"kind":"value","changed_at":0,"value":0}},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":false},{"kind":"option","presence_revision":0,"present":false},{"kind":"value","changed_at":0,"value":""},{"kind":"value","changed_at":0,"value":""}]}}]}]} + }, + { + "name": "node.8.state", + "shape": 3824465175, + "data": {"kind":"record","fields_revision":0,"fields":[{"kind":"value","changed_at":0,"value":{"kind":"visual","node":8,"output":0}}]} + } + ] + } +} diff --git a/lp-app/lpa-studio-web/src/main.rs b/lp-app/lpa-studio-web/src/main.rs index 00ecad8a6..359e04c67 100644 --- a/lp-app/lpa-studio-web/src/main.rs +++ b/lp-app/lpa-studio-web/src/main.rs @@ -1,8 +1,12 @@ -mod app; -mod components; +pub mod app; +pub mod base; +pub mod core; +pub mod exploration; #[cfg(feature = "stories")] mod stories; +mod studio_url; +mod web_app; fn main() { - dioxus::launch(app::App); + dioxus::launch(web_app::App); } diff --git a/lp-app/lpa-studio-web/src/stories/mod.rs b/lp-app/lpa-studio-web/src/stories/mod.rs index b8c8e8f6e..283ac95f4 100644 --- a/lp-app/lpa-studio-web/src/stories/mod.rs +++ b/lp-app/lpa-studio-web/src/stories/mod.rs @@ -3,4 +3,3 @@ pub mod story; pub mod story_book; pub mod story_registry; -pub mod studio_ux_stories; diff --git a/lp-app/lpa-studio-web/src/stories/story.rs b/lp-app/lpa-studio-web/src/stories/story.rs index 6588059de..a7082ca34 100644 --- a/lp-app/lpa-studio-web/src/stories/story.rs +++ b/lp-app/lpa-studio-web/src/stories/story.rs @@ -1,8 +1,18 @@ -/// Metadata for one Studio component story. +/// Metadata for one generated Studio story. +/// +/// Story authors do not construct this by hand. Story files declare +/// `#[story]` functions, and `lpa-studio-web/build.rs` infers the +/// family/category/component/story fields from the file path plus function name. +/// Labels are derived from function names unless a story provides an explicit +/// `label = "..."` override. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct StoryDescriptor { pub id: &'static str, - pub group: &'static str, + pub source_path: &'static str, + pub family: &'static str, + pub category: Option<&'static str>, + pub component: &'static str, + pub story: &'static str, pub label: &'static str, pub description: &'static str, } @@ -10,15 +20,33 @@ pub struct StoryDescriptor { impl StoryDescriptor { pub const fn new( id: &'static str, - group: &'static str, + source_path: &'static str, + family: &'static str, + category: Option<&'static str>, + component: &'static str, + story: &'static str, label: &'static str, description: &'static str, ) -> Self { Self { id, - group, + source_path, + family, + category, + component, + story, label, description, } } + + pub fn family_label(self) -> &'static str { + match self.family { + "base" => "Base", + "core" => "Core", + "studio" => "Studio", + "exploration" => "Exploration", + _ => self.family, + } + } } diff --git a/lp-app/lpa-studio-web/src/stories/story_book.rs b/lp-app/lpa-studio-web/src/stories/story_book.rs index e0a11303b..05c1b4475 100644 --- a/lp-app/lpa-studio-web/src/stories/story_book.rs +++ b/lp-app/lpa-studio-web/src/stories/story_book.rs @@ -1,94 +1,437 @@ use dioxus::prelude::*; +use std::rc::Rc; +use wasm_bindgen::{JsCast, closure::Closure}; -use crate::stories::story_registry::{DEFAULT_STORY_ID, all_stories, render_story, story_by_id}; +use crate::stories::story::StoryDescriptor; +use crate::stories::story_registry::{ + DEFAULT_STORY_ID, all_stories, generated_at_utc, render_story, story_by_id, +}; #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] pub fn StoryBook() -> Element { - let initial_story_id = selected_story_id_from_hash(); - let mut selected_story_id = use_signal(move || initial_story_id); - let mut viewport = use_signal(|| StoryViewport::Wide); - let selected = selected_story_id.read().clone(); - let descriptor = story_by_id(&selected).unwrap_or_else(|| { - story_by_id(DEFAULT_STORY_ID).expect("default story descriptor is registered") - }); + let initial_route = selected_story_route_from_hash(); + let mut selected_story_id = use_signal(move || initial_route.story_id); + let mut viewport = use_signal(move || initial_route.viewport); + let _hash_listener = use_hook(move || install_story_hash_listener(selected_story_id, viewport)); let stories = all_stories(); + let story_groups = story_groups(&stories); + let selected = selected_story_id.read().clone(); + let selected_viewport = *viewport.read(); + let selection = story_selection(&selected, &story_groups) + .or_else(|| story_selection(DEFAULT_STORY_ID, &story_groups)) + .expect("default story descriptor is registered"); + let build_stamp = generated_at_utc(); + let story_summary = format!("{} states / sm md lg", stories.len()); + let page_title = selection.label(); + let page_description = selection.description(); + let page_source_ref = selection.source_ref(); + let page_id = selection.id().to_string(); if is_story_png_mode() { + let story_viewport = story_png_viewport(); return rsx! { - main { class: "story-png-page", - StoryCanvas { - story_id: descriptor.id, - label: descriptor.label, - description: descriptor.description, - frame_style: StoryViewport::Wide.frame_style(), - } + main { class: "tw:p-[22px]", + {render_story_selection(&selection, story_viewport)} } }; } - let frame_style = viewport.read().frame_style(); rsx! { - main { class: "story-book", - aside { class: "story-sidebar", - div { class: "story-sidebar-heading", - h1 { "Studio Stories" } - p { "{stories.len()} component states" } + main { class: "tw:grid tw:h-screen tw:min-h-0 tw:grid-cols-[260px_minmax(0,1fr)] tw:overflow-hidden tw:max-[880px]:grid-cols-1", + aside { class: "tw:min-h-0 tw:overflow-y-auto tw:border-r tw:border-border tw:bg-card-subtle tw:max-[880px]:border-b tw:max-[880px]:border-r-0", + header { class: "tw:grid tw:gap-2 tw:border-b tw:border-border-muted tw:bg-[linear-gradient(135deg,var(--studio-color-surface-muted),var(--studio-status-good-bg)_54%,var(--studio-color-surface-subtle))] tw:px-[18px] tw:py-4", + h1 { class: "tw:m-0 tw:text-lg tw:font-extrabold tw:leading-tight tw:text-strong-foreground", "Lightplayer Design" } + div { class: "tw:grid tw:gap-1", + p { class: "tw:m-0 tw:font-mono tw:text-[0.68rem] tw:leading-tight tw:text-muted-foreground", "built {build_stamp}" } + p { class: "tw:m-0 tw:text-xs tw:font-bold tw:uppercase tw:leading-tight tw:text-subtle-foreground", "{story_summary}" } + } } - nav { class: "story-nav", - for story in stories.iter() { - { - let story_id = story.id; - let link_class = if story.id == selected { - "story-nav-link is-active" - } else { - "story-nav-link" - }; - rsx! { - a { - class: "{link_class}", - href: "#/stories/{story.id}", - onclick: move |_| selected_story_id.set(story_id.to_string()), - span { class: "story-nav-group", "{story.group}" } - strong { "{story.label}" } + div { class: "tw:hidden", "aria-hidden": "true", + for family in story_groups.iter() { + for category in family.categories.iter() { + for component in category.components.iter() { + { + let overview_href = story_hash(&component.overview_id, selected_viewport); + rsx! { + a { + href: "{overview_href}", + tabindex: "-1", + "{component.label} overview" + } + } + } + for story in component.stories.iter() { + { + let story_href = story_hash(story.id, selected_viewport); + rsx! { + a { + href: "{story_href}", + tabindex: "-1", + "{story.label}" + } + } + } } } } } } - } - section { class: "story-stage", - div { class: "story-toolbar", - div { - h2 { "{descriptor.label}" } - p { "{descriptor.group} / {descriptor.id}" } - } - div { class: "story-viewport-controls", - ViewportButton { - label: "Narrow", - active: *viewport.read() == StoryViewport::Narrow, - onclick: move |_| viewport.set(StoryViewport::Narrow), + nav { class: "tw:grid tw:gap-[18px] tw:p-[18px]", + for family in story_groups.iter() { + section { class: "tw:grid tw:min-w-0 tw:gap-2", + h2 { class: "tw:m-0 tw:mb-0.5 tw:text-xs tw:font-extrabold tw:uppercase tw:text-heading", "{family.label}" } + div { class: "tw:grid tw:min-w-0 tw:gap-0.5", + for category in family.categories.iter() { + { + rsx! { + div { class: "tw:grid tw:min-w-0 tw:gap-1 tw:border-l tw:border-border-muted tw:pl-2.5", + if let Some(category_label) = category.label.as_deref() { + h3 { class: "tw:m-0 tw:mt-2 tw:text-xs tw:font-extrabold tw:uppercase tw:text-subtle-foreground", "{category_label}" } + } + div { class: "tw:grid tw:min-w-0 tw:gap-1", + for component in category.components.iter() { + { + let expanded = component.overview_id == selected || component + .stories + .iter() + .any(|story| story.id == selected); + let component_class = story_nav_component_class(expanded); + let component_href = story_hash(&component.overview_id, selected_viewport); + let overview_id_for_component = component.overview_id.clone(); + rsx! { + div { class: "tw:grid tw:min-w-0", + a { + class: "{component_class}", + href: "{component_href}", + onclick: move |_| selected_story_id.set(overview_id_for_component.clone()), + span { class: "tw:min-w-0 tw:overflow-hidden tw:text-ellipsis tw:whitespace-nowrap", "{component.label}" } + span { class: "tw:text-xs tw:text-subtle-foreground", "{component.stories.len()}" } + } + { + let story_list_class = if expanded { + story_nav_story_list_class(true) + } else { + story_nav_story_list_class(false) + }; + rsx! { + div { + class: "{story_list_class}", + "aria-hidden": if expanded { "false" } else { "true" }, + div { class: "tw:grid tw:min-h-0 tw:min-w-0 tw:gap-0.5 tw:overflow-hidden tw:pl-2", + { + let overview_link_class = if component.overview_id == selected { + story_nav_link_class(true, true) + } else { + story_nav_link_class(true, false) + }; + let overview_href = story_hash(&component.overview_id, selected_viewport); + let overview_id_for_link = component.overview_id.clone(); + rsx! { + a { + class: "{overview_link_class}", + href: "{overview_href}", + tabindex: if expanded { "0" } else { "-1" }, + onclick: move |_| selected_story_id.set(overview_id_for_link.clone()), + "Overview" + } + } + } + for story in component.stories.iter() { + { + let story_id = story.id; + let link_class = if story.id == selected { + story_nav_link_class(false, true) + } else { + story_nav_link_class(false, false) + }; + let story_href = story_hash(story_id, selected_viewport); + rsx! { + a { + class: "{link_class}", + href: "{story_href}", + tabindex: if expanded { "0" } else { "-1" }, + onclick: move |_| selected_story_id.set(story_id.to_string()), + "{story.label}" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } } - ViewportButton { - label: "Panel", - active: *viewport.read() == StoryViewport::Panel, - onclick: move |_| viewport.set(StoryViewport::Panel), + } + } + } + section { class: "tw:min-h-0 tw:overflow-y-auto tw:p-[22px]", + div { class: "tw:mb-4 tw:flex tw:items-start tw:justify-between tw:gap-4", + div { class: "tw:grid tw:min-w-0 tw:gap-1", + h2 { class: "tw:m-0 tw:text-xl tw:font-bold tw:text-strong-foreground", "{page_title}" } + p { class: "tw:m-0 tw:font-mono tw:text-xs tw:text-dim-foreground tw:break-words", "{page_source_ref}" } + if !page_description.is_empty() { + p { class: "tw:m-0 tw:pt-1.5 tw:text-sm tw:text-dim-foreground", "{page_description}" } } - ViewportButton { - label: "Wide", - active: *viewport.read() == StoryViewport::Wide, - onclick: move |_| viewport.set(StoryViewport::Wide), + } + div { class: "tw:flex tw:gap-2", + for target_viewport in [StoryViewport::Sm, StoryViewport::Md, StoryViewport::Lg] { + { + let selected_for_button = page_id.clone(); + rsx! { + ViewportButton { + viewport: target_viewport, + active: selected_viewport == target_viewport, + onclick: move |_| { + viewport.set(target_viewport); + set_story_hash(&selected_for_button, target_viewport); + }, + } + } + } } } } - StoryCanvas { - story_id: descriptor.id, - label: descriptor.label, - description: descriptor.description, - frame_style, + {render_story_selection(&selection, selected_viewport)} + } + } + } +} + +#[derive(Clone, Debug)] +struct StoryRoute { + story_id: String, + viewport: StoryViewport, +} + +#[derive(Clone, Debug)] +struct StoryFamilyGroup { + key: &'static str, + label: &'static str, + categories: Vec, +} + +#[derive(Clone, Debug)] +struct StoryCategoryGroup { + key: Option<&'static str>, + label: Option, + components: Vec, +} + +#[derive(Clone, Debug)] +struct StoryComponentGroup { + key: &'static str, + label: String, + overview_id: String, + stories: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum StorySelection { + Story(StoryDescriptor), + ComponentOverview { + id: String, + label: String, + description: String, + source_path: String, + stories: Vec, + }, +} + +impl StorySelection { + fn id(&self) -> &str { + match self { + Self::Story(story) => story.id, + Self::ComponentOverview { id, .. } => id, + } + } + + fn label(&self) -> String { + match self { + Self::Story(story) => story.label.to_string(), + Self::ComponentOverview { label, .. } => label.clone(), + } + } + + fn description(&self) -> String { + match self { + Self::Story(story) => story.description.to_string(), + Self::ComponentOverview { description, .. } => description.clone(), + } + } + + fn source_ref(&self) -> String { + match self { + Self::Story(story) => { + format!("{}:{}", story.source_path, story_function_name(story.story)) + } + Self::ComponentOverview { source_path, .. } => { + format!("{source_path}:overview") + } + } + } +} + +fn story_groups(stories: &[StoryDescriptor]) -> Vec { + let mut groups = Vec::::new(); + for story in stories { + let family_index = groups + .iter() + .position(|group| group.key == story.family) + .unwrap_or_else(|| { + groups.push(StoryFamilyGroup { + key: story.family, + label: story.family_label(), + categories: Vec::new(), + }); + groups.len() - 1 + }); + let family = &mut groups[family_index]; + let category_index = family + .categories + .iter() + .position(|category| category.key == story.category) + .unwrap_or_else(|| { + family.categories.push(StoryCategoryGroup { + key: story.category, + label: story.category.map(segment_label), + components: Vec::new(), + }); + family.categories.len() - 1 + }); + let category = &mut family.categories[category_index]; + let component_index = category + .components + .iter() + .position(|component| component.key == story.component) + .unwrap_or_else(|| { + category.components.push(StoryComponentGroup { + key: story.component, + label: segment_label(story.component), + overview_id: component_overview_id(story), + stories: Vec::new(), + }); + category.components.len() - 1 + }); + category.components[component_index].stories.push(*story); + } + groups.sort_by(|left, right| { + story_group_order(left.key) + .cmp(&story_group_order(right.key)) + .then_with(|| left.label.cmp(right.label)) + }); + groups +} + +fn story_selection(selected_id: &str, groups: &[StoryFamilyGroup]) -> Option { + for family in groups { + for category in &family.categories { + for component in &category.components { + if component.overview_id == selected_id { + return Some(StorySelection::ComponentOverview { + id: component.overview_id.clone(), + label: format!("{} Overview", component.label), + description: format!( + "All {} stories for this component.", + component.stories.len() + ), + source_path: component_source_path(&component.stories), + stories: component.stories.clone(), + }); + } + + if let Some(story) = component + .stories + .iter() + .find(|story| story.id == selected_id) + { + return Some(StorySelection::Story(*story)); } } } } + None +} + +fn story_route_exists(story_id: &str) -> bool { + if story_by_id(story_id).is_some() { + return true; + } + + let stories = all_stories(); + let groups = story_groups(&stories); + story_selection(story_id, &groups).is_some() +} + +fn component_source_path(stories: &[StoryDescriptor]) -> String { + let Some(first) = stories.first() else { + return "generated overview".to_string(); + }; + if stories + .iter() + .all(|story| story.source_path == first.source_path) + { + return first.source_path.to_string(); + } + "multiple story files".to_string() +} + +fn component_overview_id(story: &StoryDescriptor) -> String { + let mut id = story.family.to_string(); + id.push('/'); + if let Some(category) = story.category { + id.push_str(category); + id.push('/'); + } + id.push_str(story.component); + id.push_str("/overview"); + id +} + +fn story_function_name(story_segment: &str) -> String { + story_segment.replace('-', "_") +} + +fn story_group_order(family: &str) -> usize { + match family { + "base" => 0, + "core" => 1, + "studio" => 2, + "exploration" => 3, + _ => 99, + } +} + +fn segment_label(segment: &str) -> String { + segment + .split('-') + .filter(|part| !part.is_empty()) + .map(|part| match part { + "ui" => "UI".to_string(), + "ux" => "UX".to_string(), + "usb" => "USB".to_string(), + "esp32" => "ESP32".to_string(), + _ => { + let mut chars = part.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + let mut label = first.to_ascii_uppercase().to_string(); + label.push_str(&chars.as_str().to_ascii_lowercase()); + label + } + }) + .collect::>() + .join(" ") } pub fn should_show_story_book() -> bool { @@ -97,69 +440,303 @@ pub fn should_show_story_book() -> bool { #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn StoryCanvas( - story_id: &'static str, - label: &'static str, - description: &'static str, - frame_style: &'static str, -) -> Element { +fn StoryCanvas(story_id: &'static str, viewport: StoryViewport) -> Element { + let frame_style = viewport.frame_style(); + let canvas_label = viewport.canvas_label(); + rsx! { div { - class: "story-canvas-shell", + class: "tw:inline-grid tw:w-max tw:overflow-visible", "data-story-capture": "1", "data-story-id": "{story_id}", - "data-story-label": "{label}", - div { class: "story-canvas-meta", - h3 { "{label}" } - p { "{description}" } + div { class: "tw:box-content tw:flow-root tw:min-w-0 tw:overflow-hidden tw:rounded-sm tw:border-4 tw:border-border-muted tw:bg-card-muted", style: "{frame_style}", + div { class: "tw:flex tw:min-w-0 tw:w-full tw:justify-start tw:border-b-4 tw:border-border-muted tw:bg-border-muted", + span { class: "tw:px-2 tw:py-1 tw:font-mono tw:text-xs tw:leading-none tw:text-subtle-foreground", "{canvas_label}" } + } + div { class: "tw:flow-root tw:min-w-0 tw:w-full tw:bg-card-muted tw:p-2", + div { class: "story-frame-checker tw:flow-root tw:min-w-0 tw:w-full", + {render_story(story_id)} + } + } + } + } + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn StoryFrame(story_id: &'static str, viewport: StoryViewport) -> Element { + let frame_style = viewport.frame_style(); + let canvas_label = viewport.canvas_label(); + + rsx! { + div { class: "tw:inline-grid tw:w-max tw:overflow-visible", + div { class: "tw:box-content tw:flow-root tw:min-w-0 tw:overflow-hidden tw:rounded-sm tw:border-4 tw:border-border-muted tw:bg-card-muted", style: "{frame_style}", + div { class: "tw:flex tw:min-w-0 tw:w-full tw:justify-start tw:border-b-4 tw:border-border-muted tw:bg-border-muted", + span { class: "tw:px-2 tw:py-1 tw:font-mono tw:text-xs tw:leading-none tw:text-subtle-foreground", "{canvas_label}" } + } + div { class: "tw:flow-root tw:min-w-0 tw:w-full tw:bg-card-muted tw:p-2", + div { class: "story-frame-checker tw:flow-root tw:min-w-0 tw:w-full", + {render_story(story_id)} + } + } + } + } + } +} + +fn render_story_selection(selection: &StorySelection, viewport: StoryViewport) -> Element { + match selection { + StorySelection::Story(story) => rsx! { + StoryCanvas { + key: "{story.id}", + story_id: story.id, + viewport, } - div { class: "story-frame", style: "{frame_style}", - {render_story(story_id)} + }, + StorySelection::ComponentOverview { id, stories, .. } => rsx! { + StoryOverviewCanvas { + key: "{id}", + story_id: id.clone(), + stories: stories.clone(), + viewport, } + }, + } +} + +#[component] +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +fn StoryOverviewCanvas( + story_id: String, + stories: Vec, + viewport: StoryViewport, +) -> Element { + rsx! { + div { class: "tw:grid tw:w-max tw:gap-[26px]", + "data-story-capture": "1", + "data-story-id": "{story_id}", + for story in stories { + section { + key: "{story.id}", + class: "tw:grid tw:w-max tw:gap-2.5 tw:border-b tw:border-border-muted tw:pb-6 tw:last:border-b-0 tw:last:pb-0", + header { class: "tw:grid tw:min-w-0 tw:gap-1", + h3 { class: "tw:m-0 tw:text-base tw:font-bold tw:text-strong-foreground", "{story.label}" } + p { class: "tw:m-0 tw:font-mono tw:text-xs tw:text-dim-foreground tw:break-words", "{story.source_path}" } + } + div { class: "tw:min-w-0", + StoryFrame { + key: "{story.id}", + story_id: story.id, + viewport, + } + } + } + } + } + } +} + +fn story_nav_component_class(active: bool) -> &'static str { + if active { + "tw:flex tw:min-w-0 tw:items-center tw:justify-between tw:gap-2 tw:rounded-sm tw:border tw:border-border-strong tw:bg-card-raised tw:px-2 tw:py-1.5 tw:text-sm tw:leading-tight tw:text-strong-foreground tw:no-underline" + } else { + "tw:flex tw:min-w-0 tw:items-center tw:justify-between tw:gap-2 tw:rounded-sm tw:border tw:border-transparent tw:px-2 tw:py-1.5 tw:text-sm tw:leading-tight tw:text-soft-foreground tw:no-underline tw:hover:bg-card-raised tw:hover:text-strong-foreground" + } +} + +fn story_nav_story_list_class(expanded: bool) -> &'static str { + if expanded { + "tw:grid tw:grid-rows-[1fr] tw:opacity-100 tw:transition-[grid-template-rows,opacity] tw:duration-150" + } else { + "tw:grid tw:grid-rows-[0fr] tw:opacity-0 tw:transition-[grid-template-rows,opacity] tw:duration-150" + } +} + +fn story_nav_link_class(overview: bool, active: bool) -> &'static str { + match (overview, active) { + (true, true) => { + "tw:block tw:min-w-0 tw:border-l-2 tw:border-accent-border tw:bg-[linear-gradient(90deg,var(--studio-status-good-bg),transparent_90%)] tw:py-1 tw:pl-2.5 tw:text-sm tw:font-extrabold tw:leading-tight tw:text-strong-foreground tw:no-underline tw:break-words" + } + (true, false) => { + "tw:block tw:min-w-0 tw:border-l-2 tw:border-transparent tw:py-1 tw:pl-2.5 tw:text-sm tw:font-extrabold tw:leading-tight tw:text-soft-foreground tw:no-underline tw:break-words tw:hover:text-strong-foreground" + } + (false, true) => { + "tw:block tw:min-w-0 tw:border-l-2 tw:border-accent-border tw:bg-[linear-gradient(90deg,var(--studio-status-good-bg),transparent_90%)] tw:py-1 tw:pl-2.5 tw:text-sm tw:leading-tight tw:text-strong-foreground tw:no-underline tw:break-words" } + (false, false) => { + "tw:block tw:min-w-0 tw:border-l-2 tw:border-transparent tw:py-1 tw:pl-2.5 tw:text-sm tw:leading-tight tw:text-muted-foreground tw:no-underline tw:break-words tw:hover:text-strong-foreground" + } + } +} + +fn viewport_button_class(active: bool) -> &'static str { + if active { + "tw:grid tw:min-w-[58px] tw:gap-px tw:rounded-sm tw:border tw:border-accent-border tw:bg-card-raised tw:px-2.5 tw:py-1.5 tw:text-left tw:leading-tight" + } else { + "tw:grid tw:min-w-[58px] tw:gap-px tw:rounded-sm tw:border tw:border-border-strong tw:bg-card-raised tw:px-2.5 tw:py-1.5 tw:text-left tw:leading-tight" } } #[component] #[allow(non_snake_case, reason = "Dioxus components use PascalCase")] -fn ViewportButton(label: &'static str, active: bool, onclick: EventHandler) -> Element { +fn ViewportButton( + viewport: StoryViewport, + active: bool, + onclick: EventHandler, +) -> Element { let class = if active { - "story-viewport-button is-active" + viewport_button_class(true) } else { - "story-viewport-button" + viewport_button_class(false) }; rsx! { button { class, type: "button", onclick: move |event| onclick.call(event), - "{label}" + span { class: "tw:text-xs tw:font-extrabold tw:text-strong-foreground", "{viewport.slug()}" } + span { class: "tw:text-[0.66rem] tw:text-subtle-foreground", "{viewport.width_label()}" } } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum StoryViewport { - Narrow, - Panel, - Wide, + Sm, + Md, + Lg, } impl StoryViewport { fn frame_style(self) -> &'static str { match self { - Self::Narrow => "max-width: 390px;", - Self::Panel => "max-width: 720px;", - Self::Wide => "max-width: 1040px;", + Self::Sm => "width: 390px;", + Self::Md => "width: 720px;", + Self::Lg => "width: 1080px;", + } + } + + const fn slug(self) -> &'static str { + match self { + Self::Sm => "sm", + Self::Md => "md", + Self::Lg => "lg", + } + } + + const fn width_label(self) -> &'static str { + match self { + Self::Sm => "390px", + Self::Md => "720px", + Self::Lg => "1080px", + } + } + + const fn canvas_label(self) -> &'static str { + match self { + Self::Sm => "sm - 390px", + Self::Md => "md - 720px", + Self::Lg => "lg - 1080px", + } + } + + fn parse(value: &str) -> Option { + match value { + "sm" => Some(Self::Sm), + "md" => Some(Self::Md), + "lg" => Some(Self::Lg), + _ => None, } } } -fn selected_story_id_from_hash() -> String { +fn selected_story_route_from_hash() -> StoryRoute { location_hash() - .and_then(|hash| hash.strip_prefix("#/stories/").map(str::to_string)) - .filter(|id| story_by_id(id).is_some()) - .unwrap_or_else(|| DEFAULT_STORY_ID.to_string()) + .and_then(|hash| parse_story_hash(&hash)) + .unwrap_or_else(|| StoryRoute { + story_id: DEFAULT_STORY_ID.to_string(), + viewport: StoryViewport::Lg, + }) +} + +fn parse_story_hash(hash: &str) -> Option { + let route = hash.strip_prefix("#/stories/")?; + let (story_id, query) = route.split_once('?').unwrap_or((route, "")); + if !story_route_exists(story_id) { + return None; + } + let story_id = story_id.to_string(); + let viewport = query + .split('&') + .filter_map(|part| part.split_once('=')) + .find_map(|(key, value)| { + (key == "viewport") + .then(|| StoryViewport::parse(value)) + .flatten() + }) + .unwrap_or(StoryViewport::Lg); + Some(StoryRoute { story_id, viewport }) +} + +fn story_hash(story_id: &str, viewport: StoryViewport) -> String { + format!("#/stories/{story_id}?viewport={}", viewport.slug()) +} + +fn set_story_hash(story_id: &str, viewport: StoryViewport) { + if let Some(location) = web_sys::window().map(|window| window.location()) { + let _ = location.set_hash(&story_hash(story_id, viewport)); + } +} + +fn install_story_hash_listener( + mut selected_story_id: Signal, + mut viewport: Signal, +) -> Option> { + let window = web_sys::window()?; + let callback = Closure::::wrap(Box::new(move |_| { + let route = selected_story_route_from_hash(); + selected_story_id.set(route.story_id); + viewport.set(route.viewport); + })); + + window + .add_event_listener_with_callback("hashchange", callback.as_ref().unchecked_ref()) + .ok()?; + + Some(Rc::new(StoryHashListener { window, callback })) +} + +struct StoryHashListener { + window: web_sys::Window, + callback: Closure, +} + +impl Drop for StoryHashListener { + fn drop(&mut self) { + let _ = self.window.remove_event_listener_with_callback( + "hashchange", + self.callback.as_ref().unchecked_ref(), + ); + } +} + +fn story_png_viewport() -> StoryViewport { + web_sys::window() + .map(|window| window.location()) + .and_then(|location| location.search().ok()) + .and_then(|search| { + search + .trim_start_matches('?') + .split('&') + .filter_map(|part| part.split_once('=')) + .find_map(|(key, value)| { + (key == "viewport") + .then(|| StoryViewport::parse(value)) + .flatten() + }) + }) + .unwrap_or(StoryViewport::Lg) } fn is_story_png_mode() -> bool { diff --git a/lp-app/lpa-studio-web/src/stories/story_registry.rs b/lp-app/lpa-studio-web/src/stories/story_registry.rs index 6c0343d50..c56fc2e38 100644 --- a/lp-app/lpa-studio-web/src/stories/story_registry.rs +++ b/lp-app/lpa-studio-web/src/stories/story_registry.rs @@ -1,12 +1,24 @@ use dioxus::prelude::*; use crate::stories::story::StoryDescriptor; -use crate::stories::studio_ux_stories; -pub const DEFAULT_STORY_ID: &str = "studio/simulator-idle"; +pub const DEFAULT_STORY_ID: &str = "studio/layout/studio-shell/simulator-idle"; +mod generated { + include!(concat!(env!("OUT_DIR"), "/story_registry.generated.rs")); +} + +/// Return every generated story descriptor. +/// +/// The source of truth is the set of `#[story]` functions discovered by +/// `lpa-studio-web/build.rs`; this module intentionally contains no hand-written +/// story list. pub fn all_stories() -> Vec { - studio_ux_stories::STORIES.to_vec() + generated::all_generated_stories() +} + +pub fn generated_at_utc() -> &'static str { + generated::GENERATED_AT_UTC } pub fn story_by_id(id: &str) -> Option { @@ -14,13 +26,13 @@ pub fn story_by_id(id: &str) -> Option { } pub fn render_story(id: &str) -> Element { - studio_ux_stories::render_story(id).unwrap_or_else(|| { + generated::render_generated_story(id).unwrap_or_else(|| { rsx! { - section { class: "ux-panel", - div { class: "ux-panel-heading", - h2 { "Story not found" } + section { class: "tw:rounded-md tw:border tw:border-border tw:bg-card tw:p-[18px]", + div { class: "tw:mb-3 tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-3", + h2 { class: "tw:m-0 tw:text-base tw:font-bold tw:text-strong-foreground", "Story not found" } } - p { class: "ux-panel-copy", "No story is registered for `{id}`." } + p { class: "tw:m-0 tw:text-sm tw:leading-normal tw:text-muted-foreground", "No story is registered for `{id}`." } } } }) diff --git a/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs b/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs deleted file mode 100644 index a2e236e3f..000000000 --- a/lp-app/lpa-studio-web/src/stories/studio_ux_stories.rs +++ /dev/null @@ -1,898 +0,0 @@ -use dioxus::prelude::*; -use lpa_studio_ux::{ - DeviceOp, DeviceUx, LinkEndpointId, LinkProviderKind, ProgressState, ProjectInventorySummary, - ProjectOp, ProjectState, ProjectUx, StudioView, UiAction, UiActivity, UiActivityStep, - UiActivityStepState, UiBody, UiMetric, UiPaneView, UiProgress, UiStackSection, UiStackView, - UiStatus, UiStepState, UiTerminalLine, UxIssue, UxLogEntry, UxLogLevel, UxNodeId, -}; - -use crate::components::{ActionStrip, StudioShell, UxPane}; -use crate::stories::story::StoryDescriptor; - -pub const STORIES: &[StoryDescriptor] = &[ - StoryDescriptor::new( - "studio/actions/provider-actions", - "Studio UX", - "Connection actions", - "Generic action strip for connection choices exposed by Device UX.", - ), - StoryDescriptor::new( - "studio/panes/device", - "Studio UX", - "Device pane", - "Device pane rendered from a stack of connection, LightPlayer, and project steps.", - ), - StoryDescriptor::new( - "studio/panes/project", - "Studio UX", - "Project pane", - "Loaded project pane rendered directly from the Project UX view.", - ), - StoryDescriptor::new( - "studio/device-project-empty", - "Studio UX", - "Project launcher", - "Device pane offering running-project attach and demo loading.", - ), - StoryDescriptor::new( - "studio/device-project-selection", - "Studio UX", - "Project selection", - "Loaded project choices exposed in the Device open-project step.", - ), - StoryDescriptor::new( - "studio/simulator-idle", - "Studio UX", - "Simulator idle", - "Initial Studio UX state before launching the browser simulator.", - ), - StoryDescriptor::new( - "studio/browser-serial-canceled", - "Studio UX", - "Serial chooser canceled", - "Browser serial picker after the native dialog was canceled.", - ), - StoryDescriptor::new( - "studio/browser-serial-open-failed", - "Studio UX", - "Serial open failed", - "Recoverable serial-open failure with picker actions still available.", - ), - StoryDescriptor::new( - "studio/simulator-endpoint", - "Studio UX", - "Simulator endpoint", - "Endpoint choices returned by the selected lpa-link provider.", - ), - StoryDescriptor::new( - "studio/simulator-starting", - "Studio UX", - "Simulator starting", - "Progress state while the selected endpoint is opening.", - ), - StoryDescriptor::new( - "studio/simulator-ready", - "Studio UX", - "Simulator ready", - "Connected simulator after the UX layer auto-loads the demo project.", - ), - StoryDescriptor::new( - "studio/server-disconnected-link-ready", - "Studio UX", - "Server disconnected", - "Open device session with LightPlayer detached and reconnect action available.", - ), - StoryDescriptor::new( - "studio/provision-ready", - "Studio UX", - "Flash ready", - "Blank ESP32 device session offering firmware flashing.", - ), - StoryDescriptor::new( - "studio/browser-serial-blank-firmware", - "Studio UX", - "Blank firmware readiness", - "Browser serial readiness with boot logs and firmware flashing available.", - ), - StoryDescriptor::new( - "studio/provisioning", - "Studio UX", - "Flashing", - "Progress while Studio flashes packaged LightPlayer firmware.", - ), - StoryDescriptor::new( - "studio/provision-failed", - "Studio UX", - "Flash failed", - "Firmware flashing issue with retry and disconnect actions.", - ), - StoryDescriptor::new( - "studio/resetting-to-blank", - "Studio UX", - "Wiping", - "Progress while Studio erases an existing ESP32.", - ), - StoryDescriptor::new( - "studio/reset-complete", - "Studio UX", - "Reset complete", - "Blank ESP32 after erase with firmware flashing available again.", - ), - StoryDescriptor::new( - "studio/project-ready", - "Studio UX", - "Project ready", - "Demo project loaded and summarized through the UX view.", - ), - StoryDescriptor::new( - "studio/error", - "Studio UX", - "Action error", - "Failure state shown through the same shell and action surface.", - ), -]; - -pub fn render_story(id: &str) -> Option { - match id { - "studio/actions/provider-actions" => { - return Some(rsx! { - section { class: "ux-panel ux-panel-primary", - div { class: "ux-panel-heading", - p { "Actions" } - } - ActionStrip { - actions: start_actions(), - running: false, - on_action: move |_| {}, - } - } - }); - } - "studio/panes/device" => { - let view = idle_device_view(); - return Some(rsx! { - UxPane { - view, - primary: true, - running: false, - on_action: move |_| {}, - } - }); - } - "studio/panes/project" => { - let view = project_view(project_ready_state(), true); - return Some(rsx! { - UxPane { - view, - primary: false, - running: false, - on_action: move |_| {}, - } - }); - } - "studio/device-project-empty" => { - let view = device_project_empty_view(); - return Some(rsx! { - UxPane { - view, - primary: true, - running: false, - on_action: move |_| {}, - } - }); - } - "studio/device-project-selection" => { - let view = device_project_selection_view(); - return Some(rsx! { - UxPane { - view, - primary: true, - running: false, - on_action: move |_| {}, - } - }); - } - _ => {} - } - - let (mut view, running, story_logs) = match id { - "studio/simulator-idle" => (idle_view(), false, Vec::new()), - "studio/browser-serial-canceled" => (browser_serial_canceled_view(), false, Vec::new()), - "studio/browser-serial-open-failed" => { - (browser_serial_open_failed_view(), false, Vec::new()) - } - "studio/simulator-endpoint" => (endpoint_view(), false, Vec::new()), - "studio/simulator-starting" => (starting_view(), true, Vec::new()), - "studio/simulator-ready" => ( - simulator_ready_view(), - false, - vec![ - studio_log(UxLogLevel::Info, "Simulator is running"), - studio_log(UxLogLevel::Info, "Demo project loaded"), - ], - ), - "studio/server-disconnected-link-ready" => ( - lightplayer_disconnected_view(), - false, - vec![studio_log(UxLogLevel::Info, "LightPlayer disconnected")], - ), - "studio/provision-ready" => (provision_ready_view(), false, Vec::new()), - "studio/browser-serial-blank-firmware" => { - (browser_serial_blank_firmware_view(), false, Vec::new()) - } - "studio/provisioning" => (provisioning_view(), true, Vec::new()), - "studio/provision-failed" => ( - provision_failed_view(), - false, - vec![studio_log( - UxLogLevel::Error, - "browser serial firmware flashing failed", - )], - ), - "studio/resetting-to-blank" => (resetting_to_blank_view(), true, Vec::new()), - "studio/reset-complete" => ( - reset_complete_view(), - false, - vec![studio_log(UxLogLevel::Info, "ESP32-C6 wiped")], - ), - "studio/project-ready" => ( - project_ready_view(), - false, - vec![studio_log(UxLogLevel::Info, "Demo project loaded")], - ), - "studio/error" => ( - error_view(), - false, - vec![studio_log( - UxLogLevel::Error, - "browser worker boot timed out", - )], - ), - _ => return None, - }; - view.logs.extend(story_logs); - - Some(rsx! { - StudioShell { - view, - running, - on_action: move |_| {}, - } - }) -} - -fn studio_log(level: UxLogLevel, message: impl Into) -> UxLogEntry { - UxLogEntry::new(level, "studio", message) -} - -fn idle_view() -> StudioView { - StudioView::new(vec![idle_device_view()], Vec::new()) -} - -fn browser_serial_canceled_view() -> StudioView { - StudioView::new( - vec![idle_device_view()], - vec![studio_log(UxLogLevel::Info, "Port selection canceled")], - ) -} - -fn browser_serial_open_failed_view() -> StudioView { - picker_issue_view( - "Failed to open serial port.", - "Failed to execute 'open' on 'SerialPort': Failed to open serial port.", - ) -} - -fn endpoint_view() -> StudioView { - StudioView::new(vec![endpoint_device_view()], Vec::new()) -} - -fn starting_view() -> StudioView { - StudioView::new( - vec![starting_device_view()], - vec![UxLogEntry::new( - UxLogLevel::Info, - "lpa-link", - "browser worker session created", - )], - ) -} - -fn simulator_ready_view() -> StudioView { - StudioView::new( - vec![ - project_view(project_ready_state(), true), - simulator_ready_device_view(), - ], - vec![ - UxLogEntry::new(UxLogLevel::Info, "fw-browser", "ready"), - UxLogEntry::new( - UxLogLevel::Info, - "lpa-link", - "browser worker session owns Worker lifecycle in lpa-link", - ), - UxLogEntry::new(UxLogLevel::Info, "fw-browser", "project loaded"), - ], - ) -} - -fn project_ready_view() -> StudioView { - StudioView::new( - vec![ - project_view(project_ready_state(), true), - simulator_ready_device_view(), - ], - vec![ - UxLogEntry::new(UxLogLevel::Info, "fw-browser", "project loaded"), - UxLogEntry::new( - UxLogLevel::Debug, - "lp-server", - "heartbeat frame=42 uptime_ms=700", - ), - ], - ) -} - -fn lightplayer_disconnected_view() -> StudioView { - StudioView::new( - vec![device_view( - UiStatus::good("Simulator connected"), - vec![ - select_connection_complete("Simulator"), - connect_device_complete_with_actions( - browser_worker_metrics(), - vec![disconnect_device_action()], - ), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Active, - UiBody::text("Attach Studio to LightPlayer on the connected simulator."), - vec![connect_lightplayer_action()], - ), - ], - vec!["[lpa-studio-ux] LightPlayer protocol detached; device session remains open"], - )], - vec![UxLogEntry::new( - UxLogLevel::Info, - "lpa-studio-ux", - "LightPlayer protocol detached; device session remains open", - )], - ) -} - -fn provision_ready_view() -> StudioView { - StudioView::new( - vec![blank_device_view( - UiStatus::warning("Ready to flash"), - UiBody::text("No LightPlayer firmware is running on this ESP32."), - false, - )], - vec![UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", - "server protocol is unavailable; firmware flashing is available", - )], - ) -} - -fn browser_serial_blank_firmware_view() -> StudioView { - StudioView::new( - vec![blank_device_view( - UiStatus::warning("Ready to flash"), - UiBody::Activity(blank_firmware_activity()), - false, - )], - vec![ - UxLogEntry::new(UxLogLevel::Info, "fw-esp32", "ESP-ROM:esp32c6-20220919"), - UxLogEntry::new(UxLogLevel::Info, "fw-esp32", "invalid header: 0xffffffff"), - UxLogEntry::new( - UxLogLevel::Warn, - "lpa-studio-ux", - "no LightPlayer firmware detected; firmware flashing is available", - ), - ], - ) -} - -fn provisioning_view() -> StudioView { - StudioView::new( - vec![device_view( - UiStatus::working("Flashing"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Flashing firmware", - UiStepState::Active, - UiBody::Activity(provisioning_activity()), - Vec::new(), - ), - ], - vec![ - "[lpa-link] Connected to ESP32 bootloader", - "[lpa-link] Writing app image at 0x10000", - "[lpa-link] Progress 42%", - ], - )], - vec![UxLogEntry::new( - UxLogLevel::Info, - "lpa-link", - "Connected to ESP32 bootloader", - )], - ) -} - -fn provision_failed_view() -> StudioView { - StudioView::new( - vec![device_view( - UiStatus::error("Needs attention"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), - stack_section( - "connect-lightplayer", - "Flashing firmware", - UiStepState::NeedsAttention, - UiBody::Issue( - UxIssue::new("firmware flashing failed").with_detail( - "Check the cable, boot mode, and browser serial permission.", - ), - ), - Vec::new(), - ), - ], - vec![ - "[lpa-link] Connected to ESP32 bootloader", - "[lpa-link] failed to write firmware image", - ], - )], - vec![UxLogEntry::new( - UxLogLevel::Error, - "lpa-link", - "failed to write firmware image", - )], - ) -} - -fn resetting_to_blank_view() -> StudioView { - StudioView::new( - vec![device_view( - UiStatus::working("Resetting"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Wiping device", - UiStepState::Active, - UiBody::Activity(reset_activity()), - Vec::new(), - ), - ], - vec![ - "[lpa-link] Connected to ESP32 bootloader", - "[lpa-link] Erasing device flash", - ], - )], - vec![UxLogEntry::new( - UxLogLevel::Info, - "lpa-link", - "Erasing device flash", - )], - ) -} - -fn reset_complete_view() -> StudioView { - StudioView::new( - vec![blank_device_view( - UiStatus::warning("Blank ESP32"), - UiBody::text("The device has been erased and can be flashed again."), - true, - )], - vec![UxLogEntry::new( - UxLogLevel::Info, - "lpa-link", - "Chip erase completed successfully", - )], - ) -} - -fn error_view() -> StudioView { - picker_issue_view( - "browser worker boot timed out", - "browser worker boot timed out", - ) -} - -fn picker_issue_view(message: &'static str, log_message: &'static str) -> StudioView { - StudioView::new( - vec![device_view( - UiStatus::error("Needs attention"), - vec![stack_section( - "select-connection", - "Select connection", - UiStepState::NeedsAttention, - UiBody::Issue(UxIssue::new(message)), - start_actions(), - )], - Vec::new(), - )], - vec![studio_log(UxLogLevel::Error, log_message)], - ) -} - -fn idle_device_view() -> UiPaneView { - device_view( - UiStatus::neutral("Choose connection"), - vec![stack_section( - "select-connection", - "Select connection", - UiStepState::Active, - UiBody::text("Choose how Studio should connect."), - start_actions(), - )], - Vec::new(), - ) -} - -fn endpoint_device_view() -> UiPaneView { - device_view( - UiStatus::working("Connecting"), - vec![ - select_connection_complete("Simulator"), - stack_section( - "connect-device", - "Connect device", - UiStepState::Active, - UiBody::text("Choose the device endpoint to open."), - vec![ - device_action(DeviceOp::ConnectEndpoint { - provider_id: LinkProviderKind::BrowserWorker, - endpoint_id: LinkEndpointId::new("browser-worker-worker-1"), - }) - .with_label("Open browser simulator") - .with_summary("Open the browser-local firmware runtime."), - ], - ), - ], - vec!["[lpa-link] Browser worker provider selected"], - ) -} - -fn starting_device_view() -> UiPaneView { - device_view( - UiStatus::working("Connecting"), - vec![ - select_connection_complete("Simulator"), - connect_device_complete(browser_worker_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Active, - UiBody::Progress(ProgressState::new("Opening server protocol")), - Vec::new(), - ), - ], - vec![ - "[lpa-link] browser worker session created", - "[fw-browser] booting firmware runtime", - ], - ) -} - -fn simulator_ready_device_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("Simulator"), - connect_device_complete(browser_worker_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiBody::Metrics(vec![UiMetric::new( - "Protocol", - "fw-browser-post-message-v1", - )]), - vec![disconnect_lightplayer_action()], - ), - stack_section( - "open-project", - "Open project", - UiStepState::Complete, - UiBody::text("Project controls are available in the Project pane."), - Vec::new(), - ), - ], - vec![ - "[fw-browser] ready", - "[lp-server] loaded project studio-demo", - "[fw-browser] heartbeat frame=42", - ], - ) -} - -fn device_project_empty_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiBody::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), - vec![disconnect_lightplayer_action()], - ), - stack_section( - "open-project", - "Open project", - UiStepState::Active, - UiBody::text("Connect to a running project or load the demo project."), - vec![ - project_action(ProjectOp::ConnectRunningProject), - project_action(ProjectOp::LoadDemoProject), - ], - ), - ], - vec![ - "[fw-esp32] LightPlayer protocol ready", - "[lp-server] loaded projects: 0", - ], - ) -} - -fn device_project_selection_view() -> UiPaneView { - device_view( - UiStatus::good("LightPlayer ready"), - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete(esp32_metrics()), - stack_section( - "connect-lightplayer", - "Connect LightPlayer", - UiStepState::Complete, - UiBody::Metrics(vec![UiMetric::new("Protocol", "lp-serial-json-lines-v1")]), - vec![disconnect_lightplayer_action()], - ), - stack_section( - "open-project", - "Open project", - UiStepState::Active, - UiBody::text("2 projects are running. Choose one to open."), - vec![ - project_action(ProjectOp::ConnectLoadedProject { handle_id: 1 }) - .with_label("Connect /projects/ambient") - .with_summary("Attach to running project handle 1."), - project_action(ProjectOp::ConnectLoadedProject { handle_id: 2 }) - .with_label("Connect /projects/palette-test") - .with_summary("Attach to running project handle 2."), - ], - ), - ], - vec![ - "[fw-esp32] LightPlayer protocol ready", - "[lp-server] loaded projects: 2", - ], - ) -} - -fn blank_device_view(status: UiStatus, body: UiBody, after_reset: bool) -> UiPaneView { - let detail = if after_reset { - vec![ - "[lpa-link] Chip erase completed successfully", - "[fw-esp32] invalid header: 0xffffffff", - ] - } else { - vec![ - "[esp32-reset] Hard resetting via RTS pin...", - "[fw-esp32] ESP-ROM:esp32c6-20220919", - "[fw-esp32] invalid header: 0xffffffff", - ] - }; - device_view( - status, - vec![ - select_connection_complete("ESP32 over USB"), - connect_device_complete_with_actions(esp32_metrics(), device_management_actions()), - stack_section( - "connect-lightplayer", - "LightPlayer unavailable", - UiStepState::Active, - body, - Vec::new(), - ), - ], - detail, - ) -} - -fn blank_firmware_activity() -> UiActivity { - UiActivity::new("Connecting ESP32 server") - .with_detail("ESP32 boot output looks like blank or erased flash.") - .with_steps(vec![ - UiActivityStep::new("serial-access", "Serial access") - .with_state(UiActivityStepState::Complete) - .with_detail("Browser serial port is open."), - UiActivityStep::new("reset-device", "Reset device") - .with_state(UiActivityStepState::Complete) - .with_detail("Device reset was requested before protocol attach."), - UiActivityStep::new("boot-output", "Boot output") - .with_state(UiActivityStepState::Complete), - UiActivityStep::new("server-protocol", "LightPlayer protocol") - .with_state(UiActivityStepState::Failed), - ]) -} - -fn provisioning_activity() -> UiActivity { - UiActivity::new("Flashing firmware") - .with_detail("Writing packaged LightPlayer ESP32-C6 firmware.") - .with_progress(UiProgress::determinate("Writing flash", 42)) - .with_steps(vec![ - UiActivityStep::new("bootloader", "Bootloader") - .with_state(UiActivityStepState::Complete), - UiActivityStep::new("erase", "Erase").with_state(UiActivityStepState::Complete), - UiActivityStep::new("write", "Write firmware").with_state(UiActivityStepState::Active), - UiActivityStep::new("reboot", "Reboot").with_state(UiActivityStepState::Pending), - ]) -} - -fn reset_activity() -> UiActivity { - UiActivity::new("Wiping device") - .with_detail("Erasing ESP32 flash through the bootloader.") - .with_progress(UiProgress::determinate("Erasing flash", 58)) - .with_steps(vec![ - UiActivityStep::new("bootloader", "Bootloader") - .with_state(UiActivityStepState::Complete), - UiActivityStep::new("erase", "Erase flash").with_state(UiActivityStepState::Active), - UiActivityStep::new("blank", "Blank device").with_state(UiActivityStepState::Pending), - ]) -} - -fn device_view( - status: UiStatus, - sections: Vec, - terminal: Vec<&'static str>, -) -> UiPaneView { - UiPaneView::new( - DeviceUx::NODE_ID, - "Device", - status, - UiBody::Stack(Box::new( - UiStackView::new(sections).with_terminal( - terminal - .into_iter() - .map(UiTerminalLine::new) - .collect::>(), - ), - )), - Vec::new(), - ) -} - -fn stack_section( - id: &'static str, - title: &'static str, - state: UiStepState, - body: UiBody, - actions: Vec, -) -> UiStackSection { - UiStackSection::new(id, title, state) - .with_body(body) - .with_actions(actions) -} - -fn select_connection_complete(label: &'static str) -> UiStackSection { - stack_section( - "select-connection", - "Select connection", - UiStepState::Complete, - UiBody::text(label), - Vec::new(), - ) -} - -fn connect_device_complete(metrics: Vec) -> UiStackSection { - connect_device_complete_with_actions(metrics, Vec::new()) -} - -fn connect_device_complete_with_actions( - metrics: Vec, - actions: Vec, -) -> UiStackSection { - stack_section( - "connect-device", - "Connect device", - UiStepState::Complete, - UiBody::Metrics(metrics), - actions, - ) -} - -fn browser_worker_metrics() -> Vec { - vec![ - UiMetric::new("Provider", "Browser worker"), - UiMetric::new("Endpoint", "browser-worker-worker-1"), - UiMetric::new("Session", "browser-worker-worker-1:1"), - ] -} - -fn esp32_metrics() -> Vec { - vec![ - UiMetric::new("Provider", "Browser serial ESP32"), - UiMetric::new("Endpoint", "browser-serial-esp32-port-1"), - UiMetric::new("Session", "browser-serial-esp32-port-1:1"), - ] -} - -fn project_view(state: ProjectState, server_connected: bool) -> UiPaneView { - let mut project = ProjectUx::new(); - let no_running_project = matches!(state, ProjectState::NotLoaded) && server_connected; - project.set_state(state); - if no_running_project { - project.mark_no_running_project(); - } - project.view(server_connected) -} - -fn project_ready_state() -> ProjectState { - ProjectState::Ready { - project_id: "studio-demo".to_string(), - handle_id: 1, - inventory: ProjectInventorySummary { - node_count: 4, - definition_count: 3, - asset_count: 1, - }, - } -} - -fn start_actions() -> Vec { - vec![ - device_action(DeviceOp::OpenProvider { - provider_id: LinkProviderKind::BrowserWorker, - }) - .with_label("Start simulator") - .with_summary("Run LightPlayer locally in a browser worker.") - .with_short_label("Simulator") - .with_icon("play"), - device_action(DeviceOp::OpenProvider { - provider_id: LinkProviderKind::BrowserSerialEsp32, - }) - .with_label("Connect ESP32") - .with_summary("Connect to ESP32 hardware through browser Web Serial.") - .with_short_label("ESP32") - .with_icon("usb"), - ] -} - -fn disconnect_device_action() -> UiAction { - device_action(DeviceOp::DisconnectDevice) -} - -fn disconnect_lightplayer_action() -> UiAction { - device_action(DeviceOp::DisconnectLightPlayer) -} - -fn connect_lightplayer_action() -> UiAction { - device_action(DeviceOp::ConnectLightPlayer) -} - -fn device_management_actions() -> Vec { - vec![ - device_action(DeviceOp::ProvisionFirmware), - device_action(DeviceOp::ResetToBlank), - disconnect_device_action(), - ] -} - -fn device_action(op: DeviceOp) -> UiAction { - UiAction::from_op(UxNodeId::new(DeviceUx::NODE_ID), op) -} - -fn project_action(op: ProjectOp) -> UiAction { - UiAction::from_op(UxNodeId::new(ProjectUx::NODE_ID), op) -} diff --git a/lp-app/lpa-studio-web/src/studio_url.rs b/lp-app/lpa-studio-web/src/studio_url.rs new file mode 100644 index 000000000..f09327e82 --- /dev/null +++ b/lp-app/lpa-studio-web/src/studio_url.rs @@ -0,0 +1,200 @@ +//! URL launch intent for the Studio web shell. +//! +//! The URL records browser launch/session intent such as +//! `?connect=simulator`. It is deliberately owned by the web crate so the +//! headless Studio controller stays independent of browser routing. + +use lpa_studio_core::{DeviceController, DeviceOp, LinkProviderKind, UiAction}; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::JsValue; + +const CONNECTION_PARAM: &str = "connect"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ConnectionIntent { + Simulator, + Usb, +} + +impl ConnectionIntent { + fn from_provider_kind(kind: LinkProviderKind) -> Option { + match kind { + LinkProviderKind::BrowserWorker => Some(Self::Simulator), + LinkProviderKind::BrowserSerialEsp32 => Some(Self::Usb), + LinkProviderKind::Fake + | LinkProviderKind::HostProcess + | LinkProviderKind::HostSerialEsp32 => None, + } + } + + fn from_query_value(value: &str) -> Option { + match value { + "simulator" => Some(Self::Simulator), + "usb" => Some(Self::Usb), + _ => None, + } + } + + #[cfg(any(target_arch = "wasm32", test))] + fn query_value(self) -> &'static str { + match self { + Self::Simulator => "simulator", + Self::Usb => "usb", + } + } + + fn provider_kind(self) -> LinkProviderKind { + match self { + Self::Simulator => LinkProviderKind::BrowserWorker, + Self::Usb => LinkProviderKind::BrowserSerialEsp32, + } + } + + fn should_auto_open(self) -> bool { + matches!(self, Self::Simulator) + } + + pub(crate) fn startup_action(self) -> Option { + self.should_auto_open().then(|| { + UiAction::from_op( + DeviceController::NODE_ID, + DeviceOp::OpenProvider { + provider_id: self.provider_kind(), + }, + ) + }) + } +} + +pub(crate) fn read_connection_intent() -> Option { + current_search() + .as_deref() + .and_then(connection_intent_from_search) +} + +pub(crate) fn update_for_action(action: &UiAction) { + let Some(op) = action.op_as::() else { + return; + }; + + match op { + DeviceOp::OpenProvider { provider_id } => { + if let Some(intent) = ConnectionIntent::from_provider_kind(*provider_id) { + write_connection_intent(Some(intent)); + } + } + DeviceOp::DisconnectDevice => write_connection_intent(None), + DeviceOp::ConnectEndpoint { .. } + | DeviceOp::ConnectLightPlayer + | DeviceOp::DisconnectLightPlayer + | DeviceOp::ResetDevice + | DeviceOp::ProvisionFirmware + | DeviceOp::ResetToBlank + | DeviceOp::RefreshConnections => {} + } +} + +#[cfg(target_arch = "wasm32")] +fn current_search() -> Option { + web_sys::window() + .map(|window| window.location()) + .and_then(|location| location.search().ok()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn current_search() -> Option { + None +} + +#[cfg(target_arch = "wasm32")] +fn write_connection_intent(intent: Option) { + let Some(window) = web_sys::window() else { + return; + }; + let location = window.location(); + let pathname = location.pathname().unwrap_or_default(); + let search = location.search().unwrap_or_default(); + let hash = location.hash().unwrap_or_default(); + let next_url = format!( + "{pathname}{}{hash}", + search_with_connection_intent(&search, intent) + ); + + if let Ok(history) = window.history() { + let _ = history.replace_state_with_url(&JsValue::NULL, "", Some(&next_url)); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn write_connection_intent(_intent: Option) {} + +fn connection_intent_from_search(search: &str) -> Option { + search + .trim_start_matches('?') + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find_map(|(key, value)| { + (key == CONNECTION_PARAM) + .then(|| ConnectionIntent::from_query_value(value)) + .flatten() + }) +} + +#[cfg(any(target_arch = "wasm32", test))] +fn search_with_connection_intent(search: &str, intent: Option) -> String { + let mut params = search + .trim_start_matches('?') + .split('&') + .filter(|pair| !pair.is_empty()) + .filter(|pair| pair.split_once('=').map_or(*pair, |(key, _)| key) != CONNECTION_PARAM) + .map(ToOwned::to_owned) + .collect::>(); + + if let Some(intent) = intent { + params.push(format!("{CONNECTION_PARAM}={}", intent.query_value())); + } + + if params.is_empty() { + String::new() + } else { + format!("?{}", params.join("&")) + } +} + +#[cfg(test)] +mod tests { + use super::{ConnectionIntent, connection_intent_from_search, search_with_connection_intent}; + + #[test] + fn parses_connection_intent_from_search() { + assert_eq!( + connection_intent_from_search("?connect=simulator"), + Some(ConnectionIntent::Simulator) + ); + assert_eq!( + connection_intent_from_search("?foo=1&connect=usb"), + Some(ConnectionIntent::Usb) + ); + assert_eq!(connection_intent_from_search("?connect=serial"), None); + } + + #[test] + fn writes_connection_intent_without_dropping_other_params() { + assert_eq!( + search_with_connection_intent("?foo=1", Some(ConnectionIntent::Simulator)), + "?foo=1&connect=simulator" + ); + assert_eq!( + search_with_connection_intent("?connect=usb&foo=1", Some(ConnectionIntent::Simulator)), + "?foo=1&connect=simulator" + ); + assert_eq!(search_with_connection_intent("?connect=usb", None), ""); + } + + #[test] + fn only_simulator_auto_opens_from_url() { + assert!(ConnectionIntent::Simulator.startup_action().is_some()); + assert!(ConnectionIntent::Usb.startup_action().is_none()); + } +} diff --git a/lp-app/lpa-studio-web/src/style.css b/lp-app/lpa-studio-web/src/style.css index 23f94d5db..e6c3b80cb 100644 --- a/lp-app/lpa-studio-web/src/style.css +++ b/lp-app/lpa-studio-web/src/style.css @@ -1,748 +1,2160 @@ :root { - color-scheme: dark; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + color-scheme: dark; + --studio-font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #101317; - color: #f2f0e8; + --studio-font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + + --studio-color-bg: #101317; + --studio-color-bg-wash: rgba(255, 255, 255, 0.04); + --studio-color-surface: #171b20; + --studio-color-surface-subtle: #14181d; + --studio-color-surface-muted: #11161b; + --studio-color-surface-raised: #20272e; + --studio-color-surface-raised-strong: #26313a; + --studio-color-panel-primary: #18201d; + --studio-color-terminal: #0c1114; + --studio-color-track: #10151a; + + --studio-color-border: #2a3138; + --studio-color-border-muted: #252d34; + --studio-color-border-subtle: #293039; + --studio-color-border-strong: #3e4852; + --studio-color-panel-primary-border: #34463e; + + --studio-color-text: #f2f0e8; + --studio-color-text-strong: #fffaf0; + --studio-color-text-soft: #f9f6eb; + --studio-color-text-muted: #c7cbd0; + --studio-color-text-subtle: #99a2ad; + --studio-color-text-dim: #9ba4ad; + --studio-color-heading: #94b8aa; + --studio-color-accent: #7be0b2; + --studio-color-accent-border: #65bd96; + --studio-color-accent-hover: #6fc39f; + --studio-color-accent-text-on-fill: #08130d; + + --studio-status-neutral-bg: #12171c; + --studio-status-neutral-border: #3a444c; + --studio-status-neutral-text: #d9e2df; + --studio-status-working-bg: #24230f; + --studio-status-working-border: #706730; + --studio-status-working-text: #f2e6a2; + --studio-status-good-bg: #13241d; + --studio-status-good-border: #365545; + --studio-status-good-text: #7be0b2; + --studio-status-warning-bg: #292614; + --studio-status-warning-border: #796c33; + --studio-status-warning-text: #f0dc7a; + --studio-status-error-bg: #301b1d; + --studio-status-error-border: #874b4b; + --studio-status-error-text: #ffc7c7; + + --studio-step-marker-bg: #151a1f; + --studio-step-active-bg: #2a2916; + --studio-step-active-border: #717d44; + + --studio-space-1: 3px; + --studio-space-2: 6px; + --studio-space-3: 8px; + --studio-space-4: 10px; + --studio-space-5: 12px; + --studio-space-6: 14px; + --studio-space-7: 18px; + --studio-space-8: 22px; + --studio-space-9: 28px; + + --studio-radius-xs: 5px; + --studio-radius-sm: 6px; + --studio-radius-md: 8px; + --studio-radius-pill: 999px; + --studio-shadow-inset: inset 0 0 0 1px rgba(255, 255, 255, 0.02); + + font-family: var(--studio-font-sans); + background: var(--studio-color-bg); + color: var(--studio-color-text); } * { - box-sizing: border-box; + box-sizing: border-box; } body { - margin: 0; - min-width: 320px; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent 240px), - #101317; + margin: 0; + min-width: 320px; + background: linear-gradient(180deg, var(--studio-color-bg-wash), transparent 240px), + var(--studio-color-bg); } button, input, textarea, select { - font: inherit; + font: inherit; } -.ux-shell { - width: min(1180px, 100%); - min-height: 100vh; - margin: 0 auto; - padding: 28px 28px 64px; +/* Transitional CSS for story-only editor and exploration surfaces. + Production Studio components should prefer semantic Tailwind classes. */ + +.ux-editor-inspector { + display: grid; + gap: var(--studio-space-6); } -.ux-header { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 20px; - margin-bottom: 18px; +.ux-editor-shell { + display: grid; + grid-template-columns: + minmax(190px, 250px) + minmax(340px, 1fr) + minmax(280px, 340px); + gap: var(--studio-space-6); + align-items: start; + width: 100%; } -.ux-eyebrow, -.ux-panel-heading p, -.ux-log-heading p { - margin: 0; - color: #94b8aa; - font-size: 0.76rem; - font-weight: 700; - letter-spacing: 0; - text-transform: uppercase; +.ux-editor-workspace, +.ux-editor-side, +.ux-editor-compact-side, +.ux-editor-mobile { + min-width: 0; } -.ux-layout { - display: grid; - gap: 14px; +.ux-editor-side { + display: grid; + gap: var(--studio-space-6); } -.ux-layout-device-only { - grid-template-columns: minmax(0, 1fr); +.ux-editor-compact-side, +.ux-editor-mobile { + display: none; } -.ux-layout-main-device { - grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); +.ux-node-tree { + display: grid; + gap: var(--studio-space-2); + margin: 0; + padding: 0; + list-style: none; } -.ux-layout-triple { - grid-template-columns: minmax(0, 1.2fr) minmax(260px, 0.8fr) minmax(260px, 0.9fr); +.ux-node-tree-item { + min-width: 0; + padding: var(--studio-space-2) var(--studio-space-3); + border: 1px solid transparent; + border-radius: var(--studio-radius-sm); + color: var(--studio-color-text-muted); + overflow-wrap: anywhere; } -.ux-main-column, -.ux-device-column { - display: grid; - align-content: start; - gap: 14px; - min-width: 0; +.ux-node-tree-item-active { + border-color: var(--studio-color-accent-border); + color: var(--studio-color-text-strong); + background: var(--studio-status-good-bg); } -.ux-panel, -.ux-log-panel { - border: 1px solid #2a3138; - border-radius: 8px; - background: #171b20; +.ux-node-tree-depth-1 { + margin-left: var(--studio-space-5); } -.ux-panel { - padding: 18px; +.ux-node-tree-depth-2 { + margin-left: var(--studio-space-9); } -.ux-panel-primary { - background: #18201d; - border-color: #34463e; +.ux-node-workspace { + display: grid; + gap: var(--studio-space-7); + align-items: start; } -.ux-panel-heading { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; +.ux-node-preview { + min-height: 180px; + overflow: hidden; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + background: linear-gradient(140deg, rgba(123, 224, 178, 0.18), transparent 44%), + linear-gradient(40deg, rgba(240, 220, 122, 0.14), transparent 48%), + var(--studio-color-terminal); } -.ux-status { - display: inline-flex; - align-items: center; - flex-shrink: 1; - min-width: 0; - min-height: 24px; - max-width: 100%; - padding: 0 8px; - border: 1px solid #3a444c; - border-radius: 999px; - color: #d9e2df; - background: #12171c; - font-size: 0.76rem; - font-weight: 700; - line-height: 1; - overflow-wrap: anywhere; +.ux-node-preview-bars { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--studio-space-2); + align-items: end; + height: 100%; + padding: var(--studio-space-4); } -.ux-status-working { - border-color: #706730; - color: #f2e6a2; - background: #24230f; +.ux-node-preview-bars span { + display: block; + min-height: 36px; + border-radius: var(--studio-radius-xs); + background: var(--studio-color-accent); } -.ux-status-good { - border-color: #365545; - color: #7be0b2; - background: #13241d; +.ux-node-preview-bars span:nth-child(2) { + min-height: 104px; + background: var(--studio-status-working-text); } -.ux-status-warning { - border-color: #796c33; - color: #f0dc7a; - background: #292614; +.ux-node-preview-bars span:nth-child(3) { + min-height: 68px; } -.ux-status-error { - border-color: #874b4b; - color: #ffc7c7; - background: #301b1d; +.ux-node-preview-bars span:nth-child(4) { + min-height: 142px; + background: var(--studio-status-warning-text); } -.ux-panel-copy { - margin: 0; - color: #c7cbd0; - line-height: 1.5; +.ux-node-preview-bars span:nth-child(5) { + min-height: 88px; } -.ux-panel-detail { - margin-top: 6px; - color: #99a2ad; +.ux-node-fields { + min-width: 0; } -.ux-panel-issue { - color: #ffc7c7; +.ux-editor-terminal { + max-height: 150px; } -.ux-stack { - display: grid; - gap: 14px; +/* Node UI spike stories */ + +.ux-node-ui-story, +.ux-node-ui-gallery, +.ux-node-ui-project-nodes, +.ux-node-ui-node-stack, +.ux-node-ui-window, +.ux-node-ui-produced, +.ux-node-ui-values, +.ux-node-ui-tabs { + display: grid; + min-width: 0; } -.ux-stack-sections { - display: grid; - gap: 0; - margin: 0; - padding: 0; - list-style: none; +.ux-node-ui-story { + gap: var(--studio-space-6); } -.ux-stack-section { - display: grid; - grid-template-columns: 28px minmax(0, 1fr); - gap: 11px; - padding: 8px 0; - background: transparent; +.ux-node-ui-story-heading { + display: grid; + gap: var(--studio-space-2); + min-width: 0; } -.ux-stack-section + .ux-stack-section { - margin-top: 3px; - padding-top: 12px; - border-top: 1px solid #252d34; +.ux-node-ui-story-heading h2, +.ux-node-ui-story-heading p { + margin: 0; } -.ux-stack-section-content { - display: grid; - gap: 7px; - min-width: 0; +.ux-node-ui-story-heading h2 { + color: var(--studio-color-text-strong); + font-size: 1rem; } -.ux-stack-section-content h3 { - margin: 0; - color: #fffaf0; - font-size: 0.98rem; - line-height: 1.25; - overflow-wrap: anywhere; +.ux-node-ui-story-heading p { + max-width: 72ch; + color: var(--studio-color-text-muted); + line-height: 1.4; } -.ux-stack-section-marker { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - margin-top: 1px; - border: 1px solid #3e4852; - border-radius: 50%; - color: #99a2ad; - background: #151a1f; - font-size: 0.76rem; - font-weight: 700; - line-height: 1; +.ux-node-ui-gallery { + gap: var(--studio-space-7); } -.ux-stack-section-body { - min-width: 0; +.ux-node-ui-status-gallery { + display: grid; + gap: var(--studio-space-5); + min-width: 0; } -.ux-stack-section-active .ux-stack-section-marker { - border-color: #717d44; - color: #f2e6a2; - background: #2a2916; +.ux-node-ui-status-gallery .ux-node-ui-window { + min-height: 210px; } -.ux-stack-section-complete .ux-stack-section-marker { - border-color: #365545; - color: #7be0b2; - background: #13241d; +.ux-node-ui-node-stack { + gap: var(--studio-space-3); } -.ux-stack-section-attention .ux-stack-section-marker { - border-color: #874b4b; - color: #ffc7c7; - background: #301b1d; +.ux-node-ui-window { + gap: 0; + overflow: visible; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-md); + background: var(--studio-color-surface-subtle); } -.ux-stack-section .ux-actions { - margin-top: 8px; +.ux-node-ui-window-instrument { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent 140px), + var(--studio-color-surface-subtle); } -.ux-activity { - display: grid; - gap: 10px; +.ux-node-ui-window-compact { + gap: 0; + background: var(--studio-color-surface); } -.ux-activity-title { - color: #fffaf0; - font-weight: 700; +.ux-node-ui-window-status-running .ux-node-ui-header { + background: linear-gradient(90deg, rgba(123, 224, 178, 0.11), transparent 62%), + var(--studio-color-surface-subtle); } -.ux-activity-steps { - display: grid; - gap: 6px; - margin: 0; - padding: 0; - list-style: none; +.ux-node-ui-window-status-idle .ux-node-ui-header { + background: linear-gradient(90deg, rgba(153, 162, 173, 0.1), transparent 62%), + var(--studio-color-surface-subtle); } -.ux-activity-step { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - gap: 8px; - align-items: flex-start; - padding: 8px; - border: 1px solid #293039; - border-radius: 6px; - background: #11161b; +.ux-node-ui-window-status-error .ux-node-ui-header { + background: linear-gradient(90deg, rgba(255, 199, 199, 0.14), transparent 66%), + var(--studio-color-surface-subtle); } -.ux-activity-step-marker { - color: #99a2ad; - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 0.78rem; +.ux-node-ui-header { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + gap: 0; + align-items: stretch; + min-width: 0; + min-height: 46px; + padding: 0; + border-bottom: 1px solid var(--studio-color-border-muted); + border-radius: calc(var(--studio-radius-md) - 1px) calc(var(--studio-radius-md) - 1px) 0 0; } -.ux-activity-step-copy { - display: grid; - gap: 2px; - min-width: 0; +.ux-node-ui-window-compact .ux-node-ui-header { + min-height: 42px; } -.ux-activity-step-copy span { - color: #dce4df; - font-size: 0.9rem; - line-height: 1.25; - overflow-wrap: anywhere; +.ux-node-ui-window-collapsed .ux-node-ui-header { + border-bottom: 0; + border-radius: calc(var(--studio-radius-md) - 1px); } -.ux-activity-step-copy small { - color: #99a2ad; - font-size: 0.78rem; - line-height: 1.3; - overflow-wrap: anywhere; +.ux-node-ui-window-collapsed .ux-node-ui-collapse-button { + border-radius: calc(var(--studio-radius-md) - 1px) 0 0 calc(var(--studio-radius-md) - 1px); } -.ux-activity-step-active { - border-color: #717d44; +.ux-node-ui-collapse-button { + display: inline-grid; + place-items: center; + align-self: stretch; + width: 34px; + min-height: 100%; + padding: 0; + border: 0; + border-right: 1px solid var(--studio-color-border-muted); + border-radius: calc(var(--studio-radius-md) - 1px) 0 0 0; + color: var(--studio-color-text-subtle); + background: rgba(255, 255, 255, 0.018); + cursor: pointer; } -.ux-activity-step-active .ux-activity-step-marker { - color: #f2e6a2; +.ux-node-ui-collapse-button svg { + display: block; } -.ux-activity-step-complete { - border-color: #365545; +.ux-node-ui-title { + display: flex; + align-content: center; + align-items: center; + min-width: 0; + padding: 0 var(--studio-space-5); } -.ux-activity-step-complete .ux-activity-step-marker { - color: #7be0b2; +.ux-node-ui-title h3 { + margin: 0; + display: flex; + align-items: baseline; + min-width: 0; + max-width: 100%; + color: var(--studio-color-text-strong); + font-size: 1.04rem; + line-height: 1.1; } -.ux-activity-step-failed { - border-color: #874b4b; +.ux-node-ui-title h3 span { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.ux-activity-step-failed .ux-activity-step-marker { - color: #ffc7c7; +.ux-node-ui-status-summary { + flex: 1 1 auto; + min-width: 0; + margin-left: var(--studio-space-2); + color: var(--studio-status-error-text); + font-size: 0.74rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.ux-progress { - display: grid; - gap: 6px; +.ux-node-ui-status-control { + position: relative; + align-self: center; + margin: 0 var(--studio-space-2); + z-index: 2; } -.ux-progress-meta { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - color: #c7cbd0; - font-size: 0.9rem; +.ux-node-ui-status-button { + display: inline-grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-pill); + background: var(--studio-color-surface-raised); + color: var(--studio-color-text-strong); + cursor: pointer; + line-height: 1; } -.ux-progress-meta span { - min-width: 0; - overflow-wrap: anywhere; +.ux-node-ui-status-button span { + display: inline-flex; + align-items: center; + justify-content: center; } -.ux-progress-meta strong { - color: #f2e6a2; - font-size: 0.86rem; +.ux-node-ui-status-button svg { + display: block; + width: 14px; + height: 14px; + stroke-width: 2.5; } -.ux-progress-track { - position: relative; - height: 9px; - overflow: hidden; - border: 1px solid #303942; - border-radius: 999px; - background: #10151a; +.ux-node-ui-status-button-running { + border-color: var(--studio-color-accent-border); + background: var(--studio-status-good-bg); + color: var(--studio-status-good-text); } -.ux-progress-fill { - height: 100%; - border-radius: inherit; - background: #7be0b2; +.ux-node-ui-status-button-idle { + border-color: var(--studio-status-neutral-border); + background: var(--studio-status-neutral-bg); + color: var(--studio-status-neutral-text); } -.ux-progress-fill-determinate { - transition: width 140ms ease-out; +.ux-node-ui-status-button-error { + border-color: var(--studio-status-error-border); + background: var(--studio-status-error-bg); + color: var(--studio-status-error-text); } -.ux-progress-fill-indeterminate { - width: 42%; - animation: ux-progress-sweep 1.2s ease-in-out infinite; +.ux-node-ui-status-button-running.ux-node-ui-status-button-open { + box-shadow: 0 0 0 3px rgba(123, 224, 178, 0.18); } -.ux-progress-fill-timeout { - transform-origin: left center; - animation-name: ux-progress-timeout; - animation-timing-function: linear; - animation-fill-mode: forwards; +.ux-node-ui-status-button-idle.ux-node-ui-status-button-open { + box-shadow: 0 0 0 3px rgba(153, 162, 173, 0.18); } -.ux-progress-detail { - margin: 0; - color: #99a2ad; - font-size: 0.88rem; +.ux-node-ui-status-button-error.ux-node-ui-status-button-open { + box-shadow: 0 0 0 3px rgba(255, 199, 199, 0.22); } -.ux-terminal { - display: grid; - gap: 3px; - max-height: 170px; - margin: 0; - padding: 10px; - overflow: auto; - border: 1px solid #293039; - border-radius: 6px; - color: #d8dfdb; - background: #0c1114; - font-family: - "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; - font-size: 0.78rem; - line-height: 1.35; - list-style: none; +.ux-node-ui-status-popup { + position: absolute; + top: calc(100% + var(--studio-space-2)); + left: 0; + z-index: 30; + display: grid; + gap: 0; + width: min(300px, 82vw); + padding: 0; + overflow: hidden; + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-sm); + background: var(--studio-color-surface-raised); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.34); } -.ux-terminal li { - overflow-wrap: anywhere; - white-space: pre-wrap; +.ux-node-ui-status-popup-running { + border-color: var(--studio-color-accent-border); + background: linear-gradient(90deg, rgba(123, 224, 178, 0.13), transparent 72%), + var(--studio-color-surface-raised); } -.ux-stack-terminal { - max-height: 220px; +.ux-node-ui-status-popup-idle { + border-color: var(--studio-status-neutral-border); + background: linear-gradient(90deg, rgba(153, 162, 173, 0.12), transparent 72%), + var(--studio-color-surface-raised); } -.ux-actions { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 10px; - margin-top: 18px; +.ux-node-ui-status-popup-error { + border-color: var(--studio-status-error-border); + background: linear-gradient(90deg, rgba(255, 199, 199, 0.15), transparent 74%), + var(--studio-color-surface-raised); } -.ux-action-item { - display: grid; - gap: 6px; - min-width: 0; +.ux-node-ui-status-popup-summary { + display: grid; + gap: var(--studio-space-1); + min-width: 0; + padding: var(--studio-space-3) var(--studio-space-4); } -.ux-action { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - min-height: 40px; - padding: 0 14px; - border: 1px solid #3e4852; - border-radius: 6px; - color: #f9f6eb; - background: #20272e; - cursor: pointer; - font-weight: 700; +.ux-node-ui-status-popup-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--studio-space-4); + min-width: 0; } -.ux-action-icon { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 22px; - height: 22px; - padding: 0 5px; - border: 1px solid currentColor; - border-radius: 5px; - font-size: 0.72rem; - line-height: 1; +.ux-node-ui-status-popup-kind { + min-width: 0; + overflow: hidden; + color: var(--studio-color-text-strong); + font-size: 0.88rem; + text-overflow: ellipsis; + white-space: nowrap; } -.ux-action-icon-play::before { - content: ">"; +.ux-node-ui-status-popup-perf, +.ux-node-ui-status-popup-source { + color: var(--studio-color-text-muted); + font-size: 0.72rem; } -.ux-action-icon-usb::before { - content: "USB"; +.ux-node-ui-status-popup-perf { + flex: 0 0 auto; + white-space: nowrap; } -.ux-action-icon-test::before { - content: "T"; +.ux-node-ui-status-popup-source { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.ux-action:hover:not(:disabled) { - border-color: #6fc39f; +.ux-node-ui-status-popup-error-detail { + min-width: 0; + margin: 0; + padding: var(--studio-space-3) var(--studio-space-4); + overflow-x: auto; + border-top: 1px solid var(--studio-status-error-border); + color: var(--studio-status-error-text); + background: transparent; + font-family: var(--studio-font-mono); + font-size: 0.68rem; + line-height: 1.45; + white-space: pre; } -.ux-action:disabled { - cursor: default; - opacity: 0.58; +.ux-popover-story { + display: grid; + gap: var(--studio-space-7); + min-height: min(660px, calc(100vh - 120px)); } -.ux-action-primary { - border-color: #65bd96; - color: #08130d; - background: #7be0b2; +.ux-popover-story-heading { + display: grid; + gap: var(--studio-space-2); } -.ux-action-secondary { - background: #26313a; +.ux-popover-story-heading h2, +.ux-popover-story-heading p { + margin: 0; } -.ux-action-tertiary { - background: transparent; +.ux-popover-story-heading p { + color: var(--studio-color-text-muted); } -.ux-disabled-reason { - width: 100%; - margin: -4px 0 0; - color: #aab2ba; - font-size: 0.88rem; +.ux-popover-story-grid { + position: relative; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: 1fr 1fr; + gap: var(--studio-space-6); + min-height: 520px; + padding: var(--studio-space-5); + border: 1px solid var(--studio-color-border); + border-radius: var(--studio-radius-md); + background: var(--studio-color-surface-subtle); } -.ux-metrics { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; - margin: 0; +.ux-popover-story-cell { + display: grid; + min-width: 0; + min-height: 180px; + padding: var(--studio-space-3); + border: 1px dashed var(--studio-color-border-muted); + border-radius: var(--studio-radius-sm); } -.ux-metrics div { - min-width: 0; - padding: 10px; - border: 1px solid #2d343b; - border-radius: 6px; - background: #11161b; +.ux-popover-story-cell-start { + align-content: start; + justify-content: start; } -.ux-metrics dt { - margin-bottom: 3px; - color: #99a2ad; - font-size: 0.78rem; +.ux-popover-story-cell-center { + align-content: center; + justify-content: center; } -.ux-metrics dd { - margin: 0; - color: #fffaf0; - font-weight: 700; - overflow-wrap: anywhere; +.ux-popover-story-cell-end { + align-content: start; + justify-content: end; } -.ux-log-panel { - min-width: 0; - padding: 10px; - border-color: #303942; - background: #11161b; +.ux-popover-story-cell-lower-end { + grid-column: 3; + align-content: end; + justify-content: end; } -.ux-log-heading { - margin-bottom: 6px; +.ux-popover-story-panel { + width: min(280px, calc(100vw - 24px)); } -.ux-log-list { - display: grid; - gap: 4px; - margin: 0; - padding: 0; - max-height: min(34vh, 360px); - overflow: auto; - list-style: none; +.ux-attached-popover-story { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: var(--studio-space-7); + min-height: min(720px, calc(100vh - 120px)); } -.ux-log { - display: grid; - grid-template-columns: 44px 88px minmax(0, 1fr); - gap: 8px; - align-items: baseline; - padding: 5px 7px; - border: 1px solid #293039; - border-radius: 5px; - background: #11161b; - font-size: 0.78rem; +.ux-attached-popover-story-card { + display: grid; + gap: var(--studio-space-3); + min-width: 0; } -.ux-log span { - color: #9aa5af; - font-size: 0.72rem; - text-transform: uppercase; +.ux-attached-popover-story-heading { + display: flex; + gap: var(--studio-space-4); + align-items: baseline; + justify-content: space-between; + min-width: 0; } -.ux-log strong { - color: #d9dedf; - font-size: 0.76rem; - overflow-wrap: anywhere; +.ux-attached-popover-story-heading strong { + min-width: 0; + color: var(--studio-color-text-strong); + font-size: 0.82rem; } -.ux-log p { - margin: 0; - color: #c7cbd0; - line-height: 1.25; - overflow-wrap: anywhere; +.ux-attached-popover-story-heading span { + flex: 0 0 auto; + color: var(--studio-color-text-subtle); + font-family: var(--studio-font-mono); + font-size: 0.68rem; + font-weight: 700; } -.ux-log-empty { - opacity: 0.68; +.ux-attached-popover-story-canvas { + position: relative; + display: grid; + min-height: 270px; + padding: 46px 18px; + overflow: visible; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-md); + background: + linear-gradient(45deg, var(--studio-color-surface-subtle) 25%, transparent 25%) 0 0 / 24px 24px, + linear-gradient(-45deg, var(--studio-color-surface-subtle) 25%, transparent 25%) 0 12px / 24px 24px, + linear-gradient(45deg, transparent 75%, var(--studio-color-surface-subtle) 75%) 12px -12px / 24px 24px, + linear-gradient(-45deg, transparent 75%, var(--studio-color-surface-subtle) 75%) -12px 0 / 24px 24px, + var(--studio-color-terminal); } -.ux-log-warn { - border-color: #6e6031; +.ux-popover-trigger-attached.ux-attached-popover-story-button, +.ux-popover-panel.ux-attached-popover-story-panel, +.ux-popover-bridge.ux-attached-popover-story-bridge { + position: absolute; } -.ux-log-error { - border-color: #874b4b; +.ux-popover-trigger-attached.ux-attached-popover-story-button { + z-index: 90; } -.story-book { - display: grid; - grid-template-columns: 260px minmax(0, 1fr); - min-height: 100vh; +.ux-popover-panel.ux-attached-popover-story-panel { + z-index: 20; + width: min(320px, calc(100% - 36px)); + max-width: none; } -.story-sidebar { - border-right: 1px solid #2a3138; - background: #14181d; - padding: 18px; +.ux-popover-bridge.ux-attached-popover-story-bridge { + z-index: 95; + width: 24px; + height: 1px; } -.story-sidebar-heading h1 { - margin: 0 0 6px; - font-size: 1.1rem; +.ux-popover-trigger-attached.ux-attached-popover-story-button-below { + top: 46px; + bottom: auto; } -.story-sidebar-heading p { - margin: 0 0 18px; - color: #9ba4ad; +.ux-popover-trigger-attached.ux-attached-popover-story-button-above { + top: auto; + bottom: 46px; } -.story-nav { - display: grid; - gap: 8px; +.ux-popover-panel.ux-attached-popover-story-panel-below, +.ux-popover-bridge.ux-attached-popover-story-bridge-below { + top: 69px; + bottom: auto; } -.story-nav-link { - display: block; - padding: 10px; - border: 1px solid #2d343b; - border-radius: 6px; - color: #e9edf0; - text-decoration: none; - background: #171b20; +.ux-popover-panel.ux-attached-popover-story-panel-above { + top: auto; + bottom: 69px; } -.story-nav-link.is-active { - border-color: #65bd96; +.ux-popover-bridge.ux-attached-popover-story-bridge-above { + top: auto; + bottom: 69px; } -.story-nav-group { - display: block; - margin-bottom: 3px; - color: #94b8aa; - font-size: 0.72rem; - text-transform: uppercase; +.ux-popover-trigger-attached.ux-attached-popover-story-start, +.ux-popover-panel.ux-attached-popover-story-start, +.ux-popover-bridge.ux-attached-popover-story-start { + left: 18px; + right: auto; + transform: none; } -.story-stage, -.story-png-page { - padding: 22px; +.ux-popover-trigger-attached.ux-attached-popover-story-middle, +.ux-popover-panel.ux-attached-popover-story-middle, +.ux-popover-bridge.ux-attached-popover-story-middle { + left: 50%; + right: auto; + transform: translateX(-50%); } -.story-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 16px; +.ux-popover-trigger-attached.ux-attached-popover-story-end, +.ux-popover-panel.ux-attached-popover-story-end, +.ux-popover-bridge.ux-attached-popover-story-end { + left: auto; + right: 18px; + transform: none; } -.story-toolbar h2, -.story-toolbar p { - margin: 0; +.ux-attached-popover-story-canvas-below-start, +.ux-attached-popover-story-canvas-below-middle, +.ux-attached-popover-story-canvas-below-end { + align-content: start; } -.story-toolbar p, -.story-canvas-meta p { - color: #9ba4ad; +.ux-attached-popover-story-canvas-above-start, +.ux-attached-popover-story-canvas-above-middle, +.ux-attached-popover-story-canvas-above-end { + align-content: end; } -.story-viewport-controls { - display: flex; - gap: 8px; +.ux-attached-popover-story-canvas-below-start, +.ux-attached-popover-story-canvas-above-start { + justify-content: start; } -.story-viewport-button { - padding: 7px 10px; - border: 1px solid #3e4852; - border-radius: 6px; - color: #f9f6eb; - background: #20272e; +.ux-attached-popover-story-canvas-below-middle, +.ux-attached-popover-story-canvas-above-middle { + justify-content: center; } -.story-viewport-button.is-active { - border-color: #65bd96; +.ux-attached-popover-story-canvas-below-end, +.ux-attached-popover-story-canvas-above-end { + justify-content: end; } -.story-canvas-shell { - width: 100%; +.ux-produced-product-preview { + width: min(100%, 168px); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + background: var(--studio-color-terminal); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.035); } -.story-canvas-meta { - margin-bottom: 10px; +.ux-produced-product-preview-visual, +.ux-node-ui-shader-preview { + background: + radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.72) 0 6%, transparent 14%), + radial-gradient(circle at 72% 34%, rgba(255, 255, 255, 0.34) 0 5%, transparent 13%), + conic-gradient(from 210deg at 50% 52%, + #f97390, + #f9c74f, + #7ee081, + #31d5c8, + #60a5fa, + #a78bfa, + #f97390); } -.story-canvas-meta h3 { - margin: 0 0 4px; +.ux-produced-product-frame { + position: relative; + width: 100%; + min-width: 0; + overflow: hidden; + border: 1px solid var(--studio-color-border-subtle); + background: var(--studio-color-terminal); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025); } -.story-canvas-meta p { - margin: 0; +.ux-produced-product-frame-capped { + width: min(100%, 320px); + max-height: 320px; + margin-inline: auto; } -.story-frame { - width: 100%; +.ux-produced-product-pixel-grid { + display: grid; + position: absolute; + inset: 0; + min-width: 0; + background: var(--studio-color-terminal); } -@keyframes ux-progress-sweep { - 0% { - transform: translateX(-110%); - } +.ux-produced-product-control-layout { + position: absolute; + inset: 0; + isolation: isolate; + overflow: hidden; + background: #000; +} - 100% { - transform: translateX(250%); - } +.ux-produced-product-control-lamp { + position: absolute; + display: block; + pointer-events: none; + transform: translate(-50%, -50%); + border-radius: 50%; + background: radial-gradient( + circle closest-side, + rgb(var(--lamp-r) var(--lamp-g) var(--lamp-b) / 1) 0%, + rgb(var(--lamp-r) var(--lamp-g) var(--lamp-b) / 1) 75%, + rgb(var(--lamp-r) var(--lamp-g) var(--lamp-b) / 0) 100% + ); + filter: saturate(1.08); + mix-blend-mode: screen; } -@keyframes ux-progress-timeout { - from { - transform: scaleX(1); - } +.ux-produced-product-skeleton { + position: absolute; + inset: 0; + display: grid; + align-content: center; + gap: var(--studio-space-3); + padding: var(--studio-space-3); + overflow: hidden; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.035), transparent 38%), + linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent), + var(--studio-color-terminal); +} + +.ux-produced-product-skeleton-visual { + background: + radial-gradient(circle at 24% 18%, rgba(96, 165, 250, 0.18), transparent 24%), + radial-gradient(circle at 78% 22%, rgba(123, 224, 178, 0.14), transparent 20%), + linear-gradient(135deg, rgba(255, 255, 255, 0.035), transparent 38%), + var(--studio-color-terminal); +} + +.ux-produced-product-skeleton-control { + background: + linear-gradient(135deg, rgba(123, 224, 178, 0.11), transparent 42%), + linear-gradient(180deg, rgba(255, 255, 255, 0.018), transparent), + var(--studio-color-terminal); +} + +.ux-produced-product-skeleton-working { + box-shadow: inset 0 0 0 1px var(--studio-status-working-border); +} + +.ux-produced-product-skeleton-graphic { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 5px; + opacity: 0.78; +} + +.ux-produced-product-skeleton-bar { + height: 10px; + border-radius: var(--studio-radius-pill); + background: linear-gradient(90deg, + var(--studio-color-surface-muted), + var(--studio-color-surface-subtle)); +} - to { - transform: scaleX(0); - } +.ux-produced-product-skeleton-bar:nth-child(3n) { + opacity: 0.62; +} + +.ux-produced-product-skeleton-bar:nth-child(4n) { + opacity: 0.42; +} + +.ux-produced-product-message { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: var(--studio-space-3); + text-align: center; + font-size: 0.75rem; + font-weight: 700; +} + +.ux-produced-product-message-warning { + color: var(--studio-status-warning-text); + background: var(--studio-status-warning-bg); +} + +.ux-produced-product-message-error { + color: var(--studio-status-error-text); + background: var(--studio-status-error-bg); +} + +.ux-produced-product-overlay { + position: absolute; + inset: 0; + display: grid; + place-content: center; + gap: var(--studio-space-1); + width: 100%; + border: 0; + padding: var(--studio-space-3); + color: var(--studio-color-text-strong); + text-align: center; + background: + linear-gradient(180deg, rgba(8, 12, 14, 0.58), rgba(8, 12, 14, 0.72)), + rgba(8, 12, 14, 0.52); + backdrop-filter: blur(2px); +} + +.ux-produced-product-overlay strong { + font-size: 0.92rem; } -@media (max-width: 880px) { - .ux-shell { - padding: 18px 18px 72px; - } +.ux-produced-product-overlay span { + color: var(--studio-color-text-muted); + font-size: 0.75rem; + font-weight: 700; +} - .ux-header { - flex-direction: column; - } +.ux-produced-product-overlay-button { + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} - .ux-layout, - .story-book { - grid-template-columns: 1fr; - } +.ux-produced-product-overlay-button:hover { + color: var(--studio-color-accent); + background: + linear-gradient(180deg, rgba(8, 12, 14, 0.48), rgba(8, 12, 14, 0.66)), + rgba(8, 12, 14, 0.46); +} - .story-sidebar { - border-right: 0; - border-bottom: 1px solid #2a3138; - } +.ux-produced-value-card { + display: grid; + min-height: 80px; + min-width: 0; + align-content: space-between; + gap: var(--studio-space-2); + padding: var(--studio-space-2); + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + background: var(--studio-color-surface-muted); +} + +.ux-produced-value-reading { + display: inline-grid; + position: relative; + width: max-content; + min-width: 9.75ch; + max-width: 100%; + color: var(--studio-color-text-strong); + font-family: var(--studio-font-mono); + font-size: 1.35rem; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.ux-produced-value-reading-with-unit { + padding-right: 2.35ch; +} + +.ux-produced-value-number { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + font-size: inherit; + font-weight: 800; +} + +.ux-produced-value-unit { + display: inline-flex; + position: absolute; + right: 0; + bottom: 0.05em; + align-items: baseline; + color: var(--studio-color-text-subtle); + font-family: var(--studio-font-sans); + font-size: 0.72rem; + font-weight: 800; + line-height: 1; +} + +.ux-node-ui-perf, +.ux-node-ui-status { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 var(--studio-space-2); + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-pill); + color: var(--studio-color-text-muted); + background: var(--studio-color-surface-muted); + font-size: 0.68rem; + font-weight: 700; + white-space: nowrap; +} + +.ux-node-ui-status-good { + border-color: var(--studio-status-good-border); + color: var(--studio-status-good-text); + background: var(--studio-status-good-bg); +} + +.ux-node-ui-produced { + display: grid; + gap: 0; + min-width: 0; +} + +.ux-node-ui-products { + display: grid; + min-width: 0; +} + +.ux-node-ui-produced-values { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); + gap: var(--studio-space-2); + min-width: 0; + padding: var(--studio-space-3) var(--studio-space-6); + border-top: 1px solid var(--studio-color-border-muted); + background: rgba(12, 17, 20, 0.22); +} + +.ux-node-ui-window-compact .ux-node-ui-produced-values { + padding: var(--studio-space-3) var(--studio-space-5); +} + +.ux-node-ui-metric { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: var(--studio-space-1) var(--studio-space-2); + align-items: center; + min-width: 0; + padding: var(--studio-space-1) 0; +} + +.ux-node-ui-metric-label { + min-width: 0; + color: var(--studio-color-text-subtle); + font-size: 0.76rem; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ux-node-ui-metric strong { + justify-self: end; + min-width: 0; + overflow-wrap: anywhere; + color: var(--studio-color-text-strong); + font-family: var(--studio-font-mono); + font-size: 0.86rem; + line-height: 1.2; +} + +.ux-node-ui-metric small { + grid-column: 2 / -1; + min-width: 0; + color: var(--studio-color-text-subtle); + font-size: 0.72rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ux-node-ui-product { + display: grid; + gap: 0; + min-width: 0; + background: var(--studio-color-surface-subtle); +} + +.ux-node-ui-product-meta { + display: flex; + flex-wrap: wrap; + gap: var(--studio-space-2); + align-items: baseline; + min-width: 0; + padding: var(--studio-space-3); + border-top: 1px solid var(--studio-color-border-strong); + border-bottom: 1px solid var(--studio-color-border-muted); + background: rgba(12, 17, 20, 0.84); + color: var(--studio-color-text-muted); + font-size: 0.74rem; +} + +.ux-node-ui-product-meta em, +.ux-node-ui-product-meta span, +.ux-node-ui-product-meta small { + margin: 0; + overflow-wrap: anywhere; +} + +.ux-node-ui-product-meta em { + color: var(--studio-color-text-strong); + font-style: italic; + font-weight: 800; +} + +.ux-node-ui-product-meta span { + color: var(--studio-color-text-muted); +} + +.ux-node-ui-product-meta small { + color: var(--studio-color-text-subtle); +} + +.ux-node-ui-preview { + position: relative; + display: grid; + align-content: stretch; + min-width: 0; + min-height: 260px; + overflow: hidden; + background: var(--studio-color-terminal); +} + +.ux-node-ui-window-compact .ux-node-ui-preview { + min-height: 160px; +} + +.ux-node-ui-preview-visual { + align-content: start; + justify-items: start; + min-height: 0; + padding: var(--studio-space-3); + background: rgba(12, 17, 20, 0.58); +} + +.ux-node-ui-shader-preview { + width: min(100%, 224px); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.035); +} + +.ux-node-ui-window-compact .ux-node-ui-shader-preview { + width: min(100%, 152px); +} + +.ux-node-ui-preview-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: 1px; + min-height: 100%; + opacity: 0.92; +} + +.ux-node-ui-preview-grid span { + min-height: 18px; + background: rgba(123, 224, 178, 0.5); +} + +.ux-node-ui-preview-visual .ux-node-ui-preview-grid span:nth-child(3n) { + background: rgba(240, 220, 122, 0.72); +} + +.ux-node-ui-preview-visual .ux-node-ui-preview-grid span:nth-child(4n) { + background: rgba(255, 199, 199, 0.56); +} + +.ux-node-ui-preview-control .ux-node-ui-preview-grid { + grid-template-columns: repeat(10, minmax(0, 1fr)); + align-content: end; + padding: var(--studio-space-3); + gap: 3px; +} + +.ux-node-ui-preview-control .ux-node-ui-preview-grid span { + min-height: 9px; + border-radius: 2px; + background: var(--studio-color-accent); +} + +.ux-node-ui-preview-control .ux-node-ui-preview-grid span:nth-child(5n) { + background: var(--studio-status-warning-text); +} + +.ux-node-ui-values { + display: grid; + gap: 0; + padding: var(--studio-space-4) var(--studio-space-6) var(--studio-space-5); + border-top: 1px solid var(--studio-color-border-strong); +} + +.ux-node-ui-window-compact .ux-node-ui-values { + padding: var(--studio-space-4) var(--studio-space-5); +} + +.ux-node-ui-row { + display: grid; + grid-template-columns: 24px minmax(110px, 0.32fr) minmax(0, 1fr); + gap: var(--studio-space-3); + align-items: baseline; + min-width: 0; + padding: var(--studio-space-3) 0; + border-top: 1px solid var(--studio-color-border-muted); +} + +.ux-node-ui-row:first-child { + border-top: 0; +} + +.ux-node-ui-row-bound .ux-node-ui-row-value { + color: var(--studio-color-accent); +} + +.ux-node-ui-source { + display: inline-grid; + place-items: center; + align-self: center; + justify-self: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-pill); + background: var(--studio-color-surface-raised); + color: var(--studio-color-text-subtle); + cursor: pointer; + line-height: 1; +} + +.ux-node-ui-source svg, +.ux-node-ui-popup-trigger svg { + display: block; +} + +.ux-node-ui-source-bound { + border-color: var(--studio-color-accent-border); + color: var(--studio-color-accent); + background: var(--studio-status-good-bg); +} + +.ux-node-ui-source-direct { + color: var(--studio-color-text-subtle); +} + +.ux-node-ui-source-child { + border-color: var(--studio-status-warning-text); + border-radius: 3px; + color: var(--studio-status-warning-text); + background: var(--studio-color-surface-raised); +} + +.ux-node-ui-popup-trigger { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-pill); + color: var(--studio-color-text-subtle); + background: var(--studio-color-surface-raised); + cursor: pointer; + line-height: 1; +} + +.ux-node-ui-popup-trigger-routed, +.ux-node-ui-popup-trigger-open { + border-color: var(--studio-color-accent-border); + color: var(--studio-color-accent); + background: var(--studio-status-good-bg); +} + +.ux-node-ui-popup { + position: absolute; + top: calc(100% + var(--studio-space-2)); + left: 0; + z-index: 40; + display: grid; + gap: var(--studio-space-2); + width: min(280px, 82vw); + min-width: 220px; + padding: var(--studio-space-3); + border: 1px solid var(--studio-color-border-strong); + border-radius: var(--studio-radius-sm); + background: linear-gradient(90deg, rgba(123, 224, 178, 0.08), transparent 74%), + var(--studio-color-surface-raised); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.34); +} + +.ux-node-ui-route-popup { + left: auto; + right: 0; +} + +.ux-popover-layer { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + overflow: visible; + border: 0; + background: transparent; + color: inherit; + pointer-events: none; +} + +.ux-popover-layer::backdrop { + background: transparent; +} + +.ux-popover-layer > * { + pointer-events: auto; +} + +.ux-popover-panel { + position: fixed; + right: auto; + bottom: auto; + z-index: 80; + max-width: calc(100vw - 24px); +} + +.ux-popover-chrome-quiet { + --ux-popover-border-color: var(--studio-color-border-strong); + --ux-popover-icon-color: var(--studio-color-text-strong); + --ux-popover-trigger-fill-top: var(--studio-color-terminal); + --ux-popover-trigger-fill-bottom: var(--studio-color-terminal); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-chrome-neutral { + --ux-popover-border-color: var(--studio-color-border-strong); + --ux-popover-icon-color: var(--studio-color-text-strong); + --ux-popover-trigger-fill-top: var(--studio-color-surface-raised-strong); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-chrome-accent, +.ux-popover-chrome-good { + --ux-popover-border-color: var(--studio-color-accent-border); + --ux-popover-icon-color: var(--studio-color-accent); + --ux-popover-trigger-fill-top: var(--studio-status-good-bg); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-chrome-working { + --ux-popover-border-color: var(--studio-status-working-border); + --ux-popover-icon-color: var(--studio-status-working-text); + --ux-popover-trigger-fill-top: var(--studio-status-working-bg); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-chrome-warning { + --ux-popover-border-color: var(--studio-status-warning-border); + --ux-popover-icon-color: var(--studio-status-warning-text); + --ux-popover-trigger-fill-top: var(--studio-status-warning-bg); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-chrome-error { + --ux-popover-border-color: var(--studio-status-error-border); + --ux-popover-icon-color: var(--studio-status-error-text); + --ux-popover-trigger-fill-top: var(--studio-status-error-bg); + --ux-popover-trigger-fill-bottom: var(--studio-color-surface-raised); + --ux-popover-panel-fill-away: var(--studio-color-surface-raised); +} + +.ux-popover-trigger-attached { + position: relative; + z-index: 90; + border-color: var(--ux-popover-border-color, var(--studio-color-border-strong)); + color: var(--ux-popover-icon-color, var(--studio-color-text-strong)); + background: linear-gradient( + 180deg, + var(--ux-popover-trigger-fill-top, var(--studio-color-surface-raised)), + var(--ux-popover-trigger-fill-bottom, var(--studio-color-surface-raised)) 95% + ); + box-shadow: none; +} + +.ux-popover-trigger-attached.ux-popover-trigger-attached-below { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.ux-popover-trigger-attached.ux-popover-trigger-attached-above { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.ux-popover-panel.ux-attached-popover-panel { + --ux-popover-radius: var(--studio-radius-md); + --ux-popover-border-width: 1px; + overflow: hidden; + border-color: var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-radius: var(--ux-popover-radius); + background: linear-gradient( + 180deg, + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)), + var(--ux-popover-panel-fill-away, var(--studio-color-surface-raised)) 95% + ); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.34); +} + +.ux-popover-panel.ux-attached-popover-panel-below { + --ux-popover-seam-fill: var(--ux-popover-trigger-fill-bottom, var(--studio-color-surface-raised)); +} + +.ux-popover-panel.ux-attached-popover-panel-above { + --ux-popover-seam-fill: var(--ux-popover-trigger-fill-top, var(--studio-color-surface-raised)); + background: linear-gradient( + 180deg, + var(--ux-popover-panel-fill-away, var(--studio-color-surface-raised)), + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)) 95% + ); +} + +.ux-popover-panel.ux-attached-popover-panel-square-top-left { + border-top-left-radius: 0; +} + +.ux-popover-panel.ux-attached-popover-panel-square-top-right { + border-top-right-radius: 0; +} + +.ux-popover-panel.ux-attached-popover-panel-square-bottom-left { + border-bottom-left-radius: 0; +} + +.ux-popover-panel.ux-attached-popover-panel-square-bottom-right { + border-bottom-right-radius: 0; +} + +.ux-popover-bridge { + --ux-popover-radius: var(--studio-radius-md); + --ux-popover-border-width: 1px; + position: fixed; + z-index: 95; + pointer-events: none; + background: var(--ux-popover-seam-fill, var(--studio-color-surface-raised)); +} + +.ux-popover-bridge-below { + --ux-popover-seam-fill: var(--ux-popover-trigger-fill-bottom, var(--studio-color-surface-raised)); +} + +.ux-popover-bridge-above { + --ux-popover-seam-fill: var(--ux-popover-trigger-fill-top, var(--studio-color-surface-raised)); +} + +.ux-popover-bridge-corner { + position: absolute; + width: var(--ux-popover-radius); + height: var(--ux-popover-radius); +} + +.ux-popover-bridge-corner::before { + position: absolute; + z-index: -1; + content: ""; +} + +.ux-popover-bridge-below .ux-popover-bridge-corner { + bottom: 0; +} + +.ux-popover-bridge-below .ux-popover-bridge-corner-left { + left: calc(-1 * var(--ux-popover-radius) + var(--ux-popover-border-width)); + border-right: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-bottom: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-bottom-right-radius: var(--ux-popover-radius); +} + +.ux-popover-bridge-below .ux-popover-bridge-corner-left::before { + inset: 0 calc(-1 * var(--ux-popover-border-width)) calc(-1 * var(--ux-popover-border-width)) 0; + background: radial-gradient( + circle at top left, + transparent 0 calc(var(--ux-popover-radius) - var(--ux-popover-border-width)), + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)) calc(var(--ux-popover-radius) - var(--ux-popover-border-width) + 0.5px) + ); +} + +.ux-popover-bridge-below .ux-popover-bridge-corner-right { + right: calc(-1 * var(--ux-popover-radius) + var(--ux-popover-border-width)); + border-bottom: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-left: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-bottom-left-radius: var(--ux-popover-radius); +} + +.ux-popover-bridge-below .ux-popover-bridge-corner-right::before { + inset: 0 0 calc(-1 * var(--ux-popover-border-width)) calc(-1 * var(--ux-popover-border-width)); + background: radial-gradient( + circle at top right, + transparent 0 calc(var(--ux-popover-radius) - var(--ux-popover-border-width)), + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)) calc(var(--ux-popover-radius) - var(--ux-popover-border-width) + 0.5px) + ); +} + +.ux-popover-bridge-above .ux-popover-bridge-corner { + top: 0; +} - .ux-log-list { - max-height: 260px; - } +.ux-popover-bridge-above .ux-popover-bridge-corner-left { + left: calc(-1 * var(--ux-popover-radius) + var(--ux-popover-border-width)); + border-top: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-right: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-top-right-radius: var(--ux-popover-radius); +} - .ux-log { - grid-template-columns: 44px minmax(0, 1fr); - } +.ux-popover-bridge-above .ux-popover-bridge-corner-left::before { + inset: calc(-1 * var(--ux-popover-border-width)) calc(-1 * var(--ux-popover-border-width)) 0 0; + background: radial-gradient( + circle at bottom left, + transparent 0 calc(var(--ux-popover-radius) - var(--ux-popover-border-width)), + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)) calc(var(--ux-popover-radius) - var(--ux-popover-border-width) + 0.5px) + ); +} - .ux-log strong { +.ux-popover-bridge-above .ux-popover-bridge-corner-right { + right: calc(-1 * var(--ux-popover-radius) + var(--ux-popover-border-width)); + border-top: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-left: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); + border-top-left-radius: var(--ux-popover-radius); +} + +.ux-popover-bridge-above .ux-popover-bridge-corner-right::before { + inset: calc(-1 * var(--ux-popover-border-width)) 0 0 calc(-1 * var(--ux-popover-border-width)); + background: radial-gradient( + circle at bottom right, + transparent 0 calc(var(--ux-popover-radius) - var(--ux-popover-border-width)), + var(--ux-popover-seam-fill, var(--studio-color-surface-raised)) calc(var(--ux-popover-radius) - var(--ux-popover-border-width) + 0.5px) + ); +} + +.ux-popover-bridge-no-left-corner .ux-popover-bridge-corner-left, +.ux-popover-bridge-no-right-corner .ux-popover-bridge-corner-right { display: none; - } +} + +.ux-popover-bridge-no-left-corner { + border-left: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); +} + +.ux-popover-bridge-no-right-corner { + border-right: var(--ux-popover-border-width) solid var(--ux-popover-border-color, var(--studio-color-border-strong)); +} + +.ux-node-ui-popup strong, +.ux-node-ui-popup p, +.ux-node-ui-popup small { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.ux-node-ui-popup strong { + color: var(--studio-color-text-strong); + font-size: 0.82rem; +} + +.ux-node-ui-popup p, +.ux-node-ui-popup small { + color: var(--studio-color-text-muted); + font-size: 0.72rem; + line-height: 1.35; +} + +.ux-node-ui-popup-kicker { + color: var(--studio-color-text-subtle); + font-size: 0.62rem; + font-weight: 800; + text-transform: uppercase; +} + +.ux-node-ui-binding-section { + display: grid; + gap: var(--studio-space-1); + min-width: 0; +} + +.ux-node-ui-binding-heading { + color: var(--studio-color-text-subtle); + font-size: 0.64rem; + font-weight: 800; +} + +.ux-node-ui-bus-binding-section { + gap: var(--studio-space-2); +} + +.ux-node-ui-bus-binding-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--studio-space-1); + align-items: center; + min-width: 0; + padding: var(--studio-space-2); + border: 1px solid var(--studio-color-border-muted); + border-radius: var(--studio-radius-sm); + background: var(--studio-color-surface-muted); +} + +.ux-node-ui-bus-binding-row span { + color: var(--studio-color-text-subtle); + font-family: var(--studio-font-mono); + font-size: 0.72rem; +} + +.ux-node-ui-bus-binding-row code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--studio-color-text-strong); + font-family: var(--studio-font-mono); + font-size: 0.78rem; +} + +.ux-node-ui-bus-binding-row-empty code { + color: var(--studio-color-text-subtle); + font-family: var(--studio-font-sans); + font-style: italic; +} + +.ux-node-ui-binding-item, +.ux-node-ui-binding-empty { + display: flex; + gap: var(--studio-space-2); + align-items: center; + min-width: 0; + color: var(--studio-color-text-muted); + font-size: 0.7rem; +} + +.ux-node-ui-binding-item code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--studio-color-text-strong); + font-family: var(--studio-font-mono); + font-size: 0.72rem; +} + +.ux-node-ui-binding-item-readonly code { + color: var(--studio-color-text-muted); +} + +.ux-node-ui-binding-arrow { + flex: 0 0 auto; + color: var(--studio-color-accent); + font-family: var(--studio-font-mono); +} + +.ux-node-ui-binding-add, +.ux-node-ui-bus-binding-row button, +.ux-node-ui-binding-item button, +.ux-node-ui-binding-empty button { + justify-self: start; + min-height: 24px; + padding: 0 var(--studio-space-2); + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + color: var(--studio-color-text-muted); + background: var(--studio-color-surface-muted); + cursor: pointer; + font-size: 0.66rem; + font-weight: 700; +} + +.ux-node-ui-popup-actions { + display: flex; + flex-wrap: wrap; + gap: var(--studio-space-2); + min-width: 0; +} + +.ux-node-ui-popup-actions button { + min-height: 26px; + padding: 0 var(--studio-space-2); + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-sm); + color: var(--studio-color-text-muted); + background: var(--studio-color-surface-muted); + cursor: pointer; + font-size: 0.68rem; + font-weight: 700; +} + +.ux-node-ui-popup-actions button.is-active { + border-color: var(--studio-color-accent-border); + color: var(--studio-color-accent); + background: var(--studio-status-good-bg); +} + +.ux-node-ui-row-label, +.ux-node-ui-row-value { + min-width: 0; + overflow-wrap: anywhere; +} + +.ux-node-ui-row-label { + color: var(--studio-color-text-subtle); + font-size: 0.78rem; +} + +.ux-node-ui-row-value { + color: var(--studio-color-text-strong); + font-family: var(--studio-font-mono); + font-size: 0.78rem; +} + +.ux-node-ui-row-binding { + color: inherit; +} + +.ux-node-ui-nested { + grid-column: 2 / -1; + display: grid; + gap: var(--studio-space-3); + min-width: 0; + margin-top: var(--studio-space-2); + padding: var(--studio-space-3) 0 0 var(--studio-space-4); + border-left: 1px solid var(--studio-color-border-muted); +} + +.ux-node-ui-nested-heading { + display: flex; + flex-wrap: wrap; + gap: var(--studio-space-2); + align-items: baseline; + justify-content: space-between; + min-width: 0; +} + +.ux-node-ui-nested-heading span { + color: var(--studio-color-text-strong); + font-size: 0.8rem; + font-weight: 700; +} + +.ux-node-ui-nested-heading small { + color: var(--studio-color-text-subtle); + font-size: 0.72rem; +} + +.ux-node-ui-nested dl { + display: grid; + gap: var(--studio-space-2); + margin: 0; +} + +.ux-node-ui-nested dl div { + display: grid; + grid-template-columns: minmax(96px, 0.3fr) minmax(0, 1fr); + gap: var(--studio-space-3); +} + +.ux-node-ui-nested dt, +.ux-node-ui-nested dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + font-size: 0.74rem; +} + +.ux-node-ui-nested dt { + color: var(--studio-color-text-subtle); +} + +.ux-node-ui-nested dd { + color: var(--studio-color-text-muted); + font-family: var(--studio-font-mono); +} + +.ux-node-ui-children { + display: grid; + gap: var(--studio-space-3); + min-width: 0; + padding: 0 var(--studio-space-2); +} + +.ux-node-ui-children h4 { + margin: 0; + color: var(--studio-color-text-subtle); + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; +} + +.ux-node-ui-child-nodes { + display: grid; + gap: var(--studio-space-2); +} + +.ux-node-ui-child-node { + border-radius: var(--studio-radius-sm); +} + +.ux-node-ui-child-node .ux-node-ui-header { + min-height: 38px; + border-radius: calc(var(--studio-radius-sm) - 1px) calc(var(--studio-radius-sm) - 1px) 0 0; +} + +.ux-node-ui-child-node.ux-node-ui-window-collapsed .ux-node-ui-header { + border-bottom: 0; + border-radius: calc(var(--studio-radius-sm) - 1px); +} + +.ux-node-ui-child-node.ux-node-ui-window-collapsed .ux-node-ui-collapse-button { + border-radius: calc(var(--studio-radius-sm) - 1px) 0 0 calc(var(--studio-radius-sm) - 1px); +} + +.ux-node-ui-child-node .ux-node-ui-title h3 { + font-size: 0.86rem; +} + +.ux-node-ui-child-node .ux-node-ui-preview { + min-height: 96px; +} + +.ux-node-ui-header-tabs { + display: inline-flex; + flex-wrap: nowrap; + overflow: hidden; + align-self: stretch; + border-left: 1px solid var(--studio-color-border-muted); + border-top-right-radius: calc(var(--studio-radius-md) - 1px); + background: var(--studio-color-surface); +} + +.ux-node-ui-tab { + min-height: 100%; + padding: 0 var(--studio-space-5); + border: 0; + border-right: 1px solid var(--studio-color-border-muted); + border-radius: 0; + color: var(--studio-color-text-muted); + background: transparent; + cursor: pointer; + font-size: 0.68rem; + font-weight: 700; +} + +.ux-node-ui-tab:last-child { + border-right: 0; +} + +.ux-node-ui-tab-active { + color: var(--studio-color-text-strong); + background: var(--studio-color-surface-subtle); +} + +.ux-node-ui-tab-panel { + display: grid; + gap: var(--studio-space-2); + min-width: 0; + padding: var(--studio-space-4) var(--studio-space-5) var(--studio-space-5); + border-top: 1px solid var(--studio-color-border-muted); + background: var(--studio-color-surface-subtle); +} + +.ux-node-ui-json-heading { + color: var(--studio-color-text-subtle); + font-size: 0.68rem; + font-weight: 800; + text-transform: uppercase; +} + +.ux-node-ui-json { + max-height: min(52vh, 560px); + min-width: 0; + margin: 0; + overflow: auto; + padding: var(--studio-space-4); + border: 1px solid var(--studio-color-border-muted); + border-radius: var(--studio-radius-sm); + background: var(--studio-color-terminal); + color: var(--studio-color-text-muted); + font-family: var(--studio-font-mono); + font-size: 0.68rem; + line-height: 1.45; + white-space: pre; +} + +.ux-node-ui-project-layout { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + gap: var(--studio-space-6); + align-items: start; + min-width: 0; +} + +.ux-node-ui-project-tree { + display: grid; + gap: var(--studio-space-4); + min-width: 0; + padding: var(--studio-space-5); + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-md); + background: var(--studio-color-surface-subtle); +} + +.ux-node-ui-tree-heading { + margin: 0; + color: var(--studio-color-heading); + font-size: 0.76rem; + font-weight: 700; +} + +.ux-node-ui-project-tree ol { + display: grid; + gap: var(--studio-space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.ux-node-ui-tree-item { + min-width: 0; + padding: var(--studio-space-2) var(--studio-space-3); + border: 1px solid transparent; + border-radius: var(--studio-radius-sm); + color: var(--studio-color-text-muted); + font-size: 0.76rem; + overflow-wrap: anywhere; +} + +.ux-node-ui-tree-root, +.ux-node-ui-tree-active { + border-color: var(--studio-color-accent-border); + color: var(--studio-color-text-strong); + background: var(--studio-status-good-bg); +} + +.ux-node-ui-tree-depth-1 { + margin-left: var(--studio-space-5); +} + +.ux-node-ui-tree-depth-2 { + margin-left: var(--studio-space-9); +} + +.ux-node-ui-project-nodes { + gap: var(--studio-space-6); +} + +.story-frame-checker { + background-color: var(--studio-color-bg); + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.035) 75%); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; +} + +.ux-slot-optional-toggle { + position: relative; + display: inline-flex; + min-width: 0; + flex: none; + align-items: center; + gap: 0.4rem; + color: var(--studio-color-text-subtle); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + cursor: pointer; + user-select: none; +} + +.ux-slot-optional-toggle-input { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.ux-slot-optional-toggle-track { + position: relative; + width: 1.85rem; + height: 1rem; + flex: none; + border: 1px solid var(--studio-color-border-subtle); + border-radius: var(--studio-radius-pill); + background: var(--studio-color-surface-muted); + transition: border-color 120ms ease, background-color 120ms ease; +} + +.ux-slot-optional-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: calc(1rem - 6px); + height: calc(1rem - 6px); + border-radius: 999px; + background: var(--studio-color-text-subtle); + transition: transform 120ms ease, background-color 120ms ease; +} + +.ux-slot-optional-toggle-input:checked + .ux-slot-optional-toggle-track { + border-color: var(--studio-color-accent-border); + background: var(--studio-status-good-bg); +} + +.ux-slot-optional-toggle-input:checked + .ux-slot-optional-toggle-track .ux-slot-optional-toggle-thumb { + transform: translateX(0.85rem); + background: var(--studio-color-accent); +} + +.ux-slot-optional-toggle-input:focus-visible + .ux-slot-optional-toggle-track { + outline: 1px solid var(--studio-color-border-strong); + outline-offset: 2px; +} + +.ux-slot-optional-toggle-input:disabled + .ux-slot-optional-toggle-track, +.ux-slot-optional-toggle-input:disabled ~ .ux-slot-optional-toggle-label { + opacity: 0.55; +} + +@keyframes ux-progress-sweep { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(250%); + } +} + +@keyframes ux-progress-timeout { + from { + transform: scaleX(1); + } + + to { + transform: scaleX(0); + } +} + +@media (max-width: 960px) and (min-width: 641px) { + .ux-editor-shell { + grid-template-columns: minmax(0, 1fr) minmax(260px, 340px); + } + + .ux-editor-desktop-tree, + .ux-editor-side { + display: none; + } + + .ux-editor-compact-side { + display: block; + } + + .ux-node-preview { + min-height: 150px; + } + + .ux-node-ui-project-layout { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .ux-editor-shell { + display: block; + } + + .ux-editor-desktop-tree, + .ux-editor-workspace, + .ux-editor-side, + .ux-editor-compact-side { + display: none; + } + + .ux-editor-mobile { + display: block; + } + + .ux-node-ui-header, + .ux-node-ui-project-layout, + .ux-node-ui-product, + .ux-node-ui-child, + .ux-node-ui-nested dl div { + grid-template-columns: 1fr; + } + + .ux-node-ui-header-meta { + justify-content: flex-start; + } + + .ux-node-ui-row { + grid-template-columns: 22px minmax(0, 1fr); + gap: var(--studio-space-2) var(--studio-space-3); + } + + .ux-node-ui-row-value, + .ux-node-ui-nested { + grid-column: 2; + } + + .ux-slot-optional-toggle-label { + display: none; + } + + .ux-node-ui-header { + padding: var(--studio-space-4); + } + + .ux-node-ui-values { + padding: var(--studio-space-4); + } + + .ux-node-ui-produced-values { + grid-template-columns: 1fr; + padding: var(--studio-space-3) var(--studio-space-4); + } + + .ux-node-ui-preview { + min-height: 112px; + } + + .ux-node-ui-tab-list { + padding: 0 var(--studio-space-2); + } + } diff --git a/lp-app/lpa-studio-web/src/web_app.rs b/lp-app/lpa-studio-web/src/web_app.rs new file mode 100644 index 000000000..80637c1a8 --- /dev/null +++ b/lp-app/lpa-studio-web/src/web_app.rs @@ -0,0 +1,373 @@ +use std::cell::Cell; +use std::rc::Rc; + +use crate::app::StudioShell; +use crate::studio_url; +use dioxus::prelude::*; +use gloo_timers::future::TimeoutFuture; +use lpa_studio_core::core::view::steps_view::UiStepState; +use lpa_studio_core::{ + LinkProviderKind, LinkState, StudioController, UiAction, UiActivityView, UiError, UiLogEntry, + UiLogLevel, UiNotice, UiNoticeLevel, UiStatus, UiStudioView, UiViewContent, UxActivityTarget, + UxUpdate, UxUpdateSink, +}; + +const STYLE: &str = include_str!("style.css"); +const DEVICE_PROJECT_REFRESH_INTERVAL_MS: u32 = 750; +const SIMULATOR_PROJECT_REFRESH_INTERVAL_MS: u32 = 16; + +#[allow(non_snake_case, reason = "Dioxus components use PascalCase")] +pub fn App() -> Element { + #[cfg(feature = "stories")] + if crate::stories::story_book::should_show_story_book() { + return rsx! { + style { "{STYLE}" } + document::Stylesheet { href: asset!("/assets/tailwind.css") } + crate::stories::story_book::StoryBook {} + }; + } + + let model = use_signal(StudioWebModel::new); + let startup_intent = use_hook(studio_url::read_connection_intent); + let startup_model = model; + let _startup_task = use_future(move || async move { + if let Some(action) = startup_intent.and_then(|intent| intent.startup_action()) { + execute_action(startup_model, action).await; + } + }); + let refresh_model = model; + let _refresh_task = use_future(move || async move { + loop { + wait_for_next_project_refresh(refresh_model).await; + execute_refresh_tick(refresh_model).await; + } + }); + let view = model.read().view.clone(); + let running = model.read().running; + let on_action = move |action: UiAction| { + spawn(async move { + execute_action(model, action).await; + }); + }; + + rsx! { + style { "{STYLE}" } + document::Stylesheet { href: asset!("/assets/tailwind.css") } + StudioShell { + view, + running, + on_action, + } + } +} + +struct StudioWebModel { + ux: Option, + view: UiStudioView, + running: bool, + refreshing: bool, + console_logs: Vec, +} + +impl StudioWebModel { + fn new() -> Self { + let ux = StudioController::new(); + let view = ux.view(); + Self { + ux: Some(ux), + view, + running: false, + refreshing: false, + console_logs: Vec::new(), + } + } + + fn refresh_from_ux(&mut self) { + if let Some(ux) = &self.ux { + self.view = ux.view(); + self.append_console_logs_to_view(); + } + } + + fn apply_update(&mut self, update: UxUpdate) { + match update { + UxUpdate::View(mut view) => { + view.logs.extend(self.console_logs.clone()); + self.view = view; + } + UxUpdate::Activity { + target, + status, + activity, + } => { + self.apply_activity_update(target, status, activity); + } + UxUpdate::Log(log) => { + self.view.logs.push(log); + } + } + } + + fn push_console_log(&mut self, log: UiLogEntry) { + self.console_logs.push(log.clone()); + if self.console_logs.len() > 80 { + let remove_count = self.console_logs.len() - 80; + self.console_logs.drain(0..remove_count); + } + self.view.logs.push(log); + } + + fn append_console_logs_to_view(&mut self) { + self.view.logs.extend(self.console_logs.clone()); + } + + fn project_refresh_cadence(&self) -> ProjectRefreshCadence { + self.ux + .as_ref() + .map(StudioController::snapshot) + .map(|snapshot| project_refresh_cadence_for_link_state(&snapshot.link.state)) + .unwrap_or(ProjectRefreshCadence::Device) + } + + fn apply_activity_update( + &mut self, + target: UxActivityTarget, + status: UiStatus, + activity: UiActivityView, + ) { + let Some(pane) = self + .view + .panes + .iter_mut() + .find(|pane| pane.node_id.as_str() == target.pane_node_id().as_str()) + else { + return; + }; + pane.status = status; + + match target { + UxActivityTarget::Pane { .. } => { + pane.body = UiViewContent::Activity(activity); + } + UxActivityTarget::StackSection { section_id, .. } => { + if let UiViewContent::Stack(stack) = &mut pane.body { + if let Some(section) = stack + .sections + .iter_mut() + .find(|section| section.id == section_id) + { + section.state = UiStepState::Active; + section.body = UiViewContent::Activity(activity); + section.actions.clear(); + return; + } + } + pane.body = UiViewContent::Activity(activity); + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProjectRefreshCadence { + Simulator, + Device, +} + +async fn wait_for_next_project_refresh(model: Signal) { + let cadence = { + let state = model.read(); + state.project_refresh_cadence() + }; + match cadence { + ProjectRefreshCadence::Simulator => { + TimeoutFuture::new(SIMULATOR_PROJECT_REFRESH_INTERVAL_MS).await; + } + ProjectRefreshCadence::Device => { + TimeoutFuture::new(DEVICE_PROJECT_REFRESH_INTERVAL_MS).await; + } + } +} + +fn project_refresh_cadence_for_link_state(state: &LinkState) -> ProjectRefreshCadence { + match state { + LinkState::Connected { device } | LinkState::Managing { device, .. } + if device.provider_id == LinkProviderKind::BrowserWorker => + { + ProjectRefreshCadence::Simulator + } + _ => ProjectRefreshCadence::Device, + } +} + +async fn execute_action(mut model: Signal, action: UiAction) { + let mut ux = loop { + let acquire = { + let mut state = model.write(); + if state.running { + return; + } + if state.refreshing { + ActionAcquire::Wait + } else if let Some(ux) = state.ux.take() { + state.running = true; + ActionAcquire::Ready(ux) + } else { + ActionAcquire::MissingUx + } + }; + match acquire { + ActionAcquire::Ready(ux) => break ux, + ActionAcquire::Wait => TimeoutFuture::new(25).await, + ActionAcquire::MissingUx => { + model.write().push_console_log(UiLogEntry::new( + UiLogLevel::Error, + "studio", + "Studio UX is already busy.", + )); + return; + } + } + }; + + studio_url::update_for_action(&action); + let accepting_updates = Rc::new(Cell::new(true)); + let mut update_model = model; + let update_gate = Rc::clone(&accepting_updates); + let updates = UxUpdateSink::new(move |update| { + if update_gate.get() { + update_model.write().apply_update(update); + } + }); + let result = ux.dispatch_with_updates(action, updates).await; + accepting_updates.set(false); + let mut state = model.write(); + state.ux = Some(ux); + state.refresh_from_ux(); + match result { + Ok(outcome) => { + for notice in outcome.notices { + state.push_console_log(log_from_notice(notice)); + } + } + Err(error) => { + state.push_console_log(log_from_error(error)); + } + } + state.running = false; +} + +enum ActionAcquire { + Ready(StudioController), + Wait, + MissingUx, +} + +async fn execute_refresh_tick(mut model: Signal) { + let Some(mut ux) = ({ + let mut state = model.write(); + if state.running || state.refreshing { + return; + } + let ux = state.ux.take(); + if ux.is_some() { + state.refreshing = true; + } + ux + }) else { + return; + }; + + let result = ux.refresh_loaded_project_tick().await; + let mut state = model.write(); + state.ux = Some(ux); + state.refresh_from_ux(); + if let Err(error) = result { + state.push_console_log(log_from_error(error)); + } + state.refreshing = false; +} + +fn log_from_notice(notice: UiNotice) -> UiLogEntry { + UiLogEntry::new( + log_level_from_notice(notice.level), + "studio", + notice.message, + ) +} + +fn log_level_from_notice(level: UiNoticeLevel) -> UiLogLevel { + match level { + UiNoticeLevel::Info => UiLogLevel::Info, + UiNoticeLevel::Warning => UiLogLevel::Warn, + UiNoticeLevel::Error => UiLogLevel::Error, + } +} + +fn log_from_error(error: UiError) -> UiLogEntry { + let level = if matches!(&error, UiError::Cancelled(_)) { + UiLogLevel::Info + } else { + UiLogLevel::Error + }; + UiLogEntry::new(level, "studio", error.to_string()) +} + +#[cfg(test)] +mod tests { + use lpa_studio_core::{ConnectedDeviceSummary, LinkState, ProgressState}; + + use super::*; + + #[test] + fn browser_worker_link_uses_simulator_refresh_cadence() { + let state = LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::BrowserWorker, + "browser-worker", + "session", + "Simulator", + ), + }; + + assert_eq!( + project_refresh_cadence_for_link_state(&state), + ProjectRefreshCadence::Simulator + ); + } + + #[test] + fn serial_link_keeps_device_refresh_cadence() { + let state = LinkState::Connected { + device: ConnectedDeviceSummary::new( + LinkProviderKind::BrowserSerialEsp32, + "serial", + "session", + "ESP32", + ), + }; + + assert_eq!( + project_refresh_cadence_for_link_state(&state), + ProjectRefreshCadence::Device + ); + } + + #[test] + fn managing_browser_worker_keeps_simulator_refresh_cadence() { + let state = LinkState::Managing { + device: ConnectedDeviceSummary::new( + LinkProviderKind::BrowserWorker, + "browser-worker", + "session", + "Simulator", + ), + progress: ProgressState::new("Resetting simulator"), + }; + + assert_eq!( + project_refresh_cadence_for_link_state(&state), + ProjectRefreshCadence::Simulator + ); + } +} diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__lg.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__lg.png new file mode 100644 index 000000000..6e66319cc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__md.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__md.png new file mode 100644 index 000000000..c7b43800d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__sm.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__sm.png new file mode 100644 index 000000000..43ff23b7e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__open-menu__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__lg.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__lg.png new file mode 100644 index 000000000..de811bc0e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__md.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__md.png new file mode 100644 index 000000000..114a84465 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__sm.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__sm.png new file mode 100644 index 000000000..a0f28d534 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__lg.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__lg.png new file mode 100644 index 000000000..6a72cae8b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__md.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__md.png new file mode 100644 index 000000000..8340f94c0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__sm.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__sm.png new file mode 100644 index 000000000..1575fb01f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__tones__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__lg.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__lg.png new file mode 100644 index 000000000..6a5aaac53 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__md.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__md.png new file mode 100644 index 000000000..c21181fde Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__sm.png b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__sm.png new file mode 100644 index 000000000..9d9f7a4be Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__icon-menu__trigger-states__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__lg.png b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__lg.png new file mode 100644 index 000000000..feef286b1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__md.png b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__md.png new file mode 100644 index 000000000..0888444ff Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__sm.png b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__sm.png new file mode 100644 index 000000000..01e104b0c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__edge-placement__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__open-popover__lg.png b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__lg.png new file mode 100644 index 000000000..ea5e752e0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__open-popover__md.png b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__md.png new file mode 100644 index 000000000..77e11e663 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__open-popover__sm.png b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__sm.png new file mode 100644 index 000000000..94910270e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__open-popover__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__overview__lg.png b/lp-app/lpa-studio-web/story-images/base__popover__overview__lg.png new file mode 100644 index 000000000..e2cc6e41f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__overview__md.png b/lp-app/lpa-studio-web/story-images/base__popover__overview__md.png new file mode 100644 index 000000000..a90eb809e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/base__popover__overview__sm.png b/lp-app/lpa-studio-web/story-images/base__popover__overview__sm.png new file mode 100644 index 000000000..1fbd34010 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/base__popover__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__lg.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__lg.png new file mode 100644 index 000000000..1a6e0b024 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__md.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__md.png new file mode 100644 index 000000000..a330c7fb3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__sm.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__sm.png new file mode 100644 index 000000000..3d8c0a49f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__confirmation__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__lg.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__lg.png new file mode 100644 index 000000000..aa25b0994 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__md.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__md.png new file mode 100644 index 000000000..03f28635d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__sm.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__sm.png new file mode 100644 index 000000000..11ce0e395 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__disabled-reason__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__lg.png new file mode 100644 index 000000000..100b05297 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__md.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__md.png new file mode 100644 index 000000000..9ad6d2b0a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__sm.png new file mode 100644 index 000000000..f87bb2347 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__lg.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__lg.png new file mode 100644 index 000000000..cf5b94425 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__md.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__md.png new file mode 100644 index 000000000..84bbed625 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__sm.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__sm.png new file mode 100644 index 000000000..a79a36b1c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__priorities__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__lg.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__lg.png new file mode 100644 index 000000000..1f19dcad0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__md.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__md.png new file mode 100644 index 000000000..0be14e116 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__sm.png b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__sm.png new file mode 100644 index 000000000..55b5aea46 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__action__action-strip__running-state__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__lg.png b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__lg.png new file mode 100644 index 000000000..0b927d17e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__md.png b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__md.png new file mode 100644 index 000000000..2c57999da Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__sm.png b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__sm.png new file mode 100644 index 000000000..2a10efedd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__message-only__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__lg.png new file mode 100644 index 000000000..dd077fcdd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__overview__md.png b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__md.png new file mode 100644 index 000000000..2d9f46dfc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__sm.png new file mode 100644 index 000000000..f249285d6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__lg.png b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__lg.png new file mode 100644 index 000000000..8ff85d968 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__md.png b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__md.png new file mode 100644 index 000000000..370958949 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__sm.png b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__sm.png new file mode 100644 index 000000000..2b1c52988 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__issue-view__with-detail__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__empty__lg.png b/lp-app/lpa-studio-web/story-images/core__log-list__empty__lg.png new file mode 100644 index 000000000..9bda9803e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__empty__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__empty__md.png b/lp-app/lpa-studio-web/story-images/core__log-list__empty__md.png new file mode 100644 index 000000000..76ad1689f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__empty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__empty__sm.png b/lp-app/lpa-studio-web/story-images/core__log-list__empty__sm.png new file mode 100644 index 000000000..6cf491571 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__empty__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__lg.png b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__lg.png new file mode 100644 index 000000000..5a4b22c9d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__md.png b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__md.png new file mode 100644 index 000000000..e45ad7384 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__sm.png b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__sm.png new file mode 100644 index 000000000..88c74384f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__mixed-levels__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__log-list__overview__lg.png new file mode 100644 index 000000000..be6f13cf8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__overview__md.png b/lp-app/lpa-studio-web/story-images/core__log-list__overview__md.png new file mode 100644 index 000000000..5ec70496d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__log-list__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__log-list__overview__sm.png new file mode 100644 index 000000000..8f72607a4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__log-list__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__lg.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__lg.png new file mode 100644 index 000000000..689fa05eb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__md.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__md.png new file mode 100644 index 000000000..4dce5a46a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__sm.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__sm.png new file mode 100644 index 000000000..1a63e6ef7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__compact__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__lg.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__lg.png new file mode 100644 index 000000000..9b5d6da89 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__md.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__md.png new file mode 100644 index 000000000..a57bfa8c0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__sm.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__sm.png new file mode 100644 index 000000000..bbfec01ef Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__dense__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__lg.png new file mode 100644 index 000000000..7dac86648 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__md.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__md.png new file mode 100644 index 000000000..6868dab1b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__sm.png new file mode 100644 index 000000000..e242f1614 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__metric-grid__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__lg.png new file mode 100644 index 000000000..cb1cdb690 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__md.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__md.png new file mode 100644 index 000000000..b1dddd23e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__sm.png new file mode 100644 index 000000000..34b47db98 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__lg.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__lg.png new file mode 100644 index 000000000..5e2a6d46b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__md.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__md.png new file mode 100644 index 000000000..5bce9558a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__sm.png b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__sm.png new file mode 100644 index 000000000..d25973247 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__progress-bar__variants__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__lg.png b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__lg.png new file mode 100644 index 000000000..6fb720695 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__md.png b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__md.png new file mode 100644 index 000000000..91eaeea9f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__sm.png b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__sm.png new file mode 100644 index 000000000..133861bef Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__kinds__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__lg.png new file mode 100644 index 000000000..84178678c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__overview__md.png b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__md.png new file mode 100644 index 000000000..95e468a49 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__status-chip__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__sm.png new file mode 100644 index 000000000..94ad6fe1f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__status-chip__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__lg.png new file mode 100644 index 000000000..d1576bf81 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__md.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__md.png new file mode 100644 index 000000000..bbc82cf80 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__sm.png new file mode 100644 index 000000000..325af665a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__lg.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__lg.png new file mode 100644 index 000000000..c484cee0b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__md.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__md.png new file mode 100644 index 000000000..3953a533b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__sm.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__sm.png new file mode 100644 index 000000000..8faaf4abd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__short-output__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__lg.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__lg.png new file mode 100644 index 000000000..d613823d0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__md.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__md.png new file mode 100644 index 000000000..1934d0c78 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__sm.png b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__sm.png new file mode 100644 index 000000000..b4f03966a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__terminal-output__wrapped-output__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__lg.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__lg.png new file mode 100644 index 000000000..db826a841 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__md.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__md.png new file mode 100644 index 000000000..da56c65e4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__sm.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__sm.png new file mode 100644 index 000000000..7ba6c07a6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__failed-step__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__lg.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__lg.png new file mode 100644 index 000000000..f08052202 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__md.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__md.png new file mode 100644 index 000000000..be46984aa Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__sm.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__sm.png new file mode 100644 index 000000000..383f72694 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__flashing__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__lg.png new file mode 100644 index 000000000..ee20544b2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__md.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__md.png new file mode 100644 index 000000000..e3325c214 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__sm.png new file mode 100644 index 000000000..1622dcde9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__activity-view__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__lg.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__lg.png new file mode 100644 index 000000000..68f2356ff Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__md.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__md.png new file mode 100644 index 000000000..3cbfed58b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__sm.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__sm.png new file mode 100644 index 000000000..9a1613f02 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__attention-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__lg.png new file mode 100644 index 000000000..626d4368b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__md.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__md.png new file mode 100644 index 000000000..90c670a22 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__sm.png new file mode 100644 index 000000000..603d3ff20 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__lg.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__lg.png new file mode 100644 index 000000000..cf21fb7c5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__md.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__md.png new file mode 100644 index 000000000..cc79baed8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__sm.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__sm.png new file mode 100644 index 000000000..0fbbab066 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__quiet-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png new file mode 100644 index 000000000..ab3ab5311 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png new file mode 100644 index 000000000..f65b18d22 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png new file mode 100644 index 000000000..54927421d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__pane-view__workflow-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png new file mode 100644 index 000000000..9bcd9878c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png new file mode 100644 index 000000000..97bedbd42 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png new file mode 100644 index 000000000..118bcf471 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__nested-content__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__lg.png new file mode 100644 index 000000000..f052aa595 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__md.png new file mode 100644 index 000000000..edceae7ab Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__sm.png new file mode 100644 index 000000000..b3fb06ae4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png new file mode 100644 index 000000000..987933ce0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png new file mode 100644 index 000000000..69ce7d169 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png new file mode 100644 index 000000000..8bfc37bc5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__running-actions__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png new file mode 100644 index 000000000..2b1f9f5fa Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png new file mode 100644 index 000000000..04cbcc30d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png new file mode 100644 index 000000000..eae662548 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__steps-view__workflow__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__lg.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__lg.png new file mode 100644 index 000000000..4eca9744e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__md.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__md.png new file mode 100644 index 000000000..ecf92558d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__sm.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__sm.png new file mode 100644 index 000000000..de71d0119 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__body-variants__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png new file mode 100644 index 000000000..664d5d919 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png new file mode 100644 index 000000000..feae4dd60 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png new file mode 100644 index 000000000..bbcfed7f5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__composed-variants__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__lg.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__lg.png new file mode 100644 index 000000000..37eaa7862 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__md.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__md.png new file mode 100644 index 000000000..4fa4f3040 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__sm.png b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__sm.png new file mode 100644 index 000000000..ce32d3bf6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/core__view__view-content__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__lg.png new file mode 100644 index 000000000..665162461 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__md.png new file mode 100644 index 000000000..753e0b8a8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__sm.png new file mode 100644 index 000000000..0f3a8e4f9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-compact__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__lg.png new file mode 100644 index 000000000..979d4126e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__md.png new file mode 100644 index 000000000..69613ce8f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__sm.png new file mode 100644 index 000000000..ba310fce6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__clock-instrument__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__lg.png new file mode 100644 index 000000000..ae20503e2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__md.png new file mode 100644 index 000000000..6ae8b3ab5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__sm.png new file mode 100644 index 000000000..ebd7f41ff Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__fixture-control-product__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__lg.png new file mode 100644 index 000000000..c8252d00b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__md.png new file mode 100644 index 000000000..91f6f9900 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__sm.png new file mode 100644 index 000000000..005992a0d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__lg.png new file mode 100644 index 000000000..54c8e5322 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__md.png new file mode 100644 index 000000000..2b3b5e426 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__sm.png new file mode 100644 index 000000000..86d59a722 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__lg.png new file mode 100644 index 000000000..8bef7e162 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__md.png new file mode 100644 index 000000000..55cd45e08 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__sm.png new file mode 100644 index 000000000..9744e82a5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__playlist-children__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__lg.png new file mode 100644 index 000000000..a82795797 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__md.png new file mode 100644 index 000000000..38edeb144 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__sm.png new file mode 100644 index 000000000..b9f7dd22b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__project-context__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__lg.png new file mode 100644 index 000000000..486888370 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__md.png new file mode 100644 index 000000000..7738ebab6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__sm.png new file mode 100644 index 000000000..5cbb9642e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__shader-visual-product__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__lg.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__lg.png new file mode 100644 index 000000000..2a2a3aa83 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__md.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__md.png new file mode 100644 index 000000000..ddaa6d810 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png new file mode 100644 index 000000000..2c81a3125 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/exploration__node-ui__status-indicators__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png b/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png deleted file mode 100644 index 7fa0aa7b1..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__actions__provider-actions.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png deleted file mode 100644 index 7f8835455..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__browser-serial-blank-firmware.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png deleted file mode 100644 index 15f62a880..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__browser-serial-canceled.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png b/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png deleted file mode 100644 index 899924b39..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__browser-serial-open-failed.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png b/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png deleted file mode 100644 index 9ae0c5515..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device-project-empty.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png b/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png deleted file mode 100644 index 4f3f995f2..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__device-project-selection.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__lg.png new file mode 100644 index 000000000..37702dec4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__md.png new file mode 100644 index 000000000..54c5f962f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__sm.png new file mode 100644 index 000000000..1ac489ca5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-blank-firmware__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__lg.png new file mode 100644 index 000000000..fb0317dc4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__md.png new file mode 100644 index 000000000..edfdefef0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__sm.png new file mode 100644 index 000000000..57a796088 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-canceled__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__lg.png new file mode 100644 index 000000000..111c8f844 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__md.png new file mode 100644 index 000000000..277130975 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__sm.png new file mode 100644 index 000000000..7020a9eed Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__browser-serial-open-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__lg.png new file mode 100644 index 000000000..a77e42229 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__md.png new file mode 100644 index 000000000..d6313a8e7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__sm.png new file mode 100644 index 000000000..25e70d207 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__device-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__lg.png new file mode 100644 index 000000000..81820529c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__md.png new file mode 100644 index 000000000..0d74d6bc5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__sm.png new file mode 100644 index 000000000..1a5373460 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__lg.png new file mode 100644 index 000000000..ae5b05149 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__md.png new file mode 100644 index 000000000..28e7cdfee Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__sm.png new file mode 100644 index 000000000..c0e9e1ab8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__lg.png new file mode 100644 index 000000000..6b9f4e6ca Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__md.png new file mode 100644 index 000000000..176f19b4e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__sm.png new file mode 100644 index 000000000..6e4a5a839 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provision-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__lg.png new file mode 100644 index 000000000..72db83e4a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__md.png new file mode 100644 index 000000000..b56aa905f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__sm.png new file mode 100644 index 000000000..1e950d261 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__provisioning__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__lg.png new file mode 100644 index 000000000..3d556f46e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__md.png new file mode 100644 index 000000000..76c739300 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__sm.png new file mode 100644 index 000000000..05fc4ac91 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__reset-complete__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__lg.png new file mode 100644 index 000000000..b563bba70 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__md.png new file mode 100644 index 000000000..2ef93d275 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__sm.png new file mode 100644 index 000000000..e458fcba2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__resetting-to-blank__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__lg.png new file mode 100644 index 000000000..3910a39f2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__md.png new file mode 100644 index 000000000..7300fdc48 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__sm.png new file mode 100644 index 000000000..ddc0c3cec Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__device__device-pane__server-disconnected-link-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__error.png b/lp-app/lpa-studio-web/story-images/studio__error.png deleted file mode 100644 index 96d961c8e..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__error.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png new file mode 100644 index 000000000..5bc0309f5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png new file mode 100644 index 000000000..d14e5446a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png new file mode 100644 index 000000000..3c6d5733f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__action-error__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__lg.png new file mode 100644 index 000000000..b2feb4152 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__md.png new file mode 100644 index 000000000..1cca03fbe Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__sm.png new file mode 100644 index 000000000..497ba0c18 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__editor-shell__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__lg.png new file mode 100644 index 000000000..1dd266253 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__md.png new file mode 100644 index 000000000..e1c8250ae Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png new file mode 100644 index 000000000..28f9953bd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png new file mode 100644 index 000000000..c096d7fe3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png new file mode 100644 index 000000000..a8380dea3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png new file mode 100644 index 000000000..39b4613dd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-endpoint__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png new file mode 100644 index 000000000..95d097ab5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png new file mode 100644 index 000000000..1b8dccfd1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png new file mode 100644 index 000000000..e5893ca3b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-idle__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png new file mode 100644 index 000000000..fc43dbaa2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png new file mode 100644 index 000000000..a6230f580 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png new file mode 100644 index 000000000..6086fd78f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png new file mode 100644 index 000000000..2234f9265 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png new file mode 100644 index 000000000..2aaf3d0da Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png new file mode 100644 index 000000000..c9653493f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__layout__studio-shell__simulator-starting__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__lg.png new file mode 100644 index 000000000..1a9ca3e07 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__md.png new file mode 100644 index 000000000..9b9ca7d95 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__sm.png new file mode 100644 index 000000000..b7582e39f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__all-states__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__lg.png new file mode 100644 index 000000000..02272b70e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__md.png new file mode 100644 index 000000000..bd025ef2b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__sm.png new file mode 100644 index 000000000..c134ca1cf Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__bound-value__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__lg.png new file mode 100644 index 000000000..f2e63bb0b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__md.png new file mode 100644 index 000000000..e1ab84cf2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__sm.png new file mode 100644 index 000000000..c33a36758 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__direct-value__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__lg.png new file mode 100644 index 000000000..8be2b0e0a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__md.png new file mode 100644 index 000000000..5020aafa7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__sm.png new file mode 100644 index 000000000..c66673b4e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__edited-value__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__lg.png new file mode 100644 index 000000000..ac3a7750e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__md.png new file mode 100644 index 000000000..ea5ad514f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__sm.png new file mode 100644 index 000000000..ca7fcd671 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__info-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__lg.png new file mode 100644 index 000000000..d7279f33c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__md.png new file mode 100644 index 000000000..9a576a7b6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__sm.png new file mode 100644 index 000000000..7f59b163a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__invalid-value__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__lg.png new file mode 100644 index 000000000..7cc7731e7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__md.png new file mode 100644 index 000000000..970e260b7 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__sm.png new file mode 100644 index 000000000..99e4a67d6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-record-included__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__lg.png new file mode 100644 index 000000000..40c457f5b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__md.png new file mode 100644 index 000000000..762488b6f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__sm.png new file mode 100644 index 000000000..bf3c5a673 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-excluded__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__lg.png new file mode 100644 index 000000000..ca5101860 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__md.png new file mode 100644 index 000000000..8522e8400 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__sm.png new file mode 100644 index 000000000..6eab6733b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__optional-scalar-included__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png new file mode 100644 index 000000000..78314f471 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__md.png new file mode 100644 index 000000000..e24d98241 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__sm.png new file mode 100644 index 000000000..52b13db7a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__lg.png new file mode 100644 index 000000000..8ff881ddc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__md.png new file mode 100644 index 000000000..d587d722c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__sm.png new file mode 100644 index 000000000..203249957 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__record-row__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__lg.png new file mode 100644 index 000000000..e69d98db4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__md.png new file mode 100644 index 000000000..4a46c9d58 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__sm.png new file mode 100644 index 000000000..7daf08c96 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__unset-value__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__lg.png new file mode 100644 index 000000000..a8859d4dd Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__md.png new file mode 100644 index 000000000..bfca0a5a0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__sm.png new file mode 100644 index 000000000..fe3760002 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__config-slot-row__write-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__lg.png new file mode 100644 index 000000000..8afc8256d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__md.png new file mode 100644 index 000000000..6f2532255 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__sm.png new file mode 100644 index 000000000..ac389db5e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__collapsed-node-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png new file mode 100644 index 000000000..ed2290e4f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png new file mode 100644 index 000000000..5553b65c2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png new file mode 100644 index 000000000..1e35f045e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__error-node__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png new file mode 100644 index 000000000..8918ffe3a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png new file mode 100644 index 000000000..79955aea5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png new file mode 100644 index 000000000..ced39b060 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__node-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__lg.png new file mode 100644 index 000000000..f6ade43e2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__md.png new file mode 100644 index 000000000..4e5c15a4f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__node__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__sm.png new file mode 100644 index 000000000..6fddc052d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__node__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__lg.png new file mode 100644 index 000000000..15c03fe9a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__md.png new file mode 100644 index 000000000..ad39640e1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__sm.png new file mode 100644 index 000000000..e475ea3c3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-error__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__lg.png new file mode 100644 index 000000000..f49239f50 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__md.png new file mode 100644 index 000000000..89a050d62 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__sm.png new file mode 100644 index 000000000..a914426ea Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-loaded__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__lg.png new file mode 100644 index 000000000..8976a44f0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__md.png new file mode 100644 index 000000000..e78e69f74 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__sm.png new file mode 100644 index 000000000..23b05f4e4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-paused__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__lg.png new file mode 100644 index 000000000..8b3138c26 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__md.png new file mode 100644 index 000000000..12f8ffa7a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__sm.png new file mode 100644 index 000000000..869dae170 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-pending__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__lg.png new file mode 100644 index 000000000..b82d044ae Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__md.png new file mode 100644 index 000000000..baba7fd10 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__sm.png new file mode 100644 index 000000000..415f0d043 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-unsupported__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__lg.png new file mode 100644 index 000000000..71ef56b43 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__md.png new file mode 100644 index 000000000..be16151aa Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__sm.png new file mode 100644 index 000000000..219edaf7c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__control-untracked__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__lg.png new file mode 100644 index 000000000..d29a7b7c9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__md.png new file mode 100644 index 000000000..7213933d8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__sm.png new file mode 100644 index 000000000..2360c8228 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__lg.png new file mode 100644 index 000000000..957fdef74 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__md.png new file mode 100644 index 000000000..bc06b2cf2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__sm.png new file mode 100644 index 000000000..d73176261 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__empty-product__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__lg.png new file mode 100644 index 000000000..e40bea04b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__md.png new file mode 100644 index 000000000..326c67529 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__sm.png new file mode 100644 index 000000000..67a3f0426 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__lg.png new file mode 100644 index 000000000..aec5e618f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__md.png new file mode 100644 index 000000000..457d7bc4b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__sm.png new file mode 100644 index 000000000..be24eeb84 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__lg.png new file mode 100644 index 000000000..75d43b6e1 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__md.png new file mode 100644 index 000000000..05cf1e925 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__sm.png new file mode 100644 index 000000000..601815c27 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-error__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__lg.png new file mode 100644 index 000000000..b79815f06 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__md.png new file mode 100644 index 000000000..60b38953b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__sm.png new file mode 100644 index 000000000..73ee87eee Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-loaded__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__lg.png new file mode 100644 index 000000000..b15ce02ab Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__md.png new file mode 100644 index 000000000..cc6b47259 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__sm.png new file mode 100644 index 000000000..33363d2d2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-paused__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__lg.png new file mode 100644 index 000000000..f160dc2ef Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__md.png new file mode 100644 index 000000000..1df341f26 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__sm.png new file mode 100644 index 000000000..3e3db9f62 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-pending__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__lg.png new file mode 100644 index 000000000..e9a0e398d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__md.png new file mode 100644 index 000000000..61b5d740a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__sm.png new file mode 100644 index 000000000..9b5fd8b86 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-product__visual-untracked__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__lg.png new file mode 100644 index 000000000..5bfc83e7c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__md.png new file mode 100644 index 000000000..70f423e85 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__sm.png new file mode 100644 index 000000000..c34c877f0 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__bound-stat__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__lg.png new file mode 100644 index 000000000..4880e0d4b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__md.png new file mode 100644 index 000000000..3abebda29 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__sm.png new file mode 100644 index 000000000..ce1a02f9e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__detail-popup__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__lg.png new file mode 100644 index 000000000..828ccdc04 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__md.png new file mode 100644 index 000000000..4e18b0bbf Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__sm.png new file mode 100644 index 000000000..14b3f5631 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__lg.png new file mode 100644 index 000000000..166b08020 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__md.png new file mode 100644 index 000000000..75e664f13 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__sm.png new file mode 100644 index 000000000..5f286774c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__numeric-stat__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__lg.png new file mode 100644 index 000000000..434c98159 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__md.png new file mode 100644 index 000000000..c3e4d7b01 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__sm.png new file mode 100644 index 000000000..1441ad07c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__produced-value__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__lg.png new file mode 100644 index 000000000..7027954f6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__md.png new file mode 100644 index 000000000..6920a9cac Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__sm.png new file mode 100644 index 000000000..e69af7129 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__asset-editor__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__lg.png new file mode 100644 index 000000000..9e37a05ec Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__md.png new file mode 100644 index 000000000..5ab5eb3f2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__sm.png new file mode 100644 index 000000000..5d10a0a5f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__lg.png new file mode 100644 index 000000000..40eb99102 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__md.png new file mode 100644 index 000000000..67a44216c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__sm.png new file mode 100644 index 000000000..daa58b015 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__nested-record__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__lg.png new file mode 100644 index 000000000..ad8b1f346 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__md.png new file mode 100644 index 000000000..fa482069b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__sm.png new file mode 100644 index 000000000..ca0f05940 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-record-editor__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__lg.png new file mode 100644 index 000000000..b77c72037 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__md.png new file mode 100644 index 000000000..b5b8fbc66 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__sm.png new file mode 100644 index 000000000..45f8ce11f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__lg.png new file mode 100644 index 000000000..83b5c1fe5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__md.png new file mode 100644 index 000000000..dce37731a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__sm.png new file mode 100644 index 000000000..fd7919f0d Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__lg.png new file mode 100644 index 000000000..4e560bea8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__md.png new file mode 100644 index 000000000..00584acc3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__sm.png new file mode 100644 index 000000000..9113008ed Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__record-shape__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__lg.png new file mode 100644 index 000000000..20c1b111f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__md.png new file mode 100644 index 000000000..3c7c07e13 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__sm.png new file mode 100644 index 000000000..2550ebe20 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-shape-display__verbose-record__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__lg.png new file mode 100644 index 000000000..4ee870735 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__md.png new file mode 100644 index 000000000..64e50bd3a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__sm.png new file mode 100644 index 000000000..a1805bb37 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__lg.png new file mode 100644 index 000000000..4ac175f3c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__md.png new file mode 100644 index 000000000..a395300ea Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__sm.png new file mode 100644 index 000000000..f2795f5bb Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__lg.png new file mode 100644 index 000000000..68c31f264 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__md.png new file mode 100644 index 000000000..a966627be Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__sm.png new file mode 100644 index 000000000..ca284b196 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-unit-display__suffix-spacing__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__lg.png new file mode 100644 index 000000000..ab5d14c70 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__md.png new file mode 100644 index 000000000..71af469e6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__sm.png new file mode 100644 index 000000000..d54d68505 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__bool-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__lg.png new file mode 100644 index 000000000..5e45f13ce Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__md.png new file mode 100644 index 000000000..870c5ab1f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__sm.png new file mode 100644 index 000000000..53c454fe9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__dropdown-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__lg.png new file mode 100644 index 000000000..5c9523277 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__md.png new file mode 100644 index 000000000..8dd26f4d8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__sm.png new file mode 100644 index 000000000..19de3678e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__float-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__lg.png new file mode 100644 index 000000000..7f3f142f4 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__md.png new file mode 100644 index 000000000..a49ce699b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__sm.png new file mode 100644 index 000000000..4d54ff251 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__gallery__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__lg.png new file mode 100644 index 000000000..e9246e348 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__md.png new file mode 100644 index 000000000..13081c179 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__sm.png new file mode 100644 index 000000000..e597ca100 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__int-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__lg.png new file mode 100644 index 000000000..08e80e2cc Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__md.png new file mode 100644 index 000000000..e471b9544 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__sm.png new file mode 100644 index 000000000..30e588bda Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__invalid-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png new file mode 100644 index 000000000..83e4b5c2b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png new file mode 100644 index 000000000..5920c41b3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__sm.png new file mode 100644 index 000000000..b7c830269 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__lg.png new file mode 100644 index 000000000..ef70af987 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__md.png new file mode 100644 index 000000000..4d40ba650 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__sm.png new file mode 100644 index 000000000..67f29a01f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__slider-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__lg.png new file mode 100644 index 000000000..e7f11a509 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__md.png new file mode 100644 index 000000000..32fa2068c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__sm.png new file mode 100644 index 000000000..de6152776 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__string-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__lg.png new file mode 100644 index 000000000..e71cab74f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__md.png new file mode 100644 index 000000000..4c84a9ee9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__sm.png new file mode 100644 index 000000000..f78cd2cb5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__uint-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__lg.png new file mode 100644 index 000000000..16606c625 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__md.png new file mode 100644 index 000000000..3458504be Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__sm.png new file mode 100644 index 000000000..c74ba3213 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec2-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__lg.png new file mode 100644 index 000000000..fc54988ba Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__md.png new file mode 100644 index 000000000..b867b6daf Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__sm.png new file mode 100644 index 000000000..4f8a465e6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__vec3-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__lg.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__lg.png new file mode 100644 index 000000000..afa86bb5c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__md.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__md.png new file mode 100644 index 000000000..c0be805c6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__sm.png b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__sm.png new file mode 100644 index 000000000..a22a7e173 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__node__slot-value-editor__xy-field__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__panes__device.png b/lp-app/lpa-studio-web/story-images/studio__panes__device.png deleted file mode 100644 index dbfccda9a..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__panes__device.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__panes__project.png b/lp-app/lpa-studio-web/story-images/studio__panes__project.png deleted file mode 100644 index b2a54dbbc..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__panes__project.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project-ready.png b/lp-app/lpa-studio-web/story-images/studio__project-ready.png deleted file mode 100644 index 255ac69d7..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__project-ready.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__lg.png new file mode 100644 index 000000000..df1ab0f68 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__md.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__md.png new file mode 100644 index 000000000..c8547b6cf Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__sm.png new file mode 100644 index 000000000..1e2d29a4a Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__editor-fields__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__lg.png new file mode 100644 index 000000000..f8bfdd700 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__md.png new file mode 100644 index 000000000..160834ad9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__sm.png new file mode 100644 index 000000000..5699d125e Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__editor-fields__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png new file mode 100644 index 000000000..daf87ea44 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png new file mode 100644 index 000000000..0d04c1b81 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png new file mode 100644 index 000000000..c4ee11ea6 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-empty__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png new file mode 100644 index 000000000..e40f1f3f3 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png new file mode 100644 index 000000000..72b678fc5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png new file mode 100644 index 000000000..b9715b2f9 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__device-project-selection__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__lg.png new file mode 100644 index 000000000..1a69734c5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__md.png new file mode 100644 index 000000000..0dffb9d00 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__sm.png new file mode 100644 index 000000000..b5a486570 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__overview__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__lg.png new file mode 100644 index 000000000..8243ab078 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__md.png new file mode 100644 index 000000000..32a7952c2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__sm.png new file mode 100644 index 000000000..233e8a78c Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-pane__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png new file mode 100644 index 000000000..8a3a978f5 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png new file mode 100644 index 000000000..ef912d223 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png new file mode 100644 index 000000000..ce6bcb2e8 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-ready__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png new file mode 100644 index 000000000..900bd346b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png new file mode 100644 index 000000000..d4cd50fe2 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png new file mode 100644 index 000000000..9ad6a43fa Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-sync-failed__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png new file mode 100644 index 000000000..4980a053f Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__lg.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png new file mode 100644 index 000000000..d374c720b Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__md.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png new file mode 100644 index 000000000..240e1ef13 Binary files /dev/null and b/lp-app/lpa-studio-web/story-images/studio__project__project-workspace__project-syncing__sm.png differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provision-failed.png b/lp-app/lpa-studio-web/story-images/studio__provision-failed.png deleted file mode 100644 index 31dcdc16b..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__provision-failed.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provision-ready.png b/lp-app/lpa-studio-web/story-images/studio__provision-ready.png deleted file mode 100644 index 604de65d4..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__provision-ready.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__provisioning.png b/lp-app/lpa-studio-web/story-images/studio__provisioning.png deleted file mode 100644 index 5023efd75..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__provisioning.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__reset-complete.png b/lp-app/lpa-studio-web/story-images/studio__reset-complete.png deleted file mode 100644 index 6c9d9a25e..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__reset-complete.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png b/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png deleted file mode 100644 index 0be6933a5..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__resetting-to-blank.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png b/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png deleted file mode 100644 index 17d1b70bb..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__server-disconnected-link-ready.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png b/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png deleted file mode 100644 index 82acee47c..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__simulator-endpoint.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png b/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png deleted file mode 100644 index ed69fb1f6..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__simulator-idle.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png b/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png deleted file mode 100644 index ac1807e2d..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__simulator-ready.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png b/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png deleted file mode 100644 index 29c81ac49..000000000 Binary files a/lp-app/lpa-studio-web/story-images/studio__simulator-starting.png and /dev/null differ diff --git a/lp-app/lpa-studio-web/tailwind.css b/lp-app/lpa-studio-web/tailwind.css new file mode 100644 index 000000000..af288faaf --- /dev/null +++ b/lp-app/lpa-studio-web/tailwind.css @@ -0,0 +1,63 @@ +@layer theme, base, components, utilities; +@import "tailwindcss/theme.css" layer(theme) prefix(tw); +@import "tailwindcss/utilities.css" layer(utilities) prefix(tw); +@source "./src/**/*.{rs,html,css}"; + +@theme { + --font-sans: var(--studio-font-sans); + --font-mono: var(--studio-font-mono); + + --color-background: var(--studio-color-bg); + --color-background-wash: var(--studio-color-bg-wash); + --color-foreground: var(--studio-color-text); + --color-card: var(--studio-color-surface); + --color-card-subtle: var(--studio-color-surface-subtle); + --color-card-muted: var(--studio-color-surface-muted); + --color-card-raised: var(--studio-color-surface-raised); + --color-card-raised-strong: var(--studio-color-surface-raised-strong); + --color-panel-primary: var(--studio-color-panel-primary); + --color-terminal: var(--studio-color-terminal); + --color-track: var(--studio-color-track); + + --color-border: var(--studio-color-border); + --color-border-muted: var(--studio-color-border-muted); + --color-border-subtle: var(--studio-color-border-subtle); + --color-border-strong: var(--studio-color-border-strong); + --color-panel-primary-border: var(--studio-color-panel-primary-border); + + --color-strong-foreground: var(--studio-color-text-strong); + --color-soft-foreground: var(--studio-color-text-soft); + --color-muted-foreground: var(--studio-color-text-muted); + --color-subtle-foreground: var(--studio-color-text-subtle); + --color-dim-foreground: var(--studio-color-text-dim); + --color-heading: var(--studio-color-heading); + --color-accent: var(--studio-color-accent); + --color-accent-border: var(--studio-color-accent-border); + --color-accent-hover: var(--studio-color-accent-hover); + --color-accent-foreground: var(--studio-color-accent-text-on-fill); + + --color-status-neutral-bg: var(--studio-status-neutral-bg); + --color-status-neutral-border: var(--studio-status-neutral-border); + --color-status-neutral-foreground: var(--studio-status-neutral-text); + --color-status-working-bg: var(--studio-status-working-bg); + --color-status-working-border: var(--studio-status-working-border); + --color-status-working-foreground: var(--studio-status-working-text); + --color-status-good-bg: var(--studio-status-good-bg); + --color-status-good-border: var(--studio-status-good-border); + --color-status-good-foreground: var(--studio-status-good-text); + --color-status-warning-bg: var(--studio-status-warning-bg); + --color-status-warning-border: var(--studio-status-warning-border); + --color-status-warning-foreground: var(--studio-status-warning-text); + --color-status-error-bg: var(--studio-status-error-bg); + --color-status-error-border: var(--studio-status-error-border); + --color-status-error-foreground: var(--studio-status-error-text); + + --color-step-marker: var(--studio-step-marker-bg); + --color-step-active: var(--studio-step-active-bg); + --color-step-active-border: var(--studio-step-active-border); + + --radius-xs: var(--studio-radius-xs); + --radius-sm: var(--studio-radius-sm); + --radius-md: var(--studio-radius-md); + --radius-pill: var(--studio-radius-pill); +} diff --git a/lp-cli/src/debug_ui/ui.rs b/lp-cli/src/debug_ui/ui.rs index 6535e8d28..0b0a84940 100644 --- a/lp-cli/src/debug_ui/ui.rs +++ b/lp-cli/src/debug_ui/ui.rs @@ -35,6 +35,10 @@ const TARGET_UI_FPS: u64 = 30; const TARGET_UI_FRAME_MS: u64 = 1000 / TARGET_UI_FPS; const PROJECT_POLL_INTERVAL: Duration = Duration::from_millis(TARGET_UI_FRAME_MS); const UI_REPAINT_INTERVAL: Duration = Duration::from_millis(TARGET_UI_FRAME_MS); +// Keep shape pages small. Some shape definitions include other shapes and can +// overflow the firmware's 16KB internal JSON buffer, which has caused project +// sync parse errors/crashes. Raise this only after the server buffer/streaming +// limitation is fixed. const SHAPE_SYNC_PAGE_LIMIT: u32 = 4; /// Debug UI application state. @@ -336,6 +340,7 @@ fn node_id_from_def_root(root: &str) -> Option { fn render_product_probe(probe: &ProjectProbeResult) -> Option<&RenderProductProbeResult> { match probe { ProjectProbeResult::RenderProduct(probe) => Some(probe), + ProjectProbeResult::ControlProduct(_) => None, ProjectProbeResult::ExplainSlot(_) => None, } } diff --git a/lp-core/lpc-engine/examples/dump_studio_node_ui_story_data.rs b/lp-core/lpc-engine/examples/dump_studio_node_ui_story_data.rs new file mode 100644 index 000000000..37cfd8c95 --- /dev/null +++ b/lp-core/lpc-engine/examples/dump_studio_node_ui_story_data.rs @@ -0,0 +1,329 @@ +//! Dump real project-read data for the Studio node UI story spike. +//! +//! This is intentionally a small development tool instead of story-only mock +//! data. The stories can then show the exact shape and slot JSON they are using +//! as grounding material while the visual design is still exploratory. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::sync::Arc; + +use lpc_engine::{ButtonService, EngineServices, Graphics, ProjectLoader, RadioService}; +use lpc_hardware::{HardwareSystem, HwRegistry, default_esp32c6_hardware_manifest}; +use lpc_model::{ + NodeId, Revision, SlotShapeEntry, SlotShapeId, SlotShapeRegistrySnapshot, TreePath, +}; +use lpc_wire::{ + ProjectReadRequest, ProjectReadResult, WireSlotRootSnapshot, WireSlotRootsSnapshot, + WireTreeDelta, +}; +use lpfs::{LpFsMemory, LpPath}; +use serde::Serialize; + +const STORY_NODES: &[StoryNodeSpec] = &[ + StoryNodeSpec { + slug: "clock", + title: "Clock", + path: "/fyeah_sign.show/clock.clock", + }, + StoryNodeSpec { + slug: "fixture", + title: "Fixture", + path: "/fyeah_sign.show/fixture.fixture", + }, + StoryNodeSpec { + slug: "shader", + title: "blast", + path: "/fyeah_sign.show/playlist.playlist/blast.shader", + }, + StoryNodeSpec { + slug: "playlist", + title: "Playlist", + path: "/fyeah_sign.show/playlist.playlist", + }, +]; + +fn main() -> Result<(), Box> { + let args = Args::parse()?; + fs::create_dir_all(&args.out)?; + + let fs = load_project_files(&args.project)?; + let mut services = EngineServices::new(TreePath::parse("/fyeah_sign.show")?); + let registry = Rc::new(HwRegistry::new(default_esp32c6_hardware_manifest())); + let hardware = Rc::new(HardwareSystem::with_virtual_drivers(registry)); + let button_service: Rc = hardware.clone(); + let radio_service: Rc = hardware; + services.set_button_service(Some(button_service)); + services.set_radio_service(Some(radio_service)); + + let mut runtime = ProjectLoader::load_from_root(&fs, services)?; + runtime.set_graphics(Some(Arc::new(Graphics::new()))); + for _ in 0..102 { + runtime.tick(33)?; + } + + let response = runtime.read_project(ProjectReadRequest::default_debug(None)); + let mut shape_registry = None; + let mut roots = Vec::new(); + let mut node_refs = BTreeMap::new(); + + for result in response.results { + match result { + ProjectReadResult::Shapes(result) => { + shape_registry = result.registry; + } + ProjectReadResult::Nodes(result) => { + if let Some(snapshot) = result.slots { + roots = snapshot.roots; + } + for delta in result.tree_deltas { + if let WireTreeDelta::Created { id, path, .. } = delta { + node_refs.insert(path.to_string(), id); + } + } + } + ProjectReadResult::Resources(_) | ProjectReadResult::Runtime(_) => {} + } + } + + let shape_registry = shape_registry.ok_or("project read did not include shape registry")?; + + for spec in STORY_NODES { + let node_id = *node_refs + .get(spec.path) + .ok_or_else(|| format!("project read did not include node {}", spec.path))?; + let node_roots = roots_for_node(&roots, node_id); + if node_roots.is_empty() { + return Err(format!("node {} had no slot roots", spec.path).into()); + } + + let shape_json = StoryShapeJson { + source: StorySource::new(&args.project, response.revision.as_i64()), + node: StoryNodeRef::new(spec, node_id), + root_shapes: root_shape_refs(&node_roots), + registry: shape_registry_for_roots(&shape_registry, &node_roots), + }; + write_json( + &args.out.join(format!("{}.shape.json", spec.slug)), + &shape_json, + )?; + + let slot_json = StorySlotJson { + source: StorySource::new(&args.project, response.revision.as_i64()), + node: StoryNodeRef::new(spec, node_id), + roots: WireSlotRootsSnapshot { + roots: node_roots.into_iter().cloned().collect(), + }, + }; + write_json( + &args.out.join(format!("{}.slots.json", spec.slug)), + &slot_json, + )?; + } + + Ok(()) +} + +fn load_project_files(root: &Path) -> Result> { + let mut fs = LpFsMemory::new(); + for (path, bytes) in read_project_files(root)? { + fs.write_file_mut(LpPath::new(&path), &bytes) + .map_err(|error| format!("write project file {path}: {error}"))?; + } + Ok(fs) +} + +fn read_project_files(root: &Path) -> Result>, std::io::Error> { + let mut files = BTreeMap::new(); + read_project_files_recursive(root, root, &mut files)?; + Ok(files) +} + +fn read_project_files_recursive( + root: &Path, + dir: &Path, + files: &mut BTreeMap>, +) -> Result<(), std::io::Error> { + let mut entries = fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + entries.sort(); + + for path in entries { + if path.is_dir() { + read_project_files_recursive(root, &path, files)?; + continue; + } + + let relative = path.strip_prefix(root).expect("project-relative path"); + let project_path = format!("/{}", relative.to_string_lossy()); + files.insert(project_path, fs::read(&path)?); + } + + Ok(()) +} + +fn roots_for_node(roots: &[WireSlotRootSnapshot], node_id: NodeId) -> Vec<&WireSlotRootSnapshot> { + let prefix = format!("node.{node_id}."); + roots + .iter() + .filter(|root| root.name.starts_with(&prefix)) + .collect() +} + +fn root_shape_refs<'a>(roots: &'a [&'a WireSlotRootSnapshot]) -> Vec> { + roots + .iter() + .map(|root| StoryRootShape { + name: root.name.as_str(), + shape: root.shape, + }) + .collect() +} + +fn shape_registry_for_roots<'a>( + registry: &'a SlotShapeRegistrySnapshot, + roots: &[&WireSlotRootSnapshot], +) -> StoryShapeRegistry<'a> { + let mut queue = roots.iter().map(|root| root.shape).collect::>(); + let mut shapes = BTreeMap::new(); + let mut missing_refs = Vec::new(); + + while let Some(id) = queue.pop() { + if shapes.contains_key(&id) { + continue; + } + + let Some(entry) = registry.shapes.get(&id) else { + if !missing_refs.contains(&id) { + missing_refs.push(id); + } + continue; + }; + + queue.extend(entry.shape.referenced_shape_ids()); + shapes.insert(id, entry); + } + + missing_refs.sort(); + StoryShapeRegistry { + ids_revision: registry.ids_revision, + shapes, + missing_refs, + } +} + +fn write_json(path: &Path, value: &T) -> Result<(), Box> { + let json = serde_json::to_string_pretty(value)?; + fs::write(path, format!("{json}\n"))?; + Ok(()) +} + +struct Args { + project: PathBuf, + out: PathBuf, +} + +impl Args { + fn parse() -> Result> { + let mut project = PathBuf::from("projects/test/fyeah-sign"); + let mut out = PathBuf::from("lp-app/lpa-studio-web/src/stories/data/node_ui"); + let mut args = std::env::args().skip(1); + + while let Some(arg) = args.next() { + match arg.as_str() { + "--project" => { + project = args.next().ok_or("--project requires a path")?.into(); + } + "--out" => { + out = args.next().ok_or("--out requires a path")?.into(); + } + "--help" | "-h" => { + print_usage(); + std::process::exit(0); + } + _ => return Err(format!("unknown argument: {arg}").into()), + } + } + + Ok(Self { project, out }) + } +} + +fn print_usage() { + eprintln!( + "usage: cargo run -p lpc-engine --example dump_studio_node_ui_story_data -- \\\n+ [--project projects/test/fyeah-sign] \\\n+ [--out lp-app/lpa-studio-web/src/stories/data/node_ui]" + ); +} + +#[derive(Clone, Copy)] +struct StoryNodeSpec { + slug: &'static str, + title: &'static str, + path: &'static str, +} + +#[derive(Serialize)] +struct StorySource { + project: String, + read_revision: i64, + request: &'static str, +} + +impl StorySource { + fn new(project: &Path, read_revision: i64) -> Self { + Self { + project: project.display().to_string(), + read_revision, + request: "ProjectReadRequest::default_debug(None) after 102 x 33ms ticks", + } + } +} + +#[derive(Serialize)] +struct StoryNodeRef { + id: NodeId, + title: &'static str, + path: &'static str, +} + +impl StoryNodeRef { + fn new(spec: &StoryNodeSpec, id: NodeId) -> Self { + Self { + id, + title: spec.title, + path: spec.path, + } + } +} + +#[derive(Serialize)] +struct StoryRootShape<'a> { + name: &'a str, + shape: SlotShapeId, +} + +#[derive(Serialize)] +struct StoryShapeRegistry<'a> { + ids_revision: Revision, + shapes: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + missing_refs: Vec, +} + +#[derive(Serialize)] +struct StoryShapeJson<'a> { + source: StorySource, + node: StoryNodeRef, + root_shapes: Vec>, + registry: StoryShapeRegistry<'a>, +} + +#[derive(Serialize)] +struct StorySlotJson { + source: StorySource, + node: StoryNodeRef, + roots: WireSlotRootsSnapshot, +} diff --git a/lp-core/lpc-engine/src/engine/engine.rs b/lp-core/lpc-engine/src/engine/engine.rs index 467135411..59913c7e8 100644 --- a/lp-core/lpc-engine/src/engine/engine.rs +++ b/lp-core/lpc-engine/src/engine/engine.rs @@ -15,7 +15,7 @@ use lpc_model::{ }; use lpc_registry::ProjectRegistry; use lpc_shared::time::TimeProvider; -use lpc_wire::NodeRuntimeStatus; +use lpc_wire::{ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, NodeRuntimeStatus}; use crate::dataflow::binding::{BindingDraft, BindingError, BindingRef}; use crate::dataflow::bus::Bus; @@ -481,6 +481,34 @@ impl Engine { }; host.render_node_control(product, request, target) } + + pub(crate) fn render_control_product_probe( + &mut self, + registry: &ProjectRegistry, + product: ControlProduct, + request: &ControlRenderRequest, + target: ControlRenderTarget<'_>, + display_layout: ControlDisplayLayoutRead, + ) -> Result<(ControlLayout, ControlDisplayLayoutProbeResult), SessionResolveError> { + let mut producers_ticked = VecSet::new(); + let time_s = self.frame_time.total_ms as f32 / 1000.0; + let time_provider = self.services.time_provider(); + let button_service = self.services.button_service(); + let radio_service = self.services.radio_service(); + let mut host = EngineResolveHost { + tree: &mut self.tree, + registry, + producers_ticked: &mut producers_ticked, + runtime_buffers: &mut self.runtime_buffers, + slot_shapes: &self.slot_shapes, + graphics: self.graphics.clone(), + time_provider, + button_service, + radio_service, + frame_time_seconds: time_s, + }; + host.render_node_control_probe(product, request, target, display_layout) + } } /// Host adapter with borrows disjoint from the [`Resolver`] handed to [`EngineSession`]. @@ -1207,6 +1235,133 @@ impl EngineResolveHost<'_> { } } } + + fn render_node_control_probe( + &mut self, + product: ControlProduct, + request: &ControlRenderRequest, + target: ControlRenderTarget<'_>, + display_layout: ControlDisplayLayoutRead, + ) -> Result<(ControlLayout, ControlDisplayLayoutProbeResult), SessionResolveError> { + let node_id = product.node(); + let revision = current_revision(); + let mut node_runtime = { + let entry = self.tree.get_mut(node_id).ok_or_else(|| { + SessionResolveError::other(format!( + "control product probe: unknown node {node_id:?}" + )) + })?; + let old_changed_at = entry.state.changed_at(); + let executing = NodeEntryState::Executing { + call: NodeCallKey::new(node_id, NodeCall::Control { product }), + }; + let stolen = core::mem::replace( + &mut entry.state, + WithRevision::new(old_changed_at, executing), + ); + match stolen.into_value() { + NodeEntryState::Alive(n) => n, + NodeEntryState::Executing { call } => { + entry.state = WithRevision::new( + old_changed_at, + NodeEntryState::Executing { call: call.clone() }, + ); + return Err(SessionResolveError::other(format!( + "node {node_id:?} is already executing {}; re-entry through EngineSession is unsupported", + call.call.label() + ))); + } + other => { + entry.state = WithRevision::new(old_changed_at, other); + return Err(SessionResolveError::other(format!( + "control product probe: node {node_id:?} not alive" + ))); + } + } + }; + + let result = { + let Some(control_node) = node_runtime.control_node() else { + return restore_node_after_failed_control_probe( + self.tree, + node_id, + node_runtime, + revision, + SessionResolveError::other(format!( + "node {node_id:?} cannot render control product output {}: NodeRuntime::control_node() returned None", + product.output() + )), + ); + }; + let mut ctx = ControlRenderContext::new( + node_id, + revision, + self.graphics.clone(), + self.frame_time_seconds, + self, + ); + catch_node_panic(|| { + let sample_layout = + control_node.render_control(product, request, target, &mut ctx)?; + let display_layout = + control_display_layout_result(control_node, product, display_layout, &mut ctx)?; + Ok((sample_layout, display_layout)) + }) + }; + + let entry = self.tree.get_mut(node_id).ok_or_else(|| { + SessionResolveError::other(format!("control product probe: unknown node {node_id:?}")) + })?; + let runtime_status = runtime_status_or_ok(&*node_runtime); + entry.set_state(NodeEntryState::Alive(node_runtime), revision); + + match result { + Ok(probe) => { + set_entry_status_if_changed(entry, runtime_status, revision); + Ok(probe) + } + Err(e) => { + let message = e.to_string(); + set_entry_status_if_changed( + entry, + NodeRuntimeStatus::Error(message.clone()), + revision, + ); + Err(SessionResolveError::other(format!( + "control product probe: {message}" + ))) + } + } + } +} + +fn control_display_layout_result( + control_node: &mut dyn crate::node::ControlNode, + product: ControlProduct, + request: ControlDisplayLayoutRead, + ctx: &mut ControlRenderContext<'_>, +) -> Result { + match request { + ControlDisplayLayoutRead::None => Ok(ControlDisplayLayoutProbeResult::Omitted), + ControlDisplayLayoutRead::Always | ControlDisplayLayoutRead::IfChanged { .. } => { + let Some(layout) = control_node.control_display_layout(product, ctx)? else { + return Ok(ControlDisplayLayoutProbeResult::Unsupported { + reason: alloc::string::String::from( + "control product does not expose display layout", + ), + }); + }; + let revision = layout.revision(); + match request { + ControlDisplayLayoutRead::IfChanged { + known_revision: Some(known), + } if known == revision => { + Ok(ControlDisplayLayoutProbeResult::Unchanged { revision }) + } + _ => Ok(ControlDisplayLayoutProbeResult::Layout(layout)), + } + } + } } fn slot_path_semantics( @@ -1399,6 +1554,19 @@ fn restore_node_after_failed_control( Err(err) } +fn restore_node_after_failed_control_probe( + tree: &mut RuntimeNodeTree>, + node_id: NodeId, + node_runtime: Box, + revision: Revision, + err: SessionResolveError, +) -> Result<(ControlLayout, ControlDisplayLayoutProbeResult), SessionResolveError> { + if let Some(entry) = tree.get_mut(node_id) { + entry.set_state(NodeEntryState::Alive(node_runtime), revision); + } + Err(err) +} + fn consume_tree_node( session: &mut EngineSession<'_>, host: &mut EngineResolveHost<'_>, diff --git a/lp-core/lpc-engine/src/engine/project_read.rs b/lp-core/lpc-engine/src/engine/project_read.rs index 0f089a8a7..008d0233b 100644 --- a/lp-core/lpc-engine/src/engine/project_read.rs +++ b/lp-core/lpc-engine/src/engine/project_read.rs @@ -41,6 +41,9 @@ impl Engine { ProjectProbeRequest::RenderProduct(request) => ProjectProbeResult::RenderProduct( self.read_project_render_product_probe(registry, request), ), + ProjectProbeRequest::ControlProduct(request) => ProjectProbeResult::ControlProduct( + self.read_project_control_product_probe(registry, request), + ), ProjectProbeRequest::ExplainSlot(request) => { ProjectProbeResult::ExplainSlot(self.read_project_explain_slot_probe(request)) } diff --git a/lp-core/lpc-engine/src/engine/project_read_probes.rs b/lp-core/lpc-engine/src/engine/project_read_probes.rs index 3d77c36a9..c44b7b852 100644 --- a/lp-core/lpc-engine/src/engine/project_read_probes.rs +++ b/lp-core/lpc-engine/src/engine/project_read_probes.rs @@ -1,14 +1,18 @@ //! Project probe helpers. use alloc::format; +use alloc::vec; +use alloc::vec::Vec; use lpc_registry::ProjectRegistry; use lpc_wire::{ - ExplainSlotProbeRequest, ExplainSlotProbeResult, RenderProductProbeRequest, - RenderProductProbeResult, SlotExplanation, + ControlProductProbeRequest, ControlProductProbeResult, ExplainSlotProbeRequest, + ExplainSlotProbeResult, RenderProductProbeRequest, RenderProductProbeResult, SlotExplanation, + WireChannelSampleFormat, }; use lps_shared::TextureStorageFormat; +use crate::products::control::{ControlRenderRequest, ControlRenderTarget}; use crate::products::visual::RenderTextureRequest; use super::Engine; @@ -68,6 +72,62 @@ impl Engine { ), } } + + pub(super) fn read_project_control_product_probe( + &mut self, + registry: &ProjectRegistry, + request: ControlProductProbeRequest, + ) -> ControlProductProbeResult { + let product = request.product; + let extent = product.preferred_extent(); + let WireChannelSampleFormat::U16 = request.sample_format else { + return ControlProductProbeResult::Unsupported { + product, + reason: format!( + "control product preview sample format {:?} is not supported", + request.sample_format + ), + }; + }; + let sample_count = extent.sample_count() as usize; + let mut samples = vec![0u16; sample_count]; + let render_request = ControlRenderRequest::unorm16(extent); + let target = ControlRenderTarget::new( + extent, + crate::products::control::ControlSampleFormat::Unorm16, + samples.as_mut_slice(), + ); + let revision = self.revision(); + match self.render_control_product_probe( + registry, + product, + &render_request, + target, + request.display_layout, + ) { + Ok((sample_layout, display_layout)) => ControlProductProbeResult::Preview { + product, + revision, + extent, + sample_format: request.sample_format, + sample_layout, + display_layout, + bytes: control_samples_u16_to_bytes(&samples), + }, + Err(error) => ControlProductProbeResult::Error { + product, + message: format!("{error}"), + }, + } + } +} + +fn control_samples_u16_to_bytes(samples: &[u16]) -> Vec { + let mut bytes = Vec::with_capacity(samples.len() * 2); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + bytes } fn rgba16_linear_to_srgb8(bytes: &[u8]) -> alloc::vec::Vec { diff --git a/lp-core/lpc-engine/src/engine/project_read_stream.rs b/lp-core/lpc-engine/src/engine/project_read_stream.rs index 1f0bc8787..dc4c4beef 100644 --- a/lp-core/lpc-engine/src/engine/project_read_stream.rs +++ b/lp-core/lpc-engine/src/engine/project_read_stream.rs @@ -137,6 +137,10 @@ impl lpc_shared::transport::ProjectReadJsonSource for EngineProjectReadSource<'_ self.engine .read_project_render_product_probe(self.registry, request), ), + ProjectProbeRequest::ControlProduct(request) => ProjectProbeResult::ControlProduct( + self.engine + .read_project_control_product_probe(self.registry, request), + ), ProjectProbeRequest::ExplainSlot(request) => ProjectProbeResult::ExplainSlot( self.engine.read_project_explain_slot_probe(request), ), diff --git a/lp-core/lpc-engine/src/node/control_node.rs b/lp-core/lpc-engine/src/node/control_node.rs index c6f9e599b..932f797c4 100644 --- a/lp-core/lpc-engine/src/node/control_node.rs +++ b/lp-core/lpc-engine/src/node/control_node.rs @@ -1,5 +1,7 @@ //! Optional runtime capability for nodes that can materialize control products. +use lpc_model::ControlDisplayLayout; + use crate::products::control::{ ControlLayout, ControlProduct, ControlRenderRequest, ControlRenderTarget, }; @@ -15,4 +17,13 @@ pub trait ControlNode { target: ControlRenderTarget<'_>, ctx: &mut ControlRenderContext<'_>, ) -> Result; + + fn control_display_layout( + &mut self, + product: ControlProduct, + ctx: &mut ControlRenderContext<'_>, + ) -> Result, NodeError> { + let _ = (product, ctx); + Ok(None) + } } diff --git a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs index db670fca4..ee307eec8 100644 --- a/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs +++ b/lp-core/lpc-engine/src/nodes/fixture/fixture_node.rs @@ -9,8 +9,9 @@ use lpc_model::nodes::fixture::{ ColorOrder, FixtureDiagnosticMode, FixtureSamplingConfig, MappingConfig, PathSpec, RingOrder, }; use lpc_model::{ - ControlExtent, ControlProduct, Dim2u, FixtureDefView, FixtureState, Revision, SlotAccess, - SlotPath, SlotShapeRegistry, SlotShapeRegistryError, + ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlProduct, Dim2u, + FixtureDefView, FixtureState, Revision, SlotAccess, SlotPath, SlotShapeRegistry, + SlotShapeRegistryError, }; use lps_q32::q32::{Q32, ToQ32}; @@ -52,6 +53,7 @@ pub struct FixtureNode { /// `(width, height, mapping_ver)` key for cached precomputed pixel entries. precomputed: Option<(u32, u32, Revision, alloc::vec::Vec)>, direct_points: Option<(Revision, alloc::vec::Vec)>, + display_layout_revision: Option<(FixtureDisplayLayoutKey, Revision)>, } impl FixtureNode { @@ -75,6 +77,7 @@ impl FixtureNode { sample_target: None, precomputed: None, direct_points: None, + display_layout_revision: None, } } @@ -140,9 +143,30 @@ impl FixtureNode { self.mapping_version = ctx.revision(); self.precomputed = None; self.direct_points = None; + self.display_layout_revision = None; } Ok(()) } + + fn control_display_layout_revision( + &mut self, + settings: FixtureRenderSettings, + ctx: &ControlRenderContext<'_>, + ) -> Revision { + let key = FixtureDisplayLayoutKey { + mapping_version: self.mapping_version, + width: settings.width, + height: settings.height, + }; + match self.display_layout_revision { + Some((cached, revision)) if cached == key => revision, + _ => { + let revision = ctx.revision(); + self.display_layout_revision = Some((key, revision)); + revision + } + } + } } #[derive(Clone, Copy)] @@ -152,6 +176,13 @@ struct DirectSamplePoint { y_norm_q16: i32, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FixtureDisplayLayoutKey { + mapping_version: Revision, + width: u32, + height: u32, +} + pub fn fixture_input_path() -> SlotPath { SlotPath::parse("input").expect("fixture input path") } @@ -465,6 +496,38 @@ impl ControlNode for FixtureNode { settings.gamma_correction, ) } + + fn control_display_layout( + &mut self, + _product: ControlProduct, + ctx: &mut ControlRenderContext<'_>, + ) -> Result, NodeError> { + let settings = self + .last_settings + .ok_or_else(|| NodeError::msg("fixture display layout missing cached settings"))?; + let revision = self.control_display_layout_revision(settings, ctx); + let points = crate::nodes::fixture::mapping::generate_mapping_points( + &self.mapping, + settings.width, + settings.height, + ); + let lamps = points + .into_iter() + .map(|point| ControlLamp2d { + lamp_index: point.channel, + sample_start: point.channel.saturating_mul(3), + center: point.center, + radius: point.radius, + }) + .collect(); + + Ok(Some(ControlDisplayLayout::Layout2d(ControlLayout2d::new( + revision, + settings.width, + settings.height, + lamps, + )))) + } } fn ensure_fixture_render_target<'a>( @@ -631,7 +694,7 @@ fn render_direct_fixture_control( row: 0, start: 0, len: written_samples as u32, - hint: ControlHint::RgbPixels { + encoding: ControlHint::RgbPixels { count: (written_samples / 3) as u32, color_order: settings.color_order, }, @@ -705,7 +768,7 @@ fn render_fixture_diagnostic_control( row: 0, start: 0, len: (rendered_lamps * 3) as u32, - hint: ControlHint::RgbPixels { + encoding: ControlHint::RgbPixels { count: rendered_lamps as u32, color_order: settings.color_order, }, @@ -1030,7 +1093,7 @@ fn render_fixture_control_target( row: 0, start: 0, len: written_samples as u32, - hint: ControlHint::RgbPixels { + encoding: ControlHint::RgbPixels { count: (written_samples / 3) as u32, color_order, }, @@ -1144,7 +1207,11 @@ mod tests { use lpc_model::nodes::fixture::{PathSpec, RingOrder}; use lpc_model::{Dim2u, Kind, LpValue, ToLpValue, TreePath}; use lpc_registry::ProjectRegistry; - use lpc_wire::{WireChildKind, WireSlotIndex}; + use lpc_wire::{ + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, ProjectProbeRequest, ProjectProbeResult, ProjectReadRequest, + WireChannelSampleFormat, WireChildKind, WireSlotIndex, + }; use crate::dataflow::binding::{BindingDraft, BindingPriority, BindingSource, BindingTarget}; use crate::engine::{Engine, default_demand_input_path}; @@ -1940,6 +2007,188 @@ mod tests { assert_eq!(layout.spans[0].len, 3); } + #[test] + fn fixture_project_read_control_probe_returns_native_samples_and_cached_layout() { + let ticks = Arc::new(AtomicU32::new(0)); + let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); + let registry = ProjectRegistry::new(); + engine.set_graphics(Some(Arc::new(crate::Graphics::new()))); + let frame = Revision::new(1); + let root = engine.tree().root(); + let spine = test_placeholder_spine(); + + let sh_id = engine + .tree_mut() + .add_child( + root, + lpc_model::NodeName::parse("sh").unwrap(), + lpc_model::NodeName::parse("shader").unwrap(), + WireChildKind::Input { + source: WireSlotIndex(0), + }, + spine.clone(), + frame, + ) + .unwrap(); + + let out_path = shader_output_path(); + engine + .attach_runtime_node( + sh_id, + Box::new(FixtureTickCountSolidProducer { + state: ShaderState::new(VisualProduct::new(sh_id, 0)), + ticks: Arc::clone(&ticks), + color: [u16::MAX, 0, 0, u16::MAX], + }), + frame, + ) + .unwrap(); + + let mapping = MappingConfig::path_points_vec( + vec![PathSpec::ring_array_counts( + [0.5, 0.5], + 1.0, + 0, + 1, + &[1], + 0.0, + RingOrder::InnerFirst, + )], + 2.0, + ); + + let fix_id = engine + .tree_mut() + .add_child( + root, + lpc_model::NodeName::parse("fx").unwrap(), + lpc_model::NodeName::parse("fixture").unwrap(), + WireChildKind::Input { + source: WireSlotIndex(0), + }, + spine, + frame, + ) + .unwrap(); + + engine + .attach_runtime_node( + fix_id, + Box::new(FixtureNode::new( + fix_id, + mapping, + FixtureSamplingConfig::Direct, + frame, + )), + frame, + ) + .unwrap(); + bind_fixture_def_defaults(&mut engine, fix_id, frame); + engine + .add_binding( + BindingDraft { + source: BindingSource::ProducedSlot { + node: sh_id, + slot: out_path, + }, + target: BindingTarget::ConsumedSlot { + node: fix_id, + slot: fixture_input_path(), + }, + priority: BindingPriority::new(0), + kind: Kind::Color, + owner: fix_id, + }, + frame, + ) + .unwrap(); + engine + .add_binding( + BindingDraft { + source: BindingSource::Literal(LpValue::F32(0.0)), + target: BindingTarget::ConsumedSlot { + node: fix_id, + slot: default_demand_input_path(), + }, + priority: BindingPriority::new(0), + kind: Kind::Color, + owner: fix_id, + }, + frame, + ) + .unwrap(); + + engine.add_demand_root(fix_id); + engine.tick(®istry, 10).unwrap(); + + let extent = ControlExtent::new(1, 3); + let product = ControlProduct::new(fix_id, 0, extent); + let first = engine.read_project( + ®istry, + ProjectReadRequest { + since: None, + queries: vec![], + probes: vec![ProjectProbeRequest::ControlProduct( + ControlProductProbeRequest { + product, + sample_format: WireChannelSampleFormat::U16, + display_layout: ControlDisplayLayoutRead::Always, + }, + )], + }, + ); + + let ProjectProbeResult::ControlProduct(ControlProductProbeResult::Preview { + extent: returned_extent, + sample_format, + sample_layout, + display_layout: + ControlDisplayLayoutProbeResult::Layout(ControlDisplayLayout::Layout2d(layout)), + bytes, + .. + }) = &first.probes[0] + else { + panic!("expected fixture control preview with layout"); + }; + assert_eq!(*returned_extent, extent); + assert_eq!(*sample_format, WireChannelSampleFormat::U16); + assert_eq!(bytes, &[255, 255, 0, 0, 0, 0]); + assert_eq!(sample_layout.spans.len(), 1); + assert_eq!(sample_layout.spans[0].len, 3); + assert_eq!(layout.width_hint, 4); + assert_eq!(layout.height_hint, 4); + assert_eq!(layout.lamps.len(), 1); + assert_eq!(layout.lamps[0].sample_start, 0); + + let known_revision = layout.revision; + let second = engine.read_project( + ®istry, + ProjectReadRequest { + since: None, + queries: vec![], + probes: vec![ProjectProbeRequest::ControlProduct( + ControlProductProbeRequest { + product, + sample_format: WireChannelSampleFormat::U16, + display_layout: ControlDisplayLayoutRead::IfChanged { + known_revision: Some(known_revision), + }, + }, + )], + }, + ); + let ProjectProbeResult::ControlProduct(ControlProductProbeResult::Preview { + display_layout: ControlDisplayLayoutProbeResult::Unchanged { revision }, + bytes, + .. + }) = &second.probes[0] + else { + panic!("expected unchanged fixture display layout"); + }; + assert_eq!(*revision, known_revision); + assert_eq!(bytes, &[255, 255, 0, 0, 0, 0]); + } + #[test] fn fixture_direct_sampling_sends_pixel_space_points_and_output_size() { let mut engine = Engine::new(TreePath::parse("/show.t").unwrap()); diff --git a/lp-core/lpc-engine/src/products/control/control_layout.rs b/lp-core/lpc-engine/src/products/control/control_layout.rs index 789b067a7..2477dbc94 100644 --- a/lp-core/lpc-engine/src/products/control/control_layout.rs +++ b/lp-core/lpc-engine/src/products/control/control_layout.rs @@ -1,34 +1,9 @@ -//! Metadata describing rendered logical control samples. - -use alloc::vec::Vec; - -use lpc_model::ColorOrder; - -/// Debug/inspection metadata for one rendered control range. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ControlSpan { - pub row: u32, - pub start: u32, - pub len: u32, - pub hint: ControlHint, -} - -/// Semantic hint for interpreting a range of control samples. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ControlHint { - RgbPixels { count: u32, color_order: ColorOrder }, - Raw, -} - -/// Debug/inspection metadata returned after control rendering. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ControlLayout { - pub spans: Vec, -} - -impl ControlLayout { - #[must_use] - pub fn empty() -> Self { - Self { spans: Vec::new() } - } -} +//! Engine-facing aliases for core control sample layout metadata. +//! +//! The durable vocabulary lives in `lpc-model`. These aliases preserve the +//! existing engine names while the runtime moves onto the shared model types. + +pub use lpc_model::{ + ControlSampleEncoding as ControlHint, ControlSampleLayout as ControlLayout, + ControlSampleSpan as ControlSpan, +}; diff --git a/lp-core/lpc-model/src/lib.rs b/lp-core/lpc-model/src/lib.rs index 7dca1a986..f47c6a356 100644 --- a/lp-core/lpc-model/src/lib.rs +++ b/lp-core/lpc-model/src/lib.rs @@ -110,7 +110,11 @@ pub use nodes::{ ShaderValueShapeRef, TextureDef, TextureDefView, TextureFormat, TextureState, TextureStateView, generate_compute_shader_header, resolve_artifact_specifier, }; -pub use product::{ControlExtent, ControlProduct, ProductKind, ProductRef, VisualProduct}; +pub use product::{ + ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlProduct, + ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, ProductKind, ProductRef, + VisualProduct, +}; pub use project::overlay::{ ArtifactOverlay, AssetBodyOverlay, ProjectOverlay, SlotEdit, SlotEditOp, SlotOverlay, }; diff --git a/lp-core/lpc-model/src/product/mod.rs b/lp-core/lpc-model/src/product/mod.rs index 6a301831e..bfb01ff4f 100644 --- a/lp-core/lpc-model/src/product/mod.rs +++ b/lp-core/lpc-model/src/product/mod.rs @@ -7,6 +7,9 @@ pub mod product_ref; -pub use crate::products::control::{ControlExtent, ControlProduct}; +pub use crate::products::control::{ + ControlDisplayLayout, ControlExtent, ControlLamp2d, ControlLayout2d, ControlProduct, + ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan, +}; pub use crate::products::visual::VisualProduct; pub use product_ref::{ProductKind, ProductRef}; diff --git a/lp-core/lpc-model/src/products/control/control_display_layout.rs b/lp-core/lpc-model/src/products/control/control_display_layout.rs new file mode 100644 index 000000000..195c39633 --- /dev/null +++ b/lp-core/lpc-model/src/products/control/control_display_layout.rs @@ -0,0 +1,78 @@ +//! Optional human-facing control product display metadata. +//! +//! Display layout is distinct from sample layout. Sample layout describes the +//! native output buffer; display layout describes where logical lamps should be +//! drawn in a UI when a producer can provide that information. + +use alloc::vec::Vec; + +use crate::project::Revision; + +/// Optional control-product geometry for user-facing previews. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ControlDisplayLayout { + /// A normalized two-dimensional lamp layout. + Layout2d(ControlLayout2d), +} + +impl ControlDisplayLayout { + #[must_use] + pub const fn revision(&self) -> Revision { + match self { + Self::Layout2d(layout) => layout.revision, + } + } +} + +/// Normalized two-dimensional lamp display layout. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ControlLayout2d { + pub revision: Revision, + pub width_hint: u32, + pub height_hint: u32, + pub lamps: Vec, +} + +impl ControlLayout2d { + #[must_use] + pub const fn new( + revision: Revision, + width_hint: u32, + height_hint: u32, + lamps: Vec, + ) -> Self { + Self { + revision, + width_hint, + height_hint, + lamps, + } + } +} + +/// One logical lamp in a two-dimensional display layout. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ControlLamp2d { + pub lamp_index: u32, + pub sample_start: u32, + pub center: [f32; 2], + pub radius: f32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_layout_exposes_revision() { + let revision = Revision::new(9); + let layout = + ControlDisplayLayout::Layout2d(ControlLayout2d::new(revision, 16, 9, Vec::new())); + + assert_eq!(layout.revision(), revision); + } +} diff --git a/lp-core/lpc-model/src/products/control/control_sample_layout.rs b/lp-core/lpc-model/src/products/control/control_sample_layout.rs new file mode 100644 index 000000000..efdbb9f05 --- /dev/null +++ b/lp-core/lpc-model/src/products/control/control_sample_layout.rs @@ -0,0 +1,54 @@ +//! Native control sample interpretation metadata. +//! +//! A control product renders the same sample buffer that an output consumes. +//! The sample layout explains how to interpret ranges of that native buffer +//! without changing the bytes into a display-only preview format. + +use alloc::vec::Vec; + +use crate::ColorOrder; + +/// Metadata describing how native control samples are grouped. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ControlSampleLayout { + pub spans: Vec, +} + +impl ControlSampleLayout { + #[must_use] + pub const fn empty() -> Self { + Self { spans: Vec::new() } + } +} + +/// A contiguous range in a native control sample buffer. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ControlSampleSpan { + pub row: u32, + pub start: u32, + pub len: u32, + pub encoding: ControlSampleEncoding, +} + +/// How to interpret a native control sample range. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ControlSampleEncoding { + /// A run of RGB lamps stored in the product's native RGB channel order. + RgbPixels { count: u32, color_order: ColorOrder }, + /// Samples with no known higher-level display interpretation. + Raw, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_sample_layout_has_no_spans() { + assert!(ControlSampleLayout::empty().spans.is_empty()); + } +} diff --git a/lp-core/lpc-model/src/products/control/mod.rs b/lp-core/lpc-model/src/products/control/mod.rs index 96c5760ee..5f1324776 100644 --- a/lp-core/lpc-model/src/products/control/mod.rs +++ b/lp-core/lpc-model/src/products/control/mod.rs @@ -1,5 +1,9 @@ //! Logical control product values. +mod control_display_layout; mod control_product; +mod control_sample_layout; +pub use control_display_layout::{ControlDisplayLayout, ControlLamp2d, ControlLayout2d}; pub use control_product::{ControlExtent, ControlProduct}; +pub use control_sample_layout::{ControlSampleEncoding, ControlSampleLayout, ControlSampleSpan}; diff --git a/lp-core/lpc-view/README.md b/lp-core/lpc-view/README.md index cb3c13713..6f5e8d155 100644 --- a/lp-core/lpc-view/README.md +++ b/lp-core/lpc-view/README.md @@ -13,6 +13,10 @@ It should depend on `lpc-model` and `lpc-wire`, not on `lps-shared`. Client property views use portable `LpValue` from wire updates, not runtime shader values (`LpsValueF32`). +Project reads retain the latest runtime status alongside the structural project +mirror. UI/controller layers can therefore summarize frame counters, runtime +buffers, and server memory without owning protocol response details themselves. + **Naming:** Structures that mirror engine state locally use natural `*View` suffixes (`ProjectView`, `NodeTreeView`, `PropAccessView`, `PropsMapView`, …). Reserve `Client*` for genuine client API types (for example `ClientApi`), not diff --git a/lp-core/lpc-view/src/project/apply_project_read.rs b/lp-core/lpc-view/src/project/apply_project_read.rs index 5d056da41..27fac2d00 100644 --- a/lp-core/lpc-view/src/project/apply_project_read.rs +++ b/lp-core/lpc-view/src/project/apply_project_read.rs @@ -64,7 +64,9 @@ pub fn apply_project_read_response( view.resource_cache .apply_runtime_buffer_payloads(&resources.runtime_buffer_payloads); } - ProjectReadResult::Runtime(_) => {} + ProjectReadResult::Runtime(runtime) => { + view.runtime = Some(runtime); + } } } view.revision = revision; @@ -128,4 +130,49 @@ mod tests { assert_eq!(view.revision, Revision::new(1)); } + + #[test] + fn apply_project_read_retains_runtime_status() { + let mut view = ProjectView::new(); + let response = ProjectReadResponse { + revision: Revision::new(9), + results: vec![ProjectReadResult::Runtime(lpc_wire::RuntimeReadResult { + project: lpc_wire::ProjectRuntimeStatus { + revision: Revision::new(9), + frame_num: 42, + frame_delta_ms: 16, + frame_total_ms: 17, + demand_root_count: 2, + runtime_buffer_count: 3, + }, + server: Some(lpc_wire::ServerRuntimeStatus { + theoretical_fps: Some(60.0), + last_frame_time_us: Some(16_000), + memory: Some(lpc_wire::server::MemoryStats { + free_bytes: 1024, + used_bytes: 2048, + total_bytes: 3072, + }), + }), + })], + probes: vec![], + }; + + apply_project_read_response(&mut view, response).unwrap(); + + let runtime = view.runtime.as_ref().expect("runtime retained"); + assert_eq!(runtime.project.frame_num, 42); + assert_eq!(runtime.project.runtime_buffer_count, 3); + assert_eq!( + runtime + .server + .as_ref() + .and_then(|server| server.memory.as_ref()), + Some(&lpc_wire::server::MemoryStats { + free_bytes: 1024, + used_bytes: 2048, + total_bytes: 3072, + }) + ); + } } diff --git a/lp-core/lpc-view/src/project/project_view.rs b/lp-core/lpc-view/src/project/project_view.rs index a18ec62ea..0c4bf5a49 100644 --- a/lp-core/lpc-view/src/project/project_view.rs +++ b/lp-core/lpc-view/src/project/project_view.rs @@ -1,6 +1,6 @@ use lpc_model::NodeKind; use lpc_model::{LpPathBuf, Revision}; -use lpc_wire::NodeRuntimeStatus; +use lpc_wire::{NodeRuntimeStatus, RuntimeReadResult}; use super::resource_cache::ClientResourceCache; use crate::slot::SlotMirrorView; @@ -27,6 +27,8 @@ pub struct ProjectView { pub slots: SlotMirrorView, /// Cached resource summaries and payloads. pub resource_cache: ClientResourceCache, + /// Latest project/server runtime counters from project reads. + pub runtime: Option, } /// Minimal node entry retained until canonical project sync is rebuilt. @@ -45,6 +47,7 @@ impl ProjectView { tree: NodeTreeView::new(), slots: SlotMirrorView::new(), resource_cache: ClientResourceCache::new(), + runtime: None, } } } diff --git a/lp-core/lpc-wire/src/lib.rs b/lp-core/lpc-wire/src/lib.rs index cd3d67593..2d9035bd8 100644 --- a/lp-core/lpc-wire/src/lib.rs +++ b/lp-core/lpc-wire/src/lib.rs @@ -22,8 +22,9 @@ pub mod tree; pub use messages::{ClientMessage, ClientRequest, Message, NoDomain, ServerMessage}; pub use messages::{ - ExplainSlotProbeRequest, ExplainSlotProbeResult, NodeReadQuery, NodeReadResult, - NodeReadSelection, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, ExplainSlotProbeRequest, ExplainSlotProbeResult, NodeReadQuery, + NodeReadResult, NodeReadSelection, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, ProjectReadResponseWriter, ProjectReadResult, ProjectRuntimeStatus, ReadLevel, RenderProductProbeRequest, RenderProductProbeResult, ResourcePayloadRead, ResourceReadQuery, ResourceReadResult, RuntimeReadQuery, diff --git a/lp-core/lpc-wire/src/messages/mod.rs b/lp-core/lpc-wire/src/messages/mod.rs index 0ea884d0d..450c394e7 100644 --- a/lp-core/lpc-wire/src/messages/mod.rs +++ b/lp-core/lpc-wire/src/messages/mod.rs @@ -6,8 +6,9 @@ pub mod stream_server_message; pub use crate::message::client::{ClientMessage, ClientRequest}; pub use crate::message::envelope::{Message, NoDomain, ServerMessage}; pub use project_read::{ - ExplainSlotProbeRequest, ExplainSlotProbeResult, NodeReadQuery, NodeReadResult, - NodeReadSelection, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, ExplainSlotProbeRequest, ExplainSlotProbeResult, NodeReadQuery, + NodeReadResult, NodeReadSelection, ProjectProbeRequest, ProjectProbeResult, ProjectReadQuery, ProjectReadRequest, ProjectReadResponse, ProjectReadResponseWriter, ProjectReadResult, ProjectRuntimeStatus, ReadLevel, RenderProductProbeRequest, RenderProductProbeResult, ResourcePayloadRead, ResourceReadQuery, ResourceReadResult, RuntimeReadQuery, diff --git a/lp-core/lpc-wire/src/messages/project_read/mod.rs b/lp-core/lpc-wire/src/messages/project_read/mod.rs index 6e1418902..67eca9187 100644 --- a/lp-core/lpc-wire/src/messages/project_read/mod.rs +++ b/lp-core/lpc-wire/src/messages/project_read/mod.rs @@ -12,8 +12,10 @@ mod stream_response; pub use node_read::{NodeReadQuery, NodeReadResult, NodeReadSelection}; pub use probe::{ - ExplainSlotProbeRequest, ExplainSlotProbeResult, ProjectProbeRequest, ProjectProbeResult, - RenderProductProbeRequest, RenderProductProbeResult, SlotExplanation, + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, ExplainSlotProbeRequest, ExplainSlotProbeResult, + ProjectProbeRequest, ProjectProbeResult, RenderProductProbeRequest, RenderProductProbeResult, + SlotExplanation, }; pub use project_read_request::{ProjectReadQuery, ProjectReadRequest}; pub use project_read_response::{ProjectReadResponse, ProjectReadResult}; diff --git a/lp-core/lpc-wire/src/messages/project_read/probe/control_product_probe.rs b/lp-core/lpc-wire/src/messages/project_read/probe/control_product_probe.rs new file mode 100644 index 000000000..072bfb6cc --- /dev/null +++ b/lp-core/lpc-wire/src/messages/project_read/probe/control_product_probe.rs @@ -0,0 +1,104 @@ +//! Control-product preview probe. +//! +//! The probe returns native control samples plus metadata that lets clients +//! inspect those samples and optionally render a human-facing display layout. + +use alloc::string::String; +use alloc::vec::Vec; + +use lpc_model::{ + ControlDisplayLayout, ControlExtent, ControlProduct, ControlSampleLayout, Revision, +}; + +use crate::project::WireChannelSampleFormat; + +/// Request to materialize a control product for inspection. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +pub struct ControlProductProbeRequest { + pub product: ControlProduct, + pub sample_format: WireChannelSampleFormat, + pub display_layout: ControlDisplayLayoutRead, +} + +/// Whether and how a control-product probe should include display layout data. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ControlDisplayLayoutRead { + None, + Always, + IfChanged { known_revision: Option }, +} + +/// Display layout payload attached to a control-product probe response. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ControlDisplayLayoutProbeResult { + Omitted, + Unchanged { revision: Revision }, + Layout(ControlDisplayLayout), + Unsupported { reason: String }, +} + +/// Result of a control-product preview probe. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "schema-gen", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum ControlProductProbeResult { + Preview { + product: ControlProduct, + revision: Revision, + extent: ControlExtent, + sample_format: WireChannelSampleFormat, + sample_layout: ControlSampleLayout, + display_layout: ControlDisplayLayoutProbeResult, + #[cfg_attr(feature = "schema-gen", schemars(with = "String"))] + #[serde(with = "crate::serde_base64")] + bytes: Vec, + }, + Unsupported { + product: ControlProduct, + reason: String, + }, + Error { + product: ControlProduct, + message: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use lpc_model::{ColorOrder, ControlSampleEncoding, ControlSampleSpan, NodeId}; + + #[test] + fn control_product_probe_round_trips_native_samples() { + let product = ControlProduct::new(NodeId::new(4), 0, ControlExtent::new(1, 3)); + let result = ControlProductProbeResult::Preview { + product, + revision: Revision::new(7), + extent: ControlExtent::new(1, 3), + sample_format: WireChannelSampleFormat::U16, + sample_layout: ControlSampleLayout { + spans: Vec::from([ControlSampleSpan { + row: 0, + start: 0, + len: 3, + encoding: ControlSampleEncoding::RgbPixels { + count: 1, + color_order: ColorOrder::Rgb, + }, + }]), + }, + display_layout: ControlDisplayLayoutProbeResult::Omitted, + bytes: Vec::from([0, 0, 255, 255, 128, 0]), + }; + + let json = serde_json::to_string(&result).unwrap(); + let round_trip: ControlProductProbeResult = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_trip, result); + } +} diff --git a/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs b/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs index 2f72d8dcd..d0165a6e1 100644 --- a/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs +++ b/lp-core/lpc-wire/src/messages/project_read/probe/mod.rs @@ -1,9 +1,14 @@ //! Request-scoped diagnostic probes. +mod control_product_probe; mod explain_slot_probe; mod project_probe; mod render_product_probe; +pub use control_product_probe::{ + ControlDisplayLayoutProbeResult, ControlDisplayLayoutRead, ControlProductProbeRequest, + ControlProductProbeResult, +}; pub use explain_slot_probe::{ExplainSlotProbeRequest, ExplainSlotProbeResult, SlotExplanation}; pub use project_probe::{ProjectProbeRequest, ProjectProbeResult}; pub use render_product_probe::{RenderProductProbeRequest, RenderProductProbeResult}; diff --git a/lp-core/lpc-wire/src/messages/project_read/probe/project_probe.rs b/lp-core/lpc-wire/src/messages/project_read/probe/project_probe.rs index 349f440d0..e4fc63552 100644 --- a/lp-core/lpc-wire/src/messages/project_read/probe/project_probe.rs +++ b/lp-core/lpc-wire/src/messages/project_read/probe/project_probe.rs @@ -1,8 +1,8 @@ //! Top-level project probe variants. use super::{ - ExplainSlotProbeRequest, ExplainSlotProbeResult, RenderProductProbeRequest, - RenderProductProbeResult, + ControlProductProbeRequest, ControlProductProbeResult, ExplainSlotProbeRequest, + ExplainSlotProbeResult, RenderProductProbeRequest, RenderProductProbeResult, }; /// Request-scoped diagnostic work attached to a project read. @@ -11,6 +11,7 @@ use super::{ #[serde(rename_all = "snake_case")] pub enum ProjectProbeRequest { RenderProduct(RenderProductProbeRequest), + ControlProduct(ControlProductProbeRequest), ExplainSlot(ExplainSlotProbeRequest), // Future: ShaderPixel(ShaderPixelProbeRequest), // Future: ShaderTrace(ShaderTraceProbeRequest), @@ -25,6 +26,7 @@ pub enum ProjectProbeRequest { #[serde(rename_all = "snake_case")] pub enum ProjectProbeResult { RenderProduct(RenderProductProbeResult), + ControlProduct(ControlProductProbeResult), ExplainSlot(ExplainSlotProbeResult), // Future: ShaderPixel(ShaderPixelProbeResult), // Future: ShaderTrace(ShaderTraceProbeResult), @@ -36,12 +38,12 @@ pub enum ProjectProbeResult { impl ProjectProbeRequest { #[cfg(test)] pub(crate) fn unsupported_example_for_test() -> Self { - use lpc_model::{NodeId, SlotPath}; + use lpc_model::{ControlExtent, ControlProduct, NodeId}; - Self::ExplainSlot(ExplainSlotProbeRequest { - node: NodeId::new(1), - slot: SlotPath::parse("input").unwrap(), - include_trace: true, + Self::ControlProduct(ControlProductProbeRequest { + product: ControlProduct::new(NodeId::new(1), 0, ControlExtent::new(1, 3)), + sample_format: crate::WireChannelSampleFormat::U16, + display_layout: super::ControlDisplayLayoutRead::None, }) } } diff --git a/lp-fw/README.md b/lp-fw/README.md index 99bfadc6d..a70429773 100644 --- a/lp-fw/README.md +++ b/lp-fw/README.md @@ -129,7 +129,7 @@ profile, then runs `espflash save-image --merge --skip-padding` to emit a merged binary image and manifest under: ```text -lp-app/lp-studio-web/public/firmware/esp32c6/ +lp-app/lpa-studio-web/public/firmware/esp32c6/ ``` The package is generated output and is gitignored. `just studio-web-build` diff --git a/scratch/2026-06-22-studio-pages-deployment/_DONE.md b/scratch/2026-06-22-studio-pages-deployment/_DONE.md new file mode 100644 index 000000000..328dd3926 --- /dev/null +++ b/scratch/2026-06-22-studio-pages-deployment/_DONE.md @@ -0,0 +1,75 @@ +--- +kind: implementation-log +status: local-complete +repo: lightplayer +plan: plan.md +completed: 2026-06-22 +commit: pending +adrs: + - docs/adr/2026-06-22-studio-pages-deployment.md +--- + +# Implementation Log + +## Outcome + +Implemented the first GitHub Pages deployment path for LightPlayer Studio and +the web demo. The canonical Photomancer planning workspace was still unwritable +from this sandbox, so this log remains in the repo-local scratch plan copy. + +## Completed Work + +- Added clean Pages artifact packaging for Studio and the web demo. +- Added generated `version.json`, `.nojekyll`, and optional `CNAME` support. +- Added static smoke checks with a server-backed default and file-only fallback + for restricted sandboxes. +- Added production GitHub Pages workflow for `lightplayer.app`. +- Added manual beta/demo workflow for `beta.lightplayer.app` and + `demo.lightplayer.app`. +- Added `gh` helper automation for beta/demo Pages repository setup. +- Added an operational deployment checklist and ADR. +- Fixed the stale web-demo default shader source path. + +## Validation + +- `just --list` passed. +- `node --check scripts/pages/prepare-pages-artifact.mjs` passed. +- `node --check scripts/pages/static-site-smoke.mjs` passed. +- `bash -n scripts/pages/setup-pages-repos.sh` passed. +- `bash -n scripts/pages/publish-static-repo.sh` passed. +- `ruby -e 'require "yaml"; ...' .github/workflows/deploy-studio-pages.yml .github/workflows/deploy-pages-channel.yml` passed. +- `scripts/pages/setup-pages-repos.sh --dry-run` passed. +- `just web-demo-deploy-dir demo target/pages/web-demo demo.lightplayer.app` passed; artifact size was about 1.4 MiB. +- `PAGES_SMOKE_SERVER=off just web-demo-smoke target/pages/web-demo` passed. +- `just studio-web-deploy-dir production target/pages/studio lightplayer.app` passed; artifact size was about 9.2 MiB. +- `PAGES_SMOKE_SERVER=off just studio-web-smoke target/pages/studio` passed. +- `cargo check -p lpa-studio-web --target wasm32-unknown-unknown` passed. +- `cargo check -p lpa-link --features browser-serial-esp32 --target wasm32-unknown-unknown` passed. +- `git diff --check` passed. + +Server-backed smoke checks were not run locally because this sandbox blocks +localhost connections with `connect EPERM 127.0.0.1`. CI and normal local shells +use server-backed smoke by default. + +## Deviations + +- Did not move the plan into the canonical Photomancer planning workspace + because writes through `~/.photomancer/planning` were still blocked. +- Did not commit because `.git` is read-only in this sandbox and there were + unrelated staged/dirty changes already present. + +## Documentation + +- Added `docs/deploy/studio-pages.md`. +- Added `docs/adr/2026-06-22-studio-pages-deployment.md`. +- Updated `lp-app/lpa-studio-web/README.md`. +- Updated `lp-app/web-demo/README.md`. + +## Follow-Up + +- Run `scripts/pages/setup-pages-repos.sh --apply` once GitHub token scopes are + ready. +- Add `LIGHTPLAYER_PAGES_APP_ID` and `LIGHTPLAYER_PAGES_PRIVATE_KEY` to the + source repo for beta/demo publishing. +- Confirm GitHub Pages custom-domain checks and enforce HTTPS. +- Consider self-hosting `esptool-js` for production/offline robustness. diff --git a/scratch/2026-06-22-studio-pages-deployment/plan.md b/scratch/2026-06-22-studio-pages-deployment/plan.md index e9ff23207..0dfff6113 100644 --- a/scratch/2026-06-22-studio-pages-deployment/plan.md +++ b/scratch/2026-06-22-studio-pages-deployment/plan.md @@ -2,9 +2,11 @@ kind: plan size: sm depth: small -status: active +status: done repo: lightplayer created: 2026-06-22 +completed: 2026-06-22 +commit: pending adr: expected --- diff --git a/scratch/attached-popup-playground.html b/scratch/attached-popup-playground.html new file mode 100644 index 000000000..3a2369901 --- /dev/null +++ b/scratch/attached-popup-playground.html @@ -0,0 +1,640 @@ + + + + + + Attached Popup Playground + + + +
+
+ + + +
+ + +
+ + + + + + diff --git a/scratch/style-lab/dynamic-logo.html b/scratch/style-lab/dynamic-logo.html new file mode 100644 index 000000000..475df35da --- /dev/null +++ b/scratch/style-lab/dynamic-logo.html @@ -0,0 +1,680 @@ + + + + + + LightPlayer Style Lab - Dynamic Logo + + + +
+
+
+
+

Logo sketch

+

LightPlayer dynamic shader mark

+

A 64px play-logo canvas with quiet procedural motion, independent animation and palette cycles, and a crisp white border for dark surfaces.

+
+
+
+
+
+ +
+
+ LightPlayer + On-device shader runtime +
+
+
+
+
+ +
+

64px surface check

+
+
+
+ +
+
+ Dark chrome + App shell +
+
+
+
+ +
+
+ Cool panel + Sidebar +
+
+
+
+ +
+
+ Warm panel + Device view +
+
+
+
+
+
+ + + + diff --git a/scratch/style-lab/index.html b/scratch/style-lab/index.html index e53883232..fdd23d4c8 100644 --- a/scratch/style-lab/index.html +++ b/scratch/style-lab/index.html @@ -116,6 +116,11 @@

LightPlayer Style Lab

Mostly neutral surfaces with selective rainbow feedback.

Most likely production baseline. + + Dynamic Logo +

64px play mark with a live shader fill and white border.

+ Slow procedural animation and palette cycling. +
diff --git a/scripts/pages/prepare-pages-artifact.mjs b/scripts/pages/prepare-pages-artifact.mjs index 4e6a1cb66..89ba24128 100755 --- a/scripts/pages/prepare-pages-artifact.mjs +++ b/scripts/pages/prepare-pages-artifact.mjs @@ -16,12 +16,13 @@ const domain = args.domain ?? ""; const configs = { studio: { app: "lightplayer-studio", - sourceDir: path.join(repoRoot, "lp-app/lpa-studio-web/public"), - entries: ["index.html", "pkg", "lpa-link", "firmware", "serial-debug.html"], + sourceDir: path.join(repoRoot, "target/dx/lpa-studio-web/release/web/public"), + entries: ["index.html", "assets", "pkg", "lpa-link", "firmware", "serial-debug.html"], required: [ "index.html", - "pkg/lpa-studio-web.js", - "pkg/lpa-studio-web_bg.wasm", + { prefix: "assets/tailwind-", suffix: ".css" }, + { prefix: "assets/lpa-studio-web-", suffix: ".js" }, + { prefix: "assets/lpa-studio-web_bg-", suffix: ".wasm" }, "pkg/fw_browser.js", "pkg/fw_browser_bg.wasm", "lpa-link/browser_esp32_device_controller.js", @@ -53,9 +54,9 @@ for (const entry of config.entries) { } for (const required of config.required) { - const file = path.join(outDir, required); + const file = await findRequiredAsset(outDir, required); if (!existsSync(file)) { - throw new Error(`missing required deploy asset: ${required}`); + throw new Error(`missing required deploy asset: ${formatRequiredAsset(required)}`); } } @@ -114,6 +115,26 @@ async function listFiles(directory) { return files; } +async function findRequiredAsset(root, required) { + if (typeof required === "string") { + return path.join(root, required); + } + + const files = await listFiles(root); + const match = files.find((file) => { + const relative = path.relative(root, file.path); + return relative.startsWith(required.prefix) && relative.endsWith(required.suffix); + }); + return match?.path ?? path.join(root, `${required.prefix}*${required.suffix}`); +} + +function formatRequiredAsset(required) { + if (typeof required === "string") { + return required; + } + return `${required.prefix}*${required.suffix}`; +} + function isDirty() { if (process.env.GITHUB_ACTIONS === "true") { return false; diff --git a/scripts/pages/static-site-smoke.mjs b/scripts/pages/static-site-smoke.mjs index bf956544d..2c966d7d6 100755 --- a/scripts/pages/static-site-smoke.mjs +++ b/scripts/pages/static-site-smoke.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,12 +16,13 @@ const baseUrl = `http://127.0.0.1:${port}/`; const checks = { studio: { - indexNeedle: "pkg/lpa-studio-web.js", + indexNeedle: "assets/lpa-studio-web-", required: [ "index.html", "version.json", - "pkg/lpa-studio-web.js", - "pkg/lpa-studio-web_bg.wasm", + { prefix: "assets/tailwind-", suffix: ".css" }, + { prefix: "assets/lpa-studio-web-", suffix: ".js" }, + { prefix: "assets/lpa-studio-web_bg-", suffix: ".wasm" }, "pkg/fw_browser.js", "pkg/fw_browser_bg.wasm", "firmware/esp32c6/manifest.json", @@ -66,7 +67,7 @@ try { throw new Error(`index.html does not reference ${check.indexNeedle}`); } for (const asset of check.required) { - await fetchBytes(new URL(asset, baseUrl)); + await fetchBytes(new URL(requiredAssetPath(asset), baseUrl)); } await maybeRunBrowserSmoke(); console.log(`Static smoke passed: ${kind} at ${path.relative(repoRoot, siteDir)}`); @@ -81,16 +82,53 @@ function checkLocalFiles() { throw new Error(`index.html does not reference ${check.indexNeedle}`); } for (const asset of check.required) { - const assetPath = path.join(siteDir, asset); + const assetPath = findRequiredAsset(asset); if (!existsSync(assetPath)) { - throw new Error(`missing required asset: ${asset}`); + throw new Error(`missing required asset: ${formatRequiredAsset(asset)}`); } if (statSync(assetPath).size === 0) { - throw new Error(`required asset is empty: ${asset}`); + throw new Error(`required asset is empty: ${formatRequiredAsset(asset)}`); } } } +function findRequiredAsset(required) { + if (typeof required === "string") { + return path.join(siteDir, required); + } + + const match = listFiles(siteDir).find((file) => { + const relative = path.relative(siteDir, file); + return relative.startsWith(required.prefix) && relative.endsWith(required.suffix); + }); + return match ?? path.join(siteDir, `${required.prefix}*${required.suffix}`); +} + +function requiredAssetPath(required) { + const assetPath = findRequiredAsset(required); + return path.relative(siteDir, assetPath).split(path.sep).join("/"); +} + +function formatRequiredAsset(required) { + if (typeof required === "string") { + return required; + } + return `${required.prefix}*${required.suffix}`; +} + +function listFiles(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...listFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + return files; +} + async function maybeRunBrowserSmoke() { if (browserMode === "off") { return;