diff --git a/.repository-projection.json b/.repository-projection.json index fd819f87a..517b0262b 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "6139cda76fc6bd4d07a40c8351468364c090e619", + "sourceSha": "915d91afd3e2038786a2e1cf12dd920ce17a8316", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "7f514fca918d431635a0ba92a825eb4bd0812cac", - "definitionDigest": "064b6b9745d392dde277afd7dfdf028cff32b6178a8b9c99c518343219bab429", - "toolDigest": "c8000985e1bd708bf1f8754b27cc1a2aad67af10ee23cf5317bd1fba670b4456", - "contentDigest": "0a9c99f245ca6f3b5929664ecdaa6dc22347566ee2a0af6f97bd8ac4c482bc64", + "priorProjectedBase": "09d57e69e8649768da349fb38eb9c513df0e5d01", + "definitionDigest": "cc5446d8982464d5504de96b74641fe6f74b8e430830d0cca507e19fab0041b8", + "toolDigest": "de718162d050963e0c04ca9cbc0e38b1db545e838e587f949bcd9fd8aded6bcd", + "contentDigest": "eeeb25196b828d9c832f1193fd3974f52c00fd422ed308c137262393d8a9b7d4", "publicationEligible": true } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7a793e5f..a770a7a38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,27 @@ cargo run -p maestro -- web --port 3000 Use `cargo test -p [test-name]` for focused work. Workspace package names include `maestro`, `maestro-tui`, `maestro-runtime`, `maestro-runtime-contracts`, `maestro-runtime-gateway`, and `ambient-agent`. +### Native UI development + +Use the repository-local UI front door instead of remembering Cargo examples +or fixture paths: + +```bash +./dev ui # live full-flow workbench +./dev ui list # registered stories and cases +./dev ui inspect menu-ready # owner, source, states, and exact commands +./dev ui check menu-ready # focused render and behavior verification +./dev ui new workspace-picker --adapter shared-menu --check +./dev ui doctor +``` + +`./dev ui review --story ID` keeps the familiar AI composer and onboarding +flows in the production Rust renderer while narrowing rebuilds to the selected +story. Exported menu and theme receipts replay with `./dev ui replay FILE`. +Run `./dev ui migrate FILE --check` on checked-in receipts. See the +[UI contributor workflow](docs/ui-contributor-workflow.md) for adapters, +compatibility, and pull request evidence. + ## Verification Before opening a pull request, run the checks relevant to the changed surface. For workspace or release changes, run the full set: diff --git a/dev b/dev new file mode 100755 index 000000000..fcd8a4812 --- /dev/null +++ b/dev @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Stable contributor commands for Deixic Code.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +MAESTRO_ROOT = Path(__file__).resolve().parent +MONO_ROOT = ( + MAESTRO_ROOT.parents[1] + if MAESTRO_ROOT.parent.name == "products" + else MAESTRO_ROOT +) +WORKBENCH = MAESTRO_ROOT / "scripts/dev/ui-workbench.py" + + +def cargo_preview(*args: str, stdin: bytes | None = None) -> int: + command = [ + "cargo", + "run", + "--quiet", + "--locked", + "-p", + "maestro-ui-preview", + "--", + *args, + ] + return subprocess.run(command, cwd=MAESTRO_ROOT, input=stdin).returncode + + +def cargo_onboarding(mode: str, body: bytes) -> int: + command = [ + "cargo", + "run", + "--quiet", + "--locked", + "-p", + "maestro-tui", + "--example", + "onboarding-preview", + "--", + mode, + ] + return subprocess.run(command, cwd=MAESTRO_ROOT, input=body).returncode + + +def read_receipt(path: str) -> tuple[bytes, str]: + receipt = Path(path) + if receipt.stat().st_size > 1_048_576: + raise ValueError("receipt exceeds 1048576 bytes") + body = receipt.read_bytes() + value = json.loads(body) + if not isinstance(value, dict) or not isinstance(value.get("schema"), str): + raise ValueError("receipt must be a versioned wire envelope") + return body, value["schema"] + + +def changed_review_filter() -> list[str]: + if MONO_ROOT == MAESTRO_ROOT or not (MONO_ROOT / ".git").exists(): + return [] + base = subprocess.run( + ["git", "merge-base", "origin/main", "HEAD"], + cwd=MONO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + paths = subprocess.run( + ["git", "diff", "--name-only", base, "HEAD", "--", "products/maestro"], + cwd=MONO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + visual = [path for path in paths if "/packages/ui-preview-rs/" in path] + if visual and len(visual) == len(paths): + return ["--components-only", "--adapter", "shared-menu"] + return [] + + +def doctor(as_json: bool) -> int: + tools = {name: shutil.which(name) for name in ("cargo", "python3", "rustfmt")} + required_paths = [ + MAESTRO_ROOT / "Cargo.toml", + MAESTRO_ROOT / "packages/ui-preview-rs/Cargo.toml", + WORKBENCH, + ] + missing = [str(path) for path in required_paths if not path.is_file()] + ready = tools["cargo"] is not None and tools["python3"] is not None and not missing + report = { + "status": "ready" if ready else "blocked", + "maestro_root": str(MAESTRO_ROOT), + "repository_root": str(MONO_ROOT), + "layout": "mono" if MONO_ROOT != MAESTRO_ROOT else "public", + "tools": tools, + "missing": missing, + } + if as_json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"Maestro UI development: {report['status']}") + print(f"Layout: {report['layout']} ({MAESTRO_ROOT})") + for name, path in tools.items(): + print(f"{name}: {path or 'missing'}") + for path in missing: + print(f"missing: {path}") + return 0 if ready else 2 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="./dev", description=__doc__) + top = parser.add_subparsers(dest="area", required=True) + ui = top.add_parser("ui", help="design, inspect, replay, and review native UI stories") + commands = ui.add_subparsers(dest="command") + + commands.add_parser("list", help="list registered stories and cases") + new = commands.add_parser("new", help="plan or create an adapter-owned story") + new.add_argument("story") + new.add_argument("--adapter", default="shared-menu") + new.add_argument("--check", action="store_true") + + check = commands.add_parser("check", help="render one story and evaluate its contract") + check.add_argument("story") + check.add_argument("--require-contract", action="store_true") + + inspect = commands.add_parser("inspect", help="show owner, source, cases, and exact commands") + inspect.add_argument("story") + inspect.add_argument("--json", action="store_true", help="reserved; output is always JSON") + + replay = commands.add_parser("replay", help="replay a portable menu or theme receipt") + replay.add_argument("receipt") + + migrate = commands.add_parser("migrate", help="migrate a versioned recipe, replay, or contract") + migrate.add_argument("receipt") + migrate.add_argument("--check", action="store_true") + + review = commands.add_parser("review", help="open the live production-renderer workbench") + review.add_argument("--story") + review.add_argument("--adapter") + review.add_argument("--components-only", action="store_true") + review.add_argument("--changed", action="store_true") + review.add_argument("--port", type=int, default=8770) + + doctor_parser = commands.add_parser("doctor", help="check the local UI toolchain and layout") + doctor_parser.add_argument("--json", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.area != "ui": + parser.error("unknown development area") + command = args.command or "review" + if command == "list": + return cargo_preview("--list") + if command == "new": + tail = ["studio", "new", "--adapter", args.adapter, args.story] + if args.check: + tail.append("--check") + return cargo_preview(*tail) + if command == "check": + tail = ["studio", "verify", args.story] + if args.require_contract: + tail.append("--require-contract") + return cargo_preview(*tail) + if command == "inspect": + return cargo_preview("studio", "inspect", args.story) + if command == "migrate": + tail = ["studio", "migrate", args.receipt] + if args.check: + tail.append("--check") + return cargo_preview(*tail) + if command == "replay": + try: + body, schema = read_receipt(args.receipt) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(error, file=sys.stderr) + return 2 + if schema == "maestro.ui.menu-recipe": + return cargo_onboarding("--studio-stdin", body) + if schema == "maestro.ui.theme-replay": + return cargo_onboarding("--replay-stdin", body) + print(f"unsupported replay schema: {schema}", file=sys.stderr) + return 2 + if command == "doctor": + return doctor(args.json) + if command == "review": + port = getattr(args, "port", 8770) + story = getattr(args, "story", None) + adapter = getattr(args, "adapter", None) + tail = [sys.executable, str(WORKBENCH), "--port", str(port)] + if getattr(args, "changed", False): + tail.extend(changed_review_filter()) + elif getattr(args, "components_only", False): + tail.append("--components-only") + if story: + tail.extend(["--story", story]) + if adapter: + tail.extend(["--adapter", adapter]) + return subprocess.run(tail, cwd=MAESTRO_ROOT).returncode + parser.error(f"unknown ui command: {command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/tui-rs/examples/onboarding-preview.rs b/packages/tui-rs/examples/onboarding-preview.rs index c3246b0e4..a6108b39c 100644 --- a/packages/tui-rs/examples/onboarding-preview.rs +++ b/packages/tui-rs/examples/onboarding-preview.rs @@ -108,18 +108,21 @@ fn fixture(id: &str, expected: SetupPage) -> SetupModal { assert_eq!(modal.page(), expected, "fixture {id}"); modal } -fn captures() -> Result, String> { +fn captures_selected(story: Option<&str>) -> Result, String> { let mut captures = Vec::new(); for (width, height) in [(40, 24), (60, 32), (100, 40)] { for motion in [true, false] { + let scene_id = if motion { + "first-boot" + } else { + "first-boot-motion-off" + }; + if story.is_some_and(|selected| selected != scene_id) { + continue; + } for tick in 0..if motion { 36 } else { 1 } { let scene = Scene { - id: if motion { - "first-boot" - } else { - "first-boot-motion-off" - } - .into(), + id: scene_id.into(), label: if motion { "First launch" } else { @@ -141,6 +144,10 @@ fn captures() -> Result, String> { } } for &(id, page) in STATES { + let scene_id = format!("onboarding-{id}"); + if story.is_some_and(|selected| selected != scene_id) { + continue; + } let times: &[u64] = if id == "welcome" { &[0, 640, 1280, 1920] } else { @@ -149,7 +156,7 @@ fn captures() -> Result, String> { for &time_ms in times { let mut modal = fixture(id, page); let scene = Scene { - id: format!("onboarding-{id}"), + id: scene_id.clone(), label: format!("Onboarding / {id}"), width, height, @@ -172,10 +179,22 @@ fn captures() -> Result, String> { for capture in &mut captures { capture.source = "products/maestro/packages/tui-rs/examples/onboarding-preview.rs".into(); } - captures.extend(support::ui_stories::captures()?); - captures.extend(maestro_ui_preview::registry()?.captures()?); + captures.extend(support::ui_stories::captures_selected(story)?); + match story { + Some(id) => { + if let Ok(selected) = maestro_ui_preview::registry()? + .filtered(maestro_ui_preview::StoryFilter::Story(id.into())) + { + captures.extend(selected.captures()?); + } + } + None => captures.extend(maestro_ui_preview::registry()?.captures()?), + } Ok(captures) } +fn captures() -> Result, String> { + captures_selected(None) +} fn profiled_captures(profile_id: &str) -> Result, String> { let profile = maestro_ui_preview::schema::builtin_profile(profile_id)?; let mut selected = captures()?; @@ -272,6 +291,7 @@ fn run() -> Result<(), String> { } let mut format = None; let mut profile = None; + let mut story = None; let mut index = 0; while index < args.len() { match args[index].as_str() { @@ -284,13 +304,28 @@ fn run() -> Result<(), String> { .as_str(), ); } + "--story" if story.is_none() => { + index += 1; + story = Some(args.get(index).ok_or("missing value for --story")?.as_str()); + } _ => { - return Err("usage: onboarding-preview [--html|--json] [--profile pr-v1|scheduled-v1] | [--sequences|--replay-stdin|--studio-stdin]".into()); + return Err("usage: onboarding-preview [--html|--json] [--profile pr-v1|scheduled-v1] [--story ID] | [--sequences|--replay-stdin|--studio-stdin]".into()); } } index += 1; } - let captures = profile.map_or_else(captures, profiled_captures)?; + if profile.is_some() && story.is_some() { + return Err("--profile and --story cannot be combined".into()); + } + let captures = if let Some(story) = story { + let selected = captures_selected(Some(story))?; + if selected.is_empty() { + return Err(format!("unknown story: {story}")); + } + selected + } else { + profile.map_or_else(captures, profiled_captures)? + }; let output = if format == Some("--html") { review::html(&captures)? } else { diff --git a/packages/tui-rs/examples/support/theme_selector_story.rs b/packages/tui-rs/examples/support/theme_selector_story.rs index 996a9eb41..ee4966961 100644 --- a/packages/tui-rs/examples/support/theme_selector_story.rs +++ b/packages/tui-rs/examples/support/theme_selector_story.rs @@ -407,11 +407,14 @@ pub fn fixture_requests() -> Vec { .collect() } -pub fn captures() -> Result, String> { +pub fn captures_selected(selected: Option<&str>) -> Result, String> { let mut captures = Vec::new(); for fixture in FIXTURES { let (width, height) = fixture.dimensions(); let story_id = format!("theme-selector-{}", fixture.id()); + if selected.is_some_and(|id| id != story_id) { + continue; + } let story = ThemeStory::new(fixture, width, height)?; let contract = StoryContract::new(&story_id, "maestro-tui") .expect(StoryExpectation::observation("state", fixture.id())) @@ -424,6 +427,9 @@ pub fn captures() -> Result, String> { } for (index, request) in presets().into_iter().enumerate() { let id = format!("theme-selector-interaction-{}", index + 1); + if selected.is_some_and(|story_id| story_id != id) { + continue; + } let label = match index { 0 => "Theme selector interaction / keyboard commit", 1 => "Theme selector interaction / type and cancel", @@ -454,10 +460,26 @@ pub fn captures() -> Result, String> { Ok(captures) } +#[cfg(test)] +fn captures() -> Result, String> { + captures_selected(None) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn focused_capture_renders_only_the_selected_story() { + let captures = captures_selected(Some("theme-selector-interaction-1")).unwrap(); + assert!(!captures.is_empty()); + assert!( + captures + .iter() + .all(|capture| capture.scene.id == "theme-selector-interaction-1") + ); + } + #[test] fn combined_retry_and_cancel_has_one_final_state_expectation() { let request = ThemeReplayRequest { diff --git a/packages/tui-rs/examples/support/ui_stories.rs b/packages/tui-rs/examples/support/ui_stories.rs index 42f7b7e3a..6b4c4711c 100644 --- a/packages/tui-rs/examples/support/ui_stories.rs +++ b/packages/tui-rs/examples/support/ui_stories.rs @@ -23,8 +23,9 @@ generated_stories! { // maestro-ui-stories:end } -pub fn captures() -> Result, String> { - let mut captures = super::theme_selector_story::captures()?; +pub fn captures_selected(selected: Option<&str>) -> Result, String> { + let mut captures = super::theme_selector_story::captures_selected(selected)?; generated_captures(&mut captures)?; + captures.retain(|capture| selected.is_none_or(|id| capture.scene.id == id)); Ok(captures) } diff --git a/packages/ui-preview-rs/README.md b/packages/ui-preview-rs/README.md index 40e7d06f8..661428940 100644 --- a/packages/ui-preview-rs/README.md +++ b/packages/ui-preview-rs/README.md @@ -7,11 +7,16 @@ script so changing preview inputs cannot invalidate the native TUI library. From the public repository root (or `products/maestro` in Mono), run: ```sh -cargo run --locked -p maestro-ui-preview -- --list -cargo run --locked -p maestro-ui-preview -- --scene startup --width 100 --height 10 -cargo test --locked -p maestro-ui-preview +./dev ui +./dev ui list +./dev ui inspect menu-ready +./dev ui check menu-ready ``` +In Mono, use `products/maestro/dev ui ...`. The front door discovers either +layout and keeps adapter-owned paths relative to the Maestro workspace, so the +same plan and receipt work in the internal and public repositories. + The executable prints ANSI terminal previews. In Mono, the optional `make maestro-ui-review MAESTRO_UI_OUTPUT=/tmp/dex-review` wrapper builds a comparison gallery; use a new output directory for each run. That wrapper and @@ -92,17 +97,20 @@ write, then verify the registered story through the same result stream used by the gallery and PR evidence: ```sh -cargo run --locked -p maestro-ui-preview -- studio new --adapter shared-menu workspace-picker --check -cargo run --locked -p maestro-ui-preview -- studio new --adapter shared-menu workspace-picker -cargo run --locked -p maestro-ui-preview -- studio verify workspace-picker -cargo run --locked -p maestro-ui-preview -- studio promote --adapter shared-menu path/to/menu-recipe.json --check -cargo run --locked -p maestro-ui-preview -- studio coverage +./dev ui new workspace-picker --adapter shared-menu --check +./dev ui new workspace-picker --adapter shared-menu +./dev ui check workspace-picker +./dev ui inspect workspace-picker ``` Promotion validates the recipe before writing, refuses existing fixtures, runs a fixed adapter verifier, and restores the invocation-owned fixture and registration edit if verification fails. Adapter manifests publish the owner, supported story kinds, coverage profiles, source directory, and lifecycle. +`inspect` adds the exact source, case matrix, input and assertion counts, and +copyable check/review commands. A successful check says +`behavior-not-asserted` when a story has no semantic contract; use +`--require-contract` when that should fail. The complete authoring loop is: @@ -238,7 +246,13 @@ add 21 bounded cases, including select, filter/cancel, retry/select, and Unicode/resize/cancel prefixes. Registration rejects duplicate IDs or invalid dimensions; it never accepts a baseline. -The workbench preserves URL state and scroll across successful refreshes. +The workbench preserves scene, size, frame, comparison, named layout, and scroll +state across successful refreshes. **Browse**, **Design**, **Debug**, and +**Review** reorganize the same production-rendered catalog without creating a +second renderer. The header distinguishes the complete catalog from a focused +`--story` or `--adapter` server and exposes the last-good checkout and content +digests. A link to a scene outside the current server scope remains intact and +shows an explicit unavailable notice instead of silently replacing the URL. 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 existing build-capacity check before diff --git a/packages/ui-preview-rs/src/adapters.rs b/packages/ui-preview-rs/src/adapters.rs index db3deb9bc..98940bcf6 100644 --- a/packages/ui-preview-rs/src/adapters.rs +++ b/packages/ui-preview-rs/src/adapters.rs @@ -164,8 +164,8 @@ impl AdapterRegistry { ..AdapterManifest::active( "legacy", "maestro-presentation", - "products/maestro/packages/ui-preview-rs/src", - "products/maestro/packages/ui-preview-rs/src/lib.rs", + "packages/ui-preview-rs/src", + "packages/ui-preview-rs/src/lib.rs", StoryTemplate::ThemeSelector, ) }, @@ -174,8 +174,8 @@ impl AdapterRegistry { ..AdapterManifest::active( "shared-menu", "maestro-ui", - "products/maestro/packages/ui-preview-rs/src/stories", - "products/maestro/packages/ui-preview-rs/src/stories/mod.rs", + "packages/ui-preview-rs/src/stories", + "packages/ui-preview-rs/src/stories/mod.rs", StoryTemplate::MenuRecipe, ) }, @@ -185,8 +185,8 @@ impl AdapterRegistry { ..AdapterManifest::active( "theme-selector", "maestro-tui", - "products/maestro/packages/tui-rs/examples/support/ui_stories", - "products/maestro/packages/tui-rs/examples/support/ui_stories.rs", + "packages/tui-rs/examples/support/ui_stories", + "packages/tui-rs/examples/support/ui_stories.rs", StoryTemplate::ThemeSelector, ) }, @@ -570,7 +570,7 @@ mod tests { assert_eq!(theme.verifier, AdapterVerifier::OnboardingPreview); assert_eq!( theme.fixture_dir, - PathBuf::from("products/maestro/packages/tui-rs/examples/support/ui_stories") + PathBuf::from("packages/tui-rs/examples/support/ui_stories") ); let source = template_source(theme, "workspace-picker").unwrap(); assert!(source.contains("ThemeSelector::with_themes")); diff --git a/packages/ui-preview-rs/src/main.rs b/packages/ui-preview-rs/src/main.rs index 889f9ebd5..1fb2962ac 100644 --- a/packages/ui-preview-rs/src/main.rs +++ b/packages/ui-preview-rs/src/main.rs @@ -9,19 +9,41 @@ use std::{ process::Command, }; -fn repo_root() -> Result { - let current = std::env::current_dir().map_err(|error| error.to_string())?; - for directory in current.ancestors() { - if directory.join("products/maestro").is_dir() { - return Ok(directory.to_path_buf()); +#[derive(Clone, Debug, Eq, PartialEq)] +struct WorkspaceLayout { + repository_root: PathBuf, + maestro_root: PathBuf, +} +impl WorkspaceLayout { + fn discover_from(start: &Path) -> Result { + for directory in start.ancestors() { + let nested = directory.join("products/maestro"); + if nested.join("Cargo.toml").is_file() && nested.join("packages/ui-preview-rs").is_dir() + { + return Ok(Self { + repository_root: directory.to_path_buf(), + maestro_root: nested, + }); + } + } + for directory in start.ancestors() { + if directory.join("Cargo.toml").is_file() + && directory.join("packages/ui-preview-rs").is_dir() + { + return Ok(Self { + repository_root: directory.to_path_buf(), + maestro_root: directory.to_path_buf(), + }); + } } + Err("run Studio inside Mono or the Maestro public repository".into()) } - let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); - manifest - .ancestors() - .find(|directory| directory.join("products/maestro").is_dir()) - .map(Path::to_path_buf) - .ok_or("run Studio inside the Mono repository".into()) +} + +fn workspace_layout() -> Result { + let current = std::env::current_dir().map_err(|error| error.to_string())?; + WorkspaceLayout::discover_from(¤t) + .or_else(|_| WorkspaceLayout::discover_from(Path::new(env!("CARGO_MANIFEST_DIR")))) } fn verify_adapter( @@ -40,9 +62,8 @@ fn verify_adapter( return Err("generated fixture formatting failed".into()); } } - let maestro = root.join("products/maestro"); let mut cargo = Command::new("cargo"); - cargo.current_dir(maestro).args(["test", "--locked"]); + cargo.current_dir(root).args(["test", "--locked"]); match verifier { AdapterVerifier::UiPreview => { cargo.args(["-p", "maestro-ui-preview"]); @@ -63,7 +84,7 @@ fn verify_adapter( fn onboarding_story(root: &Path, story_id: &str) -> Result>, String> { let output = Command::new("cargo") - .current_dir(root.join("products/maestro")) + .current_dir(root) .args([ "run", "--quiet", @@ -74,10 +95,15 @@ fn onboarding_story(root: &Path, story_id: &str) -> Result Result VerificationSummary { + let assertions = captures + .iter() + .filter_map(|capture| capture.semantic.as_ref()) + .map(|semantic| semantic.expectations.len()) + .sum(); + VerificationSummary { + story: story.into(), + owner: owner.into(), + captures: captures.len(), + assertions, + contract_status: if assertions == 0 { + "behavior-not-asserted" + } else { + "asserted-passed" + }, + } +} + fn validate_onboarding_captures( captures: Vec, story_id: &str, @@ -170,33 +228,135 @@ fn studio(args: &[String]) -> Result<(), String> { ); return Ok(()); } + if command == "inspect" { + if tail.len() != 1 || tail[0].starts_with('-') { + return Err("usage: studio inspect ".into()); + } + let registry = maestro_ui_preview::registry()?; + let inspection = registry.inspect(&tail[0])?; + let adapters = AdapterRegistry::builtins()?; + let manifest = adapters.get(&inspection.adapter)?; + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "story": inspection, + "owner": manifest.owner, + "adapter": manifest, + "commands": { + "check": format!("./dev ui check {}", tail[0]), + "review": format!("./dev ui review --story {}", tail[0]), + }, + })) + .map_err(|error| error.to_string())? + ); + return Ok(()); + } + if command == "migrate" { + let check = tail.iter().any(|arg| arg == "--check"); + let paths: Vec<_> = tail + .iter() + .filter(|arg| arg.as_str() != "--check") + .collect(); + if paths.len() != 1 || paths[0].starts_with('-') { + return Err("usage: studio migrate [--check]".into()); + } + let path = Path::new(paths[0]); + let size = std::fs::metadata(path) + .map_err(|error| format!("read {} metadata: {error}", path.display()))? + .len(); + if size > 1_048_576 { + return Err("receipt exceeds 1048576 bytes".into()); + } + let bytes = + std::fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?; + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + let schema = value.get("schema").and_then(serde_json::Value::as_str); + let migrated = match schema { + Some(maestro_ui_preview::schema::RECIPE_SCHEMA) => serde_json::to_value( + maestro_ui_preview::schema::migrate_recipe(value) + .map_err(|error| error.to_string())?, + ), + Some(maestro_ui_preview::schema::REPLAY_SCHEMA) => serde_json::to_value( + maestro_ui_preview::schema::migrate_replay(value) + .map_err(|error| error.to_string())?, + ), + Some(maestro_ui_preview::schema::CONTRACT_SCHEMA) => serde_json::to_value( + maestro_ui_preview::schema::migrate_contract(value) + .map_err(|error| error.to_string())?, + ), + Some(other) => return Err(format!("unsupported migration schema: {other}")), + None => return Err("migration requires a versioned wire envelope".into()), + } + .map_err(|error| error.to_string())?; + let encoded = + serde_json::to_string_pretty(&migrated).map_err(|error| error.to_string())? + "\n"; + if check { + let current: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|error| error.to_string())?; + if current != migrated { + return Err(format!("{} requires migration", path.display())); + } + } else { + std::fs::write(path, encoded) + .map_err(|error| format!("write {}: {error}", path.display()))?; + } + println!("{}", path.display()); + return Ok(()); + } if !matches!(command.as_str(), "new" | "verify" | "promote") { return Err(format!("unknown studio command: {command}")); } if command == "verify" { - if tail.len() != 1 { - return Err("usage: studio verify ".into()); + let require_contract = tail.iter().any(|arg| arg == "--require-contract"); + let positional: Vec<_> = tail + .iter() + .filter(|arg| arg.as_str() != "--require-contract") + .collect(); + if positional.len() != 1 { + return Err("usage: studio verify [--require-contract]".into()); } - let story_id = &tail[0]; + let story_id = positional[0]; if story_id.starts_with('-') { return Err(format!("unknown argument: {story_id}")); } - let captures: Vec<_> = maestro_ui_preview::registry()? - .results()? - .into_iter() - .filter(|capture| capture.scene.id == *story_id) - .collect(); - if captures.is_empty() { - let root = repo_root()?; - if let Some(capture) = onboarding_story(&root, story_id)? { - println!( - "{}", - serde_json::to_string_pretty(&capture).map_err(|error| error.to_string())? - ); - return Ok(()); + let registry = maestro_ui_preview::registry()?; + let captures = registry + .filtered(StoryFilter::Story(story_id.clone())) + .and_then(|selected| selected.captures()); + let captures = match captures { + Ok(captures) => captures, + Err(error) if error == format!("unknown story: {story_id}") => { + let root = workspace_layout()?.maestro_root; + if let Some(capture) = onboarding_story(&root, story_id)? { + let assertions = capture + .iter() + .filter_map(|value| value.pointer("/semantic/expectations")) + .filter_map(serde_json::Value::as_array) + .map(Vec::len) + .sum::(); + if require_contract && assertions == 0 { + return Err(format!("story {story_id}: behavior not asserted")); + } + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "summary": { + "story": story_id, + "owner": "maestro-tui", + "captures": capture.len(), + "assertions": assertions, + "contract_status": if assertions == 0 { "behavior-not-asserted" } else { "asserted-passed" }, + }, + "captures": capture, + })).map_err(|error| error.to_string())? + ); + return Ok(()); + } + return Err(format!("unknown story: {story_id}")); } - return Err(format!("unknown story: {story_id}")); - } + Err(error) => return Err(error), + }; let failures: Vec<_> = captures .iter() .filter_map(|capture| capture.semantic.as_ref()) @@ -208,9 +368,23 @@ fn studio(args: &[String]) -> Result<(), String> { failures.join("; ") )); } + let adapters = AdapterRegistry::builtins()?; + let diagnostics = registry.contribution_diagnostics(&adapters)?; + let owner = diagnostics + .iter() + .find(|diagnostic| diagnostic.story == *story_id) + .map_or("unowned", |diagnostic| diagnostic.owner.as_str()); + let summary = verification_summary(story_id, owner, &captures); + if require_contract && summary.assertions == 0 { + return Err(format!("story {story_id}: behavior not asserted")); + } println!( "{}", - serde_json::to_string_pretty(&captures).map_err(|error| error.to_string())? + serde_json::to_string_pretty(&serde_json::json!({ + "summary": summary, + "captures": captures, + })) + .map_err(|error| error.to_string())? ); return Ok(()); } @@ -243,7 +417,7 @@ fn studio(args: &[String]) -> Result<(), String> { positional.ok_or_else(|| format!("studio {command} requires a story or recipe path"))?; let adapters = AdapterRegistry::builtins()?; let adapter = adapters.get(&adapter_id)?; - let root = repo_root()?; + let root = workspace_layout()?.maestro_root; let plan = if command == "new" { create_story(&root, adapter, &value, check)? } else { @@ -437,6 +611,57 @@ fn main() { #[cfg(test)] mod studio_tests { use super::*; + use std::fs; + + fn temp_layout(name: &str) -> PathBuf { + let root = + std::env::temp_dir().join(format!("maestro-ui-layout-{}-{name}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn workspace_layout_supports_mono_and_the_public_repository() { + let mono = temp_layout("mono"); + let maestro = mono.join("products/maestro"); + fs::create_dir_all(maestro.join("packages/ui-preview-rs")).unwrap(); + fs::write(maestro.join("Cargo.toml"), "[workspace]\n").unwrap(); + let layout = WorkspaceLayout::discover_from(&maestro.join("packages/ui-preview-rs")) + .expect("Mono layout"); + assert_eq!(layout.repository_root, mono); + assert_eq!(layout.maestro_root, maestro); + + let public = temp_layout("public"); + fs::create_dir_all(public.join("packages/ui-preview-rs/src")).unwrap(); + fs::write(public.join("Cargo.toml"), "[workspace]\n").unwrap(); + let layout = WorkspaceLayout::discover_from(&public.join("packages/ui-preview-rs/src")) + .expect("public layout"); + assert_eq!(layout.repository_root, public); + assert_eq!(layout.maestro_root, layout.repository_root); + let _ = fs::remove_dir_all(layout.repository_root); + } + + #[test] + fn verification_summary_names_unasserted_behavior() { + let captures = vec![ + maestro_ui_preview::review::capture( + Scene { + id: "plain-menu".into(), + label: "Plain menu".into(), + width: 20, + height: 5, + time_ms: 0, + }, + |_| {}, + ) + .unwrap(), + ]; + let summary = verification_summary("plain-menu", "maestro-ui", &captures); + assert_eq!(summary.contract_status, "behavior-not-asserted"); + assert_eq!(summary.captures, 1); + assert_eq!(summary.assertions, 0); + } #[test] fn controller_capture_verification_rejects_failed_expectations() { diff --git a/packages/ui-preview-rs/src/registry.rs b/packages/ui-preview-rs/src/registry.rs index 320d8a80c..53e1ec2e8 100644 --- a/packages/ui-preview-rs/src/registry.rs +++ b/packages/ui-preview-rs/src/registry.rs @@ -56,6 +56,18 @@ pub struct ContributionDiagnostic { pub status: &'static str, } +#[derive(Clone, Debug, Serialize)] +pub struct StoryInspection { + pub id: String, + pub label: String, + pub source: String, + pub adapter: String, + pub cases: Vec<(u16, u16, u64)>, + pub input_count: usize, + pub assertion_count: usize, + pub contract_status: &'static str, +} + type Renderer = Box Result>; type Observer = Box< dyn Fn( @@ -185,6 +197,34 @@ pub struct Registry { aliases: StoryAliases, } impl Registry { + pub fn inspect(&self, id: &str) -> Result { + let resolved = self.aliases.resolve(id); + let story = self + .stories + .get(&resolved.canonical) + .ok_or_else(|| format!("unknown story: {id}"))?; + let assertion_count = story + .contract + .as_ref() + .map_or(0, |contract| contract.expectations.len()); + Ok(StoryInspection { + id: story.id.clone(), + label: story.label.clone(), + source: story.source.clone(), + adapter: story.adapter.clone(), + cases: story.cases.clone(), + input_count: story + .sequence + .as_ref() + .map_or(0, |sequence| sequence.inputs.len()), + assertion_count, + contract_status: if assertion_count == 0 { + "behavior-not-asserted" + } else { + "asserted" + }, + }) + } pub fn add(&mut self, story: Story) -> Result<(), String> { validate_id(&story.id)?; if let Some(sequence) = &story.sequence { diff --git a/packages/ui-preview-rs/src/review.html b/packages/ui-preview-rs/src/review.html index 5b1765209..70b051a3a 100644 --- a/packages/ui-preview-rs/src/review.html +++ b/packages/ui-preview-rs/src/review.html @@ -35,6 +35,40 @@ color: #b0a8c6; margin: 0; } + .header-tools { + display: grid; + justify-items: end; + gap: 8px; + min-width: min(100%, 440px); + } + #scope-status { + color: #efebff; + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + } + #provenance { + color: #b0a8c6; + font-size: 10px; + max-width: 100%; + } + #provenance summary { cursor: pointer; text-align: right; } + #provenance-detail { margin-top: 7px; overflow-wrap: anywhere; text-align: right; } + .layout-switcher { + display: flex; + gap: 0; + } + .layout-switcher button { + padding: 5px 8px; + border-right-width: 0; + color: #b0a8c6; + } + .layout-switcher button:last-child { border-right-width: 1px; } + .layout-switcher button[aria-pressed="true"] { + color: #171624; + background: #aba0ff; + } + #layout-note { text-align: right; } .workbench > nav { padding: 20px 28px; display: flex; @@ -110,6 +144,20 @@ color: #b0a8c6; padding: 0 28px; } + #route-notice { + margin: 12px 18px 0; + padding: 10px 12px; + border: 1px solid #ffbf69; + color: #ffe0b2; + background: #2a211c; + font-size: 11px; + } + #generation-status { + margin: 8px 18px 0; + color: #81798f; + font-size: 10px; + letter-spacing: 0.04em; + } #interaction { margin: 0 18px 14px; padding: 14px; @@ -305,6 +353,13 @@ .studio-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; align-items: start; } .studio-layout #inspector { margin-top: 20px; } } + body[data-layout="design"] .workspace { grid-template-columns: 230px minmax(0, 1fr); } + body[data-layout="design"] #inspector { border-color: #aba0ff; } + body[data-layout="debug"] .workspace { grid-template-columns: 230px minmax(0, 1fr); } + body[data-layout="debug"] #interaction { border-color: #aba0ff; } + body[data-layout="review"] .workspace { grid-template-columns: minmax(0, 1fr); } + body[data-layout="review"] aside { display: none; } + body[data-layout="review"] main { gap: 48px; } @media (max-width: 760px) { .workspace { grid-template-columns: 150px minmax(0, 1fr); } .field-grid { grid-template-columns: 1fr; } @@ -350,8 +405,21 @@ }
-

DEIXIC / UI SCENE WORKSPACE

-

Explore experiences · author bounded fixtures · replay in Rust

+
+

DEIXIC / UI SCENE WORKSPACE

+

Explore experiences · author bounded fixtures · replay in Rust

+
+
+
Loading workspace scope…
+
Build provenance
+
+ + + + +
+

Catalog, state list, and selected preview

+