From e5403d282087c752be439ce79b4f63f9243c7f37 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 05:30:51 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 6 +- Cargo.lock | 271 ++++++++++- packages/ambient-agent-rs/Cargo.toml | 2 +- .../tui-rs/examples/onboarding-preview.rs | 46 +- packages/tui-rs/examples/support/mod.rs | 1 + .../examples/support/theme_selector_story.rs | 434 ++++++++++++++++++ .../tui-rs/src/components/theme_selector.rs | 77 +++- packages/ui-preview-rs/README.md | 36 +- packages/ui-preview-rs/examples/menu_story.rs | 30 ++ packages/ui-preview-rs/src/authoring.rs | 303 ++++++++++++ packages/ui-preview-rs/src/lib.rs | 1 + packages/ui-preview-rs/src/main.rs | 25 + packages/ui-preview-rs/src/registry.rs | 109 ++++- packages/ui-preview-rs/src/review.html | 288 +++++++++++- scripts/dev/test-ui-workbench.py | 69 +++ scripts/dev/ui-workbench.py | 170 ++++++- 16 files changed, 1795 insertions(+), 73 deletions(-) create mode 100644 packages/tui-rs/examples/support/mod.rs create mode 100644 packages/tui-rs/examples/support/theme_selector_story.rs create mode 100644 packages/ui-preview-rs/examples/menu_story.rs create mode 100644 packages/ui-preview-rs/src/authoring.rs diff --git a/.repository-projection.json b/.repository-projection.json index 4e3cb8120..c951932b1 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "bc849ade0d0805cc847a465ecbd4a886dc4ddcff", + "sourceSha": "582b01208b58857ec89ea02e0a1330a84f0650f7", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "2c18a51a1ff60144824efafb4427990b5430e271", + "priorProjectedBase": "72a207b043c3f3167e8f44fd68143b17abe2fc3f", "definitionDigest": "064b6b9745d392dde277afd7dfdf028cff32b6178a8b9c99c518343219bab429", "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "9f58c10adc39e4211ad750b0cc85040e15482a0d64c8029b1470698a7bedbbbd", + "contentDigest": "0b2113cfef482d03f1d99af1630641768d0445721e8d46efdd31817cd8941614", "publicationEligible": true } diff --git a/Cargo.lock b/Cargo.lock index 943c0923c..c8af69ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.52.0", + "windows-sys 0.60.2", "x11rb", ] @@ -919,6 +919,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -1120,6 +1126,39 @@ dependencies = [ "either", ] +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "cast" version = "0.3.0" @@ -1744,6 +1783,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1805,6 +1856,7 @@ dependencies = [ "fiat-crypto 0.2.9", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -1963,6 +2015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -2073,12 +2126,27 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ + "pkcs8", "signature 2.2.0", ] @@ -2099,9 +2167,11 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek 4.1.3", "ed25519 2.2.3", + "serde", "sha2 0.10.9", "signature 2.2.0", "subtle", + "zeroize", ] [[package]] @@ -2122,6 +2192,27 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -2316,7 +2407,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2328,6 +2419,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2561,6 +2662,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2649,6 +2751,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.16" @@ -2839,6 +2952,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http 1.5.0", + "serde", +] + [[package]] name = "httparse" version = "1.10.1" @@ -3304,17 +3427,26 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "base64 0.22.1", + "ed25519-dalek 2.2.0", + "getrandom 0.2.17", + "hmac 0.12.1", "js-sys", + "p256", + "p384", "pem 3.0.6", - "ring", + "rand 0.8.8", + "rsa", "serde", "serde_json", + "sha2 0.10.9", + "signature 2.2.0", "simple_asn1", + "zeroize", ] [[package]] @@ -3420,6 +3552,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -4369,6 +4504,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -4432,6 +4583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4628,30 +4780,31 @@ dependencies = [ [[package]] name = "octocrab" -version = "0.41.2" +version = "0.54.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2dfd11f6efbd39491d71a3864496f0b6f45e2d01b73b26c55d631c4e0dafaef" +checksum = "8486ecb1ba585c76dd91472320b5334e95d0d2e8b451a3f0a3fced4415ec3993" dependencies = [ "arc-swap", - "async-trait", "base64 0.22.1", "bytes", + "cargo_metadata", "cfg-if", "chrono", - "either", "futures", "futures-util", + "getrandom 0.2.17", "http 1.5.0", "http-body 1.1.0", "http-body-util", + "http-serde", "hyper", "hyper-rustls", "hyper-timeout", "hyper-util", - "jsonwebtoken 9.3.1", - "once_cell", + "jsonwebtoken 10.4.0", "percent-encoding", "pin-project", + "rustls", "secrecy", "serde", "serde_json", @@ -4663,6 +4816,7 @@ dependencies = [ "tower-http", "tracing", "url", + "web-time", ] [[package]] @@ -4824,6 +4978,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "page_size" version = "0.6.0" @@ -5122,6 +5300,17 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -5294,6 +5483,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5945,6 +6143,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5970,6 +6178,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -6150,6 +6378,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "seccompiler" version = "0.5.0" @@ -6600,6 +6842,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spki" version = "0.7.3" @@ -8136,6 +8384,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", + "serde", "wasm-bindgen", ] diff --git a/packages/ambient-agent-rs/Cargo.toml b/packages/ambient-agent-rs/Cargo.toml index f2a34d0ec..02217a727 100644 --- a/packages/ambient-agent-rs/Cargo.toml +++ b/packages/ambient-agent-rs/Cargo.toml @@ -54,7 +54,7 @@ dirs.workspace = true dunce.workspace = true # GitHub -octocrab = "0.41" +octocrab = "0.54.2" [dev-dependencies] tokio-test = "0.4" diff --git a/packages/tui-rs/examples/onboarding-preview.rs b/packages/tui-rs/examples/onboarding-preview.rs index e27e83ee0..52e1782ee 100644 --- a/packages/tui-rs/examples/onboarding-preview.rs +++ b/packages/tui-rs/examples/onboarding-preview.rs @@ -1,5 +1,7 @@ //! Full-TUI-linked fixtures; never starts App, authentication or checks. //! COLORTERM=truecolor cargo run --locked -p maestro-tui --example onboarding-preview -- --html > review.html +mod support; + use maestro_tui::{ components::{SetupModal, SetupPage, SetupPresentation, startup::render_startup}, doctor::CheckStatus, @@ -165,13 +167,55 @@ fn captures() -> Result, String> { for capture in &mut captures { capture.source = "products/maestro/packages/tui-rs/examples/onboarding-preview.rs".into(); } + captures.extend(support::theme_selector_story::captures()?); captures.extend(maestro_ui_preview::registry()?.captures()?); Ok(captures) } fn run() -> Result<(), String> { let args: Vec<_> = std::env::args().skip(1).collect(); + if args == ["--sequences"] { + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "fixtures": support::theme_selector_story::fixture_requests(), + "presets": support::theme_selector_story::presets() + .into_iter() + .zip([ + "Navigate and select", + "Filter and cancel", + "Unicode and resize", + "Retry and select", + ]) + .map(|(request, label)| serde_json::json!({ + "label": label, + "request": request, + })) + .collect::>(), + })) + .map_err(|error| error.to_string())? + ); + return Ok(()); + } + if args == ["--replay-stdin"] { + use std::io::Read; + let mut body = String::new(); + std::io::stdin() + .take(16_385) + .read_to_string(&mut body) + .map_err(|error| error.to_string())?; + if body.len() > 16_384 { + return Err("replay request exceeds 16384 bytes".into()); + } + let request = serde_json::from_str(&body).map_err(|error| error.to_string())?; + let response = support::theme_selector_story::replay(request)?; + println!( + "{}", + serde_json::to_string(&response).map_err(|error| error.to_string())? + ); + return Ok(()); + } if args.len() > 1 || args.first().is_some_and(|a| a != "--html" && a != "--json") { - return Err("usage: onboarding-preview [--html|--json]".into()); + return Err("usage: onboarding-preview [--html|--json|--sequences|--replay-stdin]".into()); } let captures = captures()?; let output = if args.first().is_some_and(|a| a == "--html") { diff --git a/packages/tui-rs/examples/support/mod.rs b/packages/tui-rs/examples/support/mod.rs new file mode 100644 index 000000000..d3abec409 --- /dev/null +++ b/packages/tui-rs/examples/support/mod.rs @@ -0,0 +1 @@ +pub mod theme_selector_story; diff --git a/packages/tui-rs/examples/support/theme_selector_story.rs b/packages/tui-rs/examples/support/theme_selector_story.rs new file mode 100644 index 000000000..d390ef6bd --- /dev/null +++ b/packages/tui-rs/examples/support/theme_selector_story.rs @@ -0,0 +1,434 @@ +//! Effect-free host for the production ThemeSelector controller and renderer. +use maestro_tui::components::ThemeSelector; +use maestro_ui::{PickerOutcome, PickerStatus}; +use maestro_ui_preview::{ + Scene, + authoring::{REPLAY_VERSION, StoryInput, StoryKey, StorySequence}, + review::{self, Capture}, +}; +use serde::{Deserialize, Serialize}; + +const SOURCE: &str = "products/maestro/packages/tui-rs/examples/support/theme_selector_story.rs"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ThemeFixture { + Ready, + Loading, + Empty, + Error, + Long, + Unicode, + Narrow, +} +impl ThemeFixture { + fn id(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::Loading => "loading", + Self::Empty => "empty", + Self::Error => "error", + Self::Long => "long", + Self::Unicode => "unicode", + Self::Narrow => "narrow", + } + } + fn label(self) -> &'static str { + match self { + Self::Ready => "Theme selector / ready", + Self::Loading => "Theme selector / loading", + Self::Empty => "Theme selector / empty", + Self::Error => "Theme selector / error", + Self::Long => "Theme selector / long names", + Self::Unicode => "Theme selector / Unicode", + Self::Narrow => "Theme selector / narrow", + } + } + fn dimensions(self) -> (u16, u16) { + if self == Self::Narrow { + (28, 14) + } else { + (72, 22) + } + } +} + +const FIXTURES: [ThemeFixture; 7] = [ + ThemeFixture::Ready, + ThemeFixture::Loading, + ThemeFixture::Empty, + ThemeFixture::Error, + ThemeFixture::Long, + ThemeFixture::Unicode, + ThemeFixture::Narrow, +]; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(tag = "type", content = "theme", rename_all = "kebab-case")] +pub enum SimulatedEffect { + PreviewTheme(String), + CommitTheme(String), + RestoreOpeningTheme, + RetryThemes, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ThemeReplayRequest { + pub version: u8, + pub fixture: ThemeFixture, + pub width: u16, + pub height: u16, + pub inputs: Vec, +} +impl ThemeReplayRequest { + fn sequence(&self, id: &str, label: &str) -> StorySequence { + StorySequence { + version: self.version, + id: id.into(), + label: label.into(), + width: self.width, + height: self.height, + inputs: self.inputs.clone(), + } + } + pub fn validate(&self) -> Result<(), String> { + self.sequence("theme-selector-live", "Theme selector / live") + .validate() + } +} + +#[derive(Debug, Serialize)] +pub struct ThemeReplayResponse { + pub capture: Capture, + pub effects: Vec, + pub sequence: ThemeReplayRequest, + pub effect_policy: &'static str, +} + +struct ThemeStory { + selector: ThemeSelector, + fixture: ThemeFixture, + effects: Vec, + width: u16, + height: u16, +} +impl ThemeStory { + fn new(fixture: ThemeFixture, width: u16, height: u16) -> Result { + let mut selector = + ThemeSelector::with_themes(items(fixture)).map_err(|error| error.to_string())?; + selector.show(); + selector.set_status(match fixture { + ThemeFixture::Loading => PickerStatus::Loading("Loading themes…".into()), + ThemeFixture::Error => { + PickerStatus::Error("Themes could not be loaded. Retry to continue.".into()) + } + _ => PickerStatus::Ready, + }); + Ok(Self { + selector, + fixture, + effects: Vec::new(), + width, + height, + }) + } + fn apply(&mut self, input: &StoryInput) -> Result<(), String> { + match input { + StoryInput::Key { key, ctrl } => { + let outcome = self.selector.handle_key(key.code(), *ctrl); + self.record(outcome); + } + StoryInput::Text { text } => { + let outcome = self.selector.insert_str(text); + self.record(outcome); + } + StoryInput::Resize { width, height } => { + self.width = *width; + self.height = *height; + } + StoryInput::Retry => { + if self.fixture == ThemeFixture::Error { + self.effects.push(SimulatedEffect::RetryThemes); + self.selector + .replace_themes(items(ThemeFixture::Ready)) + .map_err(|error| error.to_string())?; + self.selector.set_status(PickerStatus::Ready); + self.fixture = ThemeFixture::Ready; + } + } + } + Ok(()) + } + fn record(&mut self, outcome: PickerOutcome) { + match outcome { + PickerOutcome::Changed(Some(theme)) => { + self.effects.push(SimulatedEffect::PreviewTheme(theme)); + } + PickerOutcome::Changed(None) | PickerOutcome::Cancelled => { + self.effects.push(SimulatedEffect::RestoreOpeningTheme); + } + PickerOutcome::Selected(theme) => { + self.effects.push(SimulatedEffect::CommitTheme(theme)); + } + PickerOutcome::Pending => {} + } + } + fn capture(mut self, id: &str, label: &str, step: usize) -> Result { + let scene = Scene { + id: id.into(), + label: label.into(), + width: self.width, + height: self.height, + time_ms: step as u64 * maestro_ui_preview::authoring::REPLAY_STEP_MS, + }; + let mut capture = review::capture(scene, |frame| { + self.selector.render(frame, frame.area()); + })?; + capture.source = SOURCE.into(); + Ok(capture) + } +} + +fn items(fixture: ThemeFixture) -> Vec { + match fixture { + ThemeFixture::Loading | ThemeFixture::Empty | ThemeFixture::Error => Vec::new(), + ThemeFixture::Long => (1..=24) + .map(|index| format!("workspace-contrast-theme-with-a-long-name-{index:02}")) + .collect(), + ThemeFixture::Unicode => ["Café crème", "夜明け", "Καλημέρα", "Nord 🌙"] + .into_iter() + .map(str::to_owned) + .collect(), + ThemeFixture::Ready | ThemeFixture::Narrow => [ + "auto", + "dark", + "light", + "green", + "pink", + "blue", + "green-dark", + "pink-dark", + "blue-dark", + "high-contrast", + "vscode-monokai", + "vscode-light-modern", + ] + .into_iter() + .map(str::to_owned) + .collect(), + } +} + +pub fn replay(request: ThemeReplayRequest) -> Result { + request.validate()?; + let mut story = ThemeStory::new(request.fixture, request.width, request.height)?; + for input in &request.inputs { + story.apply(input)?; + } + let effects = story.effects.clone(); + let capture = story.capture( + "theme-selector-live", + "Theme selector / interactive replay", + request.inputs.len(), + )?; + Ok(ThemeReplayResponse { + capture, + effects, + sequence: request, + effect_policy: "simulated only; no theme, sign-in, tool, or setting effect was applied", + }) +} + +pub fn presets() -> Vec { + vec![ + ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Ready, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Key { + key: StoryKey::Down, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Down, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ], + }, + ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Ready, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Text { + text: "vscode".into(), + }, + StoryInput::Key { + key: StoryKey::Down, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Escape, + ctrl: false, + }, + ], + }, + ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Unicode, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Resize { + width: 30, + height: 14, + }, + StoryInput::Text { text: "夜".into() }, + ], + }, + ThemeReplayRequest { + version: REPLAY_VERSION, + fixture: ThemeFixture::Error, + width: 72, + height: 22, + inputs: vec![ + StoryInput::Retry, + StoryInput::Text { + text: "vscode".into(), + }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ], + }, + ] +} + +pub fn fixture_requests() -> Vec { + FIXTURES + .into_iter() + .map(|fixture| { + let (width, height) = fixture.dimensions(); + ThemeReplayRequest { + version: REPLAY_VERSION, + fixture, + width, + height, + inputs: Vec::new(), + } + }) + .collect() +} + +pub fn captures() -> Result, String> { + let mut captures = Vec::new(); + for fixture in FIXTURES { + let (width, height) = fixture.dimensions(); + captures.push(ThemeStory::new(fixture, width, height)?.capture( + &format!("theme-selector-{}", fixture.id()), + fixture.label(), + 0, + )?); + } + for (index, request) in presets().into_iter().enumerate() { + let id = format!("theme-selector-interaction-{}", index + 1); + let label = match index { + 0 => "Theme selector interaction / keyboard commit", + 1 => "Theme selector interaction / type and cancel", + 2 => "Theme selector interaction / Unicode and resize", + _ => "Theme selector interaction / error and retry", + }; + for step in 0..=request.inputs.len() { + let mut story = ThemeStory::new(request.fixture, request.width, request.height)?; + for input in &request.inputs[..step] { + story.apply(input)?; + } + captures.push(story.capture(&id, label, step)?); + } + } + Ok(captures) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixtures_and_interactions_are_deterministic_and_bounded() { + let captures = captures().unwrap(); + assert_eq!(captures.len(), 22); + assert!(captures.iter().all(|capture| capture.scene.width <= 72)); + assert_eq!( + review::json(&captures).unwrap(), + review::json(&super::captures().unwrap()).unwrap() + ); + } + + #[test] + fn retry_cancel_and_commit_are_receipts_without_runtime_effects() { + let retry = replay(presets().pop().unwrap()).unwrap(); + assert_eq!(retry.effects[0], SimulatedEffect::RetryThemes); + assert!(matches!( + retry.effects.last(), + Some(SimulatedEffect::CommitTheme(_)) + )); + assert!(retry.effect_policy.starts_with("simulated only")); + + let cancel = replay(presets()[1].clone()).unwrap(); + assert!(matches!( + cancel.effects.last(), + Some(SimulatedEffect::RestoreOpeningTheme) + )); + } + + #[test] + fn unicode_and_narrow_render_use_production_clipping_and_focus_styles() { + let response = replay(presets()[2].clone()).unwrap(); + assert_eq!( + (response.capture.scene.width, response.capture.scene.height), + (30, 14) + ); + let text = response + .capture + .cells + .iter() + .map(|cell| cell.text.as_str()) + .collect::(); + assert!(text.contains('夜')); + assert!(text.contains("Esc cancel")); + assert!( + response + .capture + .cells + .iter() + .any(|cell| cell.modifiers != 0) + ); + } + + #[test] + fn request_bounds_are_fail_closed() { + let mut request = presets()[0].clone(); + request.width = 500; + assert!(replay(request).is_err()); + let malformed = + r#"{"version":1,"fixture":"ready","width":60,"height":20,"inputs":[],"path":"/tmp"}"#; + assert!(serde_json::from_str::(malformed).is_err()); + } + + #[test] + fn fixture_items_are_fixed_and_do_not_scan_user_theme_directories() { + let ready = items(ThemeFixture::Ready); + assert_eq!(ready.len(), 12); + assert_eq!(ready[0], "auto"); + assert_eq!(ready.last().unwrap(), "vscode-light-modern"); + } +} diff --git a/packages/tui-rs/src/components/theme_selector.rs b/packages/tui-rs/src/components/theme_selector.rs index 3c19ae199..01b347da0 100644 --- a/packages/tui-rs/src/components/theme_selector.rs +++ b/packages/tui-rs/src/components/theme_selector.rs @@ -1,7 +1,9 @@ //! Theme selection uses the shared picker; applying a theme remains with the app. use crate::themes; use crossterm::event::KeyCode; -use maestro_ui::{ActionPicker, KeyHint, Menu, PickerOptions, PickerOutcome}; +use maestro_ui::{ + ActionPicker, KeyHint, Menu, PickerError, PickerOptions, PickerOutcome, PickerStatus, +}; use ratatui::{ Frame, layout::Rect, @@ -23,14 +25,27 @@ impl Default for ThemeSelector { impl ThemeSelector { #[must_use] pub fn new() -> Self { - Self { - picker: ActionPicker::new(themes::available_themes()) - .identified_by(String::as_str) - .expect("theme names are unique") + Self::with_themes(themes::available_themes()).expect("theme names are unique") + } + /// Construct from caller-owned theme names. This keeps fixtures and future + /// discovery flows on the production picker without applying a theme. + pub fn with_themes(names: Vec) -> Result { + Ok(Self { + picker: ActionPicker::new(names) + .identified_by(String::as_str)? .searchable(String::as_str), current_theme: None, original_theme: None, - } + }) + } + pub fn replace_themes( + &mut self, + names: Vec, + ) -> Result, PickerError> { + self.picker.replace_items(names) + } + pub fn set_status(&mut self, status: PickerStatus) { + self.picker.set_status(status); } pub fn set_current_theme(&mut self, name: Option) { self.current_theme = name; @@ -80,6 +95,15 @@ impl ThemeSelector { } let theme = themes::current_ui_theme(); let current = &self.current_theme; + let compact_hints = [ + KeyHint::new("↵", ""), + KeyHint::new("Esc", maestro_ui::localization::tr("cancel")), + ]; + let full_hints = [ + KeyHint::new("Enter", maestro_ui::localization::tr("select")), + KeyHint::new("Esc", maestro_ui::localization::tr("cancel")), + KeyHint::new("↑↓", maestro_ui::localization::tr("navigate")), + ]; Menu::new( maestro_ui::localization::tr("Select Theme"), &mut self.picker, @@ -87,11 +111,13 @@ impl ThemeSelector { .options(PickerOptions { placeholder: maestro_ui::localization::tr("Type to filter themes..."), empty: maestro_ui::localization::tr("No matching themes"), - hints: Some(&[ - KeyHint::new("Enter", maestro_ui::localization::tr("select")), - KeyHint::new("Esc", maestro_ui::localization::tr("cancel")), - KeyHint::new("↑↓", maestro_ui::localization::tr("navigate")), - ]), + // The full English line is 39 columns; modal margins, border and + // padding consume eight more. Keep one spare column at the edge. + hints: Some(if area.width < 48 { + &compact_hints + } else { + &full_hints + }), ..PickerOptions::default() }) .render_items(frame, area, theme, |name| { @@ -154,6 +180,35 @@ mod tests { } } + #[test] + fn narrow_picker_keeps_the_cancel_binding_visible() { + use ratatui::{Terminal, backend::TestBackend}; + let render = |width| { + let mut selector = ThemeSelector::new(); + selector.show(); + let mut terminal = Terminal::new(TestBackend::new(width, 14)).unwrap(); + terminal + .draw(|frame| selector.render(frame, frame.area())) + .unwrap(); + terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::() + }; + let narrow = render(28); + assert!(narrow.contains('↵')); + assert!(narrow.contains("Esc cancel")); + let compact = render(40); + assert!(compact.contains("Esc cancel")); + assert!(!compact.contains("↑↓ navigate")); + let normal = render(60); + assert!(normal.contains("Enter select")); + assert!(normal.contains("↑↓ navigate")); + } + #[test] fn current_theme_is_selected_and_preview_cancel_returns_the_opening_palette() { let mut selector = ThemeSelector::new(); diff --git a/packages/ui-preview-rs/README.md b/packages/ui-preview-rs/README.md index 85b876d8f..fd485b71b 100644 --- a/packages/ui-preview-rs/README.md +++ b/packages/ui-preview-rs/README.md @@ -66,8 +66,9 @@ step through supplied timestamps, play animation, and export a frame as JSON. Nothing is fetched from a server. The fixtures do not sign in, call a model, execute tools, or persist configuration. Playback starts only when requested. RGB, indexed/default colors, style flags and Unicode column widths are retained; -blink flags remain static for inspection. Native cursor behavior and keyboard -interaction remain covered by the PTY suite rather than this cell-only gallery. +blink flags remain static for inspection. Interaction scenes use 80 ms per +input, so every prefix remains an ordinary capture consumed by the PR evidence +pipeline. `review::capture(scene, |frame| ...)` accepts a production rendering closure. `review::from_buffer` adapts existing buffer-based widgets. `review::html` and @@ -85,6 +86,22 @@ comparisons and the native terminal suite for interaction regressions. ## Author a menu or a scene +Generate a minimal runnable menu story without overwriting an existing file: + +```sh +cargo run --locked -p maestro-ui-preview -- \ + --scaffold workspace-menu \ + --output packages/ui-preview-rs/examples/workspace_menu.rs +cargo run --locked -p maestro-ui-preview --example workspace_menu > workspace-menu.html +``` + +The generated example declares its labels, items, and typed interaction inputs, +then calls `menu_story` and `export_story`. Those helpers use the real shared +`ActionPicker` and `Menu`; product-specific adapters can use the lower-level +`Story::replay`. Move a finished story beside its owning adapter and add it once +to that adapter's registry. IDs are lowercase and at most 64 bytes. The +generator uses create-new semantics and refuses to overwrite a file. + Use `maestro_ui::Menu` for menu chrome, empty/loading/error presentation and help. Keep one caller-owned `ActionPicker` for query, selection and keyboard input: @@ -131,8 +148,19 @@ python3 scripts/dev/ui-workbench.py python3 scripts/dev/ui-workbench.py --components-only --port 8771 ``` -The command builds the real Rust renderer, watches source changes, and serves -only the generated page and rebuild status on loopback. It preserves URL state +The command builds the real Rust renderer once, watches source changes, and serves +the generated page on loopback. In the **Interactive Rust replay** panel, focus +the terminal preview and type, paste, navigate, resize, cancel, or retry. The +browser sends a bounded typed sequence to a fixed Rust executable; the production +`ThemeSelector` controller and renderer rebuild the frame from the full sequence. +Export saves that portable JSON receipt and **Replay file** imports it. Effects +are returned as explicit simulated receipts. The preview never changes a theme, +signs in, executes a tool, or writes application settings. + +The server rejects non-loopback Host/Origin values, oversized bodies, text, +event counts and dimensions. It invokes no browser-supplied command or path. +`--components-only` remains a static catalog and exposes no replay endpoint. +The workbench preserves URL state and scroll across successful refreshes. Failed builds retain the previous page and show an error; they never update accepted baselines. It uses the configured `CARGO_TARGET_DIR` or a user cache outside the checkout. In Mono it runs the diff --git a/packages/ui-preview-rs/examples/menu_story.rs b/packages/ui-preview-rs/examples/menu_story.rs new file mode 100644 index 000000000..8f2b2371b --- /dev/null +++ b/packages/ui-preview-rs/examples/menu_story.rs @@ -0,0 +1,30 @@ +//! Generated by `maestro-ui-preview --scaffold menu-story --output …`. +use maestro_ui_preview::authoring::{ + StoryInput, StoryKey, StorySequence, export_story, menu_story, +}; + +fn main() -> Result<(), String> { + let sequence = StorySequence::new("menu-story", "Menu story", 60, 20).inputs([ + StoryInput::Text { text: "sec".into() }, + StoryInput::Key { + key: StoryKey::Down, + ctrl: false, + }, + StoryInput::Key { + key: StoryKey::Enter, + ctrl: false, + }, + ]); + let story = menu_story( + sequence, + file!(), + "Choose a section", + ["Overview", "Security", "Settings"] + .map(str::to_owned) + .into(), + "Filter sections", + "No sections", + )?; + println!("{}", export_story(story)?); + Ok(()) +} diff --git a/packages/ui-preview-rs/src/authoring.rs b/packages/ui-preview-rs/src/authoring.rs new file mode 100644 index 000000000..4ad39860d --- /dev/null +++ b/packages/ui-preview-rs/src/authoring.rs @@ -0,0 +1,303 @@ +//! Small, portable interaction receipts for deterministic UI stories. +use crate::{Scene, registry::Story}; +use crossterm::event::KeyCode; +use maestro_ui::{ActionPicker, Menu, PickerOptions, UiTheme}; +use ratatui::{Terminal, backend::TestBackend, widgets::ListItem}; +use serde::{Deserialize, Serialize}; +use std::{fs::OpenOptions, io::Write, path::Path}; + +pub const REPLAY_VERSION: u8 = 1; +pub const MAX_INPUTS: usize = 64; +pub const MAX_TEXT_BYTES: usize = 4_096; +pub const REPLAY_STEP_MS: u64 = 80; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum StoryKey { + Up, + Down, + Left, + Right, + Enter, + Escape, + Backspace, +} +impl StoryKey { + #[must_use] + pub fn code(self) -> KeyCode { + match self { + Self::Up => KeyCode::Up, + Self::Down => KeyCode::Down, + Self::Left => KeyCode::Left, + Self::Right => KeyCode::Right, + Self::Enter => KeyCode::Enter, + Self::Escape => KeyCode::Esc, + Self::Backspace => KeyCode::Backspace, + } + } +} + +/// Inputs describe user intent. The product controller remains the authority +/// for what each input does, and hosts explicitly own effects such as retry. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] +pub enum StoryInput { + Key { + key: StoryKey, + #[serde(default)] + ctrl: bool, + }, + Text { + text: String, + }, + Resize { + width: u16, + height: u16, + }, + Retry, +} + +/// A replay is both a fixture recipe and a portable, reviewable interaction receipt. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StorySequence { + pub version: u8, + pub id: String, + pub label: String, + pub width: u16, + pub height: u16, + pub inputs: Vec, +} +impl StorySequence { + #[must_use] + pub fn new(id: &str, label: &str, width: u16, height: u16) -> Self { + Self { + version: REPLAY_VERSION, + id: id.into(), + label: label.into(), + width, + height, + inputs: Vec::new(), + } + } + #[must_use] + pub fn inputs(mut self, inputs: impl IntoIterator) -> Self { + self.inputs = inputs.into_iter().collect(); + self + } + pub fn validate(&self) -> Result<(), String> { + if self.version != REPLAY_VERSION { + return Err(format!( + "unsupported story sequence version: {}", + self.version + )); + } + validate_id(&self.id)?; + if self.label.is_empty() || self.label.len() > 160 { + return Err("story sequence label must be 1..160 bytes".into()); + } + validate_dimensions(self.width, self.height)?; + if self.inputs.len() > MAX_INPUTS { + return Err(format!("story sequence exceeds {MAX_INPUTS} inputs")); + } + let mut text_bytes = 0usize; + for input in &self.inputs { + match input { + StoryInput::Text { text } => { + if text.chars().any(char::is_control) { + return Err("story text cannot contain control characters".into()); + } + text_bytes = text_bytes.saturating_add(text.len()); + } + StoryInput::Resize { width, height } => { + validate_dimensions(*width, *height)?; + } + StoryInput::Key { .. } | StoryInput::Retry => {} + } + } + if text_bytes > MAX_TEXT_BYTES { + return Err(format!( + "story sequence text exceeds {MAX_TEXT_BYTES} bytes" + )); + } + Ok(()) + } + /// One case before input plus one after each input. The step index is a + /// deterministic 80 ms authoring clock, while resize inputs update later frames. + pub fn cases(&self) -> Result, String> { + self.validate()?; + let (mut width, mut height) = (self.width, self.height); + let mut cases = vec![(width, height, 0)]; + for (index, input) in self.inputs.iter().enumerate() { + if let StoryInput::Resize { + width: next_width, + height: next_height, + } = input + { + width = *next_width; + height = *next_height; + } + cases.push((width, height, (index + 1) as u64 * REPLAY_STEP_MS)); + } + Ok(cases) + } +} + +pub(crate) fn validate_dimensions(width: u16, height: u16) -> Result<(), String> { + if !(8..=240).contains(&width) || !(3..=100).contains(&height) { + return Err("width must be 8..240 and height 3..100".into()); + } + Ok(()) +} + +pub(crate) fn validate_id(id: &str) -> Result<(), String> { + if id.is_empty() + || id.len() > 64 + || !id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err("story ID must be 1..64 lowercase letters, digits or hyphens".into()); + } + Ok(()) +} + +/// Source for a Cargo-discovered example. `create_new` is enforced by the CLI. +pub fn scaffold_source(id: &str) -> Result { + validate_id(id)?; + Ok(include_str!("../examples/menu_story.rs").replace("menu-story", id)) +} + +pub fn write_scaffold(id: &str, output: &Path) -> Result<(), String> { + let source = scaffold_source(id)?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(output) + .map_err(|error| format!("refusing to overwrite {}: {error}", output.display()))?; + file.write_all(source.as_bytes()).map_err(|e| e.to_string()) +} + +/// The small default recipe: caller-owned labels, real shared Menu/ActionPicker, +/// and deterministic replay. Use `Story::replay` directly for product adapters. +pub fn menu_story( + sequence: StorySequence, + source: &str, + title: &'static str, + items: Vec, + placeholder: &'static str, + empty: &'static str, +) -> Result { + Story::replay( + sequence, + source, + move |scene: &Scene, inputs: &[StoryInput]| { + let mut picker = ActionPicker::new(items.clone()) + .identified_by(String::as_str) + .map_err(|error| error.to_string())? + .searchable(String::as_str); + picker.open(); + for input in inputs { + match input { + StoryInput::Key { key, ctrl } => { + picker.handle_key(key.code(), *ctrl); + } + StoryInput::Text { text } => { + picker.insert_str(text); + } + StoryInput::Resize { .. } | StoryInput::Retry => {} + } + } + let mut terminal = Terminal::new(TestBackend::new(scene.width, scene.height)) + .map_err(|error| error.to_string())?; + terminal + .draw(|frame| { + Menu::new(title, &mut picker) + .options(PickerOptions { + placeholder, + empty, + ..PickerOptions::default() + }) + .render_items(frame, frame.area(), UiTheme::default(), |item| { + ListItem::new(item.as_str()) + }); + }) + .map_err(|error| error.to_string())?; + Ok(terminal.backend().buffer().clone()) + }, + ) +} + +pub fn export_story(story: Story) -> Result { + let mut registry = crate::registry::Registry::default(); + registry.add(story)?; + crate::review::html(®istry.captures()?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static NEXT_TEMP_FILE: AtomicUsize = AtomicUsize::new(0); + + #[test] + fn sequence_cases_follow_resizes_and_keep_every_input_step() { + let sequence = StorySequence::new("menu-journey", "Menu journey", 60, 20).inputs([ + StoryInput::Text { text: "夜".into() }, + StoryInput::Resize { + width: 32, + height: 12, + }, + StoryInput::Key { + key: StoryKey::Escape, + ctrl: false, + }, + ]); + assert_eq!( + sequence.cases().unwrap(), + vec![(60, 20, 0), (60, 20, 80), (32, 12, 160), (32, 12, 240)] + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&sequence).unwrap()) + .unwrap(), + sequence + ); + } + + #[test] + fn sequence_rejects_unbounded_and_ambiguous_inputs() { + let mut sequence = StorySequence::new("UPPER", "label", 60, 20); + assert!(sequence.validate().is_err()); + sequence.id = "valid".into(); + sequence.inputs = vec![StoryInput::Text { + text: "x".repeat(MAX_TEXT_BYTES + 1), + }]; + assert!(sequence.validate().is_err()); + sequence.inputs = vec![StoryInput::Resize { + width: 241, + height: 20, + }]; + assert!(sequence.validate().is_err()); + } + + #[test] + fn scaffold_is_the_compiled_example_and_never_overwrites() { + assert_eq!( + scaffold_source("menu-story").unwrap(), + include_str!("../examples/menu_story.rs") + ); + let output = std::env::temp_dir().join(format!( + "maestro-ui-story-{}-{}.rs", + std::process::id(), + NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed) + )); + let _ = std::fs::remove_file(&output); + write_scaffold("generated-menu", &output).unwrap(); + let before = std::fs::read_to_string(&output).unwrap(); + assert!(before.contains("generated-menu")); + assert!(write_scaffold("replacement", &output).is_err()); + assert_eq!(std::fs::read_to_string(&output).unwrap(), before); + std::fs::remove_file(output).unwrap(); + } +} diff --git a/packages/ui-preview-rs/src/lib.rs b/packages/ui-preview-rs/src/lib.rs index ad54f6b39..dc1bdc6fe 100644 --- a/packages/ui-preview-rs/src/lib.rs +++ b/packages/ui-preview-rs/src/lib.rs @@ -1,4 +1,5 @@ //! Deterministic component scenes using the widgets linked by the native TUI. +pub mod authoring; mod conversation; mod menus; pub mod registry; diff --git a/packages/ui-preview-rs/src/main.rs b/packages/ui-preview-rs/src/main.rs index e2eede8ea..13204892d 100644 --- a/packages/ui-preview-rs/src/main.rs +++ b/packages/ui-preview-rs/src/main.rs @@ -1,5 +1,6 @@ //! Headless component rendering; no terminal session or agent runtime. use maestro_ui_preview::{Scene, ansi, catalog, render}; +use std::path::PathBuf; fn run() -> Result<(), String> { let mut args = std::env::args().skip(1); let mut scene = Scene { @@ -13,6 +14,8 @@ fn run() -> Result<(), String> { let mut selectors = false; let mut format = String::from("ansi"); let mut identity = false; + let mut scaffold = None; + let mut output = None; while let Some(arg) = args.next() { if arg == "--html" || arg == "--json" { format = arg.trim_start_matches("--").into(); @@ -26,6 +29,16 @@ fn run() -> Result<(), String> { list = true; continue; } + if arg == "--scaffold" { + scaffold = Some(args.next().ok_or("missing value for --scaffold")?); + continue; + } + if arg == "--output" { + output = Some(PathBuf::from( + args.next().ok_or("missing value for --output")?, + )); + continue; + } selectors = true; let value = args .next() @@ -38,6 +51,18 @@ fn run() -> Result<(), String> { _ => return Err(format!("unknown argument: {arg}")), } } + if let Some(scaffold) = scaffold { + if selectors || list || identity || format != "ansi" { + return Err("--scaffold cannot be combined with rendering options".into()); + } + let output = output.ok_or("--scaffold requires --output")?; + maestro_ui_preview::authoring::write_scaffold(&scaffold, &output)?; + println!("{}", output.display()); + return Ok(()); + } + if output.is_some() { + return Err("--output requires --scaffold".into()); + } if format != "ansi" && (selectors || list || identity) { return Err("--html and --json export the full catalog; use them without scene selectors, --list, or --identity".into()); } diff --git a/packages/ui-preview-rs/src/registry.rs b/packages/ui-preview-rs/src/registry.rs index cb41c60bd..769b545a5 100644 --- a/packages/ui-preview-rs/src/registry.rs +++ b/packages/ui-preview-rs/src/registry.rs @@ -1,6 +1,7 @@ //! Register a renderer once; derive catalog, captures, and source links from it. use crate::{ Scene, + authoring::{REPLAY_STEP_MS, StoryInput, StorySequence, validate_dimensions, validate_id}, review::{self, Capture}, }; use ratatui::{Frame, Terminal, backend::TestBackend, buffer::Buffer}; @@ -14,6 +15,7 @@ pub struct Story { source: String, cases: Vec<(u16, u16, u64)>, renderer: Renderer, + sequence: Option, } impl Story { pub fn new( @@ -43,8 +45,41 @@ impl Story { source: source.into(), cases: vec![(60, 24, 0)], renderer: Box::new(render), + sequence: None, } } + /// Build deterministic frames by replaying each prefix through the same + /// controller and renderer used by the host application. + pub fn replay( + sequence: StorySequence, + source: &str, + render: impl Fn(&Scene, &[StoryInput]) -> Result + 'static, + ) -> Result { + let cases = sequence.cases()?; + let replay = sequence.clone(); + Ok(Self { + id: sequence.id.clone(), + label: sequence.label.clone(), + source: source.into(), + cases, + renderer: Box::new(move |scene| { + if scene.time_ms % REPLAY_STEP_MS != 0 { + return Err("replay time is not an interaction step".into()); + } + let step = usize::try_from(scene.time_ms / REPLAY_STEP_MS) + .map_err(|_| "invalid replay step")?; + if step > replay.inputs.len() { + return Err("replay step exceeds input sequence".into()); + } + let expected = replay.cases()?[step]; + if (scene.width, scene.height, scene.time_ms) != expected { + return Err("scene does not match replay dimensions".into()); + } + render(scene, &replay.inputs[..step]) + }), + sequence: Some(sequence), + }) + } /// Cross product of sizes and fixed timestamps; no wall clock or sleeps. pub fn matrix(mut self, sizes: &[(u16, u16)], times: &[u64]) -> Self { self.cases = sizes @@ -60,21 +95,20 @@ pub struct Registry { } impl Registry { pub fn add(&mut self, story: Story) -> Result<(), String> { - if story.id.is_empty() - || !story - .id - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-') - { - return Err("story ID must contain letters, digits or hyphens".into()); + validate_id(&story.id)?; + if let Some(sequence) = &story.sequence { + if sequence.cases()? != story.cases { + return Err("replay cases must be derived from its interaction sequence".into()); + } } if self.stories.contains_key(&story.id) { return Err(format!("duplicate story: {}", story.id)); } if story.cases.is_empty() - || story.cases.iter().any(|&(w, h, t)| { - !(8..=240).contains(&w) || !(3..=100).contains(&h) || t > 86_400_000 - }) + || story + .cases + .iter() + .any(|&(w, h, t)| validate_dimensions(w, h).is_err() || t > 86_400_000) { return Err("story needs bounded dimensions and timestamps".into()); } @@ -87,6 +121,12 @@ impl Registry { self.stories.insert(story.id.clone(), story); Ok(()) } + pub fn sequences(&self) -> Vec { + self.stories + .values() + .filter_map(|story| story.sequence.clone()) + .collect() + } /// Bridge existing catalogs without changing their IDs or capture matrix. pub fn import( &mut self, @@ -165,4 +205,53 @@ mod tests { review::json(®istry.captures().unwrap()).unwrap() ); } + + #[test] + fn replay_uses_prefixes_and_rejects_mismatched_dimensions() { + let sequence = StorySequence::new("menu-replay", "Menu replay", 40, 10).inputs([ + StoryInput::Text { text: "猫".into() }, + StoryInput::Resize { + width: 20, + height: 8, + }, + ]); + let story = Story::replay(sequence.clone(), "example.rs", |scene, inputs| { + let mut buffer = + Buffer::empty(ratatui::layout::Rect::new(0, 0, scene.width, scene.height)); + buffer.set_string( + 0, + 0, + inputs.len().to_string(), + ratatui::style::Style::default(), + ); + Ok(buffer) + }) + .unwrap(); + let mut registry = Registry::default(); + registry.add(story).unwrap(); + assert_eq!(registry.sequences(), vec![sequence]); + let captures = registry.captures().unwrap(); + assert_eq!(captures.len(), 3); + assert_eq!(captures[0].cells[0].text, "0"); + assert_eq!(captures[2].scene.width, 20); + let mut invalid = captures[2].scene.clone(); + invalid.width = 21; + assert!(registry.render(&invalid).is_err()); + } + + #[test] + fn replay_matrix_cannot_replace_sequence_derived_cases() { + let sequence = StorySequence::new("matrix-replay", "Matrix replay", 40, 10); + let story = Story::replay(sequence, "example.rs", |scene, _| { + Ok(Buffer::empty(ratatui::layout::Rect::new( + 0, + 0, + scene.width, + scene.height, + ))) + }) + .unwrap() + .matrix(&[(80, 20)], &[0]); + assert!(Registry::default().add(story).is_err()); + } } diff --git a/packages/ui-preview-rs/src/review.html b/packages/ui-preview-rs/src/review.html index c259c815b..372149e6c 100644 --- a/packages/ui-preview-rs/src/review.html +++ b/packages/ui-preview-rs/src/review.html @@ -105,6 +105,31 @@ color: #b0a8c6; padding: 0 28px; } + #interaction { + margin: 0 18px 14px; + padding: 14px; + border: 1px solid #4e4764; + background: #1d1b2a; + } + #interaction .controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: end; + } + #interaction input[type="number"] { + width: 76px; + } + #interaction p { + margin: 12px 0 0; + color: #b0a8c6; + font-size: 11px; + } + #screen.live { + cursor: text; + outline: 1px dashed #4e4764; + outline-offset: 8px; + } a { color: #aba0ff; } @@ -286,19 +311,36 @@

DEIXIC / UI LIBRARY

+

SELECTED STATE

-

+