From 0752ea823403eb86190736452e2440a5f603239b Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 13 Jul 2026 17:07:46 +0900 Subject: [PATCH] feat: bring Uhura into the repo as the sibling client language (RFD 0022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Uhura — a declarative UI language and deterministic headless experience runtime — as a nested Cargo workspace under uhura/, with Spock as its canonical backend provider. Uhura fills the client-language slot Spock's doctrine deliberately leaves vacant (RFD 0010, "generate types, never the client"): a separate language with its own grammar, checker, and runtime, wired to Spock through the port seam. - uhura/: nested Cargo workspace (own rust-toolchain 1.92.0 pin, Cargo.lock, per-crate clippy purity denylists), path-filtered CI workflow, wasm build. - docs/rfd/0022: records the integration direction — contract projection (Spock Contract -> Uhura port contracts), adapter obligations, and the determinism boundary. No integration code lands yet; this is repo setup. - rust-toolchain.toml: pin the root workspace to 1.92.0 to match uhura; Spock builds and tests green on it (was floating on stable). - examples/instagram-uhura/: the Uhura client slice for the Instagram domain, at repo root beside examples/instagram/ (the Spock backend slice). Kept as distinct folders until the languages are wired; merge into one instagram/ domain planned (RFD 0022 section 7). Canonical PRD stays examples/instagram/PRD.md (language-neutral). Full acceptance gate green in the new location: fmt, clippy -D warnings, 33 suites / 118 tests / 0 ignored, native<->wasm parity byte-identical under UHURA_REQUIRE_PARITY=1. --- .claude/launch.json | 11 + .github/workflows/uhura.yml | 43 + .gitignore | 3 + README.md | 13 + docs/rfd/0022-uhura-the-client-language.md | 173 +++ examples/README.md | 11 + examples/instagram-uhura/README.md | 34 + .../app/feed/page.examples.uhura | 66 + examples/instagram-uhura/app/feed/page.uhura | 166 +++ .../app/profile/[user]/page.examples.uhura | 23 + .../app/profile/[user]/page.uhura | 92 ++ examples/instagram-uhura/catalog/base.toml | 154 ++ .../components/bottom-nav.examples.uhura | 9 + .../components/bottom-nav.uhura | 31 + .../components/comment-row.examples.uhura | 21 + .../components/comment-row.uhura | 28 + .../components/notice-bar.examples.uhura | 5 + .../components/notice-bar.uhura | 21 + .../components/post-card.examples.uhura | 35 + .../components/post-card.uhura | 73 + .../components/profile-header.examples.uhura | 11 + .../components/profile-header.uhura | 45 + .../components/stories-tray.examples.uhura | 5 + .../components/stories-tray.uhura | 28 + .../fixtures/assets/avatar-ayla.jpg | Bin 0 -> 2599 bytes .../fixtures/assets/avatar-june.jpg | Bin 0 -> 2615 bytes .../fixtures/assets/avatar-kenji.jpg | Bin 0 -> 2373 bytes .../fixtures/assets/avatar-lena.jpg | Bin 0 -> 2539 bytes .../fixtures/assets/avatar-marco.jpg | Bin 0 -> 2397 bytes .../fixtures/assets/avatar-mira.jpg | Bin 0 -> 2665 bytes .../fixtures/assets/avatar-nils.jpg | Bin 0 -> 2498 bytes .../fixtures/assets/avatar-priya.jpg | Bin 0 -> 2509 bytes .../fixtures/assets/avatar-theo.jpg | Bin 0 -> 2532 bytes .../fixtures/assets/manifest.toml | 250 ++++ .../fixtures/assets/media-ayla-ferry.jpg | Bin 0 -> 24708 bytes .../fixtures/assets/media-june-lookbook.jpg | Bin 0 -> 29988 bytes .../fixtures/assets/media-kenji-copper.jpg | Bin 0 -> 31456 bytes .../fixtures/assets/media-lena-glaze.jpg | Bin 0 -> 120347 bytes .../fixtures/assets/media-marco-baja-1.jpg | Bin 0 -> 25439 bytes .../fixtures/assets/media-marco-baja-2.jpg | Bin 0 -> 26509 bytes .../fixtures/assets/media-marco-baja-3.jpg | Bin 0 -> 25377 bytes .../assets/media-nils-aurora-poster.jpg | Bin 0 -> 27351 bytes .../fixtures/assets/media-priya-starter.jpg | Bin 0 -> 58838 bytes .../fixtures/assets/media-theo-court.jpg | Bin 0 -> 30117 bytes .../fixtures/assets/thumb-lena-1.jpg | Bin 0 -> 48452 bytes .../fixtures/assets/thumb-lena-2.jpg | Bin 0 -> 47881 bytes .../fixtures/assets/thumb-lena-3.jpg | Bin 0 -> 48699 bytes .../fixtures/assets/thumb-lena-4.jpg | Bin 0 -> 45983 bytes .../fixtures/assets/thumb-lena-5.jpg | Bin 0 -> 23354 bytes .../fixtures/assets/thumb-lena-6.jpg | Bin 0 -> 23448 bytes .../fixtures/assets/thumb-lena-7.jpg | Bin 0 -> 10913 bytes .../fixtures/assets/thumb-lena-8.jpg | Bin 0 -> 47228 bytes .../fixtures/assets/thumb-lena-9.jpg | Bin 0 -> 10253 bytes .../fixtures/assets/thumb-mira-1.jpg | Bin 0 -> 23269 bytes .../fixtures/assets/thumb-mira-2.jpg | Bin 0 -> 10948 bytes .../fixtures/assets/thumb-mira-3.jpg | Bin 0 -> 48498 bytes .../fixtures/assets/thumb-mira-4.jpg | Bin 0 -> 46967 bytes .../fixtures/assets/thumb-mira-5.jpg | Bin 0 -> 24818 bytes .../fixtures/assets/thumb-mira-6.jpg | Bin 0 -> 9581 bytes .../fixtures/scripts/comment-ok.toml | 46 + .../fixtures/scripts/demo.toml | 117 ++ .../fixtures/scripts/feed-empty.toml | 8 + .../fixtures/scripts/feed-failed.toml | 22 + .../fixtures/scripts/like-ok.toml | 23 + .../fixtures/scripts/like-refused.toml | 24 + .../fixtures/scripts/paginate.toml | 27 + .../instagram-uhura/fixtures/standard.toml | 509 +++++++ .../instagram-uhura/ports/comments.port.toml | 51 + examples/instagram-uhura/ports/feed.port.toml | 103 ++ .../instagram-uhura/ports/profile.port.toml | 48 + examples/instagram-uhura/styles/theme.css | 59 + .../surfaces/comments-sheet.examples.uhura | 25 + .../surfaces/comments-sheet.uhura | 110 ++ examples/instagram-uhura/uhura.lock | 6 + examples/instagram-uhura/uhura.toml | 25 + rust-toolchain.toml | 6 + uhura/.gitignore | 4 + uhura/Cargo.lock | 612 ++++++++ uhura/Cargo.toml | 36 + uhura/README.md | 99 ++ uhura/crates/uhura-base/Cargo.toml | 13 + uhura/crates/uhura-base/clippy.toml | 20 + uhura/crates/uhura-base/src/canonical.rs | 118 ++ uhura/crates/uhura-base/src/codes.rs | 128 ++ uhura/crates/uhura-base/src/diagnostic.rs | 133 ++ uhura/crates/uhura-base/src/envelope.rs | 153 ++ uhura/crates/uhura-base/src/lib.rs | 19 + uhura/crates/uhura-base/src/span.rs | 132 ++ uhura/crates/uhura-base/src/value.rs | 164 ++ uhura/crates/uhura-check/Cargo.toml | 17 + uhura/crates/uhura-check/clippy.toml | 20 + uhura/crates/uhura-check/src/catalog.rs | 787 ++++++++++ uhura/crates/uhura-check/src/examples.rs | 420 ++++++ uhura/crates/uhura-check/src/fixture.rs | 309 ++++ uhura/crates/uhura-check/src/infer.rs | 860 +++++++++++ uhura/crates/uhura-check/src/lib.rs | 23 + uhura/crates/uhura-check/src/lower.rs | 895 +++++++++++ uhura/crates/uhura-check/src/manifest.rs | 188 +++ uhura/crates/uhura-check/src/markup.rs | 1262 ++++++++++++++++ uhura/crates/uhura-check/src/pipeline.rs | 408 +++++ uhura/crates/uhura-check/src/preview.rs | 609 ++++++++ uhura/crates/uhura-check/src/replay.rs | 583 ++++++++ uhura/crates/uhura-check/src/resolve.rs | 864 +++++++++++ uhura/crates/uhura-check/src/style.rs | 103 ++ uhura/crates/uhura-check/src/types.rs | 230 +++ .../uhura-check/tests/contracts_corpus.rs | 162 ++ uhura/crates/uhura-cli/Cargo.toml | 34 + uhura/crates/uhura-cli/src/bin/gen_assets.rs | 340 +++++ uhura/crates/uhura-cli/src/cmd/check.rs | 93 ++ uhura/crates/uhura-cli/src/cmd/dev.rs | 484 ++++++ uhura/crates/uhura-cli/src/cmd/fmt.rs | 69 + uhura/crates/uhura-cli/src/cmd/mod.rs | 92 ++ uhura/crates/uhura-cli/src/cmd/project.rs | 198 +++ uhura/crates/uhura-cli/src/cmd/trace.rs | 409 +++++ uhura/crates/uhura-cli/src/fsio.rs | 65 + uhura/crates/uhura-cli/src/lib.rs | 16 + uhura/crates/uhura-cli/src/main.rs | 98 ++ uhura/crates/uhura-core/Cargo.toml | 14 + uhura/crates/uhura-core/clippy.toml | 20 + uhura/crates/uhura-core/src/decode.rs | 176 +++ uhura/crates/uhura-core/src/eval.rs | 722 +++++++++ uhura/crates/uhura-core/src/event.rs | 419 ++++++ uhura/crates/uhura-core/src/ir.rs | 620 ++++++++ uhura/crates/uhura-core/src/lib.rs | 15 + uhura/crates/uhura-core/src/state.rs | 193 +++ uhura/crates/uhura-core/src/step.rs | 1314 ++++++++++++++++ uhura/crates/uhura-core/src/trace.rs | 170 +++ uhura/crates/uhura-core/src/view.rs | 251 ++++ uhura/crates/uhura-core/tests/txn_matrix.rs | 1282 ++++++++++++++++ uhura/crates/uhura-fixture/Cargo.toml | 15 + uhura/crates/uhura-fixture/clippy.toml | 20 + uhura/crates/uhura-fixture/src/driver.rs | 369 +++++ uhura/crates/uhura-fixture/src/lib.rs | 40 + uhura/crates/uhura-fixture/src/script.rs | 357 +++++ uhura/crates/uhura-fixture/tests/driver.rs | 590 ++++++++ uhura/crates/uhura-port/Cargo.toml | 17 + uhura/crates/uhura-port/clippy.toml | 20 + uhura/crates/uhura-port/src/contract.rs | 459 ++++++ uhura/crates/uhura-port/src/envelope.rs | 294 ++++ uhura/crates/uhura-port/src/lib.rs | 19 + uhura/crates/uhura-port/src/load.rs | 351 +++++ uhura/crates/uhura-port/src/types.rs | 118 ++ uhura/crates/uhura-project/Cargo.toml | 13 + uhura/crates/uhura-project/clippy.toml | 20 + uhura/crates/uhura-project/src/icons.rs | 53 + uhura/crates/uhura-project/src/lib.rs | 544 +++++++ uhura/crates/uhura-syntax/Cargo.toml | 11 + uhura/crates/uhura-syntax/clippy.toml | 20 + uhura/crates/uhura-syntax/src/ast.rs | 533 +++++++ uhura/crates/uhura-syntax/src/css.rs | 134 ++ uhura/crates/uhura-syntax/src/cursor.rs | 424 ++++++ uhura/crates/uhura-syntax/src/format.rs | 637 ++++++++ uhura/crates/uhura-syntax/src/lib.rs | 15 + uhura/crates/uhura-syntax/src/parser/dsl.rs | 650 ++++++++ .../uhura-syntax/src/parser/examples.rs | 296 ++++ uhura/crates/uhura-syntax/src/parser/expr.rs | 436 ++++++ .../crates/uhura-syntax/src/parser/markup.rs | 664 +++++++++ uhura/crates/uhura-syntax/src/parser/mod.rs | 336 +++++ .../crates/uhura-syntax/src/parser/stream.rs | 131 ++ uhura/crates/uhura-syntax/src/token.rs | 105 ++ .../tests/common/normative_sources.rs | 182 +++ .../uhura-syntax/tests/fmt_roundtrip.rs | 54 + .../uhura-syntax/tests/parse_normative.rs | 209 +++ uhura/crates/uhura-tests/Cargo.toml | 20 + .../uhura-tests/goldens/m2/instagram-ir.json | 1 + .../goldens/m2/reject-alt-xor-decorative.txt | 2 + .../m2/reject-class-rooting-violation.txt | 3 + .../m2/reject-controlled-promotion.txt | 2 + .../goldens/m2/reject-event-on-layout.txt | 3 + .../m2/reject-missing-availability-arm.txt | 2 + .../goldens/m2/reject-nested-interactive.txt | 4 + .../m2/reject-non-exhaustive-union-match.txt | 8 + .../m2/reject-unbound-required-prop.txt | 2 + .../m2/reject-undeclared-component-emit.txt | 4 + .../m2/reject-undefined-class-warning.txt | 2 + .../m2/reject-unguarded-projection-read.txt | 2 + .../goldens/m2/reject-unkeyed-each.txt | 2 + .../goldens/m2/reject-unknown-element.txt | 3 + .../goldens/m2/reject-unknown-icon.txt | 3 + .../goldens/m2/reject-unreachable-handler.txt | 3 + .../reject-unresolved-name-did-you-mean.txt | 3 + .../goldens/m3/v-bottom-nav-feed-active.json | 1 + .../m3/v-bottom-nav-profile-active.json | 1 + .../goldens/m3/v-comment-row-pending.json | 1 + .../goldens/m3/v-comment-row-settled.json | 1 + .../goldens/m3/v-comments-sheet-empty.json | 1 + .../m3/v-comments-sheet-populated.json | 1 + .../uhura-tests/goldens/m3/v-feed-empty.json | 1 + .../goldens/m3/v-feed-exhausted.json | 1 + .../uhura-tests/goldens/m3/v-feed-failed.json | 1 + .../goldens/m3/v-feed-first-page.json | 1 + .../goldens/m3/v-feed-loading.json | 1 + .../goldens/m3/v-notice-bar-refusal.json | 1 + .../m3/v-post-card-carousel-liked.json | 1 + .../goldens/m3/v-post-card-image-post.json | 1 + .../goldens/m3/v-post-card-like-pending.json | 1 + .../goldens/m3/v-post-card-video-poster.json | 1 + .../goldens/m3/v-profile-header-lena.json | 1 + .../goldens/m3/v-profile-lena-posts.json | 1 + .../goldens/m3/v-profile-loading.json | 1 + .../goldens/m3/v-profile-self.json | 1 + .../goldens/m3/v-stories-tray-tray.json | 1 + .../goldens/m4/trace-comment-ok.jsonl | 8 + .../goldens/m4/trace-feed-empty.jsonl | 2 + .../goldens/m4/trace-feed-failed.jsonl | 4 + .../goldens/m4/trace-like-ok.jsonl | 4 + .../goldens/m4/trace-like-refused.jsonl | 5 + .../goldens/m4/trace-paginate.jsonl | 5 + .../m4/v-comments-sheet-composing.json | 1 + .../m4/v-comments-sheet-pending-append.json | 1 + .../goldens/m4/v-feed-appended.json | 1 + .../goldens/m4/v-feed-comments-open.json | 1 + .../goldens/m4/v-feed-like-pending.json | 1 + .../goldens/m4/v-feed-like-refused.json | 1 + .../goldens/m4/v-feed-load-failed.json | 1 + .../goldens/m4/v-feed-load-pending.json | 1 + .../goldens/m4/v-profile-tagged-empty.json | 1 + uhura/crates/uhura-tests/src/lib.rs | 2 + .../uhura-tests/tests/acceptance_feed.rs | 813 ++++++++++ .../crates/uhura-tests/tests/common/corpus.rs | 104 ++ uhura/crates/uhura-tests/tests/m2_gates.rs | 282 ++++ uhura/crates/uhura-tests/tests/m3_gates.rs | 193 +++ uhura/crates/uhura-tests/tests/m4_gates.rs | 443 ++++++ uhura/crates/uhura-tests/tests/purity.rs | 184 +++ uhura/crates/uhura-wasm/Cargo.toml | 26 + uhura/crates/uhura-wasm/src/lib.rs | 191 +++ uhura/crates/uhura-wasm/tests/abi_contract.rs | 425 ++++++ uhura/docs/README.md | 47 + uhura/docs/rfcs/0001-project-foundation.md | 230 +++ uhura/docs/rfcs/README.md | 12 + uhura/docs/spec/README.md | 333 +++++ uhura/docs/working-group/README.md | 68 + .../frame-stress-test-handoff.md | 132 ++ .../working-group/instagram-spike-design.md | 1322 +++++++++++++++++ uhura/rust-toolchain.toml | 6 + uhura/scripts/build-wasm.sh | 45 + uhura/scripts/parity.mjs | 217 +++ uhura/shell/appliers.js | 227 +++ uhura/shell/focus.js | 41 + uhura/shell/index.html | 20 + uhura/shell/jsconfig.json | 15 + uhura/shell/main.js | 221 +++ uhura/shell/overlay.js | 99 ++ uhura/shell/pump.js | 101 ++ uhura/shell/reconciler.js | 219 +++ uhura/shell/scroll.js | 123 ++ uhura/shell/shell.css | 64 + uhura/shell/surfaces.js | 95 ++ uhura/shell/textfield.js | 104 ++ uhura/shell/ticks.js | 49 + uhura/shell/types.js | 95 ++ uhura/shell/wasm.d.ts | 38 + 252 files changed, 33874 insertions(+) create mode 100644 .claude/launch.json create mode 100644 .github/workflows/uhura.yml create mode 100644 docs/rfd/0022-uhura-the-client-language.md create mode 100644 examples/instagram-uhura/README.md create mode 100644 examples/instagram-uhura/app/feed/page.examples.uhura create mode 100644 examples/instagram-uhura/app/feed/page.uhura create mode 100644 examples/instagram-uhura/app/profile/[user]/page.examples.uhura create mode 100644 examples/instagram-uhura/app/profile/[user]/page.uhura create mode 100644 examples/instagram-uhura/catalog/base.toml create mode 100644 examples/instagram-uhura/components/bottom-nav.examples.uhura create mode 100644 examples/instagram-uhura/components/bottom-nav.uhura create mode 100644 examples/instagram-uhura/components/comment-row.examples.uhura create mode 100644 examples/instagram-uhura/components/comment-row.uhura create mode 100644 examples/instagram-uhura/components/notice-bar.examples.uhura create mode 100644 examples/instagram-uhura/components/notice-bar.uhura create mode 100644 examples/instagram-uhura/components/post-card.examples.uhura create mode 100644 examples/instagram-uhura/components/post-card.uhura create mode 100644 examples/instagram-uhura/components/profile-header.examples.uhura create mode 100644 examples/instagram-uhura/components/profile-header.uhura create mode 100644 examples/instagram-uhura/components/stories-tray.examples.uhura create mode 100644 examples/instagram-uhura/components/stories-tray.uhura create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-ayla.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-june.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-kenji.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-lena.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-marco.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-mira.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-nils.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-priya.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/avatar-theo.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/manifest.toml create mode 100644 examples/instagram-uhura/fixtures/assets/media-ayla-ferry.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-june-lookbook.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-kenji-copper.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-lena-glaze.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-marco-baja-1.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-marco-baja-2.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-marco-baja-3.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-nils-aurora-poster.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-priya-starter.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/media-theo-court.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-1.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-2.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-3.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-4.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-5.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-6.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-7.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-8.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-lena-9.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-1.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-2.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-3.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-4.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-5.jpg create mode 100644 examples/instagram-uhura/fixtures/assets/thumb-mira-6.jpg create mode 100644 examples/instagram-uhura/fixtures/scripts/comment-ok.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/demo.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/feed-empty.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/feed-failed.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/like-ok.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/like-refused.toml create mode 100644 examples/instagram-uhura/fixtures/scripts/paginate.toml create mode 100644 examples/instagram-uhura/fixtures/standard.toml create mode 100644 examples/instagram-uhura/ports/comments.port.toml create mode 100644 examples/instagram-uhura/ports/feed.port.toml create mode 100644 examples/instagram-uhura/ports/profile.port.toml create mode 100644 examples/instagram-uhura/styles/theme.css create mode 100644 examples/instagram-uhura/surfaces/comments-sheet.examples.uhura create mode 100644 examples/instagram-uhura/surfaces/comments-sheet.uhura create mode 100644 examples/instagram-uhura/uhura.lock create mode 100644 examples/instagram-uhura/uhura.toml create mode 100644 rust-toolchain.toml create mode 100644 uhura/.gitignore create mode 100644 uhura/Cargo.lock create mode 100644 uhura/Cargo.toml create mode 100644 uhura/README.md create mode 100644 uhura/crates/uhura-base/Cargo.toml create mode 100644 uhura/crates/uhura-base/clippy.toml create mode 100644 uhura/crates/uhura-base/src/canonical.rs create mode 100644 uhura/crates/uhura-base/src/codes.rs create mode 100644 uhura/crates/uhura-base/src/diagnostic.rs create mode 100644 uhura/crates/uhura-base/src/envelope.rs create mode 100644 uhura/crates/uhura-base/src/lib.rs create mode 100644 uhura/crates/uhura-base/src/span.rs create mode 100644 uhura/crates/uhura-base/src/value.rs create mode 100644 uhura/crates/uhura-check/Cargo.toml create mode 100644 uhura/crates/uhura-check/clippy.toml create mode 100644 uhura/crates/uhura-check/src/catalog.rs create mode 100644 uhura/crates/uhura-check/src/examples.rs create mode 100644 uhura/crates/uhura-check/src/fixture.rs create mode 100644 uhura/crates/uhura-check/src/infer.rs create mode 100644 uhura/crates/uhura-check/src/lib.rs create mode 100644 uhura/crates/uhura-check/src/lower.rs create mode 100644 uhura/crates/uhura-check/src/manifest.rs create mode 100644 uhura/crates/uhura-check/src/markup.rs create mode 100644 uhura/crates/uhura-check/src/pipeline.rs create mode 100644 uhura/crates/uhura-check/src/preview.rs create mode 100644 uhura/crates/uhura-check/src/replay.rs create mode 100644 uhura/crates/uhura-check/src/resolve.rs create mode 100644 uhura/crates/uhura-check/src/style.rs create mode 100644 uhura/crates/uhura-check/src/types.rs create mode 100644 uhura/crates/uhura-check/tests/contracts_corpus.rs create mode 100644 uhura/crates/uhura-cli/Cargo.toml create mode 100644 uhura/crates/uhura-cli/src/bin/gen_assets.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/check.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/dev.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/fmt.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/mod.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/project.rs create mode 100644 uhura/crates/uhura-cli/src/cmd/trace.rs create mode 100644 uhura/crates/uhura-cli/src/fsio.rs create mode 100644 uhura/crates/uhura-cli/src/lib.rs create mode 100644 uhura/crates/uhura-cli/src/main.rs create mode 100644 uhura/crates/uhura-core/Cargo.toml create mode 100644 uhura/crates/uhura-core/clippy.toml create mode 100644 uhura/crates/uhura-core/src/decode.rs create mode 100644 uhura/crates/uhura-core/src/eval.rs create mode 100644 uhura/crates/uhura-core/src/event.rs create mode 100644 uhura/crates/uhura-core/src/ir.rs create mode 100644 uhura/crates/uhura-core/src/lib.rs create mode 100644 uhura/crates/uhura-core/src/state.rs create mode 100644 uhura/crates/uhura-core/src/step.rs create mode 100644 uhura/crates/uhura-core/src/trace.rs create mode 100644 uhura/crates/uhura-core/src/view.rs create mode 100644 uhura/crates/uhura-core/tests/txn_matrix.rs create mode 100644 uhura/crates/uhura-fixture/Cargo.toml create mode 100644 uhura/crates/uhura-fixture/clippy.toml create mode 100644 uhura/crates/uhura-fixture/src/driver.rs create mode 100644 uhura/crates/uhura-fixture/src/lib.rs create mode 100644 uhura/crates/uhura-fixture/src/script.rs create mode 100644 uhura/crates/uhura-fixture/tests/driver.rs create mode 100644 uhura/crates/uhura-port/Cargo.toml create mode 100644 uhura/crates/uhura-port/clippy.toml create mode 100644 uhura/crates/uhura-port/src/contract.rs create mode 100644 uhura/crates/uhura-port/src/envelope.rs create mode 100644 uhura/crates/uhura-port/src/lib.rs create mode 100644 uhura/crates/uhura-port/src/load.rs create mode 100644 uhura/crates/uhura-port/src/types.rs create mode 100644 uhura/crates/uhura-project/Cargo.toml create mode 100644 uhura/crates/uhura-project/clippy.toml create mode 100644 uhura/crates/uhura-project/src/icons.rs create mode 100644 uhura/crates/uhura-project/src/lib.rs create mode 100644 uhura/crates/uhura-syntax/Cargo.toml create mode 100644 uhura/crates/uhura-syntax/clippy.toml create mode 100644 uhura/crates/uhura-syntax/src/ast.rs create mode 100644 uhura/crates/uhura-syntax/src/css.rs create mode 100644 uhura/crates/uhura-syntax/src/cursor.rs create mode 100644 uhura/crates/uhura-syntax/src/format.rs create mode 100644 uhura/crates/uhura-syntax/src/lib.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/dsl.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/examples.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/expr.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/markup.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/mod.rs create mode 100644 uhura/crates/uhura-syntax/src/parser/stream.rs create mode 100644 uhura/crates/uhura-syntax/src/token.rs create mode 100644 uhura/crates/uhura-syntax/tests/common/normative_sources.rs create mode 100644 uhura/crates/uhura-syntax/tests/fmt_roundtrip.rs create mode 100644 uhura/crates/uhura-syntax/tests/parse_normative.rs create mode 100644 uhura/crates/uhura-tests/Cargo.toml create mode 100644 uhura/crates/uhura-tests/goldens/m2/instagram-ir.json create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-alt-xor-decorative.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-class-rooting-violation.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-controlled-promotion.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-event-on-layout.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-missing-availability-arm.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-nested-interactive.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-non-exhaustive-union-match.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unbound-required-prop.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-undeclared-component-emit.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-undefined-class-warning.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unguarded-projection-read.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unkeyed-each.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unknown-element.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unknown-icon.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unreachable-handler.txt create mode 100644 uhura/crates/uhura-tests/goldens/m2/reject-unresolved-name-did-you-mean.txt create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-feed-active.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-profile-active.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-comment-row-pending.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-comment-row-settled.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-empty.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-populated.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-feed-empty.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-feed-exhausted.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-feed-failed.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-feed-first-page.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-feed-loading.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-notice-bar-refusal.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-post-card-carousel-liked.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-post-card-image-post.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-post-card-like-pending.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-post-card-video-poster.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-profile-header-lena.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-profile-lena-posts.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-profile-loading.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-profile-self.json create mode 100644 uhura/crates/uhura-tests/goldens/m3/v-stories-tray-tray.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-comment-ok.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-feed-empty.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-feed-failed.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-like-ok.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-like-refused.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/trace-paginate.jsonl create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-composing.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-pending-append.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-appended.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-comments-open.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-like-pending.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-like-refused.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-load-failed.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-feed-load-pending.json create mode 100644 uhura/crates/uhura-tests/goldens/m4/v-profile-tagged-empty.json create mode 100644 uhura/crates/uhura-tests/src/lib.rs create mode 100644 uhura/crates/uhura-tests/tests/acceptance_feed.rs create mode 100644 uhura/crates/uhura-tests/tests/common/corpus.rs create mode 100644 uhura/crates/uhura-tests/tests/m2_gates.rs create mode 100644 uhura/crates/uhura-tests/tests/m3_gates.rs create mode 100644 uhura/crates/uhura-tests/tests/m4_gates.rs create mode 100644 uhura/crates/uhura-tests/tests/purity.rs create mode 100644 uhura/crates/uhura-wasm/Cargo.toml create mode 100644 uhura/crates/uhura-wasm/src/lib.rs create mode 100644 uhura/crates/uhura-wasm/tests/abi_contract.rs create mode 100644 uhura/docs/README.md create mode 100644 uhura/docs/rfcs/0001-project-foundation.md create mode 100644 uhura/docs/rfcs/README.md create mode 100644 uhura/docs/spec/README.md create mode 100644 uhura/docs/working-group/README.md create mode 100644 uhura/docs/working-group/frame-stress-test-handoff.md create mode 100644 uhura/docs/working-group/instagram-spike-design.md create mode 100644 uhura/rust-toolchain.toml create mode 100755 uhura/scripts/build-wasm.sh create mode 100644 uhura/scripts/parity.mjs create mode 100644 uhura/shell/appliers.js create mode 100644 uhura/shell/focus.js create mode 100644 uhura/shell/index.html create mode 100644 uhura/shell/jsconfig.json create mode 100644 uhura/shell/main.js create mode 100644 uhura/shell/overlay.js create mode 100644 uhura/shell/pump.js create mode 100644 uhura/shell/reconciler.js create mode 100644 uhura/shell/scroll.js create mode 100644 uhura/shell/shell.css create mode 100644 uhura/shell/surfaces.js create mode 100644 uhura/shell/textfield.js create mode 100644 uhura/shell/ticks.js create mode 100644 uhura/shell/types.js create mode 100644 uhura/shell/wasm.d.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a717303 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "uhura-dev", + "runtimeExecutable": "uhura/target/debug/uhura", + "runtimeArgs": ["dev", "examples/instagram-uhura", "--port", "8787"], + "port": 8787 + } + ] +} diff --git a/.github/workflows/uhura.yml b/.github/workflows/uhura.yml new file mode 100644 index 0000000..477cb4a --- /dev/null +++ b/.github/workflows/uhura.yml @@ -0,0 +1,43 @@ +# Uhura spike CI — path-filtered; the root ci.yml is untouched. +# Steps grow with milestones (design §12.5); M0 ships fmt/clippy/test. +name: uhura + +on: + push: + paths: ["uhura/**", ".github/workflows/uhura.yml"] + pull_request: + paths: ["uhura/**", ".github/workflows/uhura.yml"] + +defaults: + run: + working-directory: uhura + +jobs: + uhura: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # rustup honors uhura/rust-toolchain.toml automatically. + - uses: Swatinem/rust-cache@v2 + with: + workspaces: uhura + # --locked everywhere: the committed Cargo.lock IS the pin. + - run: cargo fmt --all --check + - run: cargo clippy --locked --workspace --all-targets -- -D warnings + - run: cargo test --locked --workspace + # The corpus lives at the repo-root examples/ (../ from working-directory). + - run: cargo run --locked -p uhura-cli -- fmt --check ../examples/instagram-uhura + - run: cargo run --locked -p uhura-cli -- check ../examples/instagram-uhura --deny-warnings + # M4: the headless demo walkthrough runs end to end (goldens live in + # uhura-tests; this is the CLI-level smoke). + - run: cargo run --locked -p uhura-cli -- trace ../examples/instagram-uhura --script=demo > /dev/null + - run: cargo run --locked -p uhura-cli -- project ../examples/instagram-uhura --out=renders + - uses: actions/upload-artifact@v4 + with: + name: uhura-canvas + path: uhura/renders/canvas.html + # M5: the wasm crate compiles for its real target (bindgen + parity + # land with M6's full battery); the play shell typechecks strict. + - run: cargo build --locked -p uhura-wasm --target wasm32-unknown-unknown + - run: npx -y -p typescript@5.6.3 tsc -p shell/jsconfig.json + # M6: ./scripts/build-wasm.sh && node scripts/parity.mjs && acceptance diff --git a/.gitignore b/.gitignore index d767cc9..355fb49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,8 @@ **/*.rs.bk .DS_Store +# Uhura corpus build artifacts (nested workspace corpus at repo-root examples/) +/examples/instagram-uhura/build + # Prebuilt binaries assembled into the npm package at release time (RFD 0020). /npm/binaries/ diff --git a/README.md b/README.md index 7e1e545..92440f7 100644 --- a/README.md +++ b/README.md @@ -626,11 +626,24 @@ prints the SDL for offline schema tooling. The npm package metadata lives under `npm/` only to reserve the package name. It is not the primary implementation target. +## Uhura, the sibling client language + +This repository also hosts [Uhura](uhura/README.md) — an incubating +declarative UI language and deterministic headless experience runtime, the +client-side sibling of Spock. Spock deliberately borrows its client tooling +(`docs/rfd/0010`) and never grows a client language of its own; Uhura fills +that slot as a separate language with its own nested Cargo workspace under +`uhura/`, with Spock as its canonical backend provider. The integration +direction — contract projection, adapter obligations, and the determinism +boundary — is recorded in `docs/rfd/0022-uhura-the-client-language.md`. + ## Repository Layout - `examples/` contains product requirements and current-valid Spock examples. - `docs/rfd/` contains discussion drafts and proposal-only language ideas. - `npm/` contains package metadata for npm name reservation. +- `uhura/` contains the Uhura client language and runtime — a sibling + project in its own nested Cargo workspace (RFD 0022). ## References and prior work diff --git a/docs/rfd/0022-uhura-the-client-language.md b/docs/rfd/0022-uhura-the-client-language.md new file mode 100644 index 0000000..9ab7d32 --- /dev/null +++ b/docs/rfd/0022-uhura-the-client-language.md @@ -0,0 +1,173 @@ +# RFD 0022 — Uhura, the client language beside Spock + +Status: **discussion draft**. This RFD records why the [Uhura](../../uhura/README.md) +project now lives in this repository, what the integration between the two +languages will look like, and where the hard seam is. It lands no code: the +contract projection and the adapter it describes are direction, not +implementation. The type-mapping decisions in §4 are recorded as open, not +decided. + +## 1. What Uhura is + +Uhura is an incubating declarative UI language and deterministic headless +experience runtime — "a minimal Svelte without JavaScript." Svelte-flavored +markup over a closed transition language and typed service ports, compiled by +a Rust checker into a replayable machine that evaluates a checked program into +a renderer-neutral semantic view and emits typed commands and platform +intents. It owns UI-session state and experience behavior; it does not paint +pixels, perform I/O, or hold authoritative product truth. The authoritative +design lives in `uhura/docs/`, exercised end to end by the Instagram slice at +the repo-root `examples/instagram-uhura/`. + +## 2. Why it lives here — the vacant client slot + +Spock's own doctrine forbids Spock from building a client language. RFD 0010's +governing rule is "generate types, never the client"; RFD 0009's walls are +"borrow, don't build" and "language work is the differentiator" — where the +language is Spock itself. Everything client-side is deliberately borrowed: +graphql-codegen, urql, Apollo, a future thin protocol client. The one thing +that doctrine rules out of Spock is exactly the space a *sibling* language +occupies: a specification language for the client half of the product, with +its own grammar, checker, and runtime. + +The fit was designed in from both sides. Uhura's design doc names Spock as its +intended real provider — its architecture diagram reads `fixture driver ⇄ +future Spock adapter`, its seam crate is documented as "the Spock seam crate," +and its §9.6 is titled "The Spock-replaceability argument," pinning exactly +what stays byte-identical when Spock replaces the test fixture. Together the +two languages state the whole product: Spock is the executable PRD for the +backend; Uhura is the executable PRD for the experience. Spock's README notes +that commercial prototyping tools stop at click-through screens — "the backend +of every prototype is imaginary." Uhura is the inverse image: a real client +prototype whose backend has so far been a scripted fixture. Each is the +other's missing half, and the shared Instagram dogfood makes that literal +(§6). + +**The relationship is canonical provider, not hard-wiring.** Uhura's port seam +(`uhura-provider/0` envelopes, hash-pinned port contracts, a conformance suite +that runs against any driver) stays provider-neutral, and the scripted fixture +remains the permanent CI test double — both per uhura design §9.6. Spock +becomes the privileged first provider: dogfooded, contract-projected, and what +`uhura.lock` binds to by default. This mirrors Spock's own relationship to +SQLite — first conforming engine, not the definition. + +Boundary hygiene, restated from both projects' founding docs: no fact may be +authoritative in both languages; Uhura is a separate nested Cargo workspace +with its own toolchain pin and no dependency on Spock internals; the `spock` +npm name and binary surface are untouched. Uhura sits beside the reserved +slots, never inside them. + +## 3. What binds them — the contract, not the crates + +Direct code sharing between the two workspaces is deliberately near-zero. The +value models are doctrinally incompatible: Spock has `float` and wall-clock +`timestamp`/UUIDv7 throughout; Uhura's core denies float arithmetic at the +compiler level, bans clocks and randomness from the machine, and its +canonical-JSON hasher rejects floats outright. Forcing a shared base crate +would make one language lie about its doctrine. What is genuinely shared is +one level up: **the contract as data**. + +Spock already emits its whole contract as a frozen-additive JSON document +(`GET /~contract`, spec §6) that downstream tools consume without touching the +compiler. Uhura already consumes typed port contracts (`*.port.toml`, +hash-pinned) from whoever provides them. The integration artifact is a +projection from the first to the second. + +## 4. The contract projection + +A deterministic generator — working name `spock gen ports` (binary-owned, per +RFD 0010's "the binary owns generation"), or equivalently `uhura import spock` +on the other side; which binary owns it is an open decision — that reads the +contract JSON and emits Uhura port contracts: + +| Spock (contract IR) | Uhura (port contract) | +| --------------------------------------- | -------------------------------------------- | +| `fn` (mut) | command with outcome union | +| minted refusals (`!` codes, 409) | refusal variants of the outcome | +| derived error codes (per constraint) | typed failure outcomes | +| reserved codes / transport failure | `unavailable` (adapter-mapped, §5) | +| read `fn` / future `view` | read-model projection | +| `record` (flat, scalar-only) | port record | +| `set` (closed string union) | `enum` | +| `uuid` | `id` | +| `text`, `int`, `bool` | `text`, `int`, `bool` | + +Two type decisions are recorded here and deliberately left open: + +- **`timestamp`** — Uhura has no datetime type and forbids clocks. Candidates: + project as an opaque nominal (echo-only, ordered server-side), or exclude + from port shapes and require contracts to expose ordering some other way. +- **`float`** — excluded from ports. Uhura cannot carry it; a contract field + that a client genuinely needs must be reshaped (fixed-point int, text) on + the Spock side. + +The projection must be total and checked: a Spock contract that cannot be +projected should fail generation with a stated reason, never emit a port +contract that lies (the same law RFD 0010 sets for the TypeScript emission). + +## 5. The adapter + +A real provider adapter — the thing uhura's design calls the "future Spock +adapter" — owns everything the port seam forbids from crossing into the +machine: + +- **Wall-clock and transport.** The machine has no timeouts; the adapter maps + transport failure to `unavailable`. +- **Identity.** The `X-Spock-Actor` header (RFD 0014), later the bearer token, + lives entirely in the adapter. Nothing identity-shaped crosses the seam. +- **Settlement.** Uhura requires exactly-one-outcome-eventually and read-model + consequences settled at or before the outcome. Spock satisfies the hard + half for free: fns return the written row, which is precisely the + "read-model fragment carried in the command response" pattern uhura design + §9.4 names as the conforming CQRS shape. +- **Conformance.** The adapter passes the same conformance suite the fixture + passes. The fixture stays the CI double; the adapter is for live play. + +## 6. The determinism boundary + +The one deep tension. Spock mints wall-clock UUIDv7 ids and real UTC +timestamps on every write — both on Uhura's forbidden-inputs list. Against a +live Spock, Uhura's *per-step* determinism is untouched (that property never +depended on the provider), but *byte-golden replay* of full traces is not +possible: two identical runs produce different ids and timestamps. + +Uhura's design already absorbs this — golden traces live on the fixture, +live delivery order is allowed to be nondeterministic — so nothing breaks. But +if end-to-end golden traces across both languages are ever wanted, the +resolution is on the Spock side: a deterministic prototype mode (seeded id +mint, virtual clock). That fits Spock's identity — the README already imagines +sagas running in simulated time — but it is a real RFD of its own and is +explicitly out of scope here. + +## 7. Acceptance direction + +Both repos dogfooded the same product. Spock's `examples/instagram/v0.spock` +models 19 tables and 43 fns; its TypeScript client demo exercises five floor +CRUD calls and zero deliberate fns — no actor header, no refusal branching, no +pagination (`v0-FEEDBACK.md`, G12/G13/G16 and the client README's own scope +note). Uhura's Instagram spike is exactly the missing consumer: optimistic +like with rollback, typed refusal branching, cursor pagination, keyed +navigation. + +The integration's acceptance proof is therefore already written: **Uhura's +Instagram slice running against Spock's `v0.spock` through the projection and +the adapter.** The day that works, the client-surface gaps in Spock's own +dogfood ledger close from the outside, and the two executable PRDs compose +into one. + +The two slices sit side by side today as distinct repo-root folders — +`examples/instagram/` (Spock backend) and `examples/instagram-uhura/` (Uhura +client) — sharing only the canonical PRD, because they are not yet wired. +Merging them into a single `examples/instagram/` domain (backend + client +sides) is deferred to the milestone that lands the projection and adapter, +when the runtime wire dictates the shared layout. + +## 8. Non-goals + +- No code lands with this RFD — projection and adapter are future milestones. +- No npm name reservation for uhura; it publishes nothing. +- The `UHURA_REQUIRE_PARITY=1` CI gate stays deferred (uhura's own debt + ledger). +- No repository rename is proposed. +- No change to Spock's language surface, roadmap order (RFD 0009), or the + reserved `spock` npm client slot (RFD 0010). diff --git a/examples/README.md b/examples/README.md index 3700652..9b4874c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,17 @@ An example should: If an example needs a language feature that does not exist yet, write an RFD instead of adding the feature here. +## Shared with Uhura + +A domain's `PRD.md` is language-neutral, so the same PRD can be dogfooded from +both ends. The Instagram PRD is also the canonical spec for the Uhura client +slice in the sibling folder [`instagram-uhura/`](instagram-uhura/README.md): +Spock implements the backend here, Uhura the experience there. The two are +kept as distinct folders until the languages are actually wired, then merged +into one `instagram/` domain — see +[RFD 0022](../docs/rfd/0022-uhura-the-client-language.md) for the integration +direction. + ## Answer Sheets (`pg.sql`, …) A scenario may also include a reference implementation in a conventional diff --git a/examples/instagram-uhura/README.md b/examples/instagram-uhura/README.md new file mode 100644 index 0000000..407eada --- /dev/null +++ b/examples/instagram-uhura/README.md @@ -0,0 +1,34 @@ +# Instagram — Uhura client slice + +This is the Uhura implementation of the **client** half of the Instagram +example: the feed, profile, post, like, comment, and pagination experience, +compiled into a deterministic headless machine and driven by the scripted +fixture under `fixtures/`. + +It is built and played from the sibling [`uhura/`](../../uhura/) workspace +(paths are `../examples/instagram-uhura` from there — see that README's quick +tour). + +## Relationship to `examples/instagram/` + +The Spock backend slice for the same product lives next door in +[`examples/instagram/`](../instagram/). They are kept as **two distinct +folders for now** because the two languages are not yet wired together — Uhura +still runs against its scripted fixture, not a live Spock provider. Once the +integration in [RFD 0022](../../docs/rfd/0022-uhura-the-client-language.md) +lands (contract projection + adapter), the plan is to **merge these into one +`examples/instagram/` domain** with a backend and a client side. + +The product requirements are already shared: the canonical, language-neutral +PRD is [`examples/instagram/PRD.md`](../instagram/PRD.md), which Spock's +`v0.spock` implements and this slice targets. Uhura's assets are locally +generated (no shared media). + +## Layout + +- `app/` — routes and pages (`.uhura`) +- `components/` — reusable markup + `store` machines +- `ports/` — the typed service seam (`*.port.toml`) a Spock provider would satisfy +- `fixtures/` — scripted provider data + play scripts (the CI test double) +- `catalog/`, `styles/`, `surfaces/` — icon/token catalog, CSS, surface defs +- `uhura.toml` / `uhura.lock` — app manifest and port-binding lock diff --git a/examples/instagram-uhura/app/feed/page.examples.uhura b/examples/instagram-uhura/app/feed/page.examples.uhura new file mode 100644 index 0000000..6e9e16e --- /dev/null +++ b/examples/instagram-uhura/app/feed/page.examples.uhura @@ -0,0 +1,66 @@ +use fixture standard + +example loading { + note "cold start — nothing delivered yet" +} + +example first-page default { + projection feed.viewer = fixture.users.mira + projection feed.feed-page = fixture.feed.page-1 +} + +example like-pending { + from first-page + events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] + note "optimistic heart + count while like-post is in flight" +} + +example like-refused { + from like-pending + events [ outcome like-post.err(refusal: rate-limited) ] + note "authority refused — rollback, notice explains" +} + +example comments-open { + from first-page + projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze + events [ comments-requested(post: "post-lena-glaze") ] + note "the sheet mounts because the machine mounted it" +} + +example load-pending { + from first-page + events [ feed-near-end() ] + note "footer spinner; the guard suppresses duplicates" +} + +example load-failed { + from load-pending + events [ outcome load-next-page.err(reason: "unreachable") ] +} + +example appended { + from first-page + events [ + feed-near-end() + projection feed.feed-page = fixture.feed.pages-1-2 + outcome load-next-page.ok() + ] +} + +example exhausted { + projection feed.viewer = fixture.users.mira + projection feed.feed-page = fixture.feed.final + note "cursor none, has-more false — end cap; pinned state" +} + +example empty { + projection feed.viewer = fixture.users.mira + projection feed.feed-page = fixture.feed.empty +} + +example failed { + projection feed.viewer = fixture.users.mira + projection feed.feed-page = failed("unreachable") + note "provider reported projection-failed" +} diff --git a/examples/instagram-uhura/app/feed/page.uhura b/examples/instagram-uhura/app/feed/page.uhura new file mode 100644 index 0000000..6e5eaa4 --- /dev/null +++ b/examples/instagram-uhura/app/feed/page.uhura @@ -0,0 +1,166 @@ +page + +use component bottom-nav +use component notice-bar +use component post-card +use component stories-tray +use surface comments-sheet +use port feed { + projection feed-page + projection viewer + command like-post + command unlike-post + command load-next-page + command reload +} + +store { + state { + like-overlay: map[id]bool = {} + like-pending: map[id]bool = {} + load-pending: bool = false + load-failed: bool = false + reload-pending: bool = false + notice: text? = none + } + + // like / unlike: optimistic overlay; guard-ordered multi-handler dispatch + on like-toggled(post: id, now-liked: bool) when now-liked && !(like-pending[post] ?? false) { + set like-overlay[post] = true + set like-pending[post] = true + send like-post(post: post) + } + + on like-toggled(post: id, now-liked: bool) when !now-liked && !(like-pending[post] ?? false) { + set like-overlay[post] = false + set like-pending[post] = true + send unlike-post(post: post) + } + + on like-post.ok(tag, cmd) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + } + + on like-post.err(tag, cmd, refusal) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + set notice = "Couldn't like this post. Try again." + } + + on unlike-post.ok(tag, cmd) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + } + + on unlike-post.err(tag, cmd, refusal) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + set notice = "Couldn't unlike this post." + } + + // pagination: the guard IS the dedupe; exhausted derives from truth + on feed-near-end() when !load-pending && !load-failed && feed-page.has-more && feed-page.cursor != none { + set load-pending = true + send load-next-page(cursor: feed-page.cursor) + } + + on load-next-page.ok(tag, cmd) { + set load-pending = false + } + + on load-next-page.err(tag, cmd, refusal) { + set load-pending = false + set load-failed = true + } + + on retry-load-tapped() when load-failed && feed-page.cursor != none { + set load-failed = false + set load-pending = true + send load-next-page(cursor: feed-page.cursor) + } + + on retry-reload-tapped() when !reload-pending { + set reload-pending = true + send reload() + } + + on reload.ok(tag, cmd) { + set reload-pending = false + } + + on reload.err(tag, cmd, refusal) { + set reload-pending = false + } + + on comments-requested(post: id) { + open-surface comments-sheet(post: post) + } + + on author-tapped(user: id) { + navigate profile(user: user) + } + + on tab-selected(section: text) when section == "profile" { + navigate profile(user: viewer.id) + } + + on notice-dismissed() { + set notice = none + } +} + + + {#if notice != none} + + {/if} + {#match feed-page} + {:when loading} + + Loading your feed… + + {:when failed reason} + + Your feed didn't load. + + + {:when ready f} + {#if count(f.posts) == 0} + + Follow people to fill your feed. + + {:else} + + + + {#each f.posts as p (p.id)} + + {/each} + + {#if load-pending} + Loading more… + {/if} + {#if load-failed} + + Couldn't load more. + + + {/if} + {#if !f.has-more} + You're all caught up. + {/if} + + {/if} + {/match} + + + + diff --git a/examples/instagram-uhura/app/profile/[user]/page.examples.uhura b/examples/instagram-uhura/app/profile/[user]/page.examples.uhura new file mode 100644 index 0000000..4e74c5b --- /dev/null +++ b/examples/instagram-uhura/app/profile/[user]/page.examples.uhura @@ -0,0 +1,23 @@ +use fixture standard + +example loading { + params { user = "user-lena" } + projection feed.viewer = fixture.users.mira +} + +example lena-posts default { + params { user = "user-lena" } + projection feed.viewer = fixture.users.mira + projection profile.profile("user-lena") = fixture.profiles.lena +} + +example tagged-empty { + from lena-posts + events [ profile-tab-selected(tab: "tagged") ] +} + +example self { + params { user = "user-mira" } + projection feed.viewer = fixture.users.mira + projection profile.profile("user-mira") = fixture.profiles.mira +} diff --git a/examples/instagram-uhura/app/profile/[user]/page.uhura b/examples/instagram-uhura/app/profile/[user]/page.uhura new file mode 100644 index 0000000..1bb5b91 --- /dev/null +++ b/examples/instagram-uhura/app/profile/[user]/page.uhura @@ -0,0 +1,92 @@ +page + +use component bottom-nav +use component profile-header +use port profile { projection profile } + +param user: id + +store { + state { + active-tab: text = "posts" + } + + on profile-tab-selected(tab: text) { + set active-tab = tab + } + + on back-tapped() { + navigate back + } + + on tab-selected(section: text) when section == "feed" { + navigate feed() + } +} + + + + + Profile + + {#match profile(user)} + {:when loading} + + Loading profile… + + {:when failed reason} + + This profile didn't load. + + {:when ready pr} + + + + + + + {#if active-tab == "posts"} + {#if count(pr.posts) == 0} + + No posts yet. + + {:else} + + {#each pr.posts as th (th.id)} + {th.alt} + {/each} + + {/if} + {:else} + {#if count(pr.tagged) == 0} + + No tagged posts yet. + + {:else} + + {#each pr.tagged as th (th.id)} + {th.alt} + {/each} + + {/if} + {/if} + + {/match} + + + + diff --git a/examples/instagram-uhura/catalog/base.toml b/examples/instagram-uhura/catalog/base.toml new file mode 100644 index 0000000..31146ae --- /dev/null +++ b/examples/instagram-uhura/catalog/base.toml @@ -0,0 +1,154 @@ +# The base semantic element catalog (design §10 — normative). Nine +# elements, three classes; layout and aesthetics belong to CSS. The catalog +# is DATA: source cannot invent an element, prop, or event by naming it, +# and the checker validates this file against a meta-schema (input events +# only on interactive elements; observation events only on viewports). +# +# Every element additionally takes `class` (opaque, CSS-owned) — it is +# universal and deliberately not declared per element. + +[catalog] +name = "base" +version = "0.1.0" +# The closed icon set, recomputed from slice usage (§10). +icons = [ + "home", "search", "plus", "reels", "profile", + "heart", "heart-filled", "comment", "close", "back", + "grid", "layers", "video-off", "progress", +] + +[elements.view] +class = "layout" +children = "any" + +[elements.view.props.role] +type = "enum" +values = ["none", "list", "navigation", "tablist"] + +[elements.scroll] +class = "layout" +viewport = true +children = "any" + +[elements.scroll.props.direction] +type = "enum" +values = ["vertical", "horizontal"] + +[elements.scroll.events.near-end] +kind = "observe" +# Physical proximity: remaining extent below 100% of one viewport extent — +# integer percentage, stated once here (§8.2). +threshold-percent = 100 + +[elements.pager] +class = "layout" +viewport = true +# Children come from exactly one keyed each (§10); uncontrolled in the spike. +children = "keyed-each" + +[elements.pager.props.indicator] +type = "enum" +values = ["none", "dots"] + +[elements.pager.props.label] +type = "text" +required = true + +# Declared for controlled use; the spike never binds it (§10). +[elements.pager.events.page-change] +kind = "observe" + +[elements.text] +class = "content" +# Literal text and {expr} interpolation — only here (§4.4). +children = "text" + +[elements.image] +class = "content" +children = "none" +# a11y completeness: exactly one of alt / decorative (§10). +exactly-one-of = [["alt", "decorative"]] + +[elements.image.props.src] +type = "asset" +required = true + +[elements.image.props.alt] +type = "text" + +[elements.image.props.decorative] +type = "bool" + +[elements.icon] +class = "content" +children = "none" + +[elements.icon.props.name] +type = "icon" +required = true + +[elements.button] +class = "interactive" +children = "content" + +[elements.button.props.label] +type = "text" +required = true + +[elements.button.props.disabled] +type = "bool" + +[elements.button.props.busy] +type = "bool" + +[elements.button.props.pressed] +type = "bool" + +[elements.button.props.current] +type = "bool" + +[elements.button.events.press] +kind = "input" + +[elements.text-field] +class = "interactive" +children = "none" +# Binding `value` obligates handling `change` (controlled promotion, §10). +controlled = { prop = "value", event = "change" } + +[elements.text-field.props.value] +type = "text" + +[elements.text-field.props.placeholder] +type = "text" + +[elements.text-field.props.label] +type = "text" +required = true + +[elements.text-field.props.disabled] +type = "bool" + +[elements.text-field.events.change] +kind = "input" +carries = { value = "text" } + +[elements.text-field.events.submit] +kind = "input" + +[elements.region] +class = "interactive" +children = "one" + +[elements.region.props.label] +type = "text" +required = true + +[elements.region.props.supplementary] +type = "bool" + +[elements.region.events.activate] +kind = "input" + +[elements.region.events.activate-double] +kind = "input" diff --git a/examples/instagram-uhura/components/bottom-nav.examples.uhura b/examples/instagram-uhura/components/bottom-nav.examples.uhura new file mode 100644 index 0000000..15be74f --- /dev/null +++ b/examples/instagram-uhura/components/bottom-nav.examples.uhura @@ -0,0 +1,9 @@ +use fixture standard + +example feed-active default { + props { current = "feed" } +} + +example profile-active { + props { current = "profile" } +} diff --git a/examples/instagram-uhura/components/bottom-nav.uhura b/examples/instagram-uhura/components/bottom-nav.uhura new file mode 100644 index 0000000..717162d --- /dev/null +++ b/examples/instagram-uhura/components/bottom-nav.uhura @@ -0,0 +1,31 @@ +component bottom-nav + +props { + current: text +} + +emits { + tab-selected(section: text) +} + + + + + + + + + + diff --git a/examples/instagram-uhura/components/comment-row.examples.uhura b/examples/instagram-uhura/components/comment-row.examples.uhura new file mode 100644 index 0000000..c29ba30 --- /dev/null +++ b/examples/instagram-uhura/components/comment-row.examples.uhura @@ -0,0 +1,21 @@ +use fixture standard + +example settled default { + props { + avatar = fixture.avatars.kenji + username = "kenji.rides" + body = "That copper red is unreal. What cone are you firing to?" + time-label = "1h" + pending = false + } +} + +example pending { + props { + avatar = fixture.avatars.mira + username = "mira.santos" + body = "Saving this palette for my kitchen reno — stunning work!" + time-label = "Posting…" + pending = true + } +} diff --git a/examples/instagram-uhura/components/comment-row.uhura b/examples/instagram-uhura/components/comment-row.uhura new file mode 100644 index 0000000..0946e41 --- /dev/null +++ b/examples/instagram-uhura/components/comment-row.uhura @@ -0,0 +1,28 @@ +component comment-row + +use port comments { type image-ref } + +props { + avatar: image-ref + username: text + body: text + time-label: text + pending: bool +} + + + {avatar.alt} + + {username ++ " · " ++ time-label} + {body} + + + + diff --git a/examples/instagram-uhura/components/notice-bar.examples.uhura b/examples/instagram-uhura/components/notice-bar.examples.uhura new file mode 100644 index 0000000..682b0d1 --- /dev/null +++ b/examples/instagram-uhura/components/notice-bar.examples.uhura @@ -0,0 +1,5 @@ +use fixture standard + +example refusal default { + props { text = "Couldn't like this post. Try again." } +} diff --git a/examples/instagram-uhura/components/notice-bar.uhura b/examples/instagram-uhura/components/notice-bar.uhura new file mode 100644 index 0000000..75442b2 --- /dev/null +++ b/examples/instagram-uhura/components/notice-bar.uhura @@ -0,0 +1,21 @@ +component notice-bar + +props { + text: text +} + +emits { + dismissed() +} + + + {text} + + + + diff --git a/examples/instagram-uhura/components/post-card.examples.uhura b/examples/instagram-uhura/components/post-card.examples.uhura new file mode 100644 index 0000000..dedb211 --- /dev/null +++ b/examples/instagram-uhura/components/post-card.examples.uhura @@ -0,0 +1,35 @@ +use fixture standard + +example image-post default { + props { + post = fixture.posts.lena-glaze + liked = false + like-pending = false + } +} + +example carousel-liked { + props { + post = fixture.posts.marco-baja + liked = true + like-pending = false + } +} + +example video-poster { + props { + post = fixture.posts.nils-aurora + liked = false + like-pending = false + } + note "capability honesty: poster + unsupported badge" +} + +example like-pending { + props { + post = fixture.posts.lena-glaze + liked = true + like-pending = true + } + note "busy heart during the optimistic window" +} diff --git a/examples/instagram-uhura/components/post-card.uhura b/examples/instagram-uhura/components/post-card.uhura new file mode 100644 index 0000000..6a8ff2b --- /dev/null +++ b/examples/instagram-uhura/components/post-card.uhura @@ -0,0 +1,73 @@ +component post-card + +use port feed { type post-summary } + +props { + post: post-summary + liked: bool + like-pending: bool +} + +emits { + like-toggled(post: id, now-liked: bool) + comments-requested(post: id) + author-tapped(user: id) +} + + + + + {post.author.avatar.alt} + {post.author.username} + + + {#match post.media} + {:when image m} + + {m.image.alt} + + {:when carousel c} + + + {#each c.slides as s (s.id)} + {s.alt} + {/each} + + + {:when video v} + + {v.poster.alt} + + + Video isn't supported in this preview + + + {/match} + + + + + + {post.caption} + {to-text(post.comment-count) ++ " comments · " ++ post.posted-label} + + + diff --git a/examples/instagram-uhura/components/profile-header.examples.uhura b/examples/instagram-uhura/components/profile-header.examples.uhura new file mode 100644 index 0000000..bdea779 --- /dev/null +++ b/examples/instagram-uhura/components/profile-header.examples.uhura @@ -0,0 +1,11 @@ +use fixture standard + +example lena default { + props { + user = fixture.users.lena + bio = "Ceramics + slow mornings. Studio in SE Portland. Seconds sale every first Sunday." + posts-label = "214" + followers-label = "18.2k" + following-label = "512" + } +} diff --git a/examples/instagram-uhura/components/profile-header.uhura b/examples/instagram-uhura/components/profile-header.uhura new file mode 100644 index 0000000..e98aa19 --- /dev/null +++ b/examples/instagram-uhura/components/profile-header.uhura @@ -0,0 +1,45 @@ +component profile-header + +use port profile { type user-ref } + +props { + user: user-ref + bio: text + posts-label: text + followers-label: text + following-label: text +} + + + + {user.avatar.alt} + + + {posts-label} + Posts + + + {followers-label} + Followers + + + {following-label} + Following + + + + {user.display-name} + {bio} + + + diff --git a/examples/instagram-uhura/components/stories-tray.examples.uhura b/examples/instagram-uhura/components/stories-tray.examples.uhura new file mode 100644 index 0000000..efc188d --- /dev/null +++ b/examples/instagram-uhura/components/stories-tray.examples.uhura @@ -0,0 +1,5 @@ +use fixture standard + +example tray default { + props { stories = fixture.feed.stories } +} diff --git a/examples/instagram-uhura/components/stories-tray.uhura b/examples/instagram-uhura/components/stories-tray.uhura new file mode 100644 index 0000000..f84cef8 --- /dev/null +++ b/examples/instagram-uhura/components/stories-tray.uhura @@ -0,0 +1,28 @@ +component stories-tray + +use port feed { type story-ring } + +props { + stories: list[story-ring] +} + + + + {#each stories as story (story.id)} + + {story.user.avatar.alt} + {if story.is-self then "Your story" else story.user.username} + + {/each} + + + + diff --git a/examples/instagram-uhura/fixtures/assets/avatar-ayla.jpg b/examples/instagram-uhura/fixtures/assets/avatar-ayla.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ced002526ea9c60ad9d3d0429218c97be9aa818f GIT binary patch literal 2599 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-june.jpg b/examples/instagram-uhura/fixtures/assets/avatar-june.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c415d61536493884e14819582ad0de096c7c3961 GIT binary patch literal 2615 zcmbW1cT|&U7RJ9Xfh5$>f`CYif`9|kq!)>_Fw&%>fS|(INN7r=BZ3r>jvxv{2?I#z zAPkPwpeRiQ0V9Ya3_XMrOduO)_UxXsf9>w`p7;Lq-t*q)ckVrx^?@}D2w=^v%mEMr z03iDSte1cw0`LaBp`aiD5d@)vAZr@H0Gu3bL4QOTCnpTShd{vL2mu~mEL0004@Pzc*B+buggg8dF~2yzO^s-1=j+h2ss z;YHMA(u)yj)4EQS!^p-_4WGbRE^g67V&W3V+{!<;cQx3ILro^y0^ zc5!uc_q}xail6^4SA&8>LT}v;BgEZ}zn74BKPe+KD?2AQ?@|7*C8cHM#ERc4>l+%I znp;|*w{>;*^!AbaU%eiEH}-yf;=|LgAzXOZ?zsUXt_HV8!fENm~#|SD27z4Ae8P~dvt-VV+fOtb3!47J9 zL^OH}C@C&imU=(C@QZ;sv7vvk^Fkom0PYQ+1yEabc?x3Y`xvvpDCvQ(lbJ_AuRIG7 zE^-QddVH=O$?GI`Bdr&(F+Fh!+N&n8b~D-XQlc$|z+t?LCD49G7U;rZFHY=#2(^@S z5X0Rz^alYMh8?7^BgjOGxH0l;1Z>H0$?ayc`ocqQ6)q{daXP7Jza@0V~{5r!?RWvBI z^)PLHSB*YVoI8-y(N3Zp7t_vLTTFRNu5UE%6Czs-Q#j40nNC{?^YO~7&fPyJgcgUP znjLHTUy72?^OrKX2g_W~xfImBPv~;#8OTYI>=2z;`yT$LJxb#C)j6|RLuakfLEgj> zlFiCXaBp0&Z#c6)ebKR>#*JUdRtS(UU}RqZ5c~DQboF>W*+8|h-n=u578h%{T&eEh zlH6vHpS6HJpDlbgn4sQu*|(eOctz(Vbl$)Y{&w#RifXYA#laPxL4cRT0G9&14B!X; zG|}+w$_;X(lYY)eIk<-;f0Mj;fcdUgQ+?v`HM1HP@FXF-z`+Tp<@;0h45gBpW=+ja zk~VFzykT0vhz=Ewn^)aU+}>L4-0Iw^%Om+pxiW1xjNs_k&U!aPA`$D~7+$pHmOCVw zCv$U4p-+~=6dt|U%22yv;F}RqwQx*9Axytv>wEBLtdhv6Rj|99UM9xYd?LgUwm3vQ ze|;uK?&NDra1e1&0?&-q@E9TKsciVcv_Z^aek`@4Il_gweRFY#qTt*-q3KUrowULy zm<0NU2NH?46LFtjl&8ABk9}8zQ?{tL4J$c^gwWoSCw1ZQPnGKUj0}qS7*QgN zqFYs7y)RxhSGAnX0z_tB;U+iB{OusP%F<{UA$Vvkf98<3-e^Ec%g3}dQ~lPVJei$EE&!k_(xd3(k%Vbo;ro49pRXT! z1F|XK<|aDZ*usN9npCeDvwNwQH!#8+7tvW+xL)-+B03i z!_%eS)3y{+Fq6JVM(d!cerum=Lp1rjU44mIj@}FGO;xR|wrX-pW2Qsx2qo>_yPhZT z#^>*54&+aHUOG|wutvM#?m{$fHtyYHY5OG%Z`J3qVyZH!Z-;4{M#{HVc|){m+yA)2 z9KY}N&RXk5u4nj}^jEwjk&2Q2yH(1(D0Bt7j-*&#_-LD&<%c@nUVbXwY+u=8Osk-` zxi;o;2GsKOq*Ty=RCnAqSzN10Nux@|6`d@eS{z4QRmM_sT^8F83T*?1x9;3PS|!-D z1e`2)`>F({r#ayF__~s@CTYUsQl}=T%l*G$J(uZ`}a%ku-s?Z)QS(K41B`8ha|Pt`%5MnyQMTt zbot_96u^_`gUj6!zBUlDt6#OZ<`4za?N6hvSwR106xAth>?8{qp#MSBX9459dmFGe9G(T={|LBh7mc7Ct|FQ{wib(aB52 zlA{{muk;eR&wG%)d^zK4U|Q}x3*ZkAK#khO>yd(2cdHH(s?EuMsCDT=-YXBA5p&U! z+Q=;atm7VnPXrbA=e)6*87s>~5|yr;L32hEJ(=%5>TFnypE@ ot#A3!_N|v|q^5o*SOg;q;>V-Kv^7$afclC`ZK<)IDAwdZ05qASlmGw# literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-kenji.jpg b/examples/instagram-uhura/fixtures/assets/avatar-kenji.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f6ec02c3b6adc563b0089edc9cbc08162e82ad26 GIT binary patch literal 2373 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-marco.jpg b/examples/instagram-uhura/fixtures/assets/avatar-marco.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2696823201e9d3d7afbdbc9ae6f984d1155194b6 GIT binary patch literal 2397 zcmbW2c{tSH9>>44m>GkZv5mDUu(G z?B+6d6A42mm1XRc!5zQfeeQjp``5kqeV+6A=X{>?ykF<@Ip^$C_oo3qQ$rI&00agA z5PJao%>W7kTmh~?Kqvr=0zpuq{Ru!H;DE9PIfyV04j6(5fq=sid|cd|Jp6nD{AfNj zT0mG#L_kPX2#v-{V@1U!q@<(-L}cV-BxS`Ur6dnF0dc@!2snZlf#8)CL<>s(&)RPT zB-lPVKz|DOr$Ar`lmiAwa3ZKdo;np*m24GfKpO-%o0eZj`o z?&2jES2y=-9-dx){sDnOcY;G=q)%FcO|`>d$A>Qaex~FVz&_l1?U2^n2hzR5?y`gGFp_LZovXEXGFxo z3aVHpiUoJNt$d4PsJBS0eV_V;wSDwxAK(nxOJX|7?F0TP+u3szYnS6i_^PM6n%Jn= z>JE8ui?wyL8seUxzbWkWp*s;g04~8SN`OI>Akg8k_tf3CKx2#maLbVQ5;QkoKbl=%$->votKG-Pb|9n8$|3Oc^(T@hPrCeDrT6pN$x3C}{^Q96rZ)lB^BWU~_hvhRvavIkf!sWn)%ANS4S`Jm1vfKYiy^gNt$&c4oc$7M?h zXH39C@yWj9(TRYBDZu5oNW@}=Mj0oww0A3&cDZdZDbtjJJ)=V|dvtr23hU9bwAb?~ z+Im&0?qQMosPT|~oIc;<(EcN5CF-@e3!pq02S_^r8k109HyPel+{`Xd!f5}y{K2w;_J2TV~_QNGkgWRf}wxE zm|2N0IP2s@O`Oezp`@l!>=w9^eb54(WpvA=TnsKzSVYW|lWQ&cS&o-{-)#?(1s%?n zHa$cDd1FdCY?Pt$MDi#7w8?jE7kDLZ#)Xl*l%(-v@A7<@c;pk49y_f=-C-6VvP&cC zhPc^;r7S0`&ac68oqMqlZ1uWXZ;QS#2 zu(6eWpuEUU^IH2!=e|Cxj|y~SYSnozz?V51;5x%;;(u@CXA|Bw+jk>(EtRt}Fn@1GdzDpTP&=*>e%(=!`6(%;KfbF3cnG*8@3#(3^2? z>1!V^`LCA8ROj3s=-?wsJWFJrbG7#FR{H)cKb@{dIv#G<*|fJ2IXr&-KGBKP6$}jG zeM*=5;S^0M?ckCbnyVT_7nVB&(o#v?An07Ki`<(=4J}f|w1x5+MYB5mWKC?+=7Oaa zVdd6R5>BnDR%EcLKI3vo^>TH$X!Y@Gt;QRWV+vhYLij?*I@E^V=n7>z*e8*43HoiD z6mMFSsRA;6s3u>jh}F7&_438|bP}d^VzqxaNTIxZ`i1G~^qrc>v@}Pf=K`Rm2uyAG zsgL8~neA~gr+>a|FwHuen6}s;5g(SN=vCrr5^2-Vx~E~yT-9f1)}ldQO&O$8rP;Ew@-Q=bu_wE>w2GeX|cpX zSh@kyA1eOA{Jn;%X!>07#`50fF&1x9pC7e7lv}3q%c>(GW0^iQcGI}EF}jP^C_QZu z0FNGxk`N7bo=i3X>jZWjP9|-3E=ecY{OZR=8=qD{w&ksNs6Wo*0jT9Vj+5UYLt&_! zhe~H2)@{CWJX+CO>~!690)%YiPO?iGHj675IOO#%WZ!Z94y(@sliG35iCyrkv zQmnRpj{D5~DARutP=B19N0fhu-)dn!4BK?iueBg=T%$W!3U8E|j^Ey^V)ap#jgUOS zM%`#l$C5WhE`9}v`Bc<<3PHm&s$@%}|Bf1H?b$3woqZ;H8FkTvQGgi|(8Kitc$XU< zpo*RhqH6zp3?IME#x7-#;w0|5sS{ba(s*GXI2pOPyj!!bU&HELwoMEpHs)}i_5hU` YKS-_tMm{fh6>uHH7ECS>Q}#do18Z$X4gdfE literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-mira.jpg b/examples/instagram-uhura/fixtures/assets/avatar-mira.jpg new file mode 100644 index 0000000000000000000000000000000000000000..363e12e32d53d8d2bd12f742f68d9111de96841a GIT binary patch literal 2665 zcmbV~c{tSj9>;%UFvA(fFce|NPB@Z%mqb*Sq)wbOwq)ys7GoV`scr%cWlPLxXbd$oW~{^9sdJxupXdH{?|pxt&*z`-=lMME*YkOEC%AKfn4_J89RT73 z01)p1+)e-i1-t=oFbDzgAwXaRh&v4&1_bzd0{`fO0s?|i5hxS_fr|YC6B3aSLrRE? ziHjrm$VnrmWTnK#Wt3!O3P@>XHD$D_99jYWV+lw=P!I}%!l6(&T2fpR{XdIK z08l)i0-&D){wWY2m|s8;0u>Veg*VV32JnHvU_PE%o?G5@9Pc0CM+k^3=pGW3aP@&G zh9eIq-Y$kJ+0=DOo_tSN*7Ln|O-NX3&t7R66;(BLjs5xthDOFFriX2h*x4U-aCAH6 ze)`OBXFdGR`JWF63<|z{o&coFb9uzfcwyzfzYN%vBb9ZGB^N>jxJI0RN4} z>wkm&2N&-gAU@s=_#r>IKzvwUff4)y3c7;ghg=~(;S!1m6QM|(+r@QVLP~lk>5{&e z-U~}9>yuO%KhS=X{dZv3{x7nBf&H6n3V?w@ycmHIfECd1OBnA(dF&Ups01m`Cype8 zk^bS<*Y{_pCKeWv9F*i`8W7D|Dct~$`q>FScZd_R%xo-_GNi^2a=x3U|28=j8cN(( zF_;q|UE{*A!=qPl8nA-oQ=R&RBQ34kX!+|KUTNOCHyk=KIUsTrdTx9XLhEMQ%(?J| z*}**k>oR92byHx6?`nd)fa#u?{;NgHY6 z?j*~J+ z&eF|;H{Xh<*Wc4)jx(kiMlUuzyVkHqrd1N!1Eqo5?Y>Z0)Jz)t06prQis@99{)^4^ zIx_jO)M|Do_0E46 z>NA;@_T2ouFUn|zseD#9tBqs4wmr$wd^1!$lp6ZnnYH5_qgX}EaBU*w?Zh7*(wxtF z=d}2GSFO1)etE_*Y&$`$pQCFoQo24|wP@KQ$!fXiqcJdf8kfb|?buf&mrrp?wrH0( zY^36FnDk>2&&^y2s-M{`d!jgghdA5AC${(XoV+?9iuZ9wg2?=g#ttJZZ|N7J&K|y| z-@y|?-_MVob4w4ew-o(F?DBB!%I=4lNej{7%;(gmCtM(Lvq*=qw?f#p7}fBE<+ZT2 zDYp~n$^cnQ=W&F>Nz)sjZt>eWS)5rwRaR?VLDE;98+KQm?X@eZWGWHcG+VJ0r5&rV z73Kn|4#YkWHZ>?)?q1Jq_FFoHF6xu6dD->xYNcIiQ+)aL1?g0I(K+RDm|Am`jZR$V}Dq?g!5KLeS_29yAuV4iM$ltY` z%P~XcO9wE$2YoNx-XXoqBAh$T(lj+tI%y$#!AZ_ny+5Os3pj8d(4uk0AytzqGo|^r zo6&5yPl$=}8J8Miy1Paje6Y?q_ zWzV?{&~*@&K^6TSD4h+T^DWu0M=7P_W*l=a(9Z>KQU_f+AbHzLtDOh^D5?oEj-n2D zKc{g{Ah?@}4fiT2s?OeHypWr!7iBK2CGb+tX8Xh~qJJ2@ZV95$%7!G3v7db$upRcf%_`% z4elAXo}iq&=xmeWT54v6L9U9u?&GBsRuTQP@$Ar^{4{7Hm`~f2)g(5FpcxkkN`#v901mTiWq`qKfksK^{=_D1G(a;KF%z+2PM^2EebXqQ zd^E#vS77T*NA}y%0<$MOv-HJ*a7nX~lw->)>s+8){<^f8aZRPAx}Sau-5v?d?fX_q zP7q5#L7o97=~+2hcN5I5y$R0IoVlIIBVKouo&oCGnSp>Br3Wx@k$(ZqGZ|9}rf|Ve z`WTfo72xStMq>=@;yl9@Q|)df>v-&O2zv((H!rV9x)kqUu`$iIFg{=}EE0Enld6=9 zZVVKwJD5w<2P?%D;+Eg(9{U!Drw1ZP1J_=j;6=J!!z@xx9pJCU^D7|%O7Gyv-j5Uq zB*E*QYNP=Tagyi=^*M)V#hv^IKf>O3 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-nils.jpg b/examples/instagram-uhura/fixtures/assets/avatar-nils.jpg new file mode 100644 index 0000000000000000000000000000000000000000..68742ab3921a6304025dcf8dc79eceeae5c6aac5 GIT binary patch literal 2498 zcmbW1c{tSj9>;&Pm>ElqF$$?M8QV}1*$!j0jOCo{J4HrmDjY@jF}4m9CVNee8QE#k z30X2BgVZTYN|fwEn54#*x#OJs-1|KDuY2$N`+PtDd_K?jdA~lN&+`=w3Pu5>HOYzu zfWQC%5*|R%0!SkOAHW9!k_N!iAc!MU`vqyK+CRrYt<}@RD?Q17cN!US<(~qR(>23LS-|qRW zRTOE-KW3L!!Cf_e(4(W2E&OfE`Jmgn$K)0c2G?W^X4H&s`1O)sFc8nUmtEd&n(nsNkhi_dozrz@J2nfpG!b z8Plw%q$bCL;@G*+(jR^k;?PGbDtv*g^q7aCE<%)<}oSZiS?V=lg}qdclUzwTxH`@ISD zQ_XsxR7GS=E7XaV;afeRol|n;-u(LJwNGB9eFP5+^@yV;lIr3t1D%V(edRx9-}_z< z*Atag&X>&Y@b3K#E4u6M7Lbt{k%M`T(e~PGkAc~aSx$DmU9VFpIdg%lf4_G9;H{o@ znBfE+N5{EM5M`Z7;J6;ppc5;(+aW){c=6q>al?tcw#LdX`rLj$A2~li4W%FEz!tjq zV@|hyM{n>Ie%|Na+CMi zJ&B=W;xMwwsdC{gL|%wh?r%*!02(jeT!q{tIC6pLK3lm;v*_J5lC~aDbsvjn(B)zt zHfYr*L^$Jlb)_q0-!{Bc+R$~JF?_XUgP0~?BmDskS5liY^`F6jmzc( ziB$@Q^w2XkeysP4(MvAWaWr5D|9RA$rAV20`$7Qpo)ESUD*!SAyz{oQ8G*~++et>{ zX1MxeX+3lhOW$YdC7v#-FNh_osFr2r{KKF^4%Cu?nFQ1}aC3>HOW}9dOe7q%l zymO3Gd-Zqt0A;>^@d_a>-Et~LCFPCfdzg#k^_CSSzO34KW@@3Dyrv!@-j+G{YNTzu zQL#xw(YKesx}6RK0Mi?)GcR#v8M1CE)stau7v zim#~}_#gnhGtT^>0*Z7nN}?$0_w&z*V+{Oc=tX??pp*yvbQzv{DqH+?OZ+8DDv3N4 zryrtVmKM6O7o)0~)IgKaa#gCgq4`WA0lALg1?$d)v@TwF=PUK)=Z{04+_z~O+B&;< zbF?Db@r(cvg*GyWO;<9ld+%~a%c39&O=W1WLsRp(78(q_4)S4ZG=Nxu`tGCwJ zg?HYM+NpBwm#1kKccKqVMC)D}oLC4D0BjXisz29zDMJ8=ZBB+g$a+Y(=+a5;+tsRQ zr_*Ba!JZ3X#V{ByQBq}V7PB8rI6vCSe8}g-tFK)ixxPP1zEclC>5tPCD%DX(84$F@ zH>sq&O9WPh+jWkU1CGsq4wj7Jn6LNw&*XbrbyP Y<`v2&yGA|PN7K4efHYY9_cWk>|kqe z3xL1?0Fo_0S_hyIzzN_41cU;>C=dh%lJ*1E01PS<FXArNplLRmo(`J1XT zT2)0^MFp*)i9xI5)Kyfl1T1dtIy@ea#%SwmYw2ie;kABU0)olOA>arl1VTwmO+`)X zf0ncnSSRxd1N|l7p8|m)P?#JXfs|K}HIyp@U=RcXmYJ2gm32qS&H*S2rh+G0%Bddk zfv=~ax5g&rAP82a^=b#Z=CzG|gRdav)iqXQup4xA_4Ez4Z8zDmbC;?0UK`tecJ>a3 z4!gR!AMxH6%3b!o~22tJmVL$KSX~OHN5myPbaLZpK5_Ble%1+`Rn9 zPs+-lR#a9!Yk1Mv)ZEhgvhDSox9{F}_w@FSj0(rbCni6B`n<6CWl8i^{7v%x7Z(VC z{Ea26e}nx8mn;qtSe5}4{)-C)z98EW6cmOh%Bff$fcsEX*KdtQpskW}O6!pXe|~zHZ1%?`-|+q1H1Bnk^Kwo-&}owA_OFRMi3N00t!3qzvhultg`v9WBg7E zwL-F$n=nURh;1=AEO@xbZ8XM8bJmy!*^4SLzVS8Lz77Z`8xT|b=xY6DJg<5a_&5O(4pU{;oL-#Zzk}+5QYDLzN8{8ItFtSnl-7)yzr4o(H6Z z1_ow(zi-iOD6}=*(YHrDP%-+(44lmj7$*j4gq%IjFr<$H`&Me9=LF*0br`DBuo6@&-Fh;w)w z@42uIM1BrtFWF+AW?|r1U7|i|eH>=0iBj#&|3E%?*x!~OM%zCtNF*v$R1(I4A9LOE?A9_DV>^UeU15g?It8=uH$$c^Ne2mTEFAHgI%5# z-?yYk8BU1G8P9=yH48uyFmuQJK>Mw>u{C&!%PifQbtuTljX2IQH(hzu?Y*6xl}u6B z_a!dwCr8$XNr411zhF;@9@vhZjJ!=g%F1h9f4!Jxl5>nLPvh$PZ@$~xGsqTYbnKA= z0~ERsuUnhdHXYrbCKNJX7_>)arA#f(j88Z@~QK{sjw0v2BbEXJZXx&m8#;!qFv|s{fk3l1JVd{j9#pKG|i6dS|jchW@_o4Q%8rxMl!CU6W((Lu`8=A*U_(HFGk0Z-cU`O+&(_2GX z^V8KD<54pchx2zQW`kvx-V0`y`#E=FE|m*2gD-#YEzCFH6vdPuadmVos4c10kOCmm z^Unn4*~rq?BI;5-3&yzI=1^)QvRU?u-d~%FL4|QH3)_vbj6i4Pvmn&;@z*a$K{|O!abXs)4Di z>!S*5J)&$-V7rIO;cj|~ywW=G^_c>b94mGNEp0=%c~VbrZ1D_U3$Uu!ED+ z79ttM@tOoiAMV=*?;K0*QO(qCs3l)Jsl(i+Gm@9 zkhPj+VB{8$aB6w5nUb|=#Wl{I%k27gNTJ=~;$6d=z5ZS2&-B(dMfJHqi{kdI4OVV5 z(sV);3U!|DxoXrF)U#Cm_}$s}%;lOTahTC*L6KlW&ZB!LZiZC6j(aYD3#Y-i(hKwZ zV8Wr})E}8y30f;HZ!KGTB~l>ja&uPM&8C&&)5pI|EifgwwFFznB%G76mXo@a<@j}6 z-2)+JCC@k0^XEKr4Xb~)J2z>nXZine3S?&~VR+ojE8L(n$Qc@!get;zIMIf}#L+bm zNS$N+C!GTsDP8E2({;OD;6Pm1%=W%$Xx)SRh7J&l9HhSbrLkluK4)KSYxTbhQv{njiI~DJ+&kKWb$y`Fp lhh^GJ@?vHkwKJ?gZbzab1$Y*GU$$~HK{Rha7eLnyI literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/avatar-theo.jpg b/examples/instagram-uhura/fixtures/assets/avatar-theo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d4a300c4584da07eef36ec675e0369ab8bd0508b GIT binary patch literal 2532 zcmbW#XH?T!769;n5>iB3La5S<2$mrMm1^i!X`!hQn$qH|fCMQD0R;v{5wRdDM(HRe z2!tXa2m&G!K{^N`HApd(5GKF`XV30A`)POYJMVsZ_q=m|@4lUZopC_O%FNOX0D%Dj z#9hG7TL6gwJONJ#2nm3ZAP5q)GYl95P#$hTb~6kLg(3J62sj)ew3lxWzpxNm7$t;4 zp~a*n(4tbJD3qk4q?C-Tyu3VGLP*+H-=g#~3`3D4E3MGYI4v&buc0E2J@sFe%chm0OfABEFw(u7##XG@qKb?dS-Tx@sqi-y0*^RU~lfaKmg=#7B~M5 z`wtg)93U`v20ZXx7YKZryCFy(s5}OSGPH+#1q&-+V-aYh)aTU3J&GC*%Oc((pLs=< zG`}hT+@<}J{dcgq|4a5S*uPyv03QUz{YDTZfCFAlrnOr6O}9C^-2f%2NPx-4V#>h= z15*ZCfgoxb6b>dEKt#ra4I0e^`Y0yZYQ<2n&K9=khqG0zdB!`e411NM<5Fkz@O6C6 z8V>(q`5-*ep7=GgfiQNsmc;PD55Axd;qjI}0+C(b$`kZT~Uw+e` zvY4P{^4GxLeRJ>Z({L;@M%|hplyi@M4%gV|U-U}X) zzZ>DJ@7hnd&yut+!#TY5R2?s18auPD?f~Oq9z}z`mHGe$Oh(>!w<=3;P&bs4Jmq1g zYHF#~?yhmKS97WKWQoA3H;F0hwz)C)!_zZl+P~^*tR&AIa@8*A*-+j%(a)(_8d^+z z!2D1k-httT7Zf`_4l_@*K@}+5=+e%w=n{2Fw@sr)S>JU#(+ zBS+vdyS`*|RhIh4h^aqGz+pHj(oUVp441Nl<0xkiS}M8z#1~+GtR~KA3O(N0tiQX*h zOynx(WQ^d@9hpVLuF(|56akIDh!37lhdE!vq}gEZRwuOq zq{bPQpDN5^J#>l9Xv*VnjFAmn--SgQOP|E1u_P;ou8P}%c3T2<%=c%K=#^${h52i} z4Vz=%3CFM{n$@BmE4ULAd>1QUq9m9aNbb+&ofGn}l+F3|R8W1Zr5|B}2XNYKJR{~)4DU>{ z(|S+{BjQo61|vPd(L?xiWCL@I(5D`1EgT$nL@JsgoXMeG>?oNc_@W-_Rex>U_>lktGVqS-ZpAK$Fe%+tm{)Z1rUL#N3U6_^VdhPFx{2U=GYXs#Irr!7{$J^B7z#tCn$ zi)+d47&0HexHx1vlRA;SbpBA#Sv@|#{%1`!>LY7@5&B5Rv-eQrNJ0V2XW(9%|J+GWq4MX{7Qo_XA?JHO1!3WPwb@SImOr(RtOVTuIu;q5 zc{v$Zaix9Z?GErzTiRc!TfE+a0$=czQ+VJV1W09|YRl9xA$OCVhIJ1j>RY`ZeBVdz zK#s6VC|QDjP~o*JFcB~HpD$?h)0;cMTPBsHjt^!W^m*g6=sP+Ti!y8VL-P^E4##Ox zp!@1)=L}$U+01g_m0OTG8Z@I+iPdrW_$>LZ+{dU|x;0vKc!d`jS>7sUMbT!Tsa>`8 z=wbIikIM^VoJj0jo)*l^YO(g8tt0FJAso)_Bt?8*9Z@z+yR_lJoB<*mNvi-3sNEv_ zgMK&`|L6frFP@mOG7_;i(-snky@wuX{i29uC zjVYwlRq6cga|Zk;T>nfUOB$`|`wP8EzXd^RrQ9w@_>Xk8Wkcgl?W@)cC3Xh?0v}d^ AdjJ3c literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/manifest.toml b/examples/instagram-uhura/fixtures/assets/manifest.toml new file mode 100644 index 0000000..c2f4a1a --- /dev/null +++ b/examples/instagram-uhura/fixtures/assets/manifest.toml @@ -0,0 +1,250 @@ +# Asset manifest — id → file, required alt text, and the motif spec the +# committed generator renders (plan micro-decision #19: assets are locally +# generated, seeded, no network or licensing). Regenerate with: +# cargo run -p uhura-cli --bin gen-assets -- examples/instagram +# Motifs: duotone (avatars), glaze (tile grids), wave, aurora, crumb +# (speckle), field (soft gradient blocks). Same seed → same bytes. + +# ── avatars (96×96) ───────────────────────────────────────────────────── + +[assets.avatar-mira] +file = "avatar-mira.jpg" +alt = "Mira Santos" +motif = "duotone" +seed = 11 +size = 96 + +[assets.avatar-lena] +file = "avatar-lena.jpg" +alt = "Lena Holt" +motif = "duotone" +seed = 12 +size = 96 + +[assets.avatar-marco] +file = "avatar-marco.jpg" +alt = "Marco Reyes" +motif = "duotone" +seed = 13 +size = 96 + +[assets.avatar-nils] +file = "avatar-nils.jpg" +alt = "Nils Bergman" +motif = "duotone" +seed = 14 +size = 96 + +[assets.avatar-priya] +file = "avatar-priya.jpg" +alt = "Priya Raman" +motif = "duotone" +seed = 15 +size = 96 + +[assets.avatar-ayla] +file = "avatar-ayla.jpg" +alt = "Ayla Demir" +motif = "duotone" +seed = 16 +size = 96 + +[assets.avatar-june] +file = "avatar-june.jpg" +alt = "June Park" +motif = "duotone" +seed = 17 +size = 96 + +[assets.avatar-theo] +file = "avatar-theo.jpg" +alt = "Theo Okafor" +motif = "duotone" +seed = 18 +size = 96 + +[assets.avatar-kenji] +file = "avatar-kenji.jpg" +alt = "Kenji Tanaka" +motif = "duotone" +seed = 19 +size = 96 + +# ── post media (640×640) ──────────────────────────────────────────────── + +[assets.media-lena-glaze] +file = "media-lena-glaze.jpg" +alt = "Grid of copper-red glaze test tiles on a maple bench" +motif = "glaze" +seed = 31 +size = 640 + +[assets.media-marco-baja-1] +file = "media-marco-baja-1.jpg" +alt = "Long left-hand wave peeling along a desert point" +motif = "wave" +seed = 32 +size = 640 + +[assets.media-marco-baja-2] +file = "media-marco-baja-2.jpg" +alt = "Campfire on the bluff above the break at dusk" +motif = "wave" +seed = 33 +size = 640 + +[assets.media-marco-baja-3] +file = "media-marco-baja-3.jpg" +alt = "Board fins silhouetted against the sunrise" +motif = "wave" +seed = 34 +size = 640 + +[assets.media-nils-aurora-poster] +file = "media-nils-aurora-poster.jpg" +alt = "Green aurora curtains over a dark fjord" +motif = "aurora" +seed = 35 +size = 640 + +[assets.media-priya-starter] +file = "media-priya-starter.jpg" +alt = "Open crumb of a sourdough loaf, sliced on a flour-dusted board" +motif = "crumb" +seed = 36 +size = 640 + +[assets.media-ayla-ferry] +file = "media-ayla-ferry.jpg" +alt = "Ferry deck railing over blue water, city skyline behind" +motif = "wave" +seed = 37 +size = 640 + +[assets.media-june-lookbook] +file = "media-june-lookbook.jpg" +alt = "Folded linen garments stacked by shade on a workbench" +motif = "field" +seed = 38 +size = 640 + +[assets.media-theo-court] +file = "media-theo-court.jpg" +alt = "Basketball court painted with bold geometric shapes" +motif = "field" +seed = 39 +size = 640 + +[assets.media-kenji-copper] +file = "media-kenji-copper.jpg" +alt = "Road bike leaning on a stone wall at a mountain pass" +motif = "field" +seed = 40 +size = 640 + +# ── profile grid thumbs (320×320) ─────────────────────────────────────── + +[assets.thumb-lena-1] +file = "thumb-lena-1.jpg" +alt = "Copper-red glaze tiles" +motif = "glaze" +seed = 51 +size = 320 + +[assets.thumb-lena-2] +file = "thumb-lena-2.jpg" +alt = "Stack of unglazed bowls" +motif = "glaze" +seed = 52 +size = 320 + +[assets.thumb-lena-3] +file = "thumb-lena-3.jpg" +alt = "Kiln shelf mid-load" +motif = "glaze" +seed = 53 +size = 320 + +[assets.thumb-lena-4] +file = "thumb-lena-4.jpg" +alt = "Celadon test cups" +motif = "glaze" +seed = 54 +size = 320 + +[assets.thumb-lena-5] +file = "thumb-lena-5.jpg" +alt = "Wedging table with fresh clay" +motif = "crumb" +seed = 55 +size = 320 + +[assets.thumb-lena-6] +file = "thumb-lena-6.jpg" +alt = "Iron-speckled dinner plates" +motif = "crumb" +seed = 56 +size = 320 + +[assets.thumb-lena-7] +file = "thumb-lena-7.jpg" +alt = "Throwing a tall cylinder" +motif = "field" +seed = 57 +size = 320 + +[assets.thumb-lena-8] +file = "thumb-lena-8.jpg" +alt = "Glaze buckets labelled by cone" +motif = "glaze" +seed = 58 +size = 320 + +[assets.thumb-lena-9] +file = "thumb-lena-9.jpg" +alt = "Morning light across the studio bench" +motif = "field" +seed = 59 +size = 320 + +[assets.thumb-mira-1] +file = "thumb-mira-1.jpg" +alt = "Pastéis de nata on a marble counter" +motif = "crumb" +seed = 61 +size = 320 + +[assets.thumb-mira-2] +file = "thumb-mira-2.jpg" +alt = "Tram rails catching dawn light" +motif = "field" +seed = 62 +size = 320 + +[assets.thumb-mira-3] +file = "thumb-mira-3.jpg" +alt = "Market citrus stacked in crates" +motif = "glaze" +seed = 63 +size = 320 + +[assets.thumb-mira-4] +file = "thumb-mira-4.jpg" +alt = "Tiled facade in alternating blues" +motif = "glaze" +seed = 64 +size = 320 + +[assets.thumb-mira-5] +file = "thumb-mira-5.jpg" +alt = "Grilled sardines over coals" +motif = "crumb" +seed = 65 +size = 320 + +[assets.thumb-mira-6] +file = "thumb-mira-6.jpg" +alt = "Ferry wake at golden hour" +motif = "wave" +seed = 66 +size = 320 diff --git a/examples/instagram-uhura/fixtures/assets/media-ayla-ferry.jpg b/examples/instagram-uhura/fixtures/assets/media-ayla-ferry.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ab982d3482ccfea3054c2a0407a2fdf0804f557 GIT binary patch literal 24708 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 biTmU{zcg=DL5%e0X+?5sa|!GD+m-(VAw`G; literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/media-lena-glaze.jpg b/examples/instagram-uhura/fixtures/assets/media-lena-glaze.jpg new file mode 100644 index 0000000000000000000000000000000000000000..60552709db48be7d2a6425b6565942129fbd70dd GIT binary patch literal 120347 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%0AfhZoGGf-G!ZXzU_Lba50&;zte* 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 Dr4D5U literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/media-marco-baja-1.jpg b/examples/instagram-uhura/fixtures/assets/media-marco-baja-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fd98e9557a352521d5c2ce5ad643d2574ccf0142 GIT binary patch literal 25439 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/media-marco-baja-3.jpg b/examples/instagram-uhura/fixtures/assets/media-marco-baja-3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a97bc2249961dc91ce9c74526e57ddb8339cc44f GIT binary patch literal 25377 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 T7BZDL5zH_VpvUDJ%-;WB=#y&1 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/media-nils-aurora-poster.jpg b/examples/instagram-uhura/fixtures/assets/media-nils-aurora-poster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2fcc8ab2dabb57dc99b018baf754fbefffc812f2 GIT binary patch literal 27351 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

Sh66a&JD$BjC)22;U?SlDfdR(swpz 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 W`YP^O7;JJ?5D&M!h4=HnxBdsM@i7qq literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-1.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..453601ae7ce294304b4ba1a7753cebaaab2abf39 GIT binary patch literal 48452 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

=3Y3^h=pxQhEx>qO8TnGPkl3Lm!AMIX) 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

8r{TV}u;C%W2`c_9a+>1=53+FIhP*AG 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 PJ}}~vf;~b0_4|JSI1wGC literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-5.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1ab12ce3f06a4d62c0451513d29e9db621dee59c GIT binary patch literal 23354 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 F|Ji$76qW!0 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-6.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ceb732e12b8a88b3f1963db1985e45b1b65f680e GIT binary patch literal 23448 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 P2@HO3=ThCuO2_}%%{FAK literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-7.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-7.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0783509893ac84718c127ae135379a0d1d6bfb67 GIT binary patch literal 10913 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 REU@3}GFW<oUik6e{{UUP57htw literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-8.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f4ae5869e76f2d8fd07453b1a46cfbbd7a12582d GIT binary patch literal 47228 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 U7uLA6)Q;3m6_-!b{`~rX09ThCqW}N^ literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-9.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-9.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8987ac1e736fe0034191bb3a3282a9dfca652014 GIT binary patch literal 10253 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 Fe*oKWe82zz literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-1.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b62067290f47fa056f526dc9184ed112167bb2f5 GIT binary patch literal 23269 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-2.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..15dc79477d6baa3fecc8739ac3674bab1d176f45 GIT binary patch literal 10948 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 PC;?i^w|XT+KHmQyL}kus literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-3.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..230a26c70436a6c6a0717523d6220e8315b1a2ac GIT binary patch literal 48498 zcmbTdcRZEh|37|2Mu~`!V;_5ym2prAp(vD<y?1sT6h%e|nI|DBdma0bIQFskb~yIl zj^lUh{r-G^-^cH--}ifa@Xvj_uGhX^<9S_<UCx1SYpANLf(WjGKm@=K=&}h!MM|JZ zph!qS1-eE>KuAS!ISG0Qx=sW<gjauwuU{u7y+uk&LPC1`COO%yJGW`>P~WDerlDhE zpt%daOHF-`;~toqg^i7kh5^D2VdY|CWn;Zsgy1?cF)0ZtB`GN-D=jrG>;Lg{`4_|j z(0QHU-vj#ZLvW3d=sGb8DcOyizz3DLLDvWf39kXf0@MOu2LkUwL{!(Q*+i9y?>sXn zVRxqy3yI4j<xnndrq%7=frwjpgp%F3OGnRekCTg=hnMfZgrt<T%!7xIR8-X-t83^z zf1z(+`0|ydm9>qnoxOwS8!vAkUqAnM@591Be2j>UPxz9Ul>9X%^?P<s?vK2m`M=7_ zD=MoH)it$$TK=}Sq1rn-2L^|RM@GlSC*~IxF-yzXmDRP~z5Rp3qvMm)vn##`K!pEc z3;6pV#{NHi0qP*Q2Izo@<ccqXYd*j)Ar;YeHc?_~rDr7O?swS5LP%+p<FZPd$vDJy zcW5m<`fuEY+@I&%y<+VjXaD~g3;n<1?0+%#|ME2fA}1sO%!rT*1O**#)B66#Q^89w zLEi$4H)StDOGQ0(-sj+p?ScAkZSFB6&8b5-UudCcz({2+coruJJqv+hMlV6;#;^(4 z6b?4W9D5{nd<mjFf!OaF*O#F#L8R+Hqe2>rR2ido_l|z-3kr9gT!NZ7t&W{N@hm&) zuz9;n(9q3G5Et_NhS`4>p}mN{xCFJ}$;esmW~X)t^@dtRC&0Fag8v+L{8`MqKVvbt zgaJ0A0%u9@|67Iq<TtoKf~|k#kvxOT9_3<LLU(F^%kCv8Shr))-~|+*M{C!E3L=X7 z?uQm4rJ8;h25k#6t3$nlO$h7H|D$M$Z<Q|@nY>uNyQ{1J)z6<?hlCBji4@^rjq>wd zlMl4R59*KHJBvMMK;#@8T!KO$EAYA+&InSOFlPMOu9D`!U@L_;6?Oo^00shI=l~2< z&cpAuF36h($~YyFgbGXSD(uOCu8D}IV|R+uB?U*5g#|0{wP3^UYb_7cGS4PmPdI0( zPm=Levt^f{tiZfYMnDHYdg|OaJdb~s8W#Va)SQaCdvo1*=CIHuc?wJNcO_=Zp3g^A z37<F=g$bO=CfSo{z4&<vO1NU)A3F9M9~8v0qjCuf%b?4^z^sMS5JBk5TVuWA(G5KK zA#Wu2;w1<K8~b&1lzIkh=15pb7d2IKI1{DTrq4T5BXJ>P@#({V8L^r0cshy369{u@ zXbAnlo9(ER{LQnD4i7{4%-r(o+}(J8C!GQrExf6MgU8~oAxl~I0rfrt_Dw65(#qz0 zJI1nV?BND|m%kXR7h{JX+N4C`LC8|)20%`vi@U-s``0f)9dP`g*iEyoAm|RsMZ_gY z=Sp*8Z=c4Q+=WuoP5HT22Hy_A)ttQ!GO;h{@ru2oJvl)k0SBG?bP2ktbUbHAPf((1 z;zhqEl3DK=a|w#L$iJ8cc7j<TvA|cUGjzMd$cV$V3^9I*u+4>i;{v>m!s|MnP7(j( zO%va@dWHPbWAm+Z`pn=?pOH!b)6;v*T)R>@4y5%ZNcmi6Lh2IKrIqHrIELNUYqe-B zb{6&!`QKbZpyTZZ%v?ATu^m{vjQ%0{<`_K>I7j3x$rY^!(9#S%6ku3o7upjB!T+(E z$*O8-WAGe{Zm_TK?VUo#;y^DYhEK0th`nQ1fyYuzE2qHjLHRU@WI;c^d(!(UU|)nC z0|s7zXgDrG^HzZS&;jlPyK*1+l@C&$GvYG=r7HzNN?8;>5_GJ3Ws0MA{Ppldc<dDw z&{8Dw1lCN4zpHkkgxs^e1eN9jVnY8D(<KPD0~j~UP8oFV7GOuvi)fbrS(ono67&H_ zEgKuL?(8hsvXWEq*f_+!7lb}`NV4^D(Lvz}#5!v9T=M2GK}6#_=qSd&28B*%dWMM~ zyY{=mJ581$F{kS$(r;}MX<@ZKfQ81$nG&uY3~M`11GX=a)@sUk33_k40b5~lJlDPi zojR7gOgEyS0SASm(s1)DSsM!fC;XUsrkF)`F7kdSX5e67mwWdP$6SfJ$i3o%V5K1S z{5hcQ;FE#^L!pmkdg0gBv)cSl<w>*8HO~S21mW8*L3!{GII7v5ic8QMV0sB=7hx<D zW>*3vITF5*1O7eQEHd}A=+3FGszzE?+~%KW!17nNB#``14pejt)i(SW?<!F*q%5U7 zKsyZ-w^$~i)4&R3R<j5DS7L<X@4_z>k$aApppyUYO>haaxC9NNui_h&&bde`5Uw8M z+~93WjKX}Gc|CF!K<<Du#ZDZ|mH;uvHS&ol>nUC@ZIbdOy71P;58$`2l$aNP1iupV z)DAK_`R_z}m3`hPcd!=Mt^)qybq+=~&ocvU7P`&6t}bUGcZ<DC&~H6G-NvfPQFMFr zNvSgv>PYhFi}QYInZC{z>`m0Dq&2?>?h^FyH&0};JPxVXHOy62TUA-{PI6ZE$(A~Q zdR(XuBpq8kZ>ln=)#W);{^?odV7^3W4I*Ax^mEU7^~`;Xr*To~0T^tE9$jH)e18$9 zM42^&jbgjP@=ess*cz?`A0Ix^vQn<*dZ-L2CXXd~{_$@wc0`qt;a`z?^XoBFZ#c`_ z9uOq7$wTRQJsmotXkH|>8g2+US$=&bHmSjm`r2*QY5Eu{I%mt>w@}!ti;(=`kj@Gh zf@gi33_exl=Ez4eU=Ip<8gB5q`(6Bocs!c54kfP-6(foVvwZP*+~&)43A)k$b8ofI zMne}{J}&(Cz%_gk&!-uCcHnw(i@#0~&wp_7Y}uBj$rPC_jC&HDx~Q6H#mbZMQdUI4 zY>gCr4u3U!-(STa|3OHjGVS0(o0yl{TA0y_*m`w}nsV)yud|Wos-BMJgIV>YkyYxM zl^>bK%cKNO`E4SMa`})_l4`Jr<I5MiuL_@7s<)0qeM-*H-92{Wz&>>PE<$$|^xR}a zqo)?*;K~N+gn!;4;{7Rd{OTu$kar&UWoxn|ZG0=UmDO?2JWQzEh)&%6$<H5^4R`=6 z_OqKBR=c^eMe%hcINZrYv59+iB34OQG%dv9s|<+w1JgO4=ImzsN51=;tou?tV;+XW z#e`YwqdR#6e^&!C@>(RkcA47awXhol;6*S49pMf=OPGI(>uyFJEN;<e_53<)`GVp1 zu4r%|r-;WRR2GU+7B5j{f207*>PiT#Ts_P5TPn_2UP3reMx$I5GQ_t>>Rpl)!Y8I2 z>(@PRa5kR6eNwn~<mxG;w^Q$z4>9_vv&(kDD({OHOuk~!wN)IOB9KN`sRkY`#$>hb zO(#h2zch2!d~941vN%Cok|#4ILJ?R438EFK^pL8F;cqX*d?8L};gw8}uSdu~upHdY zvl$*$lHr8FpRv8W6ZoCidYnDJzugcMPs%EK>W&6cG!CU#loM6fa_duw5v-}P#(ZE} z)2O+p!sBiKWA^+{I}Pm((J1C17i=bWOwYBHSD|2|kx7)9GXuO7{rj`>XVGQu_4c!U z(;qK!2CI0r*tAO!0r%{IWuxF`Vug|FguZq4J`I*rBzP|7)EXyl;un)%c6dQy3^(;X zMoi~pnT<Z|d)DgIMR)&3_~dc0`fP8SN}RE%*#^W*F1%+WC~f9oWyxiS$KB+RsOm}< zPzP18B_QdWA>P|(FU(BH95pp}KnalDjnDM7-d~)O&^A+^nO{*gGT+x`%xzMu{zfnR zeS9&*+B;_eIeU$bZ68{Zcl}maolV3lDGjkAJox5vL0TAA+qOKes2|sL`$6LZBlM$$ zE>hPb`$%oe>cg84?k~D%^w32l+&jy8MeoKPypcB-zxZ~Z86<m1Bn>~YbYb%F-_s7P zy>YO2)R0>bU-7M}4QiD=G)s_F;L9OTeK^HO`>~75n4FDK!NuQlq@p|RSP**#9)Dm~ z@0yF$>&f!Yoa>^AJgOsZQzo_PKr4)~q^wW2Idq~uSM=&?$3D{wQ9rr`{zVqu#1<Zq z5BTe=?-fbG)G{8E?;}1-?kHNz2i4$8RW|NEaf<(3(!<CWoH6j&@HN+f4<Xz*=*_^i z{NI@#r^f@2;vrh>zdJI1=?KPDGp#z-7#Ng388<SXLIny~SPHOnJ$<n_nK6#>?3Fol zH|QN$QZ<fC!1}N5ZM{@9PkJb@Cnn1HB<)&0Bsv?!qTQH1leJbMXm@B$FSM02xW#dk zXDBpXd7kE7uvm8OHD7Ld*^weqU7d|fkXghRTkgA!qg^|DvS#nf9Sj}}_q2Vp1WnMW zyx?mp2o^P8(}oI#Hpo$j77YblIhSsFNe8+Xmoo5HTZkg+8N@a7sj~BG7zSIij=I0E zQ@(#e*5SGh1Z8yQUH+vO?$3FUuR|^!GVQ%;MZWTmEyzHf9LMi{PRaqA>T(M%L>$Kf z1Y!@um^_Ee;l-6w_VV{RO+|f1W=Ch9l>AKYeG}Ca+W6X-sivLb5+pY?tNBJa+31kk zdy<Oaqc;(&uGF1Sx0*#c=Eg6aKXVRf*uT%y@N`HMf)nCl4}{$(CTV(F|4er>v7Yl; zRW!Pg`#yDRVe&(p!i&;>&Da?CsLxvXsJMT4+QK>)wMU_ZTf0;0Z0wkCWljDxF1Uq( z88>+6!6hg>7yZeVK%G`41(Z>z(_6y<zVKXa^{;W=HOt2JFW4L(@ec<*89Mn^`Xtr5 zqMH4AI!*6Q;daV>mJHVv6Ay_io05lDeA>Ivk*uuRTaJez(4NR8zKvWZ5-^CEk_a3m zvDj&#Ul|)DTHdf_{K&BX84cghKR@X~{`%Ir&QwNJFUyVTot^u~QgUYz$W9$_LMFyi ze(f+Rwd{UOm1d26MIV|}#JI4@>`QwAg|_Ts)KJI$LwzAZiR|h|!+;XBQ(mVrinp0H zJ)E$5j<P5)-%N|%$mA*Req8?PcW$B>G1<98=BXf>D$8kF;he^E(FYz`wbAeBt(s?Q zI5K@ne#&gdK6|C4ia%=e^gz_qa8?KWwoAo#L)stGx)l(H948YkrY|;V9-Gy6Mq`p6 z3~e?t`Dra^rdUp>S6T^FZ?f;gE#V^#a#i@@yh&Uvixi?n%jbeYs?{*t{{G8*61F-} zN2*S&jaz_$w$*HH1MDH3OJhF>p1<s1E8bDh`iannOh88lo#(K3zw|)k*k`Pmcs)7X zkCcnEc|t+W=7ntEnI(t9a{lU@kFoWGfzUaJ2z_fhGpnxpv?pPaJKr*iVREoqdn3)n z?wT5nb<Xvv)1$q~sj(7)YZthXDBfO54rKnX3TNAF@%rL7gAo>D#q1sIUzhll+s=J> z&=5=cAmja2-J>!=*}8@fYMzDZrMCQrPm=vn2}5xIq{*qA__23gHAYLK6}&byNs35u z85uO<5@dX#V&bu0b2nKsjl$M0sGm2Ome6<a)Q=Rr8&eOGfrc@T8~ZqHq5O90JbiyG z^)5|Y1*xJsee&{PD6m0R@+FA2Sa_IO!4*e@8^!b>BPrq@{^;SIh%4wuF*Grf6^h@V z3^j#ig+Cd1!3bEGOx5H1=)P=radGqEFPKv9aEynRZ&|~kLZoEXJK-MzjxwB)lJO>a zT`!{Hg72b2CemrAzvc-URz)z_+9^B)d)|H!x<(0mf=_&4*6wn&&zBP$kS&@toT~q^ z;B#4p(br-=@jlO}k!TOJstsID!b~v2`B7Zn`0`9|B%@!4F|}J(tbihY2WBT=sB`lB zqE|MfId5jaO&lafAJkq{Q0<(}O1ycnc$g<)YsKfXTQ`+7A;a;pptvC_rU{dIlbU3X zlvd2X;YOsrgG7J!q$j2o(wSxTnfDeVe==Ad`yF3#JoqgY(cla#UPC$W%*c%X-4~y7 zedZ`+@S36fqro0H;ILRn&~c*S0T8fk$<9fq4=+LDmJ4dQKbN4*VtmId8>A)nj2;8u z+h01%^z;_e`Yo7pQ~Y+;$KJlj*Gv58tQIHg4JYtNs6KS+%+^vsag~Yk2rni1AKoSN zN(~y>NDpm)3eS%1cLvOcjjnZgGV0}cjOHkwAp0d_oH1EX-NlzSE>zd@H3Nl{ABBP5 zTn31`>eh%cCC=uHJ>hN+Z?brq#p;6|*{!5Mnl{Ib*DLfp*KfOKAVhs^A#)Tt34J$Q zw6-{DnLg%)3C&NK+BLmhV#t<bR&${Q49ffd;F3kW?!(=s0=r+`3R~*E#1G=Nb#y#? z1P^aj$m)Dx*%O<Bjb_7E>`i|d0muJYc3UqrxB;eWe3E}VNl=tF%A-A@E&f-$9-B=2 zYM&1{WYdHpq5K2-Kzc9C>zUW!p|n(-<z%(!RI}=kAiD7ZrDv;egz=f=1|(^nuY(cS zuaRq|tF5gF2JeVwk`x7f_a4%g>fh)v6lGxpVZ0a?<?xA|{-o?ScLd_^szG^Jc$I=h z<Iu+G=jVEjBR5?=R9-K#GUs@ZuDH*9?i4efesyZI){RUNnld6z=z0>k;C*%>EoV#n zJv0fuE#~<pi1XM%V`bEZHt7*!3@lcs+KrNPQ=(^zHIDh3uai9Vqo#qt(l959F5Z?- zs2lnA)Y7hf8YhQ|Q8VRfINZ2fIk@J-LJ7lIR$qdO^)Eqv0}TS@C+NXLwS;sSfj>T~ zcsFx(bK@ZQeMxnkEtLh=+Y(ZD6s&mtok{h{^q+IbGqB9_Q%l{+RoB1!SRrG`6Po<% z2sYMYIl?$J^U1+-{fp&5epf4w?%z(fiq=o-nqrp)L^I_*RVquQa{n;A-+^Q?Ns({H zX_OqX_6=u*(RsNi?db>>*2nY2s6HLw)obSXEH9G>NwB{mSdqrzTO{<XrL&Ls^|gHL z4J@}?^Jy}RhJEe@%!n*c5a{Q8xKNwgu=}eU+TZyW@ic0jh-z}{!d@-ldu}ECS-g&5 zt@`~=CQZH^_t><g2{CmLmSd3l!cJYDN=Tb;BYG>dKPUd|1yu*nV_Mp;2dhn(^gB`R zYYsJj6(7@UB;f4_lwmtRcQb~Y;-92(>!x*>Az7qXD480xe3sD1(SFR@t}N5s*fVjT zg)i0%0}-jx3woko9%m&E-u&!2II1x0mo~xz?Q7vOcp)*UiRaEdP3HA5m-JM>tI9R? z6_dXE*-d&AzWi2`-}Dlca^Ms1jX*0V5gCmYjS%~Je^ocB=VirU!Q|eDa!+&fc%}@* z5-Z1497}zq<2fB+37tGaF22s=^0gQ8T2SxK$xkzNjV(+2U)n65b|EP!O{(Q$vzpIr z8+rM?7#owoorbT0E>SzGSR2M?!1#!ci{Cn6@kVKkctaE85lEY)nB#}abC-(&PP_9X zj*pC(m?fo9DXR8@D5=_u%|a9*_c!hasjU^x?ej?|qqx7030>eYMMWVuZQ_6(2kM$^ z?+3HtA;@{228Fi?Y*kKo${paMO3y+vMSEdw4Ni!YVr@9a*AKaK7cqKZgZP_I1RD>- zKfft{|9$Z9=nNRzVN}8ZbBYgV;6PL$lh^zj%1ovv?!T%mEbV9}_2Hk(bW>v@Sc6d{ zE~>Z6Hp3s%^721_T-I}|@VCrgw?8S;V7`zsN{28%sJNP4F+_s8dq!hd0O|lDV^EwS zSfLDRDbAH#vda9UA=gvbHg=J>ar>g&XF?Lc?>&V@PA@WN&Kzh@p_9!YrX_m1JdRv@ zug?9|N0fwqE?yBwOfs)N`E9__omS&}z6XZ<u5`!o!q+$KNxp1v)NNuUKhM*Cy@u-Z zKcpFOeXbe*N>V?~uG;A1Lj1zxcaL->O{JVI)8kfTkAKCC_)`g<CRNE6e-OxSy_02L zJt1>5^D#vV^e1k8po?zXEN?BAL)vjn4G>c7UyRV3L{>Q`ZUfaeQZ`vj_!Bn~ol4h~ zI*+$N)t|s^fISV$OLsoM)=XQNx$d1n&LM}YJ|wK}z-C_ey#z5$W#%DwUaHt>|1iu7 z1532*6s^$)6)4HehdY?01>PKnAZU^`L-#Zyi2Hs$u<k%XPgdv;6^3f<s~s|C{#M7q zAsa7TB-6L(x8qRUh;P_Rwl#j0JCEZl?9j~KqM$^yG4qhghQjmdCNf&fr*FI6x=|!~ zro?2u^3Yt6gjIAAL?vDP8$mYF+l<HD)o5hyUGK5`jZu+2%BvCA(@(D5j?-*`Q4snw zx8)=K_a1R>y%N|AVlIPIPkl45eno8{7i{7Ndw6P)n<PAg)EQ=0b-M()C8y%PZhsPQ z1@%8Ex)ad(DRV;ublq#2x6#NCDDbr)HQT^hW)E(B-NF|0#H#J@-De3=bD7l=i<Oou z3Dx2BH&nxE_!+Jf_lI%(kQY@MtI;1}580L*Pg<7#L)@*yAXhARJ0ObE#l9=4^G~eA ztSGNJdoARv`CfcAr#2MMy02Giv=U<ECs4-EtrYW@ni4uVHdrF4WMV>?O{~vA1P<jv zxM`ndk$+9J9GewOnj#^jpM#si9%@e*3m1NTW#xEKwRbF*^`%MyE6oydisaBnCt+%Y zT=W`FjGv!$uF%I|N}Xg(MXa6#!{#`kVg53(U6#C4W(Ai^kVk_7rZ;Ut84{7l@zdIz zhfh_Bg`38vXexH}K)i$Y;(L%;K6d>^K`g6aa+{Ke8uPnvIR2K#B!&19pKFS4Uu9S` zKY%Tf+6R>FR%L3`BylA-`C??_K^KpIBm|IEAQ))KuF*VXBf?_i((z7OQe4NJDr)9k zK->di4rCVPg1-1u4t+F!G^j$&gm<!0v2NBS0D{*Jn@-{cW}iQ&_7m8GB_483>?OL} zvpW6Y7;=0GTE8{xp6h%&mm^6<Ag4k&v7Lj&MNHn;5_m&XG;ny6>w;o*9*c`IU^!fy zJ-cU+mjq)t3;MZR4xbY?>rZ8&)+8F#0QE$akTr2*wkHrhK`8WrOqnKrZwBBtsmJ4? z;8>J;Mw2R|ovt?V;&Cka;LQJ|3^A?{BV=Y5@pf`yGKp@`_T5smtLQPsg@}kwgFFWw z(mnzVG7wAUTo9*>@l%h6Jrc7YfiUoM|9eK&&8*a7bs@<U!TH9xnkMwjmIpK|?{#Bs zBn{u3ZN6N*fBJFG-P?nTB0NBZ%h^+3^PEYMEWMK-xY9yuz@X1oht1QT!tP~v>zGiW z=7%1?HMrjz<W=rPB2VDEj|=m!u?!k67T%M4vLtLt2b>^g3p)Rklo{E;fusyPE`_TH zygO4oqm{~|g$T>~8gA_2D;M~{re1Hwb&R~dyx_XkObhGBvEqfXrqy+oit4RmbJiN_ zCTku8>Iy>!mly-NK=I9o(HZY^y<qDB)DN#2Wj(Sd<%Y^dVfQWUrQF+i?LK75M#^+i zc+iA|YLnC~!dK%S@_JKSrSvB;ynmDpnv|k-K@~}3V-6Bb9u2NMM-0qY4rT6<Su<M& zK-9bg|5Akm6)gixtjpPhm}g5?s_{`ZpARN~8n#0Edk9|BfAfj{a!f)GT^va`h73@~ z@HRjRi%-nKCF^ul-Ntd`)iX;mv-h1b*b{ftAEtvv6PtLo=+qx2tI*|3keF-K9f^h* z_yf=@=`AqfAS+u@L9^*oQ)E`p7X`y%$F4)Uvh(MmJ-Favi9s$*7)|^|z~UJG1?Ep$ zQUj&G*Yo?+NAw8sSN5g#I~}~?Y}nhXbIPGQ(&P{JD9udWJn}Uam)%00oohHT7S?-r z?dSbUq)>IidE9Y-;>E;gXKB>5G*TOrD_arz@zXPop3VY)Q*NurQ%D{%H@(SPIq)I< z{kzhpOM>_vlW~Q$)dX!>3&WI%?-7<{C(AE>yU!|_3dQ%gi_NC(Mq4+On)teIO!A$0 z?LTVQ9%a4Ak#?TFA2qeegw}ESB00vBTRMvqx&+1j!s6>Ef#9xjAa)5_K<eztMa`bv z96hkr+`WT0haZhzf~>M7&w;c84NMY};le|E7y8Rv<BOk-CgtNp_tnK0?ZL&Cv-C~e z3K@>=dGuYHYXQU7JQW*-LqTcP_M+30r+JwYsSlfkTGh0J-9VdzyWe57wQPeYG_?(C z6(UADM+lDstR<K-y&P22gav~FuGyPZcFOWJzK-O-{<4rY<~rep6AxWAo>gA0kZURD zE9Z6J-!#s`c?kwJ<#27g=?B`?6CFvD33vvqyojT^5)UtzH5o#Rjs+ffuf=5|7t=xc z)p@~4r<`l%Y4CV+6G|F&&Rwiz@HXFPWI^2%;apMb-!^jV!3aO86@<qkt9?q<LuVQG z>}9XEb4ThQJ~Ab06>J2SdM_*_YPV^EfVrlfdza;X-AlvVfI6Tn`Vm(cH1AWsJUuDk z;TNZ^@J^a}c+EQI5Do-UA-BR0n&-3G_ZvA9kQyc*h@SYkha5{^f)o;~9IyJK=r~|l zgic;_)A;z#f#+Gg8?g*G1n)JE@LbicYIC|5{k}x}eE?~;nGnOWO7vrSxw7fTr|z-Z z{I+D=vuA7O_dYwE3&pxt580~lF$4>^eap;aXu=WTels#o<^BwVadDN$?9d7Hi|Bd> z2fd~bY3768tM>9KP1JHK^w4v3JaR1$Ocmz9`#CZC13{#IXkT{bEU}BxV^Zm+jd|%O zXHKT)QM^qu@)r+a0eNmC&4F)jH@2s$N4C*HGNwSX@qs_9suXAS^}pyqw2zVr>gOcO zsj5U{>#JF$EJOVcX*D{|yDX)=i|J~n-^u_M7r%&aVEkusW#~mHaLQ_X;;yfsiK{KR z-J(dpCw!mwE6w8aJP-r|*R#cE0LxqKILZeKY}w#Rh^a}cs;<<SF4H$Qw48UFhuW)M z>wt4qU(xeID!Ix~{%mJw_KMi*QzMUV#p~gVvFK_!=B2ISu~!{?-`n4!mqPPy)-<|) z^><%!^E<-*m@xjmZjpPjl=xM%?rS}oPe-8rBYjJ?mwZzjRMdd9({v5U`Pn_A0<o7M za+C2I$!;pVbIa1=yF{EC_$+Go<!otTT?B2#Q?%R))K8Y!!+l(la22&?=i$sbrok(D z{&C~ARSds@mp5lrfZ~(%cn`IFIok%twK3=K4~I3r=`OX?3%Dn2xwlFxe0Z@l&uVsi zI?XGJIHZe)FlpvnLd+@nf`7Qnbu;4}7B|1V8gSip`GOuv=W@NwuE;#~X%8Qq_a^Om z`e#`KQ**|>tJzF4PcmO-22!tAk&jfsDBi<;eK2xYC!3x4MU{TyLk&8C(gVR5MKrQd zKe<oiXw*Z72yUk%gb`8`6$_;&>|mnsBb4EoNUf~8({Nz<mv^a5?8A)6Qt;_Xn~r5u z&Lj(8<=0k^Q6uK5@D~sb5d)$FwDArZ{lU@q2^ao#n+O`v02T8eqXbk>6ZXt=_2E7C z{b#OnKWemkv#lV(Q?}eg*fYJV<Wj*RM>C8xNi@U)E!y!K$phVrffr*n-Ph`S*XLbk zPo5NozMnoKp7Pwc?AEIH4_rF{rrirrAU-+Go@fGHzf}c@i|xR?D4cg7u$RSN@4TKM z#;*hOrj?<1EC`v!z<CMsZGh_a{unB7+F~z_e<`<+@`!X#oWgGod?2y#%Oif-h(&o# z`IgG|zSUa4nnU3|+6X;1<=cuVt!ngqq`r^f{iLvfT&n77^+)7ruuH3^i^&uZQ<)LN zG49uc)wM^7mD-bcm`Yl3DwbwGPL$mVXG2?^<F~wozqX$;Jhq?@XL%ANhmE9j^IuVR z4)OYYt4ukU&0}Ngws^IbZCqbMA9%SqZI>gt*!dHY<c<=fn}=wxFAAEWWN8^~IbBgY z)f$5pL|6G!nz!DXqAnf9Nyg=l7QI{)jU)Q>ZUyp`a)B6j@6cn|0X>L|y(8_@l6SGa zFQEY8vCN6r>9pn`is_U&TsQjHa{-@n|2y6@beM)BsMW-NaFK5iki3?ekXdshI6@E9 zKVLv7L~3Y{1gekg^!8VcGq~Nf`net>zP*{6cFbGU5)}z%bFU6n#>Nwi!JeJ=|J?2i z-SRd6DA4vq!<*n<sfC*kqu&ap#51i1vh?^@;p?$gW%Pp^Glp#Y`z{^rs2`%2pwW#A z#MfdWSN3|<BTf1*?WNOvKfiQPy*9Gz6;WLCe-~5fny7xn<|$pYppLD|y?qzk{z$-P zKQby)a)TuRVfuyNWBaG<e&PMY3o;?1Ws-BzD6QD!Zla>VUG-OIiTsn(Q)`Vh1zo>2 zk3PA(ioa^_4MBj~R3O-?^mCgAr^cD-@maut1V%1F`q9G~m!R&MlY~o<2=W{ly_V^x z<c$I4pp5W8LBT~l6qE_t*TmcWF8Kc{e9%>ToK@;qr;}0cz1%jn$RG;?TPhq(*OHn2 zd{WXyg-DN1AY>kJuVckfw%q5>dvlAc;4J>yc{`uv>(|)y{Y)rdT%5_&Mbhxn?GEu^ z?;JbBQWRmZ`{A!Bzl@0Vnip-&g7wMbe$K)vl?=lz!2~2Ogl-SOi`CfFd-nYAUpbG( z+q?-^i-d})=)Mq-MuEwXvRr>oZ)T0XRRCgYBoI@Lu43w;*neWGr2AD&6%FD)h41o+ zZc4wz>$T}@?V`;`ON$CRXO{j&3NGZfAW*wlQh#kx9k?G|-S}9ZA%ak08cJaLa8z%F zy6U}K`#^h3k3Ln<ChAM4&yI(f|La(@LdgQ2y0Ry5DRVGwyBuC-kq{B&caYond)@qH z;_bSU7ti9=o32GS*E#$(`TMqsFrg0{wX62cKPBGy<J)sBWwO*ew*@_DQ>;$iri7=q zQ9~Er&l+;+_V5UqsY2f#``NRxQhxcc3Jadx8Z&Wn&AZmRI`UnXgGGD>^@n{mresFC z2GyUA?Gjl1n)i?&r5MmmDjJ{8BRyV0j>onYXdj~&>MDIZMJj?Ggvt$ruz9SuOy#CA z1VM*qKwokheS8Bz4gso%)1_Rb>Js_AO?$7$&5KUnE7_tTaY5}C^h_pPbt!-02fB=e zhvoze3O1MQH#f2oap5hMitR*a{)f2o!B}d&1br9mfGX9eT76anBRC?km^5i0znLz) zReCR@MTV(^=nPM5WRzTdnh@mT;u3qn9Y1lis%N@Dmk-0<MQ<S^BZGYxauG<xD~%J# zqZN+0%csexcw5;yqo=h@b-_F-RX#^j8WcB*#5iP(#Ae-0TxV`jd2by4c}nilUD<2A z^bFD++zC<MG#RxNa?`4<&Mr^(Nsj}I$>&FZP5^MZ^w)0`exxPzds9aaAIs{5H>)B& zsP%aEATomKETSGz!Zuk;+BIurTM*DHsX?9BBKBb^zjs;i=CFT>Utzgf>HY<|n9ivm zaQ@20VJYb&dB)XGyJU2;$6jNRxxSIpI@!(#tSQ!wWuhUNR(C>vSF?<p1Tpeu9tw%- zKkJUHWP2r7`np&Y7-ijm{tC>ZX|tIkJ#V5=?=@P2nrUB`8Ao&=4o`2;Ju^NB#$qX6 zn8*DYV|+h5EZjhc1p|#AhwB_VyF@?oH6Y@PIj%hv>fZ<7X<sUPK-jTdG`fBPYhsgi zK^C7tl)Q)%bJdATYpov2+^}>yApf=-8t6xu>CiA~n5KPf<_RE4AV>H&HeH)OB1v3I z?2idcrqn@)P3@a~A(<7tMl&V3j73IeGNu;gg$1f>3%pgu85IJLe#$J8*xlMf#cgYG z<{mt%I$Ro_J;NhpVc`jr=!M_8`VmEJ<>ssp>$<v%44^MoH>8U-1wXwoa}4^qC+^3s z;-eMxWa=aailMs%Im3=L1|_bN=@#I4x=N<!+)ddIENCJ5<OU=V^0!lOpy-_+?2WB( zYU9<p4)PqeI>*~$B6CNJB%?7nn_}lX?4u)^7BSgJS-*@@T(T=m9xnTBv=<atCR<?N zHKRF3GeaxFYD^NxGqhc-eXOSj%Z<XSCf_V!C>Gt@G`3{bQ7k^RQJaCcvH}AByt`HQ z#a{RRjhD|qXHhAVFt^112>McKIRFuv*s90(Wk?!_`3C7V#|9bfHj(T?!)ok1t2ot6 zeHv%^<I&DENBd2x{SOj7s);a`P-7UMhSvRaif399QyX{|SbMAcCCIc8K&a*V``PZo zfi@G1H2&J*1^p!`vaUSa85Gr1$4yVKomsz7TP_TYFDaaoH-YhG=a!4<$bzsa-A3`} zy`%|tX=!xnoOknC<X$#op_Ke;N9*s+Q;NNUBAfmCYOZ*$NhCoe)ujB#TtCj&6;|5~ z5nn`E9t3;%gLgBb8u7^~RK8Xr6IzW*rY}G8bVXrT5A$p?(;`<3z1jDig;^nAYGRbd zrfxS$Dz>iys2h2QVxGpodu@)@F$>39-sN9|j>I@VQ;R&FJBzv}*d^@Ci2YD|%%)ZA z^vvkv7FSdWv5hrU92%sw7RU#0+)}=ncs4pZoKkhhH(~lN&$x$yMW9SnmB4?Kkm1&` z&J)YMLK9Q#?QGIMOmg*wx4hoGQ%Jp|&uxJlhIcs#N#1;leH)#(kE<;^+(s<n)hK`t z%p0Kplrrm!Xc?_N9&1DiP@OU!`xU5qBIuhG!fQ*bUoOb_-5bDWBwGRnWzFEoq@VGU z<%xR%s?K>j3rHbWb6f(;>VX`>Z(E??tau&c-zD8r6pw>;yW;o@y>(=`s|QoLq+d*7 z`9nUBn;GNjv(LqTm1_k-F~Xav6sxTlT{x8b&av5qTv{R2d#M-u){+C-GRlBzxXJ%} zxEO^C-%pjl1FRP=9Cr4F&eKHA-HtPDJ3%hbCy&T7X6DO`iAnC=5%{QuzH_*)$Fs!Q z!>RDm`Ph%&hFpp`hXnFo?uJ^qmt6DyW|AQzhlOh&>URB-&Pg2{#YKcWtEg9{A@fc@ zIqrNv>R=MIsfsYZ{oJ@KCg5OhceIeY$%3;W72l`?9=v&rg8^nHrhnqO;6oOzruB)M zdTeGg*#|@|E$b`CT>tzMWb=Hh@e*`$U2Y*^1;2#1G@8$BU>cm7G##T9xdU;Ae2pu) zmcDuF9_2=%)~AQ>`8AwoHrj4Bad2lj687PA#fzDCrzk6HZD4x3$7N;bYg5G6^d!A0 zN{zMVIjpjauA}8Ak#1fKdmbgAgu>xEaOU%A#Z;2%N9b#5s&PpFZkg94^dxe&#f6Sh zE!qt=_tt>}DctN+Uik7+MBT*5>FH^DCr)lu@pNm0tYf8D8qLzOYU*=jZMqI1H91CK z_E6*l+V~6mkh-c$=JUERhRl|g<;RR2zWetypf5_9tbS+>Eae7HV$^invaX>d<K6bu z&2R0_U2QXCOy@6PHp2}Iql@@Q1v6oVkl7AcipYsi(GtRBj0&DizDUS~H}&h$`R{)0 z$SON;n8zhZG-3y9vTtfoUw%E<B-DTp#C_FYmAfFxl}Z|}EJCvEQTOtgZbDmdZK$2m zS1+Z~e(NME3%PJQ6@xz;F!cIi&inQzrbiF=uBSh39Kbi43%AI>vS&{$a(eW+3gfmJ zHx^sd7eje6*H^aIxpu`Xo7^nL#M@#WPP`?$CE89PoIgXJ>2wMx$VJFbRsAVDV>c$I zU*TZwuu1x$xGN&Xvv!KZyqGBUleV@TebnB!5k2{fDa)MmH!!6nd2q)g!3fL{u8_nP z|Nc(Hb}_oUW&urU>Nsn84k^itL;9rtwUu>`B3yAacDd_y3OLj<Ly%Y|V_Js1Gol+a zJCFSs)CbX=B)o0%gS}VGL&146fPBt!_h5hIrS3>AK*%ApyXvtzpq=JMruDc}(<etd z2#o9&gx(ap#e@nOolS+7I~+Qmost|iTz6CfjyjP~^E1_kI`@y%C8u+B7gQC+SDuoe zFUtS)b2drP$JdA097wtQ;OqaEpSTvin#iqbDD{dWiVB?zBLuM|Cv2kz(#s|aXRgG> z8t$shpM>5rTQ&s1w6KCp5V|M%;^-2D)B4}E$>R+e@dq6Lg)KhF3Av2`>M47e_3##; zK+4TjCipzw96-`sf?i&N;Az6=n}DkTMDrb+#R5dPQlJ5>qG;ayQBC5cj|Fj%Q+axn z_o*+I<`VRuZwPyV4X!ld`KOTv+2^J1Qe+eQc!o=nYCJ}hc<X_iBZBOGzH0WD)QJgZ zZ&@ZyDs^SD?ZBWQOPuq3!1f)^_?JE8$^@)1iE*7C1U(;RR$-~H-^kKZRYrFi&O}a1 zEY*L~6`q1p+o?1C$j7-E5k+{s6!$e9DD;X)agp<Wfd+MezaHLoRkBOKH(I96Cx5&~ zo&3tr$&I{-0BZ6DS^n!7_`p><Yd{!aMhtrL!v7=#Vxrt-@%vi%OA2*X?AB2;ZuJsm zdu7+fanOk&;Q+v{TLGzBFBqdz4!Wt%(X$<U>E0K!au+=K-u!9GbBmbFbs+q~aQwFd z@Ik<o@caajyo&I~S_9x~5AmZ}<9L>32|V4&`z*46JYp14-Dk={XNhD$^u?buUDdw- z^Rf@~rj6Dw6{amD^GzBZ=dj?o@?w_!>~n>}La&RrVHbtU$K}8_J~+PZ+c5Mp901|6 zT!KCV9~1-cKU@?)yn4MV$1vBU*HSw*IyyMf!8lo=sNAis$-9*u`(@OW?&bq=z!R8* zWSMlRfJFRC`ZlOYoPHynra^$HTloh_%G;6<?{gNjfvUd?fngO5y-v)O>vBDvBM2Qc zaiC-h%f19Xh2l~!K^y#^&ULPUvV;S8KtTVHvXrgtH$uK8B!sH;Eym<6U4mq?fp4w= z%lKA207M3`sIEA{7q-3&{c#v&sk}*<r-4B_+G>R?6DGz`mXmAvwv}H1ELnB(?aR>Z zZpkz0ILpAWq?Oy+1ao1>c>=8{UjX)f9h{+Yln<bjxm+XWRkg$YLt2~VwPiVVj^M|@ zBG>;Hc$Tzf_epMjywr!WddH+k)!f_+f0l^XU=#k6$$-}c3vU?lUm?&{06@E2xL<`i zjRS$0{MgNoHihlMeHwwDhAcpnO(gNTzpr*<uQ~ZGrrcvYKU|wgPaM0MMT+zG!ho5F z7E}1o`w;-}U92P3bYtrRI+sBgjswOGWE6Rv$wxlpRgbJ^v97(O^lkm?CiG6W2snW0 z-||=#+Xeu)%K+dOPnNYnd7JAUSWMjoqE47(lki%x8nYAVu=@|u><Paac7_u7sEjf@ z(fM2xW&f=<SmPr5Glg5wb}8<6?0@x8cg_#w&;a-vffERmLfU9i7!Xv+{T3OEMtD;) ztj4&<DE2xaE8ZZ57Xff^>_(4s55SgyaI9f0TpgIVRHGRdPXhU16_=ozE5`Z%XFd8L z$oQ0sK;L-V?jyIooA58MulY^Jyw~&lq}|IBiz5&y6EwNPQb#qOBr?OT<(Jx`xs%BV zm%dVuvs$^Y1uSOefm=Irvac4UDQl$x`tPu2p;#~$C^Sw1BQE`kAN&vOeDIw6KP=qc z-S|N1ngiL5Za$iRI}Uz*tD2iN=5edRQR+Gi0N;j=>*QPk;8`z0x8vm{iZT6|M~3yw zs>u|@VoLPun&+A`OG^Msny_<|*%m<4lND$CW(pcT4}`WqwlRD%`F5@c;l|KL!XWRM zck7cEW>gSa{4kj|)X+EI(+n_NqS#hbelv2$TSuk6LemLYcvmy%HRYErNM`zr1$Tu% zGn?D6CPO)v5!dZkBRE&Qnw$bx54KF|LsKk+0iEAjsHFR5)WN2)83602yb|{TvXp5P z4e(8>z*E>-VL<l%cG)+#TWDzx==ArO85!w-<r09CQ@H-wKce+<{Hh>abz+a#ss}xb zda7WW9g7~DFX*WZ0wDAtm?h-Y<m&baBhJv!rBKkO?{O+dQgUkP`OgmQYy9&O>o*hO zGq4>pKl#*8nZ;drGDrMfvkN8I6_UT?=l`Jm2hZ>Py9S^E+o^&scwS^&gj|9I<bcrc za0zmxZ|g*E3VWs{7sF17&Bg!+=7wql?}(CIBl}eNlqanC#p9}^8g4x12H#3QK1<aU zb2j^TsYEIvXLC~m2!){<m!LaI_iY2gKuO274x%)x(fp6KUdz;U{>MS(JQ^sC{wU?E z+g@ZGIma>h_)YGI5{p*&k7_eAY%VW$Im@Q|cGs;{44oDiRw3537U004^GD)H=YX;@ zFb{^S97nj!TEZL7^uMO-lvE|xB;CAzSmf$Ig3#RZq<(7<L*JcX*L9DgVAPjzcii}s zn5zoq)eQi-6iy$#D+E3jPM=z6sjut<qoGMCy`>Dj=@+@qS<9;!agz&)s|QRwu4C7O zr)aJ+ko-fssLnwq#T2yyHtT}7ptWL_3lKEX-kY57Ypz$86EnMTrfKc!DHJM*+mZY| zL!rJx($Q<*B~os$e6$a&$+JY(sy<YSL(at)x90LT9NeLw(!<uaX>Vqq$Yy5N&>$;d zbyvQ^a-@)ab%%o;(Hf?kRUvW{401V3w<W|J5XX1J{`n}&k<`C?2mpQlJTWYfKuf9$ z!{~0{5>3som+xTzc+l*mp80p(!U;+Tar<LNsajMR%F#F*Lx4TDBuQnqNb1Ka%g6>? zo$#S=Oz-ZZaFE)HDwc7W_1?U=>;5s8Jez(3+Hhu|3fvaR?Q#Tvtw*l){Wo$&?><>P z=fF?GHWE8h0%PGT-WcRRe3z4=d8p1)U&tGU;l+)>+c5(<y0tus!JN0>)+}am&mtS{ zYQ3w~`j*O@I=oO-E%I3V(R){vj2z3~fTyVq6U1rypGwYsUrqesf5WaCDH06-B1*`7 za$zs!XY6iVs{V$X01^LqG9)C%a~9s2n-tzf0=GHZuX@|wDa(p_7OLtQJCf?{qwKyM z%g^X}J42VLRr@eOR-J|ax23`*2%AggJ)~<ec-E(~&j9gCzOe0KKG`jC22(Z5VUFY$ zGD8hY$8e(YD!Xze#<h&$<#8X9a;|-U#DLAa9x$4p%(-b~WCXNbrCi5UGy?!Q&EUx? zJ&-t*NseFZ{H3iCwSg%D64lWkj>hPXEA+$%zH^xH&;o{_|8qPcIhdMqS%!9zW6D1< zt0Ht(!62pU^nFD^Ys_?gyx$_2Md>+aoR8OuqxaXV`4JsK;{HnGQrVIqMVS>5zqJ4_ z!9L-kMDAM-h+E)~H(Yt1Zi8)!C_FdS3!UyC)M!B9+L~!~(<)edD{Z+SJnvYqKO#y` zUxaE`%OLDui9UkN`>Cfs13c3<MtwoF?)ua_>;wvI<<%_j?^7@_(mpINgbL4a9&3N9 zVfdaes>!<KeN2NW9QB!JV7{;t%<1ctWK^1REmlN1YSAa~+qK6%{3hcRM+P>tA<WCd zv?edx(Q-Cq*3|SS$~KSKq`=|x)or4Py~8q0hU%#6p4{7>N-dRRAOS=D?ch-D@1G=A zO?hsa{|An_rEgc<wSRG=<DT*Q;GrB)^!Pmi2U9)-bi7l1`^=1k`_&M8`5s#m*^;=+ z-d}1lQS&GYQfXP_SzA>u34g_r%<rLRWsX5@DOx?XiiMSBaAE#7sDh51o4es`X}`<d zWT>d(P0L_y0_ZC{md||e=^55^AenjcE3}c4+g`*m%m)R!t#sY3Q5keeE-CUJ1UN@^ z2zD!_we$YP#lWF}Ci|qKnN=BRD(w1X!s;j?^z%=Rt=6jVMe^me<wV>VZL;fV!olA# zan%jxPY=sZY|6PB9-w)WbjoD}=zv>{hSl`gZ;Ly{dP0aQZ(#Twk@llx7YXZ6QV=rw zl;$icxb!4$N-fh(>`uB*QF@!c8!*b~N<gJNHw6H>RZklVEA$7okHtXhi6OuR1o*Sr zO5Z&P?N!4w9&VcN(`^rrQ11mhIU>N({Vq`j0lpCChqh4^DMvp}_4=MIJI-8!1jz)Q zua}kVTYV|NlkDw8o(@DO3i(|<UQb$5k3(+uoU+Q&TDyq4=ju|Dv@!&qDESktoY$%W zzuB~StVF{C|NY=s1tV+ow%5#NL~UdU^C3sqemT#evqUtLcGol<G{kLR7H+-T+@f`+ zjVk)hFn#PjxA=3r9cU73y+~cE`a!KNoYFw4e4_LwIg_orI_hn-J*$};=TpkXsL`y@ zSV0>&U?Bwq4!*t}K0q<yx==4FcLv@H?x0WSV3|iJrw6_8OYn|71lp2zzkAMt^3wiV zO}w41Lx7!Eqtb(<+Z@}xR|4-LpDD(0QfoAQ?R&>R2ljy+t&p8P``u6cN&kWD`{XV= zl8igp<DZ?+Eq8I!J=}~g5|E=|$`mWl8+_pr=@DOom9z>`3g`B!6EW&_LK%;vw=&G# zlE`<B-i9kmKO%%CsiQIWdw`pH_|21al-g**`P>;>SYyq-(}F*e7{~kKka2&1P8%cm zWhM665kt;JTe+d*_1nBkez!w|GvCQzb|)h}-bL1NNpSE=FuzlnCPysv&iH0N@6xD` zr?K(^+j>jOm+Qp++`ljMW~qxVe;OwdP4dZQ`R0CQoUZ5CgT>R-gR;@{k$PnN?*99R z$RNi(TlK{Qb&k>)6^bZM@&Yl^Q(uWH+p8IKb7Ei}fbD>*$W5mAhDsBb{cb<;@R_?+ zr`WvVp-#W$*kS)hK4;b>Bz|VU%ie-~N7^k)NwjfB{9RP=Z2Q@>Ut_N{Jt{GOdU8?) z6&6$j3n5kQVQD0w+uAzdH*?I{l`5Wg<6>`XbC2&XQp+vav#Xd1Tu0C9>U5KVEyGr) zSR6i7ellq2_}lL^yZmE%QoP#I+da0(_iUh?Z$&k7`8icu0vYHIgw;A$hS5-jFl$Zc zP$*fkJW%r~ttijSx6?@8w^8xZGAM?2nV;5=A~2bZUHo3v!P()|ekkgWu(@yaB$+z2 z>Rf5G6ZXdIIQm>KzYw4lt+7mq-7W_EzbSqwh!+Ra5svO2SSWzUeR={H;{;OW1$Tyh zt){0zJFkCw$d^{%*#fdbaf7H=K+}8g<ttsCu0v_w#dDLI$3;0Gsh`(FY^ymSV9(WF z6<_<khGz!@#+@A(f2k%WRcNy(o0*!U@818q+5W_9`ru$q$2^|5qiSnIIZD^~7v9=v zyqJBckkjP8j%(7BU&M*@7%T7`?;>2g`giE3PX^^Uax5=zUYFNN62lfl!T7fjpFm6; z-pUC^1`1W2U4@%g@(OA?uYSkZ1#uL{Puxu<WzyUhdrZI>Vxc2_AwH&?;L;OwgKrgP zz;sWYk;vXUjsF;K*NiR!r=M9a;RfxaP3&vfw>YpMaOMX&+t`7)$JV7aBC(Y@nmp@E zqI`j;ge=-Y6-$oiIjGc`Bdq%l7Ox6ic{{wi@;2B7i@bn7_dP}L&=s$Anb90O4Mc(r z$^HY7GGRyt24^3*G%rEISMwx~Uy98ZQwa&Nrs)Pzf$aAzJmQMstUOABVO@)*i^wY8 z*>?TFzq9z1CBNOko*8|GAY?$X_B8aK_rL<O!7;8@`&b~Rq%vDAl$7(6?cQYMeZ<#P z=kX18)UC34$yT}>KS3{uGG{QULSrx0oWs?fodx*GlXmOvkDq<MElYmGK}+@rH+`ke zN7^F%v{(liW^;qwg={)=E_Q#i*x_xJ{?w<gl)RGpr4*)Qd-pJl2WP=BIsAPGc_AN` z8~XHbU&vAMsVe+kdZq~Wi^CG`UN{aknG8Dv?G%o-p7`hN$*}%3=v_-S=UZQ`+6KkW z!L0a4BPpLwf`GdK+6kV3qoovWS2P+YPE?!J6DA~No-W3W9T&4?9q@aYiPFf5I~n5l zMVi32GV%>;PO@3eCM7tIP&HlEshBCxkP};^n|DCc-EXA}pWBNzE$gcS{9dW(6H&)J zKR~%O_%rimbJM+fGWVm#Fq#uRsn1zOJZrQ3wIhmUD-0$7vuvs1@Mc%)$Y6DXkx|bw z1Dn1uLqr;jsNRrm5pTXkNiNN?n#oQre|7y!k9h6)CIQT3A{%P^jvY6*4+Fz`(|V78 zO~9vL8<mJtW@eY1pE)zh7Ysuucpi*1@3cq%{h|-@6rd}96A;1xY`rWu2nJwRo)#_` z5xDL4DD1~0`8EaIV;bnd0bbjt4=WGkb9<bX=c(;d$=Jozcz8~vn^>`O*vZ&ecT?XP z=v$_Ql7IFOY2waDIS{jV-)d2`j5EB|9GOudpO!c0xA;RQt567XSaR|$l6N_UfKv22 z-a?7>HTVsfU~nM8`S&eIMtkP4IU*pyPbhIqDN4i2PcFOiqiaevPIdt<F+Ho~G5*r) ze!B;f1LW}_YBv+6v*#$TTQAw|m9n)|rT9l8hBvYIB_*fELA!bS8P$qI+Oh7FM3%sg z?2;W{*k(tEdHDvG5d5y%7TyJP%#8ZGpjWQomr}fAP`uI!zcWE^_P8bvV)IOl_Yu&I z709Gv*@|(@7O%r-q7OJY%diQ+=nR@fS@}>viq%hB@{1~KT*7epgDCey%xE~AUyDU! zu{$;C?^BdS&Ry@Hp@#iLu3VeBZuMZF4(58>2FancI9}<Wtv4O_9}UpO;ZQcGeOZKZ zxAJ}vlORnGavMTrjpH5?J;mHbLCCKKeW9uUf4Dm9u&BbW-47v%AdP^4pp>+L0z;$H z4GPjoHw@i_l!8MGNQ1O=4jl>%J#=>sjdTd(+35S8?>pyTy)Izy{p@G2bw9uRULAfN zzQI^AW0tVvE44>K1S$O$i1-*>tygd+$X4U8=X>LEKJ!ml{c?IoD_`p{;#Xk{Tzk;b zF!+ISQ%4nZL;Ons1q}C;DM`gNvETgN*GBe0kBA&otTHv9lbl9E1A5ca3tKm)7QXrr z`yArWm}nQwR$kXjC)s@Q`TO4Wb{Ylqr||74BB2!5*xE#+xaTeGFJoK6clF8nNor-( zfd=y`!0X!9w`VpH;0`Z2HBo@u2=%Q=b4%g(8^IY<(j;bm_C_JYBx*X!e0~4CqMn$W z^~uRADTqxKF8dx?OWR<v6njakljoouviY7C-pget)&y2X>tH^tzyoSK$ZSk{cDk$$ zMcf#(dkLipO~Ht{XTR!W7SVv+fcx`Or?lzdB_GYtwcnCeRaN@~ue(^__Z>we7`^ne zI0<Z=<e6Nl@fY*_wwHax<O*#|@Xm3lsc)U2sU8KKrEjzAZ^jsMa+*qwvOpLJ-w%5u zWp0HJs)ilBaep8$AMUsA478p+FMGuU*o1*Cqk8~os(??<yNh8b&cP3^6ZXQ{`VOv= z0}uC<Vvuz8Td?2SGUB3h6|K;rmEPPwB!`jX*zxWl$tyNavn~?^eq*4AkEswUougyu z%D}!*Frh{FN5$jMG}J^OZeff_9|DaQIQxw5%smsU5&|tY0wvt;Tz^UbAo4N~uRA3) zlHn^?dUVX(rCesQ_CemS`6z1F*3R@Jo5p4CX;hl^W(R|l$A%w=69)srBrL!`KG8t= z%IYzxmqKvbu0Rqa$X@986!_qDM<($>Z3S)OgG2pKxs4G_PM95ondzzwZs4s8O?7*N z$6#p@0Zw~<>em%_&svB>=j)xG`L(2viMV1fz(t5ckxviP`pr7mX&!JK`(Bc=c<zH~ z|9~RAlWYu!TX~K|Mb}%+5;HajQxK&ePNw?quPNNJCjA*>{%*AvEx0>?6GVF!*jtl1 zhnsIY)0%JkN5BjFDb)6muDO^Gsiw5)c&l-S=FQ_QtxM_6rw~;%M&?p%4p^Tyn)eto z1ORtp9Q;yYEE`r+nK53#^5Iq?T8%<YlE2d?rEyc_wdIU*&T=OcpsG7xO=p9b3^C`X z=Vb5E+h9M3T247X^Vsi3i1ETkMixvb(2}Kt<%ZQrd(19Fxg0ej29mmrMtFM0NPJ7T zk+t1t>AgJ$YbMX7PqbCM_cG(gZse~AlvB*-rEOk0e*fuqzjz|L2SU4sIUFx8-{o|D zh?pF$>vOWM?tB=QGXWP-#r*^7wlKO{f}Sv!z2f|5H{WP&2khqTzN%y|Y655L<inLn zq9mu3=m#74MGPrtjfP+eXGMK28HH_KQm{LT1G7hotN%)-vitjf>{Fo-Z1+V=zR%{6 zwJfc>Qj8#;*4!8F2lafFTC<t2*pf*ehs%XAkw=4M2u1v!+6=Z+y7^#N<P0_Om97Ey z8Kj!`sLJ5xLuH+q!N)PaVWv9p<MmoX85tEKHNN{V+`f|+pLmDo$n0QBYQ0dQGJd)T zr$%mt8+zQy3nLjU;gtI^Z7}kdK@MXE%eWj>Iv2Besi`a06WcJAtRq@rXaM_7UOPr@ z8rkMO0yI=`bC>E1SG+^fy{08yq1i5?K|tlt&^<Xq0*LDPBW{=LyH{vf^Pw#IJ^k4% zfT)(j{&m?YO#jZ+k9nTxD~L}&TX4?DJ8~KFZAHFVGjJFG7{61tCKinL^|^P~$zzSQ z;}GWUF~G$s=(AP9{P@fL&NhLfXGia3H|Ek>pG4$P%#VtE67$Bo*y^o;clRZ2`3YBA zy#9*C$*}9!ON$jqL?D6mPMI~CQFY}_OnOV+_0Ft2(-{)g4-$@2@F3HhdKkcHBBe&2 zno7!xpo1by_*8b5Ew3Xyj7~Rh!Sme2KOmC7PzoUS08jV1KlkkCsNENqk16sjrBdad zWm}*1;656!HT{>nkoYu8@HTg$zfb?gwJjbdRXZ8(Oa5qqc%gO#7}e}~<-F~O<(Q(} z5Yg~KyiYLs*ekBf#PnhJHXc>Cq>2Pmbo8@_*4EZ#I7Gkn#t+IU{RWq3P!6jQv3@Zj zv*q?Uw4YN@;Fw<?yZB|1T>r~>5G>sr)#{zkrKuO4m91)_QZDCU9xERf5r{3_yEh|0 z=rSmOwq%<8xk!hycYoHj>UoB!279*b(MQ<+{i$@>M^m^95f5PA&1!m`oN9K-j^K0t z2VKGd<En}!v@;X-eBlH6NR>HN&RDu)*xJWF61E7g57=@)v4wmI2_YlZHh4@ez$p|C z+d%EiZ2peRPzw#T8wrpch7q@5fya7lr+>zG(I(U_v220rP%PMMtrG2whkMl>Eqh7S zPg)6;!g{%mW|1eNbEWbwi$FaeL`*5;*2ejPJ}53w;k>O&4-U0CJjji5o|y|*v@&am zT>qK$<adS$paiAWcR8sF<@fKs7s}gbp>6vyeI~vd8n09C;nL2qS_(!eL1Yc2M<w6Z zTYWnwuzjXf#b*Ca$#nm)-b+mN{VC3ek{Z?s4YB|;aoe~a%l0lGl-25#ziWCf7v{)* z-<v8vz3xO}arF<hK7m9>;?=p-Qq%h+Uo1*%x$r*3XGYy;zhj{x_oIRljHrU_&6X%v zH?m(%KcMb_RFyM5W!l5eXd_gX_ox`YV0oLN8T*=$ylsjwglNIA1CwK{?`_dvw$|!E zN<v~7b`kSGoLhYvP_BOf{^1ro_L^0FI9P6$L}iH~-*(F6x(P|ID|<LNDIV5i_>*a# zR-;MIJnl{)W!GvX2&s}W5mF={wwV^FqrptXX#4<ww4Q-zES$7%RuQ5MVzDBjp?>{| zf;?;RNDtfF*fgVGb4YjY!@}ZTw8-;5#)Q|@PB}0X<NnDY-cBTYth<@uBXdA7<?gE) z_+q=U)T`6$fQ3PGKr$=hDP|Qp5Paa7-EsjuZboLrjW-+}8o&9UC!YHEiJNDH|9JH% z_OYAn%^5!Tk~qpP3S|I}Z|(!S5pHFlH*nzcO3Kooi2MG~T9|rI9R)jSXFcBMJrx%% z@gw>cvRA}v-+A{XmT91jB*2PfL|2361oR--mg{X@I3ica$sh|FuQmI&HU#(pxvun@ zBxl~z+)_V4lYtm=4PHXCo_1>5X5oqF*f7O1qDs36oyb=M5RR|rFp|AlGvjB*AmpJQ z68O+H+j_wMDDcnz*m_Y0;F>gow=4{G?xER!oOD2rLo8R<uf0JDk6}nRDm~5C4zb&6 zG_>Axo_unn`a+I$VYa*W)x)(V*{DR;SbhXGD}BDr;cR20c$l=bE>jT=V*S%uYOT2i zmBl3_U1;znn!Iez-PZU%)mW`xZqvkPaTU)t2_LGZ<H#m7dCmINb5zY~ZAV4>!#M@C z4s)tveYvE4qvyU5eF@8axVkoUGUh$`_oWI4`5wT&C&3;#G!(65Qio3)B4Zi{S|S;v zhp4{I)De;U%yp+f3gX%Frnr#Y4s#DMD;|=mx|xMt7_6Q6P^=|%bq(0%#2A@LX+2<Q z3LpSc)(at;bv5EPWbA*CtohZ79E5()?$lNxcM!0{Loneg5sjhUHqCEe>oqU%kDDKW zs$ua5)S)vnGcvLvsqi{Z1Y;7!w$i{);Q{cCkKDC(^KK+GgUOi3l<e30?OHhxE6b6L zkxeiUDj<VHx}<(@;Wu$7W8M@u9yew|d~HEe?b;p)RUy<V(2&JyPt6-c{=EvMzbqxD z!vEm{>+((HN@dQc;f9YgRP2D9^Gb52E<6t5P)NJr`XSWNm`~e7!WARclGa;2xAVJn z7~^qfEZ4!KQ;vE<YG>h~sj-E@^%rjMwdQHmek(;wDlm}rz|RA&XG8s-$!=J+B;fl@ z(!0jWon>*oPt!6+0;ZF`XBd}>H<^&AGKVbbnX5>Jv!U%KI$?TJRXZs{E`oq`m|y?| z;BdF<yeIP$$`%9xEGRL#b)Fe@#YYs*Ch{}SFWrdy?Xkkz{bHxD_uE$|X(y&rv_r0e zBr0dKJ<UWv2~A{&0-VPQZ(*}Q)(*1+x+&A7xMjL!U&d=l+GiD~>>@Dd6@d!Y$aa)z zqe2+{EM-qWllycn=y*V`Vgk2zd?<w3NRR-spL>~#szacpOZSL3Dx0dw%zDVxWY(iu zCgL+tv`*f{s*|VRw$I;N#L6j0opr_6ejy1_5jS@BaM|=X>ixWBT(!}NZY{ZMoKA!A zVyLr}Gw*sEI&q0h>qal&wJ-KPRo~U>Q;+H!pwE6H-Pa4FW{yxyL)?i!4!{vwa=>lf zk>3p0tvKp)pytXqXYu{s^;6`cXLXc3)~7Vc-j}Cv_FC{5y3ElqxX7_<>3Ck%?2_S- zQR3}o-&}NChj$I~>}uPV^(6YUH53j9J-Y*I!}Lyly!s0V-rs_}^>=9>0-x3ahlk5t z^ChM_%=*MICz^B_A6m;tr)x5kV^?g33;S!)JsUq_?abz~YU_RyoH0(sX}Lf2&?Yey zNPn#p#0Pr#Otxoi?VS4j<l79U%~`$fI@dvogGo~`;vs2nE>=|HLE)OKn12n#flDZ< zM+Rb}c(t#{*>l%2DHu|K-Mx!>1c_R|9AjGOtY<EII#1Iry7f3=L~QR<%+P*(Eez8G zBUVN{K&xWiTjVP;S<q$?brH9rRWE4(B8g)%UJGTi7ZY|`$XFJB*X(!$HRvwm79Sus z56g|2HG`CCIVzpel4VG#@NSr-)~hdGbIt>2)MD*USbkxZ7~Qzu9{;+Q8}&8drMoRi zczjKtCM27|bR}Y!Mc4Gde!x414D#WsIx0mR{%!{Xjvi^6Ka3hRoK3~dTrf(F#?_4K zI|PJ!vxfNBCTj+t_H(s${53<3*aAT~LUR(I=?HBOYz?Sqlh2^Po^wmnwpd&yvp#-V z`0gxb)PpG<4=c*yG{rXSyUMxg3TZV>@1>?(R(Qn%Nx9I@kdNtU&Yq9!!{)Lv$fuq@ zewo>ib1uVrjK>G3U8>}UKWlnEFqJ>m#s#IiAk?p0@5S~W*uY7C@VNIL$}wvMWD*F| z0y${HrM7~-7Rv(;4nY=rKOxkia<9Rc<3kAuvAYsdql+_uMEx!C#ds~icZO4TOfr!D z0X-`9Vmgf>@;<3t{A-Z1Tm5U0l3iWA{0N;@y-9%gqXC1U5MLP_X!V5@AKQCybr7z} zN6kNvsF61@2<V{(Z$-{m+V(m(REfFKh<uYZ^GtY85?!zmu6;k%eI7<j3P$J}*a_D3 zmWU0F`vzA<zwRcQ!8aEYji_G@f}J8w?ijJJH+OINPE=TI$eY%>IHaK1eQOw&YVkl< zgu=1xq}pS(GrA7)P9@AOow9!YnZeOrl5>0>gr3ym7oGT!=Fh~}Me89>g&}uRAn!j6 zI?JD+(%E(7D)TZkEh87eEbkv<2fhrAKBIlwbN#XKG{3RjuotDeG|>7hdd}4Upvon6 zuj>%zj?`aGPuep~xJ@@e%f=@I>&LsSC;D1>W^o!KaFAxP?ksdE68SMhAN>}{eWEYh z<cu@20(=+)4#fkbsDT7d`=7G>rIL2#Gk0kt`6n}M4p+G~H!TT)W9d6#1e^-W?DAv` zGoMjazICfkn|c}0!mO6hM|Vrhj_IG{LF~@X6)LmWNMHn(QHXr;ro)%^BAE~kt(ZHx z4Z4>x2->iR(S;3KPH!-VNZpU*5H(=M6fLDE*{-d;e?ZnB_cF{D0la{xe>Ujc1%}Gk z1sl{i5GA=dpFS$3qi7R)9}_Kg9i{L}<tjca-l8sj-aa=}=qvHM0v=VYim;iPvkz*t zq{b|*e`SBp-j))hctE9Zxi}on)(FGt)ummRqit{%BU(1h({1%$Ox%v56oS?%4)`mz z9xDE~Q7Ji=0FY`z-$b;bFR<I-Gm%h=^ajNR40?$Nv9>N7itk>rjJ@qDK2jpti(6j| z!|onVtJ{m2%!li`D>?%Pe~C|kfFkR`++*U~A$JzSO2vhwKk%pecCLl5tG^JaD2#ZL zukkiD08d0#1BA=U?0KHW5z#x?srF>tE>-oB{e1YPCA+^iBWo5NF4aoWJ262<g^Eca zR5CcaDYj5ew07oM8)_XlL#0{b(J7a1f6f7<;L;<aQiq83y?W3n_Ak$!sF4%{De(}9 zW7v$GnVbC-^jRYtot&Bq?wIS^TNi5!S#G#d;id!uj;Q8st#1FXR{sO~Tdb%4wHI1s zbV7N0N3Qo;w5HDwwxZ&|NSj%{=4D5Tp^csro5WewvEz4K_9bw?^o6G$Zc`szF0~`q z7Iv?wx_B?%hK*Cmvu8d4o8W4~TrMy)on!ADqerCh*6gUjB5=!eVN<GXMXxBmOA3d~ zz>TxJ${j_{FpF0YhK38M#AN4L;b7Jwh`K$?URLT#E>l@4U`>f}R{Vrv_FGa*R@yj+ z6rUtORtTwzI4n^2i_lMmm_<k>$fLM0UOoh4?qboVC!KEl3}w8o!GYUvT@clM`H2@T z&6GooDTBevN_QyF(MYJ$d-m9BEpq8O9+f<8Q|DLIe!w#cj7y(!R=2|Y&rdGXKK`gT z$>^ZulMErA1$<Q2-SJXF=inFH=3Czf#242*ld4n+lhKaH!aSd1ze6p%`VX;a_bov7 zQ8dDFZK7+ILlc*=7k|%oS|hyu^8x7{Yqrzwb>LDXTd=w3`qvnQW>jGrWBm5wtLY70 z=CPQSuyuLE=hlpaUF>-Jr|o<40s`^-Pa5iapS_ReBlN<>_IGWwOLQ-1v)(reds|Do z55lEJh#d~lmh1*4aZUB))We@9xLXY;QTkX?%>gXnJ>hhDrqxofjo`TXu-X>#cMUD! zeY<Fz`rohnF!!)R?Uphe*x%@vIfN)E2()UfX3LYjPFgm-w!)F!xO>A+Hdb3246jtw z6Y){Benho|n8#1VDge`6#Xhs{)wVB(*~Q9A|Jt6h)!X@sikbhE)b3Z`{)OaRavOZ@ z<N7EMBVVTm2sC2k{rrX$I)FB9VUuT<&E;fk&_0gGqkb#$CU9y~2(hYbVeON6e1jV& z5U=Q%zwLFIz$4OqOgS*yYT<9^Iw;2Rv#JJ}+tAz)Ew{yI%JGCR;oWe2)7mW3kT-5; z3n^5jusG2nkc_UOttck`w3ih@{)pH&li+yBPnl(ZzbiieTkza2NmFzE@AfDTM6DDU z;q6W_O%lNr*YEkuo5DC>$gc2OIMV0p#G^KNTI^lnLq@sYXJ+;TNX-C=X!4Xg=J%I5 zeAOeNqYe(7)CMyGW>x|A{W!QP`2iC5hIzc$NLcp;vEUb`qT1rz^|3QIED?4|x=ieC zWKZ5#g6Z!50i6Om0_($w7eHG<FBlt7z5+-ijfa7XQ{P5%s=~qUx$W#7D2c`{5bgPD z<lLl;@wYkhCV)steCnc*9lm#!N?b;8^`XGbuehx<D6JklCNg5HXV-_&M{`JF*U8|c z&6Dr6agktFF|i{RXjec@?ZYChhPiLq{4Cf3m<p#|ej>xAZL$7G9E(z%XWQlFgt1It zO#^1%(UJv4rhU&G@s82pD!KbDcE)o<K>PE?Tv>8{p!WJb8pq7GorS`@lKxs|p~j=; z(;J+oCn)t$FD?{p)^TbrZvsgdCea(b_aJUgMRZiTq#1X&qQ2&Xu24EIsG~TWj_(;! zt+k<i%epRU_^yZ1y>Z;|{qPOt+E^1UhSPZG%+sX+-d!<{k-mdHBfyC(V3$~K*t+4K zF7&Ge75=_DbVkulF5zvoQh#HR{Z@eAefO1L$Hgm4XQE9w-05`6Z<H5ieFwGC6nTw* zKoais=ZAn45q=Edt^h?Eb@{0Ezf7#`PXiCM|E|Ps<mMrHYn9FaL(CTa`l+ik^WMw4 zKJ+esul6s4Iyx~n0KL$<B}}7RYkr+K=id1CXfD^nAF|^&J|Q=@&}Kd67*Oo96)(EF zB(H)AYu$pKo)<190M|Pn^^fbNpRVx%*!zz+Re!P1h!*PY1G@>Ni`j6c9Yfz^CQ~|h z)M5S&kV!cYHF_+%N;t^@Yb83SXnDB9^F+>J_b920C7RGrQlGvHV5w&fphgT{U385z zMjtQ3019}~u+O2XoKb?C8t8<tn~rbk<$sqoKzPd;J6uTo5Uvd>QT2g}WxxLQT%nww zAFGKr_6;OJ{NqHU(^vW7jOWR~>nTVdl^>RuMSk9j+OWOIf7vpzTKj-simZb^sS1{A z`e8D^#`^P5pg-Yxm2xLMg3!UCR;W&#YP*P2P&@H#(j&&9qf4IL1#uT=xC<An);40v zW;a>!>PSTEy{s(LEV`*rd5O!zF2$mp$*;{Eni2^>YP6`yt2f0B`L5b$Y23)G%cRQd zXNLsdAx#qx<8z1)Eb3&EWr<>G)-`enQjd5iYTmJ@mMAP3jl&k+t0oC8BCqOw&=eC@ z%-zX8pUKwhpY0_`1-!w0XWuRgU-GZX7-OI50elojXAvCp`8U3u@C%pX*#&*on{4bb zfe}V8gJ3gD5$7hX7zBJx^o~~S=+z}=zctH3>tw$k^rLX@S51mFO>xXPvAL3%n7F{Z zNPp1D%Ey2rt{HU8E1d|c-Hg*??u<LOpwj8$Zsb4`m9Z(j4=Hp4VBP;70_9Di;_Z^K zdxSr!wX!-`U->KnhgQVwekQRDQcYEJvX-r>Ha9*Qz|s|;=X$sToaC{fuSllRmdA*` zp;$cuEa3I-V#i(4f?bIlKieX_ptO6DfO{c|IwHi|hF1kZb9h_0f9>tJp7B)lVf)M6 zEZCBy&Hm-%mHxxWFaIyU$*Sy6BjV<%1ltIH8&uyc(}%=byxC){Nm}C%lLgCwk0L(q zmdlo?eY&uJ3tm4<SuXqJJDyIH_999B3uE_AYCM`NIPxRR1(A!-Rb(pw5b5Mq;erjH zfs6FIfUMAq&|8f*mDJCpfbz)HQq>m?yk_jP0QMY?p3a7b0C=?^nj*bvXgBt(Ew;`i zR<2dwx1K(YetO(7`=5ma;7XR!#*6n~9EmFqh|)%uqF~3@;$Hwq#4Z0$Hu&^J6<F4< zTz-vqbP7lbBrkus->@OqeMLfzj#uxqakEOYIBAduuzg;=<+89ZnR*d?lJE9etSPOK zR5XMtVu%aijTFiH1G3XVYhOT<?!bT8dp)nnpWhasyh~~EHU}2JONhI?&z>7lG%!HJ zvorK*;|mzflyb>@mF5xbOApPo)ij##vsqDeA=*#+?6)N2oE=GoGN4joR^Z==(}G{p ze!X_%lzu_Q>T?Ru$Vjs6N~df&q}e(!e@>F}p1~13h1F;&iGiFyxKXzK{By9T-aaeA z2nLu-P^x<N$H!o5aca*`U9SaaoFz;K7>W9<&~7N;Eztr%_Z`udi0a!LfCXF}Mws$o z@+y6kqkwcf;(ht~`RYQ(nVZVa)g}IKi+`*;%$M&_642er?hEaGi94>F`zY!+co(@^ z?b`DbJ(%GJz>CqqK|kOJm_1-t4He#g9&xtAvM^h2?o1i*Ok4$*`YKN2=r12H&AAe| z#q;yJ17OQ7R~TXQTYbm0QGBmrfaEme6N#_q*}sMJfhQgG+ak*H0{XQZO<_yorN_;Q z8=J1{f2KkiDSq&q@hI=;3i%z~^167Iu*9la>@O$3=Q4QgKW*OVFy}R5N)NcYTXiL> zw)g(g@-NS;f&GvSO%d8s@};5taSB&b&T8aO%ktGo)Hc-eFPjgwQv<R(iH?O;aXdxd z{yM98v3}E;Q}C-=Qc_Q}A*LOx{Is-*`+=*)I)|Ot#jc;IH1pkjpT*;2i?Yw6Zx*N( zHIhA>Yy&QE962!?efGpWv@ZSus(nH2ezwUYH6>T6xn-S5aR#%Y2Vg<rUCN~2CDk!U z1d(ZtlblsFVvM&GsK|2MF_&dozGD(o#)Ir|tH<_4sI>5c-`bf>8TF%4V{iV;pyC|4 zCL4}Ic`lgpSHiFurMSAOC|^yMaoiHO0D}(%u0wA#z3*IPpOY$+Xld#1QhJ>XimOO= zqsXDwXCH4d<p+wxW4E!^DJ#`#)mAgyhBEgGxcx2GFePWZse4YyIeS_wZvQAevmWcc z+vN44Loox2QJGubsG*y2a$n=WD7P(B%lE(3X>scVqO;(HI4ewUjF5<>n{!-uoWZG$ zvfqmH4N<qhGbHZ!T&aJu<3$2d$2GJch}yeNDv#QexXpOSM*}P4)?7fQ_XGU`N@_Ez zBM^7?B9iQ>i};aKJ0v0-E7~QaP22E4Xew2I^Kuq)z|S6Dmc#?|H3g;%Bu+tB49<`> zj(<Q23x@NJ&}s1C!AMP+(G(DskHihSLJBt9>gBt{0!y&x4@d)rR)L;T^qn5;qa}V3 z0#1jh1HdJqFmTv4#T)TlPHB3EcpF5zkl`{>wiD&Ay0g#krSJZh0^t7R04^C2^$LOC z#47RIKy|Y9FUQOCn-OR`fh2M0GC(Db0USYG29P9f_%EefqEh1avCshF^xwo9TbJn# z>Sr@*4^vm^T}nN(caWD*fDFHhg`WX8ft2ZD23zkyl>baT=p4Qi=!b!iCSMRVau-B5 z<LPK6rtTd9$IUuWSWHK#yemZnu9j^-#vf4Rn;XAoa^UCOX-fMK^DGbiIv;fI!F397 zfoxDKs0UEr1Ze*UCr&|=9!?+16mf-(C?_W3D+2G1ka1nt@FaN0fSlKGLcg}6^K&1% zL|HZYYSz(6z{)efV{f^-md%)ZW=R|)r4Ljp0<3rSuudO*YoD0F|19^5xs8;SI3{*v z*0&1wyMn2ha#XmMVxFrRHmm5-$t8f-fAsN;E1~8#x1Q-D*Qqp1RJasBKy?;U1L=bn z?WFUbpoWVFpnnYvj})#{Z;53Q^n(Jt24(!d%i8P7SCd#5K8H~yJbT<jz>mW{6Z8gd z8?jM%aHeS{1(R7}1NEahE+EAHHi8}aU*KB@@YOt>8b4Wou6r@!Y<izAvjJMczuQ4S zwYYfuHmHUV>Pn&?-9}9BdCffo|4wy@xllXixm_UGm6m|m3Ow8@U!5cO#!W~L<3Z!X z-s~38r~mRgC!a8dR+N=pV<@uLmgc2r{F45>GUfpitBs)TX(Z_LNrmjDc|#85H@F-D zC>=DRl3Q#8IAD~^?p|C?b~b#OaGxlok0XgmvOE@krf+I>D*9XdErDW@xHgTo%V&zf zh<m<=83KPZ+Z$I}DfB(9cN6FswlO#56UaVwn7glnaT)eSVKI>(U%X3I$g(Zz|K%wd z)|^&y;{j+Q%fS0F2CO_5iQf-_(zfOwR4%5vt9s=HtjQ;J7xAL%AA^1_c+FukG~R>E z!Se9VnEb2}&y{y*eE)!!?&)wS$}iqx?}`%P_+B<}yexSt6PX^?pO|>qB=9lK1k2vN zp4%ORoX^4B?RKs~tgnhy@;LoVntZ>Hlsfm^`JpZujge!VBj?ouz~2x+#8MNNemgy) zb=iz=9a=#M!u6PuAu<#{xCG3JWq&O`P!rqh+o2HC;>?$}O`E-p`iT||UBf}hxhro? zom@2Hc3)9thW};vVNol=E_XbiLpbg5NSTK?+5vyh<k9m5i@!Pgz)rS(>$!Lh4Zl}A z;FSD0>7^s4NJM8BKca8YN+-Scc8myCPj$ns2M8#mRu1-V6ci<Z4?~sRr@qF4#Z+S6 zi0p|5r>R9pK|($2xz#S<P)jQ?<Hc$1;x84`m8;$}0X!VvQMxuITDmDEm)T^*GuvcJ zKm{t!5`0*A+mwELr<>ijLp_gSO3mF7xad=e5<gw7Z}vrgd$Usf+dsEL%}T>3OYrMN zMgLKoJKN)6^BxYQavMnx3M<zt+x{vEY<S&R)(BrxCRL_A?0v75V2<y!^Z3USt-a<f z)0G=ibgZ_-u`VasqxN@P58>svBt5g3HB0I{Dtr=B$O5`bg8baiHha%ilVf{bbZI_k zF^#Z=kq6()HIVO8`Q<G9=<Cj)tuzM*&(4}T&@~=7A`U2R^&)A%lHkp}5<!6n<(Y1H z(d3TX57tjaj6$6dfG!v@-iF&Rz!|(R#2^<88LdYxG;`LsT1_Sbp-2#l-8W5>YdtM| zMRA$&ewoyL1eQ)eAbW#E%LbkYw`OqtZ-CtdNZk5bFsGLq6WQedcH_}0Z2Z`+WIde5 zJ$9|j(xZLqU@^n+0_0Co8tdVx9O-AAtZ0BXzhu-Q&VB&;19A(`=y%a6c_C=C{ewxN zl!~(3psAjb{-yWt)2s?6xpz-{kZMBIIa=*3)YzQGuj^mF45SR|K~k-rk|#R(HHI;b z3ddJd#n9p$o7kBOF<DY042c}Q-HohhQ!eB=mu#&T=+?j8|DCC#P>M@^21eAfDd^9{ ztB8J|7p6n}pebaz_*n3ILbD@gN;pivT)U&<gig$BC;Y@oT|v9`)z1NoInt^{Xwx>D z<FYteNiCW>xMQCHgPuiZ`COGT-Txy6aFp2vY%MSOe9(tHJd^vPvo{QVw4-&>>Otf> zj$czT^<XgfE*zIB)2@*T*<qha<=7Aj4iWPoOYz;fd7eo4vf@ygMrAde4C&}%T`83G zNRTLo6^xtNen*ymv}7~ZI;p6qu7|7wRKReJzyhUDqegS3vPsk!+K?K6<_$hj(qb=Z z?jB2D(l^0T%OgwZ!8#!H*PO))iqh;n=GN2G6xVS%HhQU3l2xZAALlJFS40*R6oXJ{ z&G3$Asb%X_lwq9VP$-QYMxQ7$XfgIbb`S8{I``b_yr=t)_C<pj)^h$eY*ONM^OlCl zPJ~hbF?>fZQGa9j{(wk>^9*RS3t(Qe?fP-&yg}&_jJg&H=YZ%n0D#H>3xvb*({dz6 zmxKAs-4W3cQ#FHjf;l&aBch2ght4}z-+V`&=5uv+JkT)g4gV%Y0DKGyapV?LP2sBE zHROxz`)2FRk1T<iB1{#7aIIma9dJst{H2m4CgW95_a^j_^^4x!UX2a?VH&2x90?n~ z{OJU~-8M@`>Tbqb{@G+}TZeDX@-D8ABS=Nw(@&!ZKeJFg**d?F;4y8N&`dv>wO=?V zmFLjyKsNqQIPcRucO$~qHM6?^n}@4Z1sn*U%6|Trhg)^1oai5!61u|5U>S>>7<Uyl z*x(}ZHyt7V+7_@xv|Ldn568Yt*OH2sKUaW0P_-kJT8-xD391@8-<CP7n);|>ATZI7 zuy!Laymx)%s3F-S!An>FLK79&6hl77NF%*#7&Y+!0P}u3f)1g*H*)d&A<0UI8%xp& zA#(SOuwciLo;!=pVmuCkD*Eu9DNXobxNWf7vJ=(b69G|UvY@zBpH;X^ZnU-Qn()Q~ zM;7rH`gys^EL<vM&u@H?DP{@|qb$btm@m99Wv~d$e2z83&D1W7>`4cg*Y9GLZ|c!z z45rkaun1PRpx*x~xkgfNtCa9Rt$r#<QuTOL1cKBIRE0#2Z?A8UiyM6x6`fv49Pn2) zS?sdb68v@b3>=fM9;N>n9X}eWy$&4s3#jdGG|rxi#*<))1~NpVHiW3{ocN@@qACt@ z;N?WzYO(iA#gE{I^Ri1;<5pacvZ)%EUiX#nYdOsoZnd>zqbAa4KWmpqm&M(0!Y5C8 zd8dctXvqUrHu_vl&m_E3;b+v0?^NxlU@u30rnsnO#A&u7#eso4lWXM0`0D)E^_PNd zyRzyUrD<IwU+m}Hn(S$<N|;K@DQ=dr;i9Cnqo=53n!1%_Ehcw2-gs@t6l`u`@6@v^ z7llrd6X47MjQs4-x&u_7CnnnNSQk-%n8)~!5r7|>fX&eexpm%b{19JS;=4)|Vqa<f zTgG+b6l2lAyAYRmIpPOELCGG0SA)|l#|v-c_*d_c8y|mIT*u8st{c9T@2uLSP5xqX zx~TDPDfBKz<RFvz<#k=a@2Pf#ijQS@BFn=1)12y8j#xKo)4HonnfN&KZTs8&S11@! ziI4-^!o2^h2UB6(+%)~3C`sM%z>E|t+>A^tYJ`y?y+9va{pt3if5<0z-7%e^*w06L zdzW6Khv}_GI~scxHV0ZyXGZ8MEiHR{c_)LAzN?ovPYJ(q-h=Z9vATTAqASN^1opV+ zMvA!HaBAA<-bS@cvH_|uSs*z+P!De1lFOEIUNd&GkJaLW$Krk2>o@=1FR~JVi1ynJ zga6-#u}H>5!kuvNgGi`~6eJY_2Jdy+CUYDCRm!UrX7@87ZRNPpkz+7JTz1C8|Gc+r z*QLtCcMoCYtNoc{+8u~#Z#=kp=hj)Du4qa6^*YRV5G2x~!HfL8h$?y7J9$i-?Ve#m zO?`4PV{&Z3(Tme%-n(xHY9cslbM~ZHdT5AFAw9U`hv<(r)LGDzr-l<gWHNc>a0k)| zx{5=<Z4`#Bv#L>)l$o=<G`cc^J-i{)6dUcnh3qP_EBupm*1hNCoRdD#pCma>d0!XJ zN5@~sB>o0Vri9VEeU$WhXy0OQ)GyCW<mUV7eVuyIqRcN@kQC*CT>F<=$({pbuh{XM z)qPg~H8!6kt=dJteoXV)FiYcOY>X*au1Hms8W8!y=)-M4IJw+dbP>6hjYDq^iFWCB z#rAjGS}!`kjS!;|^vEn(JTXUx2Y<I#BCp3ZAva~+hYYw{7~KYhUj7Tl`eLg!YOpyv zJDYcD<Uq+_Nk9b6^>WS0em=cF85A#&T`yKrQCt2l=6$3=5acz6;%eLnK$BBQQaGwt zkK(k0vq--`7BTM)=rF^Il1c}LJfyy<jNO-I?|G4pXzhs~DqRCl$!!zSN!+vPnNt>| zvHRKQxp&>@B~ZROvLDy#o0>5gF+Zi-I}GSZgjy!8+y-#tZkK8h9@fr>4+!u!L}ljA z?yjtto1?8G%0PtaeW=bWTH3?3=2n&YO_+XKbiPoh;VaX$6t^D*ww6*)>f9|HaDSOb zieO!sjoRj`v$<{ExQZ->v=|?9wIz)&?mEU6RYQwarX>cO30fA$X8|x?#vW>12HdF( z81xwX52zNf{Hrun0HQ8%o9ZQQDb>hVVn0<O<h31lr|!6IE@eQaV3X-1mDcHV<{Z@% zKd@OfOr9i~lq!X4;Wv(2zCwV})qc_NR`}`_+1|V9oXZ|(jJ5KUCHHHIhiND5JvQ2r z6Skg8y!!GiH;93xb3R(ghe#P8$eXp4#@A1UidfGR+3(A}Z8|1{Rdb4Vx|!5%1;}^} z%Ac&}YE2r*Uld=FU1qyH$*$1AxO4)aum)YcnJPMCOppyX-p}~~LL^Y9c!<4>l#wv{ zk*uxjz_Po4c9|aa>kH9^lEZ9j$^$x2z`B;!`f#u9AF#L%@B!_HMMsv>hsr<FsA#Xd zjGy7{e#}tdzfJNX;Iq6%DF(3u*TLPO6Ct36xpx?q-6|Pf7W@0$e#WH=D3-v;(PUeQ zSPgZ2eTdG(ckRaW_XKf;7+_1Q^a@7!W8zGleIj~;<?-?gF=S-vkakh;8)z<GQBhE% zN~f3jM4|wkh{>rlEv}2I&P0Wb);nF5K9YUBcm&-TeHo|W&E1;i(#Kr6H(Z(J%-aP< zr2`^Do~=moROq&3Wjonrcx4^ND0uGfV<+5~SY{_};x+2J0<Y5x#j~ehCZR$}kAv|8 z6%N{%K&tc|Ugu}I(_*1l)iokzp_>xRY-yL>RgkgOgM7yy&745eIPm?BN-dj{zC^>O zFQWd3v`e}r?LLYFtu~LiQDGZjC>3~{2s`qlSkce=6CYUGTKN0|6~Ph4Q5O~W>Zur8 zPD<5#mXQW+imClKuR=xF3K>DLC{rZ;Bu?=C6Lus=NpV@2<#Fq{OhNZg_GdYf+R^@| zFw2=z4h<R`t^;SMBi|3J?Hmd0&fh&(D<l#f#+hx$Ofh$!2>nRM&|-**Y%cdc$!iKe z9ggWDeZ2NKdXKs<!665Dq&t^~h#JAtZ(6#<G<{}atc$FPLJRb^6j)fXkPs?_K4Jbu zQ9RtC!=;ns9Ni-8_G=k&%}kaL7I5&U^*cGZn9K&yq+2rgHZj{7pxq5G@~NP^vEtgZ z!XH8AF6$@AA^*3-NfJRou$o!X#qD0~Ih+k+R2Yf5R>DzT1C`Nb$izqIMU$6lU3c9N zt|$&kd}7o98iagEO~)P0yKhZsRBir3#3y(blbzGD@AVS<2bd|<m7f^9<6MzDM-K7j zx!^OoSA&?1Y`Axv`S|Btuf=OrRC>gNpBB|~XDi&@*Vxd}|Fmq*+4(Y3is@u+bqitR z(NKx6b1dEwb*J$%WpC9{kme}h0HJiX5^L|q*2Uur9s#>w#uV)u_tjwq3X>ej3)Irc z?|Vqx`&kyYAJlSurb7sc6zyoc&1*MIFmkln15P|B1zO9s53cwSU0$TTS&1UrH;k<1 zEBrmXLZ!vdt$=9;>>T=cdRn(ylDVv<XOdzRho}%PHA9p!l-~Q;UACddW7hdyjt=GH z=*qNG68Ig5l{hUnk%;4$VH9}jufb*-jiKJ&rcp);?iA;>IlMEoxqm=2<GzJCupoM% z_>o6w#)1a~SsgUUpX3gVr9YqM{5oI09PlFqH+7X3RgWxDG|#oT_Z^NSuf?$IF4N}Q z2}!lw#_Ocx3@!1g7%Yro`y|qZXNV&^l%AB6A!+R(u%MW@FiBkeO5(|WpXp8M^g;W< zo{GFHxqe1Ru{Zq>N{_?bv&}W>2b?%7brMY~GK5eGNALZ1o(135?jwG!c?uxk_1i^5 zOpk3I6SDG2)d3jMcVx+0-YMES{bK=|6{!~YmhAS-|J(Off~-)fcIq|SsEH<nsGL*W zG{(n@Sd5q&Vr~3O&Nx=`<<5#P@%x^-QEk-JC$hU&eh+vk;qTJ3W;q(AKW4G~{fU#N z3ZJQS?bdVK0`i9XJ1gNE(kuetHmp%m(-;K#c~$M_@)nv>dn4yxg7g->T_w*}3EVkc zCL^YB3wCt2T#URIppylN`pAXtk=|E~Jx){{OVGPLtofTy)$PIDfzIkSJcYLQ(gU0t zAzULHyw+wPM9Y0o-r`rsx);)Uq3FPa-j*`+%^PN49QVtT=CAqTf%N2tqG|{ld7IAZ zH<$=-x6^Igk-nUHZU4pZqP{C^YRcTL*)PPO#!u(4H3{UVOKaV6My&t126o#hic-t7 z=Kuu_)Omi*_V8`5)gCvBT_-*3&540y(FJ2QAndab7PmN;d`S9P+fj2fB{n{r4zJYE zHTWi(hfizSS!P^l3C<X$@1npaSg<zVJdY^;zT1xT2GT?<1!jmUKuDk)wAgI;oXSW- za(Stxq|NOFfMAgpKIpyN=1#7+pS1g`;NjGzpPd=ClR{Bqp`JZARvvK;uZyIarj2&t z(mG6IESKi<G^Inc?yCd>OGolsTiX2%Pr&Bmz<~*bxe+HQY{tI0*PsnMT22f^OA?t@ zsObFdWMN%CBrPpjr%Bu(r7$9e;RykhWKhP>PqkThBW`2c;?neyR<+1tXdHqy-16O& zkO1fJ_zzUvU#~CQ&t7b<_k1tCX?^1bZi>sM0VWz{AL=2Aturi0k}3oipGDVS3a`}m zmv|Z>Pk4LEY*HJ6;d9o)-}Ggf62)}|$llvI+6C63%5XnSik_4`5z%4XG`F2^-MRYR znXZm4qumyPVaY1N9-!ic`#iR>d~LV1d*x`+b!)@_xWMlx)#^T6wcDa_(d2D<L7;mA z4O=eEt16fi2allL2e8j>vK0x$O(hvf*8q*)3xWY+3E=`0F^oZpm~S)s9@^!dMyU#y zr1mp!!0Zx)8Ik<bS9sjTCEjO;dSPXGF(mIIhAGwfJO@Wls3r1jmfiR0hXx(k3>^WC zVOoFAKvx>V+|aq<JL@~p!7U^vd;*AF;|l8P?n+K^U*05(;Vvuva3o(Kj!dyP)1Z?Z zWKxkW5U<%ZrucrF@0~im5cmf~x!IRNEwKSOO#nRSEX~yveak=|L2nT@x`)05#C`Q? zbo12jvJCp=H~kI`p(2|;{=Ga<iH?C$*Cgp+=pk>?lP`GguQ7=`h*nUHbVWL2`m?kr z1ubt6tWJj(N?oc;b2lpI-SFEwNV_8y2_AKNU4JjJ+>cIyoX5A8F(e&&*nMNugE%J1 z-wUGej3lzNh}C@-cOF~8@zk8%C6Kqh{kroGZwbn)r#ka9uD?`|SjE+FZt#Jh0Ii_e z8i>D(NL_LeP%yD07X-%`t&(28i!GaO2MQH67sTj9<1fX<RzPYJuvx5BnEyM(pbB)w z=Mu{*MkukK0qsgKKl6uXB`BdK07@M8-9z>XqewPJwcH!yXgtQOHdKbf5vA#-kt_46 z5m(Ee*n2Oj9vzp!XP#6CsczqB;pZXZK0YZSjXyaq`}>bO7W70R4IFeGrgT(T>1%|l z<nk}_RimZz4#vF`7(-&c_H)CSQfL@?VZ1PB$~f3shNR~;b2F)Q!S>qu4IbZ?7lFTi z<wtE6rDe@%NyY#_Tz~PreAlT{4aKpV@Hjn!qeO?fT0w{3fYj!F<>QK=W1CA?pUUAc zy)dVdUZIKiA_$)Co)!;%%is?J;svz1J&JE6x_?!L*a^1+nVvQOE7Ozze`k71{Lhrb zouA^78x|!3nGZ!2X-rvX)Q5WUjF^_*hwY91PKD@cs9)rqJ^PNzF^0Ex$CJ1&+gsb( zJs3(L*wFVfOUD&Wt>|RAJYYHHxp0SR1q+)`_SSm1bYF!c1=J4X4<aQ#Vtqj1Qt#V* zm)(=LvQ#j#P+g$s8ZUkp1fgwL3I+}HU$<X~V!=`=u6Z@S&5mB78@{ZQ*Pv_A#q#78 za-&{K9oc(M?V&VSc|NANk>G**FKi>8ADGltz~y~A_}RFPFg@^No&EJ!%j`5!_V3cA zeaZ%64Cd$c?o!*qdOPr7_8B}e-Bf*UVz@R1og&*xsHd}v=Kz3i^Z#R>d<TCq&(zt! zu9B!ua02Vl@Q3~~K5h}Z?{nyTw!Q;KfOC;*t+Fclz;Wydl|Hk5xhFH3J{}L#z=(8u z6^Yl4eX{Shecy{ssOZG9Wu00v@jJRyk-4V2igQG->nMNTT~ZFgh!Y#9(DP;W!Tzw} z!k&X!np)ly=dVNw*rbHZvoQ$KD1}0q9un$fL5Bd4RlI#~F|)WfEGjWi04RLWX;wR> z%cIJlxbOGA9+}kxrXyiL=vv2KvZRAtU_ynpnp1|7T$nve<R3>5U@Dk+!U=YxK|goo zi$v}#Y%iH;d2lNh6}*h-T}@xOL~TB84xF3|#{wj`bxNlk2H7x1p_y#jb4L1+l#AwJ z+2!;2s8<*7?p@?<KHx%sLb1{x4-1uE|Fbf%3jDP)Sgr~zH9VHYH(3RxVnRI(-u;tt zv-f5J6*ozp;iDZd=sVus0HQYcG9Hh@6TGJ-#NEl(x-7A5#y?{FzHp>}Wi4x^LdUE0 znV8JhjJGV4_EI<_f6swkN!c?v<Kn7U#h~%5EccT2wq@`9<fON0f!HxK+G#w)6c@IH z#Z6}9+vL@(N9J_tx~M89Jm4#=mIIREYUO)&QvF2y*lEUwxv&$cEDhpb$36xBEMSy~ zcs?_w5$^r`HLNc%Mj}3j3ij0CE7OCW(fnoQ*rtR)BY7{nT03K%TqXkEHmu27=+(*6 zx5cJT^&TFGLE3qH5)GOxrJJS0g*&x>I8Q*pT&Wz*-R!cnSmLoX*)oH!6}1~cZ%H4x zZA}`Yv=7aC%d+_j%<!EQqMG#1J+VZ+PtOYb?e}jLUS}WReDJm=PD{gju1q5FW2*Z) zjAHe<AGyIWRb2>^&-45Dq#r1K2g%)34G}+cl2s_xJ|ydpTi1PHQeT^(%FXd9?JRVr z6c1q<r6P+`TbU?%$R^#$!UVUbYueB{N_)b$tEI`H?+h7bSo5HD%aYl9r((_OzsRR0 zaCTnAs7$~m#QrAaK0gGWo=*#d*2o)l&3`^O<ye@!R#1PpFp%HeQu*UF*YCT@n~qmh z3w*eYtdq64y2FF)+WA?r`GSvh8*XMO`-@)u)cX>)WQAmIpOc#DgAaL#Ulf;^`{zwI zu5Sw17KuD3u0H!!t{N*C&46&sTpdY<<(7o7$=sWi+i#(*j3QSF30<{XBRG<D?YWUf z8AiTq^}Y=rnC`pj(9YQGL32&%*{EAhBq~2)^D@w(mU(w)HNA<{lP<!|g5L=e3mhEK z=8+WSQ+xU6KQ<Eelk2>r#`b^bCvkmR+=YwV7pK3Og>1Z@vh$#(FVSciC%!cIY?ga6 zx)dqG^UN!D8c`{@2ED5RNx#V3n04P)vd+o&8|IJ?OW=Zeu-Q*t%G_Pb(99SbsZHc+ zw$n)17+icCJ|=T#V2UXoOGv%l0iEf`Ax$l`_>B*MPP4~jzb#c&rmaOQV#Hzy(Ym9e z%0xGEtlb_b_Ey8w%_4|FBXBD0Ri;!Thq@OEN7j4>-%?~<eht6zEZzDM(KYZvXf0st z!&Sy$GiT#p>kuFd-UF;d`G*S6-G_B1Sp<?2(?8ymh<Q<Ku?J-55IDcd5b-xE+$ddc zpDQMQ28w8hJ0O%?mt2NmXFIDoeiZux%d3jkh87$T3pu42K16!ET&mM7O3CM>n7i`! z>#k6kxzE7NA`%&BS8?eVM1P-m>NO0<7T>JN5@?;(cC^zl$@1C#grs{KW^dvyE5lg} z*<?7BKWyN+(6w}NJ*hG?y@w-=HqFJec5<$g3+yyIdT7nvGKv!XT2f_oPqGb5l<4q# zoOVr#iE&!#{Jzc{emsf+YiRZOnjQb>Nx}$QaC@>8>TyJ?rxOO%(j$y(_Akj5+<1%q zz<4xg&UE(187li~H<2%8hxtmUk4_g8i{AdV3sp4(mLQ<*`Wox$`pz6XJFFcP6Z5-O zVH+VLR0Ak8NlI_xUjw>CH@;usl>7_iJ|m}oLzMs!DC&~VhYS7DbPzVprRshi)9C%+ zTMTm;Gi;p}M|1oBE3Kgst{1NCdm3sEs~afRLNVmg-lf`$DNRZ8Z*^pH#&Q{K9C+fs zRKMgkkxY-C;Zh0hX|38TWplK=X#fgfFV60B5*g#Vso+qPY>~;KN7JX|akZO;tjyU= z?XTZTr5gM8d;59ybUxm#CXN!|4SZRSVh9S9z`Rd`vw30^3_TJu00?DZc>QMgPUD?x zS%AEcOM%|;M#E+=5F1d-cU~gE><W~{VEqr%N2l+8yS%)Wh2xD9N}2!_2L8PQTaaoq z=Tr#rK&S0=R-7b~aLv2n*6UP|WomtP)WSWmoTT-~0`k1h?U*SC2An$}wMR!^EU<&w zzq04kx{0SRDun)ikf^1jo1jajrS&w5R)b}wUlkZwP)7w8111?}*m&PV7V_SYpOK`# ztmKc0Tu5EXsZa)KKkIhGaI$#x?o(!zaW_+-%6{|5N8{T1wT$t{F8X_D+V2_UD(f=K zR!ah4P=lF&&{3J<KfCh^isPKb9||-@Eb{A8`F$0OCjGN&<|!CLSQE7a6mvifiHlij zZ#FPoo3M#->t00wV@-roS;*ieJ`Mvn-<-1-Ev<)Gk?dGVrChR(7FB;F)rXr7PUzzS z1#Nd4cWr)c^_qr&xyQ-x`o5}07b`)ZvWmzf84q|05H7lPLXA}4V`5wTR4?e0EJW%o zNWNbGVB*9$nldhI$BjyiS)?H3a<IxvD;BUC_U((8CcE1kCfYm@S=Y7(J0l<68dB!@ zTtKqH(_j46A*p@oa0t-A8m`pK9i&7dU*ZN4(QVZsa{;`OyrT{nNJrDSHz`~7e2S$A zw_l@Z8J~=QbH<1Ng|2u}x88=X^!W48c&-g4TQf)G9q*}Uqe;;^$cjrDqGmS>O5fUJ zXiU!+9q?jF;-bsoa_nOluDa+xQMnc~7$p<w6fV^k(gA(C`h3s-<A`)o-RUd){&og= z244GM2RwM+eMB!9N!Mr+rl-d%bH&o&`%z)*u65p5CS#CCG=<krhHp5j<7<JD@yh(f zjX6DoyY?ARJ>&>=oP}nl)|~x>#}|4!1}AN_j3!OY7z#VmE&F9WPembHqrVExpA-*A zmCHTBw`c5J&k{2DNTnXLsp|NnP{PMR&USwEWf!*Xr`7FwO02>)bk5j8y^kH=D*N|s z`51XNDyJ4(dkLP?YyO#3X>JrSr`7AQWYE~)v+-1-=vsG@8t-U;_Z4t#_+JzC`I(~1 zt!K&X59llX4M5q<xD7}BmXH`i^`HUkuc-g_h{%o>yq*esjRMSw8;C~-xgPL3jQiXm z*3+&cTFDg2Rn9JB$V?LQcInINA?ld+4ZJk>>6Uq>9@CaXq07?*H5GjVGqcsGyr*%> zn@<awt!;Xk7+$aE9JS%{Ao>oF<Xl~6?0R$08*+D<iSNEPen!uWP1aNN#h$!>m19t` z=Hsj`mAYw6e-S<wp4c1v*+I~$<6xdfOF_ZQUS!@PL{zQ#sk%Hvm7sV|EHq8ggG*+H zkmg;v2A*b6(9kKm8e`K44@cC<4RBTP`~hV%mhKY$9n~gdfBjElDeeu+xt+kk%ajTd zSMa(G<_QJ!!>b*E%dea`G;gk}@WopKGq|#QVS6l@if=R}$qZ}g@h`v*SaBOT83G<O zo8r-k>jYro!H(v;oc-7h(4^xnJlj8)N&LP(Z;|Qp>($;*ZBe^Xk-8f3XbIA)ZJjh3 zAyvc-HeMo&^xT#}+Jstt*jq|k^?DyE16!y5LYhD3?V2S}i%WiL6)#AxvBY5-O(-d$ ztCYJTqnHxi$TUTqw#Yr=EOd(88W0bhSluGn^``|A5PBg0HG1EUn7<uVNCf^{YGxmD zMY<Nek$Wg5I2hoY$_f_sh9E~lt9GNi5=KL7Jw}I=08?=~7T0Naq^I|Px_aw?sG_wG zcMug(6eNTZ1eETSkVYD5>6Gr2jsX-fVCWDD6_65;PC>d$y1Sd924?WC!E?@c@BM!m z_FjAKJ$t?Dd7mHpn_5Ki9&T;Ah7qf}WAFI6U`705VJp&HXJ)Y(Kj|hUPN3Zp72V4A z#$RXq4^(fGQGJ%iK3XyY5g&LrB6<rhtL(MTygYOKgo(EL^$}E<Yx6;qpq5m)VuFks zV#o0<L4dlr=wh-XzJxqeJ{P-V%Y$#L0v0vT!*4-MC3Xdu{Tag@w~vUktr2uENmFJ; z=7vOfr#rOark`=%cDU0B*uM#s{ZnAX_Ni8$y=V859M3Bm#CZTH2ZVB6wVZ*7B&Vy0 zq}+p|P>wpe1RE<}u6I&FsCEkmk7<(}(a>KMMwIT=-VWJrUEK$9`_r2?TMqRB)=QRW zR*NuE(%@62!^S5sJzb)F-<0MY?Ikbhx+@Bmqx~Y)bz52T830yU>c=r=!16IdBr`nW zMK?A1PuWuu+{}{md;nF7BYg0U0Mwc^aY?ep&qemVYAB6cg4x`Uf+BMMn0WMBiKh~P z%oX{XJ9S$Diz|TQ7z2hygvwt?1E_|#s9*M9`N_q0d@z=2bBFzgmNe5F)k6vtfS|>q zOrkUvOUg#4Yt)dQ{31tT5Mi#l{d_RXk*J}+kbhkLf00bPfh#1Fz>odl-l6D7w!`m6 zgn6IuLFtC<i9ZHX1%ho#jLDMw@qVm6KFyu27ah06Hh4^|1{{|5^UfoTz-NfixnVmT zTdVGrclIrd7v=~IjOJ9V=xV3D<sMsdRQ+TSROJdG8479Y3a<wB@q&949?!_$F-nN+ zA(08f{tJ&3Ta72?CJ<B7C&oOmv>u*XN3IaDnWq)>Z}X-Szb0Ul;)(G;^O<t`)GXE{ zM(kH_xSKe(3S>nh^~w!vvsIT_qTnbfKlkPwVGuS><F}MF+E%2K0s7FMKhbA*lc++d z)N=v3Ppw;Uea{N*2Y{NU%mW70<wnElp~~fx#9>=c05-!WhcSNY4!SeJMO_E_kn%^o zyg8bmdtxEc;epygJ1>7~5@E)K5TU@%rn|LqX<;33bBk$3{^@GENQ;$c3PQ1}k^<eA zf_J+zH19}B|NM{}{wh?<fe)-lUQ`4So|V5cFtQ=+PIF@|E)?4f{c)Kgv>O2{&!sDM z+C}S<orx@#mwIImzwTUS)w$0*=&bA#l^G6Wy<L>6d<wvKFwlw#z5HhZSs`bNi*F$K z{#m#chtE4+@`+7vB&X-}T>z5ay403$L<rT;vr&gNujJcwzS4S905fx=NZ29#=)4fb zUeG7Q6%=_;m`CQR;MZSyJb*Yz8Xx=qrO0>A&J$rd7mnibU*!0S{-E!5*}v+D_!R2t z(8h^>s-(<w_Zh107rXNRanJ(GXBI_?9ITVJB{4Z#+GdrJCpUTEX(LUCo3-?jYF5Pj zE<lqWiyvCEhC+|S=D^4=GQ<9X_ax}zt8NRpSZMO$^ot!gQ<92#^f#G)su`vb)UzRP zcYQYPV~h^puwENnw4!Da2gmTH&7NHYK1aIW8{zEJ0b)S9Zl!tE`~h|jIZ<O}4Mi*w zRCmhVtMx+~0y#}v1BRG1qP!-x$<~B@Zo?*y3+%ucaqRes?Ad^TgC@UcW0H)%wb#a% zL!EKY9Tu}zt~x<J>W*Bc12hIu*vWCS|2U8BDFmsJfOxjU%#;UiV8R1Kzx@83p+%RG zPJW?6ENxQXt>qg~ilgr(lfQtTpQz7TBo-j|>vaqZBC?VrE48)B6NB?yK}1}Ftl2e@ z&kenVDb|&`l<d8cp|2E)fWLkAbZl+a@W3go$bGLE4c8&+n5*qI<w>n-!#aBEHO;zp zcvzm*<GO7j%yD%;u%x8G?j0Wx5E*UkqPs{{Vb|~xMItNl0Mqvc_|1rJE4gj@bVHB4 zD1}`Nd!x50@iHGR$lvfG$iT_w9o5%#2j@cRB5?$+QFKEyjDRO5v4RWT|GCGb<8BOJ z{O^t>5$_Y?QGM-v@WI3swRH^k{}*!isdF^abp!b(DY3_0)g$T|1NL=z9BW0TR{1gT z=X=*vPD*cVT$;BpjDq(DF)gvvDbURGRBR$1(`EDFa{S8SE*0=_OXB(+k>KW^Mcpj9 zM!)eGD6Fd5ZfKu4H~^PB@WmhTt#}||NvmfL1p<w=C7bw!RqJjOHZaRkjO%^Y>ED4@ zHY+(=^C`KThXl^7^iwyUn0)yQVTMhVYYc)*&G31^wpVOx^|dI`ep<not&$nTPV19B zN3)wuWc7<(J|x&=kKE3*E&r2iKp7xLrE>qqErwsm$V8O!;^=;f9`R7fmce^YyjdM} zz@0nRHYjYgn2JPP>D=#Z8H~pNa4k3pCHZ9Xj2TzQ{Ba%myL0=Kr=938I~cn();i2A ze`?YW5Ukf>D7J?4#J`Y3C*Y0FxRAww<Nzo(gTcUaD_AC-^fXs&KQb5j4(Y@#AaJ>6 zyrQ&9g<)%P%}+5Z9)K)E>v<(e%7!8cxr@!YE}XE!%-#x!6+DGIOkKI6pLeM%T`1Kx z%)h5<%eOIszaAv*j<5J?_p27}5)`-9$`7}hXg<KGn3+Gy=V87OcBT)y-Z)^(bAKVP zG|*qsbKsa^<9{I-RyV5k;FCr2OT%mQ_oGA=(LMP7&E1Q+OF!xfA82@tm4Q#h!kWMz z=7RY~4>s;^r|;lnFy1?t?SMF30H?ez%&^pOrts7oG|p@nm3TWtE0Gt7P_=Les#!G4 zw@{WT#e`9T126o1i7Wc~j6Upxu<9)N1^dj?qJV)Cssz;}mP)uR?e4jY3eo$M2Y*hv znqG;P_7(u<R)!SCT67Ky@XP(+;0?qkVbfr+C?ikAz;NN8(P^LMZP?KEE_4!#lbdtA zuFZVx7F4ccKo!6|YF1DU2=42``HBdDt17Y`a1mG>1!hp2PcOwd<IEiA{+GNj`UkAb z&7XZ;ZN4M_6{+4$24gA=u-J$`6*9l=#9jJF3|vK7&?=dZIpNoH#MG7Irw`4Ya|Q5D zU&;6j>M3$NSp)d~=3&UAD@iwvpZrT1&p+3g!xQ)gAU^2{i6`mG7!f&;x1ZO5i*n!e zES~t_y~&?Ha#I;}1Jsv~t-U|%31GgX<n_o1hSkkp<hc<Qf!nDZXn>y{esFk6v6RKJ zqk<WH><g+33`0$%-7LyMNt@pN&^!TF&3$<QUkJW=JDzOI(96%NB4@!)zi;gU3)ZoZ zZT8dMo$>}XKmEny{rrvf*Bczk#}ncaCvn)0I{N{}N#-X@qh&3NAV#bnZEy9a$=`DG zq`bx$mMnMy`#{(YwW$*r-6jhx@VF;y)|8Gwj^UN^RzzIF_VJYkS?qMDFhsK(N`;A6 z4gB(o(ct2ekIoY$LDyFHBzt-P3G^uyEfd8s;};!3_W=3~Y9!ZKP5H~q2yFwig}Gs7 zta_)|#v0}mv}cJcg(rjYYOaRKTjVnMVs0qL|53`c74<`yn+ae7cc?DE%ptk{LdO4G zj{Gy?7j27Oq1aH;mhUl~dtd6L{z9;)!y`N8&^C3W{TOB**W6Rv4NUC##j~%@r+=op z7v4X4hu6+kb6u1xWg)<*&MK$>g{E+?>x?Yss3bd$`B|<me1h?N*C%pj`^e~ce#Q$` ztbQ?ekAngC<IGa&Q7}WUrO6$aVP+-$T$rCBx&dQoPyc6Vul=8)oqT6?0b^*_nf^aR zyI5-g#+^?+YPvj=cpI<Y=yssj{8})KdF<cqz};w4U)~GE`5Rkn%WmoW;Gb;X08J6b z=nxl}&A+kp5gTtfLSV*4yy3x6pT6fHu>tX{s^J=Tpvv?{?AZv+dLuwd1U65Eh8J~h zUOlWV_S$a!u%F#@!1_c4z2nwTi@$p@6l$`Je2KYn`WaOtd9KdezoE6EOguIce@CA9 zndM#z0lpXm4kC-St#2Chcxz`^)m{VR8R_(qD<61_CCEDX1G2G!n;rB?G+=OW5;VMF z?dr<9oc;Kc<A8Uq{r25CjqrKDw7@}h7P|l6ozd{`>WeMdrY^u1s064}=WNL+=iE?> zkvmtt1K1a&SM&aU?tv|-yVT&|r&zCR`-IwKyRT2buCn<@g6~OCPWy1xM>D(M5RuqY zlTu-q{n3R&iT*(BQ-?{Wne{GAVWW8wU4|5N$m$ceG(NVq^&1<V@<AdgPFow0Z(vhS z=xhUI<OE<*ES3JTC@V@79Ii3E-U)O<I&a!`t3-I(k-gqFwK}JhN^?J>nB)lQJ)$#I z!?$luI}5HA@c(Lh#^OQolJst$I1NS@rvB)|ejaY&hx$c}8=+if+eRX1jVRl+jy}HH z%bLdutY2CgXUf9n2?XqmL^H5a$wU=79`)h(E%8?6@5iObF%yWN3JRhbJ=Sldr3je& zhGg;z&j=}8e`80sr<EBia;|9CJbLUd+3|TCcF!gKvH`jHmfzRfQw+zVCb88s!rhwf zU8xC95=DBd;NmX|LWTU&`gG7UpAq||I=&nY9rpuv^CWF_`CrHclO+t`Yfw-WZ~}5# zUn8!)_$VQHyuJeB0;+z>RSWE@`JFzwH7GSzQ~FEE8UAYCcltJ3lbS($kpWoHB@y5G zlgtm+uGxEdaN_8usJa}1j{ad7hk_w1Z}|h8hm0@Y-;cd+A!}e2yB8D-KWoJ`949ZS z`l%3lvz8yLVENihQ@*!K8Gj*#-p41IqjeR-PmWgWLxLV?*a=nk)(SRmMG3S&`qgeQ zJY9=d@j*@a^yb1W^rXy%o8++GfU*wJ*iQCMNteZ*G23G=L&ym$+zzaX#!6x9ibe^# zD65V0j84!h2bdeh11{az7q`Uil`uXl2FK&UWQ>mAEC$xy4eH^k(vO-tdU-cDH6`=D zPv0Lt7VO`MY^&26lk$J4d|u+rtN^{PL~LR7{4slP?Dzv({NEG-#8Xw@lBHDZwpgAo z6A>!VXtBDLcH_7(?D<W@&LjBkjL)azW_Fx5TSEzVg$yA}kX#Yu-t_CE(+!QU9%dWF z+{XMShaQg8)iWt(G@eW0OS8$9>W1fdHc_;WS})|p$s$U4Ho4lD!Zm(YC#)&$*ZYPZ zJe}q@JMDI0V7kNXfFQzQppN16Ic+yHsy9e1cNEe`MPh^gFI>d{$JrM{XWxJG!Rwaa z_dkzBTFo64lw#x>c--13X|UUGRgGHJpd?^?j2K2lN801})xpCCW~R+wjneAUA}AQ~ z&f1oym&*C;gS9KZ$?fLh+0^X<q!kJk3+zNODpm;{5vV}fv1l`{+cOE!Vxwhwbw7S0 zEZp{6CW;*}3qLqlXTm2A#r8Pl<eLW>pQlTQU#{z7(}us>R*f#pRd4aJf}S6perpV9 z_uL;aYxys>n)MHJKR{fi&BM>y_`!>Sl;@5|3g5`}`)_IaWS@rzJgEUsYK!yBuzPAn z&wvArTZP94;jugIQu&<+43~D)&cON8fNvbV6!xX0WH`?4eOyN<uU0;~lHvEXu{$x% zv{5^g4|z2ZOVu<(qH&X2HFW$vVBQ4F)doZUtBU=gZ!6QJniTPdZ{GFt)3VD>N&vCV zaH6mE6Li{KccltD+r$J}(^~cK?EGO;R{vo>K_j_lDgMjj_`2q@K(aUC_k3JX8|4ly zpY0?Z!<ih1RLI=lLS-RQ;(ao{gXFUvbmoQUlwtenmoi&L=Z7#f^pU5D*gLQ>eOT&* zVf<4$TE;9cyQhf6y19^Yn|v;vn=p1EO-sdj@Tg)}z1o_uv7dIb3^92klG#D(1CbXr zk!yb*c_{3O$~o0Wh~RX)5o<YlrXJ8*zQ`5|gf50Wm<e5u^A0k-<9wUtNnRQj7Fltp zz%kj@<=|E?=>S_U4|7L2{A+yZ?dT}kEP1W^@HcMlFjlyh=ADOa(M8~a&`h-Ld=EAu z?9zR~$Dqm5@VGZyZ)W{{ws)aJxOLuoGc6v08;IO=tLp!p^UNhq@YzFxZsG&nSMhWa zHQWReC~5N{Js$HRZRtQ}g=a<A_WnZNhR&0ai*`A?^?4S0q4G!mDTKYMThYPC#<8?# zN!pGHp~h_KeVR1TKH`J4>|ogK^<L3t;J9~HL1I#j`_=VdTD+%y{~~<qB>_Ky^7g$o z!CtQdk^T&2772FsKB)<>pEWt$4sq8YkMz@D9G_;9((S*EGm5#-W?I%hZa*LCi$y<J z8fk>XFm?R3vwP9xOU8bt;u8s)wwND|U4AWEkSN&${O_`&N3nJ<HRRXZWm(mC4>PsL zTgh#3Y#vD*@$!x?DT%ye<K0puwJd)p1tqziv$WEOU%i%lM53OOb#VW-%bQcVxUBfk zw|0r!VjR8K+;7WA?Lo;JDNe9yeTM=NBQULjRGLRrz2}?yu8m}0@W$tf0#(M!6?g+= zwf9#!)9BN!x*9B8UqZ0xr3BB9Q9wm)hTb{?>%yi!%^VpZs{$6?LOPDGWaBsRxuPhy z%Dt%IV$2&o1-o+qUg5Tz^#Jq=4?bQ?oOx8svN?eVA7?-q&T$dVOaHq=Q(uXVOxqud zB`p^!icY^-_c%!Nb!lG(5xz!~Vdo-Q4XDz}5YZ1A3@D|Cou{U!rnOIgj3sF4xptFw z+xPwysR=G#PX9avJx5Fy$f(DsU79Pnyk)OAWEk3eQdgl{urn)=QhXyA?$!@E6+}f= zSh~1)*yp)vXe&yNB^PH}ddf|7sNQkbIk~>{^g(@FV#QJqf&BfD1(^uA0?Bovl%sQm z!tN;*vu!Vty%w{s1hXsdqkCNK^GYo7W@TrNX9wnk+fK=yQ_fV3&Fzh%b(f*Pgv$et z8)#bkjCW1zz~Jbf0nZ?(@da3_m2G8nVk+M&o%NW?cYR&#`fZPXo15_zQ9VYJ0*4ea z?bt<sA^DXGe-xCpd_Og|an|?{Nk2~D;LL%<R^pM}PrE0Z1Afx=N{ek~4CtbtykCi0 zxUa}{bnR`?x0`>oP5)fW9MgDylI{C^<vH|qtXK2AdAK`?Ez^`?PZnR{<lqC^Xw&f8 zo76COMXC-r_40wiT9)V?-59wQ)`Pyx5Jqj=`D?hxo8D{bwgu&exaCLwD&l8S#Ao#< zswGTz{MQ9VG9{v(L3|DX$|Kfd*A4n1o?hMtTSCwJ-y0)tgW5^M1syaUQ{aYPA=k-< z-JZn0J8Tb@PoJ|lpP8E-lPpV1?{53XxGK(QLoGZk;f%YItzLtyMFjnjgS3DY_*`!n z--tb<!UjV%Z&X5*B1Hi?DkGPrjFZ-(k6T8@2O%#S{=Mc~EtHUlB0o`!&Hg>l8~TxX z9?DEaV?SjTeZJCIPpVervN3wt-ohbI|CY|YRocyf?8pL({DCp)T#^>oSz60c@5jF0 z%><fJij`;bmmB?R(Z_j8vH_W$f_@y9`sL*x`es?|r0+LF#hALCUA<_}Q%--ZFfe$N zlI3J(6mw5=?4F{6EvFtxbo<p-U-!!noj><E(CAW649M1<#~1N??oQG0?4zYcVo*wl zr~#~mFZ>kTIY4`72T*lUCzwg>9<bvu=*d1^4SKCiIgw2>&7DJoq-v_+k>|~zJO#<C z3AkOZJ5IxkIu)YgxE-4=n9%D+(XZ1PPHqnQI#kT&X+%liA45I;>VjgUhkgD+^!}*5 zUdfHKcvw6*<4#|nV9g3_Dl^m;RL=ESCb*M=0@u?-m`!3mEMpyH))l*T8D#kox9DGU zA+r=oG{&50a0CQTDBs-UR`d@Ms4Ojgz~U>U-|l6?^mL-TB%eTT*Ox<MBi)B0i_U6* z*bHjOrpp<0O)e<2<!$UIavISv;Q+E$rLGmdZ%^e|9pt2AyVaE;He@lbDz4tVyUVQ> zw?HZ9{>P58m*k$awAf+xyHOmHZd-d=NCQ-%<myY*&RJ@Ia|06UD7H0%k#)<mf_MnD z+R~aa#(Kwamh+_oj#|bA3MHh~)PCWQ?yCr&SUO`Wa5D|9Qt$mYMWN1Z-CvH}Goi3U zF=wWjJhxrhCA}>tvAmU=`M<laRBM$!tSsR=x@^g_*B;;g{NxqFf$TRe)$^&zk6RuO z%DlCL_4HNnh7?7XR92ZGnI=rw*Mm~<(rmiqGh=>!eel^~I$7Y4?x_5?o>8Ruit{~j z+9C${To4Ic*^def+RwFm-<mq=J>x@XDm^?>tIm)5EA>9xxG8R#vYghzr<Kg}cF3K) zro`%0@4Ymk^f65#)-QXXvm8GU(he%YOR*u3!k?SLT80NDoHwNf9#=1AJ8}V-8N69u z%6|N8yt#!kUe!Hoyzbh&fK2k+FK=c#e|z0u5PjqI>fJ1`>qJovG++J&=V`_+_><Ju zeiKh-BGdRLo739ra%zRrB^VJbB(W+(>WstVzuyL4t6{QV`tIHNTl5!m%Gz{@a<bx# zRB@tY?lLz6BZ<O}IBw(dvKicENB%snu<WP7G+W76#zUT;Qc_S>SfaR!Xq>bG9lP38 zCHwk^hTb;_moc0Jia(Vudp|Szw69~Oxju{Bn2fSz!=|ogBGCV28BG($+IUl9i6W3E zXzX}L^U-T5h3;7=v%vsjSjP-zBTWTtq!HHcQ?+0q0^Lf?W@}&AVoIpM>rw>7Z@w=} zHbGP+X?F?x;9S#^c^8B?FLxQnT>_rpL?J1L9oy>en}L0pxU|=lz5mUNs9F8zZn%iY zvzyswjN}97xgz+=0dY(8X>a?O71g4Ru9Zw1w6kR@hWv%dG3KX4M+f`Y91Iap`>;u6 zjcjbPv^zN6{&8V`P25EG7GH&^rt%xnRP=hSlemqv9cb+jRANe*nQE)(W~(*t=#epd z6`cJDN!2l&ue+7&c&ac-^o^;O$Nv-djfvG1N19;B$?`Ex^w2M*qw+3A{qdihf27pt zz20B)V$&e52gKiz(HPNbsU0L#autzWQ?0V}CNYU1aSa!B%npZ;Em3{i8hlt0#%z?$ zo0F}P+VFfpl!~tawD$T>&d^nk_x=dw`zVCTj<CNV8v0XvIICj4^Q)l);pSwso%pl> zHkh1FD7H%aFZa4`408`jx{6(8i9h=^dfjGpX2q@C<zYl`AC7@Dw+aK?u1(?G;9OAD z&|c%clS5&=11sCtpNxisoU}9WBr=0FuvA0*DLCx(?bo2qBHe9uy%bwEhI7X%K2DY) zB=F$6p@B`|32n8UP2f}Lt&Nai{?=n;^@Np7xhLUR;F5jD<$1;Lq6Q?)_$wa$COO05 z_V2hZ9U1fAkJAMi(t_arV5ybo;W`pi9rs!<w$<jkwuT|ANiSEoAU3rL9`?rJn@GJx z`ahA9afGpnS_*kNoF9nIQ?TIjonom<UF8)#!Q}on@37R@ojBSQ^LEC)HX@VUQt~|t zioATt^hQKr`2h{yvnZjzkmv-k!>50>!^hpfbR}wb!|d>J%{}<y4S%P;Y0U?X+&w`} zQ?F64p)RzIOn17oK|Vm|;+#lo1&~a#&g%2WO+cFtVk4DZ`jM?(qoNVN*tIHOiKgCZ zs~3-<V<x4dmxZ{%MdMxnC~-g8BOZO*m8HsL+Jg5$p!0MwD=uovOKWykfFPzgM_k1K z>jUlXE@R=#nENk^{5t6>ch#QDGPTbxQz9wuPmN8crPOe0yN+1X^=4&_CcmnACw<Fc ze%@Q>(YtxGLlk3p7Szy@Ns>Dxt8&?BvXFC=?ApCs{nX0=<(~?Qs;_e!baUqmi%V90 zw)6)2U~~UziZ#KFy<{8O7Y3h?=7#q!$(bXG4F%MyCZd!Dsh`aUWFs(%JuKjnyllmC zii<4jj71rM6+OqlUHy?H|Mc{ASMxc{ie6`m?A^rA_EiWUZX;E;N?%<O&z#4&dS0s{ zBb~AgSWLk*#J6S1z2S$nwHX&dfKbm2T{}XFZ2aP-6WV+iqe0k1D|@(Tk|SAKdN;q{ z)te#R*1>3$(Bs$n-RQ!{2s%#W9CzNk@#Wyzus|7~wG=mall+E@sD-CpEa8{3w_>}E zI1t3ZzR)FfSH#Ay*vM44%8kOVUV2j3Eu2Z94|WZ9+s55wW$@M@^hp9saA9P0$c+Gv zam3i%2M(*(4CLQGtyAGnfZ8*6oqCdliJbkhsHwTxRAk?OA4<@2^arp|c;@u}fOcSG zpsO}U`jd_o6vyPLZfxlrBf$E^WsYFTrv6^9?1B3RxXF{6mCmumkI9bi0)>#0-uVaZ z(uxJ<RwkWH_31DQ3JW=!^g@9><UnD$q=ygJ-s(<^{&R}Mx#q^k_5hbQ+0hWk49|5w z;%e%TxfuZx#!L@|P1e$r*1*q&QRtWi?d1?%5Y=+B8>n5evb^<}NIO$^8LQ`!um5No zvw6w@mjv5OWNvM8ZOVe}MuLwTSi7a^@6dJBG7e^lYdc3#kO=!_diFEWY`|A^^?TU& z9K+1!nc~ipy__%VJ9<;Y9QkuD#@qZ$laCHtFGf)S@`u)<yNqBNtSCi+%dUU#eoj}_ z4GU1+z-Y{W$5$dnZIKOr3-_+=?)3w!@N%$ABgx`a?veKBj3g07U*%vo4Qn(c!{rXV zdTP^c_*`}O*XZqD#N&GD@+sa$-Tt1J+i6lzjk5h)kEFGE;OXB;SuF}1d>P;ZEo_e8 z4fEsaQY&3~nkiL`z8svrtk`pn(OO~1VP%98J7+3<)q7q>z%SQ3yq1y%ql}xX#-)AE z7ExBw>BL<6jnkct=7IV>iMuIl2l*N{GlUBds&yy*1Sacg(@16xb<^}2BDSWFh5cdA zjt=LR_*c#|TKix@B&!YddS^Lb;o-^w8eC+w9uoZppc(V3%aHjD`~s~?YT6|RVpo$G zq>a-C{e^r<K4i=4|5{(cEbAmv_>OH}unK-R&n-RuQR&pvjF`CH6K*w^?5@gU8&yV3 zi@)He9gpJON<`1&LdpC}A!7gj6ZuY%I^)mOH3stHD(mEG4c_0}4lfdXgk0L+Lb_)j zF?mw<+`mKMd{Y^V;qZ|SzJtsR??;=*AD)vhVYSFe)?di#xy-34qiSGDTV9uPS>BZL z!auX%FZt{H0xzLE3MyX~%Rmj%n5Op3{`cAU6;4;y@gsqYEs*ag`|pllTH`py^#*sm zpMfUFH$k;3+}{Gr_%mxr{H~f(d`v66@Fzbf&<da4p_(@+{_%;N95-In9lsXJ2`HMS zr@_N_`d8xdaH_5$cE(LD=yi%<W<}-muhH+8!y_4N!x-$?1@E2~@5;EH4S7oCKm8fg z^RvWBW_)6Xfm{Py&`F|QkFZKmM}<#Hlc$9!*f73P3VS$(>d!~^AdgC^A6aoZrkUY4 zAIUcGelVkZ{m%PO4^4#3y~S&Xvb5=lUf!Z_L^ihL6Y;_&yN!I;8i8F2UwB`DKBPW4 zMypoH=`Eo_Q3UvD^ALQ%-mx*}^ofidje9abf7I%uL;EWO^TtkZy|Nu=F?Zyf>e=!a z!qPcZhX#_=%Dsy!Bu~mo#y?4FJ5za{F?bgR>Dc;#CGrGG9FXYR>p{nj0#pXtkyd82 zj{IY;lD0C>Sy_Z`B-;Ic8Tx*J(=1A2Z3qW(lBYc7s3^ZzeD@2NO^|L6L8dK@)$iBh zZxZ{lg8Iu}mPd2_dA9x*&LX1y0#}gVInm_NJ}z^MIZ1o=b&s#rl6uy-GMV<r(m*~# zR^R_{N|qa935P)NWDR_m!cTHjr)t?BD0Gb-vU(TGJD>dc9Bv!)2&hT~P!d22%x7be z{~alP`-Pf{%XQZosZU7A!n<WA5uW3$<%)an%X24CVrevs-t!2J-U}Mh9@O~k8-b%D zMK+bmN}xWE{an7SjzB~CTTay6GO+Kns=LxoHHEiEr%fzHI@zxGoJ96C)7fI=5Gn0+ zNp<lw)&YOSxo^;TgT8^&y)7da^@>_5nH2A2{TOLh`eBj62~*9cTkpUxKLFrjl~1CE z3@YI*ZjXd|K1?pmYa^}~xAl-zI(AUIcM6^(l-zbh*cEe1I;*8$;_>x9iHS;3-4^e} zP9Zv5%#N$KK+v1$m<Sq4bO3F>zt77!KGM1Bk&iS4Y9+U5u;bgEiW=-yN6HAt)L`yl z1&e2^KaLlR>J<Bwolp5LuabseYqX+JZ%{ICo>`D3Di6l-U0NUB`>SWTGF+?OYQHo4 z;n91a0d(f1;FBlL;?aW8wJOxsP<PBpf1({MdeL$RZN#h(`jY=bKG@0+zBA>tq{VY< za)$NW&T;?a-~uq!fC`;gb>e>}GD9eGh`!7_HqyKq#)~v!=~QaB^xb7v)I5D_b2pB& zX=q10|27V_IXqn*mUXw9ZyT49wrH7Gs+fnS>y*-LPiH!J3Sk9$WoUhtu!;9z)a7M9 zv0U&Mtv?lT$CrXh!w*H1P_4?7aob0<x4w4okiUbZxQcI}`qvbeD%BqF=}ruPi3pX$ zMLgJuaa)IMoDKS8u_;9Q-;EQi=Vae-=Bv^ajK!Iwrl|a7{bl*+vh4DQ<t8H=tN@mu z5E;`~i6uHP^&*<!%e)C5%WLSV@Y;aO!<}Lu5l<ik#mf!`9Z<9yvNW=`jZHu4l#k}m zU;nRX%xj`0_DhUECX}hJP+yYyLb7Znl&9*Iz{wg8SnV}$qrj_KB11z1C?|0hQJ)hs zcoSOxR$AXFoA>V{TTWMoploA6@>=q(kT!$9!6=>WTbU;2OSEaqT;r-c1El<_)nPze zDpBi{6v7j}L_;h+@m)|hneAS<9L+T?t$-D6Yg7p=mBI0i{FK^JsLT#?_rpwy==b-g zerm(-37u<b6|<jeJ>+LqWc>@-A`x|=l<SB7-W!4+olo4!nwarp6rX*Xwjcr7=*t!+ zy6EWWXm|8E>O3xzg`iLBBFXuTYr^Y5LCDEZyV_2_=w5LgD|a_9dzM>_WIXlBvf1U0 z9qpvH4L@*}vCN4qi4LTzF7$Og>m740|5KI34RUmtH))AeI~V+tXSk>hT|inUSE>me z5o*s^?#3XY;n6gOd@jRpHrA~b^|1r?Ua>A!lt*rR6qniHo03x?8bx5(QlA45x9CW^ zDc}Ccf7a{7ISsF(X2P~1Yx7O}^~~H~VY2sb{tUOXtpap4PP|Ym)}F1L^UhKsmT0Eu zu3)ywuV2dT3kxY{C2RCzG~2S+I37D&X+CCTMJjBSW&R*OHS)9Z1p62o>bdy7{X`ZG z8u2Gs{*$*W{2#-1yy}ag8^{UC*tBMWOUpXDTAg#^_+9Rf3_ZzjK=*^?SEA$a*d;u@ z?xxQnzFzJws`ZXQmYx1NG9e?DRqaC<Mf}|~Zf6(;>{)08(K_dyf>6fW%_~1Oy(JQT zb|{W<1eao`W+ON}ID9QG%}*|EixP$F?K4){X(p>^=kc?ZGtYsxzJ{59VpslQRBg(u zsT+Z&zbUZVd|lQ>Y`DH=_E?y|y#0mgo_9ZkbGsvCZEi9x;jYuGV;Z1F2!kDR8^Ff$ zVe2+Vh5z<?n)_fgV@2!jDBTo&`dHylhc8v?@)B511(z!4eNG@_My5p_AV$l@{BY$Y zVh3Q5sRh&guNZ<X%;WX%Nlb;<2V7s<6hA4VRC5_jmxkur)?B{XHMiF2k@hCPq4ku4 z$D_0T(X*qqWHs#pSC0|=CTRUyUw*U0wlDVC>vLcA(dIu(mT=EQ)F)JGA@ovpYOLnM zYBbp{k2uz&lyYgeV$}2pC-Kr$nm5H7aNK;5`z|kM!Bp1m`_a83&MB<dMpwtxRcHSG zP{Pi**)QwwLp9v-baY)OZ0}H+QLPD<bb+3<RznN|RLW`br1h%_740tjy_&*`uJ<J) zOR9g9EqA>Azg7rxfh!)AqYd}l+jU9pJFC1M)w|9q0r4YA^hgJQ{ObzGtTlkQLv_Wq z_BB0)5*ALi^xD92Q;~yo#~a=r6)_@5$oR!hjc%HrNE<@Gsl>CYt-Us8!xIY@$6y{V zD}<S}YJhs7Jk7L;+40w@FC*s>($SL(461`XudpD&wK6cn8EYpy!ouAo+!LuogPh8j zrrf)nGG2nGO9c-`yVYj94hMATKIjC6E?C_;Ow__*n4_?aRT9w*W_y}ZWy!d5`;g-^ zy~plvOKb-0VZ5Qy#&15mAhqF3d8J3P%l%1(Lu?BC1)SWxa05_5uw~KmVQ4BslK)2) z!&|tGbH)DxJ`}_M!WJ_n?O4ckb>Bw!Wj@A&OWxyY3n=&gSo-D|eeXrC(Nhc;>z~yu z7&?m^Fc8hcy2BF5$A<%<a2Mu0ZFBWSs>63sk&c8DY`&*-Ri%hExKmj#xy-%aZ(qr8 zj#Do$(WD%UY?`sK=(vW=><UKIc5UXxbY)@ZOzL`f#U3q3{`n|`FVp!+b1-_C`)8v% z_pPJcTIr|$WFs;BsnHBKGRV9dDXgMYT!tbGgsfTe*ISZSJla>t1Ah$S3Q?GeR;}Ss zmT`y#Ast^_3u7mCqy~^%&;VEs=CbtU5<iGmq4h|J{yoK4Ht_9k=F-Y>|MQM^g~2W* z-<}Y%Z0mggcG&*<T1z6;@g~M66<`QL3eGcndK)nLdmOD3Pu^Mg3Kut}H%zhoyrF;V zCO22g1MqwuQ#Sp)QklN)X2Hhpy8$|^=i_NT-qVvWQ3LSW=!~YquBB8jUH0|S3wCyz zU`~sumO>9?ovA23Z5qxCf4Og54(o(5is~(@5=6DFcrImO?3&`N{whTp>QlZ6-x6}~ zOC7Kct2u@)9;?#!-OkTj5~Kl*T2wn4Hj@{R=?fF~az(yG)29jYbk1JOxgV%oLt$PC zwO@<f(_rB(03jZ?LxyaCKku^kJ=G_c2a3fU6DfIl0$6>&x#)3lX!k{`zHiq!p{?mW zOQ+6<Z9C|b&^daoPiq-9D89)k&A?a4J)K1m%A<NlJw&yq1@35%&70@<U(qI~h?%J! zK6@c?_i#BVvhEq$*()O&q4zMX%%C#4vgkN>79|WS6*2$+N`=f-rGk5|6(9NbF9fg! zF~CrT$uuyCy~;G`<1*qpGQX+xQ{*@L&|x<CaZK|g9O2lhP3D4v_w|X>MZ86<^^)=w z-mkfSCVt|pFgI}TfWXlK<?^&eXm5h$sS?!Xq1Tcov)S9mx=$343^sTK9Cb{Y>TglM zLN}oUm#z&8FP0l))=$!56zJbi;|gC(S$kA7WKbmgC7pZ3Zdr#bR9jcfE3#1w8^i@1 zou3cS6?=fLC(WG+qp2Ncm!zNBo?<%$A{*`g+f1~hdCPfu?{YqnyflGuLXL8oSghe= z3u#M>`l<8SC)(?7?MX>!5P{nOaz8r`fNO~56b&6ZTWhL`eBV%d+4!^$W{KcO_B0Ds zERo?{eE&tEMX6cPJ#ffLR`sg7dT10_#MuH!yu$I5Pn8%LGCBnMsTvK8RWW!k&G6+| z#ss<>_1ll>0KzK0n~tDJ>3+O(xkl_SSp-D2p`};Iv{}P~=2_S!jCb$i>@Nf*&9Qxs z0XFgf0o7(23o;FqJCzkUZjM6Vqb_xgaLVzqG><XZr-Q_63@V8qLdgJMzO{SQjBSzf zt0n7=@3o}%3f~JiW7V9|0^{>Th6|$)KGIX?2zuj?XXn&XHA9+UJa!>er>!c(%oz1I zajDN8X;MIeH|*!3o&!iS*%OU|T|SBY{~n7<Q-og?tlXwHkeZ6?5NiuX*?gFNd>eDP zq$lNfui))xPB*+5H1}t#pB^8#W=8+2mAcN}vRsn3DS%XRfAw=Hi=XLfaU;B4qeVkC z*mur2{ZsCe*u707YW>IAiVo1Q9y~{AC_h#(1V<SDiAwK>(L+t7w-4+$=kLX(u+>%l zsbfL6{WSxz!(+3*5dQ|?DK>(gxEpPOJ#{eRqZt$bgwge2yX?QHCkT1e#F(J*cl1O6 zGWrVRKsNxHgZWe!JHY#%44W9ikf3iaw5}|~{byP}W2vj?!hVmLDn#Z~<o9W-xdN(* zV~2mRt^(;VVDWwgzGcd2_Ez1`sW;Kph_vaQ^Fef~M$Kit1Ug?9qwo&yUPyfBu)S0n zv!#sw^KK+TTPP56EkGH+QVeW7Zt}kd!$uFyq9f-$0{fuc{V0|QjB6gY2TC!Ewy=LH zdeZ-VStM}PS5hVK_h%zx9<_5<;f+IJSnUvv%l^C_k1ISiPID5&oAZ3#k9<>9cGtsS zW>?<|;ed*q5h6Mbq9DLIZFsiT^vYH(etD!}0nX~ko$BS<C^T2p-``Wb<aB4#h}K_t zT*cwx6fXb!kJl9K+iu}pWQ(ckneHDJI2ye*;c{+vbDPt5_zSt`YPOJ2FSfD=>g!is z0NUy3RP7qzBtM3guX}(=8P(9VcVCh1N0DzfLrm)gcE<#r!u+EQxQe5HUPI|7?Fcw` zy47KQrR?l%WI}th|7%$HOyt#^E4}Q!>L{@J50L#qEW{_y^U#>J;>n`)WdCYs$5O?# z2Z;(t;ZnD|(^f}(@<LoS((uxFRSBoL|2}bV%j9c^v};N<;r=g7vsU7t@fx`0iP>_1 zu>o@OwuYy5`yZzm6?0!ko4d-$8rN`2obE_=+kQ_STe_5BU45gZTU3i`m){?InV6XT zJ0-X86lX;!G4)p@FKb1~CYVMzDX-EFz&S=z;k!>)Y<-0A94^WVlI<P3^Z^+7YNk<v z!1)#Mk<XVT`-NY>ZvL5>>2+M+Px}2nDJ4N;8ed_%aehtG5qR1AdU$T@B-;`wpVl{s zaT`DMa#B6Grkg6N=x*miFSXl+>rl1KbW~8BWTU;Z;!9?6*73?4IpkR}t1YN<rd6}| zKaQQH|3BjfcJQWng)p51u?wM9?6gTU6U+Nzf9P1k@fTPE1JXxxSN_dph2C|elf+{L z9Z)0N6@vVMnXs!1koce5=}AL{HRf7|p;-RkAqfAwnUXOL9~L-g8jIQum8s>5&mWSq z=es<??o)NZ1?do_b0RF#fw^6y>0=$Ot7X9Q*icZDn7B|_fB*Tc%}R0Sy?LUeDP3UJ z0z+05o!7_cj<J;A+3z<{3-UA@PP2$Q7Thhn`gcW9tczj55dKMTY{B#0my*NY<H*mE zb*F^g{e=qng*TbA5{Dg6jT6_Uczl@oN*m_RQ)S<v$aB1W4XTFOcd4T-16$v%cr;FD jnh-!JDC0*$n)<?#nWOFZ#2zW$S4qII?t{qmzZ3rtECH|N literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-4.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e4ae4f9e2ec6b23acce7f5b29150ec254de0de60 GIT binary patch literal 46967 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 P92kko^|h<)KhysY*7FG% literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-5.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2161a0e8a2dc007dc250c9cf701f50d30768afc0 GIT binary patch literal 24818 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-mira-6.jpg b/examples/instagram-uhura/fixtures/assets/thumb-mira-6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cc3b2f78491ae9971f533249503e7d4abc35db5b GIT binary patch literal 9581 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/scripts/comment-ok.toml b/examples/instagram-uhura/fixtures/scripts/comment-ok.toml new file mode 100644 index 0000000..72eb3b6 --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/comment-ok.toml @@ -0,0 +1,46 @@ +# §11.4 steps 5–7: open comments, type Mira's comment, Post — a dimmed +# optimistic row swaps atomically for the authoritative comment (the reply +# echoes the typed body and mints the id, §9.5); closing the sheet +# restores focus to the comment button. CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[deliver]] +after-ticks = 3 +port = "comments" +projection = "for-post" +key = "post-lena-glaze" +slice = "comments.lena-glaze" + +[[reply]] +on = { command = "add-comment", where = { post = "post-lena-glaze" } } +after-ticks = 1 +outcome = "ok" + +[[reply.updates]] +port = "comments" +projection = "for-post" +key = { from = "payload.post" } +slice = "comments.lena-glaze-plus-mira" + +[[ui]] +at-tick = 2 +emit = "comments-requested" +where = { post = "post-lena-glaze" } + +[[ui]] +at-tick = 4 +emit = "composer-changed" +data = { value = "Saving this palette for my kitchen reno — stunning work!" } + +[[ui]] +at-tick = 5 +emit = "submit-requested" + +[[ui]] +at-tick = 7 +emit = "dismiss-requested" diff --git a/examples/instagram-uhura/fixtures/scripts/demo.toml b/examples/instagram-uhura/fixtures/scripts/demo.toml new file mode 100644 index 0000000..d8e78d1 --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/demo.toml @@ -0,0 +1,117 @@ +# The §11.4 walkthrough as one play-mode script (not goldened; the M4 gate +# smoke-runs it): settle the feed, like Lena's post, add Mira's comment, +# paginate, visit Lena's profile, come back. The pagination reply carries +# the liked page-1 — authority truth persists across appends. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[deliver]] +after-ticks = 5 +port = "comments" +projection = "for-post" +key = "post-lena-glaze" +slice = "comments.lena-glaze" + +[[deliver]] +after-ticks = 13 +port = "profile" +projection = "profile" +key = "user-lena" +slice = "profiles.lena" + +[[reply]] +on = { command = "like-post", where = { post = "post-lena-glaze" } } +after-ticks = 1 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.page-1-liked" + +[[reply]] +on = { command = "add-comment", where = { post = "post-lena-glaze" } } +after-ticks = 1 +outcome = "ok" + +[[reply.updates]] +port = "comments" +projection = "for-post" +key = { from = "payload.post" } +slice = "comments.lena-glaze-plus-mira" + +# The feed carries comment-count — every carrier settles together (§9.4). +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.page-1-liked-commented" + +[[reply]] +on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } +after-ticks = 2 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.pages-1-2-liked-commented" + +[[ui]] +at-tick = 2 +emit = "like-toggled" +where = { post = "post-lena-glaze", now-liked = true } + +[[ui]] +at-tick = 4 +emit = "comments-requested" +where = { post = "post-lena-glaze" } + +[[ui]] +at-tick = 6 +emit = "composer-changed" +data = { value = "Saving this palette for my kitchen reno — stunning work!" } + +[[ui]] +at-tick = 7 +emit = "submit-requested" + +[[ui]] +at-tick = 9 +emit = "dismiss-requested" + +[[ui]] +at-tick = 10 +emit = "feed-near-end" + +[[ui]] +at-tick = 12 +emit = "author-tapped" +where = { user = "user-lena" } + +[[ui]] +at-tick = 14 +emit = "back-tapped" + +# ── hand-play additions (M5 live gate) ────────────────────────────────── +# The [[ui]] walkthrough above never triggers these; a human at the play +# shell does. One-shot, file-order — the closed world stays closed. + +# Walkthrough step 3: Marco's like survives one optimistic beat, then the +# provider refuses — rollback + notice bar. +[[reply]] +on = { command = "like-post", where = { post = "post-marco-baja" } } +after-ticks = 2 +outcome = "refused" +refusal = "rate-limited" + +# Walkthrough step 11: the bottom tab visits the viewer's own profile. +[[deliver]] +after-ticks = 1 +port = "profile" +projection = "profile" +key = "user-mira" +slice = "profiles.mira" diff --git a/examples/instagram-uhura/fixtures/scripts/feed-empty.toml b/examples/instagram-uhura/fixtures/scripts/feed-empty.toml new file mode 100644 index 0000000..29e97a0 --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/feed-empty.toml @@ -0,0 +1,8 @@ +# A followed-nobody feed: the empty state renders, nothing to observe. +# CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.empty" diff --git a/examples/instagram-uhura/fixtures/scripts/feed-failed.toml b/examples/instagram-uhura/fixtures/scripts/feed-failed.toml new file mode 100644 index 0000000..9a8b842 --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/feed-failed.toml @@ -0,0 +1,22 @@ +# The provider reports the feed projection failed; retry reloads and the +# authority recovers. CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +failed = "unreachable" + +[[reply]] +on = { command = "reload" } +after-ticks = 1 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[ui]] +at-tick = 2 +emit = "retry-reload-tapped" diff --git a/examples/instagram-uhura/fixtures/scripts/like-ok.toml b/examples/instagram-uhura/fixtures/scripts/like-ok.toml new file mode 100644 index 0000000..417d03c --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/like-ok.toml @@ -0,0 +1,23 @@ +# §11.4 step 2: like Lena's post — optimistic beat, then the authority +# settles via a piggybacked update (flicker-free, §9.4). CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[reply]] +on = { command = "like-post", where = { post = "post-lena-glaze" } } +after-ticks = 1 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.page-1-liked" + +[[ui]] +at-tick = 2 +emit = "like-toggled" +where = { post = "post-lena-glaze", now-liked = true } diff --git a/examples/instagram-uhura/fixtures/scripts/like-refused.toml b/examples/instagram-uhura/fixtures/scripts/like-refused.toml new file mode 100644 index 0000000..3efa39b --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/like-refused.toml @@ -0,0 +1,24 @@ +# §11.4 step 3: the authority refuses — heart and count roll back, the +# notice explains; after dismissing it the feed subtree is byte-identical +# to pre-like (the scoped invariant). CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[reply]] +on = { command = "like-post", where = { post = "post-lena-glaze" } } +after-ticks = 1 +outcome = "refused" +refusal = "rate-limited" + +[[ui]] +at-tick = 2 +emit = "like-toggled" +where = { post = "post-lena-glaze", now-liked = true } + +[[ui]] +at-tick = 4 +emit = "notice-dismissed" diff --git a/examples/instagram-uhura/fixtures/scripts/paginate.toml b/examples/instagram-uhura/fixtures/scripts/paginate.toml new file mode 100644 index 0000000..c16be21 --- /dev/null +++ b/examples/instagram-uhura/fixtures/scripts/paginate.toml @@ -0,0 +1,27 @@ +# §11.4 step 8: scroll to the bottom — exactly one load-next-page; the +# wiggle re-observation is guard-dropped (the guard IS the dedupe); page 2 +# appends with keys preserved. CI-goldened. + +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[reply]] +on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } +after-ticks = 2 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.pages-1-2" + +[[ui]] +at-tick = 2 +emit = "feed-near-end" + +[[ui]] +at-tick = 3 +emit = "feed-near-end" diff --git a/examples/instagram-uhura/fixtures/standard.toml b/examples/instagram-uhura/fixtures/standard.toml new file mode 100644 index 0000000..c79a27f --- /dev/null +++ b/examples/instagram-uhura/fixtures/standard.toml @@ -0,0 +1,509 @@ +# The standard fixture — the §11.2 cast as named, typed data slices. +# Slices are raw values typed at every binding site (L8 at use — an +# ill-typed slice is a link error where it binds). `"@<ns>.<name>"` strings +# splice another slice (resolved at load, cycles rejected), so a post is +# authored exactly once. No lorem ipsum; time/count labels are +# provider-formatted strings (core has no clock, §9.1). + +# ── boot (auto-bound by the examples resolver, §6.1) ──────────────────── + +[boot] +viewer = "@users.mira" + +# ── users (user-ref) ──────────────────────────────────────────────────── + +[users.mira] +id = "user-mira" +username = "mira.santos" +display-name = "Mira Santos" +avatar = "@avatars.mira" + +[users.lena] +id = "user-lena" +username = "lena.holt" +display-name = "Lena Holt" +avatar = "@avatars.lena" + +[users.marco] +id = "user-marco" +username = "marco.reyes" +display-name = "Marco Reyes" +avatar = "@avatars.marco" + +[users.nils] +id = "user-nils" +username = "nils.bergman" +display-name = "Nils Bergman" +avatar = "@avatars.nils" + +[users.priya] +id = "user-priya" +username = "priya.raman" +display-name = "Priya Raman" +avatar = "@avatars.priya" + +[users.ayla] +id = "user-ayla" +username = "ayla.demir" +display-name = "Ayla Demir" +avatar = "@avatars.ayla" + +[users.june] +id = "user-june" +username = "june.park" +display-name = "June Park" +avatar = "@avatars.june" + +[users.theo] +id = "user-theo" +username = "theo.okafor" +display-name = "Theo Okafor" +avatar = "@avatars.theo" + +[users.kenji] +id = "user-kenji" +username = "kenji.rides" +display-name = "Kenji Tanaka" +avatar = "@avatars.kenji" + +# ── avatars (image-ref) ───────────────────────────────────────────────── + +[avatars.mira] +src = "avatar-mira" +alt = "Mira Santos" + +[avatars.lena] +src = "avatar-lena" +alt = "Lena Holt" + +[avatars.marco] +src = "avatar-marco" +alt = "Marco Reyes" + +[avatars.nils] +src = "avatar-nils" +alt = "Nils Bergman" + +[avatars.priya] +src = "avatar-priya" +alt = "Priya Raman" + +[avatars.ayla] +src = "avatar-ayla" +alt = "Ayla Demir" + +[avatars.june] +src = "avatar-june" +alt = "June Park" + +[avatars.theo] +src = "avatar-theo" +alt = "Theo Okafor" + +[avatars.kenji] +src = "avatar-kenji" +alt = "Kenji Tanaka" + +# ── stories (story-ring) ──────────────────────────────────────────────── + +[stories.ring-mira] +id = "ring-mira" +user = "@users.mira" +has-unseen = false +is-self = true + +[stories.ring-lena] +id = "ring-lena" +user = "@users.lena" +has-unseen = true +is-self = false + +[stories.ring-marco] +id = "ring-marco" +user = "@users.marco" +has-unseen = true +is-self = false + +[stories.ring-priya] +id = "ring-priya" +user = "@users.priya" +has-unseen = false +is-self = false + +[stories.ring-june] +id = "ring-june" +user = "@users.june" +has-unseen = true +is-self = false + +[stories.ring-kenji] +id = "ring-kenji" +user = "@users.kenji" +has-unseen = false +is-self = false + +# ── posts (post-summary) ──────────────────────────────────────────────── + +[posts.lena-glaze] +id = "post-lena-glaze" +author = "@users.lena" +caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." +like-count = 214 +comment-count = 4 +viewer-has-liked = false +posted-label = "2h" + +[posts.lena-glaze.media.image.image] +src = "media-lena-glaze" +alt = "Grid of copper-red glaze test tiles on a maple bench" + +# The authority's view after like-post settles: same post, count 215, +# viewer-has-liked — the like-ok script's piggybacked update (§9.4). +[posts.lena-glaze-liked] +id = "post-lena-glaze" +author = "@users.lena" +caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." +like-count = 215 +comment-count = 4 +viewer-has-liked = true +posted-label = "2h" + +[posts.lena-glaze-liked.media.image.image] +src = "media-lena-glaze" +alt = "Grid of copper-red glaze test tiles on a maple bench" + +# After Mira's comment settles: the feed truth carries the new count +# (§11.4 step 6 — "post-card meta shows 5 comments"; the provider updates +# every carrier of the fact on settle). +[posts.lena-glaze-liked-commented] +id = "post-lena-glaze" +author = "@users.lena" +caption = "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved." +like-count = 215 +comment-count = 5 +viewer-has-liked = true +posted-label = "2h" + +[posts.lena-glaze-liked-commented.media.image.image] +src = "media-lena-glaze" +alt = "Grid of copper-red glaze test tiles on a maple bench" + +[posts.marco-baja] +id = "post-marco-baja" +author = "@users.marco" +caption = "Three days down the Baja coast. Swell arrived on the last morning, as it always does." +like-count = 892 +comment-count = 31 +viewer-has-liked = false +posted-label = "5h" + +[[posts.marco-baja.media.carousel.slides]] +id = "slide-marco-baja-1" +src = "media-marco-baja-1" +alt = "Long left-hand wave peeling along a desert point" + +[[posts.marco-baja.media.carousel.slides]] +id = "slide-marco-baja-2" +src = "media-marco-baja-2" +alt = "Campfire on the bluff above the break at dusk" + +[[posts.marco-baja.media.carousel.slides]] +id = "slide-marco-baja-3" +src = "media-marco-baja-3" +alt = "Board fins silhouetted against the sunrise" + +[posts.nils-aurora] +id = "post-nils-aurora" +author = "@users.nils" +caption = "Aurora over the fjord last night — the whole sky was breathing. Sound on." +like-count = 2431 +comment-count = 87 +viewer-has-liked = false +posted-label = "9h" + +[posts.nils-aurora.media.video.poster] +src = "media-nils-aurora-poster" +alt = "Green aurora curtains over a dark fjord" + +[posts.priya-starter] +id = "post-priya-starter" +author = "@users.priya" +caption = "Day 400 of the starter. She's earned a name: Clint Yeastwood." +like-count = 156 +comment-count = 12 +viewer-has-liked = false +posted-label = "12h" + +[posts.priya-starter.media.image.image] +src = "media-priya-starter" +alt = "Open crumb of a sourdough loaf, sliced on a flour-dusted board" + +[posts.ayla-ferry] +id = "post-ayla-ferry" +author = "@users.ayla" +caption = "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon." +like-count = 340 +comment-count = 0 +viewer-has-liked = false +posted-label = "1d" + +[posts.ayla-ferry.media.image.image] +src = "media-ayla-ferry" +alt = "Ferry deck railing over blue water, city skyline behind" + +[posts.june-lookbook] +id = "post-june-lookbook" +author = "@users.june" +caption = "Studio lookbook, page one. Linen in every weight we could mill." +like-count = 521 +comment-count = 18 +viewer-has-liked = false +posted-label = "1d" + +[posts.june-lookbook.media.image.image] +src = "media-june-lookbook" +alt = "Folded linen garments stacked by shade on a workbench" + +[posts.theo-court] +id = "post-theo-court" +author = "@users.theo" +caption = "Finished the mural at the 9th street court. Paint holds up better than my jumper." +like-count = 774 +comment-count = 26 +viewer-has-liked = false +posted-label = "2d" + +[posts.theo-court.media.image.image] +src = "media-theo-court" +alt = "Basketball court painted with bold geometric shapes" + +[posts.kenji-copper] +id = "post-kenji-copper" +author = "@users.kenji" +caption = "120km of switchbacks and one very smug goat. Copper Pass, you were worth it." +like-count = 1108 +comment-count = 44 +viewer-has-liked = true +posted-label = "2d" + +[posts.kenji-copper.media.image.image] +src = "media-kenji-copper" +alt = "Road bike leaning on a stone wall at a mountain pass" + +# ── feed (feed-page) ──────────────────────────────────────────────────── + +[feed] +stories = [ + "@stories.ring-mira", + "@stories.ring-lena", + "@stories.ring-marco", + "@stories.ring-priya", + "@stories.ring-june", + "@stories.ring-kenji", +] + +[feed.page-1] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", +] +cursor = "cursor-page-2" +has-more = true + +# Page 1 with Lena's post settled at 215 — the like-ok reply value. +[feed.page-1-liked] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze-liked", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", +] +cursor = "cursor-page-2" +has-more = true + +# …and with Mira's comment counted — the demo's add-comment piggyback. +[feed.page-1-liked-commented] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze-liked-commented", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", +] +cursor = "cursor-page-2" +has-more = true + +[feed.page-2] +stories = "@feed.stories" +posts = [ + "@posts.ayla-ferry", + "@posts.june-lookbook", + "@posts.theo-court", + "@posts.kenji-copper", +] +cursor = "cursor-page-3" +has-more = true + +[feed.pages-1-2] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", + "@posts.ayla-ferry", + "@posts.june-lookbook", + "@posts.theo-court", + "@posts.kenji-copper", +] +cursor = "cursor-page-3" +has-more = true + +# Both pages with Lena's like settled — what the demo's pagination reply +# delivers after like-post already settled (whole slices, §9.5). +[feed.pages-1-2-liked] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze-liked", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", + "@posts.ayla-ferry", + "@posts.june-lookbook", + "@posts.theo-court", + "@posts.kenji-copper", +] +cursor = "cursor-page-3" +has-more = true + +# Both pages, like AND comment settled, feed EXHAUSTED — the demo's +# pagination reply (authored truth never regresses mid-walkthrough; the +# end cap renders from `!has-more`, and a further near-end fires into an +# unsatisfied guard — the observation descriptor itself is markup-authored +# and stays in V; the acceptance battery pins the guard rejection). +[feed.pages-1-2-liked-commented] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze-liked-commented", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", + "@posts.ayla-ferry", + "@posts.june-lookbook", + "@posts.theo-court", + "@posts.kenji-copper", +] +has-more = false + +[feed.final] +stories = "@feed.stories" +posts = [ + "@posts.lena-glaze", + "@posts.marco-baja", + "@posts.nils-aurora", + "@posts.priya-starter", + "@posts.ayla-ferry", + "@posts.june-lookbook", + "@posts.theo-court", + "@posts.kenji-copper", +] +has-more = false + +[feed.empty] +stories = ["@stories.ring-mira"] +posts = [] +has-more = false + +# ── comments (comment-thread) ─────────────────────────────────────────── + +[comments.lena-1] +id = "comment-lena-glaze-1" +author = "@users.kenji" +body = "That copper red is unreal. What cone are you firing to?" +posted-label = "1h" + +[comments.lena-2] +id = "comment-lena-glaze-2" +author = "@users.priya" +body = "The third tile down — that speckle! Saving this for glaze inspiration." +posted-label = "1h" + +[comments.lena-3] +id = "comment-lena-glaze-3" +author = "@users.june" +body = "Would buy the whole batch honestly. Seconds sale when?" +posted-label = "45m" + +[comments.lena-4] +id = "comment-lena-glaze-4" +author = "@users.theo" +body = "These would look wild as a court-side mosaic. Collab?" +posted-label = "20m" + +[comments.lena-glaze] +comments = ["@comments.lena-1", "@comments.lena-2", "@comments.lena-3", "@comments.lena-4"] + +# The thread after add-comment settles: the four authored comments plus +# Mira's — the comment-ok script's piggybacked update (§9.4). The last +# entry carries the driver's only two substitutions (§9.5). +[comments.lena-glaze-plus-mira] +comments = [ + "@comments.lena-1", + "@comments.lena-2", + "@comments.lena-3", + "@comments.lena-4", + "@comments.mira-reply", +] + +[comments.empty] +comments = [] + +# Mira's demo comment (§11.2) as the driver's substitution template: +# `@fresh-id` mints the authority's comment id; `@payload.body` echoes the +# typed text — the optimistic row swaps for this atomically (§9.4/§9.5). +[comments.mira-reply] +id = "@fresh-id" +author = "@users.mira" +body = "@payload.body" +posted-label = "now" + +# ── profiles (profile-view) ───────────────────────────────────────────── + +[profiles.lena] +user = "@users.lena" +bio = "Ceramics + slow mornings. Studio in SE Portland. Seconds sale every first Sunday." +posts-label = "214" +followers-label = "18.2k" +following-label = "512" +tagged = [] +posts = [ + { id = "thumb-lena-1", src = "thumb-lena-1", alt = "Copper-red glaze tiles" }, + { id = "thumb-lena-2", src = "thumb-lena-2", alt = "Stack of unglazed bowls" }, + { id = "thumb-lena-3", src = "thumb-lena-3", alt = "Kiln shelf mid-load" }, + { id = "thumb-lena-4", src = "thumb-lena-4", alt = "Celadon test cups" }, + { id = "thumb-lena-5", src = "thumb-lena-5", alt = "Wedging table with fresh clay" }, + { id = "thumb-lena-6", src = "thumb-lena-6", alt = "Iron-speckled dinner plates" }, + { id = "thumb-lena-7", src = "thumb-lena-7", alt = "Throwing a tall cylinder" }, + { id = "thumb-lena-8", src = "thumb-lena-8", alt = "Glaze buckets labelled by cone" }, + { id = "thumb-lena-9", src = "thumb-lena-9", alt = "Morning light across the studio bench" }, +] + +[profiles.mira] +user = "@users.mira" +bio = "Food & travel photographer. Lisbon, mostly. Shooting the small hours." +posts-label = "87" +followers-label = "9.4k" +following-label = "301" +tagged = [] +posts = [ + { id = "thumb-mira-1", src = "thumb-mira-1", alt = "Pastéis de nata on a marble counter" }, + { id = "thumb-mira-2", src = "thumb-mira-2", alt = "Tram rails catching dawn light" }, + { id = "thumb-mira-3", src = "thumb-mira-3", alt = "Market citrus stacked in crates" }, + { id = "thumb-mira-4", src = "thumb-mira-4", alt = "Tiled facade in alternating blues" }, + { id = "thumb-mira-5", src = "thumb-mira-5", alt = "Grilled sardines over coals" }, + { id = "thumb-mira-6", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, +] diff --git a/examples/instagram-uhura/ports/comments.port.toml b/examples/instagram-uhura/ports/comments.port.toml new file mode 100644 index 0000000..387328b --- /dev/null +++ b/examples/instagram-uhura/ports/comments.port.toml @@ -0,0 +1,51 @@ +# The per-post comment thread (design §9.1: keyed projection `for-post` + +# `add-comment`). Ports are separate namespaces: `image-ref`/`user-ref` here +# are this contract's own declarations; cross-port compatibility is +# structural (micro-decision — canonical shapes compare equal). + +[port] +name = "comments" +version = "0.1.0" + +[types.image-ref] +kind = "record" + +[types.image-ref.fields] +src = "asset" +alt = "text" + +[types.user-ref] +kind = "record" + +[types.user-ref.fields] +id = "id" +username = "text" +display-name = "text" +avatar = "image-ref" + +[types.comment] +kind = "record" + +[types.comment.fields] +id = "id" +author = "user-ref" +body = "text" +posted-label = "text" + +[types.comment-thread] +kind = "record" + +[types.comment-thread.fields] +comments = "list<comment>" + +# Keyed by post id: `for-post(post)` (§9.2). +[projections.for-post] +type = "comment-thread" +key = "id" + +[refusals.not-authorized] +[refusals.rate-limited] + +[commands.add-comment] +payload = { post = "id", body = "text" } +refusals = ["not-authorized", "rate-limited"] diff --git a/examples/instagram-uhura/ports/feed.port.toml b/examples/instagram-uhura/ports/feed.port.toml new file mode 100644 index 0000000..f807099 --- /dev/null +++ b/examples/instagram-uhura/ports/feed.port.toml @@ -0,0 +1,103 @@ +# The feed read/write surface (design §9.1 — normative). The canonical-form +# hash of this contract is pinned in uhura.lock; drift is a link error. + +[port] +name = "feed" +version = "0.1.0" + +[types.image-ref] +kind = "record" + +[types.image-ref.fields] +src = "asset" +alt = "text" + +[types.user-ref] +kind = "record" + +[types.user-ref.fields] +id = "id" +username = "text" +display-name = "text" +avatar = "image-ref" + +[types.slide] +kind = "record" + +[types.slide.fields] +id = "id" +src = "asset" +alt = "text" + +[types.story-ring] +kind = "record" + +[types.story-ring.fields] +id = "id" +user = "user-ref" +has-unseen = "bool" +is-self = "bool" + +[types.media] +kind = "union" + +[types.media.variants.image] +image = "image-ref" + +[types.media.variants.carousel] +slides = "list<slide>" + +[types.media.variants.video] +poster = "image-ref" + +[types.post-summary] +kind = "record" + +[types.post-summary.fields] +id = "id" +author = "user-ref" +media = "media" +caption = "text" +like-count = "int" +comment-count = "int" +viewer-has-liked = "bool" +# provider-formatted; core has no clock (§9.1) +posted-label = "text" + +[types.feed-cursor] +kind = "opaque" + +[types.feed-page] +kind = "record" + +[types.feed-page.fields] +stories = "list<story-ring>" +posts = "list<post-summary>" +cursor = "option<feed-cursor>" +has-more = "bool" + +# Delivered before Init; bare reads are legal (§9.2). +[projections.viewer] +type = "user-ref" +boot = true + +[projections.feed-page] +type = "feed-page" + +[refusals.not-authorized] +[refusals.rate-limited] +[refusals.not-found] + +[commands.like-post] +payload = { post = "id" } +refusals = ["not-authorized", "rate-limited", "not-found"] + +[commands.unlike-post] +payload = { post = "id" } +refusals = ["not-authorized", "not-found"] + +[commands.load-next-page] +payload = { cursor = "option<feed-cursor>" } + +[commands.reload] +payload = {} diff --git a/examples/instagram-uhura/ports/profile.port.toml b/examples/instagram-uhura/ports/profile.port.toml new file mode 100644 index 0000000..d9e9cf0 --- /dev/null +++ b/examples/instagram-uhura/ports/profile.port.toml @@ -0,0 +1,48 @@ +# The profile read surface (design §9.1: keyed projection `profile`). +# Read-only in the spike: no commands, so no refusals either. + +[port] +name = "profile" +version = "0.1.0" + +[types.image-ref] +kind = "record" + +[types.image-ref.fields] +src = "asset" +alt = "text" + +[types.user-ref] +kind = "record" + +[types.user-ref.fields] +id = "id" +username = "text" +display-name = "text" +avatar = "image-ref" + +[types.thumb] +kind = "record" + +[types.thumb.fields] +id = "id" +src = "asset" +alt = "text" + +[types.profile-view] +kind = "record" + +[types.profile-view.fields] +user = "user-ref" +bio = "text" +# provider-formatted counts; core does no number formatting +posts-label = "text" +followers-label = "text" +following-label = "text" +posts = "list<thumb>" +tagged = "list<thumb>" + +# Keyed by user id: `profile(user)` (§9.2). +[projections.profile] +type = "profile-view" +key = "id" diff --git a/examples/instagram-uhura/styles/theme.css b/examples/instagram-uhura/styles/theme.css new file mode 100644 index 0000000..15bac68 --- /dev/null +++ b/examples/instagram-uhura/styles/theme.css @@ -0,0 +1,59 @@ +/* Uhura spike theme — the one built-in theme (design §8.5). + Tokens as custom properties; aesthetics are free to drift. */ + +:root { + /* space: 4px scale */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + + /* type scale (body 15px) */ + --type-xs: 12px; + --type-sm: 14px; + --type-md: 15px; + --type-lg: 17px; + --type-xl: 22px; + + /* radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-full: 999px; + + /* ink ramp + surfaces */ + --color-surface: #ffffff; + --color-surface-sunken: #f6f7f8; + --color-ink: #15181d; + --color-ink-subtle: #697077; + --color-ink-faint: #9aa1a9; + --color-line: #e8eaed; + --color-on-media: #ffffff; + + /* the one accent — used only where it means something */ + --color-accent: #ed4956; + --color-accent-ink: #ffffff; + --color-focus: #ed4956; +} + +/* base */ +body { + margin: 0; + font-family: -apple-system, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + font-size: var(--type-md); + color: var(--color-ink); + background: var(--color-surface); +} + +/* shared utilities the slice names in class= */ +.screen { display: flex; flex-direction: column; block-size: 100%; } +.fill-center { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; } +.row-center { display: flex; align-items: center; justify-content: center; padding: var(--space-3); } +.stack-sm { gap: var(--space-2); } +.gap-sm { gap: var(--space-2); } +.pad-md { padding: var(--space-4); } +.pad-lg { padding: var(--space-6); } +.muted { color: var(--color-ink-subtle); } +.title { font-weight: 600; } diff --git a/examples/instagram-uhura/surfaces/comments-sheet.examples.uhura b/examples/instagram-uhura/surfaces/comments-sheet.examples.uhura new file mode 100644 index 0000000..c7a7d8a --- /dev/null +++ b/examples/instagram-uhura/surfaces/comments-sheet.examples.uhura @@ -0,0 +1,25 @@ +use fixture standard + +example populated default { + props { post = "post-lena-glaze" } + projection feed.viewer = fixture.users.mira + projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze +} + +example composing { + from populated + events [ composer-changed(value: "Saving this palette for my kitchen reno") ] + note "composer mid-draft; Post enables once non-empty" +} + +example pending-append { + from composing + events [ submit-requested() ] + note "optimistic dimmed row until the outcome settles" +} + +example empty { + props { post = "post-ayla-ferry" } + projection feed.viewer = fixture.users.mira + projection comments.for-post("post-ayla-ferry") = fixture.comments.empty +} diff --git a/examples/instagram-uhura/surfaces/comments-sheet.uhura b/examples/instagram-uhura/surfaces/comments-sheet.uhura new file mode 100644 index 0000000..7f264a8 --- /dev/null +++ b/examples/instagram-uhura/surfaces/comments-sheet.uhura @@ -0,0 +1,110 @@ +surface comments-sheet modality sheet + +use component comment-row +use port comments { projection for-post, command add-comment } +use port feed { projection viewer } + +props { + post: id +} + +store { + state { + draft: text = "" + pending-appends: map[tag]text = {} + notice: text? = none + } + + on composer-changed(value: text) { + set draft = value + } + + on submit-requested() when draft != "" { + send add-comment(post: post, body: draft) as t + set pending-appends[t] = draft + set draft = "" + } + + on add-comment.ok(tag, cmd) { + set pending-appends[tag] = none + } + + on add-comment.err(tag, cmd, refusal) { + set pending-appends[tag] = none + set draft = cmd.body + set notice = "Couldn't post your comment. Try again." + } + + on dismiss-requested() { + dismiss + } + + on notice-dismissed() { + set notice = none + } +} + +<view class="comments-sheet"> + <view class="sheet-head"> + <text class="sheet-title">Comments</text> + <button label="Close" on:press={emit dismiss-requested()}> + <icon name="close" /> + </button> + </view> + {#if notice != none} + <view class="notice"> + <text class="muted">{notice ?? ""}</text> + <button label="Dismiss notice" on:press={emit notice-dismissed()}> + <icon name="close" /> + </button> + </view> + {/if} + {#match for-post(post)} + {:when loading} + <view class="fill-center"> + <icon name="progress" /> + </view> + {:when failed reason} + <view class="fill-center"> + <text class="muted">Comments couldn't load.</text> + </view> + {:when ready t} + <scroll class="comment-list"> + {#if count(t.comments) == 0} + <view class="fill-center stack-sm"> + <text class="empty-title">No comments yet</text> + <text class="muted">Start the conversation.</text> + </view> + {:else} + <view role="list"> + {#each t.comments as c (c.id)} + <comment-row avatar={c.author.avatar} username={c.author.username} body={c.body} time-label={c.posted-label} pending={false} /> + {/each} + </view> + {/if} + <view role="list"> + {#each pending-appends as pending-tag (pending-tag)} + <comment-row avatar={viewer.avatar} username={viewer.username} body={pending-appends[pending-tag] ?? ""} time-label="Posting…" pending={true} /> + {/each} + </view> + </scroll> + {/match} + <view class="composer"> + <image class="avatar" src={viewer.avatar.src} alt={viewer.avatar.alt} /> + <text-field value={draft} label="Add a comment" placeholder="Add a comment…" on:change={emit composer-changed()} on:submit={emit submit-requested()} /> + <button label="Post" disabled={draft == ""} on:press={emit submit-requested()}> + <text>Post</text> + </button> + </view> +</view> + +<style> +.comments-sheet { display: flex; flex-direction: column; block-size: 100%; } +.comments-sheet .sheet-head { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) var(--space-4); border-block-end: 1px solid var(--color-line); } +.comments-sheet .sheet-title { font-weight: 600; } +.comments-sheet .notice { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) var(--space-4); background: var(--color-surface-sunken); } +.comments-sheet .comment-list { flex: 1; min-block-size: 0; } +.comments-sheet .empty-title { font-weight: 600; } +.comments-sheet .composer { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-4); border-block-start: 1px solid var(--color-line); } +.comments-sheet .composer .avatar { inline-size: 28px; block-size: 28px; border-radius: var(--radius-full); object-fit: cover; } +</style> diff --git a/examples/instagram-uhura/uhura.lock b/examples/instagram-uhura/uhura.lock new file mode 100644 index 0000000..cda0836 --- /dev/null +++ b/examples/instagram-uhura/uhura.lock @@ -0,0 +1,6 @@ +# uhura.lock — canonical contract pins (§9.1). `uhura check` writes this +# file when absent and errors on drift; delete it to re-pin intentionally. +catalog base 0.1.0 sha256:120729b4cd31a07275d76ee2184ae989ed949c6544d1243cb29f42dfd65520d3 +port comments 0.1.0 sha256:51c7928689f79af601788bb003fb7ec7139ca2f927bde43a05c4329f97fc4b11 +port feed 0.1.0 sha256:3aca73f8d88096fb23ba5c1aa3c02ef44443ac775cfd2fe9953da8dad0b42ce6 +port profile 0.1.0 sha256:99a41a60d0fd23baa07f67ecafd1eb96a7bb63ac7d9ec55c180477efe7783366 diff --git a/examples/instagram-uhura/uhura.toml b/examples/instagram-uhura/uhura.toml new file mode 100644 index 0000000..96ba7a1 --- /dev/null +++ b/examples/instagram-uhura/uhura.toml @@ -0,0 +1,25 @@ +# App manifest (design §3): entry route, catalog pin, port bindings, +# fixtures, and play profiles. Paths are corpus-relative. + +[app] +name = "instagram" +entry = "feed" + +[catalog] +path = "catalog/base.toml" + +[ports] +feed = "ports/feed.port.toml" +comments = "ports/comments.port.toml" +profile = "ports/profile.port.toml" + +[fixtures] +standard = "fixtures/standard.toml" + +[assets] +manifest = "fixtures/assets/manifest.toml" + +# `uhura dev`/`uhura trace` profiles: which fixture data + script to drive. +[play.default] +fixture = "standard" +script = "demo" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..00f6774 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# Pinned to match the nested uhura workspace (uhura/rust-toolchain.toml) so the +# two co-located Cargo workspaces build on one Rust version. Bumping is a +# deliberate commit; bump both files together. +channel = "1.92.0" +components = ["rustfmt", "clippy"] diff --git a/uhura/.gitignore b/uhura/.gitignore new file mode 100644 index 0000000..453db70 --- /dev/null +++ b/uhura/.gitignore @@ -0,0 +1,4 @@ +/target +/renders +/crates/uhura-wasm/pkg +.DS_Store diff --git a/uhura/Cargo.lock b/uhura/Cargo.lock new file mode 100644 index 0000000..18b148e --- /dev/null +++ b/uhura/Cargo.lock @@ -0,0 +1,612 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uhura-base" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "uhura-check" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "uhura-base", + "uhura-core", + "uhura-port", + "uhura-syntax", +] + +[[package]] +name = "uhura-cli" +version = "0.0.0" +dependencies = [ + "image", + "serde_json", + "tiny_http", + "toml", + "uhura-base", + "uhura-check", + "uhura-core", + "uhura-fixture", + "uhura-port", + "uhura-project", + "uhura-syntax", +] + +[[package]] +name = "uhura-core" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "uhura-base", + "uhura-port", +] + +[[package]] +name = "uhura-fixture" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "uhura-base", + "uhura-port", +] + +[[package]] +name = "uhura-port" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "toml", + "uhura-base", +] + +[[package]] +name = "uhura-project" +version = "0.0.0" +dependencies = [ + "serde_json", + "uhura-base", + "uhura-core", +] + +[[package]] +name = "uhura-syntax" +version = "0.0.0" +dependencies = [ + "uhura-base", +] + +[[package]] +name = "uhura-tests" +version = "0.0.0" +dependencies = [ + "cargo_metadata", + "serde_json", + "uhura-base", + "uhura-check", + "uhura-cli", + "uhura-core", + "uhura-fixture", + "uhura-port", + "uhura-project", + "uhura-syntax", +] + +[[package]] +name = "uhura-wasm" +version = "0.0.0" +dependencies = [ + "serde_json", + "uhura-base", + "uhura-check", + "uhura-cli", + "uhura-core", + "uhura-fixture", + "uhura-port", + "wasm-bindgen", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/uhura/Cargo.toml b/uhura/Cargo.toml new file mode 100644 index 0000000..5a84f4b --- /dev/null +++ b/uhura/Cargo.toml @@ -0,0 +1,36 @@ +# The Uhura spike workspace. Independently buildable: no pnpm/node/NCC +# coupling (RFC 0001 §9). Design: docs/working-group/instagram-spike-design.md. +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "0.0.0" +edition = "2024" +publish = false + +[workspace.dependencies] +# External — pinned here, exact versions locked by the committed Cargo.lock. +# serde_json: NEVER enable "preserve_order" — canonical JSON (sorted keys) +# depends on its default BTreeMap-backed maps (design §12, risk #2). +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tiny_http = "0.12" +toml = "0.8" +wasm-bindgen = "0.2" +cargo_metadata = "0.19" +image = { version = "0.25", default-features = false, features = ["jpeg"] } + +# Internal +uhura-base = { path = "crates/uhura-base" } +uhura-syntax = { path = "crates/uhura-syntax" } +uhura-port = { path = "crates/uhura-port" } +uhura-check = { path = "crates/uhura-check" } +uhura-core = { path = "crates/uhura-core" } +uhura-fixture = { path = "crates/uhura-fixture" } +uhura-project = { path = "crates/uhura-project" } +uhura-cli = { path = "crates/uhura-cli" } + +[workspace.lints.rust] +unsafe_code = "forbid" diff --git a/uhura/README.md b/uhura/README.md new file mode 100644 index 0000000..e95a4f0 --- /dev/null +++ b/uhura/README.md @@ -0,0 +1,99 @@ +# Uhura + +**Uhura is an incubating declarative UI language and deterministic headless +experience runtime.** + +Uhura defines what an interface presents, the non-authoritative UI-session +state that drives it, and how semantic events advance that state. Its core +runtime evaluates a checked program into a renderer-neutral semantic view and +emits typed commands or platform intents. It does not lay out or paint pixels, +perform I/O, or own authoritative product truth. + +The project is an incubating spike: a Rust workspace under `crates/` +implements the checker, core machine, fixture driver, static canvas, wasm +session, play shell (`shell/`), and `uhura` CLI, exercised end to end by the +Instagram slice at the repo-root `examples/instagram-uhura/` (distinct from +Spock's `examples/instagram/` until the two are actually wired). The design +doc (`docs/working-group/instagram-spike-design.md`) is authoritative; there +is no accepted grammar freeze, package, or compatibility promise yet. + +Quick tour (run from this `uhura/` workspace): `cargo run -p uhura-cli -- +check ../examples/instagram-uhura`, `… project ../examples/instagram-uhura` +(static canvas), `… trace ../examples/instagram-uhura --script=like-refused +--expanded` (headless machine), and `scripts/build-wasm.sh && cargo run -p +uhura-cli -- dev ../examples/instagram-uhura` (live play shell at +http://127.0.0.1:8787/). `cargo test --workspace` runs the +golden suites plus the design's §13 acceptance battery +(`crates/uhura-tests/tests/acceptance_feed.rs`); the battery's native↔wasm +parity criterion runs when `node` and the wasm package are present and is +reported as skipped otherwise (`UHURA_REQUIRE_PARITY=1` makes that a +failure). + +## Project position + +Uhura is a greenfield proposed successor to the Frame workstream. It is not a +rename, source-compatible version, or incremental extension of Frame XML. +Existing Frame and Wire v4 documents remain historical and migration inputs; +they do not constrain Uhura's eventual grammar or ABI. + +Uhura is a sibling of [Spock](https://github.com/gridaco/spock), not a child of +it: + +- **Spock** specifies and executes authoritative backend state and guarded + product behavior. +- **Uhura** specifies and executes non-authoritative interface-session state + and experience behavior. +- **NCC** is the human-facing orchestration layer that authors, links, checks, + visualizes, and plays their composition. + +The shorthand `NCC = Spock + Uhura` is directional, not a literal ownership +equation. NCC adds the composition, scenario, provenance, canvas, diagnostic, +and playback layers; it must not redefine either language's semantics. + +## Founding boundary + +> Uhura owns UI semantics and the headless UI-state mechanic. Spock owns +> authoritative product semantics. A concrete renderer owns platform rendering. +> NCC owns their checked composition. + +No fact may be authoritative in both Uhura and Spock. + +The practical ownership test is: + +> If discarding the UI session could change product truth, authorization, a +> transaction, or another client's reality, the state belongs to Spock. If it +> coordinates one experience and can be reconstructed without corrupting that +> truth, it belongs to Uhura. + +## Repository status + +`uhura/` lives in the Spock repository as a sibling workspace — Spock is +Uhura's canonical provider, and the two languages are developed side by side +(see [RFD 0022](../docs/rfd/0022-uhura-the-client-language.md)). The port seam +stays provider-neutral. NCC remains the human-facing orchestration layer +consuming both languages from its own repository. + +Until an implementation RFC says otherwise: + +- this directory is a nested Cargo workspace, not a member of the host + workspace; +- it has no dependency on Spock or NCC internals; +- its example corpus lives at the repo-root `../examples/instagram-uhura/` + (shared `examples/` location); the workspace is therefore no longer + copy-extractable on its own — extracting it means carrying that corpus too; +- it publishes no package or executable; +- Rust is the preferred initial core implementation direction, not yet a + normative language requirement; and +- it is licensed under the repository's MIT license; package names, + compatibility, and release policy remain open. + +Do not add a nested `.git` directory or couple Uhura source to the host +workspace's crates. Cross-project integration must go through versioned +contracts and conformance fixtures. + +## Documentation + +- [Documentation index and authority](docs/README.md) +- [Living master specification](docs/spec/README.md) +- [RFC 0001: project foundation](docs/rfcs/0001-project-foundation.md) +- [Working group](docs/working-group/README.md) diff --git a/uhura/crates/uhura-base/Cargo.toml b/uhura/crates/uhura-base/Cargo.toml new file mode 100644 index 0000000..00fc0cc --- /dev/null +++ b/uhura/crates/uhura-base/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "uhura-base" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/uhura/crates/uhura-base/clippy.toml b/uhura/crates/uhura-base/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-base/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-base/src/canonical.rs b/uhura/crates/uhura-base/src/canonical.rs new file mode 100644 index 0000000..4b1d920 --- /dev/null +++ b/uhura/crates/uhura-base/src/canonical.rs @@ -0,0 +1,118 @@ +//! The single canonical-JSON + SHA-256 choke point (design §7.5, risk #2). +//! +//! Everything that is hashed or golden-tested — checked IR, V snapshots, +//! traces — serializes through here: UTF-8, lexicographically sorted keys, +//! minimal escapes (serde_json's), integers only, compact (no whitespace), +//! no trailing newline (callers add LF where a file format wants it). + +use std::fmt::Write as _; + +use sha2::{Digest, Sha256}; + +/// Renders a `serde_json::Value` to canonical form. Panics (debug) on any +/// non-integer number — floats are unrepresentable in the Uhura value model +/// and must never reach a hash. +pub fn to_canonical_json(v: &serde_json::Value) -> String { + let mut out = String::new(); + write_canonical(v, &mut out); + out +} + +fn write_canonical(v: &serde_json::Value, out: &mut String) { + use serde_json::Value as J; + match v { + J::Null => out.push_str("null"), + J::Bool(b) => out.push_str(if *b { "true" } else { "false" }), + J::Number(n) => { + debug_assert!( + n.is_i64() || n.is_u64(), + "float reached canonical JSON: {n} — the value model has no floats (§7.5)" + ); + let _ = write!(out, "{n}"); + } + J::String(s) => { + // serde_json's escaping is the canonical escaping. + let _ = write!(out, "{}", J::String(s.clone())); + } + J::Array(xs) => { + out.push('['); + for (i, x) in xs.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(x, out); + } + out.push(']'); + } + J::Object(fields) => { + // serde_json's default Map is BTree-backed (preserve_order is + // banned in the workspace manifest), but sort defensively so a + // dependency change can never silently reorder hashes. + let mut keys: Vec<&String> = fields.keys().collect(); + keys.sort_unstable(); + out.push('{'); + for (i, k) in keys.into_iter().enumerate() { + if i > 0 { + out.push(','); + } + let _ = write!(out, "{}", serde_json::Value::String(k.clone())); + out.push(':'); + write_canonical(&fields[k], out); + } + out.push('}'); + } + } +} + +/// SHA-256 of `bytes`, lowercase hex. +pub fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for b in digest { + let _ = write!(out, "{b:02x}"); + } + out +} + +/// Convenience: canonical JSON of `v`, hashed. +pub fn hash_json(v: &serde_json::Value) -> String { + sha256_hex(to_canonical_json(v).as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn sorts_keys_and_compacts() { + let v = json!({"b": [1, 2], "a": {"z": null, "m": "x"}}); + assert_eq!( + to_canonical_json(&v), + r#"{"a":{"m":"x","z":null},"b":[1,2]}"# + ); + } + + #[test] + fn escapes_via_serde() { + let v = json!({"k": "line\n\"quote\""}); + assert_eq!(to_canonical_json(&v), r#"{"k":"line\n\"quote\""}"#); + } + + #[test] + fn known_sha256() { + // sha256("{}") — pinned so the hash implementation can never drift. + assert_eq!( + sha256_hex(b"{}"), + "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ); + } + + #[test] + #[should_panic(expected = "float reached canonical JSON")] + #[cfg(debug_assertions)] + fn floats_panic_in_debug() { + let v = json!(1.5); + let _ = to_canonical_json(&v); + } +} diff --git a/uhura/crates/uhura-base/src/codes.rs b/uhura/crates/uhura-base/src/codes.rs new file mode 100644 index 0000000..6003739 --- /dev/null +++ b/uhura/crates/uhura-base/src/codes.rs @@ -0,0 +1,128 @@ +//! The UHnxxx diagnostic code registry (plan micro-decision #5). +//! +//! Blocks by pipeline pass: +//! - UH0xxx — lex/parse (incl. bounds) +//! - UH1xxx — routes / resolution / imports +//! - UH2xxx — catalog / ports / lock +//! - UH3xxx — types / expressions +//! - UH4xxx — machine (statements, handlers) +//! - UH5xxx — markup rules +//! - UH6xxx — style +//! - UH7xxx — examples +//! - UH8xxx — runtime (minted by core, appear in `G`/traces) +//! - UH9xxx — internal invariants +//! +//! Every constant pairs the stable code with its human `rule` slug. Codes +//! are appended, never renumbered; each new pass adds its block here so +//! collisions are impossible. + +/// `(code, rule)` pair type for registry entries. +pub type Code = (&'static str, &'static str); + +// ── UH0xxx: lex/parse ────────────────────────────────────────────────────── +pub const UNEXPECTED_TOKEN: Code = ("UH0001", "syntax/unexpected-token"); +pub const UNTERMINATED_STRING: Code = ("UH0002", "syntax/unterminated-string"); +pub const UNKEYED_EACH: Code = ("UH0003", "syntax/unkeyed-each"); +pub const UNCLOSED_TAG: Code = ("UH0004", "syntax/unclosed-tag"); +pub const UNCLOSED_BLOCK: Code = ("UH0005", "syntax/unclosed-block"); +pub const RAW_BRACE_IN_TEXT: Code = ("UH0006", "syntax/raw-brace-in-text"); +pub const INVALID_IDENT: Code = ("UH0007", "syntax/invalid-identifier"); +pub const FILE_TOO_LARGE: Code = ("UH0010", "bounds/file-too-large"); +pub const NESTING_TOO_DEEP: Code = ("UH0011", "bounds/nesting-too-deep"); +pub const TOO_MANY_NODES: Code = ("UH0012", "bounds/too-many-view-nodes"); +pub const TOO_MANY_HANDLERS: Code = ("UH0013", "bounds/too-many-handlers"); +pub const MISPLACED_SECTION: Code = ("UH0014", "syntax/misplaced-section"); +pub const INVALID_STYLE_BLOCK: Code = ("UH0015", "syntax/invalid-style-block"); + +// ── UH1xxx: routes / resolution / imports ────────────────────────────────── +pub const BAD_PAGE_PATH: Code = ("UH1001", "routes/bad-page-path"); +pub const ROUTE_COLLISION: Code = ("UH1002", "routes/route-collision"); +pub const HEADER_BASENAME_MISMATCH: Code = ("UH1003", "resolve/header-basename-mismatch"); +pub const WRONG_DIRECTORY: Code = ("UH1004", "resolve/wrong-directory"); +pub const UNKNOWN_IMPORT: Code = ("UH1005", "resolve/unknown-import"); +pub const IMPORT_CYCLE: Code = ("UH1006", "resolve/import-cycle"); +pub const SHADOWED_NAME: Code = ("UH1007", "resolve/shadowed-name"); +pub const DUPLICATE_IMPORT: Code = ("UH1008", "resolve/duplicate-import"); +pub const PARAM_ROUTE_MISMATCH: Code = ("UH1009", "resolve/param-route-mismatch"); +pub const MISPLACED_DECLARATION: Code = ("UH1010", "resolve/misplaced-declaration"); +pub const ENTRY_ROUTE_MISSING: Code = ("UH1011", "resolve/entry-route-missing"); +pub const ORPHAN_EXAMPLES_FILE: Code = ("UH1012", "resolve/orphan-examples-file"); + +// ── UH2xxx: catalog / ports / manifest / lock ────────────────────────────── +pub const INVALID_MANIFEST: Code = ("UH2001", "contract/invalid-manifest"); +pub const INVALID_CATALOG: Code = ("UH2002", "contract/invalid-catalog"); +pub const INVALID_PORT_CONTRACT: Code = ("UH2003", "contract/invalid-port-contract"); +pub const UNKNOWN_PORT: Code = ("UH2004", "contract/unknown-port"); +pub const UNKNOWN_PORT_ITEM: Code = ("UH2005", "contract/unknown-port-item"); +pub const PORT_NAME_COLLISION: Code = ("UH2006", "contract/port-name-collision"); +pub const LOCK_DRIFT: Code = ("UH2007", "contract/lock-drift"); +pub const PORT_NAME_MISMATCH: Code = ("UH2008", "contract/port-name-mismatch"); + +// ── UH3xxx: types / expressions ──────────────────────────────────────────── +pub const TYPE_MISMATCH: Code = ("UH3001", "types/type-mismatch"); +pub const UNKNOWN_FIELD: Code = ("UH3002", "types/unknown-field"); +pub const UNRESOLVED_NAME: Code = ("UH3003", "types/unresolved-name"); +pub const WRONG_ARGS: Code = ("UH3004", "types/wrong-arguments"); +pub const BAD_INDEX: Code = ("UH3005", "types/bad-index"); +pub const BAD_OPERAND: Code = ("UH3006", "types/bad-operand"); +pub const BAD_STATE_TYPE: Code = ("UH3007", "types/bad-state-type"); +pub const BAD_BUILTIN_CALL: Code = ("UH3008", "types/bad-builtin-call"); +pub const UNGUARDED_PROJECTION_READ: Code = ("UH3009", "types/unguarded-projection-read"); +pub const BAD_MAP_KEY: Code = ("UH3010", "types/bad-map-key"); + +// ── UH4xxx: machine (statements, handlers) ───────────────────────────────── +pub const UNKNOWN_ROUTE: Code = ("UH4001", "machine/unknown-route"); +pub const DISMISS_OUTSIDE_SURFACE: Code = ("UH4002", "machine/dismiss-outside-surface"); +pub const UNKNOWN_SURFACE: Code = ("UH4003", "machine/unknown-surface"); +pub const UNKNOWN_COMMAND: Code = ("UH4004", "machine/unknown-command"); +pub const UNREACHABLE_HANDLER: Code = ("UH4005", "machine/unreachable-handler"); +pub const HANDLER_SIGNATURE_MISMATCH: Code = ("UH4006", "machine/handler-signature-mismatch"); +pub const BAD_OUTCOME_SIGNATURE: Code = ("UH4007", "machine/bad-outcome-signature"); +pub const MULTIPLE_NAVIGATES: Code = ("UH4008", "machine/multiple-navigates"); +pub const STORE_NOT_ALLOWED: Code = ("UH4009", "machine/store-not-allowed"); +pub const DUPLICATE_STATE_FIELD: Code = ("UH4010", "machine/duplicate-state-field"); + +// ── UH5xxx: markup rules ─────────────────────────────────────────────────── +pub const UNKNOWN_ELEMENT: Code = ("UH5001", "markup/unknown-element"); +pub const EVENT_NOT_DECLARED: Code = ("UH5002", "markup/event-not-declared"); +pub const UNDECLARED_EMIT: Code = ("UH5003", "markup/undeclared-emit"); +pub const MISSING_REQUIRED_PROP: Code = ("UH5004", "markup/missing-required-prop"); +pub const UNKNOWN_PROP: Code = ("UH5005", "markup/unknown-prop"); +pub const BAD_CHILDREN: Code = ("UH5006", "markup/bad-children"); +pub const NESTED_INTERACTIVE: Code = ("UH5007", "markup/nested-interactive"); +pub const CONTROLLED_PROMOTION: Code = ("UH5008", "markup/controlled-promotion"); +pub const A11Y_ALT: Code = ("UH5009", "markup/a11y-alt"); +pub const LIST_NEEDS_KEYED_EACH: Code = ("UH5010", "markup/list-needs-keyed-each"); +pub const ONE_ROOT: Code = ("UH5011", "markup/one-root"); +pub const INTERP_OUTSIDE_TEXT: Code = ("UH5012", "markup/interpolation-outside-text"); +pub const UNHANDLED_EVENT: Code = ("UH5013", "markup/unhandled-event"); +pub const DUPLICATE_ATTR: Code = ("UH5014", "markup/duplicate-attribute"); +pub const BAD_AVAILABILITY_ARMS: Code = ("UH5015", "markup/bad-availability-arms"); +pub const BAD_UNION_ARMS: Code = ("UH5016", "markup/bad-union-arms"); +pub const UNKNOWN_ICON: Code = ("UH5017", "markup/unknown-icon"); +pub const CARRIED_FIELD_NAMED: Code = ("UH5018", "markup/carried-field-named"); +pub const ELEMENT_EVENT_NEEDS_EMIT: Code = ("UH5019", "markup/element-event-needs-emit"); +pub const SUPPLEMENTARY_UNREACHABLE: Code = ("UH5020", "markup/supplementary-unreachable"); + +// ── UH6xxx: style ────────────────────────────────────────────────────────── +pub const CLASS_ROOTING: Code = ("UH6001", "style/class-rooting"); +pub const UNDEFINED_CLASS: Code = ("UH6002", "style/undefined-class"); + +// ── UH7xxx: examples ─────────────────────────────────────────────────────── +pub const ILLEGAL_CLAUSE: Code = ("UH7001", "examples/illegal-clause"); +pub const MULTIPLE_DEFAULTS: Code = ("UH7002", "examples/multiple-defaults"); +pub const NO_DEFAULT: Code = ("UH7003", "examples/no-default"); +pub const BAD_FROM: Code = ("UH7004", "examples/bad-from"); +pub const UNKNOWN_PIN_TARGET: Code = ("UH7005", "examples/unknown-pin-target"); +pub const BAD_PIN: Code = ("UH7006", "examples/bad-pin"); +pub const UNKNOWN_FIXTURE: Code = ("UH7007", "examples/unknown-fixture"); +pub const BAD_EXAMPLE_EVENT: Code = ("UH7008", "examples/bad-example-event"); +pub const PIN_DECODE: Code = ("UH7009", "examples/pin-decode"); +pub const BOOT_UNBOUND: Code = ("UH7010", "examples/boot-unbound"); +pub const MULTIPLE_FIXTURES: Code = ("UH7011", "examples/multiple-fixtures"); +pub const REPLAY_STEP: Code = ("UH7012", "examples/replay-step"); +pub const REPLAY_BLOCKED: Code = ("UH7013", "examples/blocked-by-ancestor"); + +// ── UH8xxx: runtime (minted by core, appear in `G`/traces) ───────────────── +pub const DUPLICATE_IN_FLIGHT: Code = ("UH8001", "runtime/duplicate-in-flight-send"); +pub const INVALID_FIXTURE: Code = ("UH2009", "contract/invalid-fixture"); diff --git a/uhura/crates/uhura-base/src/diagnostic.rs b/uhura/crates/uhura-base/src/diagnostic.rs new file mode 100644 index 0000000..6714f1d --- /dev/null +++ b/uhura/crates/uhura-base/src/diagnostic.rs @@ -0,0 +1,133 @@ +//! Structured diagnostics (design §12.4): stable `UHnxxx` code + human +//! `rule` slug, primary span, labeled secondary spans, notes, and an +//! optional mechanical fix (emitted only when the edit is safe). + +use crate::span::Span; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum Severity { + Info, + Warning, + Error, +} + +impl Severity { + pub fn as_str(&self) -> &'static str { + match self { + Severity::Info => "info", + Severity::Warning => "warning", + Severity::Error => "error", + } + } +} + +/// A secondary span with its own message (possibly in another file). +#[derive(Clone, Debug)] +pub struct Label { + pub span: Span, + pub message: String, +} + +/// One text edit of a fix; an empty span is a pure insertion. +#[derive(Clone, Debug)] +pub struct Edit { + pub span: Span, + pub insert: String, +} + +/// A safe, mechanical repair. Never emitted for semantic choices +/// (e.g. "wrap in region" is a note, not a fix — design §4.8). +#[derive(Clone, Debug)] +pub struct Fix { + pub title: String, + pub edits: Vec<Edit>, +} + +#[derive(Clone, Debug)] +pub struct Diagnostic { + /// Stable registry code, e.g. `UH0301`. + pub code: &'static str, + /// Human-readable rule slug, e.g. `markup/unkeyed-each`. + pub rule: &'static str, + pub severity: Severity, + pub message: String, + /// Primary location. + pub span: Span, + pub labels: Vec<Label>, + pub notes: Vec<String>, + pub fix: Option<Fix>, +} + +impl Diagnostic { + pub fn new( + code: &'static str, + rule: &'static str, + severity: Severity, + message: impl Into<String>, + span: Span, + ) -> Self { + Diagnostic { + code, + rule, + severity, + message: message.into(), + span, + labels: Vec::new(), + notes: Vec::new(), + fix: None, + } + } + + pub fn error( + code: &'static str, + rule: &'static str, + message: impl Into<String>, + span: Span, + ) -> Self { + Self::new(code, rule, Severity::Error, message, span) + } + + pub fn warning( + code: &'static str, + rule: &'static str, + message: impl Into<String>, + span: Span, + ) -> Self { + Self::new(code, rule, Severity::Warning, message, span) + } + + pub fn info( + code: &'static str, + rule: &'static str, + message: impl Into<String>, + span: Span, + ) -> Self { + Self::new(code, rule, Severity::Info, message, span) + } + + pub fn with_label(mut self, span: Span, message: impl Into<String>) -> Self { + self.labels.push(Label { + span, + message: message.into(), + }); + self + } + + pub fn with_note(mut self, note: impl Into<String>) -> Self { + self.notes.push(note.into()); + self + } + + pub fn with_fix(mut self, title: impl Into<String>, edits: Vec<Edit>) -> Self { + self.fix = Some(Fix { + title: title.into(), + edits, + }); + self + } +} + +/// True if any diagnostic is an error (the "lowering is gated" predicate). +pub fn has_errors(diags: &[Diagnostic]) -> bool { + diags.iter().any(|d| d.severity == Severity::Error) +} diff --git a/uhura/crates/uhura-base/src/envelope.rs b/uhura/crates/uhura-base/src/envelope.rs new file mode 100644 index 0000000..3a1b0fe --- /dev/null +++ b/uhura/crates/uhura-base/src/envelope.rs @@ -0,0 +1,153 @@ +//! The `uhura-diagnostics/0` JSON envelope — the one stable shape NCC and +//! editors integrate against (design §12.4) — plus a plain-text renderer +//! for terminals. + +use crate::diagnostic::{Diagnostic, Severity}; +use crate::span::{SourceMap, Span}; + +fn span_json(sm: &SourceMap, span: Span) -> serde_json::Value { + let start = sm.line_col(span.file, span.start); + let end = sm.line_col(span.file, span.end); + serde_json::json!({ + "offset": span.start, + "len": span.len(), + "start": { "line": start.line, "col": start.col }, + "end": { "line": end.line, "col": end.col }, + }) +} + +/// Serializes diagnostics to the versioned envelope. +pub fn to_envelope(diags: &[Diagnostic], sm: &SourceMap) -> serde_json::Value { + let errors = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .count(); + let warnings = diags + .iter() + .filter(|d| d.severity == Severity::Warning) + .count(); + + let list: Vec<serde_json::Value> = diags + .iter() + .map(|d| { + let mut obj = serde_json::json!({ + "code": d.code, + "rule": d.rule, + "severity": d.severity.as_str(), + "message": d.message, + "file": sm.path(d.span.file), + "span": span_json(sm, d.span), + }); + if !d.labels.is_empty() { + obj["labels"] = d + .labels + .iter() + .map(|l| { + serde_json::json!({ + "file": sm.path(l.span.file), + "span": span_json(sm, l.span), + "message": l.message, + }) + }) + .collect(); + } + if !d.notes.is_empty() { + obj["notes"] = serde_json::json!(d.notes); + } + if let Some(fix) = &d.fix { + obj["fix"] = serde_json::json!({ + "title": fix.title, + "edits": fix.edits.iter().map(|e| serde_json::json!({ + "file": sm.path(e.span.file), + "offset": e.span.start, + "len": e.span.len(), + "insert": e.insert, + })).collect::<Vec<_>>(), + }); + } + obj + }) + .collect(); + + serde_json::json!({ + "format": "uhura-diagnostics", + "version": 0, + "summary": { "errors": errors, "warnings": warnings }, + "diagnostics": list, + }) +} + +/// Human-readable terminal rendering (one block per diagnostic). +pub fn render_text(diags: &[Diagnostic], sm: &SourceMap) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + for d in diags { + let lc = sm.line_col(d.span.file, d.span.start); + let _ = writeln!( + out, + "{}[{} {}]: {}", + d.severity.as_str(), + d.code, + d.rule, + d.message + ); + let _ = writeln!(out, " --> {}:{}:{}", sm.path(d.span.file), lc.line, lc.col); + for l in &d.labels { + let llc = sm.line_col(l.span.file, l.span.start); + let _ = writeln!( + out, + " = {} ({}:{}:{})", + l.message, + sm.path(l.span.file), + llc.line, + llc.col + ); + } + for n in &d.notes { + let _ = writeln!(out, " note: {n}"); + } + if let Some(fix) = &d.fix { + let _ = writeln!(out, " fix: {}", fix.title); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diagnostic::Edit; + use crate::span::SourceMap; + + #[test] + fn envelope_shape() { + let mut sm = SourceMap::new(); + let f = sm.add( + "components/post-card.uhura", + "component post-card\n<view>\n", + ); + let d = Diagnostic::error( + "UH0301", + "markup/unkeyed-each", + "`{#each}` has no key", + Span::new(f, 20, 26), + ) + .with_note("repeated content requires a stable key") + .with_fix( + "add a key", + vec![Edit { + span: Span::new(f, 26, 26), + insert: " (s.id)".into(), + }], + ); + let env = to_envelope(&[d], &sm); + assert_eq!(env["format"], "uhura-diagnostics"); + assert_eq!(env["version"], 0); + assert_eq!(env["summary"]["errors"], 1); + let diag = &env["diagnostics"][0]; + assert_eq!(diag["code"], "UH0301"); + assert_eq!(diag["file"], "components/post-card.uhura"); + assert_eq!(diag["span"]["start"]["line"], 2); + assert_eq!(diag["fix"]["edits"][0]["offset"], 26); + } +} diff --git a/uhura/crates/uhura-base/src/lib.rs b/uhura/crates/uhura-base/src/lib.rs new file mode 100644 index 0000000..be18405 --- /dev/null +++ b/uhura/crates/uhura-base/src/lib.rs @@ -0,0 +1,19 @@ +//! uhura-base: the foundation every crate shares — the deterministic value +//! model (`Value`, `Ident`), the single canonical-JSON + SHA-256 choke point +//! (design §7.5), spans and source maps, structured diagnostics, the UHnxxx +//! code registry, and the `uhura-diagnostics/0` envelope (design §12.4). +//! No floats exist in the value model (design §7.1). +#![deny(clippy::float_arithmetic)] + +mod canonical; +pub mod codes; +mod diagnostic; +mod envelope; +mod span; +mod value; + +pub use canonical::{hash_json, sha256_hex, to_canonical_json}; +pub use diagnostic::{Diagnostic, Edit, Fix, Label, Severity, has_errors}; +pub use envelope::{render_text, to_envelope}; +pub use span::{FileId, LineCol, SourceMap, Span}; +pub use value::{Ident, IdentError, Value}; diff --git a/uhura/crates/uhura-base/src/span.rs b/uhura/crates/uhura-base/src/span.rs new file mode 100644 index 0000000..a2c814e --- /dev/null +++ b/uhura/crates/uhura-base/src/span.rs @@ -0,0 +1,132 @@ +//! Byte-offset spans and the source map that resolves them to line/col. + +/// Index into the [`SourceMap`]'s file table. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct FileId(pub u32); + +/// A `[start, end)` byte range in one source file. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Span { + pub file: FileId, + pub start: u32, + pub end: u32, +} + +impl Span { + pub fn new(file: FileId, start: u32, end: u32) -> Self { + debug_assert!(start <= end); + Span { file, start, end } + } + + pub fn len(&self) -> u32 { + self.end - self.start + } + + pub fn is_empty(&self) -> bool { + self.start == self.end + } + + /// The smallest span covering both `self` and `other` (same file). + pub fn to(&self, other: Span) -> Span { + debug_assert_eq!(self.file, other.file); + Span::new( + self.file, + self.start.min(other.start), + self.end.max(other.end), + ) + } +} + +/// 1-based line and character column. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct LineCol { + pub line: u32, + pub col: u32, +} + +struct SourceFile { + path: String, + text: String, + /// Byte offset of the start of each line; `line_starts[0] == 0`. + line_starts: Vec<u32>, +} + +/// Registered in-memory sources; pure (the CLI does the file reading). +#[derive(Default)] +pub struct SourceMap { + files: Vec<SourceFile>, +} + +impl SourceMap { + pub fn new() -> Self { + Self::default() + } + + /// Registers a source and returns its id. `path` is corpus-relative. + pub fn add(&mut self, path: impl Into<String>, text: impl Into<String>) -> FileId { + let text = text.into(); + let mut line_starts = vec![0u32]; + for (i, b) in text.bytes().enumerate() { + if b == b'\n' { + line_starts.push(i as u32 + 1); + } + } + self.files.push(SourceFile { + path: path.into(), + text, + line_starts, + }); + FileId(self.files.len() as u32 - 1) + } + + pub fn path(&self, file: FileId) -> &str { + &self.files[file.0 as usize].path + } + + pub fn text(&self, file: FileId) -> &str { + &self.files[file.0 as usize].text + } + + /// Resolves a byte offset to 1-based line and character column. + pub fn line_col(&self, file: FileId, offset: u32) -> LineCol { + let f = &self.files[file.0 as usize]; + let line_idx = match f.line_starts.binary_search(&offset) { + Ok(i) => i, + Err(i) => i - 1, + }; + let line_start = f.line_starts[line_idx] as usize; + let upto = &f.text[line_start..(offset as usize).min(f.text.len())]; + LineCol { + line: line_idx as u32 + 1, + col: upto.chars().count() as u32 + 1, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn line_col_resolution() { + let mut sm = SourceMap::new(); + let f = sm.add("a.uhura", "ab\ncdé f\n"); + assert_eq!(sm.line_col(f, 0), LineCol { line: 1, col: 1 }); + assert_eq!(sm.line_col(f, 3), LineCol { line: 2, col: 1 }); + // 'é' is 2 bytes; offset 7 points at ' ' after "cdé" = col 4. + assert_eq!(sm.line_col(f, 7), LineCol { line: 2, col: 4 }); + // Offset 9 is the newline itself — still line 2. Line 3 starts at 10 + // (the EOF position). + assert_eq!(sm.line_col(f, 9), LineCol { line: 2, col: 6 }); + assert_eq!(sm.line_col(f, 10), LineCol { line: 3, col: 1 }); + } + + #[test] + fn span_join() { + let f = FileId(0); + assert_eq!( + Span::new(f, 2, 4).to(Span::new(f, 8, 9)), + Span::new(f, 2, 9) + ); + } +} diff --git a/uhura/crates/uhura-base/src/value.rs b/uhura/crates/uhura-base/src/value.rs new file mode 100644 index 0000000..a7c33c6 --- /dev/null +++ b/uhura/crates/uhura-base/src/value.rs @@ -0,0 +1,164 @@ +//! The deterministic value model shared by state fields, props, event +//! payloads, command payloads, projections, and traces (design §7.1). +//! +//! Deliberately absent: floats (design §7.5 — determinism by type shape), +//! unordered containers (`Record` is a `BTreeMap`). + +use std::collections::BTreeMap; +use std::fmt; + +/// A validated lowercase kebab-case identifier: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Ident(String); + +impl Ident { + /// Validates and interns a kebab-case identifier. + pub fn new(s: &str) -> Result<Self, IdentError> { + if s.is_empty() || s.len() > 64 { + return Err(IdentError(s.to_string())); + } + let bytes = s.as_bytes(); + if !bytes[0].is_ascii_lowercase() { + return Err(IdentError(s.to_string())); + } + let mut prev_dash = false; + for &b in &bytes[1..] { + match b { + b'a'..=b'z' | b'0'..=b'9' => prev_dash = false, + b'-' if !prev_dash => prev_dash = true, + _ => return Err(IdentError(s.to_string())), + } + } + if prev_dash { + return Err(IdentError(s.to_string())); + } + Ok(Ident(s.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Ident { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl serde::Serialize for Ident { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(&self.0) + } +} + +impl<'de> serde::Deserialize<'de> for Ident { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + let s = String::deserialize(deserializer)?; + Ident::new(&s).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for Ident { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Error for an invalid identifier; carries the offending text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentError(pub String); + +impl fmt::Display for IdentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "`{}` is not a lowercase kebab-case identifier", self.0) + } +} + +impl std::error::Error for IdentError {} + +/// The one value model. No floats exist (§7.5); all containers are ordered. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +pub enum Value { + Unit, + Bool(bool), + /// i64, saturating arithmetic at the language level (§4.3). + Int(i64), + Text(String), + /// Opaque external identity (post id, cursor, asset id). + Id(String), + /// Command tag as a first-class value; minted from `U.counters`. + /// Canonical encoding: the string `"t-<n>"` (micro-decision #3). + Tag(u64), + /// Absent optional / removed map entry. + None, + /// Present optional. Options do not nest in the language (`T?` only), + /// so `Some` never wraps `None` in checked programs. + Some(Box<Value>), + List(Vec<Value>), + Record(BTreeMap<Ident, Value>), +} + +impl Value { + /// Canonical JSON image of this value (design §7.5): `Unit` and `None` + /// encode as `null`; `Some(v)` encodes as `v` (options never nest in + /// checked programs); `Tag(n)` encodes as the string `"t-<n>"`; records + /// keep their already-sorted key order. + pub fn to_json(&self) -> serde_json::Value { + use serde_json::Value as J; + match self { + Value::Unit | Value::None => J::Null, + Value::Bool(b) => J::Bool(*b), + Value::Int(i) => J::Number((*i).into()), + Value::Text(s) | Value::Id(s) => J::String(s.clone()), + Value::Tag(n) => J::String(format!("t-{n}")), + Value::Some(v) => v.to_json(), + Value::List(xs) => J::Array(xs.iter().map(Value::to_json).collect()), + Value::Record(fields) => J::Object( + fields + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_json())) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ident_accepts_kebab() { + for ok in ["a", "post-card", "like-pending", "a1", "x-2y"] { + assert!(Ident::new(ok).is_ok(), "{ok}"); + } + } + + #[test] + fn ident_rejects_non_kebab() { + for bad in ["", "A", "1a", "-a", "a-", "a--b", "a_b", "café", "on:press"] { + assert!(Ident::new(bad).is_err(), "{bad}"); + } + } + + #[test] + fn ident_rejects_over_64_chars() { + let long = "a".repeat(65); + assert!(Ident::new(&long).is_err()); + } + + #[test] + fn value_json_shapes() { + let mut rec = BTreeMap::new(); + rec.insert(Ident::new("b-key").unwrap(), Value::Int(2)); + rec.insert(Ident::new("a-key").unwrap(), Value::Tag(4)); + let v = Value::Record(rec); + assert_eq!(v.to_json().to_string(), r#"{"a-key":"t-4","b-key":2}"#); + assert_eq!(Value::None.to_json(), serde_json::Value::Null); + assert_eq!( + Value::Some(Box::new(Value::Bool(true))).to_json(), + serde_json::Value::Bool(true) + ); + } +} diff --git a/uhura/crates/uhura-check/Cargo.toml b/uhura/crates/uhura-check/Cargo.toml new file mode 100644 index 0000000..87a682a --- /dev/null +++ b/uhura/crates/uhura-check/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "uhura-check" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } +uhura-syntax = { workspace = true } +uhura-port = { workspace = true, features = ["toml"] } +uhura-core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/uhura/crates/uhura-check/clippy.toml b/uhura/crates/uhura-check/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-check/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-check/src/catalog.rs b/uhura/crates/uhura-check/src/catalog.rs new file mode 100644 index 0000000..43896b3 --- /dev/null +++ b/uhura/crates/uhura-check/src/catalog.rs @@ -0,0 +1,787 @@ +//! The semantic element catalog as data (design §10): the model, the TOML +//! loader, the meta-schema (input events only on interactive elements; +//! observation events only on viewports), and the canonical-form hash the +//! IR pins. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Ident, hash_json}; + +/// One loaded, meta-schema-validated element catalog. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Catalog { + pub name: Ident, + pub version: String, + /// The closed icon set (`<icon name=…>` values). + pub icons: BTreeSet<Ident>, + pub elements: BTreeMap<Ident, ElementDecl>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ElementClass { + Layout, + Content, + Interactive, +} + +impl ElementClass { + pub fn as_str(self) -> &'static str { + match self { + ElementClass::Layout => "layout", + ElementClass::Content => "content", + ElementClass::Interactive => "interactive", + } + } +} + +/// What an element accepts as children (§10 children models). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChildrenModel { + /// No children ever (`image`, `icon`, `text-field`). + None, + /// Any markup (`view`, `scroll`). + Any, + /// Content-class elements only (`button` — icon/text/image). + Content, + /// Exactly one child element (`region`). + One, + /// Children come from exactly one keyed `{#each}` (`pager`). + KeyedEach, + /// Literal text and `{expr}` interpolation only (`text`). + Text, +} + +impl ChildrenModel { + pub fn as_str(self) -> &'static str { + match self { + ChildrenModel::None => "none", + ChildrenModel::Any => "any", + ChildrenModel::Content => "content", + ChildrenModel::One => "one", + ChildrenModel::KeyedEach => "keyed-each", + ChildrenModel::Text => "text", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PropType { + Text, + Bool, + Int, + /// A closed token set; values typecheck as enum values (§4.3). + Enum(BTreeSet<Ident>), + /// An asset reference (`image src`). + Asset, + /// A name from the catalog's closed icon set. + Icon, +} + +impl PropType { + pub fn describe(&self) -> String { + match self { + PropType::Text => "text".into(), + PropType::Bool => "bool".into(), + PropType::Int => "int".into(), + PropType::Enum(values) => { + let list: Vec<&str> = values.iter().map(Ident::as_str).collect(); + format!("one of {}", list.join(" | ")) + } + PropType::Asset => "an asset reference".into(), + PropType::Icon => "an icon name".into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PropDecl { + pub ty: PropType, + pub required: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EventKind { + /// User input; interactive elements only. + Input, + /// A semantic observation; viewport elements only. + Observe, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EventDecl { + pub kind: EventKind, + /// Renderer-carried payload fields (`change { value: text }`, §4.2). + /// Carried fields are `text`/`bool`/`int` only. + pub carries: BTreeMap<Ident, PropType>, + /// `near-end`: integer percentage of one viewport extent (§8.2). + pub threshold_percent: Option<i64>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ElementDecl { + pub class: ElementClass, + /// Layout elements that own scrollable extent (`scroll`, `pager`). + pub viewport: bool, + pub children: ChildrenModel, + pub props: BTreeMap<Ident, PropDecl>, + pub events: BTreeMap<Ident, EventDecl>, + /// Prop groups where exactly one member must be bound (`alt` xor + /// `decorative`). + pub exactly_one_of: Vec<Vec<Ident>>, + /// Controlled promotion: binding `prop` obligates handling `event`. + pub controlled: Option<(Ident, Ident)>, +} + +/// A catalog-level problem, located by TOML key path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CatalogIssue { + pub path: String, + pub message: String, +} + +impl Catalog { + /// The canonical JSON form — the byte form whose SHA-256 the checked IR + /// pins (§10 "versioned + hash-pinned"). + pub fn to_canonical_json(&self) -> serde_json::Value { + use serde_json::{Map, Value as J, json}; + let elements: Map<String, J> = self + .elements + .iter() + .map(|(name, el)| { + let props: Map<String, J> = el + .props + .iter() + .map(|(p, decl)| { + ( + p.to_string(), + json!({ + "type": prop_type_json(&decl.ty), + "required": decl.required, + }), + ) + }) + .collect(); + let events: Map<String, J> = el + .events + .iter() + .map(|(e, decl)| { + ( + e.to_string(), + json!({ + "kind": match decl.kind { + EventKind::Input => "input", + EventKind::Observe => "observe", + }, + "carries": decl + .carries + .iter() + .map(|(f, ty)| (f.to_string(), prop_type_json(ty))) + .collect::<Map<String, J>>(), + "threshold-percent": decl.threshold_percent, + }), + ) + }) + .collect(); + ( + name.to_string(), + json!({ + "class": el.class.as_str(), + "viewport": el.viewport, + "children": el.children.as_str(), + "props": props, + "events": events, + "exactly-one-of": el + .exactly_one_of + .iter() + .map(|group| group.iter().map(ToString::to_string).collect::<Vec<_>>()) + .collect::<Vec<_>>(), + "controlled": el + .controlled + .as_ref() + .map(|(p, e)| json!({ "prop": p.to_string(), "event": e.to_string() })), + }), + ) + }) + .collect(); + json!({ + "catalog": "uhura-catalog/0", + "name": self.name.to_string(), + "version": self.version, + "icons": self.icons.iter().map(ToString::to_string).collect::<Vec<_>>(), + "elements": elements, + }) + } + + pub fn canonical_hash(&self) -> String { + hash_json(&self.to_canonical_json()) + } + + /// The meta-schema (§10): event-kind eligibility by class, controlled + /// and exactly-one-of references, carried-field types, icon-prop + /// backing. + pub fn meta_schema(&self) -> Vec<CatalogIssue> { + let mut issues = Vec::new(); + let mut push = |path: String, message: String| issues.push(CatalogIssue { path, message }); + + for (name, el) in &self.elements { + let path = format!("elements.{name}"); + if el.viewport && el.class != ElementClass::Layout { + push( + path.clone(), + "only layout elements can be viewports".to_string(), + ); + } + for (event, decl) in &el.events { + let epath = format!("{path}.events.{event}"); + match decl.kind { + EventKind::Input if el.class != ElementClass::Interactive => push( + epath.clone(), + "input events are declarable only on interactive elements".to_string(), + ), + EventKind::Observe if !el.viewport => push( + epath.clone(), + "observation events are declarable only on viewports".to_string(), + ), + _ => {} + } + for (field, ty) in &decl.carries { + if !matches!(ty, PropType::Text | PropType::Bool | PropType::Int) { + push( + format!("{epath}.carries.{field}"), + "carried fields are text | bool | int".to_string(), + ); + } + } + if decl + .threshold_percent + .is_some_and(|t| !(1..=200).contains(&t)) + { + push( + epath, + "`threshold-percent` must be an integer in 1..=200".to_string(), + ); + } + } + for group in &el.exactly_one_of { + for prop in group { + if !el.props.contains_key(prop) { + push( + format!("{path}.exactly-one-of"), + format!("`{prop}` is not a prop of `{name}`"), + ); + } + } + if group.len() < 2 { + push( + format!("{path}.exactly-one-of"), + "a group needs at least two props".to_string(), + ); + } + } + if let Some((prop, event)) = &el.controlled { + if !el.props.contains_key(prop) { + push( + format!("{path}.controlled"), + format!("`{prop}` is not a prop of `{name}`"), + ); + } + match el.events.get(event) { + None => push( + format!("{path}.controlled"), + format!("`{event}` is not an event of `{name}`"), + ), + Some(decl) if decl.kind != EventKind::Input => push( + format!("{path}.controlled"), + "the controlling event must be an input event".to_string(), + ), + Some(_) => {} + } + } + for prop in el.props.keys() { + if prop.as_str() == "class" { + push( + format!("{path}.props.class"), + "`class` is universal and may not be redeclared".to_string(), + ); + } + } + if el.props.values().any(|p| matches!(p.ty, PropType::Icon)) && self.icons.is_empty() { + push( + path, + "an `icon` prop needs a non-empty catalog icon set".to_string(), + ); + } + } + issues + } +} + +fn prop_type_json(ty: &PropType) -> serde_json::Value { + use serde_json::json; + match ty { + PropType::Text => json!("text"), + PropType::Bool => json!("bool"), + PropType::Int => json!("int"), + PropType::Asset => json!("asset"), + PropType::Icon => json!("icon"), + PropType::Enum(values) => json!({ + "enum": values.iter().map(ToString::to_string).collect::<Vec<_>>(), + }), + } +} + +/// Loads and validates a catalog. `Err` carries every issue (structural +/// and meta-schema); `Ok` catalogs are clean. +pub fn load_catalog(text: &str) -> Result<Catalog, Vec<CatalogIssue>> { + let table: toml::Table = match text.parse() { + Ok(t) => t, + Err(e) => { + return Err(vec![CatalogIssue { + path: String::new(), + message: format!("invalid TOML: {e}"), + }]); + } + }; + let mut issues = Vec::new(); + let catalog = walk_catalog(&table, &mut issues); + match catalog { + Some(c) if issues.is_empty() => { + let meta = c.meta_schema(); + if meta.is_empty() { Ok(c) } else { Err(meta) } + } + _ => Err(issues), + } +} + +fn walk_catalog(table: &toml::Table, issues: &mut Vec<CatalogIssue>) -> Option<Catalog> { + let mut push = |path: &str, message: String| { + issues.push(CatalogIssue { + path: path.to_string(), + message, + }); + }; + + for key in table.keys() { + if !["catalog", "elements"].contains(&key.as_str()) { + push(key, format!("unknown key `{key}`")); + } + } + + let head = match table.get("catalog").and_then(toml::Value::as_table) { + Some(t) => t, + None => { + push("catalog", "missing `[catalog]` section".to_string()); + return None; + } + }; + for key in head.keys() { + if !["name", "version", "icons"].contains(&key.as_str()) { + push(&format!("catalog.{key}"), format!("unknown key `{key}`")); + } + } + let name = ident_at("catalog.name", head.get("name"), issues)?; + let version = match head.get("version").and_then(toml::Value::as_str) { + Some(v) => v.to_string(), + None => { + issues.push(CatalogIssue { + path: "catalog.version".into(), + message: "missing required string".into(), + }); + return None; + } + }; + let mut icons = BTreeSet::new(); + if let Some(toml::Value::Array(items)) = head.get("icons") { + for (i, item) in items.iter().enumerate() { + let path = format!("catalog.icons[{i}]"); + match item.as_str().map(Ident::new) { + Some(Ok(icon)) => { + if !icons.insert(icon) { + issues.push(CatalogIssue { + path, + message: "duplicate icon".into(), + }); + } + } + Some(Err(e)) => issues.push(CatalogIssue { + path, + message: e.to_string(), + }), + None => issues.push(CatalogIssue { + path, + message: "expected a string".into(), + }), + } + } + } + + let mut elements = BTreeMap::new(); + if let Some(section) = table.get("elements") { + let Some(section) = section.as_table() else { + issues.push(CatalogIssue { + path: "elements".into(), + message: "expected a table".into(), + }); + return None; + }; + for (el_name, decl) in section { + let path = format!("elements.{el_name}"); + let Some(el_ident) = ident_key(&path, el_name, issues) else { + continue; + }; + let Some(decl) = decl.as_table() else { + issues.push(CatalogIssue { + path, + message: "expected a table".into(), + }); + continue; + }; + if let Some(el) = walk_element(&path, decl, issues) { + elements.insert(el_ident, el); + } + } + } + + Some(Catalog { + name, + version, + icons, + elements, + }) +} + +fn walk_element( + path: &str, + table: &toml::Table, + issues: &mut Vec<CatalogIssue>, +) -> Option<ElementDecl> { + for key in table.keys() { + if ![ + "class", + "viewport", + "children", + "props", + "events", + "exactly-one-of", + "controlled", + ] + .contains(&key.as_str()) + { + issues.push(CatalogIssue { + path: format!("{path}.{key}"), + message: format!("unknown key `{key}`"), + }); + } + } + + let class = match table.get("class").and_then(toml::Value::as_str) { + Some("layout") => ElementClass::Layout, + Some("content") => ElementClass::Content, + Some("interactive") => ElementClass::Interactive, + Some(other) => { + issues.push(CatalogIssue { + path: format!("{path}.class"), + message: format!("`{other}` is not a class (layout | content | interactive)"), + }); + return None; + } + None => { + issues.push(CatalogIssue { + path: format!("{path}.class"), + message: "missing required `class`".into(), + }); + return None; + } + }; + + let viewport = table + .get("viewport") + .and_then(toml::Value::as_bool) + .unwrap_or(false); + + let children = match table.get("children").and_then(toml::Value::as_str) { + Some("none") => ChildrenModel::None, + Some("any") => ChildrenModel::Any, + Some("content") => ChildrenModel::Content, + Some("one") => ChildrenModel::One, + Some("keyed-each") => ChildrenModel::KeyedEach, + Some("text") => ChildrenModel::Text, + Some(other) => { + issues.push(CatalogIssue { + path: format!("{path}.children"), + message: format!( + "`{other}` is not a children model \ + (none | any | content | one | keyed-each | text)" + ), + }); + return None; + } + None => { + issues.push(CatalogIssue { + path: format!("{path}.children"), + message: "missing required `children`".into(), + }); + return None; + } + }; + + let mut props = BTreeMap::new(); + if let Some(toml::Value::Table(section)) = table.get("props") { + for (prop_name, decl) in section { + let ppath = format!("{path}.props.{prop_name}"); + let Some(prop_ident) = ident_key(&ppath, prop_name, issues) else { + continue; + }; + let Some(decl) = decl.as_table() else { + issues.push(CatalogIssue { + path: ppath, + message: "expected a table".into(), + }); + continue; + }; + if let Some(prop) = walk_prop(&ppath, decl, issues) { + props.insert(prop_ident, prop); + } + } + } + + let mut events = BTreeMap::new(); + if let Some(toml::Value::Table(section)) = table.get("events") { + for (event_name, decl) in section { + let epath = format!("{path}.events.{event_name}"); + let Some(event_ident) = ident_key(&epath, event_name, issues) else { + continue; + }; + let Some(decl) = decl.as_table() else { + issues.push(CatalogIssue { + path: epath, + message: "expected a table".into(), + }); + continue; + }; + if let Some(event) = walk_event(&epath, decl, issues) { + events.insert(event_ident, event); + } + } + } + + let mut exactly_one_of = Vec::new(); + if let Some(toml::Value::Array(groups)) = table.get("exactly-one-of") { + for (i, group) in groups.iter().enumerate() { + let gpath = format!("{path}.exactly-one-of[{i}]"); + let Some(members) = group.as_array() else { + issues.push(CatalogIssue { + path: gpath, + message: "expected an array of prop names".into(), + }); + continue; + }; + let mut names = Vec::new(); + for member in members { + match member.as_str().map(Ident::new) { + Some(Ok(name)) => names.push(name), + _ => issues.push(CatalogIssue { + path: gpath.clone(), + message: "expected a prop name".into(), + }), + } + } + exactly_one_of.push(names); + } + } + + let controlled = match table.get("controlled") { + None => None, + Some(toml::Value::Table(t)) => { + let prop = ident_at(&format!("{path}.controlled.prop"), t.get("prop"), issues); + let event = ident_at(&format!("{path}.controlled.event"), t.get("event"), issues); + match (prop, event) { + (Some(p), Some(e)) => Some((p, e)), + _ => None, + } + } + Some(_) => { + issues.push(CatalogIssue { + path: format!("{path}.controlled"), + message: "expected `{ prop = …, event = … }`".into(), + }); + None + } + }; + + Some(ElementDecl { + class, + viewport, + children, + props, + events, + exactly_one_of, + controlled, + }) +} + +fn walk_prop(path: &str, table: &toml::Table, issues: &mut Vec<CatalogIssue>) -> Option<PropDecl> { + for key in table.keys() { + if !["type", "values", "required"].contains(&key.as_str()) { + issues.push(CatalogIssue { + path: format!("{path}.{key}"), + message: format!("unknown key `{key}`"), + }); + } + } + let required = table + .get("required") + .and_then(toml::Value::as_bool) + .unwrap_or(false); + let ty = match table.get("type").and_then(toml::Value::as_str) { + Some("text") => PropType::Text, + Some("bool") => PropType::Bool, + Some("int") => PropType::Int, + Some("asset") => PropType::Asset, + Some("icon") => PropType::Icon, + Some("enum") => { + let mut values = BTreeSet::new(); + match table.get("values") { + Some(toml::Value::Array(items)) => { + for item in items { + match item.as_str().map(Ident::new) { + Some(Ok(v)) => { + values.insert(v); + } + _ => issues.push(CatalogIssue { + path: format!("{path}.values"), + message: "enum values are kebab-case strings".into(), + }), + } + } + } + _ => issues.push(CatalogIssue { + path: format!("{path}.values"), + message: "an enum prop needs a `values` array".into(), + }), + } + if values.is_empty() { + return None; + } + PropType::Enum(values) + } + Some(other) => { + issues.push(CatalogIssue { + path: format!("{path}.type"), + message: format!( + "`{other}` is not a prop type (text | bool | int | enum | asset | icon)" + ), + }); + return None; + } + None => { + issues.push(CatalogIssue { + path: format!("{path}.type"), + message: "missing required `type`".into(), + }); + return None; + } + }; + Some(PropDecl { ty, required }) +} + +fn walk_event( + path: &str, + table: &toml::Table, + issues: &mut Vec<CatalogIssue>, +) -> Option<EventDecl> { + for key in table.keys() { + if !["kind", "carries", "threshold-percent"].contains(&key.as_str()) { + issues.push(CatalogIssue { + path: format!("{path}.{key}"), + message: format!("unknown key `{key}`"), + }); + } + } + let kind = match table.get("kind").and_then(toml::Value::as_str) { + Some("input") => EventKind::Input, + Some("observe") => EventKind::Observe, + Some(other) => { + issues.push(CatalogIssue { + path: format!("{path}.kind"), + message: format!("`{other}` is not an event kind (input | observe)"), + }); + return None; + } + None => { + issues.push(CatalogIssue { + path: format!("{path}.kind"), + message: "missing required `kind`".into(), + }); + return None; + } + }; + let mut carries = BTreeMap::new(); + if let Some(toml::Value::Table(section)) = table.get("carries") { + for (field, ty) in section { + let fpath = format!("{path}.carries.{field}"); + let Some(field_ident) = ident_key(&fpath, field, issues) else { + continue; + }; + let ty = match ty.as_str() { + Some("text") => PropType::Text, + Some("bool") => PropType::Bool, + Some("int") => PropType::Int, + _ => { + issues.push(CatalogIssue { + path: fpath, + message: "carried fields are text | bool | int".into(), + }); + continue; + } + }; + carries.insert(field_ident, ty); + } + } + let threshold_percent = match table.get("threshold-percent") { + None => None, + Some(toml::Value::Integer(n)) => Some(*n), + Some(_) => { + issues.push(CatalogIssue { + path: format!("{path}.threshold-percent"), + message: "`threshold-percent` must be an integer".into(), + }); + None + } + }; + Some(EventDecl { + kind, + carries, + threshold_percent, + }) +} + +fn ident_at( + path: &str, + value: Option<&toml::Value>, + issues: &mut Vec<CatalogIssue>, +) -> Option<Ident> { + match value.and_then(toml::Value::as_str) { + Some(s) => ident_key(path, s, issues), + None => { + issues.push(CatalogIssue { + path: path.to_string(), + message: "missing required string".into(), + }); + None + } + } +} + +fn ident_key(path: &str, s: &str, issues: &mut Vec<CatalogIssue>) -> Option<Ident> { + match Ident::new(s) { + Ok(i) => Some(i), + Err(e) => { + issues.push(CatalogIssue { + path: path.to_string(), + message: e.to_string(), + }); + None + } + } +} diff --git a/uhura/crates/uhura-check/src/examples.rs b/uhura/crates/uhura-check/src/examples.rs new file mode 100644 index 0000000..90abc38 --- /dev/null +++ b/uhura/crates/uhura-check/src/examples.rs @@ -0,0 +1,420 @@ +//! Examples clause legality (§6.1 — the checker-enforced matrix). +//! Resolution to frozen snapshots is M3; derived replay is M4. This pass +//! guarantees the *shape*: clause sets by subject kind, `from` chains +//! (earlier-only, so no cycles), default uniqueness, pin targets, and the +//! static-value discipline of pinned expressions. + +use std::collections::BTreeSet; + +use uhura_base::{Diagnostic, Ident, Severity, Span, codes}; +use uhura_syntax::ast; + +use crate::manifest::Manifest; +use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; + +pub fn check_examples( + file: &ast::ExamplesFile, + subject: &DefEnv, + resolved: &Resolved, + manifest: &Manifest, + file_span: Span, + diags: &mut Vec<Diagnostic>, +) { + // ── imports: fixtures only, and known ones ───────────────────────── + for use_decl in &file.uses { + match use_decl { + ast::Use::Fixture { name, span, .. } => { + if Ident::new(name).is_ok_and(|n| !manifest.fixtures.contains_key(&n)) { + diags.push(Diagnostic::error( + codes::UNKNOWN_FIXTURE.0, + codes::UNKNOWN_FIXTURE.1, + format!("no fixture `{name}` in the manifest"), + *span, + )); + } + } + ast::Use::Component { span, .. } + | ast::Use::Surface { span, .. } + | ast::Use::Port { span, .. } => { + diags.push(Diagnostic::error( + codes::ILLEGAL_CLAUSE.0, + codes::ILLEGAL_CLAUSE.1, + "examples files import fixtures only; the subject's imports are its own \ + (§6.1)" + .to_string(), + *span, + )); + } + } + } + + let is_page = matches!(subject.kind, SubjectKind::Page { .. }); + let is_component = matches!(subject.kind, SubjectKind::Component { .. }); + let is_surface = matches!(subject.kind, SubjectKind::Surface { .. }); + + let mut declared: BTreeSet<&str> = BTreeSet::new(); + let mut default_span: Option<Span> = None; + + for example in &file.examples { + if !declared.insert(&example.name) { + diags.push(Diagnostic::error( + codes::BAD_FROM.0, + codes::BAD_FROM.1, + format!("example `{}` is declared twice", example.name), + example.span, + )); + } + if example.is_default { + match default_span { + None => default_span = Some(example.span), + Some(first) => diags.push( + Diagnostic::error( + codes::MULTIPLE_DEFAULTS.0, + codes::MULTIPLE_DEFAULTS.1, + "at most one example is `default` (§6.1)".to_string(), + example.span, + ) + .with_label(first, "already defaulted here"), + ), + } + } + + for clause in &example.clauses { + let (legal, what, span) = match clause { + ast::ExampleClause::Error { .. } => continue, + ast::ExampleClause::Note { span, .. } => (true, "note", *span), + ast::ExampleClause::From { name, span } => { + // Earlier-declared only — forward refs and cycles are + // unrepresentable (§6.2). + if !declared.contains(name.as_str()) || name == &example.name { + diags.push(Diagnostic::error( + codes::BAD_FROM.0, + codes::BAD_FROM.1, + format!( + "`from {name}` must name an example declared earlier in this \ + file (§6.2: no cycles, no forward refs)" + ), + *span, + )); + } + (true, "from", *span) + } + ast::ExampleClause::Params { entries, span } => { + check_params(entries, subject, diags); + (is_page, "params", *span) + } + ast::ExampleClause::Props { entries, span } => { + check_props(entries, subject, diags); + (is_component || is_surface, "props", *span) + } + ast::ExampleClause::State { entries, span } => { + check_state(entries, subject, diags); + (is_page || is_surface, "state", *span) + } + ast::ExampleClause::Projection(pin) => { + check_projection_pin(pin, resolved, diags); + (is_page || is_surface, "projection", pin.span) + } + ast::ExampleClause::Events { entries, span } => { + for event in entries { + check_event(event, subject, resolved, diags); + } + (is_page || is_surface, "events", *span) + } + }; + if !legal { + diags.push(Diagnostic::error( + codes::ILLEGAL_CLAUSE.0, + codes::ILLEGAL_CLAUSE.1, + format!( + "`{what}` is not a {} clause (§6.1: components take props/from/note; \ + pages add params/projection/state/events; surfaces take both sets)", + subject.kind.describe() + ), + span, + )); + } + } + } + + if default_span.is_none() && !file.examples.is_empty() { + diags.push(Diagnostic::new( + codes::NO_DEFAULT.0, + codes::NO_DEFAULT.1, + Severity::Info, + "no `default` example — the canvas cover falls back to the first declared (§6.1)", + file_span, + )); + } +} + +fn check_params(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec<Diagnostic>) { + for (name, value) in entries { + let known = Ident::new(name).is_ok_and(|n| subject.params.contains_key(&n)); + if !known { + diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + format!("the page declares no param `{name}`"), + value.span, + )); + } + require_static(value, diags); + } +} + +fn check_props(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec<Diagnostic>) { + for (name, value) in entries { + let known = Ident::new(name).is_ok_and(|n| subject.props.contains_key(&n)); + if !known { + let mut d = Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + format!("the subject declares no prop `{name}`"), + value.span, + ); + if let Ok(ident) = Ident::new(name) + && let Some(s) = did_you_mean(&ident, subject.props.keys()) + { + d = d.with_note(format!("did you mean `{s}`?")); + } + diags.push(d); + } + require_static(value, diags); + } +} + +fn check_state(entries: &[(String, ast::Expr)], subject: &DefEnv, diags: &mut Vec<Diagnostic>) { + for (name, value) in entries { + let known = Ident::new(name).is_ok_and(|n| subject.state.contains_key(&n)); + if !known { + diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + format!("the subject declares no state field `{name}`"), + value.span, + )); + } + require_static(value, diags); + } +} + +fn check_projection_pin( + pin: &ast::ProjectionPin, + resolved: &Resolved, + diags: &mut Vec<Diagnostic>, +) { + let Ok(port_name) = Ident::new(&pin.port) else { + return; + }; + let Some((contract, _)) = resolved.ports.get(&port_name) else { + diags.push(Diagnostic::error( + codes::UNKNOWN_PIN_TARGET.0, + codes::UNKNOWN_PIN_TARGET.1, + format!("no port `{}` in the manifest", pin.port), + pin.span, + )); + return; + }; + let Ok(proj_name) = Ident::new(&pin.projection) else { + return; + }; + let Some(decl) = contract.projections.get(&proj_name) else { + diags.push(Diagnostic::error( + codes::UNKNOWN_PIN_TARGET.0, + codes::UNKNOWN_PIN_TARGET.1, + format!("port `{port_name}` declares no projection `{proj_name}`"), + pin.span, + )); + return; + }; + match (&decl.key, &pin.key) { + (Some(_), None) => diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + format!("`{proj_name}` is keyed — pin an instance: `{port_name}.{proj_name}(<key>)`"), + pin.span, + )), + (None, Some(_)) => diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + format!("`{proj_name}` is not keyed — drop the key"), + pin.span, + )), + _ => {} + } + if let Some(key) = &pin.key { + require_static(key, diags); + } + // `failed("<reason>")` pins the failure state (micro-decision — mirrors + // `projection-failed`, §9.3); anything else must be static data. + match &pin.value.kind { + ast::ExprKind::Call { name, args } if name == "failed" => { + if !matches!(args.as_slice(), [one] if matches!(one.kind, ast::ExprKind::Str(_))) { + diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + "`failed(…)` takes one reason string".to_string(), + pin.value.span, + )); + } + } + _ => require_static(&pin.value, diags), + } +} + +fn check_event( + event: &ast::ExampleEvent, + subject: &DefEnv, + resolved: &Resolved, + diags: &mut Vec<Diagnostic>, +) { + match event { + ast::ExampleEvent::Projection(pin) => check_projection_pin(pin, resolved, diags), + ast::ExampleEvent::Semantic { name, args, span } => { + let known = Ident::new(name).is_ok_and(|n| subject.events.contains_key(&n)); + if !known { + diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + format!("the subject has no handler for `{name}` — replay would drop it"), + *span, + )); + } + for arg in args { + require_static(&arg.value, diags); + } + } + ast::ExampleEvent::Outcome { + command, + which, + args, + span, + } => { + let Ok(cmd) = Ident::new(command) else { + return; + }; + let Some(info) = subject.commands.get(&cmd) else { + diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + format!( + "the subject imports no command `{command}` — no send could be \ + outstanding for this outcome" + ), + *span, + )); + return; + }; + match which { + ast::OutcomeKind::Ok => { + if !args.is_empty() { + diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + "ok payloads are empty for every spike command (§9.1)".to_string(), + *span, + )); + } + } + ast::OutcomeKind::Err => { + // Mirrors OutcomeResult (§9.3): a refused outcome names + // a declared refusal; an unavailable outcome carries a + // reason text (micro-decision — the design's §6.1 + // excerpt shows only the refusal form). + match args.as_slice() { + [one] if one.name == "refusal" => { + let declared = declared_refusals(&cmd, subject, resolved); + match &one.value.kind { + ast::ExprKind::Ident(n) + if Ident::new(n).is_ok_and(|n| declared.contains(&n)) + || n == "unavailable" => {} + ast::ExprKind::Ident(n) => diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + format!( + "`{n}` is not a declared refusal of `{command}` \ + (or `unavailable`)" + ), + one.value.span, + )), + _ => diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + "the refusal is a bare name, not a string".to_string(), + one.value.span, + )), + } + } + [one] if one.name == "reason" => { + if !matches!(one.value.kind, ast::ExprKind::Str(_)) { + diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + "an unavailable outcome's `reason` is a text string (§9.3)" + .to_string(), + one.value.span, + )); + } + } + _ => diags.push(Diagnostic::error( + codes::BAD_EXAMPLE_EVENT.0, + codes::BAD_EXAMPLE_EVENT.1, + "`.err` takes exactly `(refusal: <name>)` or `(reason: \"<text>\")`" + .to_string(), + *span, + )), + } + let _ = info; + } + } + } + } +} + +fn declared_refusals(cmd: &Ident, subject: &DefEnv, resolved: &Resolved) -> BTreeSet<Ident> { + let Some(info) = subject.commands.get(cmd) else { + return BTreeSet::new(); + }; + resolved + .ports + .get(&info.port) + .and_then(|(contract, _)| contract.commands.get(cmd)) + .map(|c| c.refusals.clone()) + .unwrap_or_default() +} + +/// Pinned values are static: literals, records of static values, and +/// fixture slice references (`fixture.<ns>.<name>` field chains). No state, +/// props, operators, or reads — an example is data, not a program (§6.2). +fn require_static(expr: &ast::Expr, diags: &mut Vec<Diagnostic>) { + match &expr.kind { + ast::ExprKind::Int(_) + | ast::ExprKind::Str(_) + | ast::ExprKind::Bool(_) + | ast::ExprKind::None + | ast::ExprKind::Error => {} + ast::ExprKind::Record(entries) => { + for (_, value) in entries { + require_static(value, diags); + } + } + ast::ExprKind::Field { .. } if fixture_rooted(expr) => {} + _ => diags.push(Diagnostic::error( + codes::BAD_PIN.0, + codes::BAD_PIN.1, + "pins are static: literals, records of literals, or `fixture.…` slice references \ + (§6.2)" + .to_string(), + expr.span, + )), + } +} + +fn fixture_rooted(expr: &ast::Expr) -> bool { + match &expr.kind { + ast::ExprKind::Ident(name) => name == "fixture", + ast::ExprKind::Field { base, .. } => fixture_rooted(base), + _ => false, + } +} diff --git a/uhura/crates/uhura-check/src/fixture.rs b/uhura/crates/uhura-check/src/fixture.rs new file mode 100644 index 0000000..6667de6 --- /dev/null +++ b/uhura/crates/uhura-check/src/fixture.rs @@ -0,0 +1,309 @@ +//! Fixture data (design §9.5): named slices in `fixtures/standard.toml`, +//! loaded as raw JSON values and typed at every binding site against the +//! expected structural type (L8 — an ill-typed fixture is a link error at +//! the site that binds it). +//! +//! Slices may reference each other with `"@<ns>.<name>"` strings (spike +//! micro-decision — keeps `feed.page-1` from duplicating every post); +//! references resolve at load with cycle detection. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Ident, Value}; + +use crate::types::{MapKey, Ty}; + +/// Namespace → slice name → raw JSON value (references resolved). +#[derive(Clone, Debug, Default)] +pub struct FixtureData { + pub slices: BTreeMap<String, BTreeMap<String, serde_json::Value>>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FixtureIssue { + /// `<ns>.<name>` (or a TOML path for structural problems). + pub path: String, + pub message: String, +} + +impl FixtureData { + pub fn get(&self, ns: &str, name: &str) -> Option<&serde_json::Value> { + self.slices.get(ns).and_then(|m| m.get(name)) + } +} + +pub fn load_fixture(text: &str) -> Result<FixtureData, Vec<FixtureIssue>> { + let table: toml::Table = match text.parse() { + Ok(t) => t, + Err(e) => { + return Err(vec![FixtureIssue { + path: String::new(), + message: format!("invalid TOML: {e}"), + }]); + } + }; + + let mut issues = Vec::new(); + let mut raw: BTreeMap<String, BTreeMap<String, serde_json::Value>> = BTreeMap::new(); + for (ns, section) in &table { + let Some(section) = section.as_table() else { + issues.push(FixtureIssue { + path: ns.clone(), + message: "a fixture namespace is a table of slices".into(), + }); + continue; + }; + let mut slices = BTreeMap::new(); + for (name, value) in section { + match toml_to_json(value) { + Ok(json) => { + slices.insert(name.clone(), json); + } + Err(message) => issues.push(FixtureIssue { + path: format!("{ns}.{name}"), + message, + }), + } + } + raw.insert(ns.clone(), slices); + } + if !issues.is_empty() { + return Err(issues); + } + + // Resolve `@ns.name` references (deep), with cycle detection. + let mut resolved: BTreeMap<String, BTreeMap<String, serde_json::Value>> = BTreeMap::new(); + let keys: Vec<(String, String)> = raw + .iter() + .flat_map(|(ns, m)| m.keys().map(move |n| (ns.clone(), n.clone()))) + .collect(); + for (ns, name) in keys { + let mut in_flight = BTreeSet::new(); + match resolve_slice(&raw, &ns, &name, &mut in_flight) { + Ok(json) => { + resolved.entry(ns).or_default().insert(name, json); + } + Err(message) => issues.push(FixtureIssue { + path: format!("{ns}.{name}"), + message, + }), + } + } + if issues.is_empty() { + Ok(FixtureData { slices: resolved }) + } else { + Err(issues) + } +} + +fn resolve_slice( + raw: &BTreeMap<String, BTreeMap<String, serde_json::Value>>, + ns: &str, + name: &str, + in_flight: &mut BTreeSet<String>, +) -> Result<serde_json::Value, String> { + let key = format!("{ns}.{name}"); + if !in_flight.insert(key.clone()) { + return Err(format!("slice reference cycle through `@{key}`")); + } + let value = raw + .get(ns) + .and_then(|m| m.get(name)) + .ok_or_else(|| format!("no slice `{key}`"))?; + let resolved = resolve_refs(raw, value, in_flight)?; + in_flight.remove(&key); + Ok(resolved) +} + +fn resolve_refs( + raw: &BTreeMap<String, BTreeMap<String, serde_json::Value>>, + value: &serde_json::Value, + in_flight: &mut BTreeSet<String>, +) -> Result<serde_json::Value, String> { + use serde_json::Value as J; + match value { + J::String(s) => { + if let Some(reference) = s.strip_prefix('@') { + // Driver substitution markers (§9.5) pass through verbatim: + // scripts resolve them at delivery time (`fresh-id`, + // `payload.<field>` — the only two). Slices carrying them + // are script-reply material and never legal as pins. + if reference == "fresh-id" || reference.starts_with("payload.") { + return Ok(value.clone()); + } + let Some((ns, name)) = reference.split_once('.') else { + return Err(format!("`@{reference}` is not a `@<ns>.<name>` reference")); + }; + resolve_slice(raw, ns, name, in_flight) + } else { + Ok(value.clone()) + } + } + J::Array(items) => items + .iter() + .map(|item| resolve_refs(raw, item, in_flight)) + .collect::<Result<Vec<_>, _>>() + .map(J::Array), + J::Object(map) => map + .iter() + .map(|(k, v)| Ok((k.clone(), resolve_refs(raw, v, in_flight)?))) + .collect::<Result<serde_json::Map<_, _>, String>>() + .map(J::Object), + _ => Ok(value.clone()), + } +} + +/// TOML → JSON, refusing everything the value model refuses (floats, +/// datetimes — §7.5 determinism by type shape). +fn toml_to_json(value: &toml::Value) -> Result<serde_json::Value, String> { + use serde_json::Value as J; + match value { + toml::Value::String(s) => Ok(J::String(s.clone())), + toml::Value::Integer(i) => Ok(J::Number((*i).into())), + toml::Value::Boolean(b) => Ok(J::Bool(*b)), + toml::Value::Float(_) => Err("floats do not exist in fixture data (§7.5)".into()), + toml::Value::Datetime(_) => { + Err("no clocks: time labels are provider-formatted text (§9.1)".into()) + } + toml::Value::Array(items) => items + .iter() + .map(toml_to_json) + .collect::<Result<Vec<_>, _>>() + .map(J::Array), + toml::Value::Table(table) => table + .iter() + .map(|(k, v)| Ok((k.clone(), toml_to_json(v)?))) + .collect::<Result<serde_json::Map<_, _>, String>>() + .map(J::Object), + } +} + +/// Decodes raw fixture JSON against a structural type — the use-site half +/// of L8. Strict on records (no unknown fields; absent optionals become +/// `none`); unions are single-variant objects; optionals decode bare. +pub fn decode_against_ty(json: &serde_json::Value, ty: &Ty) -> Result<Value, String> { + use serde_json::Value as J; + match ty { + Ty::Error => Err("cannot decode against an erroneous type".into()), + Ty::Bool => match json { + J::Bool(b) => Ok(Value::Bool(*b)), + other => Err(mismatch("bool", other)), + }, + Ty::Int => match json { + J::Number(n) => n + .as_i64() + .map(Value::Int) + .ok_or_else(|| format!("`{n}` is not an i64")), + other => Err(mismatch("int", other)), + }, + Ty::Text => match json { + J::String(s) => Ok(Value::Text(s.clone())), + other => Err(mismatch("text", other)), + }, + Ty::Id | Ty::Asset | Ty::Nominal { .. } => match json { + J::String(s) => Ok(Value::Id(s.clone())), + other => Err(mismatch(&ty.describe(), other)), + }, + Ty::Tag => Err("tags are core-minted; fixtures never carry them".into()), + Ty::NoneLit => match json { + J::Null => Ok(Value::None), + other => Err(mismatch("none", other)), + }, + Ty::Enum(values) => match json { + J::String(s) if values.iter().any(|v| v.as_str() == s) => Ok(Value::Text(s.clone())), + J::String(s) => Err(format!("`{s}` is not one of the enum's values")), + other => Err(mismatch("an enum value", other)), + }, + Ty::Option(inner) => match json { + J::Null => Ok(Value::None), + present => decode_against_ty(present, inner), + }, + Ty::List(inner) => match json { + J::Array(items) => items + .iter() + .map(|item| decode_against_ty(item, inner)) + .collect::<Result<Vec<_>, _>>() + .map(Value::List), + other => Err(mismatch(&ty.describe(), other)), + }, + Ty::Map(key_kind, inner) => match json { + J::Object(map) => { + let mut record = BTreeMap::new(); + for (k, v) in map { + if *key_kind == MapKey::Tag { + return Err( + "tag-keyed maps are core state; fixtures never carry them".to_string() + ); + } + let key = Ident::new(k).map_err(|e| e.to_string())?; + record.insert(key, decode_against_ty(v, inner)?); + } + Ok(Value::Record(record)) + } + other => Err(mismatch(&ty.describe(), other)), + }, + Ty::Record(fields) => { + let J::Object(map) = json else { + return Err(mismatch(&ty.describe(), json)); + }; + for k in map.keys() { + if Ident::new(k) + .map(|k| !fields.contains_key(&k)) + .unwrap_or(true) + { + return Err(format!("`{k}` is not a field of {}", ty.describe())); + } + } + let mut record = BTreeMap::new(); + for (field, field_ty) in fields { + match map.get(field.as_str()) { + Some(v) => { + let decoded = decode_against_ty(v, field_ty) + .map_err(|e| format!("in `{field}`: {e}"))?; + record.insert(field.clone(), decoded); + } + None if matches!(field_ty, Ty::Option(_)) => { + record.insert(field.clone(), Value::None); + } + None => { + return Err(format!("missing required field `{field}`")); + } + } + } + Ok(Value::Record(record)) + } + Ty::Union(variants) => { + let J::Object(map) = json else { + return Err(mismatch("a single-variant union object", json)); + }; + if map.len() != 1 { + return Err(format!( + "a union value has exactly one variant, got {}", + map.len() + )); + } + let (variant, body) = map.iter().next().expect("len checked"); + let variant_ident = Ident::new(variant).map_err(|e| e.to_string())?; + let Some(fields) = variants.get(&variant_ident) else { + return Err(format!("`{variant}` is not a variant of the union")); + }; + let payload = decode_against_ty(body, &Ty::Record(fields.clone())) + .map_err(|e| format!("in `{variant}`: {e}"))?; + let mut record = BTreeMap::new(); + record.insert(variant_ident, payload); + Ok(Value::Record(record)) + } + } +} + +fn mismatch(expected: &str, got: &serde_json::Value) -> String { + let shape = match got { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a bool", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "a list", + serde_json::Value::Object(_) => "an object", + }; + format!("expected {expected}, got {shape}") +} diff --git a/uhura/crates/uhura-check/src/infer.rs b/uhura/crates/uhura-check/src/infer.rs new file mode 100644 index 0000000..36745b2 --- /dev/null +++ b/uhura/crates/uhura-check/src/infer.rs @@ -0,0 +1,860 @@ +//! Expression typing (§4.3), statement legality (§4.2), and handler +//! discipline — multi-handler signatures, outcome shapes, guard order. + +use std::collections::BTreeMap; + +use uhura_base::{Diagnostic, Ident, Span, codes}; +use uhura_syntax::ast; + +use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; +use crate::types::{MapKey, Ty, comparable, compatible}; + +pub struct Typer<'a> { + pub env: &'a DefEnv, + pub resolved: &'a Resolved, + pub diags: &'a mut Vec<Diagnostic>, + /// Innermost-last binding stack: handler params, `as` tags, each items, + /// match bindings. + pub locals: Vec<(Ident, Ty)>, + /// View position: non-boot projection reads must sit inside `{#match}` + /// availability (§9.2); guards/bodies read bare (transactional + /// backstop, §4.2). + pub in_view: bool, +} + +impl<'a> Typer<'a> { + pub fn new(env: &'a DefEnv, resolved: &'a Resolved, diags: &'a mut Vec<Diagnostic>) -> Self { + Typer { + env, + resolved, + diags, + locals: Vec::new(), + in_view: false, + } + } + + fn error(&mut self, code: (&'static str, &'static str), message: String, span: Span) -> Ty { + self.diags + .push(Diagnostic::error(code.0, code.1, message, span)); + Ty::Error + } + + /// Pushes a local binding; local shadowing is forbidden like every + /// other kind (§3). + pub fn push_local(&mut self, name: &str, ty: Ty, span: Span) -> usize { + let Ok(ident) = Ident::new(name) else { + return self.locals.len(); + }; + let already = self.locals.iter().any(|(n, _)| *n == ident) + || self.env.state.contains_key(&ident) + || self.env.props.contains_key(&ident) + || self.env.params.contains_key(&ident) + || self.env.projections.contains_key(&ident); + if already { + self.diags.push(Diagnostic::error( + codes::SHADOWED_NAME.0, + codes::SHADOWED_NAME.1, + format!("binding `{ident}` shadows an existing name"), + span, + )); + } + self.locals.push((ident, ty)); + self.locals.len() - 1 + } + + pub fn truncate_locals(&mut self, len: usize) { + self.locals.truncate(len); + } + + fn name_type(&mut self, name: &str, span: Span) -> Ty { + let Ok(ident) = Ident::new(name) else { + return Ty::Error; + }; + if let Some((_, ty)) = self.locals.iter().rev().find(|(n, _)| *n == ident) { + return ty.clone(); + } + if let Some(ty) = self.env.state.get(&ident) { + return ty.clone(); + } + if let Some(ty) = self.env.props.get(&ident) { + return ty.clone(); + } + if let Some(ty) = self.env.params.get(&ident) { + return ty.clone(); + } + if let Some(proj) = self.env.projections.get(&ident) { + if proj.key.is_some() { + return self.error( + codes::WRONG_ARGS, + format!("projection `{ident}` is keyed — read it as `{ident}(<key>)`"), + span, + ); + } + if self.in_view && !proj.boot { + return self.error( + codes::UNGUARDED_PROJECTION_READ, + format!( + "`{ident}` is absent until delivered — in markup, read it through \ + `{{#match {ident}}}` availability arms (§9.2)" + ), + span, + ); + } + return proj.ty.clone(); + } + let candidates = self + .locals + .iter() + .map(|(n, _)| n) + .chain(self.env.state.keys()) + .chain(self.env.props.keys()) + .chain(self.env.params.keys()) + .chain(self.env.projections.keys()); + let suggestion = did_you_mean(&ident, candidates).cloned(); + let mut d = Diagnostic::error( + codes::UNRESOLVED_NAME.0, + codes::UNRESOLVED_NAME.1, + format!("nothing named `{ident}` is in scope"), + span, + ); + if let Some(s) = suggestion { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.diags.push(d); + Ty::Error + } + + pub fn infer(&mut self, e: &ast::Expr) -> Ty { + match &e.kind { + ast::ExprKind::Error => Ty::Error, + ast::ExprKind::Int(_) => Ty::Int, + ast::ExprKind::Str(_) => Ty::Text, + ast::ExprKind::Bool(_) => Ty::Bool, + ast::ExprKind::None => Ty::NoneLit, + ast::ExprKind::Ident(name) => self.name_type(name, e.span), + ast::ExprKind::Field { base, name } => { + let base_ty = self.infer(base); + let Ok(field) = Ident::new(name) else { + return Ty::Error; + }; + match base_ty { + Ty::Error => Ty::Error, + Ty::Record(fields) => match fields.get(&field) { + Some(t) => t.clone(), + None => { + let suggestion = did_you_mean(&field, fields.keys()).cloned(); + let mut d = Diagnostic::error( + codes::UNKNOWN_FIELD.0, + codes::UNKNOWN_FIELD.1, + format!( + "no field `{field}` on {}", + Ty::Record(fields.clone()).describe() + ), + e.span, + ); + if let Some(s) = suggestion { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.diags.push(d); + Ty::Error + } + }, + Ty::Option(_) => self.error( + codes::UNKNOWN_FIELD, + format!("`.{field}` on an optional — settle it first with `??`"), + e.span, + ), + other => self.error( + codes::UNKNOWN_FIELD, + format!("{} has no fields", other.describe()), + e.span, + ), + } + } + ast::ExprKind::Index { base, key } => { + let base_ty = self.infer(base); + match base_ty { + Ty::Error => { + self.infer(key); + Ty::Error + } + Ty::Map(k, v) => { + let key_ty = match k { + MapKey::Id => Ty::Id, + MapKey::Tag => Ty::Tag, + }; + self.check(key, &key_ty); + Ty::Option(v) + } + Ty::List(t) => { + self.check(key, &Ty::Int); + Ty::Option(t) + } + other => self.error( + codes::BAD_INDEX, + format!( + "{} is not indexable (§4.3: maps and lists)", + other.describe() + ), + e.span, + ), + } + } + ast::ExprKind::Call { name, args } => self.infer_call(name, args, e.span), + ast::ExprKind::Unary { op, expr } => match op { + ast::UnaryOp::Not => { + self.check(expr, &Ty::Bool); + Ty::Bool + } + ast::UnaryOp::Neg => { + self.check(expr, &Ty::Int); + Ty::Int + } + }, + ast::ExprKind::Binary { op, lhs, rhs } => self.infer_binary(*op, lhs, rhs, e.span), + ast::ExprKind::If { cond, then, els } => { + self.check(cond, &Ty::Bool); + let t = self.infer(then); + let f = self.infer(els); + self.unify_branches(t, f, e.span) + } + ast::ExprKind::Record(entries) => { + let mut fields = BTreeMap::new(); + for (name, value) in entries { + let ty = self.infer(value); + if let Ok(name) = Ident::new(name) { + fields.insert(name, ty); + } + } + Ty::Record(fields) + } + } + } + + fn infer_call(&mut self, name: &str, args: &[ast::Expr], span: Span) -> Ty { + match name { + "to-text" => { + if args.len() != 1 { + return self.error( + codes::BAD_BUILTIN_CALL, + "`to-text` takes exactly one argument".to_string(), + span, + ); + } + let ty = self.infer(&args[0]); + if !matches!(ty, Ty::Int | Ty::Text | Ty::Bool | Ty::Id | Ty::Error) { + return self.error( + codes::BAD_BUILTIN_CALL, + format!("`to-text` renders int/text/bool/id, not {}", ty.describe()), + span, + ); + } + Ty::Text + } + "count" => { + if args.len() != 1 { + return self.error( + codes::BAD_BUILTIN_CALL, + "`count` takes exactly one argument".to_string(), + span, + ); + } + let ty = self.infer(&args[0]); + if !matches!(ty, Ty::List(_) | Ty::Map(..) | Ty::Error) { + return self.error( + codes::BAD_BUILTIN_CALL, + format!("`count` counts lists and maps, not {}", ty.describe()), + span, + ); + } + Ty::Int + } + other => { + let Ok(ident) = Ident::new(other) else { + return Ty::Error; + }; + if let Some(proj) = self.env.projections.get(&ident) { + let Some(key_ty) = proj.key.clone() else { + return self.error( + codes::WRONG_ARGS, + format!("projection `{ident}` is not keyed — read it bare"), + span, + ); + }; + if args.len() != 1 { + return self.error( + codes::WRONG_ARGS, + format!("keyed read `{ident}(<key>)` takes exactly one key"), + span, + ); + } + self.check(&args[0], &key_ty); + if self.in_view { + return self.error( + codes::UNGUARDED_PROJECTION_READ, + format!( + "`{ident}(…)` is absent until delivered — in markup, read it \ + through `{{#match {ident}(…)}}` availability arms (§9.2)" + ), + span, + ); + } + return proj.ty.clone(); + } + self.error( + codes::UNRESOLVED_NAME, + format!( + "`{other}` is not a builtin (`to-text`, `count`) or a keyed projection" + ), + span, + ) + } + } + } + + fn infer_binary( + &mut self, + op: ast::BinaryOp, + lhs: &ast::Expr, + rhs: &ast::Expr, + span: Span, + ) -> Ty { + use ast::BinaryOp as B; + match op { + B::Add | B::Sub => { + self.check(lhs, &Ty::Int); + self.check(rhs, &Ty::Int); + Ty::Int + } + B::Concat => { + self.check(lhs, &Ty::Text); + self.check(rhs, &Ty::Text); + Ty::Text + } + B::And | B::Or => { + self.check(lhs, &Ty::Bool); + self.check(rhs, &Ty::Bool); + Ty::Bool + } + B::Lt | B::Le | B::Gt | B::Ge => { + self.check(lhs, &Ty::Int); + self.check(rhs, &Ty::Int); + Ty::Bool + } + B::Eq | B::NotEq => { + let l = self.infer(lhs); + let r = self.infer(rhs); + if !comparable(&l, &r) { + self.error( + codes::BAD_OPERAND, + format!("cannot compare {} with {}", l.describe(), r.describe()), + span, + ); + } + Ty::Bool + } + B::Coalesce => { + let l = self.infer(lhs); + match l { + Ty::Error => { + self.infer(rhs); + Ty::Error + } + Ty::Option(inner) => { + self.check(rhs, &inner); + *inner + } + other => { + self.infer(rhs); + self.error( + codes::BAD_OPERAND, + format!( + "`??` settles optionals; {} is not optional", + other.describe() + ), + span, + ) + } + } + } + } + } + + fn unify_branches(&mut self, t: Ty, f: Ty, span: Span) -> Ty { + if t.is_error() || f.is_error() { + return Ty::Error; + } + if t == f { + return t; + } + match (&t, &f) { + (Ty::NoneLit, Ty::Option(_)) => f, + (Ty::Option(_), Ty::NoneLit) => t, + (Ty::NoneLit, _) => Ty::Option(Box::new(f)), + (_, Ty::NoneLit) => Ty::Option(Box::new(t)), + _ if compatible(&t, &f) => t, + _ if compatible(&f, &t) => f, + _ => self.error( + codes::BAD_OPERAND, + format!( + "`if` branches disagree: {} vs {}", + t.describe(), + f.describe() + ), + span, + ), + } + } + + /// Expected-type-directed checking: string literals against enums, + /// record literals field-wise, `if` branch-wise; everything else infers + /// and tests compatibility. + pub fn check(&mut self, e: &ast::Expr, expected: &Ty) { + match (&e.kind, expected) { + (_, Ty::Error) | (ast::ExprKind::Error, _) => {} + (ast::ExprKind::Str(s), Ty::Enum(values)) => { + if !values.iter().any(|v| v.as_str() == s) { + let list: Vec<&str> = values.iter().map(Ident::as_str).collect(); + self.error( + codes::TYPE_MISMATCH, + format!("`\"{s}\"` is not one of {}", list.join(" | ")), + e.span, + ); + } + } + (ast::ExprKind::Record(entries), Ty::Record(fields)) => { + let mut bound: BTreeMap<Ident, Span> = BTreeMap::new(); + for (name, value) in entries { + let Ok(name_ident) = Ident::new(name) else { + continue; + }; + match fields.get(&name_ident) { + Some(field_ty) => { + self.check(value, field_ty); + } + None => { + self.error( + codes::UNKNOWN_FIELD, + format!("no field `{name}` on {}", expected.describe()), + value.span, + ); + } + } + bound.insert(name_ident, value.span); + } + for (field, field_ty) in fields { + if !bound.contains_key(field) && !matches!(field_ty, Ty::Option(_)) { + self.error( + codes::TYPE_MISMATCH, + format!("record literal is missing required field `{field}`"), + e.span, + ); + } + } + } + (ast::ExprKind::Record(entries), Ty::Map(..)) if entries.is_empty() => {} + (ast::ExprKind::If { cond, then, els }, _) => { + self.check(cond, &Ty::Bool); + self.check(then, expected); + self.check(els, expected); + } + _ => { + let ty = self.infer(e); + if !compatible(expected, &ty) { + self.mismatch(expected, &ty, e.span); + } + } + } + } + + fn mismatch(&mut self, expected: &Ty, actual: &Ty, span: Span) { + self.error( + codes::TYPE_MISMATCH, + format!( + "expected {}, got {}", + expected.describe(), + actual.describe() + ), + span, + ); + } + + // ── statements (§4.2) ────────────────────────────────────────────── + + pub fn check_stmt(&mut self, stmt: &ast::Stmt) { + match stmt { + ast::Stmt::Error { .. } => {} + ast::Stmt::Set { + path, value, span, .. + } => self.check_set(path, value, *span), + ast::Stmt::Send { + command, + args, + bind, + span, + .. + } => self.check_send(command, args, bind.as_deref(), *span), + ast::Stmt::OpenSurface { + name, args, span, .. + } => { + self.check_open_surface(name, args, *span); + } + ast::Stmt::Dismiss { span, .. } => { + if !matches!(self.env.kind, SubjectKind::Surface { .. }) { + self.error( + codes::DISMISS_OUTSIDE_SURFACE, + "`dismiss` pops the surface instance — only surfaces have one".to_string(), + *span, + ); + } + } + ast::Stmt::Navigate { target, span, .. } => self.check_navigate(target, *span), + } + } + + fn check_set(&mut self, path: &ast::SetPath, value: &ast::Expr, _span: Span) { + let Ok(field) = Ident::new(&path.field) else { + return; + }; + let Some(field_ty) = self.env.state.get(&field).cloned() else { + let suggestion = did_you_mean(&field, self.env.state.keys()).cloned(); + let mut d = Diagnostic::error( + codes::UNRESOLVED_NAME.0, + codes::UNRESOLVED_NAME.1, + format!("`set` writes own-scope state; no state field `{field}`"), + path.span, + ); + if let Some(s) = suggestion { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.diags.push(d); + self.infer(value); + return; + }; + match (&path.key, field_ty) { + (None, ty) => self.check(value, &ty), + (Some(key), Ty::Map(k, v)) => { + let key_ty = match k { + MapKey::Id => Ty::Id, + MapKey::Tag => Ty::Tag, + }; + self.check(key, &key_ty); + // `= none` removes the entry (§4.2), so the value position + // is effectively optional. + self.check(value, &Ty::Option(v)); + } + (Some(_), other) => { + self.error( + codes::BAD_INDEX, + format!( + "`{field}[…]` writes a map entry, but `{field}` is {}", + other.describe() + ), + path.span, + ); + self.infer(value); + } + } + } + + fn check_send(&mut self, command: &str, args: &[ast::Arg], bind: Option<&str>, span: Span) { + let Ok(cmd_ident) = Ident::new(command) else { + return; + }; + let Some(info) = self.env.commands.get(&cmd_ident) else { + let mut d = Diagnostic::error( + codes::UNKNOWN_COMMAND.0, + codes::UNKNOWN_COMMAND.1, + format!("no command `{command}` is imported"), + span, + ); + if let Some(s) = did_you_mean(&cmd_ident, self.env.commands.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } else { + d = d.with_note("import it: `use port <p> { command <c> }`".to_string()); + } + self.diags.push(d); + for arg in args { + self.infer(&arg.value); + } + return; + }; + let payload = info.payload.clone(); + self.check_named_args(args, &payload, "command payload", span); + if let Some(bind) = bind { + self.push_local(bind, Ty::Tag, span); + } + } + + fn check_open_surface(&mut self, name: &str, args: &[ast::Arg], span: Span) { + let Ok(surface) = Ident::new(name) else { + return; + }; + if !self.env.surface_imports.contains_key(&surface) { + let mut d = Diagnostic::error( + codes::UNKNOWN_SURFACE.0, + codes::UNKNOWN_SURFACE.1, + format!("no surface `{surface}` is imported"), + span, + ); + if self.resolved.surfaces.contains_key(&surface) { + d = d.with_note(format!("add `use surface {surface}`")); + } + self.diags.push(d); + for arg in args { + self.infer(&arg.value); + } + return; + } + let Some(target) = self.resolved.surfaces.get(&surface) else { + return; // unknown-import already diagnosed + }; + let props: Vec<(Ident, Ty)> = target + .props + .iter() + .map(|(n, t)| (n.clone(), t.clone())) + .collect(); + self.check_named_args(args, &props, "surface props", span); + } + + fn check_navigate(&mut self, target: &ast::NavTarget, span: Span) { + let ast::NavTarget::Route { name, args } = target else { + return; + }; + let Ok(route) = Ident::new(name) else { + return; + }; + if !self.resolved.routes.contains_key(&route) { + let mut d = Diagnostic::error( + codes::UNKNOWN_ROUTE.0, + codes::UNKNOWN_ROUTE.1, + format!("no route `{route}` (routes come from `app/**/page.uhura` paths)"), + span, + ); + if let Some(s) = did_you_mean(&route, self.resolved.routes.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.diags.push(d); + for arg in args { + self.infer(&arg.value); + } + return; + } + let params: Vec<(Ident, Ty)> = match self.resolved.pages.get(&route) { + Some(page) => page + .params + .iter() + .map(|(n, t)| (n.clone(), t.clone())) + .collect(), + None => Vec::new(), + }; + self.check_named_args(args, &params, "route params", span); + } + + /// Named-argument lists cover the declared fields exactly (§4.2). + pub fn check_named_args( + &mut self, + args: &[ast::Arg], + declared: &[(Ident, Ty)], + what: &str, + span: Span, + ) { + let mut seen: BTreeMap<&str, ()> = BTreeMap::new(); + for arg in args { + match declared.iter().find(|(n, _)| n.as_str() == arg.name) { + Some((_, ty)) => { + let ty = ty.clone(); + self.check(&arg.value, &ty); + } + None => { + self.error( + codes::WRONG_ARGS, + format!("`{}` is not part of the {what}", arg.name), + arg.span, + ); + self.infer(&arg.value); + } + } + if seen.insert(&arg.name, ()).is_some() { + self.error( + codes::WRONG_ARGS, + format!("`{}` is given twice", arg.name), + arg.span, + ); + } + } + for (name, ty) in declared { + if !args.iter().any(|a| a.name == name.as_str()) && !matches!(ty, Ty::Option(_)) { + self.error(codes::WRONG_ARGS, format!("missing `{name}`"), span); + } + } + } +} + +/// Checks a store block and returns the machine-event signature table +/// (event → params) that markup emit-checking consumes. +pub fn check_store( + env: &DefEnv, + resolved: &Resolved, + store: &ast::Store, + diags: &mut Vec<Diagnostic>, +) -> BTreeMap<Ident, Vec<(Ident, Ty)>> { + let mut events: BTreeMap<Ident, Vec<(Ident, Ty)>> = BTreeMap::new(); + // Event key → span of an unguarded handler already seen. + let mut unguarded: BTreeMap<String, Span> = BTreeMap::new(); + + for handler in &store.handlers { + let mut typer = Typer::new(env, resolved, diags); + + // ── signature ────────────────────────────────────────────────── + match &handler.event { + ast::EventRef::Semantic { name, span } => { + let Ok(event) = Ident::new(name) else { + continue; + }; + let mut sig: Vec<(Ident, Ty)> = Vec::new(); + for param in &handler.params { + let Ok(param_name) = Ident::new(&param.name) else { + continue; + }; + let ty = match &param.ty { + Some(t) => crate::resolve::source_type(t, env, typer.diags), + None => { + typer.diags.push(Diagnostic::error( + codes::HANDLER_SIGNATURE_MISMATCH.0, + codes::HANDLER_SIGNATURE_MISMATCH.1, + format!("UI-event param `{param_name}` needs a type (§4.2)"), + param.span, + )); + Ty::Error + } + }; + sig.push((param_name, ty)); + } + match events.get(&event) { + None => { + events.insert(event.clone(), sig.clone()); + } + Some(first) if *first != sig => { + typer.diags.push(Diagnostic::error( + codes::HANDLER_SIGNATURE_MISMATCH.0, + codes::HANDLER_SIGNATURE_MISMATCH.1, + format!( + "every `on {event}` handler must declare the identical \ + signature (§4.2)" + ), + *span, + )); + } + Some(_) => {} + } + for (i, (name, ty)) in sig.iter().enumerate() { + typer.push_local(name.as_str(), ty.clone(), handler.params[i].span); + } + } + ast::EventRef::Outcome { + command, + which, + span, + } => { + let Ok(cmd) = Ident::new(command) else { + continue; + }; + let expected: &[&str] = match which { + ast::OutcomeKind::Ok => &["tag", "cmd"], + ast::OutcomeKind::Err => &["tag", "cmd", "refusal"], + }; + let names: Vec<&str> = handler.params.iter().map(|p| p.name.as_str()).collect(); + let annotated = handler.params.iter().any(|p| p.ty.is_some()); + if names != expected || annotated { + typer.diags.push(Diagnostic::error( + codes::BAD_OUTCOME_SIGNATURE.0, + codes::BAD_OUTCOME_SIGNATURE.1, + format!( + "outcome handlers have the fixed name-only signature \ + `on {command}.{}({})` (§4.2)", + match which { + ast::OutcomeKind::Ok => "ok", + ast::OutcomeKind::Err => "err", + }, + expected.join(", ") + ), + *span, + )); + } + match env.commands.get(&cmd) { + None => { + typer.diags.push(Diagnostic::error( + codes::UNKNOWN_COMMAND.0, + codes::UNKNOWN_COMMAND.1, + format!("no command `{command}` is imported to have outcomes"), + *span, + )); + } + Some(info) => { + let cmd_record = Ty::Record(info.payload.iter().cloned().collect()); + typer + .locals + .push((Ident::new("tag").expect("kebab"), Ty::Tag)); + typer + .locals + .push((Ident::new("cmd").expect("kebab"), cmd_record)); + if matches!(which, ast::OutcomeKind::Err) { + // Refusal names or "unavailable" — compared as text. + typer + .locals + .push((Ident::new("refusal").expect("kebab"), Ty::Text)); + } + } + } + } + } + + // ── guard order: unguarded-above-anything is unreachable ─────── + let event_key = match &handler.event { + ast::EventRef::Semantic { name, .. } => format!("on {name}"), + ast::EventRef::Outcome { command, which, .. } => format!( + "on {command}.{}", + match which { + ast::OutcomeKind::Ok => "ok", + ast::OutcomeKind::Err => "err", + } + ), + }; + if let Some(prev) = unguarded.get(&event_key) { + typer.diags.push( + Diagnostic::error( + codes::UNREACHABLE_HANDLER.0, + codes::UNREACHABLE_HANDLER.1, + format!("this `{event_key}` handler is unreachable"), + handler.span, + ) + .with_label(*prev, "an unguarded handler above always wins"), + ); + } + if handler.guard.is_none() { + unguarded.entry(event_key).or_insert(handler.span); + } + + // ── guard + body ─────────────────────────────────────────────── + if let Some(guard) = &handler.guard { + typer.check(guard, &Ty::Bool); + } + let mut navigates = 0usize; + for stmt in &handler.body { + typer.check_stmt(stmt); + if let ast::Stmt::Navigate { span, .. } = stmt { + navigates += 1; + if navigates > 1 { + typer.diags.push(Diagnostic::error( + codes::MULTIPLE_NAVIGATES.0, + codes::MULTIPLE_NAVIGATES.1, + "at most one `navigate` per handler (§4.2: ≤ 1/step)".to_string(), + *span, + )); + } + } + } + } + events +} diff --git a/uhura/crates/uhura-check/src/lib.rs b/uhura/crates/uhura-check/src/lib.rs new file mode 100644 index 0000000..abfcc80 --- /dev/null +++ b/uhura/crates/uhura-check/src/lib.rs @@ -0,0 +1,23 @@ +//! uhura-check: the whole front half as a pure function over in-memory +//! inputs — routes from file paths, resolution, the catalog-as-data model + +//! meta-schema (module `catalog`), port linking, typecheck, markup/style +//! rules, example resolution (replay folds uhura-core's step_u), and +//! lowering to the checked IR (design §12.1). The CLI walks the filesystem; +//! this crate never does. +#![deny(clippy::float_arithmetic)] + +pub mod catalog; +pub mod examples; +pub mod fixture; +pub mod infer; +pub mod lower; +pub mod manifest; +pub mod markup; +pub mod pipeline; +pub mod preview; +pub mod replay; +pub mod resolve; +pub mod style; +pub mod types; + +pub use pipeline::{CheckInput, CheckOutput, LockStatus, SourceInput, check}; diff --git a/uhura/crates/uhura-check/src/lower.rs b/uhura/crates/uhura-check/src/lower.rs new file mode 100644 index 0000000..b68e5ee --- /dev/null +++ b/uhura/crates/uhura-check/src/lower.rs @@ -0,0 +1,895 @@ +//! Lowering — checked AST → `uhura-ir/0` (§12.2). Gated on zero errors, so +//! resolution here is a straight replay of the rules the passes already +//! enforced; anything unresolvable lowers to a placeholder rather than +//! panicking. Node ordinals are assigned depth-first pre-order per +//! definition (§8.1 keys). Spans go to a side table keyed by IR path — the +//! IR bytes stay location-independent (examples-invariance, §6.1). + +use std::collections::BTreeMap; + +use serde::Serialize; +use uhura_base::{Ident, Span}; +use uhura_core::ir; +use uhura_syntax::{Parsed, ast}; + +use crate::catalog::{Catalog, EventKind, PropType}; +use crate::infer::Typer; +use crate::manifest::Manifest; +use crate::resolve::{DefEnv, ParsedSource, Resolved, RouteSeg}; +use crate::types::{MapKey, Ty}; + +/// One side-table entry; `file` is the corpus-relative path. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SpanEntry { + pub file: String, + pub start: u32, + pub end: u32, +} + +pub struct Lowered { + pub program: ir::ProgramIr, + pub spans: BTreeMap<String, SpanEntry>, +} + +pub fn lower( + manifest: &Manifest, + resolved: &Resolved, + catalog: &Catalog, + sources: &[ParsedSource], +) -> Lowered { + let mut spans = BTreeMap::new(); + + let ports = resolved + .ports + .iter() + .map(|(name, (contract, _))| { + ( + name.clone(), + ir::PortPin { + version: contract.version.clone(), + hash: contract.canonical_hash(), + }, + ) + }) + .collect(); + + let mut projections = BTreeMap::new(); + for (port_name, (contract, port_types)) in &resolved.ports { + for (proj_name, decl) in &contract.projections { + projections.insert( + proj_name.clone(), + ir::ProjectionIr { + port: port_name.clone(), + boot: decl.boot, + ty: lower_ty(&port_types.from_expr(contract, &decl.ty)), + key: decl + .key + .as_ref() + .map(|k| lower_ty(&port_types.from_expr(contract, k))), + }, + ); + } + } + + let mut element_events = BTreeMap::new(); + let mut element_props = BTreeMap::new(); + for (el_name, el) in &catalog.elements { + if !el.props.is_empty() { + let props: BTreeMap<Ident, ir::PropKindIr> = el + .props + .iter() + .map(|(prop, decl)| { + ( + prop.clone(), + match decl.ty { + PropType::Text => ir::PropKindIr::Plain, + PropType::Bool => ir::PropKindIr::Bool, + PropType::Int => ir::PropKindIr::Int, + PropType::Enum(_) | PropType::Icon => ir::PropKindIr::Token, + PropType::Asset => ir::PropKindIr::Asset, + }, + ) + }) + .collect(); + element_props.insert(el_name.clone(), props); + } + if el.events.is_empty() { + continue; + } + let events: BTreeMap<Ident, ir::ElementEventIr> = el + .events + .iter() + .map(|(event, decl)| { + ( + event.clone(), + ir::ElementEventIr { + kind: match decl.kind { + EventKind::Input => ir::EventKindIr::Input, + EventKind::Observe => ir::EventKindIr::Observe, + }, + carries: decl + .carries + .iter() + .map(|(f, ty)| { + ( + f.clone(), + match ty { + PropType::Bool => ir::CarryTypeIr::Bool, + PropType::Int => ir::CarryTypeIr::Int, + _ => ir::CarryTypeIr::Text, + }, + ) + }) + .collect(), + }, + ) + }) + .collect(); + element_events.insert(el_name.clone(), events); + } + + let routes = resolved + .routes + .iter() + .map(|(name, info)| { + ( + name.clone(), + ir::RouteIr { + segments: info + .segments + .iter() + .map(|seg| match seg { + RouteSeg::Static(s) => ir::RouteSegIr::Static(s.clone()), + RouteSeg::Param(p) => ir::RouteSegIr::Param(p.clone()), + }) + .collect(), + params: info.params.clone(), + }, + ) + }) + .collect(); + + let lower_defs = + |defs: &BTreeMap<Ident, DefEnv>, prefix: &str, spans: &mut BTreeMap<String, SpanEntry>| { + defs.iter() + .filter_map(|(name, env)| { + let src = &sources[env.source]; + let Parsed::Module(ast) = &src.parsed else { + return None; + }; + let path = format!("{prefix}.{name}"); + let def = lower_def(env, ast, resolved, src, &path, spans); + Some((name.clone(), def)) + }) + .collect::<BTreeMap<Ident, ir::DefIr>>() + }; + + let pages = lower_defs(&resolved.pages, "pages", &mut spans); + let components = lower_defs(&resolved.components, "components", &mut spans); + let surfaces = lower_defs(&resolved.surfaces, "surfaces", &mut spans); + + let program = ir::ProgramIr { + protocol: ir::IR_PROTOCOL.to_string(), + app: manifest.app_name.clone(), + entry: manifest.entry.clone(), + catalog: ir::CatalogPin { + name: catalog.name.clone(), + version: catalog.version.clone(), + hash: catalog.canonical_hash(), + }, + ports, + projections, + element_events, + element_props, + routes, + pages, + components, + surfaces, + }; + Lowered { program, spans } +} + +/// Check-land `Ty` → the IR's runtime decode grammar. Nominal id/cursor +/// types collapse to `Id` (nominal identity is a check-time concern; the +/// wire form is a string either way). `Error`/`NoneLit` cannot survive a +/// zero-error check in a declared signature; they lower to `Text` to keep +/// the IR total. +fn lower_ty(ty: &Ty) -> ir::TyIr { + match ty { + Ty::Bool => ir::TyIr::Bool, + Ty::Int => ir::TyIr::Int, + Ty::Text => ir::TyIr::Text, + Ty::Id | Ty::Nominal { .. } => ir::TyIr::Id, + Ty::Tag => ir::TyIr::Tag, + Ty::Asset => ir::TyIr::Asset, + Ty::Enum(values) => ir::TyIr::Enum(values.iter().cloned().collect()), + Ty::Record(fields) => ir::TyIr::Record( + fields + .iter() + .map(|(name, ty)| (name.clone(), lower_ty(ty))) + .collect(), + ), + Ty::Union(variants) => ir::TyIr::Union( + variants + .iter() + .map(|(variant, fields)| { + ( + variant.clone(), + fields + .iter() + .map(|(name, ty)| (name.clone(), lower_ty(ty))) + .collect(), + ) + }) + .collect(), + ), + Ty::List(inner) => ir::TyIr::List(Box::new(lower_ty(inner))), + Ty::Map(key, inner) => ir::TyIr::Map { + key: match key { + MapKey::Id => ir::MapKeyIr::Id, + MapKey::Tag => ir::MapKeyIr::Tag, + }, + value: Box::new(lower_ty(inner)), + }, + Ty::Option(inner) => ir::TyIr::Option(Box::new(lower_ty(inner))), + Ty::NoneLit | Ty::Error => ir::TyIr::Text, + } +} + +fn record_span(spans: &mut BTreeMap<String, SpanEntry>, key: String, rel_path: &str, span: Span) { + spans.insert( + key, + SpanEntry { + file: rel_path.to_string(), + start: span.start, + end: span.end, + }, + ); +} + +fn lower_def( + env: &DefEnv, + ast: &ast::File, + resolved: &Resolved, + src: &ParsedSource, + path: &str, + spans: &mut BTreeMap<String, SpanEntry>, +) -> ir::DefIr { + record_span(spans, path.to_string(), &src.rel_path, def_span(ast)); + + let modality = match &ast.kind { + ast::DefKind::Surface { modality, .. } => { + Some(modality.clone().unwrap_or_else(|| "sheet".to_string())) + } + _ => None, + }; + + let props = ast + .props + .iter() + .filter_map(|p| Ident::new(&p.name).ok()) + .collect(); + let emits = ast + .emits + .iter() + .filter_map(|e| Ident::new(&e.name).ok()) + .collect(); + let params = ast + .params + .iter() + .filter_map(|p| Ident::new(&p.name).ok()) + .collect(); + + let mut state = BTreeMap::new(); + let mut handlers = Vec::new(); + if let Some(store) = &ast.store { + for field in &store.state { + let Ok(name) = Ident::new(&field.name) else { + continue; + }; + state.insert(name, lower_init(&field.init)); + } + for (i, handler) in store.handlers.iter().enumerate() { + record_span( + spans, + format!("{path}/handler/{i}"), + &src.rel_path, + handler.span, + ); + if let Some(h) = lower_handler(env, resolved, handler) { + handlers.push(h); + } + } + } + + let mut ctx = LowerCtx { + env, + resolved, + locals: Vec::new(), + next_ord: 0, + }; + let root_nodes = ctx.lower_nodes(&ast.markup); + let root = root_nodes.into_iter().next().unwrap_or_else(|| { + // Zero-error gating means this only happens for a markupless def, + // which the one-root rule already rejected; keep IR total anyway. + ir::NodeIr::Element(ir::ElementIr { + element: Ident::new("view").expect("kebab"), + ord: 0, + class: None, + props: vec![], + events: vec![], + text: vec![], + children: vec![], + }) + }); + + // Machine-event signatures (typed by the check pass) bake into the IR + // so the runtime can type payload JSON and enforce eligibility (§7.2). + let events = env + .events + .iter() + .map(|(event, params)| { + ( + event.clone(), + params + .iter() + .map(|(name, ty)| ir::EventParamIr { + name: name.clone(), + ty: lower_ty(ty), + }) + .collect(), + ) + }) + .collect(); + + ir::DefIr { + modality, + props, + emits, + params, + state, + events, + handlers, + root, + } +} + +fn def_span(ast: &ast::File) -> Span { + match &ast.kind { + ast::DefKind::Component { span, .. } + | ast::DefKind::Page { span } + | ast::DefKind::Surface { span, .. } + | ast::DefKind::Error { span } => *span, + } +} + +fn lower_init(lit: &ast::Literal) -> ir::InitValue { + match lit { + ast::Literal::Int(i) => ir::InitValue::Int(*i), + ast::Literal::Str(s) => ir::InitValue::Text(s.clone()), + ast::Literal::Bool(b) => ir::InitValue::Bool(*b), + ast::Literal::None | ast::Literal::Error => ir::InitValue::None, + ast::Literal::EmptyMap => ir::InitValue::EmptyMap, + } +} + +fn lower_handler( + env: &DefEnv, + resolved: &Resolved, + handler: &ast::Handler, +) -> Option<ir::HandlerIr> { + let on = match &handler.event { + ast::EventRef::Semantic { name, .. } => ir::EventKeyIr::Semantic { + event: Ident::new(name).ok()?, + }, + ast::EventRef::Outcome { command, which, .. } => ir::EventKeyIr::Outcome { + command: Ident::new(command).ok()?, + which: match which { + ast::OutcomeKind::Ok => ir::OutcomeKindIr::Ok, + ast::OutcomeKind::Err => ir::OutcomeKindIr::Err, + }, + }, + }; + let params: Vec<Ident> = handler + .params + .iter() + .filter_map(|p| Ident::new(&p.name).ok()) + .collect(); + + // Rebuild the handler-scope binding types the checker established. + let mut scratch = Vec::new(); + let locals: Vec<(Ident, Ty)> = match &handler.event { + ast::EventRef::Semantic { .. } => handler + .params + .iter() + .filter_map(|p| { + let name = Ident::new(&p.name).ok()?; + let ty = + p.ty.as_ref() + .map(|t| crate::resolve::source_type(t, env, &mut scratch)) + .unwrap_or(Ty::Error); + Some((name, ty)) + }) + .collect(), + ast::EventRef::Outcome { command, which, .. } => { + let payload = Ident::new(command) + .ok() + .and_then(|c| env.commands.get(&c)) + .map(|info| Ty::Record(info.payload.iter().cloned().collect())) + .unwrap_or(Ty::Error); + let mut locals = vec![ + (Ident::new("tag").expect("kebab"), Ty::Tag), + (Ident::new("cmd").expect("kebab"), payload), + ]; + if matches!(which, ast::OutcomeKind::Err) { + locals.push((Ident::new("refusal").expect("kebab"), Ty::Text)); + } + locals + } + }; + let mut ctx = LowerCtx { + env, + resolved, + locals, + next_ord: 0, + }; + let guard = handler.guard.as_ref().map(|g| ctx.lower_expr(g)); + let mut body = Vec::new(); + for stmt in &handler.body { + if let Some(s) = ctx.lower_stmt(stmt) { + body.push(s); + } + } + Some(ir::HandlerIr { + on, + params, + guard, + body, + }) +} + +struct LowerCtx<'a> { + env: &'a DefEnv, + resolved: &'a Resolved, + /// Names bound locally (handler params, `as` tags, each items, match + /// bindings) — they lower to `BindingRef`. Types ride along so + /// re-inference (each-over classification, union arms) stays exact. + locals: Vec<(Ident, Ty)>, + next_ord: u32, +} + +impl LowerCtx<'_> { + fn ord(&mut self) -> u32 { + let ord = self.next_ord; + self.next_ord += 1; + ord + } + + fn lower_expr(&mut self, e: &ast::Expr) -> ir::ExprIr { + match &e.kind { + ast::ExprKind::Error => ir::ExprIr::None, + ast::ExprKind::Int(i) => ir::ExprIr::Int(*i), + ast::ExprKind::Str(s) => ir::ExprIr::Text(s.clone()), + ast::ExprKind::Bool(b) => ir::ExprIr::Bool(*b), + ast::ExprKind::None => ir::ExprIr::None, + ast::ExprKind::Ident(name) => self.lower_name(name), + ast::ExprKind::Field { base, name } => ir::ExprIr::Field { + base: Box::new(self.lower_expr(base)), + name: Ident::new(name).unwrap_or_else(|_| Ident::new("x").expect("kebab")), + }, + ast::ExprKind::Index { base, key } => ir::ExprIr::Index { + base: Box::new(self.lower_expr(base)), + key: Box::new(self.lower_expr(key)), + }, + ast::ExprKind::Call { name, args } => match name.as_str() { + "to-text" => ir::ExprIr::ToText(Box::new( + args.first() + .map_or(ir::ExprIr::Text(String::new()), |a| self.lower_expr(a)), + )), + "count" => ir::ExprIr::Count(Box::new( + args.first() + .map_or(ir::ExprIr::Int(0), |a| self.lower_expr(a)), + )), + other => { + let projection = + Ident::new(other).unwrap_or_else(|_| Ident::new("x").expect("kebab")); + ir::ExprIr::ProjectionKeyed { + projection, + key: Box::new( + args.first() + .map_or(ir::ExprIr::None, |a| self.lower_expr(a)), + ), + } + } + }, + ast::ExprKind::Unary { op, expr } => ir::ExprIr::Unary { + op: match op { + ast::UnaryOp::Not => ir::UnaryOpIr::Not, + ast::UnaryOp::Neg => ir::UnaryOpIr::Neg, + }, + expr: Box::new(self.lower_expr(expr)), + }, + ast::ExprKind::Binary { op, lhs, rhs } => ir::ExprIr::Binary { + op: lower_binop(*op), + lhs: Box::new(self.lower_expr(lhs)), + rhs: Box::new(self.lower_expr(rhs)), + }, + ast::ExprKind::If { cond, then, els } => ir::ExprIr::If { + cond: Box::new(self.lower_expr(cond)), + then: Box::new(self.lower_expr(then)), + els: Box::new(self.lower_expr(els)), + }, + ast::ExprKind::Record(entries) => ir::ExprIr::RecordLit( + entries + .iter() + .filter_map(|(name, value)| { + Some(ir::ArgIr { + name: Ident::new(name).ok()?, + value: self.lower_expr(value), + }) + }) + .collect(), + ), + } + } + + fn lower_name(&mut self, name: &str) -> ir::ExprIr { + let Ok(ident) = Ident::new(name) else { + return ir::ExprIr::None; + }; + if self.locals.iter().any(|(l, _)| *l == ident) { + return ir::ExprIr::BindingRef(ident); + } + if self.env.state.contains_key(&ident) { + return ir::ExprIr::StateRef(ident); + } + if self.env.props.contains_key(&ident) { + return ir::ExprIr::PropRef(ident); + } + if self.env.params.contains_key(&ident) { + return ir::ExprIr::ParamRef(ident); + } + if self.env.projections.contains_key(&ident) { + return ir::ExprIr::ProjectionRef(ident); + } + // Zero-error gating: unreachable for checked programs. + ir::ExprIr::None + } + + fn lower_args(&mut self, args: &[ast::Arg]) -> Vec<ir::ArgIr> { + args.iter() + .filter_map(|arg| { + Some(ir::ArgIr { + name: Ident::new(&arg.name).ok()?, + value: self.lower_expr(&arg.value), + }) + }) + .collect() + } + + fn lower_stmt(&mut self, stmt: &ast::Stmt) -> Option<ir::StmtIr> { + match stmt { + ast::Stmt::Error { .. } => None, + ast::Stmt::Set { path, value, .. } => Some(ir::StmtIr::Set { + field: Ident::new(&path.field).ok()?, + key: path.key.as_ref().map(|k| self.lower_expr(k)), + value: self.lower_expr(value), + }), + ast::Stmt::Send { + command, + args, + bind, + .. + } => { + let command = Ident::new(command).ok()?; + let port = self.env.commands.get(&command)?.port.clone(); + let args = self.lower_args(args); + let bind = bind.as_ref().and_then(|b| Ident::new(b).ok()); + if let Some(b) = &bind { + self.locals.push((b.clone(), Ty::Tag)); + } + Some(ir::StmtIr::Send { + port, + command, + args, + bind, + }) + } + ast::Stmt::OpenSurface { name, args, .. } => Some(ir::StmtIr::OpenSurface { + surface: Ident::new(name).ok()?, + args: self.lower_args(args), + }), + ast::Stmt::Dismiss { .. } => Some(ir::StmtIr::Dismiss), + ast::Stmt::Navigate { target, .. } => match target { + ast::NavTarget::Back => Some(ir::StmtIr::NavigateBack), + ast::NavTarget::Route { name, args } => Some(ir::StmtIr::Navigate { + route: Ident::new(name).ok()?, + args: self.lower_args(args), + }), + }, + } + } + + fn lower_nodes(&mut self, nodes: &[ast::Node]) -> Vec<ir::NodeIr> { + nodes.iter().filter_map(|n| self.lower_node(n)).collect() + } + + fn lower_node(&mut self, node: &ast::Node) -> Option<ir::NodeIr> { + match node { + ast::Node::Error { .. } | ast::Node::Text { .. } => None, + ast::Node::If { + cond, then, els, .. + } => Some(ir::NodeIr::If { + cond: self.lower_expr(cond), + then: self.lower_nodes(then), + els: els + .as_ref() + .map(|e| self.lower_nodes(e)) + .unwrap_or_default(), + }), + ast::Node::Each { + item, + seq, + key, + body, + .. + } => { + let ord = self.ord(); + let seq_ty = self.infer_ty(seq); + let over = match &seq_ty { + Ty::Map(MapKey::Id, _) => ir::OverIr::MapIdKeys, + Ty::Map(MapKey::Tag, _) => ir::OverIr::MapTagKeys, + _ => ir::OverIr::List, + }; + let item_ty = match seq_ty { + Ty::List(t) => *t, + Ty::Map(MapKey::Id, _) => Ty::Id, + Ty::Map(MapKey::Tag, _) => Ty::Tag, + _ => Ty::Error, + }; + let seq_ir = self.lower_expr(seq); + let item_ident = Ident::new(item).ok()?; + self.locals.push((item_ident.clone(), item_ty)); + let key_ir = self.lower_expr(key); + let body_ir = self.lower_nodes(body); + self.locals.pop(); + Some(ir::NodeIr::Each(ir::EachIr { + ord, + item: item_ident, + over, + seq: seq_ir, + key: key_ir, + body: body_ir, + })) + } + ast::Node::Match { + scrutinee, arms, .. + } => { + let source = self.match_source(scrutinee); + // Arm binding types mirror the markup pass: availability + // ready binds the projection value / failed binds text; + // union arms bind the variant's field record. + let ready_ty = match &source { + ir::MatchSourceIr::Availability { projection, .. } => self + .env + .projections + .get(projection) + .map(|p| p.ty.clone()) + .unwrap_or(Ty::Error), + ir::MatchSourceIr::Union { .. } => self.infer_ty(scrutinee), + }; + let is_availability = matches!(source, ir::MatchSourceIr::Availability { .. }); + let arms = arms + .iter() + .map(|arm| { + let variant = match &arm.pattern { + ast::MatchPattern::Else => None, + ast::MatchPattern::Variant(v) => Ident::new(v).ok(), + }; + let binding = arm.binding.as_ref().and_then(|b| Ident::new(b).ok()); + if let Some(b) = &binding { + let ty = if is_availability { + match variant.as_ref().map(Ident::as_str) { + Some("ready") => ready_ty.clone(), + _ => Ty::Text, + } + } else { + match (&ready_ty, &variant) { + (Ty::Union(variants), Some(v)) => variants + .get(v) + .map(|fields| Ty::Record(fields.clone())) + .unwrap_or(Ty::Error), + _ => Ty::Error, + } + }; + self.locals.push((b.clone(), ty)); + } + let body = self.lower_nodes(&arm.body); + if binding.is_some() { + self.locals.pop(); + } + ir::MatchArmIr { + variant, + binding, + body, + } + }) + .collect(); + Some(ir::NodeIr::Match(ir::MatchIr { source, arms })) + } + ast::Node::Element(el) => { + let name = Ident::new(&el.name).ok()?; + if self.resolved.components.contains_key(&name) + && self.env.component_imports.contains_key(&name) + { + Some(self.lower_component_call(el, name)) + } else { + Some(self.lower_element(el, name)) + } + } + } + } + + /// Availability vs union classification — mirrors the markup pass. + fn match_source(&mut self, scrutinee: &ast::Expr) -> ir::MatchSourceIr { + match &scrutinee.kind { + ast::ExprKind::Ident(name) => { + if let Ok(ident) = Ident::new(name) + && self.env.projections.contains_key(&ident) + && !self.locals.iter().any(|(l, _)| *l == ident) + { + return ir::MatchSourceIr::Availability { + projection: ident, + key: None, + }; + } + ir::MatchSourceIr::Union { + value: self.lower_expr(scrutinee), + } + } + ast::ExprKind::Call { name, args } => { + if let Ok(ident) = Ident::new(name) + && self.env.projections.contains_key(&ident) + { + return ir::MatchSourceIr::Availability { + projection: ident, + key: args.first().map(|a| self.lower_expr(a)), + }; + } + ir::MatchSourceIr::Union { + value: self.lower_expr(scrutinee), + } + } + _ => ir::MatchSourceIr::Union { + value: self.lower_expr(scrutinee), + }, + } + } + + fn lower_element(&mut self, el: &ast::Element, element: Ident) -> ir::NodeIr { + let ord = self.ord(); + let mut class = None; + let mut props = Vec::new(); + for attr in &el.attrs { + let Ok(attr_name) = Ident::new(&attr.name) else { + continue; + }; + let value = match &attr.value { + ast::AttrValue::Bare => ir::ExprIr::Bool(true), + ast::AttrValue::Literal(s) => ir::ExprIr::Text(s.clone()), + ast::AttrValue::Expr(e) => self.lower_expr(e), + }; + if attr_name.as_str() == "class" { + class = Some(value); + } else { + props.push(ir::ArgIr { + name: attr_name, + value, + }); + } + } + let events = el + .events + .iter() + .filter_map(|event_attr| { + let event = Ident::new(&event_attr.event).ok()?; + match &event_attr.binding { + ast::EventBinding::Forward => None, // rejected by markup pass + ast::EventBinding::Emit { name, args } => Some(ir::ElementEventBindingIr { + event, + emit: Ident::new(name).ok()?, + args: self.lower_args(args), + }), + } + }) + .collect(); + let text = el + .children + .iter() + .filter_map(|c| match c { + ast::Node::Text { runs, .. } => Some(runs), + _ => None, + }) + .flatten() + .map(|run| match run { + ast::TextRun::Literal(s) => ir::TextRunIr::Literal(s.clone()), + ast::TextRun::Interp(e) => ir::TextRunIr::Interp(self.lower_expr(e)), + }) + .collect(); + let children = self.lower_nodes(&el.children); + ir::NodeIr::Element(ir::ElementIr { + element, + ord, + class, + props, + events, + text, + children, + }) + } + + fn lower_component_call(&mut self, el: &ast::Element, component: Ident) -> ir::NodeIr { + let ord = self.ord(); + let props = el + .attrs + .iter() + .filter_map(|attr| { + let name = Ident::new(&attr.name).ok()?; + let value = match &attr.value { + ast::AttrValue::Bare => ir::ExprIr::Bool(true), + ast::AttrValue::Literal(s) => ir::ExprIr::Text(s.clone()), + ast::AttrValue::Expr(e) => self.lower_expr(e), + }; + Some(ir::ArgIr { name, value }) + }) + .collect(); + let emits = el + .events + .iter() + .filter_map(|event_attr| { + let emit = Ident::new(&event_attr.event).ok()?; + let target = match &event_attr.binding { + ast::EventBinding::Forward => ir::EmitTargetIr::Forward, + ast::EventBinding::Emit { name, args } => ir::EmitTargetIr::Rebind { + event: Ident::new(name).ok()?, + args: self.lower_args(args), + }, + }; + Some(ir::EmitBindingIr { emit, target }) + }) + .collect(); + ir::NodeIr::Component(ir::ComponentCallIr { + component, + ord, + props, + emits, + }) + } + + /// Re-infers an expression's type with the current typed bindings — + /// the program is already clean, so this is a lookup, not a re-check. + fn infer_ty(&self, e: &ast::Expr) -> Ty { + let mut scratch = Vec::new(); + let mut typer = Typer::new(self.env, self.resolved, &mut scratch); + typer.locals = self.locals.clone(); + typer.infer(e) + } +} + +fn lower_binop(op: ast::BinaryOp) -> ir::BinaryOpIr { + match op { + ast::BinaryOp::Add => ir::BinaryOpIr::Add, + ast::BinaryOp::Sub => ir::BinaryOpIr::Sub, + ast::BinaryOp::Concat => ir::BinaryOpIr::Concat, + ast::BinaryOp::Eq => ir::BinaryOpIr::Eq, + ast::BinaryOp::NotEq => ir::BinaryOpIr::NotEq, + ast::BinaryOp::Lt => ir::BinaryOpIr::Lt, + ast::BinaryOp::Le => ir::BinaryOpIr::Le, + ast::BinaryOp::Gt => ir::BinaryOpIr::Gt, + ast::BinaryOp::Ge => ir::BinaryOpIr::Ge, + ast::BinaryOp::And => ir::BinaryOpIr::And, + ast::BinaryOp::Or => ir::BinaryOpIr::Or, + ast::BinaryOp::Coalesce => ir::BinaryOpIr::Coalesce, + } +} diff --git a/uhura/crates/uhura-check/src/manifest.rs b/uhura/crates/uhura-check/src/manifest.rs new file mode 100644 index 0000000..148f436 --- /dev/null +++ b/uhura/crates/uhura-check/src/manifest.rs @@ -0,0 +1,188 @@ +//! The `uhura.toml` app manifest (design §3): entry route, catalog pin, +//! port bindings, fixtures, play profiles. Parsed from text; the CLI reads +//! the file. + +use std::collections::BTreeMap; + +use uhura_base::Ident; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Manifest { + pub app_name: Ident, + /// The entry route name; validated against the route table. + pub entry: Ident, + /// Corpus-relative path to the catalog TOML. + pub catalog_path: String, + /// Port name → corpus-relative contract path. The name must equal the + /// contract's own `[port] name` (link rule L1). + pub ports: BTreeMap<Ident, String>, + /// Fixture name → corpus-relative data path. + pub fixtures: BTreeMap<Ident, String>, + /// Corpus-relative path to the asset manifest, if any. + pub assets_manifest: Option<String>, + /// Play profile name → (fixture, script). + pub play: BTreeMap<Ident, PlayProfile>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PlayProfile { + pub fixture: Ident, + pub script: Ident, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestIssue { + pub path: String, + pub message: String, +} + +pub fn load_manifest(text: &str) -> Result<Manifest, Vec<ManifestIssue>> { + let mut issues: Vec<ManifestIssue> = Vec::new(); + let push = |issues: &mut Vec<ManifestIssue>, path: &str, message: String| { + issues.push(ManifestIssue { + path: path.to_string(), + message, + }); + }; + + let table: toml::Table = match text.parse() { + Ok(t) => t, + Err(e) => { + return Err(vec![ManifestIssue { + path: String::new(), + message: format!("invalid TOML: {e}"), + }]); + } + }; + for key in table.keys() { + if !["app", "catalog", "ports", "fixtures", "assets", "play"].contains(&key.as_str()) { + push(&mut issues, key, format!("unknown key `{key}`")); + } + } + + let ident_at = |issues: &mut Vec<ManifestIssue>, path: &str, v: Option<&toml::Value>| match v + .and_then(toml::Value::as_str) + { + Some(s) => match Ident::new(s) { + Ok(i) => Some(i), + Err(e) => { + issues.push(ManifestIssue { + path: path.to_string(), + message: e.to_string(), + }); + None + } + }, + None => { + issues.push(ManifestIssue { + path: path.to_string(), + message: "missing required string".into(), + }); + None + } + }; + + let app = table.get("app").and_then(toml::Value::as_table); + let app_name = app.and_then(|t| ident_at(&mut issues, "app.name", t.get("name"))); + let entry = app.and_then(|t| ident_at(&mut issues, "app.entry", t.get("entry"))); + if app.is_none() { + push(&mut issues, "app", "missing `[app]` section".into()); + } + + let catalog_path = table + .get("catalog") + .and_then(toml::Value::as_table) + .and_then(|t| t.get("path")) + .and_then(toml::Value::as_str) + .map(ToString::to_string); + if catalog_path.is_none() { + push(&mut issues, "catalog.path", "missing catalog path".into()); + } + + let string_map = |issues: &mut Vec<ManifestIssue>, section: &str| { + let mut out = BTreeMap::new(); + if let Some(toml::Value::Table(t)) = table.get(section) { + for (name, v) in t { + let path = format!("{section}.{name}"); + match (Ident::new(name), v.as_str()) { + (Ok(ident), Some(s)) => { + out.insert(ident, s.to_string()); + } + (Err(e), _) => issues.push(ManifestIssue { + path, + message: e.to_string(), + }), + (_, None) => issues.push(ManifestIssue { + path, + message: "expected a path string".into(), + }), + } + } + } + out + }; + let ports = string_map(&mut issues, "ports"); + let fixtures = string_map(&mut issues, "fixtures"); + + let assets_manifest = table + .get("assets") + .and_then(toml::Value::as_table) + .and_then(|t| t.get("manifest")) + .and_then(toml::Value::as_str) + .map(ToString::to_string); + + let mut play = BTreeMap::new(); + if let Some(toml::Value::Table(t)) = table.get("play") { + for (name, v) in t { + let path = format!("play.{name}"); + let Ok(profile_name) = Ident::new(name) else { + push(&mut issues, &path, format!("`{name}` is not kebab-case")); + continue; + }; + let Some(profile) = v.as_table() else { + push( + &mut issues, + &path, + "expected `{ fixture = …, script = … }`".into(), + ); + continue; + }; + let fixture = ident_at( + &mut issues, + &format!("{path}.fixture"), + profile.get("fixture"), + ); + let script = ident_at( + &mut issues, + &format!("{path}.script"), + profile.get("script"), + ); + if let (Some(fixture), Some(script)) = (fixture, script) { + play.insert(profile_name, PlayProfile { fixture, script }); + } + } + } + + for (name, profile) in &play { + if !fixtures.contains_key(&profile.fixture) { + push( + &mut issues, + &format!("play.{name}.fixture"), + format!("`{}` is not a declared fixture", profile.fixture), + ); + } + } + + match (app_name, entry, catalog_path) { + (Some(app_name), Some(entry), Some(catalog_path)) if issues.is_empty() => Ok(Manifest { + app_name, + entry, + catalog_path, + ports, + fixtures, + assets_manifest, + play, + }), + _ => Err(issues), + } +} diff --git a/uhura/crates/uhura-check/src/markup.rs b/uhura/crates/uhura-check/src/markup.rs new file mode 100644 index 0000000..be798ef --- /dev/null +++ b/uhura/crates/uhura-check/src/markup.rs @@ -0,0 +1,1262 @@ +//! The markup rules (§4.4, §4.8, §10): catalog authority, event +//! eligibility, children models, nested interactives, controlled +//! promotion, a11y completeness, one-root, the emit binding model, and the +//! availability-match requirement. Expressions inside markup type through +//! `Typer` with the view-position projection rule armed. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Diagnostic, Ident, Span, codes}; +use uhura_syntax::ast; + +use crate::catalog::{Catalog, ChildrenModel, ElementClass, PropType}; +use crate::infer::Typer; +use crate::resolve::{DefEnv, Resolved, SubjectKind, did_you_mean}; +use crate::types::{MapKey, Ty}; + +/// Documented patterns for things that are deliberately not elements (§10); +/// surfaced as notes on `unknown-element`. +const PATTERN_NOTES: &[(&str, &str)] = &[ + ( + "avatar", + "the avatar pattern is `<image class=…>` — see docs/patterns", + ), + ( + "card", + "the card pattern is `<view class=…>` — see docs/patterns", + ), + ( + "column", + "layout is CSS: `<view class=…>` with flex-direction", + ), + ("row", "layout is CSS: `<view class=…>` with flex-direction"), + ("stack", "layout is CSS: `<view class=…>`"), + ("grid", "layout is CSS: `<view class=…>` with display: grid"), + ( + "spacer", + "spacing is CSS: gap/padding on the parent `<view>`", + ), + ("list", "`<view role=\"list\">` with one keyed `{#each}`"), + ( + "sheet", + "sheets are surfaces — `surface <name> modality sheet`", + ), + ("dialog", "dialogs are surfaces (core surface stack)"), + ( + "video", + "video is deferred: poster `<image>` + `video-off` badge pattern", + ), +]; + +/// What one definition's markup walk produces for later passes. +pub struct MarkupFacts { + /// Class names referenced from markup (for the style existence check). + pub class_refs: Vec<(String, Span)>, +} + +struct EmitUse { + name: Ident, + on_supplementary_region: bool, +} + +pub struct MarkupChecker<'a> { + pub typer: Typer<'a>, + pub catalog: &'a Catalog, + /// Component name → its expansion contains an interactive element. + pub interactive_memo: &'a BTreeMap<Ident, bool>, + class_refs: Vec<(String, Span)>, + emit_uses: Vec<EmitUse>, +} + +pub fn check_markup( + env: &DefEnv, + resolved: &Resolved, + catalog: &Catalog, + interactive_memo: &BTreeMap<Ident, bool>, + markup: &[ast::Node], + file_span: Span, + diags: &mut Vec<Diagnostic>, +) -> MarkupFacts { + let mut typer = Typer::new(env, resolved, diags); + typer.in_view = true; + let mut checker = MarkupChecker { + typer, + catalog, + interactive_memo, + class_refs: Vec::new(), + emit_uses: Vec::new(), + }; + + // One root, and it must be keyable (§4.4/§8.1). + let roots: Vec<&ast::Node> = markup + .iter() + .filter(|n| !matches!(n, ast::Node::Error { .. })) + .collect(); + if roots.len() != 1 { + checker.typer.diags.push(Diagnostic::error( + codes::ONE_ROOT.0, + codes::ONE_ROOT.1, + format!( + "a definition has exactly one root element, found {}", + roots.len() + ), + roots.get(1).map_or(file_span, |n| node_span(n)), + )); + } + if let Some(root) = roots.first() + && !matches!(root, ast::Node::Element(_) | ast::Node::Match { .. }) + { + checker.typer.diags.push(Diagnostic::error( + codes::ONE_ROOT.0, + codes::ONE_ROOT.1, + "the root must be an element (or a `{#match}` whose arms each have one root)" + .to_string(), + node_span(root), + )); + } + checker.walk_nodes(markup, false); + + // Supplementary regions need a same-named emit reachable from a + // focusable element (§10 — name-level check). + let focusable: BTreeSet<&Ident> = checker + .emit_uses + .iter() + .filter(|u| !u.on_supplementary_region) + .map(|u| &u.name) + .collect(); + let mut flagged = BTreeSet::new(); + for emit_use in &checker.emit_uses { + if emit_use.on_supplementary_region + && !focusable.contains(&emit_use.name) + && flagged.insert(emit_use.name.clone()) + { + checker.typer.diags.push(Diagnostic::error( + codes::SUPPLEMENTARY_UNREACHABLE.0, + codes::SUPPLEMENTARY_UNREACHABLE.1, + format!( + "`{}` is only reachable through a supplementary region; a focusable \ + element in this definition must also emit it (§10)", + emit_use.name + ), + file_span, + )); + } + } + + MarkupFacts { + class_refs: checker.class_refs, + } +} + +fn node_span(node: &ast::Node) -> Span { + match node { + ast::Node::Element(el) => el.span, + ast::Node::Text { span, .. } + | ast::Node::If { span, .. } + | ast::Node::Each { span, .. } + | ast::Node::Match { span, .. } + | ast::Node::Error { span } => *span, + } +} + +impl MarkupChecker<'_> { + fn error(&mut self, code: (&'static str, &'static str), message: String, span: Span) { + self.typer + .diags + .push(Diagnostic::error(code.0, code.1, message, span)); + } + + fn walk_nodes(&mut self, nodes: &[ast::Node], in_interactive: bool) { + for node in nodes { + self.walk_node(node, in_interactive); + } + } + + fn walk_node(&mut self, node: &ast::Node, in_interactive: bool) { + match node { + ast::Node::Error { .. } => {} + ast::Node::Text { span, .. } => { + self.error( + codes::INTERP_OUTSIDE_TEXT, + "text content (and `{expr}` interpolation) lives inside `<text>` only (§4.4)" + .to_string(), + *span, + ); + } + ast::Node::If { + cond, then, els, .. + } => { + self.typer.check(cond, &Ty::Bool); + self.walk_nodes(then, in_interactive); + if let Some(els) = els { + self.walk_nodes(els, in_interactive); + } + } + ast::Node::Each { .. } => self.walk_each(node, in_interactive), + ast::Node::Match { .. } => self.walk_match(node, in_interactive), + ast::Node::Element(el) => { + let Ok(name) = Ident::new(&el.name) else { + return; + }; + if self.catalog.elements.contains_key(&name) { + self.walk_element(el, &name, in_interactive); + } else if self.typer.resolved.components.contains_key(&name) { + self.walk_component_call(el, &name, in_interactive); + } else { + let mut d = Diagnostic::error( + codes::UNKNOWN_ELEMENT.0, + codes::UNKNOWN_ELEMENT.1, + format!( + "`<{name}>` is neither a catalog element nor an imported component" + ), + el.span, + ); + if let Some((_, note)) = PATTERN_NOTES.iter().find(|(p, _)| *p == name.as_str()) + { + d = d.with_note((*note).to_string()); + } else if let Some(s) = did_you_mean( + &name, + self.catalog + .elements + .keys() + .chain(self.typer.resolved.components.keys()), + ) { + d = d.with_note(format!("did you mean `<{s}>`?")); + } + if self.typer.resolved.surfaces.contains_key(&name) { + d = d.with_note(format!( + "`{name}` is a surface — surfaces mount via `open-surface`, \ + not markup" + )); + } + self.typer.diags.push(d); + } + } + } + } + + // ── {#each} ──────────────────────────────────────────────────────── + + fn walk_each(&mut self, node: &ast::Node, in_interactive: bool) { + let ast::Node::Each { + item, + seq, + key, + body, + span, + } = node + else { + return; + }; + let seq_ty = self.typer.infer(seq); + let item_ty = match seq_ty { + Ty::List(t) => *t, + Ty::Map(k, _) => match k { + MapKey::Id => Ty::Id, + MapKey::Tag => Ty::Tag, + }, + Ty::Error => Ty::Error, + other => { + self.error( + codes::BAD_OPERAND, + format!( + "`{{#each}}` iterates lists (items) and maps (keys), not {}", + other.describe() + ), + *span, + ); + Ty::Error + } + }; + let mark = self.typer.locals.len(); + self.typer.push_local(item, item_ty, *span); + let key_ty = self.typer.infer(key); + if !matches!(key_ty, Ty::Id | Ty::Tag | Ty::Text | Ty::Int | Ty::Error) { + self.error( + codes::TYPE_MISMATCH, + format!( + "each-keys are identity values (id | tag | text | int), got {}", + key_ty.describe() + ), + *span, + ); + } + self.walk_nodes(body, in_interactive); + self.typer.truncate_locals(mark); + } + + // ── {#match} ─────────────────────────────────────────────────────── + + fn walk_match(&mut self, node: &ast::Node, in_interactive: bool) { + let ast::Node::Match { + scrutinee, + arms, + span, + } = node + else { + return; + }; + if let Some(proj_ty) = self.availability_scrutinee(scrutinee) { + self.walk_availability_arms(arms, &proj_ty, *span, in_interactive); + } else { + let ty = self.typer.infer(scrutinee); + match ty { + Ty::Union(variants) => { + self.walk_union_arms(arms, &variants, *span, in_interactive); + } + Ty::Error => { + for arm in arms { + let mark = self.typer.locals.len(); + if let Some(binding) = &arm.binding { + self.typer.push_local(binding, Ty::Error, arm.span); + } + self.walk_nodes(&arm.body, in_interactive); + self.typer.truncate_locals(mark); + } + } + other => { + self.error( + codes::BAD_UNION_ARMS, + format!( + "`{{#match}}` works on port unions and projection availability, \ + not {}", + other.describe() + ), + *span, + ); + } + } + } + } + + /// A scrutinee that is a projection read makes this an availability + /// match (§9.2); returns the projection's value type. + fn availability_scrutinee(&mut self, scrutinee: &ast::Expr) -> Option<Ty> { + match &scrutinee.kind { + ast::ExprKind::Ident(name) => { + let ident = Ident::new(name).ok()?; + let proj = self.typer.env.projections.get(&ident)?; + if proj.key.is_some() { + self.error( + codes::WRONG_ARGS, + format!("projection `{ident}` is keyed — match on `{ident}(<key>)`"), + scrutinee.span, + ); + return Some(Ty::Error); + } + Some(proj.ty.clone()) + } + ast::ExprKind::Call { name, args } => { + let ident = Ident::new(name).ok()?; + let proj = self.typer.env.projections.get(&ident)?; + let ty = proj.ty.clone(); + let key = proj.key.clone(); + match key { + Some(key_ty) if args.len() == 1 => { + // The key expression types in *non-view* position: + // it is data feeding the read, not a read itself. + self.typer.in_view = false; + self.typer.check(&args[0], &key_ty); + self.typer.in_view = true; + } + _ => { + self.error( + codes::WRONG_ARGS, + format!("keyed read `{ident}(<key>)` takes exactly one key"), + scrutinee.span, + ); + } + } + Some(ty) + } + _ => None, + } + } + + fn walk_availability_arms( + &mut self, + arms: &[ast::MatchArm], + ready_ty: &Ty, + span: Span, + in_interactive: bool, + ) { + let mut seen = BTreeSet::new(); + for arm in arms { + let variant = match &arm.pattern { + ast::MatchPattern::Else => { + self.error( + codes::BAD_AVAILABILITY_ARMS, + "availability matches spell out `loading | failed | ready` — no `{:else}`" + .to_string(), + arm.span, + ); + continue; + } + ast::MatchPattern::Variant(v) => v.as_str(), + }; + if !seen.insert(variant.to_string()) { + self.error( + codes::BAD_AVAILABILITY_ARMS, + format!("duplicate `{{:when {variant}}}` arm"), + arm.span, + ); + } + let binding_ty = match variant { + "loading" => { + if arm.binding.is_some() { + self.error( + codes::BAD_AVAILABILITY_ARMS, + "`loading` carries no value to bind".to_string(), + arm.span, + ); + } + None + } + "failed" => Some(Ty::Text), + "ready" => Some(ready_ty.clone()), + other => { + self.error( + codes::BAD_AVAILABILITY_ARMS, + format!( + "`{other}` is not an availability arm (loading | failed | ready — \ + §9.2: these are language arms, never contract types)" + ), + arm.span, + ); + None + } + }; + let mark = self.typer.locals.len(); + if let (Some(binding), Some(ty)) = (&arm.binding, binding_ty) { + self.typer.push_local(binding, ty, arm.span); + } + self.walk_nodes(&arm.body, in_interactive); + self.typer.truncate_locals(mark); + } + for required in ["loading", "failed", "ready"] { + if !seen.contains(required) { + self.error( + codes::BAD_AVAILABILITY_ARMS, + format!( + "availability match is missing its `{{:when {required}}}` arm — \ + absence is a state the design must show (§9.2)" + ), + span, + ); + } + } + } + + fn walk_union_arms( + &mut self, + arms: &[ast::MatchArm], + variants: &BTreeMap<Ident, BTreeMap<Ident, Ty>>, + span: Span, + in_interactive: bool, + ) { + let mut seen: BTreeSet<Ident> = BTreeSet::new(); + let mut has_else = false; + for arm in arms { + let mark = self.typer.locals.len(); + match &arm.pattern { + ast::MatchPattern::Else => { + has_else = true; + } + ast::MatchPattern::Variant(v) => { + if let Ok(variant) = Ident::new(v) { + match variants.get(&variant) { + Some(fields) => { + if !seen.insert(variant.clone()) { + self.error( + codes::BAD_UNION_ARMS, + format!("duplicate `{{:when {variant}}}` arm"), + arm.span, + ); + } + if let Some(binding) = &arm.binding { + self.typer.push_local( + binding, + Ty::Record(fields.clone()), + arm.span, + ); + } + } + None => { + let names: Vec<&str> = variants.keys().map(Ident::as_str).collect(); + self.error( + codes::BAD_UNION_ARMS, + format!( + "`{variant}` is not a variant (union has {})", + names.join(" | ") + ), + arm.span, + ); + } + } + } + } + } + self.walk_nodes(&arm.body, in_interactive); + self.typer.truncate_locals(mark); + } + if !has_else { + for variant in variants.keys() { + if !seen.contains(variant) { + self.error( + codes::BAD_UNION_ARMS, + format!( + "non-exhaustive match: `{variant}` is unhandled (add the arm or \ + `{{:else}}`)" + ), + span, + ); + } + } + } + } + + // ── catalog elements ─────────────────────────────────────────────── + + fn walk_element(&mut self, el: &ast::Element, name: &Ident, in_interactive: bool) { + let decl = self.catalog.elements[name].clone(); + + if decl.class == ElementClass::Interactive && in_interactive { + self.error( + codes::NESTED_INTERACTIVE, + format!("`<{name}>` cannot nest inside another interactive element (§10)"), + el.span, + ); + } + + // ── attributes ───────────────────────────────────────────────── + let mut bound: BTreeMap<Ident, Span> = BTreeMap::new(); + let mut role_literal: Option<String> = None; + for attr in &el.attrs { + let Ok(attr_name) = Ident::new(&attr.name) else { + continue; + }; + if let Some(prev) = bound.insert(attr_name.clone(), attr.span) { + self.typer.diags.push( + Diagnostic::error( + codes::DUPLICATE_ATTR.0, + codes::DUPLICATE_ATTR.1, + format!("`{attr_name}` is bound twice"), + attr.span, + ) + .with_label(prev, "first bound here"), + ); + continue; + } + if attr_name.as_str() == "class" { + self.collect_class_attr(attr); + continue; + } + let Some(prop) = decl.props.get(&attr_name) else { + let mut d = Diagnostic::error( + codes::UNKNOWN_PROP.0, + codes::UNKNOWN_PROP.1, + format!("`<{name}>` has no semantic prop `{attr_name}`"), + attr.span, + ); + if let Some(s) = did_you_mean(&attr_name, decl.props.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } else { + d = d.with_note( + "styling props do not exist — layout and aesthetics are CSS (§10)" + .to_string(), + ); + } + self.typer.diags.push(d); + continue; + }; + if name.as_str() == "view" + && attr_name.as_str() == "role" + && let ast::AttrValue::Literal(v) = &attr.value + { + role_literal = Some(v.clone()); + } + self.check_prop_value(name, &attr_name, &prop.ty, &attr.value, attr.span); + } + + for (prop_name, prop) in &decl.props { + if prop.required && !bound.contains_key(prop_name) { + let in_xor_group = decl + .exactly_one_of + .iter() + .any(|group| group.contains(prop_name)); + if !in_xor_group { + self.error( + codes::MISSING_REQUIRED_PROP, + format!("`<{name}>` requires `{prop_name}`"), + el.span, + ); + } + } + } + for group in &decl.exactly_one_of { + let present = group.iter().filter(|p| bound.contains_key(*p)).count(); + if present != 1 { + let names: Vec<&str> = group.iter().map(Ident::as_str).collect(); + self.error( + codes::A11Y_ALT, + format!("`<{name}>` takes exactly one of {}", names.join(" / ")), + el.span, + ); + } + } + + // ── events ───────────────────────────────────────────────────── + let mut events_bound: BTreeSet<Ident> = BTreeSet::new(); + for event_attr in &el.events { + let Ok(event_name) = Ident::new(&event_attr.event) else { + continue; + }; + let Some(event_decl) = decl.events.get(&event_name) else { + let mut d = Diagnostic::error( + codes::EVENT_NOT_DECLARED.0, + codes::EVENT_NOT_DECLARED.1, + format!("`<{name}>` declares no `{event_name}` event"), + event_attr.span, + ); + if decl.class == ElementClass::Layout && !decl.viewport { + d = d.with_note( + "`on:` never attaches to layout elements — wrap the content in \ + `<region>` (§4.8; never auto-repaired)" + .to_string(), + ); + } + self.typer.diags.push(d); + continue; + }; + events_bound.insert(event_name.clone()); + match &event_attr.binding { + ast::EventBinding::Forward => { + self.error( + codes::ELEMENT_EVENT_NEEDS_EMIT, + format!( + "element events bind explicitly: \ + `on:{event_name}={{emit <machine-event>(…)}}` (§4.4)" + ), + event_attr.span, + ); + } + ast::EventBinding::Emit { + name: emit_name, + args, + } => { + self.check_emit_binding(emit_name, args, &event_decl.carries, event_attr.span); + if let Ok(emit) = Ident::new(emit_name) { + self.emit_uses.push(EmitUse { + name: emit, + on_supplementary_region: name.as_str() == "region" + && bound.iter().any(|(b, _)| b.as_str() == "supplementary"), + }); + } + } + } + } + + // Controlled promotion (§10): binding `value` obligates `change`. + if let Some((prop, event)) = &decl.controlled + && bound.contains_key(prop) + && !events_bound.contains(event) + { + self.error( + codes::CONTROLLED_PROMOTION, + format!( + "binding `{prop}` makes `<{name}>` controlled — it must handle \ + `on:{event}` (§10)" + ), + el.span, + ); + } + + // ── children ─────────────────────────────────────────────────── + let now_interactive = in_interactive || decl.class == ElementClass::Interactive; + let children: Vec<&ast::Node> = el + .children + .iter() + .filter(|c| !matches!(c, ast::Node::Error { .. })) + .collect(); + match decl.children { + ChildrenModel::Any => self.walk_nodes(&el.children, now_interactive), + ChildrenModel::None => { + if !children.is_empty() { + self.error( + codes::BAD_CHILDREN, + format!("`<{name}>` takes no children"), + el.span, + ); + } + } + ChildrenModel::Text => { + for child in &children { + match child { + ast::Node::Text { runs, .. } => { + for run in runs { + if let ast::TextRun::Interp(expr) = run { + self.typer.check(expr, &Ty::Text); + } + } + } + other => { + self.error( + codes::BAD_CHILDREN, + format!("`<{name}>` holds text runs only"), + node_span(other), + ); + } + } + } + } + ChildrenModel::Content => { + for child in &children { + let ok = match child { + ast::Node::Element(child_el) => Ident::new(&child_el.name) + .ok() + .and_then(|n| self.catalog.elements.get(&n)) + .is_some_and(|d| d.class == ElementClass::Content), + _ => false, + }; + if !ok { + self.error( + codes::BAD_CHILDREN, + format!( + "`<{name}>` children are content elements (text / image / icon)" + ), + node_span(child), + ); + } + } + self.walk_nodes(&el.children, now_interactive); + } + ChildrenModel::One => { + if children.len() != 1 || !matches!(children.first(), Some(ast::Node::Element(_))) { + self.error( + codes::BAD_CHILDREN, + format!("`<{name}>` wraps exactly one element"), + el.span, + ); + } + self.walk_nodes(&el.children, now_interactive); + } + ChildrenModel::KeyedEach => { + if children.len() != 1 || !matches!(children.first(), Some(ast::Node::Each { .. })) + { + self.error( + codes::BAD_CHILDREN, + format!( + "`<{name}>` children come from exactly one keyed `{{#each}}` (§10)" + ), + el.span, + ); + } + self.walk_nodes(&el.children, now_interactive); + } + } + + // role="list" requires one keyed each (§10 a11y completeness). + if role_literal.as_deref() == Some("list") + && (children.len() != 1 || !matches!(children.first(), Some(ast::Node::Each { .. }))) + { + self.error( + codes::LIST_NEEDS_KEYED_EACH, + "`role=\"list\"` promises list semantics — children come from exactly one \ + keyed `{#each}` (§10)" + .to_string(), + el.span, + ); + } + } + + fn check_prop_value( + &mut self, + element: &Ident, + prop: &Ident, + ty: &PropType, + value: &ast::AttrValue, + span: Span, + ) { + let expected = match ty { + PropType::Text => Ty::Text, + PropType::Bool => Ty::Bool, + PropType::Int => Ty::Int, + PropType::Asset => Ty::Asset, + PropType::Enum(values) => Ty::Enum(values.clone()), + PropType::Icon => Ty::Enum(self.catalog.icons.clone()), + }; + match value { + ast::AttrValue::Bare => { + if !matches!(ty, PropType::Bool) { + self.error( + codes::TYPE_MISMATCH, + format!( + "bare `{prop}` means `true`; `<{element}>`'s `{prop}` is {}", + ty.describe() + ), + span, + ); + } + } + ast::AttrValue::Literal(s) => match ty { + PropType::Icon => { + if let Ok(icon) = Ident::new(s) { + if !self.catalog.icons.contains(&icon) { + let mut d = Diagnostic::error( + codes::UNKNOWN_ICON.0, + codes::UNKNOWN_ICON.1, + format!("`{s}` is not in the catalog icon set"), + span, + ); + if let Some(near) = did_you_mean(&icon, self.catalog.icons.iter()) { + d = d.with_note(format!("did you mean `{near}`?")); + } + self.typer.diags.push(d); + } + } else { + self.error( + codes::UNKNOWN_ICON, + format!("`{s}` is not an icon name"), + span, + ); + } + } + PropType::Enum(values) => { + if !values.iter().any(|v| v.as_str() == s) { + let names: Vec<&str> = values.iter().map(Ident::as_str).collect(); + self.error( + codes::TYPE_MISMATCH, + format!("`\"{s}\"` is not one of {}", names.join(" | ")), + span, + ); + } + } + PropType::Text => {} + other => { + self.error( + codes::TYPE_MISMATCH, + format!("`{prop}` is {}, not a text literal", other.describe()), + span, + ); + } + }, + ast::AttrValue::Expr(expr) => self.typer.check(expr, &expected), + } + } + + /// `on:<event>={emit <machine-event>(args)}` on a catalog element: + /// the target signature must equal author args ∪ carried fields (§4.2). + fn check_emit_binding( + &mut self, + emit_name: &str, + args: &[ast::Arg], + carries: &BTreeMap<Ident, PropType>, + span: Span, + ) { + let Ok(emit) = Ident::new(emit_name) else { + return; + }; + for arg in args { + if Ident::new(&arg.name).is_ok_and(|a| carries.contains_key(&a)) { + self.error( + codes::CARRIED_FIELD_NAMED, + format!( + "`{}` is carried by the renderer — the author may not bind it (§4.2)", + arg.name + ), + arg.span, + ); + } + } + + let signature = self.target_signature(&emit, span); + let Some(signature) = signature else { + for arg in args { + self.typer.infer(&arg.value); + } + return; + }; + + // Author args typecheck against the signature. + for arg in args { + match signature.iter().find(|(n, _)| n.as_str() == arg.name) { + Some((_, ty)) => { + let ty = ty.clone(); + self.typer.check(&arg.value, &ty); + } + None => { + self.error( + codes::WRONG_ARGS, + format!("`{emit}` has no param `{}`", arg.name), + arg.span, + ); + self.typer.infer(&arg.value); + } + } + } + // Coverage: args ∪ carries must equal the signature. + for (param, param_ty) in &signature { + let by_author = args.iter().any(|a| a.name == param.as_str()); + let by_carry = carries.get(param).map(|c| match c { + PropType::Text => Ty::Text, + PropType::Bool => Ty::Bool, + _ => Ty::Int, + }); + match (by_author, by_carry) { + (true, _) => {} + (false, Some(carry_ty)) => { + if carry_ty != *param_ty { + self.error( + codes::WRONG_ARGS, + format!( + "carried field `{param}` is {}, but the handler declares {}", + carry_ty.describe(), + param_ty.describe() + ), + span, + ); + } + } + (false, None) => { + self.error( + codes::WRONG_ARGS, + format!("`{emit}` needs `{param}` (§4.2: payload = args ∪ carried fields)"), + span, + ); + } + } + } + } + + /// The machine-event signature an emit targets: own handlers for + /// pages/surfaces, the `emits` declaration for components. + fn target_signature(&mut self, emit: &Ident, span: Span) -> Option<Vec<(Ident, Ty)>> { + let env = self.typer.env; + if matches!(env.kind, SubjectKind::Component { .. }) { + match env.emits.get(emit) { + Some(sig) => Some(sig.clone()), + None => { + let mut d = Diagnostic::error( + codes::UNDECLARED_EMIT.0, + codes::UNDECLARED_EMIT.1, + format!("`{emit}` is not declared in this component's `emits` block"), + span, + ); + if let Some(s) = did_you_mean(emit, env.emits.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.typer.diags.push(d); + None + } + } + } else { + match env.events.get(emit) { + Some(sig) => Some(sig.clone()), + None => { + let mut d = Diagnostic::error( + codes::UNRESOLVED_NAME.0, + codes::UNRESOLVED_NAME.1, + format!("no handler for `{emit}` in this file's store"), + span, + ); + if let Some(s) = did_you_mean(emit, env.events.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.typer.diags.push(d); + None + } + } + } + } + + // ── component calls ──────────────────────────────────────────────── + + fn walk_component_call(&mut self, el: &ast::Element, name: &Ident, in_interactive: bool) { + if !self.typer.env.component_imports.contains_key(name) { + self.error( + codes::UNKNOWN_ELEMENT, + format!("`<{name}>` exists but is not imported — add `use component {name}`"), + el.span, + ); + return; + } + let target = &self.typer.resolved.components[name]; + let target_props: Vec<(Ident, Ty)> = target + .props + .iter() + .map(|(n, t)| (n.clone(), t.clone())) + .collect(); + let target_emits: BTreeMap<Ident, Vec<(Ident, Ty)>> = target.emits.clone(); + + if in_interactive && self.interactive_memo.get(name).copied().unwrap_or(false) { + self.error( + codes::NESTED_INTERACTIVE, + format!( + "`<{name}>` expands to interactive content — it cannot nest inside an \ + interactive element (§10)" + ), + el.span, + ); + } + if !el.children.is_empty() { + self.error( + codes::BAD_CHILDREN, + "components take no children in the spike (no slots — §14 deferred)".to_string(), + el.span, + ); + } + + // ── props ────────────────────────────────────────────────────── + let mut bound: BTreeSet<Ident> = BTreeSet::new(); + for attr in &el.attrs { + let Ok(attr_name) = Ident::new(&attr.name) else { + continue; + }; + if !bound.insert(attr_name.clone()) { + self.error( + codes::DUPLICATE_ATTR, + format!("`{attr_name}` is bound twice"), + attr.span, + ); + continue; + } + let Some((_, ty)) = target_props.iter().find(|(n, _)| *n == attr_name) else { + let mut d = Diagnostic::error( + codes::UNKNOWN_PROP.0, + codes::UNKNOWN_PROP.1, + format!("`<{name}>` declares no prop `{attr_name}`"), + attr.span, + ); + if attr_name.as_str() == "class" { + d = d.with_note( + "a component's root class is its own markup's business".to_string(), + ); + } else if let Some(s) = + did_you_mean(&attr_name, target_props.iter().map(|(n, _)| n)) + { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.typer.diags.push(d); + continue; + }; + let ty = ty.clone(); + match &attr.value { + ast::AttrValue::Bare => { + if ty != Ty::Bool { + self.error( + codes::TYPE_MISMATCH, + format!( + "bare `{attr_name}` means `true`; the prop is {}", + ty.describe() + ), + attr.span, + ); + } + } + ast::AttrValue::Literal(s) => { + let lit = ast::Expr { + kind: ast::ExprKind::Str(s.clone()), + span: attr.span, + }; + self.typer.check(&lit, &ty); + } + ast::AttrValue::Expr(expr) => self.typer.check(expr, &ty), + } + } + for (prop_name, ty) in &target_props { + if !bound.contains(prop_name) && !matches!(ty, Ty::Option(_)) { + self.error( + codes::MISSING_REQUIRED_PROP, + format!("`<{name}>` requires `{prop_name}`"), + el.span, + ); + } + } + + // ── emit consumption (§4.4: one model, explicit) ─────────────── + let mut consumed: BTreeSet<Ident> = BTreeSet::new(); + for event_attr in &el.events { + let Ok(emit) = Ident::new(&event_attr.event) else { + continue; + }; + let Some(emit_sig) = target_emits.get(&emit) else { + let mut d = Diagnostic::error( + codes::UNDECLARED_EMIT.0, + codes::UNDECLARED_EMIT.1, + format!("`<{name}>` declares no emit `{emit}`"), + event_attr.span, + ); + if let Some(s) = did_you_mean(&emit, target_emits.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } + self.typer.diags.push(d); + continue; + }; + if !consumed.insert(emit.clone()) { + self.error( + codes::DUPLICATE_ATTR, + format!("`on:{emit}` is bound twice"), + event_attr.span, + ); + } + match &event_attr.binding { + ast::EventBinding::Forward => { + // Same name, same payload, enclosing machine scope. + let Some(own_sig) = self.target_signature(&emit, event_attr.span) else { + continue; + }; + if own_sig != *emit_sig { + self.error( + codes::WRONG_ARGS, + format!( + "forwarding `{emit}` requires the identical signature in the \ + enclosing scope (§4.4)" + ), + event_attr.span, + ); + } + } + ast::EventBinding::Emit { + name: rebind_name, + args, + } => { + // Rebind: new event, args in caller scope, component + // payload discarded — so no carries here. + self.check_emit_binding(rebind_name, args, &BTreeMap::new(), event_attr.span); + } + } + } + for emit in target_emits.keys() { + if !consumed.contains(emit) { + self.typer.diags.push(Diagnostic::warning( + codes::UNHANDLED_EVENT.0, + codes::UNHANDLED_EVENT.1, + format!( + "`<{name}>` emits `{emit}` but this call site leaves it unbound — \ + the control will be dead (§4.4)" + ), + el.span, + )); + } + } + } + + fn collect_class_attr(&mut self, attr: &ast::Attr) { + match &attr.value { + ast::AttrValue::Literal(s) => { + for class in s.split_whitespace() { + self.class_refs.push((class.to_string(), attr.span)); + } + } + ast::AttrValue::Expr(expr) => { + self.typer.check(expr, &Ty::Text); + collect_string_literals(expr, &mut |s, span| { + for class in s.split_whitespace() { + self.class_refs.push((class.to_string(), span)); + } + }); + } + ast::AttrValue::Bare => { + self.error( + codes::TYPE_MISMATCH, + "`class` needs a value".to_string(), + attr.span, + ); + } + } + } +} + +/// Computes, for every component, whether its expansion contains an +/// interactive element (for the nested-interactives rule across component +/// boundaries). The import graph is a DAG, so plain recursion with a memo +/// terminates. +pub fn interactive_content_memo( + resolved: &Resolved, + sources: &[crate::resolve::ParsedSource], + catalog: &Catalog, +) -> BTreeMap<Ident, bool> { + fn nodes_interactive( + nodes: &[ast::Node], + catalog: &Catalog, + resolved: &Resolved, + sources: &[crate::resolve::ParsedSource], + memo: &mut BTreeMap<Ident, bool>, + ) -> bool { + nodes.iter().any(|node| match node { + ast::Node::Element(el) => { + let Ok(name) = Ident::new(&el.name) else { + return false; + }; + if let Some(decl) = catalog.elements.get(&name) { + decl.class == ElementClass::Interactive + || nodes_interactive(&el.children, catalog, resolved, sources, memo) + } else { + component_interactive(&name, catalog, resolved, sources, memo) + } + } + ast::Node::If { then, els, .. } => { + nodes_interactive(then, catalog, resolved, sources, memo) + || els + .as_ref() + .is_some_and(|e| nodes_interactive(e, catalog, resolved, sources, memo)) + } + ast::Node::Each { body, .. } => { + nodes_interactive(body, catalog, resolved, sources, memo) + } + ast::Node::Match { arms, .. } => arms + .iter() + .any(|arm| nodes_interactive(&arm.body, catalog, resolved, sources, memo)), + _ => false, + }) + } + + fn component_interactive( + name: &Ident, + catalog: &Catalog, + resolved: &Resolved, + sources: &[crate::resolve::ParsedSource], + memo: &mut BTreeMap<Ident, bool>, + ) -> bool { + if let Some(&known) = memo.get(name) { + return known; + } + memo.insert(name.clone(), false); // cycle backstop (DAG-checked anyway) + let result = + resolved + .components + .get(name) + .is_some_and(|env| match &sources[env.source].parsed { + uhura_syntax::Parsed::Module(ast) => { + nodes_interactive(&ast.markup, catalog, resolved, sources, memo) + } + uhura_syntax::Parsed::Examples(_) => false, + }); + memo.insert(name.clone(), result); + result + } + + let mut memo = BTreeMap::new(); + let names: Vec<Ident> = resolved.components.keys().cloned().collect(); + for name in names { + component_interactive(&name, catalog, resolved, sources, &mut memo); + } + memo +} + +fn collect_string_literals(expr: &ast::Expr, f: &mut impl FnMut(&str, Span)) { + match &expr.kind { + ast::ExprKind::Str(s) => f(s, expr.span), + ast::ExprKind::If { cond, then, els } => { + collect_string_literals(cond, f); + collect_string_literals(then, f); + collect_string_literals(els, f); + } + ast::ExprKind::Binary { lhs, rhs, .. } => { + collect_string_literals(lhs, f); + collect_string_literals(rhs, f); + } + ast::ExprKind::Unary { expr, .. } => collect_string_literals(expr, f), + _ => {} + } +} diff --git a/uhura/crates/uhura-check/src/pipeline.rs b/uhura/crates/uhura-check/src/pipeline.rs new file mode 100644 index 0000000..35ecb18 --- /dev/null +++ b/uhura/crates/uhura-check/src/pipeline.rs @@ -0,0 +1,408 @@ +//! The check pipeline driver (§12.2 order): parse → routes/resolve → +//! catalog pin → port link + lock → typecheck (stores) → markup rules → +//! style checks → examples legality → lower (zero-error gated). Pure over +//! in-memory inputs; the CLI does every file read and write. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Diagnostic, Ident, SourceMap, Span, codes, has_errors}; +use uhura_port::PortContract; +use uhura_syntax::{Parsed, SourceKind, parse}; + +use crate::catalog::{Catalog, load_catalog}; +use crate::examples::check_examples; +use crate::infer::check_store; +use crate::lower::{Lowered, lower}; +use crate::manifest::Manifest; +use crate::markup::{check_markup, interactive_content_memo}; +use crate::resolve::{ParsedSource, resolve}; +use crate::style::{check_class_existence, check_style_block, compile_stylesheet, theme_classes}; +use crate::types::PortTypes; + +pub struct SourceInput { + pub rel_path: String, + pub text: String, + pub kind: SourceKind, +} + +pub struct CheckInput { + pub manifest: Manifest, + pub manifest_rel_path: String, + pub manifest_text: String, + /// (corpus-relative path, text) — `None` text = unreadable/missing. + pub catalog_file: (String, Option<String>), + /// Port name → (rel path, text). + pub port_files: BTreeMap<Ident, (String, Option<String>)>, + pub sources: Vec<SourceInput>, + pub theme_css: Option<(String, String)>, + /// Fixture name → (rel path, text) — from the manifest's `[fixtures]`. + pub fixture_files: BTreeMap<Ident, (String, Option<String>)>, + /// Existing `uhura.lock` content, if any. + pub lock_text: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LockStatus { + /// No lock existed — the CLI writes the computed one (micro-decision #6). + Absent, + Match, + /// Pins differ — diagnosed as an error. + Drift, +} + +pub struct CheckOutput { + pub diagnostics: Vec<Diagnostic>, + pub source_map: SourceMap, + /// Present iff the pipeline finished with zero errors. + pub lowered: Option<Lowered>, + /// Resolved example previews (empty unless the check came up clean). + pub previews: Vec<crate::preview::ResolvedPreview>, + /// theme.css + `<style>` blocks in path order (ships beside the IR). + pub stylesheet: String, + /// The canonical lock content for this input. + pub lock_computed: String, + pub lock_status: LockStatus, +} + +pub fn check(input: &CheckInput) -> CheckOutput { + let mut sm = SourceMap::new(); + let mut diags: Vec<Diagnostic> = Vec::new(); + + let manifest_file = sm.add(input.manifest_rel_path.clone(), input.manifest_text.clone()); + + // ── catalog load + pin ───────────────────────────────────────────── + let (catalog_path, catalog_text) = &input.catalog_file; + let catalog_file = sm.add( + catalog_path.clone(), + catalog_text.clone().unwrap_or_default(), + ); + let catalog: Option<Catalog> = match catalog_text { + None => { + diags.push(Diagnostic::error( + codes::INVALID_CATALOG.0, + codes::INVALID_CATALOG.1, + format!("catalog `{catalog_path}` is missing or unreadable"), + Span::new(manifest_file, 0, 0), + )); + None + } + Some(text) => match load_catalog(text) { + Ok(c) => Some(c), + Err(issues) => { + for issue in issues { + diags.push(Diagnostic::error( + codes::INVALID_CATALOG.0, + codes::INVALID_CATALOG.1, + format!("{}: {}", issue.path, issue.message), + Span::new(catalog_file, 0, 0), + )); + } + None + } + }, + }; + + // ── port contracts (link L1: manifest name == contract name) ────── + let mut ports: BTreeMap<Ident, (PortContract, PortTypes)> = BTreeMap::new(); + for (declared_name, (rel_path, text)) in &input.port_files { + let port_file = sm.add(rel_path.clone(), text.clone().unwrap_or_default()); + let Some(text) = text else { + diags.push(Diagnostic::error( + codes::INVALID_PORT_CONTRACT.0, + codes::INVALID_PORT_CONTRACT.1, + format!("port contract `{rel_path}` is missing or unreadable"), + Span::new(manifest_file, 0, 0), + )); + continue; + }; + match uhura_port::load_port_contract(text) { + Err(issues) => { + for issue in issues { + diags.push(Diagnostic::error( + codes::INVALID_PORT_CONTRACT.0, + codes::INVALID_PORT_CONTRACT.1, + format!("{}: {}", issue.path, issue.message), + Span::new(port_file, 0, 0), + )); + } + } + Ok(contract) => { + if contract.name != *declared_name { + diags.push(Diagnostic::error( + codes::PORT_NAME_MISMATCH.0, + codes::PORT_NAME_MISMATCH.1, + format!( + "the manifest binds `{declared_name}` but the contract says \ + `[port] name = \"{}\"`", + contract.name + ), + Span::new(port_file, 0, 0), + )); + } + let types = PortTypes::build(&contract); + ports.insert(declared_name.clone(), (contract, types)); + } + } + } + + // ── lock (computed from what loaded; compared before source work) ── + let lock_computed = render_lock(catalog.as_ref(), &ports); + let lock_status = match &input.lock_text { + None => LockStatus::Absent, + Some(existing) => { + if lock_pins(existing) == lock_pins(&lock_computed) { + LockStatus::Match + } else { + diags.push( + Diagnostic::error( + codes::LOCK_DRIFT.0, + codes::LOCK_DRIFT.1, + "contract pins drifted from uhura.lock — a contract or catalog changed \ + shape (§9.1: drift is a link error, never silent)" + .to_string(), + Span::new(manifest_file, 0, 0), + ) + .with_note( + "if the change is intentional, delete uhura.lock and re-run `uhura check` \ + to re-pin" + .to_string(), + ), + ); + LockStatus::Drift + } + } + }; + + // ── parse every source ───────────────────────────────────────────── + let mut sources: Vec<ParsedSource> = Vec::new(); + for src in &input.sources { + let file = sm.add(src.rel_path.clone(), src.text.clone()); + let out = parse(file, &src.text, src.kind); + diags.extend(out.diagnostics); + sources.push(ParsedSource { + file, + rel_path: src.rel_path.clone(), + kind: src.kind, + parsed: out.parsed, + }); + } + + // ── resolve ──────────────────────────────────────────────────────── + let mut resolved = resolve( + &sources, + ports, + &input.manifest.entry, + manifest_file, + &mut diags, + ); + + // ── typecheck stores; fill machine-event signatures ──────────────── + let mut all_events = Vec::new(); + for (kind, name, env) in resolved + .pages + .iter() + .map(|(n, e)| (0u8, n.clone(), e)) + .chain(resolved.components.iter().map(|(n, e)| (1, n.clone(), e))) + .chain(resolved.surfaces.iter().map(|(n, e)| (2, n.clone(), e))) + { + let Parsed::Module(ast) = &sources[env.source].parsed else { + continue; + }; + if let Some(store) = &ast.store { + let events = check_store(env, &resolved, store, &mut diags); + all_events.push((kind, name, events)); + } + } + for (kind, name, events) in all_events { + let env = match kind { + 0 => resolved.pages.get_mut(&name), + 1 => resolved.components.get_mut(&name), + _ => resolved.surfaces.get_mut(&name), + }; + if let Some(env) = env { + env.events = events; + } + } + + // ── markup + style (need the catalog) ────────────────────────────── + let mut stylesheet = String::new(); + if let Some(catalog) = &catalog { + let memo = interactive_content_memo(&resolved, &sources, catalog); + + let mut class_refs: Vec<(String, Span)> = Vec::new(); + let mut defined_classes: BTreeSet<String> = input + .theme_css + .as_ref() + .map(|(path, css)| { + let theme_file = sm.add(path.clone(), css.clone()); + theme_classes(theme_file, css) + }) + .unwrap_or_default(); + let mut style_blocks: Vec<(String, String)> = Vec::new(); + + for env in resolved + .pages + .values() + .chain(resolved.components.values()) + .chain(resolved.surfaces.values()) + { + let src = &sources[env.source]; + let Parsed::Module(ast) = &src.parsed else { + continue; + }; + let facts = check_markup( + env, + &resolved, + catalog, + &memo, + &ast.markup, + Span::new(env.file, 0, 0), + &mut diags, + ); + class_refs.extend(facts.class_refs); + if let Some(style) = &ast.style { + defined_classes.extend(check_style_block( + env.kind.name().as_str(), + style, + &mut diags, + )); + style_blocks.push((src.rel_path.clone(), style.raw.clone())); + } + } + + check_class_existence(&class_refs, &defined_classes, &mut diags); + style_blocks.sort_by(|a, b| a.0.cmp(&b.0)); + stylesheet = compile_stylesheet( + input.theme_css.as_ref().map(|(_, css)| css.as_str()), + &style_blocks, + ); + } + + // ── examples clause legality ─────────────────────────────────────── + let env_by_source: BTreeMap<usize, &crate::resolve::DefEnv> = resolved + .pages + .values() + .chain(resolved.components.values()) + .chain(resolved.surfaces.values()) + .map(|env| (env.source, env)) + .collect(); + for (examples_idx, subject_idx) in &resolved.example_subjects { + let Parsed::Examples(file) = &sources[*examples_idx].parsed else { + continue; + }; + let Some(subject) = env_by_source.get(subject_idx) else { + continue; // subject failed resolution; already diagnosed + }; + check_examples( + file, + subject, + &resolved, + &input.manifest, + Span::new(sources[*examples_idx].file, 0, 0), + &mut diags, + ); + } + + // ── lower (zero-error gated) ─────────────────────────────────────── + let lowered = match (&catalog, has_errors(&diags)) { + (Some(catalog), false) => Some(lower(&input.manifest, &resolved, catalog, &sources)), + _ => None, + }; + + // ── fixtures + pinned example resolution (§6.2) ──────────────────── + let mut previews = Vec::new(); + if let Some(lowered) = &lowered { + let mut fixtures: BTreeMap<Ident, crate::fixture::FixtureData> = BTreeMap::new(); + let mut fixtures_ok = true; + for (name, (rel_path, text)) in &input.fixture_files { + let fixture_file = sm.add(rel_path.clone(), text.clone().unwrap_or_default()); + let Some(text) = text else { + diags.push(Diagnostic::error( + codes::INVALID_FIXTURE.0, + codes::INVALID_FIXTURE.1, + format!("fixture `{rel_path}` is missing or unreadable"), + Span::new(manifest_file, 0, 0), + )); + fixtures_ok = false; + continue; + }; + match crate::fixture::load_fixture(text) { + Ok(data) => { + fixtures.insert(name.clone(), data); + } + Err(issues) => { + for issue in issues { + diags.push(Diagnostic::error( + codes::INVALID_FIXTURE.0, + codes::INVALID_FIXTURE.1, + format!("{}: {}", issue.path, issue.message), + Span::new(fixture_file, 0, 0), + )); + } + fixtures_ok = false; + } + } + } + if fixtures_ok { + previews = crate::preview::resolve_previews( + &lowered.program, + &resolved, + &sources, + &fixtures, + &mut diags, + ); + } + } + // Late errors (fixtures, pins) gate the artifacts like early ones. + let lowered = if has_errors(&diags) { None } else { lowered }; + let previews = if has_errors(&diags) { + Vec::new() + } else { + previews + }; + + CheckOutput { + diagnostics: diags, + source_map: sm, + lowered, + previews, + stylesheet, + lock_computed, + lock_status, + } +} + +/// The canonical lock text: one pin line per contract, sorted. +fn render_lock( + catalog: Option<&Catalog>, + ports: &BTreeMap<Ident, (PortContract, PortTypes)>, +) -> String { + let mut out = String::from( + "# uhura.lock — canonical contract pins (§9.1). `uhura check` writes this\n\ + # file when absent and errors on drift; delete it to re-pin intentionally.\n", + ); + if let Some(c) = catalog { + out.push_str(&format!( + "catalog {} {} sha256:{}\n", + c.name, + c.version, + c.canonical_hash() + )); + } + for (name, (contract, _)) in ports { + out.push_str(&format!( + "port {name} {} sha256:{}\n", + contract.version, + contract.canonical_hash() + )); + } + out +} + +/// Pin lines only — comments and blank lines never count as drift. +fn lock_pins(text: &str) -> Vec<&str> { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .collect() +} diff --git a/uhura/crates/uhura-check/src/preview.rs b/uhura/crates/uhura-check/src/preview.rs new file mode 100644 index 0000000..50429da --- /dev/null +++ b/uhura/crates/uhura-check/src/preview.rs @@ -0,0 +1,609 @@ +//! Example resolution (§6.2): every example becomes a frozen, +//! ready-to-evaluate preview payload — `Page { route, U, X }` or +//! `Fragment { definition, props, state, X }`. Pinned examples bind +//! literally; derived examples (any `events` in the `from` chain) REPLAY +//! through the public machine (`crate::replay`) — self-verifying design. +//! +//! Rules implemented: `from` = merge (child wins), timelines concatenate +//! down the chain, state-pins taint (pinned badge propagates; projection +//! pins do not — §6.2), boot projections auto-bind from the fixture's +//! `boot.<name>` slices, every pin decodes against its structural type +//! (L8 at the use site), and replay failures attribute to the first +//! failing step with descendants reporting "blocked by ancestor". + +use std::collections::BTreeMap; + +use uhura_base::{Diagnostic, Ident, Span, Value, codes}; +use uhura_core::ir::ProgramIr; +use uhura_core::state::{ + Counters, NavEntry, ProjectionSnapshot, Projections, UiState, initial_state, +}; +use uhura_syntax::{Parsed, ast}; + +use crate::fixture::{FixtureData, decode_against_ty}; +use crate::replay; +use crate::resolve::{DefEnv, ParsedSource, Resolved, SubjectKind}; +use crate::types::Ty; + +#[derive(Clone, Debug)] +pub struct ResolvedPreview { + pub subject: SubjectKind, + pub example: String, + pub is_default: bool, + /// A state pin somewhere in the chain — the caption marks `pinned`. + pub pinned: bool, + /// Resolved by timeline replay (`from X → events…` provenance). + pub derived: bool, + /// Commands unsettled at the end of a derived timeline (§6.2 caption: + /// "N command(s) in flight"); always 0 for pinned examples. + pub in_flight: usize, + /// Direct parent, for the provenance caption (`from first-page`). + pub from: Option<String>, + pub note: Option<String>, + pub payload: PreviewPayload, +} + +#[derive(Clone, Debug)] +pub enum PreviewPayload { + Page { + route: Ident, + u: UiState, + x: Projections, + }, + Fragment { + surface: bool, + name: Ident, + props: BTreeMap<Ident, Value>, + state: BTreeMap<Ident, Value>, + x: Projections, + }, +} + +/// The merged content of an example after walking its `from` chain. +#[derive(Clone, Default)] +struct Effective<'a> { + params: BTreeMap<String, &'a ast::Expr>, + props: BTreeMap<String, &'a ast::Expr>, + state: BTreeMap<String, &'a ast::Expr>, + /// (port, projection, key-literal-json) → pin. + projections: BTreeMap<(String, String, String), &'a ast::ProjectionPin>, + /// The concatenated timeline down the `from` chain, ancestor-first. + events: Vec<&'a ast::ExampleEvent>, + state_pinned: bool, +} + +pub fn resolve_previews( + program: &ProgramIr, + resolved: &Resolved, + sources: &[ParsedSource], + fixtures: &BTreeMap<Ident, FixtureData>, + diags: &mut Vec<Diagnostic>, +) -> Vec<ResolvedPreview> { + let mut previews = Vec::new(); + + // Deterministic subject order: pages, surfaces, components, by name; + // examples stay in declaration order (§6.3). + let mut subjects: Vec<(&Ident, &DefEnv)> = Vec::new(); + subjects.extend(resolved.pages.iter()); + subjects.extend(resolved.surfaces.iter()); + subjects.extend(resolved.components.iter()); + + for (_, env) in subjects { + let Some((examples_idx, _)) = resolved + .example_subjects + .iter() + .find(|(_, subject_idx)| **subject_idx == env.source) + else { + continue; + }; + let src = &sources[*examples_idx]; + let Parsed::Examples(file) = &src.parsed else { + continue; + }; + resolve_file( + program, + resolved, + env, + src, + file, + fixtures, + diags, + &mut previews, + ); + } + previews +} + +#[allow(clippy::too_many_arguments)] +fn resolve_file( + program: &ProgramIr, + resolved: &Resolved, + env: &DefEnv, + src: &ParsedSource, + file: &ast::ExamplesFile, + fixtures: &BTreeMap<Ident, FixtureData>, + diags: &mut Vec<Diagnostic>, + previews: &mut Vec<ResolvedPreview>, +) { + // Single fixture import per file in the spike; its slices are the + // `fixture.` root (micro-decision). + let fixture_imports: Vec<&str> = file + .uses + .iter() + .filter_map(|u| match u { + ast::Use::Fixture { name, .. } => Some(name.as_str()), + _ => None, + }) + .collect(); + if fixture_imports.len() > 1 { + diags.push(Diagnostic::error( + codes::MULTIPLE_FIXTURES.0, + codes::MULTIPLE_FIXTURES.1, + "the spike resolves one `use fixture` per examples file".to_string(), + Span::new(src.file, 0, 0), + )); + return; + } + let empty = FixtureData::default(); + let fixture: &FixtureData = fixture_imports + .first() + .and_then(|name| Ident::new(name).ok()) + .and_then(|name| fixtures.get(&name)) + .unwrap_or(&empty); + + let mut by_name: BTreeMap<&str, Effective<'_>> = BTreeMap::new(); + let mut failed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); + + for example in &file.examples { + let mut effective = Effective::default(); + let mut from: Option<String> = None; + let mut note: Option<String> = None; + + // Parent first (earlier-declared only — legality enforced). + for clause in &example.clauses { + if let ast::ExampleClause::From { name, .. } = clause { + from = Some(name.clone()); + if let Some(parent) = by_name.get(name.as_str()) { + effective = parent.clone(); + } + } + } + + for clause in &example.clauses { + match clause { + ast::ExampleClause::From { .. } | ast::ExampleClause::Error { .. } => {} + ast::ExampleClause::Note { text, .. } => note = Some(text.clone()), + ast::ExampleClause::Params { entries, .. } => { + for (name, value) in entries { + effective.params.insert(name.clone(), value); + } + } + ast::ExampleClause::Props { entries, .. } => { + for (name, value) in entries { + effective.props.insert(name.clone(), value); + } + } + ast::ExampleClause::State { entries, .. } => { + effective.state_pinned = true; + for (name, value) in entries { + effective.state.insert(name.clone(), value); + } + } + ast::ExampleClause::Projection(pin) => { + let key = pin + .key + .as_ref() + .and_then(|k| static_json(k, fixture).ok()) + .map(|j| j.to_string()) + .unwrap_or_default(); + effective + .projections + .insert((pin.port.clone(), pin.projection.clone(), key), pin); + } + ast::ExampleClause::Events { entries, .. } => effective.events.extend(entries), + } + } + + by_name.insert(&example.name, effective.clone()); + + // A failed ancestor poisons descendants with one pointed + // diagnostic each — never a cascade of step errors (§6.2). + if let Some(parent) = &from + && failed.contains(parent.as_str()) + { + diags.push(Diagnostic::error( + codes::REPLAY_BLOCKED.0, + codes::REPLAY_BLOCKED.1, + format!( + "`{}` is blocked by ancestor `{parent}` — fix that replay first", + example.name + ), + example.span, + )); + failed.insert(example.name.clone()); + continue; + } + + let derived = !effective.events.is_empty(); + let Some(bindings) = resolve_bindings( + program, + resolved, + env, + fixture, + &effective, + example.span, + diags, + ) else { + if derived { + failed.insert(example.name.clone()); + } + continue; // errors already diagnosed + }; + + let (payload, in_flight) = if derived { + let input = replay::ReplayInput { + x: bindings.x, + params: bindings.params, + props: bindings.props, + state_pins: bindings.state_pins, + events: &effective.events, + span: example.span, + }; + match replay::replay(program, resolved, env, fixture, input) { + Ok(outcome) => (outcome.payload, outcome.in_flight), + Err(e) => { + diags.push(Diagnostic::error( + codes::REPLAY_STEP.0, + codes::REPLAY_STEP.1, + format!("in example `{}`: {}", example.name, e.message), + e.span, + )); + failed.insert(example.name.clone()); + continue; + } + } + } else { + match assemble_pinned(program, env, bindings, example.span, diags) { + Some(payload) => (payload, 0), + None => continue, + } + }; + previews.push(ResolvedPreview { + subject: env.kind.clone(), + example: example.name.clone(), + is_default: example.is_default, + pinned: effective.state_pinned, + derived, + in_flight, + from, + note, + payload, + }); + } +} + +/// The typed bindings an example resolves to before any machine runs: +/// projections (pins + boot auto-bind), params/props, and state pins — +/// shared between pinned assembly and derived replay (§6.2 evaluation +/// order: parent chain → pins → timeline fold). +pub(crate) struct Bindings { + pub x: Projections, + pub params: BTreeMap<Ident, Value>, + pub props: BTreeMap<Ident, Value>, + pub state_pins: BTreeMap<Ident, Value>, +} + +fn resolve_bindings( + program: &ProgramIr, + resolved: &Resolved, + env: &DefEnv, + fixture: &FixtureData, + effective: &Effective<'_>, + span: Span, + diags: &mut Vec<Diagnostic>, +) -> Option<Bindings> { + let mut ok = true; + let pin_error = |diags: &mut Vec<Diagnostic>, ok: &mut bool, at: Span, msg: String| { + diags.push(Diagnostic::error( + codes::PIN_DECODE.0, + codes::PIN_DECODE.1, + msg, + at, + )); + *ok = false; + }; + + // ── projections: pins + boot auto-bind ───────────────────────────── + let mut x = Projections::default(); + for ((_, _, _), pin) in &effective.projections { + let Some((port_name, proj_name)) = Ident::new(&pin.port) + .ok() + .zip(Ident::new(&pin.projection).ok()) + else { + continue; + }; + let Some((contract, port_types)) = resolved.ports.get(&port_name) else { + continue; // legality pass diagnosed + }; + let Some(decl) = contract.projections.get(&proj_name) else { + continue; + }; + let key = match (&decl.key, &pin.key) { + (Some(key_ty), Some(key_expr)) => { + let key_ty = port_types.from_expr(contract, key_ty); + match static_json(key_expr, fixture).and_then(|j| decode_against_ty(&j, &key_ty)) { + Ok(v) => Some(v), + Err(e) => { + pin_error(diags, &mut ok, pin.span, format!("projection key: {e}")); + continue; + } + } + } + (None, None) => None, + _ => continue, // keyedness mismatch diagnosed by legality + }; + // `failed("<reason>")` pins the failure state (micro-decision — + // mirrors `projection-failed`, §9.3). + if let ast::ExprKind::Call { name, args } = &pin.value.kind + && name == "failed" + { + match args.as_slice() { + [reason] => match static_json(reason, fixture) { + Ok(serde_json::Value::String(reason)) => { + x.failed.insert((proj_name.clone(), key), reason); + } + _ => pin_error( + diags, + &mut ok, + pin.span, + "`failed(…)` takes one reason string".to_string(), + ), + }, + _ => pin_error( + diags, + &mut ok, + pin.span, + "`failed(…)` takes one reason string".to_string(), + ), + } + continue; + } + let ty = port_types.from_expr(contract, &decl.ty); + match static_json(&pin.value, fixture).and_then(|j| decode_against_ty(&j, &ty)) { + Ok(value) => { + x.snapshots.insert( + (proj_name.clone(), key), + ProjectionSnapshot { revision: 1, value }, + ); + } + Err(e) => pin_error( + diags, + &mut ok, + pin.span, + format!("`{}.{}`: {e}", pin.port, pin.projection), + ), + } + } + + // Boot projections auto-bind from `boot.<name>` unless pinned (§6.1). + for (proj_name, proj) in &program.projections { + let instance = (proj_name.clone(), None); + // A `failed("…")` pin is a binding too — auto-bind must not + // override it with the boot slice. + if !proj.boot || x.snapshots.contains_key(&instance) || x.failed.contains_key(&instance) { + continue; + } + let Some((contract, port_types)) = resolved.ports.get(&proj.port) else { + continue; + }; + let Some(decl) = contract.projections.get(proj_name) else { + continue; + }; + let ty = port_types.from_expr(contract, &decl.ty); + match fixture.get("boot", proj_name.as_str()) { + None => { + diags.push(Diagnostic::error( + codes::BOOT_UNBOUND.0, + codes::BOOT_UNBOUND.1, + format!( + "boot projection `{proj_name}` needs a `boot.{proj_name}` fixture \ + slice for the resolver to auto-bind (§6.1)" + ), + span, + )); + ok = false; + } + Some(json) => match decode_against_ty(json, &ty) { + Ok(value) => { + x.snapshots.insert( + (proj_name.clone(), None), + ProjectionSnapshot { revision: 1, value }, + ); + } + Err(e) => pin_error(diags, &mut ok, span, format!("boot.{proj_name}: {e}")), + }, + } + } + + // ── typed pin decoding against the subject's declarations ────────── + let decode_map = |entries: &BTreeMap<String, &ast::Expr>, + declared: &BTreeMap<Ident, Ty>, + what: &str, + diags: &mut Vec<Diagnostic>, + ok: &mut bool| + -> BTreeMap<Ident, Value> { + let mut out = BTreeMap::new(); + for (name, expr) in entries { + let Ok(ident) = Ident::new(name) else { + continue; + }; + let Some(ty) = declared.get(&ident) else { + continue; // legality pass diagnosed + }; + match static_json(expr, fixture).and_then(|j| decode_against_ty(&j, ty)) { + Ok(value) => { + out.insert(ident, value); + } + Err(e) => { + diags.push(Diagnostic::error( + codes::PIN_DECODE.0, + codes::PIN_DECODE.1, + format!("{what} `{name}`: {e}"), + expr.span, + )); + *ok = false; + } + } + } + out + }; + + let state_pins = decode_map(&effective.state, &env.state, "state pin", diags, &mut ok); + + let (params, props) = match &env.kind { + SubjectKind::Page { .. } => { + let params = decode_map(&effective.params, &env.params, "param", diags, &mut ok); + for name in env.params.keys() { + if !params.contains_key(name) { + pin_error( + diags, + &mut ok, + span, + format!("page previews need `params {{ {name} = … }}` to mount"), + ); + } + } + (params, BTreeMap::new()) + } + SubjectKind::Component { .. } | SubjectKind::Surface { .. } => { + let props = decode_map(&effective.props, &env.props, "prop", diags, &mut ok); + for (prop, ty) in &env.props { + if !props.contains_key(prop) && !matches!(ty, Ty::Option(_)) { + pin_error( + diags, + &mut ok, + span, + format!("this example leaves required prop `{prop}` unbound"), + ); + } + } + (BTreeMap::new(), props) + } + }; + + if !ok { + return None; + } + Some(Bindings { + x, + params, + props, + state_pins, + }) +} + +/// A pinned example freezes its bindings directly — no machine runs. +fn assemble_pinned( + program: &ProgramIr, + env: &DefEnv, + bindings: Bindings, + _span: Span, + _diags: &mut [Diagnostic], +) -> Option<PreviewPayload> { + match &env.kind { + SubjectKind::Page { route } => { + let def = program.pages.get(route)?; + let mut state = initial_state(def); + state.extend(bindings.state_pins); + let mut counters = Counters::default(); + let serial = counters.mint_page(); + Some(PreviewPayload::Page { + route: route.clone(), + u: UiState { + rev: 0, + nav: vec![NavEntry { + serial, + route: route.clone(), + params: bindings.params, + state, + }], + surfaces: Vec::new(), + pending: BTreeMap::new(), + counters, + }, + x: bindings.x, + }) + } + SubjectKind::Component { name } | SubjectKind::Surface { name, .. } => { + let surface = matches!(env.kind, SubjectKind::Surface { .. }); + let def = if surface { + program.surfaces.get(name)? + } else { + program.components.get(name)? + }; + let mut state = initial_state(def); + state.extend(bindings.state_pins); + Some(PreviewPayload::Fragment { + surface, + name: name.clone(), + props: bindings.props, + state, + x: bindings.x, + }) + } + } +} + +/// Evaluates a static pin expression (§6.2: literals, records of statics, +/// `fixture.<ns>.<name>[.<field>…]` references) to raw JSON. +pub(crate) fn static_json( + expr: &ast::Expr, + fixture: &FixtureData, +) -> Result<serde_json::Value, String> { + use serde_json::Value as J; + match &expr.kind { + ast::ExprKind::Int(i) => Ok(J::Number((*i).into())), + ast::ExprKind::Str(s) => Ok(J::String(s.clone())), + ast::ExprKind::Bool(b) => Ok(J::Bool(*b)), + ast::ExprKind::None => Ok(J::Null), + ast::ExprKind::Record(entries) => { + let mut map = serde_json::Map::new(); + for (name, value) in entries { + map.insert(name.clone(), static_json(value, fixture)?); + } + Ok(J::Object(map)) + } + ast::ExprKind::Field { .. } | ast::ExprKind::Ident(_) => { + let mut path = Vec::new(); + collect_path(expr, &mut path)?; + let ["fixture", ns, name, rest @ ..] = path.as_slice() else { + return Err("pins reference slices as `fixture.<ns>.<name>`".to_string()); + }; + let mut value = fixture + .get(ns, name) + .ok_or_else(|| format!("no fixture slice `{ns}.{name}`"))?; + for field in rest { + value = value + .get(field) + .ok_or_else(|| format!("no field `{field}` in `{ns}.{name}`"))?; + } + Ok(value.clone()) + } + _ => Err("pins are static: literals, records, or fixture references (§6.2)".to_string()), + } +} + +fn collect_path<'a>(expr: &'a ast::Expr, out: &mut Vec<&'a str>) -> Result<(), String> { + match &expr.kind { + ast::ExprKind::Ident(name) => { + out.push(name); + Ok(()) + } + ast::ExprKind::Field { base, name } => { + collect_path(base, out)?; + out.push(name); + Ok(()) + } + _ => Err("pins are static: literals, records, or fixture references (§6.2)".to_string()), + } +} diff --git a/uhura/crates/uhura-check/src/replay.rs b/uhura/crates/uhura-check/src/replay.rs new file mode 100644 index 0000000..c5ec3ee --- /dev/null +++ b/uhura/crates/uhura-check/src/replay.rs @@ -0,0 +1,583 @@ +//! Derived-example replay (design §6.2): a left fold of the PUBLIC machine +//! — `step_u` + `apply_updates` for pages, the same transactional +//! dispatcher via `FragmentMachine` for standalone surfaces — over the +//! example's timeline. Replay is a build/check phase: each derived example +//! resolves to a frozen `(route, U, X, surface stack)` snapshot; +//! `uhura project` executes zero transitions. +//! +//! This is what makes derived examples SELF-VERIFYING: change a guard and +//! the timeline's event drops (`no-handler`), which is a check error here. +//! +//! Rules (§6.2): an injected outcome settles the OLDEST unsettled matching +//! command in the replay prefix — no unsettled match is "pin this state +//! instead"; leaving the subject route at any step is an error; failures +//! attribute to the first failing step (the caller handles ancestor +//! blocking). + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Span, Value}; +use uhura_core::event::{Event, apply_failure, apply_updates}; +use uhura_core::state::{Projections, UiState, initial_state}; +use uhura_core::step::{FragmentError, FragmentMachine, FragmentNote, step_u}; +use uhura_core::trace::Disposition; +use uhura_core::view::{Descriptor, DescriptorKind}; +use uhura_port::envelope::{OutcomeResult, ProjectionUpdate}; +use uhura_syntax::ast; + +use crate::fixture::{FixtureData, decode_against_ty}; +use crate::preview::PreviewPayload; +use crate::resolve::{DefEnv, Resolved, SubjectKind}; + +pub struct ReplayOutcome { + pub payload: PreviewPayload, + /// Unsettled commands at the end of the timeline — the caption's + /// "N command(s) in flight" (§6.2). + pub in_flight: usize, +} + +/// Attributes to the first failing step (§6.2). +pub struct ReplayError { + pub span: Span, + pub message: String, +} + +fn fail<T>(span: Span, message: impl Into<String>) -> Result<T, ReplayError> { + Err(ReplayError { + span, + message: message.into(), + }) +} + +pub struct ReplayInput<'a> { + pub x: Projections, + pub params: BTreeMap<Ident, Value>, + pub props: BTreeMap<Ident, Value>, + pub state_pins: BTreeMap<Ident, Value>, + pub events: &'a [&'a ast::ExampleEvent], + /// The whole example, for errors with no better anchor. + pub span: Span, +} + +pub fn replay( + program: &uhura_core::ir::ProgramIr, + resolved: &Resolved, + env: &DefEnv, + fixture: &FixtureData, + input: ReplayInput<'_>, +) -> Result<ReplayOutcome, ReplayError> { + match &env.kind { + SubjectKind::Page { route } => replay_page(program, resolved, fixture, route, input), + SubjectKind::Surface { name, .. } => { + replay_fragment(program, resolved, env, fixture, name, true, input) + } + SubjectKind::Component { name } => { + replay_fragment(program, resolved, env, fixture, name, false, input) + } + } +} + +// ── page timelines: the real machine, boot to finish ─────────────────────── + +fn replay_page( + program: &uhura_core::ir::ProgramIr, + resolved: &Resolved, + fixture: &FixtureData, + route: &Ident, + input: ReplayInput<'_>, +) -> Result<ReplayOutcome, ReplayError> { + let mut x = input.x; + let mut revisions = RevisionCounter::default(); + + let boot = step_u( + program, + UiState::boot(), + &x, + Event::Init { + route: route.clone(), + params: input.params, + }, + ); + let mut u = match boot { + Ok(result) => result.u, + Err(e) => return fail(input.span, e.to_string()), + }; + // Pins apply before the fold (§6.2 evaluation order); state pins land + // on the mounted page. + if let Some(entry) = u.nav.last_mut() { + entry.state.extend(input.state_pins); + } + + for event in input.events { + match event { + ast::ExampleEvent::Semantic { name, args, span } => { + let Ok(emit) = Ident::new(name) else { + return fail(*span, format!("`{name}` is not a legal event name")); + }; + let mut payload = serde_json::Map::new(); + for arg in args { + let json = static_arg(&arg.value, fixture, *span)?; + payload.insert(arg.name.clone(), json); + } + let scope = match u.nav.last() { + Some(entry) => format!("page:{}", entry.serial), + None => return fail(*span, "the machine has no mounted page"), + }; + let descriptor = Descriptor { + kind: DescriptorKind::Input, + event: emit.clone(), + emit, + scope, + payload: serde_json::Value::Object(payload), + carries: BTreeMap::new(), + }; + let view_rev = u.rev; + let result = step_u( + program, + u, + &x, + Event::Ui { + descriptor, + data: None, + view_rev, + }, + ); + u = match result { + Ok(result) => { + inspect_disposition(name, &result.t.disposition, *span)?; + result.u + } + Err(e) => return fail(*span, e.to_string()), + }; + check_on_subject(&u, route, *span)?; + } + ast::ExampleEvent::Projection(pin) => { + if let Some(reason) = failed_delivery(pin, fixture)? { + let (port, projection, key) = pin_target(pin, resolved, fixture)?; + if let Err(e) = + apply_failure(program, &mut x, &port, &projection, key.as_ref(), &reason) + { + return fail(pin.span, e); + } + u = match step_u( + program, + u, + &x, + Event::ProjectionFailed { + port, + projection, + key, + reason, + }, + ) { + Ok(result) => result.u, + Err(e) => return fail(pin.span, e.to_string()), + }; + continue; + } + let update = pin_update(pin, resolved, fixture, &mut revisions, &x)?; + if let Err(e) = apply_updates(program, &mut x, std::slice::from_ref(&update)) { + return fail(pin.span, e); + } + u = match step_u( + program, + u, + &x, + Event::Projection { + updates: vec![update], + }, + ) { + Ok(result) => result.u, + Err(e) => return fail(pin.span, e.to_string()), + }; + } + ast::ExampleEvent::Outcome { + command, + which, + args, + span, + } => { + let Ok(command) = Ident::new(command) else { + return fail(*span, format!("`{command}` is not a legal command name")); + }; + // The OLDEST unsettled matching command (§6.2). + let tag = u + .pending + .iter() + .find(|(_, p)| p.command == command) + .map(|(t, _)| *t); + let Some(tag) = tag else { + return fail( + *span, + format!( + "no unsettled `{command}` command in the replay prefix — \ + pin this state instead (§6.2)" + ), + ); + }; + let result_kind = outcome_result(which, args, *span)?; + let outcome_label = format!("{command} outcome"); + let result = step_u( + program, + u, + &x, + Event::Outcome { + correlation: format!("c-{tag}"), + result: result_kind, + updates: vec![], + }, + ); + u = match result { + Ok(result) => { + inspect_disposition(&outcome_label, &result.t.disposition, *span)?; + result.u + } + Err(e) => return fail(*span, e.to_string()), + }; + // Outcome handlers can navigate too (§6.2's rule is + // per-step, not per-event-kind). + check_on_subject(&u, route, *span)?; + } + } + } + + let in_flight = u.pending.len(); + Ok(ReplayOutcome { + payload: PreviewPayload::Page { + route: route.clone(), + u, + x, + }, + in_flight, + }) +} + +/// Leaving the subject route at any step is a check error (§6.2). +fn check_on_subject(u: &UiState, route: &Ident, span: Span) -> Result<(), ReplayError> { + let on_subject = u.nav.len() == 1 && u.nav.last().is_some_and(|e| e.route == *route); + if on_subject { + Ok(()) + } else { + fail( + span, + format!( + "this step leaves the subject route `{route}` — an example \ + previews one subject (§6.2)" + ), + ) + } +} + +/// A timeline step that dropped or aborted is an authoring error — the +/// example claims a transition the machine refuses. +fn inspect_disposition( + label: &str, + disposition: &Disposition, + span: Span, +) -> Result<(), ReplayError> { + match disposition { + Disposition::Dropped { reason, detail } => fail( + span, + format!( + "the machine drops `{label}` here ({}): {detail}", + reason.as_str() + ), + ), + Disposition::Dispatched(record) if record.aborted.is_some() => fail( + span, + format!( + "`{label}` aborts on an undelivered projection — deliver or pin it \ + before this step (§4.2)" + ), + ), + Disposition::Dispatched(record) if record.selected.is_none() => fail( + span, + format!( + "no handler's guard accepts `{label}` at this point in the timeline — \ + the machine drops it" + ), + ), + _ => Ok(()), + } +} + +// ── fragment timelines: surfaces (and components) standalone ─────────────── + +fn replay_fragment( + program: &uhura_core::ir::ProgramIr, + resolved: &Resolved, + env: &DefEnv, + fixture: &FixtureData, + name: &Ident, + surface: bool, + input: ReplayInput<'_>, +) -> Result<ReplayOutcome, ReplayError> { + let def = if surface { + program.surfaces.get(name) + } else { + program.components.get(name) + }; + let Some(def) = def else { + return fail(input.span, format!("no definition `{name}`")); + }; + + let mut x = input.x; + let mut revisions = RevisionCounter::default(); + let mut state = initial_state(def); + state.extend(input.state_pins); + let mut machine = FragmentMachine::from_state(state); + + for event in input.events { + match event { + ast::ExampleEvent::Semantic { + name: event_name, + args, + span, + } => { + let Ok(event_ident) = Ident::new(event_name) else { + return fail(*span, format!("`{event_name}` is not a legal event name")); + }; + let Some(sig) = env.events.get(&event_ident) else { + return fail(*span, format!("`{name}` declares no event `{event_name}`")); + }; + // Timeline args are static; each decodes against the + // signature's declared type (L8 at use). + let mut payload = BTreeMap::new(); + for arg in args { + let Some((_, ty)) = sig.iter().find(|(p, _)| p.as_str() == arg.name) else { + return fail( + *span, + format!("`{event_name}` has no parameter `{}`", arg.name), + ); + }; + let json = static_arg(&arg.value, fixture, *span)?; + match decode_against_ty(&json, ty) { + Ok(value) => { + payload.insert(Ident::new(&arg.name).expect("checked"), value); + } + Err(e) => return fail(*span, format!("`{}`: {e}", arg.name)), + } + } + for (param, ty) in sig { + if !payload.contains_key(param) && !matches!(ty, crate::types::Ty::Option(_)) { + return fail(*span, format!("`{event_name}` needs `{param}` here")); + } + } + let note = machine.dispatch_semantic( + program, + def, + &input.props, + &x, + &event_ident, + &payload, + ); + inspect_fragment_note(event_name, note, *span)?; + } + ast::ExampleEvent::Projection(pin) => { + if let Some(reason) = failed_delivery(pin, fixture)? { + let (port, projection, key) = pin_target(pin, resolved, fixture)?; + if let Err(e) = + apply_failure(program, &mut x, &port, &projection, key.as_ref(), &reason) + { + return fail(pin.span, e); + } + continue; + } + let update = pin_update(pin, resolved, fixture, &mut revisions, &x)?; + if let Err(e) = apply_updates(program, &mut x, std::slice::from_ref(&update)) { + return fail(pin.span, e); + } + } + ast::ExampleEvent::Outcome { + command, + which, + args, + span, + } => { + let Ok(command_ident) = Ident::new(command) else { + return fail(*span, format!("`{command}` is not a legal command name")); + }; + let tag = machine + .pending + .iter() + .find(|(_, p)| p.command == command_ident) + .map(|(t, _)| *t); + let Some(tag) = tag else { + return fail( + *span, + format!( + "no unsettled `{command}` command in the replay prefix — \ + pin this state instead (§6.2)" + ), + ); + }; + let result = outcome_result(which, args, *span)?; + let note = machine.dispatch_outcome(program, def, &input.props, &x, tag, &result); + inspect_fragment_note(&format!("{command} outcome"), note, *span)?; + } + } + } + + let in_flight = machine.pending.len(); + Ok(ReplayOutcome { + payload: PreviewPayload::Fragment { + surface, + name: name.clone(), + props: input.props, + state: machine.state, + x, + }, + in_flight, + }) +} + +fn inspect_fragment_note( + label: &str, + note: Result<FragmentNote, FragmentError>, + span: Span, +) -> Result<(), ReplayError> { + match note { + Ok(FragmentNote::Committed { .. }) => Ok(()), + Ok(FragmentNote::NoHandler) => fail( + span, + format!( + "no handler's guard accepts `{label}` at this point in the timeline — \ + the machine drops it" + ), + ), + Ok(FragmentNote::NotReady) => fail( + span, + format!( + "`{label}` aborts on an undelivered projection — deliver or pin it \ + before this step (§4.2)" + ), + ), + Err(FragmentError::Structural(stmt)) => fail( + span, + format!( + "`{label}` runs `{stmt}`, which needs a mounted machine — \ + pin this state instead (§6.2)" + ), + ), + Err(FragmentError::Invariant(msg)) => fail(span, msg), + } +} + +// ── shared pieces ─────────────────────────────────────────────────────────── + +/// Timeline projection deliveries mint strictly-increasing revisions per +/// instance, above the pins' revision 1 (mirrors the fixture driver). +#[derive(Default)] +struct RevisionCounter { + next: BTreeMap<(String, String), u64>, +} + +impl RevisionCounter { + fn mint(&mut self, projection: &str, key: &Option<serde_json::Value>) -> u64 { + let key_str = key.as_ref().map(ToString::to_string).unwrap_or_default(); + let counter = self + .next + .entry((projection.to_string(), key_str)) + .or_insert(1); + *counter += 1; + *counter + } +} + +/// `failed("<reason>")` in a timeline is a projection-failed delivery +/// (§9.3), mirroring the pin form (micro-decision #31). +fn failed_delivery( + pin: &ast::ProjectionPin, + fixture: &FixtureData, +) -> Result<Option<String>, ReplayError> { + let ast::ExprKind::Call { name, args } = &pin.value.kind else { + return Ok(None); + }; + if name != "failed" { + return Ok(None); + } + match args.as_slice() { + [reason] => match static_arg(reason, fixture, pin.span)? { + serde_json::Value::String(reason) => Ok(Some(reason)), + _ => fail(pin.span, "`failed(…)` takes one reason string"), + }, + _ => fail(pin.span, "`failed(…)` takes one reason string"), + } +} + +fn pin_target( + pin: &ast::ProjectionPin, + resolved: &Resolved, + fixture: &FixtureData, +) -> Result<(Ident, Ident, Option<serde_json::Value>), ReplayError> { + let (Ok(port), Ok(projection)) = (Ident::new(&pin.port), Ident::new(&pin.projection)) else { + return fail(pin.span, "not a legal projection reference"); + }; + if !resolved.ports.contains_key(&port) { + return fail(pin.span, format!("no linked port `{port}`")); + } + let key = match &pin.key { + None => None, + Some(expr) => Some(static_arg(expr, fixture, pin.span)?), + }; + Ok((port, projection, key)) +} + +fn pin_update( + pin: &ast::ProjectionPin, + resolved: &Resolved, + fixture: &FixtureData, + revisions: &mut RevisionCounter, + _x: &Projections, +) -> Result<ProjectionUpdate, ReplayError> { + let (port, projection, key) = pin_target(pin, resolved, fixture)?; + let value = static_arg(&pin.value, fixture, pin.span)?; + let revision = revisions.mint(pin.projection.as_str(), &key); + Ok(ProjectionUpdate { + port, + projection, + key, + revision, + value, + }) +} + +fn outcome_result( + which: &ast::OutcomeKind, + args: &[ast::Arg], + span: Span, +) -> Result<OutcomeResult, ReplayError> { + match which { + ast::OutcomeKind::Ok => Ok(OutcomeResult::Ok), + ast::OutcomeKind::Err => { + // Legality (micro-decision #28) guarantees exactly one of + // `refusal: <name>` / `reason: "<text>"`. + let Some(arg) = args.first() else { + return fail(span, "`.err` takes `refusal:` or `reason:` (§6.1)"); + }; + match (arg.name.as_str(), &arg.value.kind) { + ("refusal", ast::ExprKind::Ident(name)) => match Ident::new(name) { + Ok(refusal) => Ok(OutcomeResult::Refused { refusal }), + Err(e) => fail(span, e.to_string()), + }, + ("reason", ast::ExprKind::Str(reason)) => Ok(OutcomeResult::Unavailable { + reason: reason.clone(), + }), + _ => fail( + span, + "`.err` takes `refusal: <name>` or `reason: \"<text>\"`", + ), + } + } + } +} + +/// Timeline argument values are static (§6.2): literals, records of +/// statics, or fixture references. +fn static_arg( + expr: &ast::Expr, + fixture: &FixtureData, + span: Span, +) -> Result<serde_json::Value, ReplayError> { + crate::preview::static_json(expr, fixture).map_err(|e| ReplayError { span, message: e }) +} diff --git a/uhura/crates/uhura-check/src/resolve.rs b/uhura/crates/uhura-check/src/resolve.rs new file mode 100644 index 0000000..0eec12e --- /dev/null +++ b/uhura/crates/uhura-check/src/resolve.rs @@ -0,0 +1,864 @@ +//! Routes from paths, definition/header discipline, import resolution, and +//! port linking (design §3, §9.1) — everything that turns parsed files into +//! named, connected definitions with typed environments. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Diagnostic, FileId, Ident, Span, codes}; +use uhura_port::PortContract; +use uhura_syntax::ast; +use uhura_syntax::{Parsed, SourceKind}; + +use crate::types::{MapKey, PortTypes, Ty}; + +/// One parsed source, owned by the pipeline. +pub struct ParsedSource { + pub file: FileId, + pub rel_path: String, + pub kind: SourceKind, + pub parsed: Parsed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SubjectKind { + Page { + route: Ident, + }, + Component { + name: Ident, + }, + Surface { + name: Ident, + modality: Option<String>, + }, +} + +impl SubjectKind { + pub fn name(&self) -> &Ident { + match self { + SubjectKind::Page { route } => route, + SubjectKind::Component { name } | SubjectKind::Surface { name, .. } => name, + } + } + + pub fn describe(&self) -> &'static str { + match self { + SubjectKind::Page { .. } => "page", + SubjectKind::Component { .. } => "component", + SubjectKind::Surface { .. } => "surface", + } + } +} + +#[derive(Clone, Debug)] +pub struct RouteInfo { + /// Static segments and params in path order. + pub segments: Vec<RouteSeg>, + pub params: Vec<Ident>, + pub file: FileId, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RouteSeg { + Static(String), + Param(Ident), +} + +#[derive(Clone, Debug)] +pub struct ProjInfo { + pub port: Ident, + pub ty: Ty, + /// Key type for keyed projections. + pub key: Option<Ty>, + pub boot: bool, +} + +#[derive(Clone, Debug)] +pub struct CmdInfo { + pub port: Ident, + /// Payload fields, contract order. + pub payload: Vec<(Ident, Ty)>, +} + +/// The typed environment of one definition file. +pub struct DefEnv { + pub kind: SubjectKind, + pub file: FileId, + /// Index into the pipeline's `Vec<ParsedSource>`. + pub source: usize, + pub component_imports: BTreeMap<Ident, Span>, + pub surface_imports: BTreeMap<Ident, Span>, + /// Imported `type` items → structural type. + pub type_items: BTreeMap<Ident, Ty>, + pub projections: BTreeMap<Ident, ProjInfo>, + pub commands: BTreeMap<Ident, CmdInfo>, + pub props: BTreeMap<Ident, Ty>, + pub params: BTreeMap<Ident, Ty>, + pub state: BTreeMap<Ident, Ty>, + /// Component emit signatures (declared `emits { … }`). + pub emits: BTreeMap<Ident, Vec<(Ident, Ty)>>, + /// Machine-event signatures (pages/surfaces): first handler's params. + /// Filled by the typecheck pass. + pub events: BTreeMap<Ident, Vec<(Ident, Ty)>>, +} + +/// Everything resolution produces. +pub struct Resolved { + pub routes: BTreeMap<Ident, RouteInfo>, + /// Definition environments keyed by (kind-discriminated) name. + pub pages: BTreeMap<Ident, DefEnv>, + pub components: BTreeMap<Ident, DefEnv>, + pub surfaces: BTreeMap<Ident, DefEnv>, + /// Examples file index → subject source index. + pub example_subjects: BTreeMap<usize, usize>, + /// Loaded port contracts + expansions, keyed by port name. + pub ports: BTreeMap<Ident, (PortContract, PortTypes)>, +} + +pub fn resolve( + sources: &[ParsedSource], + ports: BTreeMap<Ident, (PortContract, PortTypes)>, + entry: &Ident, + manifest_file: FileId, + diags: &mut Vec<Diagnostic>, +) -> Resolved { + let mut routes = BTreeMap::new(); + let mut pages = BTreeMap::new(); + let mut components = BTreeMap::new(); + let mut surfaces = BTreeMap::new(); + + // Global uniqueness of projection/command names across ports — what + // lets `X` key snapshots by bare name (§7.1) and envelopes route + // without qualification. + let mut seen: BTreeMap<&Ident, &Ident> = BTreeMap::new(); + for (port_name, (contract, _)) in &ports { + for item in contract.projections.keys().chain(contract.commands.keys()) { + if let Some(other) = seen.insert(item, port_name) { + diags.push(Diagnostic::error( + codes::PORT_NAME_COLLISION.0, + codes::PORT_NAME_COLLISION.1, + format!( + "`{item}` is declared by both port `{other}` and port `{port_name}`; \ + projection and command names are app-global" + ), + Span::new(manifest_file, 0, 0), + )); + } + } + } + + // ── subjects: path discipline, headers, routes ───────────────────── + for (idx, src) in sources.iter().enumerate() { + let Parsed::Module(ast) = &src.parsed else { + continue; + }; + let Some(kind) = subject_kind(src, ast, diags) else { + continue; + }; + let env = build_env(idx, src, ast, &kind, &ports, diags); + match kind { + SubjectKind::Page { ref route } => { + let info = route_info(&src.rel_path, src.file); + if let Some(existing) = routes.get(route) { + let existing: &RouteInfo = existing; + diags.push( + Diagnostic::error( + codes::ROUTE_COLLISION.0, + codes::ROUTE_COLLISION.1, + format!("route `{route}` is defined twice"), + Span::new(src.file, 0, 0), + ) + .with_label(Span::new(existing.file, 0, 0), "also defined here"), + ); + } else { + routes.insert(route.clone(), info); + pages.insert(route.clone(), env); + } + } + SubjectKind::Component { ref name } => { + components.insert(name.clone(), env); + } + SubjectKind::Surface { ref name, .. } => { + surfaces.insert(name.clone(), env); + } + } + } + + // Components and surfaces share the importable namespace; a collision + // would make `use` ambiguous. + for name in components.keys() { + if surfaces.contains_key(name) { + let file = components[name].file; + diags.push(Diagnostic::error( + codes::SHADOWED_NAME.0, + codes::SHADOWED_NAME.1, + format!("`{name}` is defined as both a component and a surface"), + Span::new(file, 0, 0), + )); + } + } + + if !routes.contains_key(entry) { + diags.push(Diagnostic::error( + codes::ENTRY_ROUTE_MISSING.0, + codes::ENTRY_ROUTE_MISSING.1, + format!("manifest entry route `{entry}` has no `app/…/page.uhura`"), + Span::new(manifest_file, 0, 0), + )); + } + + // ── import validation + DAG ──────────────────────────────────────── + for env in pages + .values() + .chain(components.values()) + .chain(surfaces.values()) + { + for (name, span) in &env.component_imports { + if !components.contains_key(name) { + let mut d = Diagnostic::error( + codes::UNKNOWN_IMPORT.0, + codes::UNKNOWN_IMPORT.1, + format!("no component named `{name}` in the corpus"), + *span, + ); + if let Some(suggestion) = did_you_mean(name, components.keys()) { + d = d.with_note(format!("did you mean `{suggestion}`?")); + } + if surfaces.contains_key(name) { + d = d.with_note(format!("`{name}` is a surface — `use surface {name}`")); + } + diags.push(d); + } + } + for (name, span) in &env.surface_imports { + if !surfaces.contains_key(name) { + let mut d = Diagnostic::error( + codes::UNKNOWN_IMPORT.0, + codes::UNKNOWN_IMPORT.1, + format!("no surface named `{name}` in the corpus"), + *span, + ); + if components.contains_key(name) { + d = d.with_note(format!("`{name}` is a component — `use component {name}`")); + } + diags.push(d); + } + } + } + + // Import cycles over the component/surface graph (pages are roots, + // never importable). + let mut graph: BTreeMap<&Ident, Vec<&Ident>> = BTreeMap::new(); + for (name, env) in components.iter().chain(surfaces.iter()) { + graph.insert( + name, + env.component_imports + .keys() + .chain(env.surface_imports.keys()) + .collect(), + ); + } + for start in graph.keys().copied() { + let mut stack: Vec<&Ident> = vec![start]; + let mut visited = BTreeSet::new(); + while let Some(n) = stack.pop() { + if !visited.insert(n) { + continue; + } + for dep in graph.get(n).into_iter().flatten() { + if *dep == start { + let file = components + .get(start) + .or_else(|| surfaces.get(start)) + .map(|e| e.file) + .expect("start came from the graph"); + diags.push(Diagnostic::error( + codes::IMPORT_CYCLE.0, + codes::IMPORT_CYCLE.1, + format!( + "`{start}` reaches itself through its imports (the graph must be a DAG)" + ), + Span::new(file, 0, 0), + )); + stack.clear(); + break; + } + stack.push(dep); + } + } + } + + // ── examples pairing ─────────────────────────────────────────────── + let mut by_path: BTreeMap<&str, usize> = BTreeMap::new(); + for (idx, src) in sources.iter().enumerate() { + if src.kind == SourceKind::Module { + by_path.insert(&src.rel_path, idx); + } + } + let mut example_subjects = BTreeMap::new(); + for (idx, src) in sources.iter().enumerate() { + if src.kind != SourceKind::Examples { + continue; + } + let subject_path = src.rel_path.replace(".examples.uhura", ".uhura"); + match by_path.get(subject_path.as_str()) { + Some(&subject) => { + example_subjects.insert(idx, subject); + } + None => diags.push(Diagnostic::error( + codes::ORPHAN_EXAMPLES_FILE.0, + codes::ORPHAN_EXAMPLES_FILE.1, + format!("no subject `{subject_path}` for this examples file"), + Span::new(src.file, 0, 0), + )), + } + } + + Resolved { + routes, + pages, + components, + surfaces, + example_subjects, + ports, + } +} + +/// Decides what a module file defines and enforces the path/header +/// discipline (§3: path defines, header matches basename). +fn subject_kind( + src: &ParsedSource, + ast: &ast::File, + diags: &mut Vec<Diagnostic>, +) -> Option<SubjectKind> { + let dir = src.rel_path.split('/').next().unwrap_or(""); + let basename = src + .rel_path + .rsplit('/') + .next() + .unwrap_or("") + .trim_end_matches(".uhura"); + + let mismatch = |diags: &mut Vec<Diagnostic>, span: Span, msg: String| { + diags.push(Diagnostic::error( + codes::HEADER_BASENAME_MISMATCH.0, + codes::HEADER_BASENAME_MISMATCH.1, + msg, + span, + )); + }; + + match &ast.kind { + ast::DefKind::Error { .. } => None, + ast::DefKind::Page { span } => { + if dir != "app" || basename != "page" { + diags.push(Diagnostic::error( + codes::WRONG_DIRECTORY.0, + codes::WRONG_DIRECTORY.1, + "a `page` lives at `app/**/page.uhura`".to_string(), + *span, + )); + return None; + } + let route = route_name(&src.rel_path)?; + let route = match Ident::new(&route) { + Ok(r) => r, + Err(_) => { + diags.push(Diagnostic::error( + codes::BAD_PAGE_PATH.0, + codes::BAD_PAGE_PATH.1, + format!("`{}` does not yield a kebab-case route name", src.rel_path), + *span, + )); + return None; + } + }; + Some(SubjectKind::Page { route }) + } + ast::DefKind::Component { name, span } => { + if dir != "components" { + diags.push(Diagnostic::error( + codes::WRONG_DIRECTORY.0, + codes::WRONG_DIRECTORY.1, + "a `component` lives under `components/`".to_string(), + *span, + )); + return None; + } + if name != basename { + mismatch( + diags, + *span, + format!("header says `component {name}` but the file is `{basename}.uhura`"), + ); + return None; + } + let name = Ident::new(name).ok()?; + Some(SubjectKind::Component { name }) + } + ast::DefKind::Surface { + name, + modality, + span, + } => { + if dir != "surfaces" { + diags.push(Diagnostic::error( + codes::WRONG_DIRECTORY.0, + codes::WRONG_DIRECTORY.1, + "a `surface` lives under `surfaces/`".to_string(), + *span, + )); + return None; + } + if name != basename { + mismatch( + diags, + *span, + format!("header says `surface {name}` but the file is `{basename}.uhura`"), + ); + return None; + } + let name = Ident::new(name).ok()?; + Some(SubjectKind::Surface { + name, + modality: modality.clone(), + }) + } + } +} + +/// `app/profile/[user]/page.uhura` → route name `profile`. +fn route_name(rel_path: &str) -> Option<String> { + let statics: Vec<&str> = rel_path + .strip_prefix("app/")? + .strip_suffix("/page.uhura")? + .split('/') + .filter(|seg| !seg.starts_with('[')) + .collect(); + if statics.is_empty() { + None + } else { + Some(statics.join("-")) + } +} + +fn route_info(rel_path: &str, file: FileId) -> RouteInfo { + let mut segments = Vec::new(); + let mut params = Vec::new(); + if let Some(inner) = rel_path + .strip_prefix("app/") + .and_then(|p| p.strip_suffix("/page.uhura")) + { + for seg in inner.split('/') { + if let Some(param) = seg.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + if let Ok(param) = Ident::new(param) { + segments.push(RouteSeg::Param(param.clone())); + params.push(param); + } + } else { + segments.push(RouteSeg::Static(seg.to_string())); + } + } + } + RouteInfo { + segments, + params, + file, + } +} + +/// Builds the typed environment: imports, port items, declared props/ +/// params/state — plus the §3 declaration-placement rules. +fn build_env( + source: usize, + src: &ParsedSource, + ast: &ast::File, + kind: &SubjectKind, + ports: &BTreeMap<Ident, (PortContract, PortTypes)>, + diags: &mut Vec<Diagnostic>, +) -> DefEnv { + let file = src.file; + let mut env = DefEnv { + kind: kind.clone(), + file, + source, + component_imports: BTreeMap::new(), + surface_imports: BTreeMap::new(), + type_items: BTreeMap::new(), + projections: BTreeMap::new(), + commands: BTreeMap::new(), + props: BTreeMap::new(), + params: BTreeMap::new(), + state: BTreeMap::new(), + emits: BTreeMap::new(), + events: BTreeMap::new(), + }; + + // Expression-namespace shadowing ledger: state, props, params, and + // projections share one unqualified namespace (§3 shadowing forbidden). + let mut expr_names: BTreeMap<Ident, Span> = BTreeMap::new(); + let claim = |diags: &mut Vec<Diagnostic>, + expr_names: &mut BTreeMap<Ident, Span>, + name: &Ident, + span: Span, + what: &str| { + if let Some(prev) = expr_names.insert(name.clone(), span) { + diags.push( + Diagnostic::error( + codes::SHADOWED_NAME.0, + codes::SHADOWED_NAME.1, + format!("`{name}` ({what}) shadows an earlier declaration"), + span, + ) + .with_label(prev, "first declared here"), + ); + false + } else { + true + } + }; + + for use_decl in &ast.uses { + match use_decl { + ast::Use::Component { name, span, .. } => { + let Ok(name) = Ident::new(name) else { continue }; + if env.component_imports.insert(name.clone(), *span).is_some() { + duplicate_import(diags, &name, *span); + } + } + ast::Use::Surface { name, span, .. } => { + let Ok(name) = Ident::new(name) else { continue }; + if env.surface_imports.insert(name.clone(), *span).is_some() { + duplicate_import(diags, &name, *span); + } + } + ast::Use::Fixture { span, .. } => { + diags.push(Diagnostic::error( + codes::MISPLACED_DECLARATION.0, + codes::MISPLACED_DECLARATION.1, + "`use fixture` belongs in `.examples.uhura` files only".to_string(), + *span, + )); + } + ast::Use::Port { + name, items, span, .. + } => { + let Ok(port_name) = Ident::new(name) else { + continue; + }; + let Some((contract, port_types)) = ports.get(&port_name) else { + diags.push(Diagnostic::error( + codes::UNKNOWN_PORT.0, + codes::UNKNOWN_PORT.1, + format!("no port `{port_name}` in the manifest"), + *span, + )); + continue; + }; + for item in items { + let Ok(item_name) = Ident::new(&item.name) else { + continue; + }; + match item.kind { + ast::PortItemKind::Projection => { + match contract.projections.get(&item_name) { + None => unknown_port_item( + diags, + "projection", + &item_name, + &port_name, + item.span, + ), + Some(decl) => { + if claim( + diags, + &mut expr_names, + &item_name, + item.span, + "projection", + ) { + env.projections.insert( + item_name.clone(), + ProjInfo { + port: port_name.clone(), + ty: port_types.from_expr(contract, &decl.ty), + key: decl + .key + .as_ref() + .map(|k| port_types.from_expr(contract, k)), + boot: decl.boot, + }, + ); + } + } + } + } + ast::PortItemKind::Command => match contract.commands.get(&item_name) { + None => unknown_port_item( + diags, "command", &item_name, &port_name, item.span, + ), + Some(decl) => { + if env.commands.contains_key(&item_name) { + duplicate_import(diags, &item_name, item.span); + } else { + env.commands.insert( + item_name.clone(), + CmdInfo { + port: port_name.clone(), + payload: decl + .payload + .iter() + .map(|(f, ty)| { + (f.clone(), port_types.from_expr(contract, ty)) + }) + .collect(), + }, + ); + } + } + }, + ast::PortItemKind::Type => match port_types.named(&item_name) { + None => { + unknown_port_item(diags, "type", &item_name, &port_name, item.span) + } + Some(ty) => { + if env.type_items.contains_key(&item_name) { + duplicate_import(diags, &item_name, item.span); + } else { + env.type_items.insert(item_name.clone(), ty.clone()); + } + } + }, + } + } + } + } + } + + // ── declaration placement (§4.1) ─────────────────────────────────── + let is_page = matches!(kind, SubjectKind::Page { .. }); + let is_component = matches!(kind, SubjectKind::Component { .. }); + if is_page && !ast.props.is_empty() { + diags.push(Diagnostic::error( + codes::MISPLACED_DECLARATION.0, + codes::MISPLACED_DECLARATION.1, + "pages take route `param`s, not `props`".to_string(), + ast.props[0].span, + )); + } + if !is_page && !ast.params.is_empty() { + diags.push(Diagnostic::error( + codes::MISPLACED_DECLARATION.0, + codes::MISPLACED_DECLARATION.1, + "`param` declares a route segment; only pages have routes".to_string(), + ast.params[0].span, + )); + } + if !is_component && !ast.emits.is_empty() { + diags.push(Diagnostic::error( + codes::MISPLACED_DECLARATION.0, + codes::MISPLACED_DECLARATION.1, + "only components declare `emits`; pages and surfaces consume them".to_string(), + ast.emits[0].span, + )); + } + if is_component && ast.store.is_some() { + diags.push(Diagnostic::error( + codes::STORE_NOT_ALLOWED.0, + codes::STORE_NOT_ALLOWED.1, + "components are pure templates — no `store` (design §4)".to_string(), + ast.store.as_ref().expect("just checked").span, + )); + } + + for prop in &ast.props { + let Ok(name) = Ident::new(&prop.name) else { + continue; + }; + let ty = source_type(&prop.ty, &env, diags); + if claim(diags, &mut expr_names, &name, prop.span, "prop") { + env.props.insert(name, ty); + } + } + for param in &ast.params { + let Ok(name) = Ident::new(&param.name) else { + continue; + }; + let ty = source_type(&param.ty, &env, diags); + if claim(diags, &mut expr_names, &name, param.span, "param") { + env.params.insert(name, ty); + } + } + if let SubjectKind::Page { route } = kind { + let info = route_info(&src.rel_path, file); + let declared: BTreeSet<&Ident> = env.params.keys().collect(); + let path_params: BTreeSet<&Ident> = info.params.iter().collect(); + if declared != path_params { + diags.push(Diagnostic::error( + codes::PARAM_ROUTE_MISMATCH.0, + codes::PARAM_ROUTE_MISMATCH.1, + format!( + "route `{route}` has path params {:?} but the page declares {:?}", + info.params.iter().map(Ident::as_str).collect::<Vec<_>>(), + env.params.keys().map(Ident::as_str).collect::<Vec<_>>(), + ), + Span::new(file, 0, 0), + )); + } + } + + for emit in &ast.emits { + let Ok(name) = Ident::new(&emit.name) else { + continue; + }; + let mut sig = Vec::new(); + for (param_name, ty) in &emit.params { + let Ok(param_name) = Ident::new(param_name) else { + continue; + }; + sig.push((param_name, source_type(ty, &env, diags))); + } + if env.emits.insert(name.clone(), sig).is_some() { + diags.push(Diagnostic::error( + codes::SHADOWED_NAME.0, + codes::SHADOWED_NAME.1, + format!("emit `{name}` is declared twice"), + emit.span, + )); + } + } + + if let Some(store) = &ast.store { + for field in &store.state { + let Ok(name) = Ident::new(&field.name) else { + continue; + }; + let ty = source_type(&field.ty, &env, diags); + if claim(diags, &mut expr_names, &name, field.span, "state field") { + env.state.insert(name, ty); + } else { + diags.push(Diagnostic::error( + codes::DUPLICATE_STATE_FIELD.0, + codes::DUPLICATE_STATE_FIELD.1, + format!( + "state field `{}` collides with another declaration", + field.name + ), + field.span, + )); + } + } + } + + env +} + +/// Converts a source type expression (§4.3 grammar) against the file's +/// imported type items. +pub fn source_type(ty: &ast::TypeExpr, env: &DefEnv, diags: &mut Vec<Diagnostic>) -> Ty { + match &ty.kind { + ast::TypeKind::Error => Ty::Error, + ast::TypeKind::Option(inner) => Ty::Option(Box::new(source_type(inner, env, diags))), + ast::TypeKind::List(inner) => Ty::List(Box::new(source_type(inner, env, diags))), + ast::TypeKind::Map(key, value) => { + let key = match key.as_str() { + "id" => MapKey::Id, + "tag" => MapKey::Tag, + other => { + diags.push(Diagnostic::error( + codes::BAD_MAP_KEY.0, + codes::BAD_MAP_KEY.1, + format!("map keys are `id` or `tag`, not `{other}` (§4.3)"), + ty.span, + )); + return Ty::Error; + } + }; + Ty::Map(key, Box::new(source_type(value, env, diags))) + } + ast::TypeKind::Name(name) => match name.as_str() { + "bool" => Ty::Bool, + "int" => Ty::Int, + "text" => Ty::Text, + "id" => Ty::Id, + "tag" => Ty::Tag, + other => { + let Ok(ident) = Ident::new(other) else { + return Ty::Error; + }; + match env.type_items.get(&ident) { + Some(t) => t.clone(), + None => { + let mut d = Diagnostic::error( + codes::UNRESOLVED_NAME.0, + codes::UNRESOLVED_NAME.1, + format!("`{other}` is not a builtin type or an imported `type` item"), + ty.span, + ); + if let Some(s) = did_you_mean(&ident, env.type_items.keys()) { + d = d.with_note(format!("did you mean `{s}`?")); + } else { + d = d.with_note( + "import contract types explicitly: `use port <p> { type <t> }`" + .to_string(), + ); + } + diags.push(d); + Ty::Error + } + } + } + }, + } +} + +fn duplicate_import(diags: &mut Vec<Diagnostic>, name: &Ident, span: Span) { + diags.push(Diagnostic::error( + codes::DUPLICATE_IMPORT.0, + codes::DUPLICATE_IMPORT.1, + format!("`{name}` is imported twice"), + span, + )); +} + +fn unknown_port_item( + diags: &mut Vec<Diagnostic>, + what: &str, + item: &Ident, + port: &Ident, + span: Span, +) { + diags.push(Diagnostic::error( + codes::UNKNOWN_PORT_ITEM.0, + codes::UNKNOWN_PORT_ITEM.1, + format!("port `{port}` declares no {what} `{item}`"), + span, + )); +} + +/// Closest name by edit distance ≤ 2, for did-you-mean notes. +pub fn did_you_mean<'a>( + target: &Ident, + candidates: impl Iterator<Item = &'a Ident>, +) -> Option<&'a Ident> { + candidates + .map(|c| (edit_distance(target.as_str(), c.as_str()), c)) + .filter(|(d, _)| *d <= 2) + .min_by_key(|(d, _)| *d) + .map(|(_, c)| c) +} + +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec<char> = a.chars().collect(); + let b: Vec<char> = b.chars().collect(); + let mut prev: Vec<usize> = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut current = vec![i + 1]; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + current.push((prev[j] + cost).min(prev[j + 1] + 1).min(current[j] + 1)); + } + prev = current; + } + prev[b.len()] +} diff --git a/uhura/crates/uhura-check/src/style.rs b/uhura/crates/uhura-check/src/style.rs new file mode 100644 index 0000000..f704126 --- /dev/null +++ b/uhura/crates/uhura-check/src/style.rs @@ -0,0 +1,103 @@ +//! The whole CSS surface (§4.5): class-rooted selectors in per-file +//! `<style>` blocks (error, with the subject's root class recommended), +//! markup-referenced classes defined nowhere (warning), and the compiled +//! stylesheet — `theme.css` then `<style>` blocks in path order (plan +//! micro-decision #7). Declarations pass through verbatim. + +use std::collections::BTreeSet; + +use uhura_base::{Diagnostic, Span, codes}; +use uhura_syntax::ast; +use uhura_syntax::css; + +/// Checks one definition's `<style>` block; returns the classes it defines. +pub fn check_style_block( + subject_name: &str, + style: &ast::StyleBlock, + diags: &mut Vec<Diagnostic>, +) -> BTreeSet<String> { + let mut defined = BTreeSet::new(); + for rule in &style.rules { + defined.extend(rule.classes.iter().cloned()); + // @-rules keep their inner selectors verbatim; the shallow check + // covers plain rules only (§4.5 — everything inside declarations + // is passed through). + if rule.selector.starts_with('@') { + continue; + } + for selector in rule.selector.split(',') { + let selector = selector.trim(); + if selector.is_empty() { + continue; + } + if !selector.starts_with('.') { + diags.push( + Diagnostic::error( + codes::CLASS_ROOTING.0, + codes::CLASS_ROOTING.1, + format!( + "`{selector}` is not class-rooted — scoped CSS is deferred, so \ + rooting is the isolation contract (§4.5)" + ), + rule.span, + ) + .with_note(format!( + "root it under the subject's class: `.{subject_name} …`" + )), + ); + } + } + } + defined +} + +/// Cross-app class existence: every class referenced in markup must appear +/// in some style source (theme.css or any `<style>` block) — else warning. +pub fn check_class_existence( + class_refs: &[(String, Span)], + defined: &BTreeSet<String>, + diags: &mut Vec<Diagnostic>, +) { + let mut reported: BTreeSet<&str> = BTreeSet::new(); + for (class, span) in class_refs { + if !defined.contains(class) && reported.insert(class) { + diags.push(Diagnostic::warning( + codes::UNDEFINED_CLASS.0, + codes::UNDEFINED_CLASS.1, + format!("class `{class}` is styled nowhere (theme.css or any `<style>` block)"), + *span, + )); + } + } +} + +/// Classes defined by `theme.css` (selector-level scan; declarations are +/// opaque). The file id is only used for spans, which nothing reports on +/// theme.css in this pass. +pub fn theme_classes(theme_file: uhura_base::FileId, theme_css: &str) -> BTreeSet<String> { + css::parse_stylesheet(theme_file, 0, theme_css) + .into_iter() + .flat_map(|r| r.classes) + .collect() +} + +/// The compiled stylesheet: `theme.css` then `<style>` blocks in +/// path-lexicographic order, each under a provenance banner. Ships beside +/// the IR, never inside it (micro-decision #8). +pub fn compile_stylesheet( + theme_css: Option<&str>, + blocks: &[(String, String)], // (rel_path, raw css) — pre-sorted by path +) -> String { + let mut out = String::new(); + if let Some(theme) = theme_css { + out.push_str("/* styles/theme.css */\n"); + out.push_str(theme.trim_end()); + out.push('\n'); + } + for (path, raw) in blocks { + out.push_str(&format!("\n/* {path} <style> */\n")); + out.push_str(raw.trim()); + out.push('\n'); + } + out +} diff --git a/uhura/crates/uhura-check/src/types.rs b/uhura/crates/uhura-check/src/types.rs new file mode 100644 index 0000000..f2fedfb --- /dev/null +++ b/uhura/crates/uhura-check/src/types.rs @@ -0,0 +1,230 @@ +//! The checker's structural type model (design §4.3) and its conversion +//! from port contracts. +//! +//! Cross-port compatibility is **structural** (micro-decision): a record/ +//! union/enum type is its canonical shape, so `comments`' `image-ref` +//! equals `feed`'s. Identity types stay nominal: the builtin `id` is one +//! type; a declared `kind = "id"`/`"opaque"` type is `(port, name)`. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use uhura_base::Ident; +use uhura_port::{PortContract, TypeDecl, TypeExpr}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ty { + Bool, + Int, + Text, + /// The builtin `id`. + Id, + /// Core-minted command tag. + Tag, + /// An asset reference. + Asset, + /// A closed token set (catalog enums, port enums) — structural by + /// value set. + Enum(BTreeSet<Ident>), + /// A declared nominal identity/cursor type. + Nominal { + port: Ident, + name: Ident, + }, + Record(BTreeMap<Ident, Ty>), + Union(BTreeMap<Ident, BTreeMap<Ident, Ty>>), + List(Box<Ty>), + /// `map[K]V`, K ∈ {id, tag} (§4.3). + Map(MapKey, Box<Ty>), + Option(Box<Ty>), + /// The type of a bare `none` literal — compatible with any option. + NoneLit, + /// Poison: an error was already reported; suppress cascades. + Error, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MapKey { + Id, + Tag, +} + +impl Ty { + pub fn is_error(&self) -> bool { + matches!(self, Ty::Error) + } + + /// Human name for diagnostics. + pub fn describe(&self) -> String { + format!("{self}") + } +} + +impl fmt::Display for Ty { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Ty::Bool => write!(f, "bool"), + Ty::Int => write!(f, "int"), + Ty::Text => write!(f, "text"), + Ty::Id => write!(f, "id"), + Ty::Tag => write!(f, "tag"), + Ty::Asset => write!(f, "asset"), + Ty::Enum(values) => { + let names: Vec<&str> = values.iter().map(Ident::as_str).collect(); + write!(f, "one of {}", names.join(" | ")) + } + Ty::Nominal { name, .. } => write!(f, "{name}"), + Ty::Record(fields) => { + let names: Vec<&str> = fields.keys().map(Ident::as_str).collect(); + write!(f, "{{ {} }}", names.join(", ")) + } + Ty::Union(variants) => { + let names: Vec<&str> = variants.keys().map(Ident::as_str).collect(); + write!(f, "union {}", names.join(" | ")) + } + Ty::List(t) => write!(f, "list[{t}]"), + Ty::Map(k, v) => { + let k = match k { + MapKey::Id => "id", + MapKey::Tag => "tag", + }; + write!(f, "map[{k}]{v}") + } + Ty::Option(t) => write!(f, "{t}?"), + Ty::NoneLit => write!(f, "none"), + Ty::Error => write!(f, "<error>"), + } + } +} + +/// `expected` accepts `actual`: structural equality plus the option rules +/// (`T` flows into `T?`; a bare `none` flows into any option) and error +/// poisoning. +pub fn compatible(expected: &Ty, actual: &Ty) -> bool { + if expected.is_error() || actual.is_error() { + return true; + } + if expected == actual { + return true; + } + match (expected, actual) { + (Ty::Option(_), Ty::NoneLit) => true, + (Ty::Option(inner), _) => compatible(inner, actual), + _ => false, + } +} + +/// Both directions — for `==`/`!=` operands. +pub fn comparable(a: &Ty, b: &Ty) -> bool { + compatible(a, b) || compatible(b, a) +} + +/// The structural expansions of one port's declared types (contracts are +/// acyclic — validated — so expansion terminates). +pub struct PortTypes { + pub port: Ident, + expanded: BTreeMap<Ident, Ty>, +} + +impl PortTypes { + pub fn build(contract: &PortContract) -> PortTypes { + let mut this = PortTypes { + port: contract.name.clone(), + expanded: BTreeMap::new(), + }; + for name in contract.types.keys() { + let ty = this.expand_named(contract, name); + this.expanded.insert(name.clone(), ty); + } + this + } + + /// The structural type of a declared name (`None` if undeclared). + pub fn named(&self, name: &Ident) -> Option<&Ty> { + self.expanded.get(name) + } + + /// Converts a contract type expression to a structural type. + pub fn from_expr(&self, contract: &PortContract, expr: &TypeExpr) -> Ty { + match expr { + TypeExpr::Bool => Ty::Bool, + TypeExpr::Int => Ty::Int, + TypeExpr::Text => Ty::Text, + TypeExpr::Id => Ty::Id, + TypeExpr::Asset => Ty::Asset, + TypeExpr::Option(t) => Ty::Option(Box::new(self.from_expr(contract, t))), + TypeExpr::List(t) => Ty::List(Box::new(self.from_expr(contract, t))), + TypeExpr::Named(name) => match self.expanded.get(name) { + Some(ty) => ty.clone(), + None => self.expand_named(contract, name), + }, + } + } + + fn expand_named(&self, contract: &PortContract, name: &Ident) -> Ty { + if let Some(ty) = self.expanded.get(name) { + return ty.clone(); + } + match contract.types.get(name) { + None => Ty::Error, + Some(TypeDecl::Id) | Some(TypeDecl::Opaque) => Ty::Nominal { + port: contract.name.clone(), + name: name.clone(), + }, + Some(TypeDecl::Asset) => Ty::Asset, + Some(TypeDecl::Enum { values }) => Ty::Enum(values.clone()), + Some(TypeDecl::Record { fields }) => Ty::Record( + fields + .iter() + .map(|(f, ty)| (f.clone(), self.from_expr(contract, ty))) + .collect(), + ), + Some(TypeDecl::Union { variants }) => Ty::Union( + variants + .iter() + .map(|(v, fields)| { + ( + v.clone(), + fields + .iter() + .map(|(f, ty)| (f.clone(), self.from_expr(contract, ty))) + .collect(), + ) + }) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cross_port_records_compare_structurally() { + let a = Ty::Record( + [(ident("src"), Ty::Asset), (ident("alt"), Ty::Text)] + .into_iter() + .collect(), + ); + let b = Ty::Record( + [(ident("alt"), Ty::Text), (ident("src"), Ty::Asset)] + .into_iter() + .collect(), + ); + assert!(compatible(&a, &b)); + } + + #[test] + fn option_rules() { + assert!(compatible(&Ty::Option(Box::new(Ty::Text)), &Ty::Text)); + assert!(compatible(&Ty::Option(Box::new(Ty::Text)), &Ty::NoneLit)); + assert!(!compatible(&Ty::Text, &Ty::NoneLit)); + assert!(!compatible(&Ty::Text, &Ty::Option(Box::new(Ty::Text)))); + } + + fn ident(s: &str) -> Ident { + Ident::new(s).unwrap() + } +} diff --git a/uhura/crates/uhura-check/tests/contracts_corpus.rs b/uhura/crates/uhura-check/tests/contracts_corpus.rs new file mode 100644 index 0000000..4c53014 --- /dev/null +++ b/uhura/crates/uhura-check/tests/contracts_corpus.rs @@ -0,0 +1,162 @@ +//! The authored contract data (catalog, ports, manifest) must load clean, +//! hash deterministically, and reject tampering. `include_str!` keeps the +//! pure-crate discipline: no fs reads at test run time. + +use uhura_check::catalog::{ChildrenModel, ElementClass, load_catalog}; +use uhura_check::manifest::load_manifest; +use uhura_port::load_port_contract; + +const CATALOG: &str = include_str!("../../../../examples/instagram-uhura/catalog/base.toml"); +const FEED: &str = include_str!("../../../../examples/instagram-uhura/ports/feed.port.toml"); +const COMMENTS: &str = + include_str!("../../../../examples/instagram-uhura/ports/comments.port.toml"); +const PROFILE: &str = include_str!("../../../../examples/instagram-uhura/ports/profile.port.toml"); +const MANIFEST: &str = include_str!("../../../../examples/instagram-uhura/uhura.toml"); + +#[test] +fn base_catalog_loads_with_nine_elements_and_fourteen_icons() { + let catalog = load_catalog(CATALOG).unwrap(); + assert_eq!(catalog.elements.len(), 9, "design §10: nine elements"); + assert_eq!(catalog.icons.len(), 14, "design §10: the closed icon set"); + + let names: Vec<&str> = catalog.elements.keys().map(|k| k.as_str()).collect(); + assert_eq!( + names, + [ + "button", + "icon", + "image", + "pager", + "region", + "scroll", + "text", + "text-field", + "view" + ] + ); + + let view = &catalog.elements[&ident("view")]; + assert_eq!(view.class, ElementClass::Layout); + assert!( + view.events.is_empty(), + "a view can never become interactive" + ); + + let scroll = &catalog.elements[&ident("scroll")]; + assert!(scroll.viewport); + assert_eq!( + scroll.events[&ident("near-end")].threshold_percent, + Some(100), + "integer percentage, stated once in the catalog (§8.2)" + ); + + let pager = &catalog.elements[&ident("pager")]; + assert_eq!(pager.children, ChildrenModel::KeyedEach); + + let text_field = &catalog.elements[&ident("text-field")]; + assert_eq!( + text_field.controlled, + Some((ident("value"), ident("change"))), + "controlled promotion (§10)" + ); + assert_eq!( + text_field.events[&ident("change")].carries.len(), + 1, + "change carries {{ value: text }} (§4.2)" + ); +} + +#[test] +fn all_three_ports_load_clean() { + let feed = load_port_contract(FEED).unwrap(); + assert_eq!(feed.name.as_str(), "feed"); + assert_eq!(feed.types.len(), 8); + assert_eq!(feed.projections.len(), 2); + assert!(feed.projections[&ident("viewer")].boot); + assert_eq!(feed.commands.len(), 4); + assert!( + feed.commands[&ident("reload")].payload.is_empty(), + "ok payloads and reload's payload are empty (§9.1)" + ); + + let comments = load_port_contract(COMMENTS).unwrap(); + assert_eq!( + comments.projections[&ident("for-post")].key, + Some(uhura_port::TypeExpr::Id), + "keyed projection (§9.2)" + ); + + let profile = load_port_contract(PROFILE).unwrap(); + assert!(profile.commands.is_empty(), "read-only port"); +} + +#[test] +fn contract_hashes_are_deterministic_and_tamper_evident() { + let a = load_port_contract(FEED).unwrap().canonical_hash(); + let b = load_port_contract(FEED).unwrap().canonical_hash(); + assert_eq!(a, b); + assert_eq!(a.len(), 64); + + let tampered = FEED.replace("like-count = \"int\"", "like-count = \"text\""); + let c = load_port_contract(&tampered).unwrap().canonical_hash(); + assert_ne!(a, c, "a shape change must change the pin (§9.1)"); + + // Comment/whitespace churn must NOT change the pin. + let reformatted = FEED.replace("# provider-formatted; core has no clock (§9.1)\n", ""); + let d = load_port_contract(&reformatted).unwrap().canonical_hash(); + assert_eq!(a, d, "the pin covers the canonical form, not the bytes"); + + let catalog_a = load_catalog(CATALOG).unwrap().canonical_hash(); + let catalog_b = load_catalog(CATALOG).unwrap().canonical_hash(); + assert_eq!(catalog_a, catalog_b); +} + +#[test] +fn manifest_loads_and_binds_all_three_ports() { + let manifest = load_manifest(MANIFEST).unwrap(); + assert_eq!(manifest.entry.as_str(), "feed"); + assert_eq!(manifest.ports.len(), 3); + assert_eq!(manifest.catalog_path, "catalog/base.toml"); + assert_eq!(manifest.play[&ident("default")].script.as_str(), "demo"); +} + +#[test] +fn meta_schema_rejects_input_events_on_layout() { + let bad = CATALOG.replace( + "[elements.scroll.events.near-end]\nkind = \"observe\"", + "[elements.scroll.events.near-end]\nkind = \"input\"", + ); + let issues = load_catalog(&bad).unwrap_err(); + assert!( + issues.iter().any(|i| i + .message + .contains("input events are declarable only on interactive")), + "{issues:?}" + ); +} + +#[test] +fn contract_validation_rejects_dangling_and_recursive_types() { + let dangling = FEED.replace("avatar = \"image-ref\"", "avatar = \"portrait-ref\""); + let issues = load_port_contract(&dangling).unwrap_err(); + assert!( + issues + .iter() + .any(|i| i.message.contains("`portrait-ref` is not declared")), + "{issues:?}" + ); + + let recursive = FEED.replace( + "[types.slide.fields]\nid = \"id\"", + "[types.slide.fields]\nagain = \"slide\"\nid = \"id\"", + ); + let issues = load_port_contract(&recursive).unwrap_err(); + assert!( + issues.iter().any(|i| i.message.contains("recursive")), + "{issues:?}" + ); +} + +fn ident(s: &str) -> uhura_base::Ident { + uhura_base::Ident::new(s).unwrap() +} diff --git a/uhura/crates/uhura-cli/Cargo.toml b/uhura/crates/uhura-cli/Cargo.toml new file mode 100644 index 0000000..feab37b --- /dev/null +++ b/uhura/crates/uhura-cli/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "uhura-cli" +version.workspace = true +edition.workspace = true +publish.workspace = true +# Two binaries live here (`uhura` + the asset generator); bare `cargo run` +# means the CLI. +default-run = "uhura" + +[lints] +workspace = true + +[[bin]] +name = "uhura" +path = "src/main.rs" + +# The committed asset generator (plan micro-decision #19): seeded +# subject-motif JPEGs, regenerated from fixtures/assets/manifest.toml. +[[bin]] +name = "gen-assets" +path = "src/bin/gen_assets.rs" + +[dependencies] +uhura-base = { workspace = true } +uhura-syntax = { workspace = true } +uhura-check = { workspace = true } +uhura-port = { workspace = true, features = ["toml"] } +uhura-core = { workspace = true } +uhura-fixture = { workspace = true } +uhura-project = { workspace = true } +serde_json = { workspace = true } +tiny_http = { workspace = true } +toml = { workspace = true } +image = { workspace = true } diff --git a/uhura/crates/uhura-cli/src/bin/gen_assets.rs b/uhura/crates/uhura-cli/src/bin/gen_assets.rs new file mode 100644 index 0000000..bddf720 --- /dev/null +++ b/uhura/crates/uhura-cli/src/bin/gen_assets.rs @@ -0,0 +1,340 @@ +//! `gen-assets` — the committed, seeded demo-asset generator (plan +//! micro-decision #19: local generation, no network, no licensing risk). +//! Reads `fixtures/assets/manifest.toml`, renders each entry's motif +//! deterministically from its seed, writes JPEGs next to the manifest. +//! Same manifest → same bytes, byte-for-byte. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +fn main() -> ExitCode { + let root = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("../examples/instagram-uhura")); + let manifest_path = root.join("fixtures/assets/manifest.toml"); + let out_dir = root.join("fixtures/assets"); + + let text = match std::fs::read_to_string(&manifest_path) { + Ok(t) => t, + Err(e) => { + eprintln!("gen-assets: {}: {e}", manifest_path.display()); + return ExitCode::from(2); + } + }; + let table: toml::Table = match text.parse() { + Ok(t) => t, + Err(e) => { + eprintln!("gen-assets: manifest: {e}"); + return ExitCode::from(2); + } + }; + let Some(assets) = table.get("assets").and_then(toml::Value::as_table) else { + eprintln!("gen-assets: manifest has no [assets] section"); + return ExitCode::from(2); + }; + + let mut written = 0usize; + for (id, entry) in assets { + let Some(entry) = entry.as_table() else { + eprintln!("gen-assets: `{id}` is not a table"); + return ExitCode::from(2); + }; + let get_str = |k: &str| entry.get(k).and_then(toml::Value::as_str); + let get_int = |k: &str| entry.get(k).and_then(toml::Value::as_integer); + let (Some(file), Some(alt), Some(motif), Some(seed), Some(size)) = ( + get_str("file"), + get_str("alt"), + get_str("motif"), + get_int("seed"), + get_int("size"), + ) else { + eprintln!("gen-assets: `{id}` needs file, alt, motif, seed, size"); + return ExitCode::from(2); + }; + if alt.trim().is_empty() { + eprintln!("gen-assets: `{id}`: alt text is required (§8.3)"); + return ExitCode::from(2); + } + let size = size.clamp(16, 2048) as u32; + let image = render_motif(motif, seed as u64, size); + let Some(image) = image else { + eprintln!("gen-assets: `{id}`: unknown motif `{motif}`"); + return ExitCode::from(2); + }; + if let Err(e) = write_jpeg(&out_dir.join(file), &image) { + eprintln!("gen-assets: {file}: {e}"); + return ExitCode::from(2); + } + written += 1; + } + println!( + "gen-assets: wrote {written} JPEGs under {}", + out_dir.display() + ); + ExitCode::SUCCESS +} + +fn write_jpeg(path: &Path, image: &image::RgbImage) -> std::io::Result<()> { + let mut bytes = Vec::new(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 84); + encoder + .encode( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::Rgb8, + ) + .map_err(std::io::Error::other)?; + std::fs::write(path, bytes) +} + +// ── deterministic pseudo-randomness ───────────────────────────────────── + +struct XorShift(u64); + +impl XorShift { + fn new(seed: u64) -> Self { + XorShift(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15).max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + + /// Uniform in [0, 1). + fn unit(&mut self) -> f32 { + (self.next() >> 40) as f32 / (1u64 << 24) as f32 + } + + fn range(&mut self, lo: f32, hi: f32) -> f32 { + lo + (hi - lo) * self.unit() + } +} + +/// Cheap value noise on a lattice (bilinear, seeded) — grain and blobs. +fn noise(seed: u64, x: f32, y: f32) -> f32 { + fn hash(seed: u64, ix: i64, iy: i64) -> f32 { + let mut h = seed ^ (ix as u64).wrapping_mul(0x517c_c1b7_2722_0a95); + h ^= (iy as u64).wrapping_mul(0x2545_f491_4f6c_dd1d); + h ^= h >> 33; + h = h.wrapping_mul(0xff51_afd7_ed55_8ccd); + h ^= h >> 33; + (h >> 40) as f32 / (1u64 << 24) as f32 + } + let (ix, iy) = (x.floor() as i64, y.floor() as i64); + let (fx, fy) = (x - x.floor(), y - y.floor()); + let (sx, sy) = (fx * fx * (3.0 - 2.0 * fx), fy * fy * (3.0 - 2.0 * fy)); + let top = hash(seed, ix, iy) * (1.0 - sx) + hash(seed, ix + 1, iy) * sx; + let bottom = hash(seed, ix, iy + 1) * (1.0 - sx) + hash(seed, ix + 1, iy + 1) * sx; + top * (1.0 - sy) + bottom * sy +} + +// ── color helpers ─────────────────────────────────────────────────────── + +#[derive(Clone, Copy)] +struct Rgb(f32, f32, f32); + +impl Rgb { + fn mix(self, other: Rgb, t: f32) -> Rgb { + let t = t.clamp(0.0, 1.0); + Rgb( + self.0 + (other.0 - self.0) * t, + self.1 + (other.1 - self.1) * t, + self.2 + (other.2 - self.2) * t, + ) + } + + fn scale(self, k: f32) -> Rgb { + Rgb(self.0 * k, self.1 * k, self.2 * k) + } + + fn pixel(self) -> image::Rgb<u8> { + image::Rgb([ + (self.0.clamp(0.0, 1.0) * 255.0) as u8, + (self.1.clamp(0.0, 1.0) * 255.0) as u8, + (self.2.clamp(0.0, 1.0) * 255.0) as u8, + ]) + } +} + +fn hsl(h: f32, s: f32, l: f32) -> Rgb { + let h = h.rem_euclid(360.0) / 60.0; + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs()); + let (r, g, b) = match h as u32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + Rgb(r + m, g + m, b + m) +} + +// ── motifs ────────────────────────────────────────────────────────────── + +fn render_motif(motif: &str, seed: u64, size: u32) -> Option<image::RgbImage> { + let render: fn(&mut XorShift, u64, f32, f32) -> Rgb = match motif { + "duotone" => duotone, + "glaze" => glaze, + "wave" => wave, + "aurora" => aurora, + "crumb" => crumb, + "field" => field, + _ => return None, + }; + // Each pixel re-derives the motif constants from a fresh RNG seeded + // identically, so a pixel is a pure function of (seed, u, v). + let image = image::RgbImage::from_fn(size, size, |px, py| { + let u = px as f32 / size as f32; + let v = py as f32 / size as f32; + let mut rng = XorShift::new(seed); + render(&mut rng, seed, u, v).pixel() + }); + Some(image) +} + +/// Avatars: a two-tone diagonal split with an offset disc — a distinctive, +/// initial-free mark per seed. +fn duotone(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let hue = rng.range(0.0, 360.0); + let a = hsl(hue, 0.45, 0.42); + let b = hsl(hue + rng.range(120.0, 200.0), 0.40, 0.68); + let angle = rng.range(-0.6, 0.6); + let split = u + angle * (v - 0.5) - 0.5; + let base = if split < 0.0 { a } else { b }; + let (cx, cy, r) = ( + rng.range(0.30, 0.70), + rng.range(0.30, 0.70), + rng.range(0.18, 0.30), + ); + let d = ((u - cx).powi(2) + (v - cy).powi(2)).sqrt(); + if d < r { + if split < 0.0 { b } else { a } + } else { + let grain = noise(seed, u * 24.0, v * 24.0); + base.scale(0.96 + grain * 0.08) + } +} + +/// Glaze test tiles: a grid with per-tile hue jitter and kiln speckle. +fn glaze(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let hue = rng.range(0.0, 40.0) - 10.0; // copper/terracotta band + let tiles = 4.0; + let (tu, tv) = (u * tiles, v * tiles); + let (col, row) = (tu.floor(), tv.floor()); + let (fu, fv) = (tu - col, tv - row); + let tile_jitter = noise(seed ^ 0x51ce, col * 7.0 + 0.5, row * 7.0 + 0.5); + let l = 0.34 + tile_jitter * 0.28; + let mut color = hsl(hue + tile_jitter * 18.0, 0.52, l); + // grout lines + let edge = fu.min(1.0 - fu).min(fv).min(1.0 - fv); + if edge < 0.045 { + color = hsl(30.0, 0.12, 0.82); // maple bench grout + } else { + let speckle = noise(seed ^ 0xdead, u * 180.0, v * 180.0); + if speckle > 0.82 { + color = color.scale(0.55); + } + let sheen = noise(seed ^ 0xbeef, u * 6.0, v * 6.0); + color = color.scale(0.92 + sheen * 0.16); + } + color +} + +/// Sea and dusk: layered sine swells with a bright horizon band. +fn wave(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let hue = rng.range(185.0, 225.0); + let horizon = rng.range(0.28, 0.42); + let sky = hsl(hue + 15.0, 0.35, 0.82); + let sun = hsl(35.0, 0.75, 0.72); + let sea_light = hsl(hue, 0.50, 0.46); + let sea_dark = hsl(hue + 8.0, 0.55, 0.22); + if v < horizon { + let glow = 1.0 - (v / horizon); + return sky.mix(sun, (glow * 0.5).powi(2)); + } + let depth = (v - horizon) / (1.0 - horizon); + let swell = ((u * 9.0 + depth * 4.0 + seed as f32 % 7.0).sin() * 0.5 + 0.5) + * ((u * 23.0 - depth * 9.0).sin() * 0.5 + 0.5); + let foam = noise(seed, u * 60.0, v * 60.0); + let mut color = sea_dark.mix(sea_light, swell * (1.0 - depth * 0.6)); + if foam > 0.86 && swell > 0.55 { + color = color.mix(Rgb(0.95, 0.97, 0.98), 0.7); + } + color +} + +/// Night sky: dark gradient, green curtain ribbons, star pricks. +fn aurora(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let night = Rgb(0.03, 0.05, 0.10).mix(Rgb(0.07, 0.09, 0.16), v); + let drift = rng.range(0.0, std::f32::consts::TAU); + let mut glow = 0.0f32; + for band in 0..3 { + let b = band as f32; + let center = 0.25 + 0.18 * b + 0.12 * ((u * (2.0 + b) * 3.0 + drift).sin()); + let d = (v - center).abs(); + glow += (0.045 / (d + 0.02)).min(1.4) * (0.5 + 0.5 * ((u * 9.0 + b * 2.0).sin())); + } + let curtain = hsl(140.0, 0.85, 0.45) + .scale(glow.min(1.6) * 0.55) + .mix(hsl(280.0, 0.5, 0.4).scale(glow.min(1.0) * 0.2), 0.25); + let star = noise(seed, u * 240.0, v * 240.0); + let mut color = Rgb( + night.0 + curtain.0, + night.1 + curtain.1, + night.2 + curtain.2, + ); + if star > 0.985 && v < 0.7 { + color = color.mix(Rgb(1.0, 1.0, 0.95), 0.8); + } + // fjord silhouette + let ridge = 0.82 + 0.06 * ((u * 5.0 + drift).sin()); + if v > ridge { + color = Rgb(0.01, 0.02, 0.04); + } + color +} + +/// Bread and clay: a warm base with thresholded blob holes. +fn crumb(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let hue = rng.range(28.0, 44.0); + let base = hsl(hue, 0.42, 0.72); + let crust = hsl(hue - 6.0, 0.55, 0.38); + let cell = + noise(seed, u * 14.0, v * 14.0) * 0.65 + noise(seed ^ 0x77, u * 40.0, v * 40.0) * 0.35; + let mut color = if cell < 0.36 { + base.scale(0.55 + cell * 0.5) // open crumb holes + } else { + base.scale(0.9 + noise(seed ^ 0x99, u * 90.0, v * 90.0) * 0.18) + }; + // crust vignette + let edge = (u - 0.5).abs().max((v - 0.5).abs()) * 2.0; + if edge > 0.82 { + color = color.mix(crust, ((edge - 0.82) / 0.18).powi(2)); + } + color +} + +/// Soft editorial blocks: 3–4 muted color fields with grain. +fn field(rng: &mut XorShift, seed: u64, u: f32, v: f32) -> Rgb { + let hue = rng.range(0.0, 360.0); + let vertical = rng.unit() > 0.5; + let t = if vertical { u } else { v }; + let bands = 3 + (seed % 2) as usize; + let idx = ((t * bands as f32) as usize).min(bands - 1); + let l = 0.38 + 0.14 * idx as f32; + let s = 0.22 + 0.06 * ((idx * 7) % 3) as f32; + let color = hsl(hue + idx as f32 * 24.0, s, l); + let grain = noise(seed, u * 120.0, v * 120.0); + let soft = noise(seed ^ 0x42, u * 3.0, v * 3.0); + color.scale(0.93 + grain * 0.07 + soft * 0.06) +} diff --git a/uhura/crates/uhura-cli/src/cmd/check.rs b/uhura/crates/uhura-cli/src/cmd/check.rs new file mode 100644 index 0000000..c9b2fbf --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/check.rs @@ -0,0 +1,93 @@ +//! `uhura check [path] [--emit-ir] [--deny-warnings] [--format=json]` — +//! the full pipeline (§12.2): manifest → catalog/ports → parse → resolve → +//! typecheck → markup → style → examples → lower. This file does every +//! read and write; the pipeline itself is pure. + +use std::path::Path; +use std::process::ExitCode; + +use uhura_base::{Severity, render_text, to_envelope}; +use uhura_check::{LockStatus, check}; + +use crate::CommonArgs; + +pub fn run(common: &CommonArgs) -> ExitCode { + let root = &common.root; + + let input = match super::assemble_input(root) { + Ok(input) => input, + Err(code) => return code, + }; + let output = check(&input); + + // ── lock: write when absent (micro-decision #6) ──────────────────── + if output.lock_status == LockStatus::Absent + && !uhura_base::has_errors(&output.diagnostics) + && let Err(e) = std::fs::write(root.join("uhura.lock"), &output.lock_computed) + { + eprintln!("uhura check: could not write uhura.lock: {e}"); + return ExitCode::from(2); + } + + // ── --emit-ir ────────────────────────────────────────────────────── + if common.emit_ir { + match &output.lowered { + None => { + eprintln!("uhura check: not emitting IR — the check did not come up clean"); + } + Some(lowered) => { + if let Err(e) = emit_build_artifacts(root, lowered, &output.stylesheet) { + eprintln!("uhura check: emit-ir failed: {e}"); + return ExitCode::from(2); + } + } + } + } + + // ── report ───────────────────────────────────────────────────────── + let diags = &output.diagnostics; + if common.format_json { + println!( + "{}", + serde_json::to_string_pretty(&to_envelope(diags, &output.source_map)) + .expect("envelope json") + ); + } else if !diags.is_empty() { + print!("{}", render_text(diags, &output.source_map)); + } else { + println!("checked {} files: clean", input.sources.len()); + } + + let failing = diags.iter().any(|d| { + d.severity == Severity::Error || (common.deny_warnings && d.severity == Severity::Warning) + }); + if failing { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} + +/// `build/ir.json` (canonical bytes — the golden artifact), `build/ +/// ir-spans.json` (side table), `build/stylesheet.css` (compiled). +fn emit_build_artifacts( + root: &Path, + lowered: &uhura_check::lower::Lowered, + stylesheet: &str, +) -> std::io::Result<()> { + let build = root.join("build"); + std::fs::create_dir_all(&build)?; + std::fs::write(build.join("ir.json"), lowered.program.to_canonical_string())?; + let spans = serde_json::to_value(&lowered.spans).expect("span table is always serializable"); + std::fs::write( + build.join("ir-spans.json"), + uhura_base::to_canonical_json(&spans), + )?; + std::fs::write(build.join("stylesheet.css"), stylesheet)?; + println!( + "emitted build/ir.json ({} bytes, hash {}), build/ir-spans.json, build/stylesheet.css", + lowered.program.to_canonical_string().len(), + lowered.program.hash() + ); + Ok(()) +} diff --git a/uhura/crates/uhura-cli/src/cmd/dev.rs b/uhura/crates/uhura-cli/src/cmd/dev.rs new file mode 100644 index 0000000..d7a69b6 --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/dev.rs @@ -0,0 +1,484 @@ +//! `uhura dev [path] [--port <n>]` — the play server (design §12.4): +//! tiny_http + SSE. Watch `*.{uhura,toml,css}` under the corpus (100 ms +//! debounce) → recheck natively → serve the LAST-GOOD IR + compiled +//! stylesheet from memory; a failing check pushes its diagnostics +//! envelope over `/events` and the shell overlays it OVER the running +//! app. Reload policy is full restart — state-preserving reload is an +//! open RFC topic the spike must not fake. +//! +//! Endpoints: `/` `/shell/*` `/wasm/*` `/ir.json` `/stylesheet.css` +//! `/fixture.json` `/script.json` `/boot.json` `/icons.json` `/assets/*` +//! `/events` (SSE). Nothing is ever written into the watched tree. + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, SystemTime}; + +use uhura_base::{Severity, to_canonical_json, to_envelope}; +use uhura_check::check; +use uhura_check::fixture::load_fixture; +use uhura_core::ir::ProgramIr; + +use crate::CommonArgs; +use crate::cmd::trace::{boot_updates, fixture_slices_json}; + +pub fn run(common: &CommonArgs, port: u16) -> ExitCode { + let root = common.root.clone(); + let state = Arc::new(RwLock::new(DevState { + generation: 0, + ok: false, + diagnostics: None, + good: None, + })); + let clients: Clients = Arc::new(Mutex::new(Vec::new())); + + // ── first check, synchronously: the URL we print is honest ────────── + // The watch baseline is scanned FIRST so an edit landing during the + // initial check is still detected as a change. + let baseline = scan_watched(&root); + recheck_into(&root, &state, &clients); + { + let s = state.read().expect("state lock"); + match (&s.good, s.ok) { + (Some(_), true) => println!("uhura dev: checked clean"), + (Some(_), false) => println!("uhura dev: check FAILING — serving the last good build"), + (None, _) => println!("uhura dev: check FAILING — no good build yet (overlay only)"), + } + } + + let server = match tiny_http::Server::http(("127.0.0.1", port)) { + Ok(server) => server, + Err(e) => { + eprintln!("uhura dev: could not bind 127.0.0.1:{port}: {e}"); + return ExitCode::from(2); + } + }; + println!("uhura dev: http://127.0.0.1:{port}/ (tick=250ms, override with ?tick=<ms>)"); + + // ── the watcher: poll mtimes, debounce, recheck, broadcast ────────── + { + let root = root.clone(); + let state = Arc::clone(&state); + let clients = Arc::clone(&clients); + std::thread::spawn(move || { + let mut seen = baseline; + loop { + std::thread::sleep(Duration::from_millis(150)); + let now = scan_watched(&root); + if now == seen { + continue; + } + // Debounce: wait for the tree to sit still for 100 ms. + let mut stable = now; + loop { + std::thread::sleep(Duration::from_millis(100)); + let again = scan_watched(&root); + if again == stable { + break; + } + stable = again; + } + seen = stable; + recheck_into(&root, &state, &clients); + let s = state.read().expect("state lock"); + println!( + "uhura dev: generation {} — {}", + s.generation, + if s.ok { + "ok, shell reloads" + } else { + "check failing, overlay pushed" + } + ); + } + }); + } + + // ── serve: one thread per request (SSE holds its thread) ──────────── + let server = Arc::new(server); + for request in server.incoming_requests() { + let state = Arc::clone(&state); + let clients = Arc::clone(&clients); + let root = root.clone(); + std::thread::spawn(move || handle(request, &root, &state, &clients)); + } + ExitCode::SUCCESS +} + +// ── state ─────────────────────────────────────────────────────────────────── + +struct DevState { + generation: u64, + ok: bool, + /// `uhura-diagnostics/0` envelope of the latest FAILING check. + diagnostics: Option<serde_json::Value>, + /// Last-good artifacts — what every endpoint serves (§12.4). + good: Option<GoodBuild>, +} + +struct GoodBuild { + ir: String, + stylesheet: String, + fixture_json: String, + script_json: String, + boot_json: String, + icons_json: String, +} + +type Clients = Arc<Mutex<Vec<Sender<String>>>>; + +/// (content-type, body, generation stamp) or (status, message). +type Served = Result<(String, Vec<u8>, Option<u64>), (u16, String)>; + +/// One recheck: run the pure pipeline, swap the state, broadcast the SSE +/// event. A failing check NEVER clobbers the last good build. +fn recheck_into(root: &Path, state: &RwLock<DevState>, clients: &Clients) { + let outcome = recheck(root); + let payload; + { + let mut s = state.write().expect("state lock"); + s.generation += 1; + match outcome { + Ok(good) => { + s.ok = true; + s.diagnostics = None; + s.good = Some(good); + } + Err(envelope) => { + s.ok = false; + s.diagnostics = Some(envelope); + } + } + payload = sse_payload(&s); + } + broadcast(clients, &payload); +} + +fn recheck(root: &Path) -> Result<GoodBuild, serde_json::Value> { + let fail = |message: String| { + serde_json::json!({ + "format": "uhura-diagnostics", + "version": 0, + "diagnostics": [{ + "code": "UH9000", + "rule": "dev/recheck", + "severity": "error", + "message": message, + }], + }) + }; + let input = crate::cmd::assemble_input(root) + .map_err(|_| fail("the corpus could not be read (see the server log)".to_string()))?; + let output = check(&input); + if output + .diagnostics + .iter() + .any(|d| d.severity == Severity::Error) + { + return Err(to_envelope(&output.diagnostics, &output.source_map)); + } + let Some(lowered) = &output.lowered else { + return Err(fail("the check produced no program".to_string())); + }; + let program = &lowered.program; + + // The play profile names the fixture + script (§3). + let manifest = &input.manifest; + let profile = manifest + .play + .values() + .next() + .ok_or_else(|| fail("uhura.toml declares no [play.*] profile (§3)".to_string()))?; + let fixture_rel = manifest.fixtures.get(&profile.fixture).ok_or_else(|| { + fail(format!( + "play fixture `{}` is not declared", + profile.fixture + )) + })?; + let read = |rel: &str| -> Result<String, serde_json::Value> { + std::fs::read_to_string(root.join(rel)).map_err(|e| fail(format!("{rel}: {e}"))) + }; + let fixture_text = read(fixture_rel)?; + let script_text = read(&format!("fixtures/scripts/{}.toml", profile.script))?; + let fixture = load_fixture(&fixture_text) + .map_err(|issues| fail(format!("fixture: {}", issues[0].message)))?; + let script_json = uhura_fixture::toml_to_json(&script_text).map_err(fail)?; + let fixture_json = fixture_slices_json(&fixture); + let script_canonical = to_canonical_json(&script_json); + // An "ok" generation must be BOOT-VIABLE: the driver's strict grammar + // (slice refs, closed script fields) validates here, not in the + // browser after a reload. + uhura_fixture::FixtureDriver::new(&fixture_json, &script_canonical) + .map_err(|e| fail(format!("script `{}`: {e}", profile.script)))?; + + Ok(GoodBuild { + ir: program.to_canonical_string(), + stylesheet: output.stylesheet.clone(), + fixture_json, + script_json: script_canonical, + boot_json: boot_envelope(program, &fixture).map_err(fail)?, + icons_json: icons_json(root, &manifest.catalog_path), + }) +} + +/// The `/boot.json` wire form — `{"updates": […]}` over `boot_updates`. +/// The acceptance battery's parity artifacts reuse it so the file the +/// wasm side boots from has exactly one producer. +pub fn boot_envelope( + program: &ProgramIr, + fixture: &uhura_check::fixture::FixtureData, +) -> Result<String, String> { + let updates = boot_updates(program, fixture)?; + Ok(to_canonical_json(&serde_json::json!({ + "updates": updates + .iter() + .map(uhura_port::envelope::ProjectionUpdate::to_json) + .collect::<Vec<_>>(), + }))) +} + +/// The catalog's closed icon set → inline SVG glyph table. One source of +/// truth: the same `uhura_project::icons` the static canvas embeds. +fn icons_json(root: &Path, catalog_rel: &str) -> String { + let mut glyphs = serde_json::Map::new(); + if let Ok(text) = std::fs::read_to_string(root.join(catalog_rel)) + && let Ok(value) = text.parse::<toml::Value>() + && let Some(icons) = value + .get("catalog") + .and_then(|c| c.get("icons")) + .and_then(|i| i.as_array()) + { + for name in icons.iter().filter_map(|n| n.as_str()) { + if let Some(glyph) = uhura_project::icons::glyph(name) { + glyphs.insert(name.to_string(), serde_json::Value::String(glyph.into())); + } + } + } + to_canonical_json(&serde_json::Value::Object(glyphs)) +} + +// ── the watcher's view of the tree ────────────────────────────────────────── + +/// (path → (mtime, len)) for every watched file: `*.{uhura,toml,css}`, +/// excluding emitted/derived trees and the lock (check writes it when +/// absent — watching it would loop). +fn scan_watched(root: &Path) -> BTreeMap<PathBuf, (SystemTime, u64)> { + let mut out = BTreeMap::new(); + scan_dir(root, &mut out); + out +} + +fn scan_dir(dir: &Path, out: &mut BTreeMap<PathBuf, (SystemTime, u64)>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + if path.is_dir() { + if !matches!(name.as_ref(), "build" | "renders" | "target" | ".git") { + scan_dir(&path, out); + } + continue; + } + let watched = matches!( + path.extension().and_then(|e| e.to_str()), + Some("uhura" | "toml" | "css") + ) && name != "uhura.lock"; + if watched && let Ok(meta) = entry.metadata() { + let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH); + out.insert(path, (mtime, meta.len())); + } + } +} + +// ── SSE ───────────────────────────────────────────────────────────────────── + +fn sse_payload(s: &DevState) -> String { + let mut event = serde_json::json!({ + "generation": s.generation, + "ok": s.ok, + }); + if let Some(diagnostics) = &s.diagnostics { + event["diagnostics"] = diagnostics.clone(); + } + // Trailing SSE comment pads past tiny_http's ~8 KiB write buffer so + // the data frame flushes NOW — EventSource ignores comment lines. + format!( + "data: {}\n\n: {}\n\n", + to_canonical_json(&event), + "·".repeat(4096) + ) +} + +fn broadcast(clients: &Clients, payload: &str) { + let mut clients = clients.lock().expect("clients lock"); + clients.retain(|tx| tx.send(payload.to_string()).is_ok()); +} + +/// Blocking channel-backed body: each `read` hands out the next pushed +/// SSE frame; the response thread parks in `recv` between events. +struct SseStream { + rx: Receiver<String>, + buffer: Vec<u8>, + offset: usize, +} + +impl Read for SseStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + if self.offset >= self.buffer.len() { + match self.rx.recv() { + Ok(frame) => { + self.buffer = frame.into_bytes(); + self.offset = 0; + } + Err(_) => return Ok(0), // server side dropped: EOF + } + } + let n = (self.buffer.len() - self.offset).min(buf.len()); + buf[..n].copy_from_slice(&self.buffer[self.offset..self.offset + n]); + self.offset += n; + Ok(n) + } +} + +// ── request handling ──────────────────────────────────────────────────────── + +/// The uhura workspace root — the shell and wasm bundle live in the +/// TOOLCHAIN tree, not the corpus (compile-time anchored; `uhura dev` +/// is a dev tool run from this repo). +fn tool_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn handle(request: tiny_http::Request, root: &Path, state: &RwLock<DevState>, clients: &Clients) { + let url = request.url().to_string(); + let path = url.split('?').next().unwrap_or("/"); + + if path == "/events" { + let (tx, rx) = channel::<String>(); + { + // Registration and the hello snapshot happen under the same + // lock `broadcast` takes, so no generation can slip between + // them (it lands as a duplicate, never a gap). + let mut clients = clients.lock().expect("clients lock"); + let hello = sse_payload(&state.read().expect("state lock")); + let _ = tx.send(hello); + clients.push(tx); + } + let response = tiny_http::Response::new( + tiny_http::StatusCode(200), + vec![ + header("Content-Type", "text/event-stream; charset=utf-8"), + header("Cache-Control", "no-store"), + ], + SseStream { + rx, + buffer: Vec::new(), + offset: 0, + }, + None, + None, + ); + let _ = request.respond(response); // blocks for the client's lifetime + return; + } + + let outcome: Served = match path { + "/" => file_bytes(&tool_root().join("shell/index.html")).map(|b| (ct("html"), b, None)), + "/ir.json" => artifact(state, |g| g.ir.clone().into_bytes(), "json"), + "/stylesheet.css" => artifact(state, |g| g.stylesheet.clone().into_bytes(), "css"), + "/fixture.json" => artifact(state, |g| g.fixture_json.clone().into_bytes(), "json"), + "/script.json" => artifact(state, |g| g.script_json.clone().into_bytes(), "json"), + "/boot.json" => artifact(state, |g| g.boot_json.clone().into_bytes(), "json"), + "/icons.json" => artifact(state, |g| g.icons_json.clone().into_bytes(), "json"), + _ => { + if let Some(rel) = path.strip_prefix("/shell/") { + serve_tree(&tool_root().join("shell"), rel) + } else if let Some(rel) = path.strip_prefix("/wasm/") { + serve_tree(&tool_root().join("crates/uhura-wasm/pkg/web"), rel).map_err( + |(code, msg)| { + ( + code, + format!("{msg}\n(build the bundle first: scripts/build-wasm.sh)"), + ) + }, + ) + } else if let Some(rel) = path.strip_prefix("/assets/") { + serve_tree(&root.join("fixtures/assets"), rel) + } else { + Err((404, format!("no such endpoint: {path}"))) + } + } + }; + + let _ = match outcome { + Ok((content_type, bytes, generation)) => { + let mut response = tiny_http::Response::from_data(bytes) + .with_header(header("Content-Type", &content_type)) + .with_header(header("Cache-Control", "no-store")); + if let Some(generation) = generation { + response = + response.with_header(header("X-Uhura-Generation", &generation.to_string())); + } + request.respond(response) + } + Err((status, message)) => request.respond( + tiny_http::Response::from_string(message) + .with_status_code(tiny_http::StatusCode(status)) + .with_header(header("Content-Type", "text/plain; charset=utf-8")) + .with_header(header("Cache-Control", "no-store")), + ), + }; +} + +/// A last-good in-memory artifact; 503 + the failure story before the +/// first clean check. The generation stamp lets the shell detect a +/// recheck landing between its artifact fetches (mixed-build boot). +fn artifact(state: &RwLock<DevState>, pick: impl Fn(&GoodBuild) -> Vec<u8>, ext: &str) -> Served { + let s = state.read().expect("state lock"); + match &s.good { + Some(good) => Ok((ct(ext), pick(good), Some(s.generation))), + None => Err(( + 503, + "no good build yet — fix the check errors (the overlay lists them)".to_string(), + )), + } +} + +/// One file under a served tree; refuses traversal. +fn serve_tree(base: &Path, rel: &str) -> Served { + if rel.split('/').any(|seg| seg == ".." || seg.is_empty()) || rel.contains('\\') { + return Err((400, "bad path".to_string())); + } + let path = base.join(rel); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + file_bytes(&path).map(|b| (ct(ext), b, None)) +} + +fn file_bytes(path: &Path) -> Result<Vec<u8>, (u16, String)> { + std::fs::read(path).map_err(|e| (404, format!("{}: {e}", path.display()))) +} + +fn ct(ext: &str) -> String { + match ext { + "html" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json; charset=utf-8", + "wasm" => "application/wasm", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + _ => "application/octet-stream", + } + .to_string() +} + +fn header(name: &str, value: &str) -> tiny_http::Header { + tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("static header") +} diff --git a/uhura/crates/uhura-cli/src/cmd/fmt.rs b/uhura/crates/uhura-cli/src/cmd/fmt.rs new file mode 100644 index 0000000..6407ea4 --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/fmt.rs @@ -0,0 +1,69 @@ +//! `uhura fmt [path] [--check]` — canonical-formats every `.uhura` source. +//! `--check` reports drift without writing (CI mode). + +use std::process::ExitCode; + +use uhura_base::{SourceMap, has_errors, render_text}; +use uhura_syntax::{Parsed, parse}; + +use crate::CommonArgs; +use crate::fsio::walk_corpus; + +pub fn run(common: &CommonArgs, check_only: bool) -> ExitCode { + let files = match walk_corpus(&common.root) { + Ok(f) => f, + Err(e) => { + eprintln!("uhura fmt: {}: {e}", common.root.display()); + return ExitCode::from(2); + } + }; + if files.is_empty() { + eprintln!( + "uhura fmt: no .uhura sources under {}", + common.root.display() + ); + return ExitCode::from(2); + } + + let mut sm = SourceMap::new(); + let mut drifted = Vec::new(); + let mut broken = false; + + for f in &files { + let id = sm.add(f.rel_path.clone(), f.text.clone()); + let out = parse(id, &f.text, f.kind); + if has_errors(&out.diagnostics) { + // Refuse to format files that do not parse. + eprint!("{}", render_text(&out.diagnostics, &sm)); + broken = true; + continue; + } + let formatted = match &out.parsed { + Parsed::Module(m) => uhura_syntax::format_module(m), + Parsed::Examples(e) => uhura_syntax::format_examples(e), + }; + if formatted != f.text { + drifted.push(f.rel_path.clone()); + if !check_only && let Err(e) = std::fs::write(&f.abs_path, &formatted) { + eprintln!("uhura fmt: cannot write {}: {e}", f.rel_path); + return ExitCode::from(2); + } + } + } + + if broken { + return ExitCode::from(1); + } + if check_only && !drifted.is_empty() { + for p in &drifted { + println!("would reformat: {p}"); + } + return ExitCode::from(1); + } + if !check_only { + for p in &drifted { + println!("reformatted: {p}"); + } + } + ExitCode::SUCCESS +} diff --git a/uhura/crates/uhura-cli/src/cmd/mod.rs b/uhura/crates/uhura-cli/src/cmd/mod.rs new file mode 100644 index 0000000..92ba3f1 --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/mod.rs @@ -0,0 +1,92 @@ +pub mod check; +pub mod dev; +pub mod fmt; +pub mod project; +pub mod trace; + +use std::path::Path; +use std::process::ExitCode; + +use uhura_check::manifest::load_manifest; +use uhura_check::{CheckInput, SourceInput}; + +use crate::fsio::walk_corpus; + +/// Reads everything `uhura check`/`uhura project` need from a corpus root +/// and assembles the pure pipeline input. +pub fn assemble_input(root: &Path) -> Result<CheckInput, ExitCode> { + // ── manifest (tells us what else to read) ────────────────────────── + let manifest_path = root.join("uhura.toml"); + let manifest_text = match std::fs::read_to_string(&manifest_path) { + Ok(t) => t, + Err(e) => { + eprintln!( + "uhura check: {}: {e}\n(a corpus root needs a `uhura.toml` manifest — design §3)", + manifest_path.display() + ); + return Err(ExitCode::from(2)); + } + }; + let manifest = match load_manifest(&manifest_text) { + Ok(m) => m, + Err(issues) => { + for issue in &issues { + eprintln!("uhura.toml: {}: {}", issue.path, issue.message); + } + return Err(ExitCode::from(1)); + } + }; + + // ── referenced files ─────────────────────────────────────────────── + let read_opt = |rel: &str| std::fs::read_to_string(root.join(rel)).ok(); + let catalog_file = ( + manifest.catalog_path.clone(), + read_opt(&manifest.catalog_path), + ); + let port_files = manifest + .ports + .iter() + .map(|(name, rel)| (name.clone(), (rel.clone(), read_opt(rel)))) + .collect(); + let theme_css = read_opt("styles/theme.css").map(|css| ("styles/theme.css".to_string(), css)); + let fixture_files = manifest + .fixtures + .iter() + .map(|(name, rel)| (name.clone(), (rel.clone(), read_opt(rel)))) + .collect(); + let lock_text = read_opt("uhura.lock"); + + let files = match walk_corpus(root) { + Ok(f) => f, + Err(e) => { + eprintln!("uhura check: {}: {e}", root.display()); + return Err(ExitCode::from(2)); + } + }; + if files.is_empty() { + eprintln!("uhura check: no .uhura sources under {}", root.display()); + return Err(ExitCode::from(2)); + } + let sources = files + .into_iter() + .map(|f| SourceInput { + rel_path: f.rel_path, + text: f.text, + kind: f.kind, + }) + .collect(); + + // ── the pure pipeline ────────────────────────────────────────────── + let input = CheckInput { + manifest, + manifest_rel_path: "uhura.toml".to_string(), + manifest_text, + catalog_file, + port_files, + sources, + theme_css, + fixture_files, + lock_text, + }; + Ok(input) +} diff --git a/uhura/crates/uhura-cli/src/cmd/project.rs b/uhura/crates/uhura-cli/src/cmd/project.rs new file mode 100644 index 0000000..08c3297 --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/project.rs @@ -0,0 +1,198 @@ +//! `uhura project [path] [--out=<dir>]` — checked program → resolved +//! example previews (pinned + replay-derived, §6.2) → `eval_view` → V → +//! HTML → one self-contained `renders/canvas.html` (§8.3). Zero +//! transitions, commands, network — replay already happened in the check. + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use uhura_base::{Severity, render_text}; +use uhura_check::check; +use uhura_check::preview::PreviewPayload; +use uhura_check::resolve::SubjectKind; +use uhura_core::eval::{eval_fragment, eval_view}; +use uhura_project::{Asset, FrameContent, FrameKind, PreviewFrame, render_canvas}; + +use crate::CommonArgs; + +pub fn run(common: &CommonArgs, out_dir: Option<&str>) -> ExitCode { + let root = &common.root; + let input = match super::assemble_input(root) { + Ok(input) => input, + Err(code) => return code, + }; + let output = check(&input); + if output + .diagnostics + .iter() + .any(|d| d.severity == Severity::Error) + { + print!("{}", render_text(&output.diagnostics, &output.source_map)); + eprintln!("uhura project: the check must come up clean first"); + return ExitCode::from(1); + } + let Some(lowered) = &output.lowered else { + eprintln!("uhura project: no checked program"); + return ExitCode::from(1); + }; + let program = &lowered.program; + + // ── previews → frames ────────────────────────────────────────────── + let mut frames = Vec::new(); + let mut derived = 0usize; + for preview in &output.previews { + let (kind, subject) = match &preview.subject { + SubjectKind::Page { route } => (FrameKind::Page, route.to_string()), + SubjectKind::Surface { name, .. } => (FrameKind::Surface, name.to_string()), + SubjectKind::Component { name } => (FrameKind::Component, name.to_string()), + }; + if preview.derived { + derived += 1; + } + let content = match &preview.payload { + PreviewPayload::Page { u, x, .. } => match eval_view(program, u, x) { + Ok(snapshot) => FrameContent::Snapshot(snapshot), + Err(e) => { + eprintln!("uhura project: {subject}/{}: {e}", preview.example); + return ExitCode::from(1); + } + }, + PreviewPayload::Fragment { + surface, + name, + props, + state, + x, + } => { + let def = if *surface { + program.surfaces.get(name) + } else { + program.components.get(name) + }; + let Some(def) = def else { + eprintln!("uhura project: no definition `{name}`"); + return ExitCode::from(1); + }; + match eval_fragment(program, def, props, state, x) { + Ok(node) => FrameContent::Fragment(node), + Err(e) => { + eprintln!("uhura project: {subject}/{}: {e}", preview.example); + return ExitCode::from(1); + } + } + } + }; + frames.push(PreviewFrame { + kind, + subject, + example: preview.example.clone(), + is_default: preview.is_default, + pinned: preview.pinned, + derived: preview.derived, + in_flight: preview.in_flight, + from: preview.from.clone(), + note: preview.note.clone(), + content, + }); + } + + // ── assets: manifest + JPEG bytes → data URIs ────────────────────── + let assets = match load_assets(root, input.manifest.assets_manifest.as_deref()) { + Ok(a) => a, + Err(e) => { + eprintln!("uhura project: assets: {e}"); + return ExitCode::from(2); + } + }; + + let html = render_canvas( + input.manifest.app_name.as_str(), + &frames, + &output.stylesheet, + &assets, + ); + + let out_dir = out_dir + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("renders")); + if let Err(e) = std::fs::create_dir_all(&out_dir) { + eprintln!("uhura project: {}: {e}", out_dir.display()); + return ExitCode::from(2); + } + let out_path = out_dir.join("canvas.html"); + if let Err(e) = std::fs::write(&out_path, &html) { + eprintln!("uhura project: {}: {e}", out_path.display()); + return ExitCode::from(2); + } + println!( + "projected {} previews ({} replay-derived) → {} ({} KiB)", + frames.len(), + derived, + out_path.display(), + html.len() / 1024 + ); + ExitCode::SUCCESS +} + +fn load_assets( + root: &Path, + manifest_rel: Option<&str>, +) -> Result<std::collections::BTreeMap<String, Asset>, String> { + let mut out = std::collections::BTreeMap::new(); + let Some(manifest_rel) = manifest_rel else { + return Ok(out); + }; + let manifest_path = root.join(manifest_rel); + let text = std::fs::read_to_string(&manifest_path) + .map_err(|e| format!("{}: {e}", manifest_path.display()))?; + let table: toml::Table = text.parse().map_err(|e| format!("manifest: {e}"))?; + let Some(assets) = table.get("assets").and_then(toml::Value::as_table) else { + return Ok(out); + }; + let asset_dir = manifest_path.parent().unwrap_or(root); + for (id, entry) in assets { + let file = entry.get("file").and_then(toml::Value::as_str); + let alt = entry.get("alt").and_then(toml::Value::as_str); + let (Some(file), Some(alt)) = (file, alt) else { + return Err(format!("asset `{id}` needs `file` and `alt` (§8.3)")); + }; + // Missing files fall back to the duotone SVG at render time. + if let Ok(bytes) = std::fs::read(asset_dir.join(file)) { + out.insert( + id.clone(), + Asset { + data_uri: format!("data:image/jpeg;base64,{}", base64(&bytes)), + alt: alt.to_string(), + }, + ); + } + } + Ok(out) +} + +/// Standard base64 (padded) — 20 lines beats a dependency. +fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = u32::from_be_bytes([0, b[0], b[1], b[2]]); + out.push(ALPHABET[(n >> 18) as usize & 63] as char); + out.push(ALPHABET[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { + ALPHABET[(n >> 6) as usize & 63] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + ALPHABET[n as usize & 63] as char + } else { + '=' + }); + } + out +} diff --git a/uhura/crates/uhura-cli/src/cmd/trace.rs b/uhura/crates/uhura-cli/src/cmd/trace.rs new file mode 100644 index 0000000..0df44c6 --- /dev/null +++ b/uhura/crates/uhura-cli/src/cmd/trace.rs @@ -0,0 +1,409 @@ +//! `uhura trace [path] --script=<name> [--expanded]` — the headless play +//! harness (design §9.5, §12.4): boot projections → `Init` → tick pump. +//! Each step prints one canonical `StepTrace` JSONL line; `--expanded` +//! embeds the full V per step as presentation. +//! +//! The pump owns exactly the §7.2 ordering contract: driver messages +//! apply to `X` (revision-checked) immediately before their event steps; +//! emitted commands deliver to the driver as they appear. Scripts carry a +//! harness-only `[[ui]]` stimulus section (plan micro-decision #13): each +//! stimulus presses a descriptor that must be PRESENT in the current view +//! — a vanished control fails the script, which is the point. + +use std::collections::BTreeMap; +use std::process::ExitCode; + +use uhura_base::{Ident, Severity, render_text, to_canonical_json}; +use uhura_check::check; +use uhura_check::fixture::{FixtureData, load_fixture}; +use uhura_core::event::{ApplyNote, Event, apply_failure, apply_updates, decode_carried_data}; +use uhura_core::ir::ProgramIr; +use uhura_core::state::{Projections, UiState}; +use uhura_core::step::step_u; +use uhura_core::view::{Descriptor, Node, Snapshot}; +use uhura_fixture::FixtureDriver; +use uhura_port::envelope::{ProjectionUpdate, ProviderMsg}; + +use crate::CommonArgs; + +pub fn run(common: &CommonArgs, script: Option<&str>, expanded: bool) -> ExitCode { + let root = &common.root; + let input = match super::assemble_input(root) { + Ok(input) => input, + Err(code) => return code, + }; + let manifest = input.manifest.clone(); + let output = check(&input); + if output + .diagnostics + .iter() + .any(|d| d.severity == Severity::Error) + { + print!("{}", render_text(&output.diagnostics, &output.source_map)); + eprintln!("uhura trace: the check must come up clean first"); + return ExitCode::from(1); + } + let Some(lowered) = &output.lowered else { + eprintln!("uhura trace: no checked program"); + return ExitCode::from(1); + }; + + // The play profile names the fixture; `--script` overrides the script. + let Some(profile) = manifest.play.values().next() else { + eprintln!("uhura trace: the manifest declares no [play.*] profile (§3)"); + return ExitCode::from(1); + }; + let Some(fixture_rel) = manifest.fixtures.get(&profile.fixture) else { + eprintln!( + "uhura trace: play fixture `{}` is not declared", + profile.fixture + ); + return ExitCode::from(1); + }; + let script_name = script.unwrap_or(profile.script.as_str()); + let script_rel = format!("fixtures/scripts/{script_name}.toml"); + + let read = |rel: &str| -> Result<String, String> { + std::fs::read_to_string(root.join(rel)).map_err(|e| format!("{rel}: {e}")) + }; + let (fixture_text, script_text) = match (read(fixture_rel), read(&script_rel)) { + (Ok(f), Ok(s)) => (f, s), + (Err(e), _) | (_, Err(e)) => { + eprintln!("uhura trace: {e}"); + return ExitCode::from(2); + } + }; + + match run_script(&lowered.program, &fixture_text, &script_text, expanded) { + Ok(lines) => { + for line in lines { + println!("{line}"); + } + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("uhura trace: {script_name}: {e}"); + ExitCode::from(1) + } + } +} + +/// The whole headless run as a pure-ish function of its inputs (the gate +/// tests golden-pin its output): boot deliveries → `Init` on the entry +/// route → tick pump until the driver idles and every stimulus fired. +pub fn run_script( + program: &ProgramIr, + fixture_text: &str, + script_text: &str, + expanded: bool, +) -> Result<Vec<String>, String> { + let fixture = + load_fixture(fixture_text).map_err(|issues| format!("fixture: {}", issues[0].message))?; + let script_json = uhura_fixture::toml_to_json(script_text)?; + let stimuli = parse_stimuli(&script_json)?; + let fixture_json = fixture_slices_json(&fixture); + let mut driver = FixtureDriver::new(&fixture_json, &to_canonical_json(&script_json))?; + + let mut x = Projections::default(); + let mut lines = Vec::new(); + + // ── boot projections (§9.2): delivered before Init, revision 1 ───── + let boot_applies = apply_updates(program, &mut x, &boot_updates(program, &fixture)?)?; + + // ── Init on the entry route ───────────────────────────────────────── + let mut result = step_u( + program, + UiState::boot(), + &x, + Event::Init { + route: program.entry.clone(), + params: BTreeMap::new(), + }, + ) + .map_err(|e| e.to_string())?; + result.t.applies = boot_applies.iter().map(ApplyNote::to_json).collect(); + let mut u = result.u; + let mut v = result.v; + finish_step(&mut lines, result.t, &v, expanded); + deliver_commands(&mut driver, &result.c)?; + + // ── the pump ──────────────────────────────────────────────────────── + let mut tick = 0u64; + let mut next_stimulus = 0usize; + loop { + let stimuli_left = next_stimulus < stimuli.len(); + if driver.idle() && !stimuli_left { + break; + } + tick += 1; + if tick > 10_000 { + return Err("the script did not quiesce within 10000 ticks".into()); + } + + // Driver arrivals first, then this tick's stimuli. + for msg_json in driver.tick() { + let msg_value: serde_json::Value = + serde_json::from_str(&msg_json).map_err(|e| format!("driver message: {e}"))?; + let msg = ProviderMsg::from_json(&msg_value)?; + let (event, applies) = match msg { + ProviderMsg::Projection(update) => { + let applies = apply_updates(program, &mut x, std::slice::from_ref(&update))?; + ( + Event::Projection { + updates: vec![update], + }, + applies, + ) + } + ProviderMsg::ProjectionFailed { + port, + projection, + key, + reason, + } => { + let note = + apply_failure(program, &mut x, &port, &projection, key.as_ref(), &reason)?; + ( + Event::ProjectionFailed { + port, + projection, + key, + reason, + }, + vec![note], + ) + } + ProviderMsg::Outcome(envelope) => { + // Piggybacked settlement updates apply BEFORE the + // outcome dispatches (§9.4) — flicker-free by + // construction. + let applies = apply_updates(program, &mut x, &envelope.updates)?; + ( + Event::Outcome { + correlation: envelope.correlation, + result: envelope.outcome, + updates: envelope.updates, + }, + applies, + ) + } + ProviderMsg::Command(c) => { + return Err(format!("the driver emitted a command `{}`", c.command)); + } + }; + let mut result = step_u(program, u, &x, event).map_err(|e| e.to_string())?; + result.t.applies = applies.iter().map(ApplyNote::to_json).collect(); + u = result.u; + v = result.v; + finish_step(&mut lines, result.t, &v, expanded); + deliver_commands(&mut driver, &result.c)?; + } + + while next_stimulus < stimuli.len() && stimuli[next_stimulus].at_tick == tick { + let stim = &stimuli[next_stimulus]; + next_stimulus += 1; + let descriptor = find_stimulus_descriptor(&v, stim)?; + let data = decode_carried_data(&descriptor, &stim.data)?; + let event = Event::Ui { + descriptor, + data, + view_rev: v.revision, + }; + let result = step_u(program, u, &x, event).map_err(|e| e.to_string())?; + u = result.u; + v = result.v; + finish_step(&mut lines, result.t, &v, expanded); + deliver_commands(&mut driver, &result.c)?; + } + + if let Some(stim) = stimuli.get(next_stimulus) + && stim.at_tick < tick + { + return Err(format!( + "`[[ui]]` stimuli must be ordered by at-tick (tick {} after {tick})", + stim.at_tick + )); + } + } + + Ok(lines) +} + +fn finish_step( + lines: &mut Vec<String>, + mut t: uhura_core::trace::StepTrace, + v: &Snapshot, + expanded: bool, +) { + if expanded { + t.v = Some(v.to_json()); + } + lines.push(t.to_line()); +} + +fn deliver_commands( + driver: &mut FixtureDriver, + commands: &[uhura_port::envelope::CommandEnvelope], +) -> Result<(), String> { + for c in commands { + let json = to_canonical_json(&ProviderMsg::Command(c.clone()).to_json()); + driver.deliver(&json)?; + } + Ok(()) +} + +/// The boot deliveries (§9.2): every `boot = true` projection from its +/// `boot.<name>` fixture slice, revision 1 (driver mints start at 2 — +/// micro-decision #43). `uhura dev`'s `/boot.json` and the wasm ABI +/// contract test build the same envelope. +pub fn boot_updates( + program: &ProgramIr, + fixture: &FixtureData, +) -> Result<Vec<ProjectionUpdate>, String> { + let mut updates = Vec::new(); + for (name, decl) in &program.projections { + if !decl.boot { + continue; + } + let Some(value) = fixture.get("boot", name.as_str()) else { + return Err(format!( + "boot projection `{name}` needs a `boot.{name}` fixture slice (§6.1)" + )); + }; + updates.push(ProjectionUpdate { + port: decl.port.clone(), + projection: name.clone(), + key: None, + revision: 1, + value: value.clone(), + }); + } + Ok(updates) +} + +/// The resolved fixture slices as one canonical JSON object — the +/// `FixtureDriver::new` input (`uhura dev` serves it as `/fixture.json`). +pub fn fixture_slices_json(fixture: &FixtureData) -> String { + let mut root = serde_json::Map::new(); + for (ns, slices) in &fixture.slices { + let mut ns_map = serde_json::Map::new(); + for (name, value) in slices { + ns_map.insert(name.clone(), value.clone()); + } + root.insert(ns.clone(), serde_json::Value::Object(ns_map)); + } + to_canonical_json(&serde_json::Value::Object(root)) +} + +// ── the harness-only [[ui]] stimulus section ──────────────────────────────── + +struct Stimulus { + at_tick: u64, + emit: Ident, + /// Payload subset the target descriptor must carry. + where_: serde_json::Map<String, serde_json::Value>, + /// Renderer-carried fields (`{ value = "…" }`). + data: serde_json::Map<String, serde_json::Value>, +} + +fn parse_stimuli(script: &serde_json::Value) -> Result<Vec<Stimulus>, String> { + let Some(entries) = script.get("ui") else { + return Ok(Vec::new()); + }; + let Some(entries) = entries.as_array() else { + return Err("`ui` must be an array of stimulus tables".into()); + }; + let mut out = Vec::new(); + for entry in entries { + let Some(obj) = entry.as_object() else { + return Err("a `[[ui]]` entry is a table".into()); + }; + for key in obj.keys() { + if !["at-tick", "emit", "where", "data"].contains(&key.as_str()) { + return Err(format!("`[[ui]]` has no `{key}` field")); + } + } + let at_tick = obj + .get("at-tick") + .and_then(serde_json::Value::as_u64) + .filter(|t| *t >= 1) + .ok_or("`[[ui]]` needs `at-tick` ≥ 1")?; + let emit = obj + .get("emit") + .and_then(serde_json::Value::as_str) + .ok_or("`[[ui]]` needs `emit`") + .and_then(|s| Ident::new(s).map_err(|_| "`emit` must be a kebab name"))?; + let table = |field: &str| -> Result<serde_json::Map<String, serde_json::Value>, String> { + match obj.get(field) { + None => Ok(serde_json::Map::new()), + Some(serde_json::Value::Object(map)) => Ok(map.clone()), + Some(_) => Err(format!("`{field}` must be a table")), + } + }; + out.push(Stimulus { + at_tick, + emit, + where_: table("where")?, + data: table("data")?, + }); + } + Ok(out) +} + +/// Finds the descriptor a stimulus presses in the CURRENT view: emit name +/// matches and `where` is a subset of the payload. Searching page, then +/// surfaces bottom→top, then surface dismiss controls; distinct matches +/// are ambiguous (identical ones — e.g. a field's submit and its button — +/// collapse). +fn find_stimulus_descriptor(v: &Snapshot, stim: &Stimulus) -> Result<Descriptor, String> { + let mut matches: Vec<Descriptor> = Vec::new(); + collect_matches(&v.page.root, stim, &mut matches); + for surface in &v.surfaces { + collect_matches(&surface.root, stim, &mut matches); + if surface.dismiss.emit == stim.emit && subset(&stim.where_, &surface.dismiss.payload) { + matches.push(surface.dismiss.clone()); + } + } + let mut distinct: Vec<&Descriptor> = Vec::new(); + for d in &matches { + if !distinct + .iter() + .any(|seen| seen.emit == d.emit && seen.scope == d.scope && seen.payload == d.payload) + { + distinct.push(d); + } + } + match distinct.len() { + 0 => Err(format!( + "no descriptor in the current view emits `{}` with {} — the control is \ + gone or never existed", + stim.emit, + serde_json::Value::Object(stim.where_.clone()) + )), + 1 => Ok(distinct[0].clone()), + n => Err(format!( + "{n} distinct descriptors emit `{}` with that `where` — narrow it", + stim.emit + )), + } +} + +fn collect_matches(node: &Node, stim: &Stimulus, out: &mut Vec<Descriptor>) { + for d in &node.on { + if d.emit == stim.emit && subset(&stim.where_, &d.payload) { + out.push(d.clone()); + } + } + for child in &node.children { + collect_matches(child, stim, out); + } +} + +fn subset( + where_: &serde_json::Map<String, serde_json::Value>, + payload: &serde_json::Value, +) -> bool { + where_ + .iter() + .all(|(k, v)| payload.get(k).is_some_and(|p| p == v)) +} diff --git a/uhura/crates/uhura-cli/src/fsio.rs b/uhura/crates/uhura-cli/src/fsio.rs new file mode 100644 index 0000000..c812ca4 --- /dev/null +++ b/uhura/crates/uhura-cli/src/fsio.rs @@ -0,0 +1,65 @@ +//! Filesystem walking — the only place in the workspace that reads source +//! trees (design §12.1). Produces the in-memory `SourceSet` every pure crate +//! consumes. + +use std::path::{Path, PathBuf}; + +use uhura_syntax::SourceKind; + +pub struct SourceFile { + /// Corpus-relative path with `/` separators (deterministic across OSes). + pub rel_path: String, + pub abs_path: PathBuf, + pub text: String, + pub kind: SourceKind, +} + +/// Walks a corpus root for `.uhura` sources, sorted by relative path. +pub fn walk_corpus(root: &Path) -> std::io::Result<Vec<SourceFile>> { + let mut out = Vec::new(); + for dir in ["app", "components", "surfaces"] { + let base = root.join(dir); + if base.is_dir() { + walk_dir(root, &base, &mut out)?; + } + } + out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(out) +} + +fn walk_dir(root: &Path, dir: &Path, out: &mut Vec<SourceFile>) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + walk_dir(root, &path, out)?; + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.ends_with(".uhura") { + continue; + } + let kind = if name.ends_with(".examples.uhura") { + SourceKind::Examples + } else { + SourceKind::Module + }; + let rel_path = path + .strip_prefix(root) + .unwrap_or(&path) + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::<Vec<_>>() + .join("/"); + let text = std::fs::read_to_string(&path)?; + out.push(SourceFile { + rel_path, + abs_path: path, + text, + kind, + }); + } + Ok(()) +} diff --git a/uhura/crates/uhura-cli/src/lib.rs b/uhura/crates/uhura-cli/src/lib.rs new file mode 100644 index 0000000..fcd3534 --- /dev/null +++ b/uhura/crates/uhura-cli/src/lib.rs @@ -0,0 +1,16 @@ +//! The `uhura` CLI as a library: command implementations live here so the +//! gate tests (`uhura-tests`) can drive the EXACT code paths the binary +//! runs — the trace harness in particular is golden-pinned (§12.5). Only +//! this crate touches the filesystem (design §12.1). + +use std::path::PathBuf; + +pub mod cmd; +pub mod fsio; + +pub struct CommonArgs { + pub root: PathBuf, + pub format_json: bool, + pub deny_warnings: bool, + pub emit_ir: bool, +} diff --git a/uhura/crates/uhura-cli/src/main.rs b/uhura/crates/uhura-cli/src/main.rs new file mode 100644 index 0000000..009c99c --- /dev/null +++ b/uhura/crates/uhura-cli/src/main.rs @@ -0,0 +1,98 @@ +//! The `uhura` CLI: check | fmt | project | dev | trace. Thin argument +//! parsing over the library crate (`uhura_cli::cmd`) — the same code the +//! gate tests drive. + +use std::path::PathBuf; +use std::process::ExitCode; + +use uhura_cli::{CommonArgs, cmd}; + +fn main() -> ExitCode { + let mut args = std::env::args().skip(1); + let Some(cmd) = args.next() else { + eprintln!("usage: uhura <check|fmt|project|dev|trace> [path] [flags]"); + return ExitCode::from(2); + }; + + let mut root = PathBuf::from("."); + let mut format_json = false; + let mut deny_warnings = false; + let mut check_only = false; + let mut emit_ir = false; + let mut out_dir: Option<String> = None; + let mut script: Option<String> = None; + let mut expanded = false; + let mut port: u16 = 8787; + + let mut args = args.peekable(); + while let Some(a) = args.next() { + match a.as_str() { + // Space-separated `--format <value>` consumes its value here — + // it must never fall through to the positional path. + "--format" => match args.next().as_deref() { + Some("json") => format_json = true, + Some("text") => format_json = false, + other => { + eprintln!( + "--format takes `json` or `text`, got {}", + other.unwrap_or("nothing") + ); + return ExitCode::from(2); + } + }, + "--format=json" | "--json" => format_json = true, + "--format=text" => format_json = false, + "--deny-warnings" => deny_warnings = true, + "--check" => check_only = true, + "--emit-ir" => emit_ir = true, + other if other.starts_with("--out=") => { + out_dir = Some(other["--out=".len()..].to_string()); + } + other if other.starts_with("--script=") => { + script = Some(other["--script=".len()..].to_string()); + } + "--expanded" => expanded = true, + // Space-separated `--port <n>` consumes its value, like --format. + "--port" => match args.next().as_deref().map(str::parse) { + Some(Ok(p)) => port = p, + _ => { + eprintln!("--port takes a port number"); + return ExitCode::from(2); + } + }, + other if other.starts_with("--port=") => match other["--port=".len()..].parse() { + Ok(p) => port = p, + Err(_) => { + eprintln!("--port takes a port number"); + return ExitCode::from(2); + } + }, + other if !other.starts_with('-') => { + root = PathBuf::from(other); + } + other => { + eprintln!("unknown flag: {other}"); + return ExitCode::from(2); + } + } + } + + let common = CommonArgs { + root, + format_json, + deny_warnings, + emit_ir, + }; + match cmd.as_str() { + "fmt" => cmd::fmt::run(&common, check_only), + "check" => cmd::check::run(&common), + "project" => cmd::project::run(&common, out_dir.as_deref()), + "trace" => cmd::trace::run(&common, script.as_deref(), expanded), + "dev" => cmd::dev::run(&common, port), + other => { + eprintln!("unknown command: {other}"); + eprintln!("usage: uhura <check|fmt|project|dev|trace> [path] [flags]"); + ExitCode::from(2) + } + } +} diff --git a/uhura/crates/uhura-core/Cargo.toml b/uhura/crates/uhura-core/Cargo.toml new file mode 100644 index 0000000..4a35a8b --- /dev/null +++ b/uhura/crates/uhura-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "uhura-core" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } +uhura-port = { workspace = true } # envelopes only — toml feature OFF +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/uhura/crates/uhura-core/clippy.toml b/uhura/crates/uhura-core/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-core/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-core/src/decode.rs b/uhura/crates/uhura-core/src/decode.rs new file mode 100644 index 0000000..ffe7de9 --- /dev/null +++ b/uhura/crates/uhura-core/src/decode.rs @@ -0,0 +1,176 @@ +//! Wire JSON → typed `Value`, directed by the IR's baked-in type slices +//! (`TyIr`). This is the runtime half of L8: projection updates and `Ui` +//! event payloads arrive as inert JSON and must land as the same typed +//! values the checker's fixture decoder produces — ids as `Value::Id`, +//! optionals bare, unions single-variant, tags `"t-<n>"`. +//! +//! Kept semantically in lock-step with `uhura-check`'s pin decoder: a +//! projection value pinned by an example and the identical value delivered +//! through a script MUST decode to equal `Value`s (derived-replay goldens +//! enforce this end to end). + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Value}; + +use crate::ir::{MapKeyIr, TyIr}; + +pub fn decode_value(json: &serde_json::Value, ty: &TyIr) -> Result<Value, String> { + use serde_json::Value as J; + match ty { + TyIr::Bool => match json { + J::Bool(b) => Ok(Value::Bool(*b)), + other => Err(mismatch("bool", other)), + }, + TyIr::Int => match json { + J::Number(n) => n + .as_i64() + .map(Value::Int) + .ok_or_else(|| format!("`{n}` is not an i64")), + other => Err(mismatch("int", other)), + }, + TyIr::Text => match json { + J::String(s) => Ok(Value::Text(s.clone())), + other => Err(mismatch("text", other)), + }, + TyIr::Id | TyIr::Asset => match json { + J::String(s) => Ok(Value::Id(s.clone())), + other => Err(mismatch("an id", other)), + }, + TyIr::Tag => match json { + J::String(s) => parse_tag(s) + .map(Value::Tag) + .ok_or_else(|| format!("`{s}` is not a `t-<n>` tag")), + other => Err(mismatch("a tag", other)), + }, + TyIr::Enum(values) => match json { + J::String(s) if values.iter().any(|v| v.as_str() == s) => Ok(Value::Text(s.clone())), + J::String(s) => Err(format!("`{s}` is not one of the enum's values")), + other => Err(mismatch("an enum value", other)), + }, + TyIr::Option(inner) => match json { + J::Null => Ok(Value::None), + // Optionals hold their value BARE (§7.1 — options do not nest). + present => decode_value(present, inner), + }, + TyIr::List(inner) => match json { + J::Array(items) => items + .iter() + .map(|item| decode_value(item, inner)) + .collect::<Result<Vec<_>, _>>() + .map(Value::List), + other => Err(mismatch("a list", other)), + }, + TyIr::Map { key, value } => match json { + J::Object(map) => { + let mut record = BTreeMap::new(); + for (k, v) in map { + if *key == MapKeyIr::Tag && parse_tag(k).is_none() { + return Err(format!("`{k}` is not a `t-<n>` tag key")); + } + let k = Ident::new(k).map_err(|e| e.to_string())?; + record.insert(k, decode_value(v, value)?); + } + Ok(Value::Record(record)) + } + other => Err(mismatch("a map object", other)), + }, + TyIr::Record(fields) => { + let J::Object(map) = json else { + return Err(mismatch("a record object", json)); + }; + for k in map.keys() { + if Ident::new(k) + .map(|k| !fields.contains_key(&k)) + .unwrap_or(true) + { + return Err(format!("`{k}` is not a field of the record")); + } + } + let mut record = BTreeMap::new(); + for (field, field_ty) in fields { + match map.get(field.as_str()) { + Some(v) => { + let decoded = + decode_value(v, field_ty).map_err(|e| format!("in `{field}`: {e}"))?; + record.insert(field.clone(), decoded); + } + None if matches!(field_ty, TyIr::Option(_)) => { + record.insert(field.clone(), Value::None); + } + None => return Err(format!("missing required field `{field}`")), + } + } + Ok(Value::Record(record)) + } + TyIr::Union(variants) => { + let J::Object(map) = json else { + return Err(mismatch("a single-variant union object", json)); + }; + if map.len() != 1 { + return Err(format!( + "a union value has exactly one variant, got {}", + map.len() + )); + } + let (variant, body) = map.iter().next().expect("len checked"); + let variant = Ident::new(variant).map_err(|e| e.to_string())?; + let Some(fields) = variants.get(&variant) else { + return Err(format!("`{variant}` is not a variant of the union")); + }; + let payload = decode_value(body, &TyIr::Record(fields.clone())) + .map_err(|e| format!("in `{variant}`: {e}"))?; + let mut record = BTreeMap::new(); + record.insert(variant, payload); + Ok(Value::Record(record)) + } + } +} + +pub fn parse_tag(s: &str) -> Option<u64> { + s.strip_prefix("t-").and_then(|n| n.parse().ok()) +} + +fn mismatch(expected: &str, got: &serde_json::Value) -> String { + let shape = match got { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a bool", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "a list", + serde_json::Value::Object(_) => "an object", + }; + format!("expected {expected}, got {shape}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_decode_nominally_and_optionals_stay_bare() { + let ty = TyIr::Record(BTreeMap::from([ + (Ident::new("post").unwrap(), TyIr::Id), + ( + Ident::new("cursor").unwrap(), + TyIr::Option(Box::new(TyIr::Text)), + ), + ])); + let v = decode_value(&serde_json::json!({ "post": "post-1" }), &ty).unwrap(); + let Value::Record(fields) = v else { panic!() }; + assert_eq!( + fields[&Ident::new("post").unwrap()], + Value::Id("post-1".into()) + ); + assert_eq!(fields[&Ident::new("cursor").unwrap()], Value::None); + } + + #[test] + fn tags_parse_the_minted_form() { + assert_eq!( + decode_value(&serde_json::json!("t-4"), &TyIr::Tag).unwrap(), + Value::Tag(4) + ); + assert!(decode_value(&serde_json::json!("c-4"), &TyIr::Tag).is_err()); + } +} diff --git a/uhura/crates/uhura-core/src/eval.rs b/uhura/crates/uhura-core/src/eval.rs new file mode 100644 index 0000000..02ab811 --- /dev/null +++ b/uhura/crates/uhura-core/src/eval.rs @@ -0,0 +1,722 @@ +//! `eval_view` — V as a pure function of (program, U, X) (design §7.1, +//! §8.1). Component expansion is transparent (call-site keys, resolved +//! emit chains, prebuilt descriptor payloads); availability arms come from +//! projection presence; descriptor presence is the subscription. + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Value}; + +use crate::ir::{self, ProgramIr}; +use crate::state::{Projections, UiState, map_key_string}; +use crate::view::{Descriptor, DescriptorKind, Node, PageView, Snapshot, SurfaceView, VValue}; + +/// An internal invariant break — checked programs cannot produce these; if +/// one appears it is a UH9xxx bug, not an author error. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EvalError(pub String); + +impl std::fmt::Display for EvalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "view evaluation broke an invariant: {}", self.0) + } +} + +/// The full snapshot (§8.1): the top nav entry's page plus the surface +/// stack, bottom → top. +pub fn eval_view(p: &ProgramIr, u: &UiState, x: &Projections) -> Result<Snapshot, EvalError> { + let entry = u + .nav + .last() + .ok_or_else(|| EvalError("empty nav stack".into()))?; + let def = p + .pages + .get(&entry.route) + .ok_or_else(|| EvalError(format!("no page for route `{}`", entry.route)))?; + let page_frame = Frame { + program: p, + x, + scope: format!("page:{}", entry.serial), + state: &entry.state, + props: BTreeMap::new(), + params: entry.params.clone(), + bindings: Vec::new(), + emits: EmitEnv::Machine, + }; + let root = single_root(page_frame.eval_node_ir(&def.root, None)?)?; + + let mut surfaces = Vec::new(); + for s in &u.surfaces { + let def = p + .surfaces + .get(&s.definition) + .ok_or_else(|| EvalError(format!("no surface `{}`", s.definition)))?; + let scope = format!("surface:{}", s.serial); + let frame = Frame { + program: p, + x, + scope: scope.clone(), + state: &s.state, + props: s.props.clone(), + params: BTreeMap::new(), + bindings: Vec::new(), + emits: EmitEnv::Machine, + }; + let root = single_root(frame.eval_node_ir(&def.root, None)?)?; + surfaces.push(SurfaceView { + key: format!("{}:{}", s.definition, s.serial), + definition: s.definition.to_string(), + modality: def.modality.clone().unwrap_or_else(|| "sheet".into()), + restore_focus: s.restore_focus.clone(), + dismiss: dismiss_descriptor(&scope), + root, + }); + } + + Ok(Snapshot { + revision: u.rev, + page: PageView { + route: entry.route.to_string(), + root, + }, + surfaces, + }) +} + +/// A standalone definition preview (§6.2 `PreviewPayload::Fragment`) — +/// component and surface examples render outside any nav stack. +pub fn eval_fragment( + p: &ProgramIr, + def: &ir::DefIr, + props: &BTreeMap<Ident, Value>, + state: &BTreeMap<Ident, Value>, + x: &Projections, +) -> Result<Node, EvalError> { + let frame = Frame { + program: p, + x, + scope: "fragment:0".to_string(), + state, + props: props.clone(), + params: BTreeMap::new(), + bindings: Vec::new(), + emits: EmitEnv::Machine, + }; + single_root(frame.eval_node_ir(&def.root, None)?) +} + +/// The first-class surface dismiss descriptor (§8.1): Escape/scrim emit the +/// reserved machine event `dismiss`, which core handles structurally (M4) — +/// no authored handler is involved. +fn dismiss_descriptor(scope: &str) -> Descriptor { + Descriptor { + kind: DescriptorKind::Input, + event: Ident::new("dismiss").expect("kebab"), + emit: Ident::new("dismiss").expect("kebab"), + scope: scope.to_string(), + payload: serde_json::json!({}), + carries: BTreeMap::new(), + } +} + +fn single_root(nodes: Vec<Node>) -> Result<Node, EvalError> { + let mut nodes = nodes; + match nodes.len() { + 1 => Ok(nodes.remove(0)), + n => Err(EvalError(format!( + "a definition renders exactly one root, got {n}" + ))), + } +} + +/// Where an emit resolves once it reaches machine scope (built at component +/// expansion; §4.4's one explicit model). +#[derive(Clone, Debug)] +pub(crate) enum EmitSink { + /// Emit under this (possibly renamed) name; use-site payload passes + /// through (bare forward chains collapse to one rename). + Rename(Ident), + /// Call-site rebind: fixed name, payload prebuilt in the caller's + /// scope; the component's payload is discarded (§4.4). + Fixed(Ident, serde_json::Value), + /// Unbound at some call site — the control is dead (warned at check). + Dead, +} + +#[derive(Clone, Debug)] +pub(crate) enum EmitEnv { + /// Page/surface markup: emits hit the machine as-is. + Machine, + /// Component markup: the component's declared emits, resolved through + /// every call site above. + Component(BTreeMap<Ident, EmitSink>), +} + +impl EmitEnv { + fn resolve(&self, emit: &Ident) -> EmitSink { + match self { + EmitEnv::Machine => EmitSink::Rename(emit.clone()), + EmitEnv::Component(table) => table.get(emit).cloned().unwrap_or(EmitSink::Dead), + } + } +} + +/// Why an expression could not produce a value. +#[derive(Clone, Debug)] +pub(crate) enum Stop { + /// A projection read hit an undelivered instance. Legal in guards and + /// bodies (transactional backstop, §4.2); an invariant break in view + /// position (the checker forces availability matches). + NotReady(Ident), + Internal(String), +} + +pub(crate) struct Frame<'a> { + pub(crate) program: &'a ProgramIr, + pub(crate) x: &'a Projections, + pub(crate) scope: String, + pub(crate) state: &'a BTreeMap<Ident, Value>, + pub(crate) props: BTreeMap<Ident, Value>, + pub(crate) params: BTreeMap<Ident, Value>, + pub(crate) bindings: Vec<(Ident, Value)>, + pub(crate) emits: EmitEnv, +} + +impl Frame<'_> { + // ── expressions ──────────────────────────────────────────────────── + // + // Optionals hold their value BARE (`Value::None` or the value itself; + // `Value::Some` is never constructed — options do not nest, §7.1), so + // equality, `??`, and JSON encoding stay uniform. + + pub(crate) fn eval_expr(&self, e: &ir::ExprIr) -> Result<Value, Stop> { + use ir::ExprIr as E; + match e { + E::Int(i) => Ok(Value::Int(*i)), + E::Text(s) => Ok(Value::Text(s.clone())), + E::Bool(b) => Ok(Value::Bool(*b)), + E::None => Ok(Value::None), + E::StateRef(name) => self + .state + .get(name) + .cloned() + .ok_or_else(|| Stop::Internal(format!("no state field `{name}`"))), + E::PropRef(name) => self + .props + .get(name) + .cloned() + .ok_or_else(|| Stop::Internal(format!("no prop `{name}`"))), + E::ParamRef(name) => self + .params + .get(name) + .cloned() + .ok_or_else(|| Stop::Internal(format!("no param `{name}`"))), + E::BindingRef(name) => self + .bindings + .iter() + .rev() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + .ok_or_else(|| Stop::Internal(format!("no binding `{name}`"))), + E::ProjectionRef(name) => self.read_projection(name, None), + E::ProjectionKeyed { projection, key } => { + let key = self.eval_expr(key)?; + self.read_projection(projection, Some(key)) + } + E::Field { base, name } => match self.eval_expr(base)? { + Value::Record(fields) => fields + .get(name) + .cloned() + .ok_or_else(|| Stop::Internal(format!("no field `{name}`"))), + other => Stop::internal(format!("`.{name}` on non-record {other:?}")), + }, + E::Index { base, key } => { + let base = self.eval_expr(base)?; + let key = self.eval_expr(key)?; + match base { + Value::Record(map) => { + let key = map_key_string(&key) + .ok_or_else(|| Stop::Internal("non-identity map key".into()))?; + let key = Ident::new(&key) + .map_err(|e| Stop::Internal(format!("map key: {e}")))?; + Ok(map.get(&key).cloned().unwrap_or(Value::None)) + } + Value::List(items) => match key { + Value::Int(i) => Ok(usize::try_from(i) + .ok() + .and_then(|i| items.get(i).cloned()) + .unwrap_or(Value::None)), + _ => Stop::internal("list index must be int".into()), + }, + other => Stop::internal(format!("indexing non-container {other:?}")), + } + } + E::Unary { op, expr } => { + let v = self.eval_expr(expr)?; + match (op, v) { + (ir::UnaryOpIr::Not, Value::Bool(b)) => Ok(Value::Bool(!b)), + (ir::UnaryOpIr::Neg, Value::Int(i)) => Ok(Value::Int(i.saturating_neg())), + (op, v) => Stop::internal(format!("unary {op:?} on {v:?}")), + } + } + E::Binary { op, lhs, rhs } => self.eval_binary(*op, lhs, rhs), + E::If { cond, then, els } => match self.eval_expr(cond)? { + Value::Bool(true) => self.eval_expr(then), + Value::Bool(false) => self.eval_expr(els), + other => Stop::internal(format!("if condition was {other:?}")), + }, + E::ToText(inner) => { + let v = self.eval_expr(inner)?; + match v { + Value::Int(i) => Ok(Value::Text(i.to_string())), + Value::Text(s) | Value::Id(s) => Ok(Value::Text(s)), + Value::Bool(b) => Ok(Value::Text(b.to_string())), + other => Stop::internal(format!("to-text on {other:?}")), + } + } + E::Count(inner) => match self.eval_expr(inner)? { + Value::List(items) => Ok(Value::Int(items.len() as i64)), + Value::Record(map) => Ok(Value::Int(map.len() as i64)), + other => Stop::internal(format!("count on {other:?}")), + }, + E::RecordLit(entries) => { + let mut record = BTreeMap::new(); + for arg in entries { + record.insert(arg.name.clone(), self.eval_expr(&arg.value)?); + } + Ok(Value::Record(record)) + } + } + } + + fn eval_binary( + &self, + op: ir::BinaryOpIr, + lhs: &ir::ExprIr, + rhs: &ir::ExprIr, + ) -> Result<Value, Stop> { + use ir::BinaryOpIr as B; + // Short-circuit forms first — they gate projection reads in guards + // (§4.2's transactional rule depends on this). + match op { + B::And => { + return match self.eval_expr(lhs)? { + Value::Bool(false) => Ok(Value::Bool(false)), + Value::Bool(true) => self.eval_expr(rhs), + other => Stop::internal(format!("&& on {other:?}")), + }; + } + B::Or => { + return match self.eval_expr(lhs)? { + Value::Bool(true) => Ok(Value::Bool(true)), + Value::Bool(false) => self.eval_expr(rhs), + other => Stop::internal(format!("|| on {other:?}")), + }; + } + B::Coalesce => { + return match self.eval_expr(lhs)? { + Value::None => self.eval_expr(rhs), + present => Ok(present), + }; + } + _ => {} + } + let l = self.eval_expr(lhs)?; + let r = self.eval_expr(rhs)?; + match (op, l, r) { + (B::Add, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_add(b))), + (B::Sub, Value::Int(a), Value::Int(b)) => Ok(Value::Int(a.saturating_sub(b))), + (B::Concat, Value::Text(a), Value::Text(b)) => Ok(Value::Text(a + &b)), + (B::Eq, a, b) => Ok(Value::Bool(a == b)), + (B::NotEq, a, b) => Ok(Value::Bool(a != b)), + (B::Lt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a < b)), + (B::Le, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a <= b)), + (B::Gt, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a > b)), + (B::Ge, Value::Int(a), Value::Int(b)) => Ok(Value::Bool(a >= b)), + (op, l, r) => Stop::internal(format!("{op:?} on {l:?} / {r:?}")), + } + } + + fn read_projection(&self, name: &Ident, key: Option<Value>) -> Result<Value, Stop> { + match self.x.snapshots.get(&(name.clone(), key)) { + Some(snapshot) => Ok(snapshot.value.clone()), + None => Err(Stop::NotReady(name.clone())), + } + } + + // ── markup ───────────────────────────────────────────────────────── + + /// Evaluates one template op into zero or more V nodes. `key_override` + /// is the call-site/each-item key (§8.1 transparent expansion). + fn eval_node_ir( + &self, + node: &ir::NodeIr, + key_override: Option<&str>, + ) -> Result<Vec<Node>, EvalError> { + match node { + ir::NodeIr::If { cond, then, els } => { + let cond = self.value(cond)?; + let branch = match cond { + Value::Bool(true) => then, + Value::Bool(false) => els, + other => { + return Err(EvalError(format!("if condition was {other:?}"))); + } + }; + self.eval_nodes(branch) + } + ir::NodeIr::Each(each) => self.eval_each(each), + ir::NodeIr::Match(m) => self.eval_match(m, key_override), + ir::NodeIr::Element(el) => Ok(vec![self.eval_element(el, key_override)?]), + ir::NodeIr::Component(call) => Ok(vec![self.eval_component(call, key_override)?]), + } + } + + fn eval_nodes(&self, nodes: &[ir::NodeIr]) -> Result<Vec<Node>, EvalError> { + let mut out = Vec::new(); + for node in nodes { + out.extend(self.eval_node_ir(node, None)?); + } + Ok(out) + } + + fn eval_each(&self, each: &ir::EachIr) -> Result<Vec<Node>, EvalError> { + let seq = self.value(&each.seq)?; + let items: Vec<Value> = match (&each.over, seq) { + (ir::OverIr::List, Value::List(items)) => items, + (ir::OverIr::MapIdKeys, Value::Record(map)) => map + .keys() + .map(|k| Value::Id(k.as_str().to_string())) + .collect(), + (ir::OverIr::MapTagKeys, Value::Record(map)) => map + .keys() + .map(|k| { + k.as_str() + .strip_prefix("t-") + .and_then(|n| n.parse::<u64>().ok()) + .map(Value::Tag) + .ok_or_else(|| EvalError(format!("non-tag map key `{k}`"))) + }) + .collect::<Result<_, _>>()?, + (over, other) => { + return Err(EvalError(format!("each over {over:?} got {other:?}"))); + } + }; + + let mut out = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for item in items { + let mut frame = self.child_frame(); + frame.bindings.push((each.item.clone(), item)); + let key_value = frame.value(&each.key)?; + let key_str = key_string(&key_value) + .ok_or_else(|| EvalError(format!("non-identity each key {key_value:?}")))?; + // Keys are sibling identity (§8.1) — a data collision is §4.8's + // "duplicate keys" rejection, raised here because only + // evaluation sees the data; check catches it through example + // replay (§6.2), the harnesses at dispatch time. + if !seen.insert(key_str.clone()) { + return Err(EvalError(format!( + "duplicate key `{key_str}` in {{#each}} — keys are sibling \ + identity and must be unique (§8.1)" + ))); + } + let item_key = format!("{}.{}", each.ord, key_str); + let rendered = { + let mut nodes = Vec::new(); + for (j, node) in each.body.iter().enumerate() { + let node_key = if each.body.len() == 1 { + item_key.clone() + } else { + format!("{item_key}.{j}") + }; + nodes.extend(frame.eval_node_ir(node, Some(&node_key))?); + } + nodes + }; + out.extend(rendered); + } + Ok(out) + } + + fn eval_match( + &self, + m: &ir::MatchIr, + key_override: Option<&str>, + ) -> Result<Vec<Node>, EvalError> { + let (variant, binding_value): (String, Option<Value>) = match &m.source { + ir::MatchSourceIr::Availability { projection, key } => { + let key = match key { + Some(k) => Some(self.value(k)?), + None => None, + }; + let instance = (projection.clone(), key); + if let Some(snapshot) = self.x.snapshots.get(&instance) { + ("ready".into(), Some(snapshot.value.clone())) + } else if let Some(reason) = self.x.failed.get(&instance) { + ("failed".into(), Some(Value::Text(reason.clone()))) + } else { + ("loading".into(), None) + } + } + ir::MatchSourceIr::Union { value } => { + let v = self.value(value)?; + let Value::Record(entries) = &v else { + return Err(EvalError(format!("match on non-union {v:?}"))); + }; + let Some((variant, payload)) = entries.iter().next() else { + return Err(EvalError("empty union value".into())); + }; + if entries.len() != 1 { + return Err(EvalError("union value with multiple variants".into())); + } + (variant.as_str().to_string(), Some(payload.clone())) + } + }; + + let arm = m + .arms + .iter() + .find(|arm| arm.variant.as_ref().is_some_and(|v| v.as_str() == variant)) + .or_else(|| m.arms.iter().find(|arm| arm.variant.is_none())); + let Some(arm) = arm else { + return Err(EvalError(format!("no arm for `{variant}`"))); + }; + + let mut frame = self.child_frame(); + if let (Some(binding), Some(value)) = (&arm.binding, binding_value) { + frame.bindings.push((binding.clone(), value)); + } + // A match root keeps the call-site key across arms (§4.4). + let mut out = Vec::new(); + for node in &arm.body { + out.extend(frame.eval_node_ir(node, key_override)?); + } + Ok(out) + } + + fn eval_element( + &self, + el: &ir::ElementIr, + key_override: Option<&str>, + ) -> Result<Node, EvalError> { + let key = key_override + .map(ToString::to_string) + .unwrap_or_else(|| el.ord.to_string()); + + let class = match &el.class { + None => None, + Some(expr) => match self.value(expr)? { + Value::Text(s) => Some(s), + other => return Err(EvalError(format!("class was {other:?}"))), + }, + }; + + let prop_kinds = self.program.element_props.get(&el.element); + let mut props = BTreeMap::new(); + for arg in &el.props { + let value = self.value(&arg.value)?; + if value == Value::None { + continue; // an unset optional prop is absent in V + } + let kind = prop_kinds + .and_then(|kinds| kinds.get(&arg.name)) + .copied() + .unwrap_or(ir::PropKindIr::Token); + let v = wrap_prop(kind, value) + .map_err(|m| EvalError(format!("prop `{}`: {m}", arg.name)))?; + props.insert(arg.name.clone(), v); + } + + // <text> content: runs join into one inert plain value. + if !el.text.is_empty() { + let mut content = String::new(); + for run in &el.text { + match run { + ir::TextRunIr::Literal(s) => content.push_str(s), + ir::TextRunIr::Interp(expr) => match self.value(expr)? { + Value::Text(s) => content.push_str(&s), + other => { + return Err(EvalError(format!("interpolation was {other:?}"))); + } + }, + } + } + props.insert( + Ident::new("content").expect("kebab"), + VValue::Plain(content), + ); + } + + let mut on = Vec::new(); + for binding in &el.events { + let mut payload = serde_json::Map::new(); + for arg in &binding.args { + payload.insert(arg.name.to_string(), self.value(&arg.value)?.to_json()); + } + let payload = serde_json::Value::Object(payload); + let (emit, payload) = match self.emits.resolve(&binding.emit) { + EmitSink::Dead => continue, // unbound at some call site — dead control + EmitSink::Rename(name) => (name, payload), + EmitSink::Fixed(name, prebuilt) => (name, prebuilt), + }; + let sig = self + .program + .element_events + .get(&el.element) + .and_then(|events| events.get(&binding.event)); + let Some(sig) = sig else { + return Err(EvalError(format!( + "`{}` has no `{}` event", + el.element, binding.event + ))); + }; + on.push(Descriptor { + kind: match sig.kind { + ir::EventKindIr::Input => DescriptorKind::Input, + ir::EventKindIr::Observe => DescriptorKind::Observe, + }, + event: binding.event.clone(), + emit, + scope: self.scope.clone(), + payload, + carries: sig + .carries + .iter() + .map(|(f, ty)| { + ( + f.clone(), + match ty { + ir::CarryTypeIr::Text => "text".to_string(), + ir::CarryTypeIr::Bool => "bool".to_string(), + ir::CarryTypeIr::Int => "int".to_string(), + }, + ) + }) + .collect(), + }); + } + + let children = self.eval_nodes(&el.children)?; + + Ok(Node { + key, + element: el.element.clone(), + class, + props, + children, + on, + }) + } + + fn eval_component( + &self, + call: &ir::ComponentCallIr, + key_override: Option<&str>, + ) -> Result<Node, EvalError> { + let def = self + .program + .components + .get(&call.component) + .ok_or_else(|| EvalError(format!("no component `{}`", call.component)))?; + + let mut props = BTreeMap::new(); + for arg in &call.props { + props.insert(arg.name.clone(), self.value(&arg.value)?); + } + + // Resolve the component's emits through this call site (§4.4). + let mut table = BTreeMap::new(); + for binding in &call.emits { + let sink = match &binding.target { + ir::EmitTargetIr::Forward => self.emits.resolve(&binding.emit), + ir::EmitTargetIr::Rebind { event, args } => { + let mut payload = serde_json::Map::new(); + for arg in args { + payload.insert(arg.name.to_string(), self.value(&arg.value)?.to_json()); + } + match self.emits.resolve(event) { + EmitSink::Dead => EmitSink::Dead, + EmitSink::Rename(name) => { + EmitSink::Fixed(name, serde_json::Value::Object(payload)) + } + // An outer rebind discards the inner payload too. + fixed @ EmitSink::Fixed(..) => fixed, + } + } + }; + table.insert(binding.emit.clone(), sink); + } + + let empty_state = BTreeMap::new(); + let frame = Frame { + program: self.program, + x: self.x, + scope: self.scope.clone(), + state: &empty_state, + props, + params: BTreeMap::new(), + bindings: Vec::new(), + emits: EmitEnv::Component(table), + }; + // Expansion is transparent: the root takes the call-site key. + let call_key = key_override + .map(ToString::to_string) + .unwrap_or_else(|| call.ord.to_string()); + let nodes = frame.eval_node_ir(&def.root, Some(&call_key))?; + single_root(nodes) + } + + // ── helpers ──────────────────────────────────────────────────────── + + fn child_frame(&self) -> Frame<'_> { + Frame { + program: self.program, + x: self.x, + scope: self.scope.clone(), + state: self.state, + props: self.props.clone(), + params: self.params.clone(), + bindings: self.bindings.clone(), + emits: self.emits.clone(), + } + } + + /// Expression evaluation in view position: `NotReady` is an invariant + /// break here (the checker forces availability matches, §9.2). + fn value(&self, e: &ir::ExprIr) -> Result<Value, EvalError> { + self.eval_expr(e).map_err(|stop| match stop { + Stop::NotReady(name) => EvalError(format!( + "view-position read of undelivered projection `{name}`" + )), + Stop::Internal(message) => EvalError(message), + }) + } +} + +impl Stop { + fn internal<T>(message: String) -> Result<T, Stop> { + Err(Stop::Internal(message)) + } +} + +fn wrap_prop(kind: ir::PropKindIr, value: Value) -> Result<VValue, String> { + match (kind, value) { + (ir::PropKindIr::Plain, Value::Text(s)) => Ok(VValue::Plain(s)), + (ir::PropKindIr::Token, Value::Text(s) | Value::Id(s)) => Ok(VValue::Text(s)), + (ir::PropKindIr::Bool, Value::Bool(b)) => Ok(VValue::Bool(b)), + (ir::PropKindIr::Int, Value::Int(i)) => Ok(VValue::Int(i)), + (ir::PropKindIr::Asset, Value::Id(s) | Value::Text(s)) => Ok(VValue::Image(s)), + (kind, value) => Err(format!("{kind:?} prop got {value:?}")), + } +} + +/// The canonical key string of an each-key value (§8.1 item keys). +fn key_string(v: &Value) -> Option<String> { + match v { + Value::Id(s) | Value::Text(s) => Some(s.clone()), + Value::Tag(n) => Some(format!("t-{n}")), + Value::Int(i) => Some(i.to_string()), + _ => None, + } +} diff --git a/uhura/crates/uhura-core/src/event.rs b/uhura/crates/uhura-core/src/event.rs new file mode 100644 index 0000000..07dd956 --- /dev/null +++ b/uhura/crates/uhura-core/src/event.rs @@ -0,0 +1,419 @@ +//! The external event alphabet (design §7.2) and the harness-side +//! projection-store transaction (`apply_updates`, §9.3/§9.4). +//! +//! Ordering contract (§7.2): for `Outcome` and `Projection` events the +//! HARNESS applies the carried updates to `X` — revision-checked — +//! immediately before calling `step_u`, so clear-overlay-on-ok is +//! flicker-free by construction. `step_u` itself never mutates `X`. + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Value}; +use uhura_port::envelope::{OutcomeResult, ProjectionUpdate}; + +use crate::decode::decode_value; +use crate::ir::ProgramIr; +use crate::state::Projections; +use crate::view::Descriptor; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + /// A renderer emission from a present descriptor. `data` carries the + /// catalog-declared `carries` fields (`{ value: "…" }`); stale + /// `view_rev` is ACCEPTED — guards are the backstop (§7.2). + Ui { + descriptor: Descriptor, + data: Option<Value>, + view_rev: u64, + }, + /// Exactly one per command, eventually. `updates` are the settlement + /// piggyback — already applied by the harness when `step_u` runs. + Outcome { + correlation: String, + result: OutcomeResult, + updates: Vec<ProjectionUpdate>, + }, + /// Standalone projection deliveries — already applied by the harness. + Projection { updates: Vec<ProjectionUpdate> }, + ProjectionFailed { + port: Ident, + projection: Ident, + key: Option<serde_json::Value>, + reason: String, + }, + /// Boots the machine onto a route. Boot projections are delivered + /// before this (§9.2), so bare reads are legal from the first view. + Init { + route: Ident, + params: BTreeMap<Ident, Value>, + }, +} + +impl Event { + /// The stable wire/trace form (hand-written — micro-decision #14). + pub fn to_json(&self) -> serde_json::Value { + match self { + Event::Ui { + descriptor, + data, + view_rev, + } => { + let mut obj = serde_json::json!({ + "kind": "ui", + "descriptor": descriptor.to_json(), + "view-rev": view_rev, + }); + if let Some(data) = data { + obj["data"] = data.to_json(); + } + obj + } + Event::Outcome { + correlation, + result, + updates, + } => serde_json::json!({ + "kind": "outcome", + "correlation": correlation, + "outcome": result.to_json(), + "updates": updates.iter().map(ProjectionUpdate::to_json).collect::<Vec<_>>(), + }), + Event::Projection { updates } => serde_json::json!({ + "kind": "projection", + "updates": updates.iter().map(ProjectionUpdate::to_json).collect::<Vec<_>>(), + }), + Event::ProjectionFailed { + port, + projection, + key, + reason, + } => serde_json::json!({ + "kind": "projection-failed", + "port": port.to_string(), + "projection": projection.to_string(), + "key": key, + "reason": reason, + }), + Event::Init { route, params } => serde_json::json!({ + "kind": "init", + "route": route.to_string(), + "params": params + .iter() + .map(|(k, v)| (k.to_string(), v.to_json())) + .collect::<serde_json::Map<_, _>>(), + }), + } + } + + /// Parses the wire form (the §12.3 ABI's `dispatch(event_json)` input). + /// `ui` carried data is typed by the descriptor's declared carries — + /// the renderer trust boundary (§4.2). Wire `init` params decode + /// strings as ids (route params are entity references — §3), bools and + /// integers as themselves (plan micro-decision #56). + pub fn from_json(json: &serde_json::Value) -> Result<Event, String> { + let kind = json + .get("kind") + .and_then(serde_json::Value::as_str) + .ok_or("an event needs a `kind`")?; + let str_field = |field: &str| -> Result<String, String> { + json.get(field) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| format!("a `{kind}` event needs a text `{field}`")) + }; + let updates_field = || -> Result<Vec<ProjectionUpdate>, String> { + match json.get("updates") { + None => Ok(Vec::new()), + Some(serde_json::Value::Array(items)) => items + .iter() + .map(ProjectionUpdate::from_json) + .collect::<Result<Vec<_>, _>>(), + Some(_) => Err("`updates` must be a list".into()), + } + }; + match kind { + "ui" => { + let descriptor = Descriptor::from_json( + json.get("descriptor") + .ok_or("a `ui` event needs a `descriptor`")?, + )?; + let view_rev = json + .get("view-rev") + .and_then(serde_json::Value::as_u64) + .ok_or("a `ui` event needs an integer `view-rev`")?; + let data = match json.get("data") { + None | Some(serde_json::Value::Null) => None, + Some(serde_json::Value::Object(map)) => decode_carried_data(&descriptor, map)?, + Some(_) => return Err("`data` must be an object".into()), + }; + Ok(Event::Ui { + descriptor, + data, + view_rev, + }) + } + "outcome" => Ok(Event::Outcome { + correlation: str_field("correlation")?, + result: OutcomeResult::from_json( + json.get("outcome") + .ok_or("an `outcome` event needs an `outcome`")?, + )?, + updates: updates_field()?, + }), + "projection" => Ok(Event::Projection { + updates: updates_field()?, + }), + "projection-failed" => Ok(Event::ProjectionFailed { + port: Ident::new(&str_field("port")?).map_err(|e| e.to_string())?, + projection: Ident::new(&str_field("projection")?).map_err(|e| e.to_string())?, + key: match json.get("key") { + None | Some(serde_json::Value::Null) => None, + Some(k) => Some(k.clone()), + }, + reason: str_field("reason")?, + }), + "init" => { + let mut params = BTreeMap::new(); + match json.get("params") { + None => {} + Some(serde_json::Value::Object(map)) => { + for (name, value) in map { + let name = Ident::new(name).map_err(|e| e.to_string())?; + let value = match value { + serde_json::Value::String(s) => Value::Id(s.clone()), + serde_json::Value::Bool(b) => Value::Bool(*b), + serde_json::Value::Number(n) => Value::Int( + n.as_i64() + .ok_or_else(|| format!("param `{name}` is not an i64"))?, + ), + other => { + return Err(format!( + "param `{name}` must be a string, bool, or integer, \ + got {other}" + )); + } + }; + params.insert(name, value); + } + } + Some(_) => return Err("`params` must be an object".into()), + } + Ok(Event::Init { + route: Ident::new(&str_field("route")?).map_err(|e| e.to_string())?, + params, + }) + } + other => Err(format!("`{other}` is not an event kind")), + } + } +} + +/// Types renderer-carried fields by the descriptor's declared carries +/// (`text | bool | int` — §4.2). Both the trace harness's `[[ui]]` +/// stimuli and the wire `ui` event decode through here, so the headless +/// pump and the play shell share one trust boundary. +pub fn decode_carried_data( + descriptor: &Descriptor, + data: &serde_json::Map<String, serde_json::Value>, +) -> Result<Option<Value>, String> { + if data.is_empty() { + return Ok(None); + } + let mut record = BTreeMap::new(); + for (field, value) in data { + let field = Ident::new(field).map_err(|e| e.to_string())?; + let Some(carry) = descriptor.carries.get(&field) else { + return Err(format!( + "`{}` does not carry `{field}` (declared carries: {:?})", + descriptor.event, + descriptor.carries.keys().collect::<Vec<_>>() + )); + }; + let typed = match (carry.as_str(), value) { + ("text", serde_json::Value::String(s)) => Value::Text(s.clone()), + ("bool", serde_json::Value::Bool(b)) => Value::Bool(*b), + ("int", serde_json::Value::Number(n)) => Value::Int( + n.as_i64() + .ok_or_else(|| format!("`{field}` is not an i64"))?, + ), + (expected, got) => { + return Err(format!("`{field}` carries {expected}, got {got}")); + } + }; + record.insert(field, typed); + } + Ok(Some(Value::Record(record))) +} + +/// What one update did to `X` — recorded in the step's trace line. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ApplyNote { + Applied { + projection: Ident, + key: Option<Value>, + revision: u64, + }, + /// Revisions strictly increase per (projection, key); stale deliveries + /// drop with a diagnosis, never an error (§9.3). + DroppedStale { + projection: Ident, + key: Option<Value>, + revision: u64, + current: u64, + }, + Failed { + projection: Ident, + key: Option<Value>, + reason: String, + }, +} + +impl ApplyNote { + pub fn to_json(&self) -> serde_json::Value { + let (kind, projection, key, extra) = match self { + ApplyNote::Applied { + projection, + key, + revision, + } => ( + "applied", + projection, + key, + serde_json::json!({ "revision": revision }), + ), + ApplyNote::DroppedStale { + projection, + key, + revision, + current, + } => ( + "dropped-stale", + projection, + key, + serde_json::json!({ "revision": revision, "current": current }), + ), + ApplyNote::Failed { + projection, + key, + reason, + } => ( + "failed", + projection, + key, + serde_json::json!({ "reason": reason }), + ), + }; + let mut obj = extra; + obj["apply"] = serde_json::Value::String(kind.into()); + obj["projection"] = serde_json::Value::String(projection.to_string()); + if let Some(key) = key { + obj["key"] = key.to_json(); + } + obj + } +} + +/// Applies provider updates to the projection store — the harness calls +/// this immediately before `step_u` for `Outcome`/`Projection` events and +/// for boot deliveries (§9.2). Value and key JSON decode against the IR's +/// baked-in types; a decode failure is a provider-conformance error, not a +/// drop. +pub fn apply_updates( + p: &ProgramIr, + x: &mut Projections, + updates: &[ProjectionUpdate], +) -> Result<Vec<ApplyNote>, String> { + let mut notes = Vec::new(); + for update in updates { + let decl = p.projections.get(&update.projection).ok_or_else(|| { + format!( + "`{}` is not a projection of this app's ports", + update.projection + ) + })?; + if decl.port != update.port { + return Err(format!( + "projection `{}` belongs to port `{}`, not `{}`", + update.projection, decl.port, update.port + )); + } + let key = decode_key(decl.key.as_ref(), update.key.as_ref(), &update.projection)?; + let value = decode_value(&update.value, &decl.ty) + .map_err(|e| format!("`{}` value: {e}", update.projection))?; + let instance = (update.projection.clone(), key.clone()); + if let Some(current) = x.snapshots.get(&instance) + && update.revision <= current.revision + { + notes.push(ApplyNote::DroppedStale { + projection: update.projection.clone(), + key, + revision: update.revision, + current: current.revision, + }); + continue; + } + // A fresh snapshot supersedes any recorded failure (session truth). + x.failed.remove(&instance); + x.snapshots.insert( + instance, + crate::state::ProjectionSnapshot { + revision: update.revision, + value, + }, + ); + notes.push(ApplyNote::Applied { + projection: update.projection.clone(), + key, + revision: update.revision, + }); + } + Ok(notes) +} + +/// Marks a projection instance failed (§9.2 session truth): the snapshot +/// is removed so availability matches render the `failed` arm. Port +/// conformance is checked exactly as `apply_updates` does — the wire is +/// not trusted to route. +pub fn apply_failure( + p: &ProgramIr, + x: &mut Projections, + port: &Ident, + projection: &Ident, + key: Option<&serde_json::Value>, + reason: &str, +) -> Result<ApplyNote, String> { + let decl = p + .projections + .get(projection) + .ok_or_else(|| format!("`{projection}` is not a projection of this app's ports"))?; + if decl.port != *port { + return Err(format!( + "projection `{projection}` belongs to port `{}`, not `{port}`", + decl.port + )); + } + let key = decode_key(decl.key.as_ref(), key, projection)?; + let instance = (projection.clone(), key.clone()); + x.snapshots.remove(&instance); + x.failed.insert(instance, reason.to_string()); + Ok(ApplyNote::Failed { + projection: projection.clone(), + key, + reason: reason.to_string(), + }) +} + +fn decode_key( + key_ty: Option<&crate::ir::TyIr>, + key: Option<&serde_json::Value>, + projection: &Ident, +) -> Result<Option<Value>, String> { + match (key_ty, key) { + (None, None) => Ok(None), + (Some(ty), Some(json)) => decode_value(json, ty) + .map(Some) + .map_err(|e| format!("`{projection}` key: {e}")), + (Some(_), None) => Err(format!("`{projection}` is keyed; the update names no key")), + (None, Some(_)) => Err(format!("`{projection}` is unkeyed; the update names a key")), + } +} diff --git a/uhura/crates/uhura-core/src/ir.rs b/uhura/crates/uhura-core/src/ir.rs new file mode 100644 index 0000000..6145002 --- /dev/null +++ b/uhura/crates/uhura-core/src/ir.rs @@ -0,0 +1,620 @@ +//! The checked IR (`"uhura-ir/0"`) — the serialized artifact the checker +//! lowers to and the machine executes (design §12.2). Everything here is +//! *resolved*: name references are classified, port/catalog authority is +//! reduced to pins plus the slices the runtime needs, and markup is a +//! closed template-op tree with stable node ordinals (§8.1 keys). +//! +//! Serialization is canonical JSON via `uhura_base::to_canonical_json` +//! (sorted keys fall out of the `BTreeMap` backing; no float can occur by +//! type shape). Spans live in a side table owned by the checker — IR bytes +//! are location-independent, so `*.examples.uhura` files and whitespace +//! churn cannot move them (§6.1 invariance). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uhura_base::{Ident, hash_json, to_canonical_json}; + +pub const IR_PROTOCOL: &str = "uhura-ir/0"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ProgramIr { + /// Always `"uhura-ir/0"`; `load_program` hard-gates on it. + pub protocol: String, + pub app: Ident, + /// Entry route name (a key of `routes`). + pub entry: Ident, + pub catalog: CatalogPin, + /// Port name → contract pin. Contracts themselves stay outside the IR; + /// the runtime slices it needs are baked in below. + pub ports: BTreeMap<Ident, PortPin>, + /// Projection name → binding. Projection (and command) names are + /// globally unique across the app's ports — a link rule, which is what + /// lets `X` key snapshots by bare name (§7.1). + pub projections: BTreeMap<Ident, ProjectionIr>, + /// Element → event → signature: the catalog slice descriptor building + /// needs (`kind`, `carries` — §8.1). + pub element_events: BTreeMap<Ident, BTreeMap<Ident, ElementEventIr>>, + /// Element → prop → kind: how `eval_view` wraps prop values in V — + /// human text becomes `{t:"plain"}`, tokens stay bare text, assets + /// become `{t:"image"}` (§8.1). + pub element_props: BTreeMap<Ident, BTreeMap<Ident, PropKindIr>>, + /// Route name → shape. The page definition lives in `pages` under the + /// same name. + pub routes: BTreeMap<Ident, RouteIr>, + pub pages: BTreeMap<Ident, DefIr>, + pub components: BTreeMap<Ident, DefIr>, + pub surfaces: BTreeMap<Ident, DefIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct CatalogPin { + pub name: Ident, + pub version: String, + /// SHA-256 of the catalog's canonical form. + pub hash: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct PortPin { + pub version: String, + /// SHA-256 of the contract's canonical form (§9.1). + pub hash: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ProjectionIr { + pub port: Ident, + /// Delivered before `Init`; bare reads legal (§9.2). + pub boot: bool, + /// The snapshot's structural type — how `apply_updates` decodes wire + /// JSON into typed values (the runtime half of L8). + pub ty: TyIr, + /// The key type of a keyed projection (`for-post(post: id)`). + pub key: Option<TyIr>, +} + +/// The closed structural type grammar, baked into the IR wherever the +/// runtime must decode wire JSON into typed `Value`s (event payloads, +/// projection updates). Declared nominal id/cursor types collapse to `Id` +/// here — the wire form is a string either way; nominal identity is a +/// check-time concern. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TyIr { + Bool, + Int, + Text, + Id, + /// Core-minted command tag (`"t-<n>"` on the wire). + Tag, + Asset, + /// A closed token set; values are the member names. + Enum(Vec<Ident>), + Option(Box<TyIr>), + List(Box<TyIr>), + Map { + key: MapKeyIr, + value: Box<TyIr>, + }, + Record(BTreeMap<Ident, TyIr>), + /// Variant → payload record fields. + Union(BTreeMap<Ident, BTreeMap<Ident, TyIr>>), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MapKeyIr { + Id, + Tag, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ElementEventIr { + pub kind: EventKindIr, + /// Renderer-carried payload fields and their shapes (§4.2). + pub carries: BTreeMap<Ident, CarryTypeIr>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EventKindIr { + Input, + Observe, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CarryTypeIr { + Text, + Bool, + Int, +} + +/// V-facing prop wrapping (§8.1): `Plain` is inert human text, `Token` is +/// a bare semantic string (enum/icon values), `Asset` is an opaque asset +/// reference. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PropKindIr { + Plain, + Token, + Bool, + Int, + Asset, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct RouteIr { + /// `/profile/:user` as segments; params in path order. + pub segments: Vec<RouteSegIr>, + pub params: Vec<Ident>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RouteSegIr { + Static(String), + Param(Ident), +} + +// ── definitions ───────────────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct DefIr { + /// Surfaces only (`"sheet"`). + pub modality: Option<String>, + /// Declared prop names, source order (payload shapes are compile- + /// checked; open-surface context and component expansion bind by name). + pub props: Vec<Ident>, + /// Declared emit names, source order. + pub emits: Vec<Ident>, + /// Route params, path order (pages). + pub params: Vec<Ident>, + pub state: BTreeMap<Ident, InitValue>, + /// Machine-event signatures (pages/surfaces): event → params in + /// signature order. This is both the `Ui` eligibility check (§7.2 — + /// payload fields must match exactly) and how payload JSON decodes + /// into typed values. + pub events: BTreeMap<Ident, Vec<EventParamIr>>, + pub handlers: Vec<HandlerIr>, + /// Exactly one root (§4.4/§8.1 — pages and surfaces included: the + /// snapshot has a single `root` Node). + pub root: NodeIr, +} + +/// State initializers are literals only (§4.3), so the IR never needs a +/// full `Value` encoding. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum InitValue { + Int(i64), + Text(String), + Bool(bool), + None, + /// `{}` — the empty map. Maps are represented as records keyed by the + /// canonical string of the key value (`"t-4"`, kebab-safe ids — + /// entity-id shapes are linted, §6.2). + EmptyMap, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EventParamIr { + pub name: Ident, + pub ty: TyIr, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct HandlerIr { + pub on: EventKeyIr, + /// Param names in signature order; payload fields bind by name. + pub params: Vec<Ident>, + pub guard: Option<ExprIr>, + pub body: Vec<StmtIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EventKeyIr { + Semantic { + event: Ident, + }, + Outcome { + command: Ident, + which: OutcomeKindIr, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OutcomeKindIr { + Ok, + Err, +} + +/// The five statements, resolved (§4.2). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum StmtIr { + Set { + field: Ident, + key: Option<ExprIr>, + value: ExprIr, + }, + Send { + port: Ident, + command: Ident, + args: Vec<ArgIr>, + bind: Option<Ident>, + }, + OpenSurface { + surface: Ident, + args: Vec<ArgIr>, + }, + Dismiss, + Navigate { + route: Ident, + args: Vec<ArgIr>, + }, + NavigateBack, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ArgIr { + pub name: Ident, + pub value: ExprIr, +} + +// ── expressions ───────────────────────────────────────────────────────────── + +/// Resolved expressions: every name is classified, so the runtime never +/// consults a scope table. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExprIr { + Int(i64), + Text(String), + Bool(bool), + None, + StateRef(Ident), + PropRef(Ident), + ParamRef(Ident), + /// `{#each}` item, `{:when v x}` binding, or handler param. + BindingRef(Ident), + /// Singleton (or boot) projection read. + ProjectionRef(Ident), + /// Keyed projection read: `for-post(post)`. + ProjectionKeyed { + projection: Ident, + key: Box<ExprIr>, + }, + Field { + base: Box<ExprIr>, + name: Ident, + }, + /// Option-returning `base[key]` (§4.3). + Index { + base: Box<ExprIr>, + key: Box<ExprIr>, + }, + Unary { + op: UnaryOpIr, + expr: Box<ExprIr>, + }, + Binary { + op: BinaryOpIr, + lhs: Box<ExprIr>, + rhs: Box<ExprIr>, + }, + If { + cond: Box<ExprIr>, + then: Box<ExprIr>, + els: Box<ExprIr>, + }, + ToText(Box<ExprIr>), + Count(Box<ExprIr>), + RecordLit(Vec<ArgIr>), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum UnaryOpIr { + Not, + Neg, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BinaryOpIr { + Add, + Sub, + Concat, + Eq, + NotEq, + Lt, + Le, + Gt, + Ge, + And, + Or, + Coalesce, +} + +// ── markup template ops ───────────────────────────────────────────────────── + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NodeIr { + Element(ElementIr), + Component(ComponentCallIr), + If { + cond: ExprIr, + then: Vec<NodeIr>, + els: Vec<NodeIr>, + }, + Each(EachIr), + Match(MatchIr), +} + +/// Node ordinals are assigned depth-first pre-order per definition, so +/// they are sibling-unique whatever branches render (§8.1: static keys are +/// source ordinals; `{#each}` items take `"<ordinal>.<key-value>"`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ElementIr { + pub element: Ident, + pub ord: u32, + /// Authored CSS classes — an opaque expression, carried to V verbatim. + pub class: Option<ExprIr>, + /// Semantic props, source order. + pub props: Vec<ArgIr>, + pub events: Vec<ElementEventBindingIr>, + /// `<text>` content runs; empty for every other element. + pub text: Vec<TextRunIr>, + pub children: Vec<NodeIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ElementEventBindingIr { + /// The catalog event (`press`, `near-end`, `change`). + pub event: Ident, + /// The machine event the descriptor emits. + pub emit: Ident, + /// Author args; carried fields are appended by the renderer (§4.2). + pub args: Vec<ArgIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct ComponentCallIr { + pub component: Ident, + pub ord: u32, + pub props: Vec<ArgIr>, + /// Call-site consumption of the component's declared emits (§4.4). + pub emits: Vec<EmitBindingIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EmitBindingIr { + /// The component's declared emit name. + pub emit: Ident, + pub target: EmitTargetIr, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EmitTargetIr { + /// Bare `on:like-toggled` — same name, same payload, enclosing scope. + Forward, + /// `on:dismissed={emit notice-dismissed()}` — new event; args evaluate + /// in the *caller's* scope; the component payload is discarded. + Rebind { event: Ident, args: Vec<ArgIr> }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct EachIr { + pub ord: u32, + pub item: Ident, + pub over: OverIr, + pub seq: ExprIr, + /// Evaluated with `item` bound; its canonical string joins the slot + /// ordinal to form the child key. + pub key: ExprIr, + pub body: Vec<NodeIr>, +} + +/// What the sequence expression yields (typecheck bakes it in so iteration +/// can rebuild typed map keys). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum OverIr { + List, + MapIdKeys, + MapTagKeys, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MatchIr { + pub source: MatchSourceIr, + pub arms: Vec<MatchArmIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum MatchSourceIr { + /// Availability arms (`loading | failed reason | ready v`) over + /// projection presence — session truth, not contract types (§9.2). + Availability { + projection: Ident, + key: Option<ExprIr>, + }, + /// A closed port union value; arms are variant names. + Union { value: ExprIr }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct MatchArmIr { + /// `None` is the `{:else}` arm. + pub variant: Option<Ident>, + pub binding: Option<Ident>, + pub body: Vec<NodeIr>, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum TextRunIr { + Literal(String), + Interp(ExprIr), +} + +// ── serialize / load / hash ───────────────────────────────────────────────── + +impl ProgramIr { + /// The canonical byte form — what `--emit-ir` writes and goldens pin. + pub fn to_canonical_string(&self) -> String { + let value = serde_json::to_value(self).expect("IR types are always serializable"); + to_canonical_json(&value) + } + + /// SHA-256 of the canonical form. + pub fn hash(&self) -> String { + let value = serde_json::to_value(self).expect("IR types are always serializable"); + hash_json(&value) + } +} + +/// Loads serialized IR with the hard version gate (§12.2): anything but +/// `"uhura-ir/0"` is refused before deserialization is attempted. +pub fn load_program(text: &str) -> Result<ProgramIr, String> { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("IR is not valid JSON: {e}"))?; + match value.get("protocol").and_then(serde_json::Value::as_str) { + Some(IR_PROTOCOL) => {} + Some(other) => { + return Err(format!( + "IR protocol `{other}` is not supported (this build reads `{IR_PROTOCOL}`)" + )); + } + None => return Err("IR has no `protocol` field".to_string()), + } + serde_json::from_value(value).map_err(|e| format!("malformed `{IR_PROTOCOL}`: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ident(s: &str) -> Ident { + Ident::new(s).unwrap() + } + + fn tiny_program() -> ProgramIr { + let root = NodeIr::Element(ElementIr { + element: ident("view"), + ord: 0, + class: Some(ExprIr::Text("screen".into())), + props: vec![], + events: vec![], + text: vec![], + children: vec![NodeIr::Element(ElementIr { + element: ident("text"), + ord: 1, + class: None, + props: vec![], + events: vec![], + text: vec![ + TextRunIr::Literal("Hello ".into()), + TextRunIr::Interp(ExprIr::StateRef(ident("who"))), + ], + children: vec![], + })], + }); + let mut state = BTreeMap::new(); + state.insert(ident("who"), InitValue::Text("world".into())); + let mut events = BTreeMap::new(); + events.insert(ident("poked"), vec![]); + let page = DefIr { + modality: None, + props: vec![], + emits: vec![], + params: vec![], + state, + events, + handlers: vec![HandlerIr { + on: EventKeyIr::Semantic { + event: ident("poked"), + }, + params: vec![], + guard: None, + body: vec![StmtIr::Set { + field: ident("who"), + key: None, + value: ExprIr::Text("you".into()), + }], + }], + root, + }; + let mut routes = BTreeMap::new(); + routes.insert( + ident("home"), + RouteIr { + segments: vec![RouteSegIr::Static("home".into())], + params: vec![], + }, + ); + let mut pages = BTreeMap::new(); + pages.insert(ident("home"), page); + ProgramIr { + protocol: IR_PROTOCOL.to_string(), + app: ident("tiny"), + entry: ident("home"), + catalog: CatalogPin { + name: ident("base"), + version: "0.1.0".into(), + hash: "0".repeat(64), + }, + ports: BTreeMap::new(), + projections: BTreeMap::new(), + element_events: BTreeMap::new(), + element_props: BTreeMap::new(), + routes, + pages, + components: BTreeMap::new(), + surfaces: BTreeMap::new(), + } + } + + #[test] + fn round_trips_canonically() { + let program = tiny_program(); + let bytes = program.to_canonical_string(); + let reloaded = load_program(&bytes).unwrap(); + assert_eq!(reloaded, program); + assert_eq!(reloaded.to_canonical_string(), bytes, "byte-stable"); + } + + #[test] + fn version_gate_is_hard() { + let program = tiny_program(); + let bytes = program + .to_canonical_string() + .replace("uhura-ir/0", "uhura-ir/1"); + let err = load_program(&bytes).unwrap_err(); + assert!(err.contains("uhura-ir/1"), "{err}"); + } +} diff --git a/uhura/crates/uhura-core/src/lib.rs b/uhura/crates/uhura-core/src/lib.rs new file mode 100644 index 0000000..07b7696 --- /dev/null +++ b/uhura/crates/uhura-core/src/lib.rs @@ -0,0 +1,15 @@ +//! uhura-core: the checked IR ("uhura-ir/0", module `ir`), the semantic view +//! protocol ("uhura-view/0", module `view`), and the machine — step-u and +//! eval_view. Pure and I/O-free by construction: the dependency closure is +//! exactly {uhura-base, uhura-port}, enforced by uhura-tests/tests/purity.rs +//! (design §7, §8.1, §12.1). No clocks, randomness, floats, or HashMap. +#![deny(clippy::float_arithmetic)] + +pub mod decode; +pub mod eval; +pub mod event; +pub mod ir; +pub mod state; +pub mod step; +pub mod trace; +pub mod view; diff --git a/uhura/crates/uhura-core/src/state.rs b/uhura/crates/uhura-core/src/state.rs new file mode 100644 index 0000000..78f44f5 --- /dev/null +++ b/uhura/crates/uhura-core/src/state.rs @@ -0,0 +1,193 @@ +//! Machine state — owned, ordered data only (design §7.1). `step_u` +//! arrives with M4; M3 builds these values from resolved examples and +//! evaluates views over them. + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Value}; + +use crate::ir; + +/// Projections are ABSENT until delivered; keyed instances (§7.1/§9.2). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Projections { + pub snapshots: BTreeMap<(Ident, Option<Value>), ProjectionSnapshot>, + pub failed: BTreeMap<(Ident, Option<Value>), String>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectionSnapshot { + /// Strictly increasing per (projection, key); stale deliveries drop. + pub revision: u64, + pub value: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UiState { + /// `+1` every step, always. + pub rev: u64, + /// Bottom → top; the top entry is the current page. + pub nav: Vec<NavEntry>, + /// Bottom → top. + pub surfaces: Vec<SurfaceState>, + /// Correlation tag → in-flight command (payload echo for outcome + /// handlers; origin scope for dispatch). + pub pending: BTreeMap<u64, PendingCommand>, + /// All identity — replay mints identical ids (§7.1). + pub counters: Counters, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NavEntry { + /// Minted page serial — the `"page:<n>"` scope. + pub serial: u64, + pub route: Ident, + pub params: BTreeMap<Ident, Value>, + pub state: BTreeMap<Ident, Value>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SurfaceState { + /// Minted surface serial — the `"surface:<n>"` scope. + pub serial: u64, + pub definition: Ident, + /// The canonical open context (evaluated `open-surface` args = props). + pub props: BTreeMap<Ident, Value>, + pub state: BTreeMap<Ident, Value>, + /// The scope that opened this instance. + pub opener: String, + /// Key-path of the triggering node, for FocusRestore (§4.2). + pub restore_focus: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PendingCommand { + pub port: Ident, + pub command: Ident, + /// The echoed payload (`cmd` in outcome handlers — §4.2). + pub payload: Value, + /// Origin scope (`"page:1"` / `"surface:2"`). + pub origin: String, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Counters { + /// Next command tag (`t-<n>` / wire `c-<n>`). + pub tag: u64, + /// Next page serial. + pub page_serial: u64, + /// Next surface serial. + pub surface_serial: u64, +} + +impl Counters { + // Mints are 1-based (`t-1`, `page:1`, `surface:1` — §8.1's examples). + pub fn mint_tag(&mut self) -> u64 { + self.tag += 1; + self.tag + } + + pub fn mint_page(&mut self) -> u64 { + self.page_serial += 1; + self.page_serial + } + + pub fn mint_surface(&mut self) -> u64 { + self.surface_serial += 1; + self.surface_serial + } +} + +impl UiState { + /// The machine boots empty: no page until `Init` mounts one (§9.2 — + /// boot projections are delivered before the first event). + pub fn boot() -> UiState { + UiState { + rev: 0, + nav: Vec::new(), + surfaces: Vec::new(), + pending: BTreeMap::new(), + counters: Counters::default(), + } + } + + /// Canonical JSON of the whole machine state — the `u-hash` input + /// (§7.5). Pending keys render as their minted tag form. + pub fn to_json(&self) -> serde_json::Value { + let fields = |map: &BTreeMap<Ident, Value>| -> serde_json::Value { + map.iter() + .map(|(k, v)| (k.to_string(), v.to_json())) + .collect::<serde_json::Map<_, _>>() + .into() + }; + serde_json::json!({ + "rev": self.rev, + "nav": self.nav.iter().map(|entry| serde_json::json!({ + "serial": entry.serial, + "route": entry.route.to_string(), + "params": fields(&entry.params), + "state": fields(&entry.state), + })).collect::<Vec<_>>(), + "surfaces": self.surfaces.iter().map(|s| { + let mut obj = serde_json::json!({ + "serial": s.serial, + "definition": s.definition.to_string(), + "props": fields(&s.props), + "state": fields(&s.state), + "opener": s.opener, + }); + if let Some(rf) = &s.restore_focus { + obj["restore-focus"] = serde_json::Value::String(rf.clone()); + } + obj + }).collect::<Vec<_>>(), + "pending": self.pending.iter().map(|(tag, cmd)| { + (format!("t-{tag}"), serde_json::json!({ + "port": cmd.port.to_string(), + "command": cmd.command.to_string(), + "payload": cmd.payload.to_json(), + "origin": cmd.origin, + })) + }).collect::<serde_json::Map<_, _>>(), + "counters": { + "tag": self.counters.tag, + "page-serial": self.counters.page_serial, + "surface-serial": self.counters.surface_serial, + }, + }) + } + + /// SHA-256 of the canonical machine state (§7.5). + pub fn u_hash(&self) -> String { + uhura_base::hash_json(&self.to_json()) + } +} + +/// The initial state record of a definition (init literals realized). +pub fn initial_state(def: &ir::DefIr) -> BTreeMap<Ident, Value> { + def.state + .iter() + .map(|(name, init)| (name.clone(), init_value(init))) + .collect() +} + +pub fn init_value(init: &ir::InitValue) -> Value { + match init { + ir::InitValue::Int(i) => Value::Int(*i), + ir::InitValue::Text(s) => Value::Text(s.clone()), + ir::InitValue::Bool(b) => Value::Bool(*b), + ir::InitValue::None => Value::None, + ir::InitValue::EmptyMap => Value::Record(BTreeMap::new()), + } +} + +/// The canonical map-key string of a key value: ids keep their text, +/// tags render `"t-<n>"` (maps are records keyed by this string — IR +/// micro-decision; entity-id shapes are linted, §6.2). +pub fn map_key_string(key: &Value) -> Option<String> { + match key { + Value::Id(s) | Value::Text(s) => Some(s.clone()), + Value::Tag(n) => Some(format!("t-{n}")), + _ => None, + } +} diff --git a/uhura/crates/uhura-core/src/step.rs b/uhura/crates/uhura-core/src/step.rs new file mode 100644 index 0000000..0214f6a --- /dev/null +++ b/uhura/crates/uhura-core/src/step.rs @@ -0,0 +1,1314 @@ +//! `step_u` — one external event, one deterministic step (design §7): +//! acceptance (stale-scope → occluded → ineligible; stale `view_rev` +//! accepted), multi-handler guard selection, TRANSACTIONAL handler bodies +//! (§4.2: staged writes/pending/counters/commands; a not-ready projection +//! read aborts atomically — guard position reads as false), structural +//! statements applied at dispatch end, `rev + 1` always, `eval_view` once +//! from the final state. No internal queue ⇒ termination by construction. + +use std::collections::BTreeMap; + +use uhura_base::{Diagnostic, FileId, Ident, Span, Value, codes}; +use uhura_port::envelope::{CommandEnvelope, OutcomeResult, ProviderMsg}; + +use crate::decode::decode_value; +use crate::eval::{EmitEnv, EvalError, Frame, Stop, eval_view}; +use crate::event::Event; +use crate::ir::{self, DefIr, ProgramIr}; +use crate::state::{ + Counters, NavEntry, PendingCommand, Projections, SurfaceState, UiState, initial_state, + map_key_string, +}; +use crate::trace::{DispatchRecord, Disposition, DropReason, GuardNote, GuardResult, StepTrace}; +use crate::view::{Descriptor, Node, Snapshot}; + +pub struct StepResult { + pub u: UiState, + pub v: Snapshot, + pub c: Vec<CommandEnvelope>, + pub i: Vec<IntentEnvelope>, + pub g: Vec<Diagnostic>, + pub t: StepTrace, +} + +impl StepResult { + /// The frozen step-result envelope `dispatch` returns across the wasm + /// boundary (§12.3, plan micro-decision #14). Every key is always + /// present: `c` command envelopes (provider wire form) for the shell + /// to forward, `i` intents, `g` runtime diagnostics, `t` the canonical + /// trace record, `v` the full `uhura-view/0` snapshot. `U` stays + /// inside the machine — hashes travel in `t`. + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "c": self + .c + .iter() + .map(|c| ProviderMsg::Command(c.clone()).to_json()) + .collect::<Vec<_>>(), + "i": self.i.iter().map(IntentEnvelope::to_json).collect::<Vec<_>>(), + "g": self.g.iter().map(diagnostic_json).collect::<Vec<_>>(), + "t": self.t.to_json(), + "v": self.v.to_json(), + }) + } +} + +/// Host intents (§7.4): the machine owns `nav`; the host's history moves +/// only via these. The spike shell executes them as no-ops — the contract +/// stays visible in `T`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IntentEnvelope { + HistoryPush { + route: Ident, + params: BTreeMap<Ident, Value>, + }, + HistoryBack, + /// Emitted when the topmost surface dismisses and its opening trigger + /// node is known (§4.2). + FocusRestore { + key_path: String, + }, +} + +impl IntentEnvelope { + pub fn to_json(&self) -> serde_json::Value { + match self { + IntentEnvelope::HistoryPush { route, params } => serde_json::json!({ + "intent": "history-push", + "route": route.to_string(), + "params": params + .iter() + .map(|(k, v)| (k.to_string(), v.to_json())) + .collect::<serde_json::Map<_, _>>(), + }), + IntentEnvelope::HistoryBack => serde_json::json!({ "intent": "history-back" }), + IntentEnvelope::FocusRestore { key_path } => serde_json::json!({ + "intent": "focus-restore", + "key-path": key_path, + }), + } + } +} + +/// The reserved structural dismiss event (micro-decision #36). +const DISMISS: &str = "dismiss"; + +pub fn step_u( + p: &ProgramIr, + u: UiState, + x: &Projections, + e: Event, +) -> Result<StepResult, EvalError> { + let event_json = e.to_json(); + let mut machine = Machine { + p, + x, + u, + c: Vec::new(), + i: Vec::new(), + g: Vec::new(), + structural: Vec::new(), + pre_view: None, + }; + + let disposition = match &e { + Event::Init { route, params } => { + machine.init(route, params)?; + Disposition::Delivery + } + Event::Projection { .. } | Event::ProjectionFailed { .. } => { + // The harness already applied the updates to X (§7.2); the + // step exists to recompute V — overlay rebase is free. + Disposition::Delivery + } + Event::Outcome { + correlation, + result, + .. + } => machine.dispatch_outcome(correlation, result)?, + Event::Ui { + descriptor, data, .. + } => machine.dispatch_ui(descriptor, data.as_ref())?, + }; + + machine.u.rev += 1; + let v = eval_view(p, &machine.u, x)?; + let t = StepTrace { + event: event_json, + applies: Vec::new(), + disposition, + structural: machine.structural, + c: machine + .c + .iter() + .map(|c| ProviderCommand(c).to_json()) + .collect(), + i: machine.i.iter().map(IntentEnvelope::to_json).collect(), + g: machine.g.iter().map(diagnostic_json).collect(), + u_hash: machine.u.u_hash(), + v_hash: v.v_hash(), + v: None, + }; + Ok(StepResult { + u: machine.u, + v, + c: machine.c, + i: machine.i, + g: machine.g, + t, + }) +} + +/// Runtime diagnostics trace as spanless records — they are minted by the +/// machine, not a source pass (§7.5). +fn diagnostic_json(d: &Diagnostic) -> serde_json::Value { + serde_json::json!({ + "code": d.code, + "rule": d.rule, + "message": d.message, + }) +} + +/// Command envelopes trace in their wire form (`kind: "command"`). +struct ProviderCommand<'a>(&'a CommandEnvelope); + +impl ProviderCommand<'_> { + fn to_json(&self) -> serde_json::Value { + uhura_port::envelope::ProviderMsg::Command(self.0.clone()).to_json() + } +} + +// ── the per-step machine ──────────────────────────────────────────────────── + +struct Machine<'a> { + p: &'a ProgramIr, + x: &'a Projections, + u: UiState, + c: Vec<CommandEnvelope>, + i: Vec<IntentEnvelope>, + /// Runtime diagnostics (`G` — §7.1). + g: Vec<Diagnostic>, + structural: Vec<serde_json::Value>, + /// The view the input event was emitted against, for the focus-restore + /// node search — captured lazily at commit time but BEFORE the commit + /// mutates state (only an `open-surface` commit fills it). + pre_view: Option<Snapshot>, +} + +/// Where a scope string lands in the current state. +enum ScopeTarget { + /// Index into `nav` (top = interactive). + Page(usize), + /// Index into `surfaces`. + Surface(usize), +} + +impl<'a> Machine<'a> { + // ── init ──────────────────────────────────────────────────────────── + + fn init(&mut self, route: &Ident, params: &BTreeMap<Ident, Value>) -> Result<(), EvalError> { + let def = self + .p + .pages + .get(route) + .ok_or_else(|| EvalError(format!("init: no page for route `{route}`")))?; + let serial = self.u.counters.mint_page(); + self.u.nav = vec![NavEntry { + serial, + route: route.clone(), + params: params.clone(), + state: initial_state(def), + }]; + self.u.surfaces.clear(); + self.structural.push(serde_json::json!({ + "op": "init", + "route": route.to_string(), + "serial": serial, + })); + Ok(()) + } + + // ── scope resolution ──────────────────────────────────────────────── + + fn parse_scope(scope: &str) -> Option<(&str, u64)> { + let (kind, serial) = scope.split_once(':')?; + Some((kind, serial.parse().ok()?)) + } + + fn find_scope(&self, scope: &str) -> Option<ScopeTarget> { + let (kind, serial) = Self::parse_scope(scope)?; + match kind { + "page" => self + .u + .nav + .iter() + .position(|e| e.serial == serial) + .map(ScopeTarget::Page), + "surface" => self + .u + .surfaces + .iter() + .position(|s| s.serial == serial) + .map(ScopeTarget::Surface), + _ => None, + } + } + + /// §7.2 Ui acceptance, first two rules: scope alive → else stale-scope + /// (a below-top page is not the interactive layer); top-surface + /// modality → else occluded. The Err IS the drop disposition — size is + /// irrelevant on the once-per-step drop path. + #[allow(clippy::result_large_err)] + fn accept_ui_scope(&self, scope: &str) -> Result<ScopeTarget, Disposition> { + let dropped = |reason, detail: String| Disposition::Dropped { reason, detail }; + let target = self + .find_scope(scope) + .ok_or_else(|| dropped(DropReason::StaleScope, format!("scope `{scope}` is gone")))?; + match target { + ScopeTarget::Page(idx) => { + if idx + 1 != self.u.nav.len() { + return Err(dropped( + DropReason::StaleScope, + format!("`{scope}` is below the top of the nav stack"), + )); + } + if !self.u.surfaces.is_empty() { + return Err(dropped( + DropReason::Occluded, + "a surface is above the page".to_string(), + )); + } + Ok(target) + } + ScopeTarget::Surface(idx) => { + if idx + 1 != self.u.surfaces.len() { + return Err(dropped( + DropReason::Occluded, + format!("`{scope}` is not the top surface"), + )); + } + Ok(target) + } + } + } + + fn scope_def(&self, target: &ScopeTarget) -> Result<(&'a DefIr, Ident), EvalError> { + match target { + ScopeTarget::Page(idx) => { + let entry = &self.u.nav[*idx]; + let def = self + .p + .pages + .get(&entry.route) + .ok_or_else(|| EvalError(format!("no page for route `{}`", entry.route)))?; + Ok((def, entry.route.clone())) + } + ScopeTarget::Surface(idx) => { + let s = &self.u.surfaces[*idx]; + let def = self + .p + .surfaces + .get(&s.definition) + .ok_or_else(|| EvalError(format!("no surface `{}`", s.definition)))?; + Ok((def, s.definition.clone())) + } + } + } + + // ── ui dispatch ───────────────────────────────────────────────────── + + fn dispatch_ui( + &mut self, + descriptor: &Descriptor, + data: Option<&Value>, + ) -> Result<Disposition, EvalError> { + // The reserved dismiss is structural: no authored handler (§8.1). + if descriptor.emit.as_str() == DISMISS && descriptor.scope.starts_with("surface:") { + return match self.accept_ui_scope(&descriptor.scope) { + Err(drop) => Ok(drop), + Ok(ScopeTarget::Surface(_)) => { + let (_, serial) = + Self::parse_scope(&descriptor.scope).expect("accepted scope parses"); + self.dismiss_surface(serial); + Ok(Disposition::Reserved { + scope: descriptor.scope.clone(), + }) + } + Ok(ScopeTarget::Page(_)) => unreachable!("surface scope resolved to a page"), + }; + } + + let target = match self.accept_ui_scope(&descriptor.scope) { + Ok(target) => target, + Err(drop) => return Ok(drop), + }; + let (def, definition) = self.scope_def(&target)?; + + // Third acceptance rule: the emit must be declared with a + // matching payload — fields (payload ∪ carried data) must cover + // the signature exactly, each decoding against its type. + let Some(sig) = def.events.get(&descriptor.emit) else { + return Ok(Disposition::Dropped { + reason: DropReason::Ineligible, + detail: format!("`{definition}` declares no event `{}`", descriptor.emit), + }); + }; + let bindings = match bind_payload(sig, &descriptor.payload, data) { + Ok(bindings) => bindings, + Err(detail) => { + return Ok(Disposition::Dropped { + reason: DropReason::Ineligible, + detail, + }); + } + }; + + let record = self.dispatch( + &target, + def, + definition, + descriptor.scope.clone(), + descriptor.emit.to_string(), + |h| matches!(&h.on, ir::EventKeyIr::Semantic { event } if *event == descriptor.emit), + &BindSpec::Named(&bindings), + Some(descriptor), + )?; + Ok(Disposition::Dispatched(record)) + } + + // ── outcome dispatch ──────────────────────────────────────────────── + + fn dispatch_outcome( + &mut self, + correlation: &str, + result: &OutcomeResult, + ) -> Result<Disposition, EvalError> { + let tag = correlation + .strip_prefix("c-") + .and_then(|n| n.parse::<u64>().ok()); + let Some(pending) = tag.and_then(|t| self.u.pending.remove(&t)) else { + return Ok(Disposition::Dropped { + reason: DropReason::UnknownCorrelation, + detail: format!("no pending command `{correlation}`"), + }); + }; + let tag = tag.expect("pending entry implies a parsed tag"); + + // Outcomes reach any mounted origin — below-top pages keep state + // and must settle their overlays. Only a DEAD origin drops + // (pending stays removed; truth already lives in X — §7.2). + let Some(target) = self.find_scope(&pending.origin) else { + return Ok(Disposition::Dropped { + reason: DropReason::StaleOutcome, + detail: format!("origin `{}` is unmounted", pending.origin), + }); + }; + let (def, definition) = self.scope_def(&target)?; + + let (which, refusal) = match result { + OutcomeResult::Ok => (ir::OutcomeKindIr::Ok, None), + OutcomeResult::Refused { refusal } => { + (ir::OutcomeKindIr::Err, Some(refusal.to_string())) + } + // `unavailable` routes to `.err` (§4.2); the refusal binding + // reads the reserved name. + OutcomeResult::Unavailable { .. } => { + (ir::OutcomeKindIr::Err, Some("unavailable".to_string())) + } + }; + let mut positional = vec![Value::Tag(tag), pending.payload.clone()]; + if let Some(refusal) = &refusal { + positional.push(Value::Text(refusal.clone())); + } + let on_label = format!( + "{}.{}", + pending.command, + match which { + ir::OutcomeKindIr::Ok => "ok", + ir::OutcomeKindIr::Err => "err", + } + ); + let record = self.dispatch( + &target, + def, + definition, + pending.origin.clone(), + on_label, + |h| { + matches!(&h.on, ir::EventKeyIr::Outcome { command, which: w } + if *command == pending.command && *w == which) + }, + &BindSpec::Positional(&positional), + None, + )?; + Ok(Disposition::Dispatched(record)) + } + + // ── guard selection + the transaction ─────────────────────────────── + + #[allow(clippy::too_many_arguments)] + fn dispatch( + &mut self, + target: &ScopeTarget, + def: &DefIr, + definition: Ident, + scope: String, + on_label: String, + matches_handler: impl Fn(&ir::HandlerIr) -> bool, + bind: &BindSpec<'_>, + trigger: Option<&Descriptor>, + ) -> Result<DispatchRecord, EvalError> { + let (entry_state, props, params) = match target { + ScopeTarget::Page(idx) => { + let entry = &self.u.nav[*idx]; + (entry.state.clone(), BTreeMap::new(), entry.params.clone()) + } + ScopeTarget::Surface(idx) => { + let s = &self.u.surfaces[*idx]; + (s.state.clone(), s.props.clone(), BTreeMap::new()) + } + }; + + let mut record = DispatchRecord { + scope: scope.clone(), + definition, + on: on_label, + guards: Vec::new(), + selected: None, + writes: Vec::new(), + aborted: None, + }; + + // Source order, first satisfied guard wins; a guard-position + // not-ready read means the guard is false (§4.2). + type Selected<'h> = (usize, &'h ir::HandlerIr, Vec<(Ident, Value)>); + let mut selected: Option<Selected<'_>> = None; + for (index, handler) in def.handlers.iter().enumerate() { + if !matches_handler(handler) { + continue; + } + let bindings = bind.bind(&handler.params); + let result = match &handler.guard { + None => GuardResult::Satisfied, + Some(guard) => { + let frame = Frame { + program: self.p, + x: self.x, + scope: scope.clone(), + state: &entry_state, + props: props.clone(), + params: params.clone(), + bindings: bindings.clone(), + emits: EmitEnv::Machine, + }; + match frame.eval_expr(guard) { + Ok(Value::Bool(true)) => GuardResult::Satisfied, + Ok(Value::Bool(false)) => GuardResult::Unsatisfied, + Ok(other) => { + return Err(EvalError(format!("guard was {other:?}"))); + } + Err(Stop::NotReady(_)) => GuardResult::NotReady, + Err(Stop::Internal(msg)) => return Err(EvalError(msg)), + } + } + }; + record.guards.push(GuardNote { + handler: index, + result, + }); + if result == GuardResult::Satisfied { + selected = Some((index, handler, bindings)); + break; + } + } + + let Some((index, handler, bindings)) = selected else { + return Ok(record); // dropped: no handler — traced by the record + }; + record.selected = Some(index); + + // The transaction: staged state / pending / counters / commands / + // structural ops. Nothing touches `self.u` until commit. + match self.run_txn( + def, + &scope, + &entry_state, + &props, + &params, + handler, + bindings, + )? { + TxnOutcome::Aborted => { + record.aborted = Some("projection-not-ready".to_string()); + Ok(record) + } + TxnOutcome::Committed(txn) => { + record.writes = txn.writes; + self.g.extend(txn.warnings); + // The focus-restore search needs the view the event was + // emitted against — capture it BEFORE the commit mutates + // anything (micro-decision #42: pre-step, not post-commit; + // a handler write can change the trigger's own payload). + if trigger.is_some() + && self.pre_view.is_none() + && txn.ops.iter().any(|op| matches!(op, StagedOp::Open { .. })) + { + self.pre_view = Some(eval_view(self.p, &self.u, self.x)?); + } + match target { + ScopeTarget::Page(idx) => self.u.nav[*idx].state = txn.state, + ScopeTarget::Surface(idx) => self.u.surfaces[*idx].state = txn.state, + } + self.u.counters = txn.counters; + self.u.pending.extend(txn.pending_add); + self.c.extend(txn.commands); + for op in txn.ops { + self.apply_op(op, &scope, trigger)?; + } + Ok(record) + } + } + } + + #[allow(clippy::too_many_arguments)] + fn run_txn( + &self, + _def: &DefIr, + scope: &str, + entry_state: &BTreeMap<Ident, Value>, + props: &BTreeMap<Ident, Value>, + params: &BTreeMap<Ident, Value>, + handler: &ir::HandlerIr, + bindings: Vec<(Ident, Value)>, + ) -> Result<TxnOutcome, EvalError> { + let mut txn = Txn { + state: entry_state.clone(), + counters: self.u.counters, + pending_add: Vec::new(), + commands: Vec::new(), + ops: Vec::new(), + writes: Vec::new(), + warnings: Vec::new(), + }; + let mut bindings = bindings; + + for stmt in &handler.body { + // Writes are visible sequentially WITHIN the handler: each + // statement evaluates against the staged state (§4.2). + let eval = |e: &ir::ExprIr, txn: &Txn, bindings: &[(Ident, Value)]| { + eval_staged( + self.p, self.x, scope, &txn.state, props, params, bindings, e, + ) + }; + let eval_args = |args: &[ir::ArgIr], + txn: &Txn, + bindings: &[(Ident, Value)]| + -> Result<BTreeMap<Ident, Value>, Stop> { + let mut out = BTreeMap::new(); + for arg in args { + out.insert(arg.name.clone(), eval(&arg.value, txn, bindings)?); + } + Ok(out) + }; + + let step: Result<(), Stop> = (|| { + match stmt { + ir::StmtIr::Set { field, key, value } => { + let key_value = match key { + Some(k) => Some(eval(k, &txn, &bindings)?), + None => None, + }; + let value = eval(value, &txn, &bindings)?; + match key_value { + None => { + txn.writes.push(serde_json::json!({ + "field": field.to_string(), + "value": value.to_json(), + })); + txn.state.insert(field.clone(), value); + } + Some(key_value) => { + let key_str = map_key_string(&key_value).ok_or_else(|| { + Stop::Internal(format!("non-identity map key {key_value:?}")) + })?; + let key_ident = Ident::new(&key_str) + .map_err(|e| Stop::Internal(format!("map key: {e}")))?; + let Some(Value::Record(map)) = txn.state.get_mut(field) else { + return Err(Stop::Internal(format!("`{field}` is not a map"))); + }; + txn.writes.push(serde_json::json!({ + "field": field.to_string(), + "key": key_str, + "value": value.to_json(), + })); + // `= none` removes the entry (§4.2). + if value == Value::None { + map.remove(&key_ident); + } else { + map.insert(key_ident, value); + } + } + } + } + ir::StmtIr::Send { + port, + command, + args, + bind, + } => { + let payload = Value::Record(eval_args(args, &txn, &bindings)?); + // Duplicate identical in-flight send → warning + // (§4.2: suppression is the author's guard's job; + // the machine only diagnoses). + let duplicate = self + .u + .pending + .values() + .chain(txn.pending_add.iter().map(|(_, p)| p)) + .any(|p| { + p.port == *port && p.command == *command && p.payload == payload + }); + if duplicate { + txn.warnings.push(Diagnostic::warning( + codes::DUPLICATE_IN_FLIGHT.0, + codes::DUPLICATE_IN_FLIGHT.1, + format!("`{command}` is already in flight with this payload"), + Span::new(FileId(0), 0, 0), + )); + } + let tag = txn.counters.mint_tag(); + txn.commands.push(CommandEnvelope { + port: port.clone(), + command: command.clone(), + correlation: format!("c-{tag}"), + payload: payload.to_json(), + }); + txn.pending_add.push(( + tag, + PendingCommand { + port: port.clone(), + command: command.clone(), + payload, + origin: scope.to_string(), + }, + )); + if let Some(bind) = bind { + bindings.push((bind.clone(), Value::Tag(tag))); + } + } + ir::StmtIr::OpenSurface { surface, args } => { + let props = eval_args(args, &txn, &bindings)?; + txn.ops.push(StagedOp::Open { + surface: surface.clone(), + props, + }); + } + ir::StmtIr::Dismiss => txn.ops.push(StagedOp::Dismiss), + ir::StmtIr::Navigate { route, args } => { + let params = eval_args(args, &txn, &bindings)?; + txn.ops.push(StagedOp::Navigate { + route: route.clone(), + params, + }); + } + ir::StmtIr::NavigateBack => txn.ops.push(StagedOp::Back), + } + Ok(()) + })(); + + match step { + Ok(()) => {} + // Body-position not-ready: atomic abort — no writes, no + // commands, counters rolled back (§4.2). + Err(Stop::NotReady(_)) => return Ok(TxnOutcome::Aborted), + Err(Stop::Internal(msg)) => return Err(EvalError(msg)), + } + } + Ok(TxnOutcome::Committed(txn)) + } + + // ── structural ops (applied at dispatch end, §4.2) ────────────────── + + fn apply_op( + &mut self, + op: StagedOp, + origin: &str, + trigger: Option<&Descriptor>, + ) -> Result<(), EvalError> { + match op { + StagedOp::Open { surface, props } => { + // Idempotent per (definition, canonical context). + if let Some(existing) = self + .u + .surfaces + .iter() + .find(|s| s.definition == surface && s.props == props) + { + self.structural.push(serde_json::json!({ + "op": "already-open", + "surface": format!("{}:{}", existing.definition, existing.serial), + })); + return Ok(()); + } + let def = self + .p + .surfaces + .get(&surface) + .ok_or_else(|| EvalError(format!("no surface `{surface}`")))?; + let restore_focus = match trigger { + Some(descriptor) => self.trigger_key_path(descriptor)?, + None => None, + }; + let serial = self.u.counters.mint_surface(); + self.structural.push(serde_json::json!({ + "op": "open-surface", + "surface": format!("{surface}:{serial}"), + "opener": origin, + })); + self.u.surfaces.push(SurfaceState { + serial, + definition: surface, + props, + state: initial_state(def), + opener: origin.to_string(), + restore_focus, + }); + self.sweep_orphans(); + } + StagedOp::Dismiss => { + let (_, serial) = Machine::parse_scope(origin) + .filter(|(kind, _)| *kind == "surface") + .ok_or_else(|| { + EvalError(format!("`dismiss` from non-surface scope `{origin}`")) + })?; + self.dismiss_surface(serial); + } + StagedOp::Navigate { route, params } => { + let def = self + .p + .pages + .get(&route) + .ok_or_else(|| EvalError(format!("no page for route `{route}`")))?; + let serial = self.u.counters.mint_page(); + self.structural.push(serde_json::json!({ + "op": "navigate", + "route": route.to_string(), + "serial": serial, + })); + self.u.nav.push(NavEntry { + serial, + route: route.clone(), + params: params.clone(), + state: initial_state(def), + }); + self.i.push(IntentEnvelope::HistoryPush { route, params }); + } + StagedOp::Back => { + if self.u.nav.len() < 2 { + self.structural + .push(serde_json::json!({ "op": "nav-underflow" })); + return Ok(()); + } + let popped = self.u.nav.pop().expect("len checked"); + self.sweep_orphans(); + self.structural.push(serde_json::json!({ + "op": "back", + "popped": popped.route.to_string(), + "to": self.u.nav.last().map(|e| e.route.to_string()), + })); + self.i.push(IntentEnvelope::HistoryBack); + } + } + Ok(()) + } + + /// Pops a surface instance; FocusRestore intent when it was topmost + /// (§4.2). Surfaces it opened force-close with it (the sweep). + fn dismiss_surface(&mut self, serial: u64) { + let Some(index) = self.u.surfaces.iter().position(|s| s.serial == serial) else { + return; // scope-alive acceptance makes this unreachable + }; + let was_top = index + 1 == self.u.surfaces.len(); + let removed = self.u.surfaces.remove(index); + self.structural.push(serde_json::json!({ + "op": "dismiss", + "surface": format!("{}:{}", removed.definition, removed.serial), + "top": was_top, + })); + if was_top && let Some(key_path) = &removed.restore_focus { + self.i.push(IntentEnvelope::FocusRestore { + key_path: key_path.clone(), + }); + } + self.sweep_orphans(); + } + + fn scope_mounted(&self, scope: &str) -> bool { + match Self::parse_scope(scope) { + Some(("page", serial)) => self.u.nav.iter().any(|e| e.serial == serial), + Some(("surface", serial)) => self.u.surfaces.iter().any(|s| s.serial == serial), + _ => false, + } + } + + /// Re-establishes the surface-stack invariant after every structural + /// op: a mounted surface's opener scope is alive. This is the §7.4 + /// force-close cascade — and it also catches a surface opened AFTER + /// its opener died within the same dispatch (dismiss-then-open). + fn sweep_orphans(&mut self) { + loop { + let Some(index) = self + .u + .surfaces + .iter() + .position(|s| !self.scope_mounted(&s.opener)) + else { + return; + }; + let removed = self.u.surfaces.remove(index); + self.structural.push(serde_json::json!({ + "op": "force-close", + "surface": format!("{}:{}", removed.definition, removed.serial), + })); + } + } + + // ── focus-restore node search ─────────────────────────────────────── + + /// Finds the key-path of the node that carried the dispatched + /// descriptor, searching the PRE-commit view (micro-decision #42: + /// descriptors match by (emit, scope, payload) — their semantic + /// identity; the commit's own writes must not move the target). + fn trigger_key_path(&mut self, descriptor: &Descriptor) -> Result<Option<String>, EvalError> { + let Some(snapshot) = self.pre_view.as_ref() else { + return Ok(None); // no Ui trigger captured a view + }; + let (kind, serial) = match Self::parse_scope(&descriptor.scope) { + Some(parsed) => parsed, + None => return Ok(None), + }; + let root = match kind { + "page" => &snapshot.page.root, + "surface" => { + let suffix = format!(":{serial}"); + match snapshot.surfaces.iter().find(|s| s.key.ends_with(&suffix)) { + Some(s) => &s.root, + None => return Ok(None), + } + } + _ => return Ok(None), + }; + Ok(find_descriptor(root, descriptor, &descriptor.scope)) + } +} + +fn find_descriptor(node: &Node, descriptor: &Descriptor, prefix: &str) -> Option<String> { + let path = format!("{prefix}/{}", node.key); + let matches = node.on.iter().any(|d| { + d.emit == descriptor.emit && d.scope == descriptor.scope && d.payload == descriptor.payload + }); + if matches { + return Some(path); + } + node.children + .iter() + .find_map(|child| find_descriptor(child, descriptor, &path)) +} + +// ── binding & payload typing ──────────────────────────────────────────────── + +enum BindSpec<'a> { + /// Semantic events bind by name from the decoded payload. + Named(&'a BTreeMap<Ident, Value>), + /// Outcome handlers bind positionally: `(tag, cmd[, refusal])` — + /// fixed shapes, author-chosen names (§4.2). + Positional(&'a [Value]), +} + +impl BindSpec<'_> { + fn bind(&self, params: &[Ident]) -> Vec<(Ident, Value)> { + match self { + BindSpec::Named(values) => params + .iter() + .map(|p| (p.clone(), values.get(p).cloned().unwrap_or(Value::None))) + .collect(), + BindSpec::Positional(values) => params + .iter() + .zip(values.iter()) + .map(|(p, v)| (p.clone(), v.clone())) + .collect(), + } + } +} + +/// The eligibility check + payload typing: (payload ∪ carried data) +/// fields must equal the declared signature exactly, each value decoding +/// against its declared type (§7.2). +fn bind_payload( + sig: &[ir::EventParamIr], + payload: &serde_json::Value, + data: Option<&Value>, +) -> Result<BTreeMap<Ident, Value>, String> { + let empty = serde_json::Map::new(); + let payload = match payload { + serde_json::Value::Object(map) => map, + serde_json::Value::Null => &empty, + other => return Err(format!("payload must be an object, got {other}")), + }; + let empty_data = BTreeMap::new(); + let data = match data { + None => &empty_data, + Some(Value::Record(fields)) => fields, + Some(other) => return Err(format!("carried data must be a record, got {other:?}")), + }; + + let mut out = BTreeMap::new(); + for param in sig { + if let Some(json) = payload.get(param.name.as_str()) { + let value = decode_value(json, &param.ty) + .map_err(|e| format!("payload field `{}`: {e}", param.name))?; + out.insert(param.name.clone(), value); + } else if let Some(value) = data.get(&param.name) { + out.insert(param.name.clone(), value.clone()); + } else if matches!(param.ty, ir::TyIr::Option(_)) { + out.insert(param.name.clone(), Value::None); + } else { + return Err(format!("payload is missing field `{}`", param.name)); + } + } + for key in payload.keys() { + if !sig.iter().any(|p| p.name.as_str() == key) { + return Err(format!("payload field `{key}` is not in the signature")); + } + } + for key in data.keys() { + if !sig.iter().any(|p| p.name == *key) { + return Err(format!("carried field `{key}` is not in the signature")); + } + } + Ok(out) +} + +/// Expression evaluation against staged transaction state — the shared +/// core of both dispatchers. +#[allow(clippy::too_many_arguments)] +fn eval_staged( + p: &ProgramIr, + x: &Projections, + scope: &str, + state: &BTreeMap<Ident, Value>, + props: &BTreeMap<Ident, Value>, + params: &BTreeMap<Ident, Value>, + bindings: &[(Ident, Value)], + e: &ir::ExprIr, +) -> Result<Value, Stop> { + let frame = Frame { + program: p, + x, + scope: scope.to_string(), + state, + props: props.clone(), + params: params.clone(), + bindings: bindings.to_vec(), + emits: EmitEnv::Machine, + }; + frame.eval_expr(e) +} + +// ── the transaction value ─────────────────────────────────────────────────── + +struct Txn { + state: BTreeMap<Ident, Value>, + counters: Counters, + pending_add: Vec<(u64, PendingCommand)>, + commands: Vec<CommandEnvelope>, + ops: Vec<StagedOp>, + writes: Vec<serde_json::Value>, + /// Runtime diagnostics (`G`) minted during the transaction. + warnings: Vec<Diagnostic>, +} + +enum TxnOutcome { + Committed(Txn), + Aborted, +} + +enum StagedOp { + Open { + surface: Ident, + props: BTreeMap<Ident, Value>, + }, + Dismiss, + Navigate { + route: Ident, + params: BTreeMap<Ident, Value>, + }, + Back, +} + +// ── fragment replay (derived surface/component examples, §6.2) ────────────── + +/// A machine-less dispatch context for standalone fragment previews: the +/// same guard selection and transaction semantics as `step_u`, minus +/// structure — a structural statement in a fragment replay has nowhere to +/// act and surfaces as an error the checker turns into "pin this state +/// instead". +#[derive(Clone, Debug, Default)] +pub struct FragmentMachine { + pub state: BTreeMap<Ident, Value>, + pub pending: BTreeMap<u64, PendingCommand>, + pub counters: Counters, +} + +/// What one fragment dispatch did (the caller owns trace presentation). +#[derive(Clone, Debug)] +pub enum FragmentNote { + Committed { commands: Vec<CommandEnvelope> }, + NoHandler, + NotReady, +} + +pub const FRAGMENT_SCOPE: &str = "fragment:0"; + +#[derive(Clone, Debug)] +pub enum FragmentError { + /// The handler ran a structural statement — not replayable standalone. + Structural(&'static str), + Invariant(String), +} + +impl std::fmt::Display for FragmentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FragmentError::Structural(stmt) => { + write!(f, "`{stmt}` needs a mounted machine") + } + FragmentError::Invariant(msg) => write!(f, "{msg}"), + } + } +} + +impl FragmentMachine { + pub fn from_state(state: BTreeMap<Ident, Value>) -> FragmentMachine { + FragmentMachine { + state, + pending: BTreeMap::new(), + counters: Counters::default(), + } + } + + /// Dispatches a semantic event with an already-typed payload. + pub fn dispatch_semantic( + &mut self, + p: &ProgramIr, + def: &DefIr, + props: &BTreeMap<Ident, Value>, + x: &Projections, + event: &Ident, + payload: &BTreeMap<Ident, Value>, + ) -> Result<FragmentNote, FragmentError> { + self.dispatch( + p, + def, + props, + x, + |h| matches!(&h.on, ir::EventKeyIr::Semantic { event: e } if e == event), + &BindSpec::Named(payload), + ) + } + + /// Settles a pending command (the oldest matching one is the CALLER's + /// choice — it holds the correlation rule, §6.2). + pub fn dispatch_outcome( + &mut self, + p: &ProgramIr, + def: &DefIr, + props: &BTreeMap<Ident, Value>, + x: &Projections, + tag: u64, + result: &OutcomeResult, + ) -> Result<FragmentNote, FragmentError> { + let Some(pending) = self.pending.remove(&tag) else { + return Err(FragmentError::Invariant(format!( + "no pending command t-{tag}" + ))); + }; + let (which, refusal) = match result { + OutcomeResult::Ok => (ir::OutcomeKindIr::Ok, None), + OutcomeResult::Refused { refusal } => { + (ir::OutcomeKindIr::Err, Some(refusal.to_string())) + } + OutcomeResult::Unavailable { .. } => { + (ir::OutcomeKindIr::Err, Some("unavailable".to_string())) + } + }; + let mut positional = vec![Value::Tag(tag), pending.payload.clone()]; + if let Some(refusal) = &refusal { + positional.push(Value::Text(refusal.clone())); + } + self.dispatch( + p, + def, + props, + x, + |h| { + matches!(&h.on, ir::EventKeyIr::Outcome { command, which: w } + if *command == pending.command && *w == which) + }, + &BindSpec::Positional(&positional), + ) + } + + fn dispatch( + &mut self, + p: &ProgramIr, + def: &DefIr, + props: &BTreeMap<Ident, Value>, + x: &Projections, + matches_handler: impl Fn(&ir::HandlerIr) -> bool, + bind: &BindSpec<'_>, + ) -> Result<FragmentNote, FragmentError> { + let params = BTreeMap::new(); + let mut selected = None; + for handler in def.handlers.iter().filter(|h| matches_handler(h)) { + let bindings = bind.bind(&handler.params); + let satisfied = match &handler.guard { + None => true, + Some(guard) => { + let frame = Frame { + program: p, + x, + scope: FRAGMENT_SCOPE.to_string(), + state: &self.state, + props: props.clone(), + params: params.clone(), + bindings: bindings.clone(), + emits: EmitEnv::Machine, + }; + match frame.eval_expr(guard) { + Ok(Value::Bool(b)) => b, + Ok(other) => { + return Err(FragmentError::Invariant(format!("guard was {other:?}"))); + } + Err(Stop::NotReady(_)) => false, + Err(Stop::Internal(msg)) => return Err(FragmentError::Invariant(msg)), + } + } + }; + if satisfied { + selected = Some((handler, bindings)); + break; + } + } + let Some((handler, mut bindings)) = selected else { + return Ok(FragmentNote::NoHandler); + }; + + // The same staged transaction as `step_u`, structural ops refused. + let mut state = self.state.clone(); + let mut counters = self.counters; + let mut pending_add = Vec::new(); + let mut commands = Vec::new(); + for stmt in &handler.body { + let eval = + |e: &ir::ExprIr, state: &BTreeMap<Ident, Value>, bindings: &[(Ident, Value)]| { + eval_staged(p, x, FRAGMENT_SCOPE, state, props, &params, bindings, e) + }; + let result: Result<(), Stop> = (|| match stmt { + ir::StmtIr::Set { field, key, value } => { + let key_value = match key { + Some(k) => Some(eval(k, &state, &bindings)?), + None => None, + }; + let value = eval(value, &state, &bindings)?; + match key_value { + None => { + state.insert(field.clone(), value); + } + Some(key_value) => { + let key_str = map_key_string(&key_value).ok_or_else(|| { + Stop::Internal("non-identity map key".to_string()) + })?; + let key_ident = Ident::new(&key_str) + .map_err(|e| Stop::Internal(format!("map key: {e}")))?; + let Some(Value::Record(map)) = state.get_mut(field) else { + return Err(Stop::Internal(format!("`{field}` is not a map"))); + }; + if value == Value::None { + map.remove(&key_ident); + } else { + map.insert(key_ident, value); + } + } + } + Ok(()) + } + ir::StmtIr::Send { + port, + command, + args, + bind, + } => { + let mut payload = BTreeMap::new(); + for arg in args { + payload.insert(arg.name.clone(), eval(&arg.value, &state, &bindings)?); + } + let payload = Value::Record(payload); + let tag = counters.mint_tag(); + commands.push(CommandEnvelope { + port: port.clone(), + command: command.clone(), + correlation: format!("c-{tag}"), + payload: payload.to_json(), + }); + pending_add.push(( + tag, + PendingCommand { + port: port.clone(), + command: command.clone(), + payload, + origin: FRAGMENT_SCOPE.to_string(), + }, + )); + if let Some(bind) = bind { + bindings.push((bind.clone(), Value::Tag(tag))); + } + Ok(()) + } + ir::StmtIr::OpenSurface { .. } => Err(Stop::Internal("open-surface".to_string())), + ir::StmtIr::Dismiss => Err(Stop::Internal("dismiss".to_string())), + ir::StmtIr::Navigate { .. } => Err(Stop::Internal("navigate".to_string())), + ir::StmtIr::NavigateBack => Err(Stop::Internal("navigate back".to_string())), + })(); + match result { + Ok(()) => {} + Err(Stop::NotReady(_)) => return Ok(FragmentNote::NotReady), + Err(Stop::Internal(msg)) + if matches!( + stmt, + ir::StmtIr::OpenSurface { .. } + | ir::StmtIr::Dismiss + | ir::StmtIr::Navigate { .. } + | ir::StmtIr::NavigateBack + ) => + { + let stmt_name: &'static str = match stmt { + ir::StmtIr::OpenSurface { .. } => "open-surface", + ir::StmtIr::Dismiss => "dismiss", + ir::StmtIr::Navigate { .. } => "navigate", + ir::StmtIr::NavigateBack => "navigate back", + _ => unreachable!(), + }; + let _ = msg; + return Err(FragmentError::Structural(stmt_name)); + } + Err(Stop::Internal(msg)) => return Err(FragmentError::Invariant(msg)), + } + } + + self.state = state; + self.counters = counters; + self.pending.extend(pending_add); + Ok(FragmentNote::Committed { commands }) + } +} diff --git a/uhura/crates/uhura-core/src/trace.rs b/uhura/crates/uhura-core/src/trace.rs new file mode 100644 index 0000000..a2c46ad --- /dev/null +++ b/uhura/crates/uhura-core/src/trace.rs @@ -0,0 +1,170 @@ +//! `StepTrace` — one canonical JSONL record per step, the conformance +//! artifact goldens pin (design §7.5): the input event, the dispatch +//! record (per-handler guard results, committed writes), structural ops, +//! `C`/`I`, drops, and the `u-hash`/`v-hash` pair. + +use uhura_base::{Ident, to_canonical_json}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StepTrace { + /// The input event, wire form. + pub event: serde_json::Value, + /// Harness-filled: what `apply_updates` did to `X` immediately before + /// this step (§7.2 ordering made visible). + pub applies: Vec<serde_json::Value>, + pub disposition: Disposition, + /// Structural ops in application order (`init`, `open-surface`, + /// `already-open`, `dismiss`, `navigate`, `back`, `nav-underflow`). + pub structural: Vec<serde_json::Value>, + /// Emitted command envelopes, wire form. + pub c: Vec<serde_json::Value>, + /// Emitted intents, wire form. + pub i: Vec<serde_json::Value>, + /// Runtime diagnostics (`G` — §7.5 lists them in the record). + pub g: Vec<serde_json::Value>, + pub u_hash: String, + pub v_hash: String, + /// `--expanded` presentation only: the full snapshot. + pub v: Option<serde_json::Value>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Disposition { + /// Nothing dispatches (projection deliveries, `Init`). + Delivery, + /// Dropped before any handler ran (§7.2 acceptance, unknown + /// correlation, unmounted outcome origin). + Dropped { reason: DropReason, detail: String }, + /// The reserved structural `dismiss` event (micro-decision #36). + Reserved { scope: String }, + /// Handlers were consulted (selected or not — `no-handler` and + /// `projection-not-ready` outcomes live inside the record). + Dispatched(DispatchRecord), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DropReason { + StaleScope, + Occluded, + Ineligible, + StaleOutcome, + UnknownCorrelation, +} + +impl DropReason { + pub fn as_str(self) -> &'static str { + match self { + DropReason::StaleScope => "stale-scope", + DropReason::Occluded => "occluded", + DropReason::Ineligible => "ineligible", + DropReason::StaleOutcome => "stale-outcome", + DropReason::UnknownCorrelation => "unknown-correlation", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DispatchRecord { + pub scope: String, + pub definition: Ident, + /// `"like-toggled"` | `"like-post.ok"` | `"like-post.err"`. + pub on: String, + /// Per matched handler, source order, up to and including the + /// selected one. + pub guards: Vec<GuardNote>, + /// Absolute handler index within the definition, when one ran. + pub selected: Option<usize>, + /// Committed writes in execution order (empty when aborted/dropped). + pub writes: Vec<serde_json::Value>, + /// `projection-not-ready`: the body read an undelivered projection — + /// nothing committed (§4.2 transactional backstop). + pub aborted: Option<String>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GuardNote { + /// Absolute handler index within the definition. + pub handler: usize, + pub result: GuardResult, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GuardResult { + Satisfied, + Unsatisfied, + /// A guard-position not-ready read: the guard is false (§4.2). + NotReady, +} + +impl StepTrace { + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::json!({ + "event": self.event, + "u-hash": self.u_hash, + "v-hash": self.v_hash, + }); + if !self.applies.is_empty() { + obj["applies"] = self.applies.clone().into(); + } + match &self.disposition { + Disposition::Delivery => {} + Disposition::Dropped { reason, detail } => { + obj["drop"] = serde_json::Value::String(reason.as_str().into()); + if !detail.is_empty() { + obj["drop-detail"] = serde_json::Value::String(detail.clone()); + } + } + Disposition::Reserved { scope } => { + obj["reserved"] = serde_json::json!({ "event": "dismiss", "scope": scope }); + } + Disposition::Dispatched(record) => { + let mut dispatch = serde_json::json!({ + "scope": record.scope, + "definition": record.definition.to_string(), + "on": record.on, + "guards": record.guards.iter().map(|g| serde_json::json!({ + "handler": g.handler, + "guard": match g.result { + GuardResult::Satisfied => "satisfied", + GuardResult::Unsatisfied => "unsatisfied", + GuardResult::NotReady => "not-ready", + }, + })).collect::<Vec<_>>(), + "selected": record.selected, + }); + if !record.writes.is_empty() { + dispatch["writes"] = record.writes.clone().into(); + } + if record.selected.is_none() { + obj["drop"] = serde_json::Value::String("no-handler".into()); + } + if let Some(reason) = &record.aborted { + dispatch["aborted"] = serde_json::Value::String(reason.clone()); + obj["drop"] = serde_json::Value::String(reason.clone()); + } + obj["dispatch"] = dispatch; + } + } + if !self.structural.is_empty() { + obj["structural"] = self.structural.clone().into(); + } + if !self.c.is_empty() { + obj["c"] = self.c.clone().into(); + } + if !self.i.is_empty() { + obj["i"] = self.i.clone().into(); + } + if !self.g.is_empty() { + obj["g"] = self.g.clone().into(); + } + if let Some(v) = &self.v { + obj["v"] = v.clone(); + } + obj + } + + /// The JSONL line (canonical: sorted keys, no floats by construction). + pub fn to_line(&self) -> String { + to_canonical_json(&self.to_json()) + } +} diff --git a/uhura/crates/uhura-core/src/view.rs b/uhura/crates/uhura-core/src/view.rs new file mode 100644 index 0000000..f919eb0 --- /dev/null +++ b/uhura/crates/uhura-core/src/view.rs @@ -0,0 +1,251 @@ +//! The semantic view protocol `uhura-view/0` (design §8.1) — the full- +//! snapshot value both renderers consume. Hand-written JSON shapes (plan +//! micro-decision #14): the wire form IS the contract, so no serde-derived +//! drift can occur. + +use std::collections::BTreeMap; + +use uhura_base::{Ident, hash_json, to_canonical_json}; + +pub const VIEW_PROTOCOL: &str = "uhura-view/0"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snapshot { + pub revision: u64, + pub page: PageView, + /// Bottom → top. + pub surfaces: Vec<SurfaceView>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PageView { + pub route: String, + pub root: Node, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SurfaceView { + /// `"comments-sheet:2"` (definition:serial). + pub key: String, + pub definition: String, + pub modality: String, + /// Key-path of the node focus returns to on dismiss. + pub restore_focus: Option<String>, + /// First-class: Escape/scrim wire here (§8.1). + pub dismiss: Descriptor, + pub root: Node, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Node { + /// Sibling-unique; the key-path is global identity (§8.1). + pub key: String, + /// Catalog element name. + pub element: Ident, + /// Authored CSS classes, passed through verbatim. + pub class: Option<String>, + /// SEMANTIC props only. + pub props: BTreeMap<Ident, VValue>, + pub children: Vec<Node>, + pub on: Vec<Descriptor>, +} + +/// §8.1 Value: bool | i64 | text | inert human text | asset reference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VValue { + Bool(bool), + Int(i64), + /// Bare semantic token (`role`, `direction`, icon names). + Text(String), + /// `{ t: "plain", v: text }` — inert human text, never markup. + Plain(String), + /// `{ t: "image", asset: text }` — opaque asset id; core never fetches. + Image(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Descriptor { + pub kind: DescriptorKind, + /// The catalog event (`press`, `near-end`, `change`). + pub event: Ident, + /// The machine event this emits. + pub emit: Ident, + /// `"page:1"` | `"surface:2"` — minted serials. + pub scope: String, + /// PREBUILT by core — inert JSON, echoed verbatim (§8.1). + pub payload: serde_json::Value, + /// Renderer-carried fields (`{ value: "text" }`). + pub carries: BTreeMap<Ident, String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DescriptorKind { + Input, + Observe, +} + +impl VValue { + pub fn to_json(&self) -> serde_json::Value { + use serde_json::json; + match self { + VValue::Bool(b) => json!(b), + VValue::Int(i) => json!(i), + VValue::Text(s) => json!(s), + VValue::Plain(s) => json!({ "t": "plain", "v": s }), + VValue::Image(asset) => json!({ "t": "image", "asset": asset }), + } + } +} + +impl Descriptor { + /// Parses the wire form back (the play shell echoes descriptors + /// verbatim in `ui` events — §8.1/§12.3). Tolerant of extra fields, + /// strict on the ones that matter. + pub fn from_json(json: &serde_json::Value) -> Result<Self, String> { + let str_field = |field: &str| -> Result<&str, String> { + json.get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("a descriptor needs a text `{field}`")) + }; + let kind = match str_field("kind")? { + "input" => DescriptorKind::Input, + "observe" => DescriptorKind::Observe, + other => return Err(format!("`{other}` is not a descriptor kind")), + }; + let ident_field = |field: &str| -> Result<Ident, String> { + Ident::new(str_field(field)?).map_err(|e| format!("descriptor `{field}`: {e}")) + }; + let mut carries = BTreeMap::new(); + match json.get("carries") { + None => {} + Some(serde_json::Value::Object(map)) => { + for (field, ty) in map { + let field = Ident::new(field).map_err(|e| e.to_string())?; + let ty = ty + .as_str() + .ok_or("a `carries` entry names its shape as text")?; + carries.insert(field, ty.to_string()); + } + } + Some(_) => return Err("`carries` must be an object".into()), + } + Ok(Descriptor { + kind, + event: ident_field("event")?, + emit: ident_field("emit")?, + scope: str_field("scope")?.to_string(), + payload: json + .get("payload") + .cloned() + .ok_or("a descriptor needs a `payload`")?, + carries, + }) + } + + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::json!({ + "kind": match self.kind { + DescriptorKind::Input => "input", + DescriptorKind::Observe => "observe", + }, + "event": self.event.to_string(), + "emit": self.emit.to_string(), + "scope": self.scope, + "payload": self.payload, + }); + if !self.carries.is_empty() { + obj["carries"] = self + .carries + .iter() + .map(|(f, ty)| (f.to_string(), serde_json::Value::String(ty.clone()))) + .collect::<serde_json::Map<_, _>>() + .into(); + } + obj + } +} + +impl Node { + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::json!({ + "key": self.key, + "element": self.element.to_string(), + "props": self + .props + .iter() + .map(|(name, v)| (name.to_string(), v.to_json())) + .collect::<serde_json::Map<_, _>>(), + }); + if let Some(class) = &self.class { + obj["class"] = serde_json::Value::String(class.clone()); + } + if !self.children.is_empty() { + obj["children"] = self.children.iter().map(Node::to_json).collect(); + } + if !self.on.is_empty() { + obj["on"] = self.on.iter().map(Descriptor::to_json).collect(); + } + obj + } +} + +impl Snapshot { + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "protocol": VIEW_PROTOCOL, + "revision": self.revision, + "page": { + "route": self.page.route, + "root": self.page.root.to_json(), + }, + "surfaces": self + .surfaces + .iter() + .map(|s| { + let mut obj = serde_json::json!({ + "key": s.key, + "definition": s.definition, + "modality": s.modality, + "dismiss": s.dismiss.to_json(), + "root": s.root.to_json(), + }); + if let Some(rf) = &s.restore_focus { + obj["restore-focus"] = serde_json::Value::String(rf.clone()); + } + obj + }) + .collect::<Vec<_>>(), + }) + } + + /// Canonical byte form (sorted keys, no floats by construction). + pub fn to_canonical_string(&self) -> String { + to_canonical_json(&self.to_json()) + } + + /// The `v-hash` that traces and goldens pin (§7.5). + pub fn v_hash(&self) -> String { + hash_json(&self.to_json()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn value_shapes_match_the_design() { + assert_eq!( + VValue::Plain("hi".into()).to_json().to_string(), + r#"{"t":"plain","v":"hi"}"# + ); + assert_eq!( + VValue::Image("avatar-mira".into()).to_json().to_string(), + r#"{"asset":"avatar-mira","t":"image"}"# + ); + assert_eq!( + VValue::Text("list".into()).to_json().to_string(), + r#""list""# + ); + } +} diff --git a/uhura/crates/uhura-core/tests/txn_matrix.rs b/uhura/crates/uhura-core/tests/txn_matrix.rs new file mode 100644 index 0000000..6965641 --- /dev/null +++ b/uhura/crates/uhura-core/tests/txn_matrix.rs @@ -0,0 +1,1282 @@ +//! The transactional-dispatch test matrix (plan risk #3) — written from +//! design §4.2/§7.2 BEFORE the interpreter, against a synthetic program. +//! +//! Axes covered: +//! - abort point × statement: guard not-ready (guard is false), body +//! not-ready after a write / after a send (atomic abort: no writes, no +//! pending, no commands, counters rolled back), clean commit per +//! statement kind (set field / set map / send-with-bind / open-surface / +//! dismiss / navigate / back); +//! - scope: the same txn rules from page and surface origin; +//! - acceptance (§7.2, in order): stale-scope, occluded, ineligible — +//! with stale `view_rev` ACCEPTED; +//! - multi-handler guard selection: source order, first satisfied wins, +//! none ⇒ dropped `no-handler`; +//! - outcome routing: `.ok`/`.err` by origin, `refusal` binding +//! (`unavailable` routes to `.err`), unknown correlation, unmounted +//! origin ⇒ `stale-outcome` with pending removed; +//! - structure: idempotent open per (definition, canonical context), +//! dismiss + FocusRestore when topmost, navigate/back with History +//! intents, force-close cascade, nav underflow; +//! - `rev + 1` always — commits, aborts, and every drop. + +use std::collections::BTreeMap; + +use uhura_base::{Ident, Value}; +use uhura_core::event::Event; +use uhura_core::ir::{self, ProgramIr, TyIr}; +use uhura_core::state::{Projections, UiState}; +use uhura_core::step::{StepResult, step_u}; +use uhura_core::trace::{Disposition, DropReason, GuardResult}; +use uhura_core::view::{Descriptor, DescriptorKind}; +use uhura_port::envelope::OutcomeResult; + +fn ident(s: &str) -> Ident { + Ident::new(s).unwrap() +} + +// ── the synthetic program ─────────────────────────────────────────────────── + +fn set(field: &str, key: Option<ir::ExprIr>, value: ir::ExprIr) -> ir::StmtIr { + ir::StmtIr::Set { + field: ident(field), + key, + value, + } +} + +fn send_ping(k: ir::ExprIr, bind: Option<&str>) -> ir::StmtIr { + ir::StmtIr::Send { + port: ident("svc"), + command: ident("ping"), + args: vec![ir::ArgIr { + name: ident("k"), + value: k, + }], + bind: bind.map(ident), + } +} + +fn handler( + event: &str, + params: &[&str], + guard: Option<ir::ExprIr>, + body: Vec<ir::StmtIr>, +) -> ir::HandlerIr { + ir::HandlerIr { + on: ir::EventKeyIr::Semantic { + event: ident(event), + }, + params: params.iter().map(|p| ident(p)).collect(), + guard, + body, + } +} + +fn outcome_handler(command: &str, which: ir::OutcomeKindIr, params: &[&str]) -> ir::HandlerIr { + ir::HandlerIr { + on: ir::EventKeyIr::Outcome { + command: ident(command), + which, + }, + params: params.iter().map(|p| ident(p)).collect(), + guard: None, + body: vec![], + } +} + +fn view_root(children: Vec<ir::NodeIr>) -> ir::NodeIr { + ir::NodeIr::Element(ir::ElementIr { + element: ident("view"), + ord: 0, + class: None, + props: vec![], + events: vec![], + text: vec![], + children, + }) +} + +fn program() -> ProgramIr { + use ir::ExprIr as E; + + let binding = |name: &str| E::BindingRef(ident(name)); + let state_ref = |name: &str| E::StateRef(ident(name)); + let eq = |lhs: E, rhs: E| E::Binary { + op: ir::BinaryOpIr::Eq, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }; + let add = |lhs: E, rhs: E| E::Binary { + op: ir::BinaryOpIr::Add, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }; + + let mut home_events = BTreeMap::new(); + for (event, params) in [ + ("bump", vec![]), + ("seq", vec![]), + ("bump-if-ready", vec![]), + ("write-then-stall", vec![]), + ("send-ping", vec![(ident("x"), TyIr::Int)]), + ("open-panel", vec![(ident("p"), TyIr::Id)]), + ("flip-open", vec![(ident("c"), TyIr::Bool)]), + ("nav-other", vec![]), + ("nav-back", vec![]), + ("multi", vec![(ident("x"), TyIr::Int)]), + ] { + home_events.insert( + ident(event), + params + .into_iter() + .map(|(name, ty)| ir::EventParamIr { name, ty }) + .collect(), + ); + } + + let home = ir::DefIr { + modality: None, + props: vec![], + emits: vec![], + params: vec![], + state: BTreeMap::from([ + (ident("n"), ir::InitValue::Int(0)), + (ident("m"), ir::InitValue::EmptyMap), + (ident("note"), ir::InitValue::None), + (ident("flag"), ir::InitValue::Bool(false)), + ]), + events: home_events, + handlers: vec![ + // 0 — set field commit + handler( + "bump", + &[], + None, + vec![set("n", None, add(state_ref("n"), E::Int(1)))], + ), + // 1 — writes visible sequentially within the handler + handler( + "seq", + &[], + None, + vec![ + set("n", None, E::Int(1)), + set("n", None, add(state_ref("n"), state_ref("n"))), + ], + ), + // 2 — guard-position not-ready ⇒ guard is false + handler( + "bump-if-ready", + &[], + Some(eq( + E::ProjectionRef(ident("spare-proj")), + E::Text("go".into()), + )), + vec![set("n", None, E::Int(42))], + ), + // 3 — body not-ready after a write and a send ⇒ atomic abort + handler( + "write-then-stall", + &[], + None, + vec![ + set("n", None, E::Int(5)), + send_ping(E::Int(9), None), + set("note", None, E::ProjectionRef(ident("spare-proj"))), + ], + ), + // 4 — send with tag bind + tag-keyed map write + handler( + "send-ping", + &["x"], + None, + vec![ + send_ping(binding("x"), Some("t")), + set("m", Some(binding("t")), E::Bool(true)), + ], + ), + // 5 — open-surface with canonical context from the payload + handler( + "open-panel", + &["p"], + None, + vec![ir::StmtIr::OpenSurface { + surface: ident("panel"), + args: vec![ir::ArgIr { + name: ident("who"), + value: binding("p"), + }], + }], + ), + // Writes state its own trigger's payload reads, THEN opens — + // the focus search must see the pre-commit view (§4.2). + handler( + "flip-open", + &["c"], + None, + vec![ + set( + "flag", + None, + E::Unary { + op: ir::UnaryOpIr::Not, + expr: Box::new(binding("c")), + }, + ), + ir::StmtIr::OpenSurface { + surface: ident("panel"), + args: vec![ir::ArgIr { + name: ident("who"), + value: E::Text("f".into()), + }], + }, + ], + ), + // 6 / 7 — navigation + handler( + "nav-other", + &[], + None, + vec![ir::StmtIr::Navigate { + route: ident("other"), + args: vec![], + }], + ), + handler("nav-back", &[], None, vec![ir::StmtIr::NavigateBack]), + // 8 / 9 — multi-handler: source order, first satisfied guard + handler( + "multi", + &["x"], + Some(eq(binding("x"), E::Int(1))), + vec![set("n", None, E::Int(1))], + ), + handler( + "multi", + &["x"], + Some(eq(binding("x"), E::Int(2))), + vec![set("n", None, E::Int(2))], + ), + // 10 / 11 — outcome handlers + { + let mut h = outcome_handler("ping", ir::OutcomeKindIr::Ok, &["tag", "cmd"]); + h.body = vec![ + set("m", Some(binding("tag")), E::None), + set( + "n", + None, + E::Field { + base: Box::new(binding("cmd")), + name: ident("k"), + }, + ), + ]; + h + }, + { + let mut h = + outcome_handler("ping", ir::OutcomeKindIr::Err, &["tag", "cmd", "refusal"]); + h.body = vec![set("note", None, binding("refusal"))]; + h + }, + ], + root: view_root(vec![ + ir::NodeIr::Element(ir::ElementIr { + element: ident("button"), + ord: 1, + class: None, + props: vec![], + events: vec![ir::ElementEventBindingIr { + event: ident("press"), + emit: ident("open-panel"), + args: vec![ir::ArgIr { + name: ident("p"), + value: E::Text("a".into()), + }], + }], + text: vec![], + children: vec![], + }), + ir::NodeIr::Element(ir::ElementIr { + element: ident("button"), + ord: 2, + class: None, + props: vec![], + events: vec![ir::ElementEventBindingIr { + event: ident("press"), + emit: ident("flip-open"), + args: vec![ir::ArgIr { + name: ident("c"), + value: E::StateRef(ident("flag")), + }], + }], + text: vec![], + children: vec![], + }), + ]), + }; + + let panel = ir::DefIr { + modality: Some("sheet".into()), + props: vec![ident("who")], + emits: vec![], + params: vec![], + state: BTreeMap::from([(ident("c"), ir::InitValue::Int(0))]), + events: BTreeMap::from([ + (ident("close"), vec![]), + (ident("panel-bump"), vec![]), + (ident("panel-send"), vec![]), + (ident("panel-stall"), vec![]), + (ident("panel-back"), vec![]), + (ident("reopen"), vec![]), + (ident("respawn"), vec![]), + ]), + handlers: vec![ + handler("close", &[], None, vec![ir::StmtIr::Dismiss]), + handler("panel-back", &[], None, vec![ir::StmtIr::NavigateBack]), + // Opens its own (definition, context) — the idempotence cell. + handler( + "reopen", + &[], + None, + vec![ir::StmtIr::OpenSurface { + surface: ident("panel"), + args: vec![ir::ArgIr { + name: ident("who"), + value: ir::ExprIr::PropRef(ident("who")), + }], + }], + ), + // Dismisses itself, then opens — the opener dies mid-dispatch. + handler( + "respawn", + &[], + None, + vec![ + ir::StmtIr::Dismiss, + ir::StmtIr::OpenSurface { + surface: ident("panel"), + args: vec![ir::ArgIr { + name: ident("who"), + value: ir::ExprIr::Text("r".into()), + }], + }, + ], + ), + handler( + "panel-bump", + &[], + None, + vec![set("c", None, add(state_ref("c"), E::Int(1)))], + ), + handler("panel-send", &[], None, vec![send_ping(E::Int(1), None)]), + handler( + "panel-stall", + &[], + None, + vec![ + set("c", None, E::Int(99)), + set("c", None, E::ProjectionRef(ident("spare-proj"))), + ], + ), + ], + root: view_root(vec![]), + }; + + let other = ir::DefIr { + modality: None, + props: vec![], + emits: vec![], + params: vec![], + state: BTreeMap::new(), + events: BTreeMap::new(), + handlers: vec![], + root: view_root(vec![]), + }; + + let projection = |port: &str| ir::ProjectionIr { + port: ident(port), + boot: false, + ty: TyIr::Text, + key: None, + }; + + ProgramIr { + protocol: ir::IR_PROTOCOL.to_string(), + app: ident("matrix"), + entry: ident("home"), + catalog: ir::CatalogPin { + name: ident("base"), + version: "0.1.0".into(), + hash: "0".repeat(64), + }, + ports: BTreeMap::from([( + ident("svc"), + ir::PortPin { + version: "0.1.0".into(), + hash: "0".repeat(64), + }, + )]), + projections: BTreeMap::from([ + (ident("ready-proj"), projection("svc")), + (ident("spare-proj"), projection("svc")), + ]), + element_events: BTreeMap::from([( + ident("button"), + BTreeMap::from([( + ident("press"), + ir::ElementEventIr { + kind: ir::EventKindIr::Input, + carries: BTreeMap::new(), + }, + )]), + )]), + element_props: BTreeMap::new(), + routes: BTreeMap::from([ + ( + ident("home"), + ir::RouteIr { + segments: vec![ir::RouteSegIr::Static("home".into())], + params: vec![], + }, + ), + ( + ident("other"), + ir::RouteIr { + segments: vec![ir::RouteSegIr::Static("other".into())], + params: vec![], + }, + ), + ]), + pages: BTreeMap::from([(ident("home"), home), (ident("other"), other)]), + components: BTreeMap::new(), + surfaces: BTreeMap::from([(ident("panel"), panel)]), + } +} + +// ── harness helpers ───────────────────────────────────────────────────────── + +fn projections() -> Projections { + let mut x = Projections::default(); + x.snapshots.insert( + (ident("ready-proj"), None), + uhura_core::state::ProjectionSnapshot { + revision: 1, + value: Value::Text("go".into()), + }, + ); + x +} + +fn ui(emit: &str, scope: &str, payload: serde_json::Value, view_rev: u64) -> Event { + Event::Ui { + descriptor: Descriptor { + kind: DescriptorKind::Input, + event: ident("press"), + emit: ident(emit), + scope: scope.to_string(), + payload, + carries: BTreeMap::new(), + }, + data: None, + view_rev, + } +} + +fn booted(p: &ProgramIr) -> UiState { + let x = projections(); + step_u( + p, + UiState::boot(), + &x, + Event::Init { + route: ident("home"), + params: BTreeMap::new(), + }, + ) + .expect("init") + .u +} + +fn run(p: &ProgramIr, u: UiState, e: Event) -> StepResult { + let x = projections(); + step_u(p, u, &x, e).expect("step") +} + +fn page_state<'a>(u: &'a UiState, field: &str) -> &'a Value { + &u.nav.last().unwrap().state[&ident(field)] +} + +fn drop_reason(r: &StepResult) -> Option<DropReason> { + match &r.t.disposition { + Disposition::Dropped { reason, .. } => Some(*reason), + _ => None, + } +} + +fn open_panel(p: &ProgramIr, u: UiState, who: &str) -> StepResult { + run( + p, + u, + ui("open-panel", "page:1", serde_json::json!({ "p": who }), 1), + ) +} + +// ── the commit / abort table ──────────────────────────────────────────────── + +struct TxnCase { + name: &'static str, + /// (emit, scope, payload) — dispatched onto the freshly booted state + /// (page cases) or a freshly opened panel (surface cases). + surface: bool, + emit: &'static str, + payload: serde_json::Value, + expect: TxnExpect, +} + +enum TxnExpect { + /// (state assertions, pending count, tag counter, commands emitted) + Commit { + writes: usize, + pending: usize, + tag: u64, + commands: usize, + }, + /// `projection-not-ready`: nothing commits, counters roll back. + Abort, + /// No guard satisfied — dropped, guards recorded. + NoHandler, +} + +#[test] +fn the_commit_abort_table() { + let p = program(); + let cases = [ + TxnCase { + name: "set-field commits from page scope", + surface: false, + emit: "bump", + payload: serde_json::json!({}), + expect: TxnExpect::Commit { + writes: 1, + pending: 0, + tag: 0, + commands: 0, + }, + }, + TxnCase { + name: "send + tag bind + map write commit together", + surface: false, + emit: "send-ping", + payload: serde_json::json!({ "x": 7 }), + expect: TxnExpect::Commit { + writes: 1, + pending: 1, + tag: 1, + commands: 1, + }, + }, + TxnCase { + name: "guard-position not-ready reads as false", + surface: false, + emit: "bump-if-ready", + payload: serde_json::json!({}), + expect: TxnExpect::NoHandler, + }, + TxnCase { + name: "body not-ready aborts atomically after write and send", + surface: false, + emit: "write-then-stall", + payload: serde_json::json!({}), + expect: TxnExpect::Abort, + }, + TxnCase { + name: "set-field commits from surface scope", + surface: true, + emit: "panel-bump", + payload: serde_json::json!({}), + expect: TxnExpect::Commit { + writes: 1, + pending: 0, + tag: 0, + commands: 0, + }, + }, + TxnCase { + name: "send commits from surface scope", + surface: true, + emit: "panel-send", + payload: serde_json::json!({}), + expect: TxnExpect::Commit { + writes: 0, + pending: 1, + tag: 1, + commands: 1, + }, + }, + TxnCase { + name: "body not-ready aborts from surface scope", + surface: true, + emit: "panel-stall", + payload: serde_json::json!({}), + expect: TxnExpect::Abort, + }, + ]; + + for case in cases { + let u0 = if case.surface { + open_panel(&p, booted(&p), "a").u + } else { + booted(&p) + }; + let scope = if case.surface { "surface:1" } else { "page:1" }; + let tag_before = u0.counters.tag; + let rev_before = u0.rev; + let state_before = if case.surface { + u0.surfaces.last().unwrap().state.clone() + } else { + u0.nav.last().unwrap().state.clone() + }; + let r = run( + &p, + u0, + ui(case.emit, scope, case.payload.clone(), rev_before), + ); + + assert_eq!(r.u.rev, rev_before + 1, "{}: rev+1 always", case.name); + assert_eq!(r.v.revision, r.u.rev, "{}: v.revision == u.rev", case.name); + + let Disposition::Dispatched(record) = &r.t.disposition else { + panic!("{}: expected a dispatch record", case.name); + }; + let state_after = if case.surface { + r.u.surfaces.last().unwrap().state.clone() + } else { + r.u.nav.last().unwrap().state.clone() + }; + match case.expect { + TxnExpect::Commit { + writes, + pending, + tag, + commands, + } => { + assert!(record.aborted.is_none(), "{}", case.name); + assert_eq!(record.writes.len(), writes, "{}: writes", case.name); + assert_eq!(r.u.pending.len(), pending, "{}: pending", case.name); + assert_eq!( + r.u.counters.tag, + tag_before + tag, + "{}: tag counter", + case.name + ); + assert_eq!(r.c.len(), commands, "{}: commands", case.name); + } + TxnExpect::Abort => { + assert_eq!( + record.aborted.as_deref(), + Some("projection-not-ready"), + "{}", + case.name + ); + assert_eq!(state_after, state_before, "{}: no writes commit", case.name); + assert!(record.writes.is_empty(), "{}: no writes traced", case.name); + assert!(r.u.pending.is_empty(), "{}: no pending", case.name); + assert!(r.c.is_empty(), "{}: no commands", case.name); + assert_eq!( + r.u.counters.tag, tag_before, + "{}: counters roll back — replay ids never drift", + case.name + ); + } + TxnExpect::NoHandler => { + assert_eq!(record.selected, None, "{}", case.name); + assert_eq!( + record.guards.first().map(|g| g.result), + Some(GuardResult::NotReady), + "{}: the not-ready guard is traced", + case.name + ); + assert_eq!(state_after, state_before, "{}", case.name); + } + } + } +} + +// ── acceptance (§7.2, in order) ───────────────────────────────────────────── + +#[test] +fn acceptance_stale_scope_occluded_ineligible() { + let p = program(); + + // Unknown page serial → stale-scope. + let r = run( + &p, + booted(&p), + ui("bump", "page:9", serde_json::json!({}), 1), + ); + assert_eq!(drop_reason(&r), Some(DropReason::StaleScope)); + assert_eq!(r.u.rev, 2, "rev+1 on drop"); + + // A below-top page is not the interactive layer → stale-scope. + let pushed = run( + &p, + booted(&p), + ui("nav-other", "page:1", serde_json::json!({}), 1), + ) + .u; + let r = run(&p, pushed, ui("bump", "page:1", serde_json::json!({}), 2)); + assert_eq!(drop_reason(&r), Some(DropReason::StaleScope)); + + // Page event under an open surface → occluded. + let covered = open_panel(&p, booted(&p), "a").u; + let r = run( + &p, + covered.clone(), + ui("bump", "page:1", serde_json::json!({}), 2), + ); + assert_eq!(drop_reason(&r), Some(DropReason::Occluded)); + + // A non-top surface is occluded too (a page event can't open the + // second instance — that path is itself occluded — so stack it + // directly; the machine still owns the acceptance rule). + let mut two = covered.clone(); + let serial = two.counters.mint_surface(); + two.surfaces.push(uhura_core::state::SurfaceState { + serial, + definition: ident("panel"), + props: BTreeMap::from([(ident("who"), Value::Id("b".into()))]), + state: BTreeMap::from([(ident("c"), Value::Int(0))]), + opener: "page:1".into(), + restore_focus: None, + }); + let r = run( + &p, + two, + ui("panel-bump", "surface:1", serde_json::json!({}), 3), + ); + assert_eq!(drop_reason(&r), Some(DropReason::Occluded)); + + // Undeclared event / payload-shape mismatch → ineligible. + let r = run( + &p, + booted(&p), + ui("nope", "page:1", serde_json::json!({}), 1), + ); + assert_eq!(drop_reason(&r), Some(DropReason::Ineligible)); + let r = run( + &p, + booted(&p), + ui("bump", "page:1", serde_json::json!({ "extra": 1 }), 1), + ); + assert_eq!(drop_reason(&r), Some(DropReason::Ineligible)); + let r = run( + &p, + booted(&p), + ui("send-ping", "page:1", serde_json::json!({}), 1), + ); + assert_eq!(drop_reason(&r), Some(DropReason::Ineligible)); + + // Stale view_rev is ACCEPTED — descriptors are self-contained (§7.2). + let mut aged = booted(&p); + aged.rev = 40; + let r = run(&p, aged, ui("bump", "page:1", serde_json::json!({}), 3)); + assert_eq!(page_state(&r.u, "n"), &Value::Int(1)); + + // The top surface's own events pass. + let r = run( + &p, + covered, + ui("panel-bump", "surface:1", serde_json::json!({}), 2), + ); + let Disposition::Dispatched(record) = &r.t.disposition else { + panic!("top-surface event dispatches"); + }; + assert_eq!(record.selected, Some(4), "panel-bump is the fifth handler"); +} + +// ── multi-handler selection ───────────────────────────────────────────────── + +#[test] +fn multi_handler_first_satisfied_guard_wins() { + let p = program(); + let r = run( + &p, + booted(&p), + ui("multi", "page:1", serde_json::json!({ "x": 2 }), 1), + ); + let Disposition::Dispatched(record) = &r.t.disposition else { + panic!("dispatched"); + }; + assert_eq!( + record + .guards + .iter() + .map(|g| (g.handler, g.result)) + .collect::<Vec<_>>(), + vec![(9, GuardResult::Unsatisfied), (10, GuardResult::Satisfied)], + ); + assert_eq!(record.selected, Some(10)); + assert_eq!(page_state(&r.u, "n"), &Value::Int(2)); + + let r = run( + &p, + booted(&p), + ui("multi", "page:1", serde_json::json!({ "x": 3 }), 1), + ); + let Disposition::Dispatched(record) = &r.t.disposition else { + panic!("consulted"); + }; + assert_eq!(record.selected, None, "none satisfied ⇒ dropped"); + assert_eq!(page_state(&r.u, "n"), &Value::Int(0)); +} + +#[test] +fn writes_are_visible_sequentially_within_the_handler() { + let p = program(); + let r = run( + &p, + booted(&p), + ui("seq", "page:1", serde_json::json!({}), 1), + ); + assert_eq!(page_state(&r.u, "n"), &Value::Int(2), "1 then 1+1"); +} + +// ── outcomes ──────────────────────────────────────────────────────────────── + +fn with_pending(p: &ProgramIr) -> UiState { + run( + p, + booted(p), + ui("send-ping", "page:1", serde_json::json!({ "x": 7 }), 1), + ) + .u +} + +#[test] +fn outcome_ok_dispatches_to_origin_with_echoed_payload() { + let p = program(); + let u = with_pending(&p); + assert_eq!(u.pending.len(), 1); + let r = run( + &p, + u, + Event::Outcome { + correlation: "c-1".into(), + result: OutcomeResult::Ok, + updates: vec![], + }, + ); + assert!(r.u.pending.is_empty(), "settled"); + assert_eq!( + page_state(&r.u, "n"), + &Value::Int(7), + "cmd echoes the sent payload" + ); + let Value::Record(m) = page_state(&r.u, "m") else { + panic!() + }; + assert!(m.is_empty(), "`set m[tag] = none` removes the entry"); +} + +#[test] +fn refused_and_unavailable_both_route_to_err() { + let p = program(); + for (result, expected_note) in [ + ( + OutcomeResult::Refused { + refusal: ident("rate-limited"), + }, + "rate-limited", + ), + ( + OutcomeResult::Unavailable { + reason: "unreachable".into(), + }, + "unavailable", + ), + ] { + let r = run( + &p, + with_pending(&p), + Event::Outcome { + correlation: "c-1".into(), + result, + updates: vec![], + }, + ); + assert_eq!(page_state(&r.u, "note"), &Value::Text(expected_note.into())); + } +} + +#[test] +fn unknown_correlation_and_unmounted_origin_drop() { + let p = program(); + let r = run( + &p, + booted(&p), + Event::Outcome { + correlation: "c-99".into(), + result: OutcomeResult::Ok, + updates: vec![], + }, + ); + assert_eq!(drop_reason(&r), Some(DropReason::UnknownCorrelation)); + + // Origin scope gone (its surface dismissed) ⇒ stale-outcome, pending + // removed — truth already lives in X (§7.2). + let opened = open_panel(&p, booted(&p), "a").u; + let sent = run( + &p, + opened, + ui("panel-send", "surface:1", serde_json::json!({}), 2), + ) + .u; + let closed = run(&p, sent, ui("close", "surface:1", serde_json::json!({}), 3)).u; + assert_eq!(closed.pending.len(), 1); + let r = run( + &p, + closed, + Event::Outcome { + correlation: "c-1".into(), + result: OutcomeResult::Ok, + updates: vec![], + }, + ); + assert_eq!(drop_reason(&r), Some(DropReason::StaleOutcome)); + assert!(r.u.pending.is_empty(), "pending removed on stale drop"); +} + +#[test] +fn outcomes_reach_below_top_pages() { + // Navigate away while a command is in flight: the origin page is still + // mounted (revealed pages keep state), so the outcome dispatches. + let p = program(); + let u = with_pending(&p); + let pushed = run(&p, u, ui("nav-other", "page:1", serde_json::json!({}), 2)).u; + let r = run( + &p, + pushed, + Event::Outcome { + correlation: "c-1".into(), + result: OutcomeResult::Ok, + updates: vec![], + }, + ); + assert!(r.u.pending.is_empty()); + assert_eq!(&r.u.nav[0].state[&ident("n")], &Value::Int(7)); +} + +// ── structure ─────────────────────────────────────────────────────────────── + +#[test] +fn open_surface_is_idempotent_per_definition_and_context() { + let p = program(); + let once = open_panel(&p, booted(&p), "a"); + assert_eq!(once.u.surfaces.len(), 1); + assert_eq!(once.u.surfaces[0].serial, 1); + assert_eq!(once.u.surfaces[0].opener, "page:1"); + + // Same (definition, canonical context) ⇒ no second instance. The + // event still dispatches from the page… which is occluded now, so + // drive it through the surface-free path: dismiss first, reopen. + let closed = run( + &p, + once.u, + Event::Ui { + descriptor: Descriptor { + kind: DescriptorKind::Input, + event: ident("dismiss"), + emit: ident("dismiss"), + scope: "surface:1".into(), + payload: serde_json::json!({}), + carries: BTreeMap::new(), + }, + data: None, + view_rev: 2, + }, + ); + assert!(closed.u.surfaces.is_empty(), "reserved dismiss pops"); + + let reopened = open_panel(&p, closed.u, "a"); + assert_eq!( + reopened.u.surfaces[0].serial, 2, + "a fresh instance mints a fresh serial" + ); + let different = open_panel(&p, booted(&p), "b"); + assert_eq!( + different.u.surfaces[0].props[&ident("who")], + Value::Id("b".into()) + ); +} + +#[test] +fn open_records_the_triggering_node_and_dismiss_restores_focus() { + let p = program(); + // The home markup has exactly this control: button ord 1, press ⇒ + // open-panel(p: "a"). The descriptor search records its key-path. + let opened = open_panel(&p, booted(&p), "a"); + assert_eq!( + opened.u.surfaces[0].restore_focus.as_deref(), + Some("page:1/0/1"), + "the pre-step view knows the triggering node" + ); + assert_eq!( + opened.v.surfaces[0].restore_focus.as_deref(), + Some("page:1/0/1"), + "V carries it for the renderer" + ); + + // Authored `dismiss` statement pops the topmost instance ⇒ + // FocusRestore intent with that key-path. + let closed = run( + &p, + opened.u, + ui("close", "surface:1", serde_json::json!({}), 2), + ); + assert!(closed.u.surfaces.is_empty()); + assert!( + closed + .i + .iter() + .any(|i| i.to_json()["intent"] == "focus-restore" + && i.to_json()["key-path"] == "page:1/0/1"), + "FocusRestore intent: {:?}", + closed.i + ); +} + +#[test] +fn navigate_pushes_and_back_pops_with_history_intents() { + let p = program(); + let bumped = run( + &p, + booted(&p), + ui("bump", "page:1", serde_json::json!({}), 1), + ) + .u; + let pushed = run( + &p, + bumped, + ui("nav-other", "page:1", serde_json::json!({}), 2), + ); + assert_eq!(pushed.u.nav.len(), 2); + assert_eq!(pushed.u.nav[1].route, ident("other")); + assert_eq!(pushed.u.nav[1].serial, 2, "page serials mint 1-based"); + assert!( + pushed + .i + .iter() + .any(|i| i.to_json()["intent"] == "history-push"), + "{:?}", + pushed.i + ); + + // `other` has no nav-back handler; drive back from home by crafting + // the pop through the machine: dispatch nav-back on the revealed page + // is stale-scope, so pop via a fresh home-on-top stack instead. + let mut stacked = pushed.u.clone(); + stacked.nav.swap(0, 1); // home on top, other revealed + let back = run( + &p, + stacked, + ui("nav-back", "page:1", serde_json::json!({}), 3), + ); + assert_eq!(back.u.nav.len(), 1); + assert_eq!(back.u.nav[0].route, ident("other")); + assert!( + back.i + .iter() + .any(|i| i.to_json()["intent"] == "history-back"), + "{:?}", + back.i + ); +} + +#[test] +fn back_force_closes_the_popped_pages_surfaces_and_never_underflows() { + let p = program(); + // A page under an open surface can't receive `nav-back` (occluded), + // so back-with-surfaces only happens from a SURFACE handler running + // `navigate back`: craft that stack — panel opened by the top page — + // and drive the pop through the surface. + let opened = open_panel(&p, booted(&p), "a").u; + let mut u = opened; + u.nav.push(uhura_core::state::NavEntry { + serial: u.counters.mint_page(), + route: ident("home"), + params: BTreeMap::new(), + state: BTreeMap::from([ + (ident("n"), Value::Int(0)), + (ident("m"), Value::Record(BTreeMap::new())), + (ident("note"), Value::None), + ]), + }); + u.surfaces[0].opener = "page:2".to_string(); // opened by the top page + let r = run( + &p, + u, + ui("panel-back", "surface:1", serde_json::json!({}), 5), + ); + assert_eq!(r.u.nav.len(), 1); + assert!( + r.u.surfaces.is_empty(), + "the popped page's surfaces force-close" + ); + + // Popping the last entry is a traced no-op, never a crash. + let r2 = run(&p, r.u, ui("nav-back", "page:1", serde_json::json!({}), 6)); + assert_eq!(r2.u.nav.len(), 1); + assert!( + r2.t.structural.iter().any(|op| op["op"] == "nav-underflow"), + "{:?}", + r2.t.structural + ); +} + +// ── deliveries ────────────────────────────────────────────────────────────── + +#[test] +fn projection_events_recompute_the_view_without_dispatch() { + let p = program(); + let u = booted(&p); + let rev = u.rev; + let r = run(&p, u, Event::Projection { updates: vec![] }); + assert!(matches!(r.t.disposition, Disposition::Delivery)); + assert_eq!(r.u.rev, rev + 1); + assert_eq!(r.v.revision, r.u.rev); +} + +#[test] +fn init_mounts_the_route_and_hashes_are_stable() { + let p = program(); + let r = step_u( + &p, + UiState::boot(), + &projections(), + Event::Init { + route: ident("home"), + params: BTreeMap::new(), + }, + ) + .expect("init"); + assert_eq!(r.u.rev, 1); + assert_eq!(r.u.nav.len(), 1); + assert_eq!(r.u.nav[0].serial, 1); + assert_eq!(r.t.u_hash, r.u.u_hash()); + assert_eq!(r.t.v_hash, r.v.v_hash()); + + // Determinism: the same fold yields byte-identical traces. + let again = step_u( + &p, + UiState::boot(), + &projections(), + Event::Init { + route: ident("home"), + params: BTreeMap::new(), + }, + ) + .expect("init"); + assert_eq!(r.t.to_line(), again.t.to_line()); +} + +#[test] +fn a_second_open_with_the_same_context_is_idempotent() { + // The §4.2 cell the name promises: same (definition, canonical + // context) while mounted ⇒ no second instance, traced `already-open`. + let p = program(); + let opened = open_panel(&p, booted(&p), "a").u; + let r = run( + &p, + opened, + ui("reopen", "surface:1", serde_json::json!({}), 2), + ); + assert_eq!(r.u.surfaces.len(), 1, "no duplicate instance"); + assert_eq!(r.u.surfaces[0].serial, 1, "the existing instance survives"); + assert_eq!( + r.u.counters.surface_serial, 1, + "no serial minted for a dedup'd open" + ); + assert!( + r.t.structural.iter().any(|op| op["op"] == "already-open"), + "{:?}", + r.t.structural + ); +} + +#[test] +fn the_focus_search_sees_the_view_the_event_was_emitted_against() { + // flip-open writes state its OWN trigger payload reads before opening: + // the pre-commit view still carries payload {c:false}, so the search + // must find the button even though the post-commit payload is {c:true} + // (§4.2 "records opener + triggering node"). + let p = program(); + let r = run( + &p, + booted(&p), + ui("flip-open", "page:1", serde_json::json!({ "c": false }), 1), + ); + assert_eq!(r.u.surfaces.len(), 1); + assert_eq!( + r.u.surfaces[0].restore_focus.as_deref(), + Some("page:1/0/2"), + "the trigger node is found in the pre-commit view" + ); +} + +#[test] +fn a_surface_opened_by_a_scope_that_died_mid_dispatch_is_swept() { + // `dismiss` then `open-surface` in one handler: the opener scope is + // dead when the open applies, so the invariant sweep force-closes the + // orphan — it must not float over the page forever. + let p = program(); + let opened = open_panel(&p, booted(&p), "a").u; + let r = run( + &p, + opened, + ui("respawn", "surface:1", serde_json::json!({}), 2), + ); + assert!( + r.u.surfaces.is_empty(), + "the orphan is swept: {:?}", + r.u.surfaces + ); + let ops: Vec<_> = + r.t.structural + .iter() + .map(|op| op["op"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!( + ops, + vec!["dismiss", "open-surface", "force-close"], + "dismissed, opened, then swept — all traced" + ); +} + +#[test] +fn a_duplicate_identical_in_flight_send_warns_in_g() { + // §4.2 send: "Duplicate identical in-flight send → warning" — + // suppression is the author's guard's job; the machine diagnoses. + let p = program(); + let once = with_pending(&p); + let r = run( + &p, + once, + ui("send-ping", "page:1", serde_json::json!({ "x": 7 }), 2), + ); + assert_eq!(r.u.pending.len(), 2, "the duplicate still commits"); + assert_eq!(r.g.len(), 1, "one warning in G"); + assert_eq!(r.g[0].code, "UH8001"); + assert!( + r.t.to_line().contains("UH8001"), + "the warning reaches the trace record" + ); + + // A different payload is not a duplicate. + let r2 = run( + &p, + with_pending(&p), + ui("send-ping", "page:1", serde_json::json!({ "x": 8 }), 2), + ); + assert!(r2.g.is_empty(), "{:?}", r2.g); +} diff --git a/uhura/crates/uhura-fixture/Cargo.toml b/uhura/crates/uhura-fixture/Cargo.toml new file mode 100644 index 0000000..6c2ca75 --- /dev/null +++ b/uhura/crates/uhura-fixture/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "uhura-fixture" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } +uhura-port = { workspace = true, features = ["toml"] } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } diff --git a/uhura/crates/uhura-fixture/clippy.toml b/uhura/crates/uhura-fixture/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-fixture/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-fixture/src/driver.rs b/uhura/crates/uhura-fixture/src/driver.rs new file mode 100644 index 0000000..3355916 --- /dev/null +++ b/uhura/crates/uhura-fixture/src/driver.rs @@ -0,0 +1,369 @@ +//! The tick-based driver (design §9.5): standalone deliveries are scheduled +//! at `new()` on absolute ticks, reply outcomes at (deliver tick + +//! after-ticks). Reply slices resolve BOTH substitutions in one pass at +//! `deliver()` — payload-echo values are spliced verbatim and never +//! re-walked, so wire data can never be mistaken for an authored +//! `@fresh-id` marker. Standalone slices (validated payload-marker-free at +//! `new()`) mint their fresh ids at emission. Revisions always mint at +//! emission (§9.3 strict increase). + +use std::collections::BTreeMap; + +use uhura_base::{Ident, to_canonical_json}; +use uhura_port::{OutcomeEnvelope, OutcomeResult, ProjectionUpdate, ProviderMsg}; + +use crate::script::{self, DeliverBody, Fixture, KeySpec, ReplyEntry}; + +#[derive(Debug)] +pub struct FixtureDriver { + replies: Vec<ReplyEntry>, + /// Insertion-ordered: standalone entries land here at `new()`, so they + /// precede same-tick reply outcomes. + scheduled: Vec<Scheduled>, + tick: u64, + /// Per-(projection, canonical key) revision, starting at 1 — revision 1 + /// is reserved for the harness's boot deliveries, so the first driver + /// delivery of an instance is revision 2. + revisions: BTreeMap<(String, String), u64>, + /// 1-based `@fresh-id` mint counter — advanced in resolution order + /// (reply slices at `deliver()`, standalone slices at emission). + fresh: u64, +} + +#[derive(Debug)] +struct Scheduled { + due: u64, + item: Pending, +} + +#[derive(Debug)] +enum Pending { + Projection { + port: Ident, + projection: Ident, + key: Option<serde_json::Value>, + slice: serde_json::Value, + }, + ProjectionFailed { + port: Ident, + projection: Ident, + key: Option<serde_json::Value>, + reason: String, + }, + Outcome { + correlation: String, + outcome: OutcomeResult, + /// Fully resolved at deliver time (payload echo AND fresh ids); + /// only revisions remain for emission. + updates: Vec<PendingUpdate>, + }, +} + +#[derive(Debug)] +struct PendingUpdate { + port: Ident, + projection: Ident, + key: Option<serde_json::Value>, + slice: serde_json::Value, +} + +impl FixtureDriver { + /// `fixture_json`: `{"<ns>": {"<name>": <value>}}` — pre-resolved slices, + /// possibly carrying the `"@fresh-id"` / `"@payload.<field>"` markers. + /// `script_json`: the §9.5 script object, parsed strictly. + pub fn new(fixture_json: &str, script_json: &str) -> Result<FixtureDriver, String> { + let fixture = parse_fixture(fixture_json)?; + let script_value: serde_json::Value = + serde_json::from_str(script_json).map_err(|e| format!("invalid script JSON: {e}"))?; + reject_floats(&script_value, "script data")?; + let parsed = script::parse_script(&script_value, &fixture)?; + + let scheduled = parsed + .deliver + .into_iter() + .map(|entry| Scheduled { + due: entry.after_ticks, + item: match entry.body { + DeliverBody::Slice(slice) => Pending::Projection { + port: entry.port, + projection: entry.projection, + key: entry.key, + slice, + }, + DeliverBody::Failed(reason) => Pending::ProjectionFailed { + port: entry.port, + projection: entry.projection, + key: entry.key, + reason, + }, + }, + }) + .collect(); + + Ok(FixtureDriver { + replies: parsed.replies, + scheduled, + tick: 0, + revisions: BTreeMap::new(), + fresh: 0, + }) + } + + /// A command envelope arrives. Matches the first unconsumed reply entry + /// in file order whose `on.command` equals the envelope's command and + /// whose every `where` field is JSON-equal to the same-named payload + /// field; no match is `on-unscripted = "error"` (§9.5 — a duplicate + /// in-flight command IS the dedupe assertion). + pub fn deliver(&mut self, cmd_json: &str) -> Result<(), String> { + let value: serde_json::Value = + serde_json::from_str(cmd_json).map_err(|e| format!("invalid command JSON: {e}"))?; + let ProviderMsg::Command(cmd) = ProviderMsg::from_json(&value)? else { + return Err("deliver() takes a `kind = \"command\"` envelope".into()); + }; + reject_floats(&cmd.payload, "command payloads")?; + + let index = self + .replies + .iter() + .position(|entry| { + !entry.consumed + && entry.command == cmd.command + && entry + .where_fields + .iter() + .all(|(field, expected)| cmd.payload.get(field) == Some(expected)) + }) + .ok_or_else(|| { + format!( + "unscripted command `{}` (on-unscripted = \"error\", §9.5)", + cmd.command + ) + })?; + + // Resolve everything payload-dependent NOW, before consuming — a + // missing payload field fails this deliver() and consumes nothing. + // Fresh ids mint in the same single pass so spliced payload data + // is never re-interpreted as a marker. + let entry = &self.replies[index]; + let mut fresh = self.fresh; + let mut updates = Vec::with_capacity(entry.updates.len()); + for spec in &entry.updates { + let key = match &spec.key { + KeySpec::Absent => None, + KeySpec::Literal(literal) => Some(literal.clone()), + KeySpec::FromPayload(field) => { + Some(cmd.payload.get(field).cloned().ok_or_else(|| { + format!("the payload has no `{field}` field for the update key") + })?) + } + }; + updates.push(PendingUpdate { + port: spec.port.clone(), + projection: spec.projection.clone(), + key, + slice: resolve_markers(&spec.slice, &cmd.payload, &mut fresh)?, + }); + } + self.fresh = fresh; + let outcome = entry.outcome.clone(); + let due = self.tick + entry.after_ticks; + if !entry.repeat { + self.replies[index].consumed = true; + } + self.scheduled.push(Scheduled { + due, + item: Pending::Outcome { + correlation: cmd.correlation, + outcome, + updates, + }, + }); + Ok(()) + } + + /// Advances one tick and returns the provider messages that come due, in + /// schedule order, as canonical JSON strings. Messages are built HERE so + /// revision numbers and fresh-id mints are ordered by emission. + pub fn tick(&mut self) -> Vec<String> { + self.tick += 1; + let scheduled = std::mem::take(&mut self.scheduled); + let mut out = Vec::new(); + for entry in scheduled { + if entry.due == self.tick { + let msg = self.emit(entry.item); + out.push(to_canonical_json(&msg.to_json())); + } else { + self.scheduled.push(entry); + } + } + out + } + + /// Nothing scheduled. Unconsumed one-shot reply entries do NOT count — + /// they may never fire. + pub fn idle(&self) -> bool { + self.scheduled.is_empty() + } + + fn emit(&mut self, item: Pending) -> ProviderMsg { + match item { + Pending::Projection { + port, + projection, + key, + slice, + } => { + let value = self.mint_fresh(&slice); + let revision = self.next_revision(&projection, &key); + ProviderMsg::Projection(ProjectionUpdate { + port, + projection, + key, + revision, + value, + }) + } + Pending::ProjectionFailed { + port, + projection, + key, + reason, + } => ProviderMsg::ProjectionFailed { + port, + projection, + key, + reason, + }, + Pending::Outcome { + correlation, + outcome, + updates, + } => { + let updates = updates + .into_iter() + .map(|u| { + let revision = self.next_revision(&u.projection, &u.key); + ProjectionUpdate { + port: u.port, + projection: u.projection, + key: u.key, + revision, + value: u.slice, + } + }) + .collect(); + ProviderMsg::Outcome(OutcomeEnvelope { + correlation, + outcome, + updates, + }) + } + } + } + + /// Increments first, so the first driver mint for an instance is 2 — + /// strictly increasing per (projection, canonical key), never colliding + /// with the harness's boot revision 1 (§9.3). + fn next_revision(&mut self, projection: &Ident, key: &Option<serde_json::Value>) -> u64 { + let canonical_key = match key { + None => "null".to_string(), + Some(k) => to_canonical_json(k), + }; + let counter = self + .revisions + .entry((projection.to_string(), canonical_key)) + .or_insert(1); + *counter += 1; + *counter + } + + /// Deep-walks a value replacing whole-string `"@fresh-id"` markers with + /// `fresh-<n>`, minted in pre-order walk order (§9.5 — object fields + /// walk in canonical key order; serde_json maps are BTree-backed). + fn mint_fresh(&mut self, value: &serde_json::Value) -> serde_json::Value { + use serde_json::Value as J; + match value { + J::String(s) if s == "@fresh-id" => { + self.fresh += 1; + J::String(format!("fresh-{}", self.fresh)) + } + J::Array(items) => J::Array(items.iter().map(|v| self.mint_fresh(v)).collect()), + J::Object(map) => J::Object( + map.iter() + .map(|(k, v)| (k.clone(), self.mint_fresh(v))) + .collect(), + ), + _ => value.clone(), + } + } +} + +/// Deep-walks an AUTHORED reply slice in one pass, resolving the only two +/// substitutions (§9.5): whole-string `"@payload.<field>"` markers splice +/// the matched payload's field value VERBATIM (spliced data is not +/// re-walked — wire data equal to a marker string stays untouched), and +/// whole-string `"@fresh-id"` markers mint `fresh-<n>` in pre-order walk +/// order. A missing payload field fails the enclosing `deliver()`. +fn resolve_markers( + value: &serde_json::Value, + payload: &serde_json::Value, + fresh: &mut u64, +) -> Result<serde_json::Value, String> { + use serde_json::Value as J; + match value { + J::String(s) if s == "@fresh-id" => { + *fresh += 1; + Ok(J::String(format!("fresh-{fresh}"))) + } + J::String(s) => match script::payload_marker(s) { + Some(field) => payload.get(field).cloned().ok_or_else(|| { + format!("the payload has no `{field}` field for `@payload.{field}`") + }), + None => Ok(value.clone()), + }, + J::Array(items) => items + .iter() + .map(|item| resolve_markers(item, payload, fresh)) + .collect::<Result<Vec<_>, _>>() + .map(J::Array), + J::Object(map) => map + .iter() + .map(|(k, v)| Ok((k.clone(), resolve_markers(v, payload, fresh)?))) + .collect::<Result<serde_json::Map<_, _>, String>>() + .map(J::Object), + _ => Ok(value.clone()), + } +} + +fn parse_fixture(text: &str) -> Result<Fixture, String> { + use serde_json::Value as J; + let value: J = serde_json::from_str(text).map_err(|e| format!("invalid fixture JSON: {e}"))?; + reject_floats(&value, "fixture data")?; + let J::Object(namespaces) = value else { + return Err("fixture data is `{\"<ns>\": {\"<name>\": <value>}}`".into()); + }; + let mut out = Fixture::new(); + for (ns, slices) in namespaces { + let J::Object(slices) = slices else { + return Err(format!( + "fixture namespace `{ns}` must be an object of slices" + )); + }; + out.insert(ns, slices.into_iter().collect()); + } + Ok(out) +} + +/// Determinism guard: the value model has no floats (§7.5) — refuse them at +/// the boundary rather than reaching canonical JSON with one. +fn reject_floats(value: &serde_json::Value, what: &str) -> Result<(), String> { + use serde_json::Value as J; + match value { + J::Number(n) if !(n.is_i64() || n.is_u64()) => { + Err(format!("floats do not exist in {what} (§7.5): {n}")) + } + J::Array(items) => items.iter().try_for_each(|v| reject_floats(v, what)), + J::Object(map) => map.values().try_for_each(|v| reject_floats(v, what)), + _ => Ok(()), + } +} diff --git a/uhura/crates/uhura-fixture/src/lib.rs b/uhura/crates/uhura-fixture/src/lib.rs new file mode 100644 index 0000000..04eae67 --- /dev/null +++ b/uhura/crates/uhura-fixture/src/lib.rs @@ -0,0 +1,40 @@ +//! uhura-fixture: the scripted mock-Spock driver — tick-based, deterministic, +//! entirely outside uhura-core (design §9.5). Compiled natively for traces +//! and to wasm for play mode. +#![deny(clippy::float_arithmetic)] + +mod driver; +mod script; + +pub use driver::FixtureDriver; + +/// TOML text → JSON for script and fixture files, refusing everything the +/// value model refuses (floats, datetimes — §7.5 determinism by type shape). +/// Kept semantically in lock-step with the checker's converter. +pub fn toml_to_json(text: &str) -> Result<serde_json::Value, String> { + let table: toml::Table = text.parse().map_err(|e| format!("invalid TOML: {e}"))?; + toml_value_to_json(&toml::Value::Table(table)) +} + +fn toml_value_to_json(value: &toml::Value) -> Result<serde_json::Value, String> { + use serde_json::Value as J; + match value { + toml::Value::String(s) => Ok(J::String(s.clone())), + toml::Value::Integer(i) => Ok(J::Number((*i).into())), + toml::Value::Boolean(b) => Ok(J::Bool(*b)), + toml::Value::Float(_) => Err("floats do not exist in fixture data (§7.5)".into()), + toml::Value::Datetime(_) => { + Err("no clocks: time labels are provider-formatted text (§9.1)".into()) + } + toml::Value::Array(items) => items + .iter() + .map(toml_value_to_json) + .collect::<Result<Vec<_>, _>>() + .map(J::Array), + toml::Value::Table(table) => table + .iter() + .map(|(k, v)| Ok((k.clone(), toml_value_to_json(v)?))) + .collect::<Result<serde_json::Map<_, _>, String>>() + .map(J::Object), + } +} diff --git a/uhura/crates/uhura-fixture/src/script.rs b/uhura/crates/uhura-fixture/src/script.rs new file mode 100644 index 0000000..4770240 --- /dev/null +++ b/uhura/crates/uhura-fixture/src/script.rs @@ -0,0 +1,357 @@ +//! Script JSON → validated entries (design §9.5). The grammar is closed: +//! unknown keys are errors at every level, `on-unscripted = "error"` is the +//! only policy, and `after-ticks ≥ 1` keeps optimistic states observable. +//! Slice references resolve against the fixture at parse time, so a dangling +//! `<ns>.<name>` can never survive to a tick. + +use std::collections::BTreeMap; + +use uhura_base::Ident; +use uhura_port::OutcomeResult; + +/// Namespace → slice name → pre-resolved JSON value. +pub(crate) type Fixture = BTreeMap<String, BTreeMap<String, serde_json::Value>>; + +type Map = serde_json::Map<String, serde_json::Value>; + +pub(crate) struct Script { + pub(crate) deliver: Vec<DeliverEntry>, + pub(crate) replies: Vec<ReplyEntry>, +} + +pub(crate) struct DeliverEntry { + /// Absolute due tick — standalone entries are scheduled at `new()`. + pub(crate) after_ticks: u64, + pub(crate) port: Ident, + pub(crate) projection: Ident, + pub(crate) key: Option<serde_json::Value>, + pub(crate) body: DeliverBody, +} + +pub(crate) enum DeliverBody { + /// The slice's JSON, cloned at parse (fixture data is fixed). May still + /// carry `"@fresh-id"` markers — minted at emission. + Slice(serde_json::Value), + /// A projection-failed reason string. + Failed(String), +} + +#[derive(Debug)] +pub(crate) struct ReplyEntry { + pub(crate) command: Ident, + /// Every field must be JSON-equal to the same-named payload field. + pub(crate) where_fields: BTreeMap<String, serde_json::Value>, + pub(crate) after_ticks: u64, + pub(crate) repeat: bool, + /// One-shot entries are consumed on match (§9.5 — a duplicate in-flight + /// command IS the dedupe assertion). + pub(crate) consumed: bool, + pub(crate) outcome: OutcomeResult, + pub(crate) updates: Vec<UpdateSpec>, +} + +/// A settlement update riding inside the outcome envelope (§9.4). +#[derive(Debug)] +pub(crate) struct UpdateSpec { + pub(crate) port: Ident, + pub(crate) projection: Ident, + pub(crate) key: KeySpec, + pub(crate) slice: serde_json::Value, +} + +#[derive(Debug)] +pub(crate) enum KeySpec { + Absent, + Literal(serde_json::Value), + /// `{ "from": "payload.<field>" }` — resolved at deliver time. + FromPayload(String), +} + +/// Whole-string `"@payload.<field>"` marker → the field name. One of the +/// only two substitutions (§9.5); anything else passes through verbatim. +pub(crate) fn payload_marker(s: &str) -> Option<&str> { + s.strip_prefix("@payload.") + .filter(|field| !field.is_empty()) +} + +pub(crate) fn parse_script(json: &serde_json::Value, fixture: &Fixture) -> Result<Script, String> { + let top = as_object(json, "a script")?; + closed_keys( + "top-level", + top, + &["on-unscripted", "deliver", "reply", "ui"], + )?; + if let Some(policy) = top.get("on-unscripted") + && policy.as_str() != Some("error") + { + return Err(format!( + "`on-unscripted` must be \"error\" — the only policy (§9.5), got {policy}" + )); + } + // "ui" is harness-only: accepted here, ignored completely. + + let mut deliver = Vec::new(); + if let Some(value) = top.get("deliver") { + for (i, item) in as_array(value, "`deliver`")?.iter().enumerate() { + deliver.push(parse_deliver(item, fixture).map_err(|e| format!("deliver[{i}]: {e}"))?); + } + } + let mut replies = Vec::new(); + if let Some(value) = top.get("reply") { + for (i, item) in as_array(value, "`reply`")?.iter().enumerate() { + replies.push(parse_reply(item, fixture).map_err(|e| format!("reply[{i}]: {e}"))?); + } + } + Ok(Script { deliver, replies }) +} + +fn parse_deliver(json: &serde_json::Value, fixture: &Fixture) -> Result<DeliverEntry, String> { + let obj = as_object(json, "a deliver entry")?; + closed_keys( + "deliver entry", + obj, + &[ + "after-ticks", + "port", + "projection", + "key", + "slice", + "failed", + ], + )?; + let after_ticks = after_ticks(obj)?; + let port = ident_field(obj, "port")?; + let projection = ident_field(obj, "projection")?; + let key = match obj.get("key") { + None | Some(serde_json::Value::Null) => None, + Some(k) => { + if k.as_object().is_some_and(|o| o.contains_key("from")) { + return Err("a deliver `key` is a literal — `{ \"from\": … }` resolves \ + from a command payload and is reply-only" + .into()); + } + Some(k.clone()) + } + }; + let body = match (obj.get("slice"), obj.get("failed")) { + (Some(slice), None) => { + let slice = lookup_slice(slice, fixture)?; + // Standalone deliveries have no matched command, so payload + // markers in their slices are errors NOW, not at emission. + reject_payload_markers(&slice)?; + DeliverBody::Slice(slice) + } + (None, Some(failed)) => DeliverBody::Failed( + failed + .as_str() + .ok_or("`failed` is a projection-failed reason string")? + .to_string(), + ), + _ => return Err("a deliver entry has exactly one of `slice` or `failed`".into()), + }; + Ok(DeliverEntry { + after_ticks, + port, + projection, + key, + body, + }) +} + +fn parse_reply(json: &serde_json::Value, fixture: &Fixture) -> Result<ReplyEntry, String> { + let obj = as_object(json, "a reply entry")?; + closed_keys( + "reply entry", + obj, + &[ + "on", + "after-ticks", + "repeat", + "outcome", + "refusal", + "reason", + "updates", + ], + )?; + + let on = as_object(obj.get("on").ok_or("missing `on`")?, "`on`")?; + closed_keys("`on`", on, &["command", "where"])?; + let command = ident_field(on, "command")?; + let mut where_fields = BTreeMap::new(); + if let Some(w) = on.get("where") { + for (field, expected) in as_object(w, "`where`")? { + where_fields.insert(field.clone(), expected.clone()); + } + } + + let after_ticks = after_ticks(obj)?; + let repeat = match obj.get("repeat") { + None => false, + Some(serde_json::Value::Bool(b)) => *b, + Some(other) => return Err(format!("`repeat` must be a bool, got {other}")), + }; + let outcome = parse_outcome(obj)?; + + let mut updates = Vec::new(); + if let Some(value) = obj.get("updates") { + for (i, item) in as_array(value, "`updates`")?.iter().enumerate() { + updates.push(parse_update(item, fixture).map_err(|e| format!("updates[{i}]: {e}"))?); + } + } + + Ok(ReplyEntry { + command, + where_fields, + after_ticks, + repeat, + consumed: false, + outcome, + updates, + }) +} + +/// `refusal` accompanies exactly `refused`; `reason` exactly `unavailable`. +fn parse_outcome(obj: &Map) -> Result<OutcomeResult, String> { + let name = obj + .get("outcome") + .and_then(serde_json::Value::as_str) + .ok_or("`outcome` must be \"ok\" | \"refused\" | \"unavailable\"")?; + let refusal = obj.get("refusal"); + let reason = obj.get("reason"); + match name { + "ok" => { + if refusal.is_some() || reason.is_some() { + return Err("`ok` carries neither `refusal` nor `reason`".into()); + } + Ok(OutcomeResult::Ok) + } + "refused" => { + if reason.is_some() { + return Err("`reason` belongs to `unavailable`, not `refused`".into()); + } + let refusal = refusal + .and_then(serde_json::Value::as_str) + .ok_or("`refused` requires a `refusal` name")?; + Ok(OutcomeResult::Refused { + refusal: Ident::new(refusal).map_err(|e| e.to_string())?, + }) + } + "unavailable" => { + if refusal.is_some() { + return Err("`refusal` belongs to `refused`, not `unavailable`".into()); + } + let reason = reason + .and_then(serde_json::Value::as_str) + .ok_or("`unavailable` requires a `reason` text")?; + Ok(OutcomeResult::Unavailable { + reason: reason.to_string(), + }) + } + other => Err(format!( + "`{other}` is not an outcome (ok | refused | unavailable)" + )), + } +} + +fn parse_update(json: &serde_json::Value, fixture: &Fixture) -> Result<UpdateSpec, String> { + let obj = as_object(json, "an update entry")?; + closed_keys("update entry", obj, &["port", "projection", "key", "slice"])?; + Ok(UpdateSpec { + port: ident_field(obj, "port")?, + projection: ident_field(obj, "projection")?, + key: parse_key_spec(obj.get("key"))?, + slice: lookup_slice(obj.get("slice").ok_or("missing `slice`")?, fixture)?, + }) +} + +fn parse_key_spec(value: Option<&serde_json::Value>) -> Result<KeySpec, String> { + use serde_json::Value as J; + match value { + None | Some(J::Null) => Ok(KeySpec::Absent), + Some(J::Object(map)) if map.contains_key("from") => { + // `{ "from": "payload.<field>" }` — the only computed key (§9.5). + let target = map.get("from").and_then(J::as_str); + let field = target.and_then(|t| t.strip_prefix("payload.")); + match (map.len(), field) { + (1, Some(field)) if !field.is_empty() => Ok(KeySpec::FromPayload(field.into())), + _ => Err("a computed key is exactly `{ \"from\": \"payload.<field>\" }`".into()), + } + } + Some(literal) => Ok(KeySpec::Literal(literal.clone())), + } +} + +/// `after-ticks ≥ 1`, so optimistic states are always observable (§9.5). +fn after_ticks(obj: &Map) -> Result<u64, String> { + let value = obj.get("after-ticks").ok_or("missing `after-ticks`")?; + match value.as_u64() { + Some(n) if n >= 1 => Ok(n), + _ => Err(format!("`after-ticks` must be an integer ≥ 1, got {value}")), + } +} + +fn lookup_slice(value: &serde_json::Value, fixture: &Fixture) -> Result<serde_json::Value, String> { + let reference = value + .as_str() + .ok_or("`slice` is a `<ns>.<name>` reference string")?; + let Some((ns, name)) = reference.split_once('.') else { + return Err(format!( + "`{reference}` is not a `<ns>.<name>` slice reference" + )); + }; + fixture + .get(ns) + .and_then(|slices| slices.get(name)) + .cloned() + .ok_or_else(|| format!("no fixture slice `{reference}`")) +} + +fn reject_payload_markers(value: &serde_json::Value) -> Result<(), String> { + use serde_json::Value as J; + match value { + J::String(s) => match payload_marker(s) { + Some(_) => Err(format!( + "`{s}` is reply-only — a standalone delivery has no command payload (§9.5)" + )), + None => Ok(()), + }, + J::Array(items) => items.iter().try_for_each(reject_payload_markers), + J::Object(map) => map.values().try_for_each(reject_payload_markers), + _ => Ok(()), + } +} + +fn ident_field(obj: &Map, field: &str) -> Result<Ident, String> { + let s = obj + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("missing or non-text `{field}`"))?; + Ident::new(s).map_err(|e| e.to_string()) +} + +fn as_object<'v>(value: &'v serde_json::Value, what: &str) -> Result<&'v Map, String> { + value + .as_object() + .ok_or_else(|| format!("{what} must be a JSON object")) +} + +fn as_array<'v>( + value: &'v serde_json::Value, + what: &str, +) -> Result<&'v Vec<serde_json::Value>, String> { + value + .as_array() + .ok_or_else(|| format!("{what} must be an array")) +} + +/// The §9.5 grammar is closed: unknown keys are errors, never ignored. +fn closed_keys(what: &str, obj: &Map, allowed: &[&str]) -> Result<(), String> { + for key in obj.keys() { + if !allowed.contains(&key.as_str()) { + return Err(format!( + "unknown {what} key `{key}` — the §9.5 grammar is closed" + )); + } + } + Ok(()) +} diff --git a/uhura/crates/uhura-fixture/tests/driver.rs b/uhura/crates/uhura-fixture/tests/driver.rs new file mode 100644 index 0000000..48dea08 --- /dev/null +++ b/uhura/crates/uhura-fixture/tests/driver.rs @@ -0,0 +1,590 @@ +//! Driver behavior (design §9.5): tick scheduling, reply matching and +//! consumption, the two substitutions, revision minting, the closed script +//! grammar, and the TOML converter's refusals. + +use serde_json::json; +use uhura_base::to_canonical_json; +use uhura_fixture::{FixtureDriver, toml_to_json}; + +const FIXTURE: &str = r#"{ + "feed": { + "page-1": { "has-more": true, "posts": ["post-lena-glaze"] }, + "page-2": { "has-more": false, "posts": ["post-kenji"] }, + "page-1-liked": { "liked": true, "post": "@payload.post", "verbatim": "see @payload.post" }, + "fresh-comment": { "a-id": "@fresh-id", "b-ids": ["@fresh-id"] }, + "needs-payload": { "post": "@payload.post" } + } +}"#; + +fn driver(script: &serde_json::Value) -> FixtureDriver { + FixtureDriver::new(FIXTURE, &script.to_string()).expect("script should parse") +} + +fn new_err(script: &serde_json::Value) -> String { + FixtureDriver::new(FIXTURE, &script.to_string()).expect_err("script should be rejected") +} + +fn like(post: &str, correlation: &str) -> String { + json!({ + "kind": "command", "port": "feed", "command": "like-post", + "correlation": correlation, "payload": { "post": post } + }) + .to_string() +} + +fn parse(msg: &str) -> serde_json::Value { + serde_json::from_str(msg).expect("emitted messages are JSON") +} + +// ── tick scheduling and revisions ─────────────────────────────────────── + +#[test] +fn standalone_delivery_fires_at_its_tick_with_revision_2() { + let mut d = driver(&json!({ + "deliver": [ + { "after-ticks": 2, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1" } + ] + })); + assert!(!d.idle()); + assert_eq!(d.tick(), Vec::<String>::new()); + let msgs = d.tick(); + let expected = json!({ + "kind": "projection", "port": "feed", "projection": "feed-page", + "key": null, "revision": 2, + "value": { "has-more": true, "posts": ["post-lena-glaze"] } + }); + // Canonical JSON, byte for byte — sorted keys, compact. + assert_eq!(msgs, vec![to_canonical_json(&expected)]); + assert!(d.idle()); +} + +#[test] +fn same_instance_updates_get_revisions_2_then_3() { + let mut d = driver(&json!({ + "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1" }, + { "after-ticks": 2, "port": "feed", "projection": "feed-page", + "slice": "feed.page-2" }, + { "after-ticks": 2, "port": "feed", "projection": "feed-page", + "key": "post-kenji", "slice": "feed.page-2" } + ] + })); + assert_eq!(parse(&d.tick()[0])["revision"], 2); + let msgs = d.tick(); + // Same (projection, key: null) instance advances; a keyed instance has + // its own counter starting fresh. + assert_eq!(parse(&msgs[0])["revision"], 3); + assert_eq!(parse(&msgs[1])["revision"], 2); +} + +#[test] +fn projection_failed_carries_no_revision_and_mints_none() { + let mut d = driver(&json!({ + "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "failed": "unreachable" }, + { "after-ticks": 2, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1" } + ] + })); + let expected = json!({ + "kind": "projection-failed", "port": "feed", "projection": "feed-page", + "key": null, "reason": "unreachable" + }); + assert_eq!(d.tick(), vec![to_canonical_json(&expected)]); + // The failure did not consume a revision: the next update is still 2. + assert_eq!(parse(&d.tick()[0])["revision"], 2); +} + +#[test] +fn standalone_entries_precede_same_tick_reply_outcomes() { + let mut d = driver(&json!({ + "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1" } + ], + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok" } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + let msgs = d.tick(); + assert_eq!(msgs.len(), 2); + assert_eq!(parse(&msgs[0])["kind"], "projection"); + assert_eq!(parse(&msgs[1])["kind"], "outcome"); +} + +#[test] +fn after_ticks_counts_from_the_deliver_tick() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 2, "outcome": "ok" } + ] + })); + // Advance to tick 3 first: the outcome is due at 3 + 2 = 5, not 4. + for _ in 0..3 { + assert!(d.tick().is_empty()); + } + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + assert!(d.tick().is_empty()); + let msgs = d.tick(); + assert_eq!(parse(&msgs[0])["correlation"], "c-1"); +} + +#[test] +fn idle_transitions() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok" } + ] + })); + // Unconsumed reply entries do not count — they may never fire. + assert!(d.idle()); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + assert!(!d.idle()); + assert_eq!(d.tick().len(), 1); + assert!(d.idle()); +} + +// ── reply matching ────────────────────────────────────────────────────── + +#[test] +fn where_discriminates_and_file_order_wins_among_unconsumed() { + let script = json!({ + "reply": [ + { "on": { "command": "like-post", "where": { "post": "post-lena-glaze" } }, + "after-ticks": 1, "outcome": "ok" }, + { "on": { "command": "like-post" }, "after-ticks": 1, + "outcome": "refused", "refusal": "rate-limited" } + ] + }); + // A non-matching `where` falls through to the later entry. + let mut d = driver(&script); + d.deliver(&like("post-kenji", "c-1")).unwrap(); + let msgs = d.tick(); + assert_eq!( + parse(&msgs[0])["outcome"], + json!({ "refused": { "refusal": "rate-limited" } }) + ); + + // A matching `where` takes the earlier entry even though both match. + let mut d = driver(&script); + d.deliver(&like("post-lena-glaze", "c-2")).unwrap(); + let msgs = d.tick(); + assert_eq!(parse(&msgs[0])["outcome"], json!({ "ok": {} })); +} + +#[test] +fn one_shot_entries_are_consumed() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok" } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + // The duplicate in-flight command IS the dedupe assertion (§9.5). + let err = d.deliver(&like("post-lena-glaze", "c-2")).unwrap_err(); + assert!(err.contains("unscripted command `like-post`"), "{err}"); +} + +#[test] +fn repeat_entries_match_again() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "repeat": true, + "outcome": "ok" } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + d.deliver(&like("post-kenji", "c-2")).unwrap(); + let msgs = d.tick(); + assert_eq!(parse(&msgs[0])["correlation"], "c-1"); + assert_eq!(parse(&msgs[1])["correlation"], "c-2"); +} + +#[test] +fn unscripted_command_errors() { + let mut d = driver(&json!({})); + let err = d.deliver(&like("post-lena-glaze", "c-1")).unwrap_err(); + assert!(err.contains("on-unscripted"), "{err}"); +} + +#[test] +fn deliver_rejects_non_command_envelopes() { + let mut d = driver(&json!({})); + let projection = json!({ + "kind": "projection", "port": "feed", "projection": "feed-page", + "key": null, "revision": 2, "value": {} + }); + let err = d.deliver(&projection.to_string()).unwrap_err(); + assert!(err.contains("command"), "{err}"); +} + +// ── substitution ──────────────────────────────────────────────────────── + +#[test] +fn fresh_ids_mint_in_walk_order_from_a_driver_wide_counter() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "add-comment" }, "after-ticks": 1, "repeat": true, + "outcome": "ok", + "updates": [ + { "port": "comments", "projection": "for-post", + "slice": "feed.fresh-comment" } + ] } + ] + })); + let add = |c: &str| { + json!({ + "kind": "command", "port": "comments", "command": "add-comment", + "correlation": c, "payload": {} + }) + .to_string() + }; + d.deliver(&add("c-1")).unwrap(); + let value = parse(&d.tick()[0])["updates"][0]["value"].clone(); + assert_eq!(value, json!({ "a-id": "fresh-1", "b-ids": ["fresh-2"] })); + + // Per-driver counter: the next emission keeps counting. + d.deliver(&add("c-2")).unwrap(); + let value = parse(&d.tick()[0])["updates"][0]["value"].clone(); + assert_eq!(value, json!({ "a-id": "fresh-3", "b-ids": ["fresh-4"] })); +} + +#[test] +fn payload_markers_substitute_whole_strings_only() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ + { "port": "feed", "projection": "feed-page", + "slice": "feed.page-1-liked" } + ] } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + let value = parse(&d.tick()[0])["updates"][0]["value"].clone(); + assert_eq!(value["post"], "post-lena-glaze"); + // Not a whole-string match — passes through verbatim. + assert_eq!(value["verbatim"], "see @payload.post"); +} + +#[test] +fn from_payload_keys_resolve_at_deliver_time() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ + { "port": "feed", "projection": "for-post", + "key": { "from": "payload.post" }, "slice": "feed.page-1" } + ] } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + let update = parse(&d.tick()[0])["updates"][0].clone(); + assert_eq!(update["key"], "post-lena-glaze"); + assert_eq!(update["revision"], 2); +} + +#[test] +fn missing_payload_field_fails_the_deliver_call() { + let script = json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ + { "port": "feed", "projection": "feed-page", + "slice": "feed.needs-payload" } + ] } + ] + }); + let cmd = json!({ + "kind": "command", "port": "feed", "command": "like-post", + "correlation": "c-1", "payload": {} + }) + .to_string(); + let mut d = driver(&script); + let err = d.deliver(&cmd).unwrap_err(); + assert!(err.contains("no `post` field"), "{err}"); + assert!(d.idle(), "a failed deliver schedules nothing"); + + // Same for a `{ "from": "payload.<field>" }` key. + let script = json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ + { "port": "feed", "projection": "for-post", + "key": { "from": "payload.post" }, "slice": "feed.page-1" } + ] } + ] + }); + let err = driver(&script).deliver(&cmd).unwrap_err(); + assert!(err.contains("no `post` field"), "{err}"); +} + +// ── outcome shapes ────────────────────────────────────────────────────── + +#[test] +fn outcomes_serialize_to_the_envelope_shapes() { + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, + "outcome": "refused", "refusal": "rate-limited" }, + { "on": { "command": "load-next-page" }, "after-ticks": 1, + "outcome": "unavailable", "reason": "unreachable" } + ] + })); + d.deliver(&like("post-lena-glaze", "c-1")).unwrap(); + d.deliver( + &json!({ + "kind": "command", "port": "feed", "command": "load-next-page", + "correlation": "c-2", "payload": {} + }) + .to_string(), + ) + .unwrap(); + let msgs = d.tick(); + let refused = json!({ + "kind": "outcome", "correlation": "c-1", + "outcome": { "refused": { "refusal": "rate-limited" } }, "updates": [] + }); + let unavailable = json!({ + "kind": "outcome", "correlation": "c-2", + "outcome": { "unavailable": { "reason": "unreachable" } }, "updates": [] + }); + assert_eq!( + msgs, + vec![to_canonical_json(&refused), to_canonical_json(&unavailable)] + ); +} + +// ── the closed grammar (rejections at new) ────────────────────────────── + +#[test] +fn rejects_unknown_keys_at_every_level() { + let cases = [ + json!({ "scripts": [] }), + json!({ "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1", "extra": 1 } ] }), + json!({ "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, + "outcome": "ok", "extra": 1 } ] }), + json!({ "reply": [ + { "on": { "command": "like-post", "port": "feed" }, "after-ticks": 1, + "outcome": "ok" } ] }), + json!({ "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ { "port": "feed", "projection": "feed-page", + "slice": "feed.page-1", "failed": "x" } ] } ] }), + ]; + for script in &cases { + let err = new_err(script); + assert!(err.contains("grammar is closed"), "{script}: {err}"); + } +} + +#[test] +fn rejects_any_on_unscripted_but_error() { + let err = new_err(&json!({ "on-unscripted": "ignore" })); + assert!(err.contains("the only policy"), "{err}"); + driver(&json!({ "on-unscripted": "error" })); +} + +#[test] +fn accepts_and_ignores_the_harness_only_ui_key() { + let d = driver(&json!({ "ui": [ { "anything": ["at", "all"] } ] })); + assert!(d.idle()); +} + +#[test] +fn rejects_after_ticks_below_1() { + for bad in [json!(0), json!(-1), json!("1"), json!(null)] { + let err = new_err(&json!({ "deliver": [ + { "after-ticks": bad, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1" } ] })); + assert!(err.contains("after-ticks"), "{err}"); + } + let err = new_err(&json!({ "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 0, "outcome": "ok" } ] })); + assert!(err.contains("after-ticks"), "{err}"); +} + +#[test] +fn rejects_deliver_without_exactly_one_of_slice_or_failed() { + for entry in [ + json!({ "after-ticks": 1, "port": "feed", "projection": "feed-page" }), + json!({ "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.page-1", "failed": "unreachable" }), + ] { + let err = new_err(&json!({ "deliver": [entry] })); + assert!(err.contains("exactly one of"), "{err}"); + } +} + +#[test] +fn rejects_dangling_slice_references() { + let err = new_err(&json!({ "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.page-9" } ] })); + assert!(err.contains("no fixture slice `feed.page-9`"), "{err}"); + + let err = new_err(&json!({ "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ { "port": "feed", "projection": "feed-page", + "slice": "nope.nothing" } ] } ] })); + assert!(err.contains("no fixture slice `nope.nothing`"), "{err}"); +} + +#[test] +fn rejects_payload_markers_in_standalone_slices_at_new() { + let err = new_err(&json!({ "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "slice": "feed.needs-payload" } ] })); + assert!(err.contains("reply-only"), "{err}"); +} + +#[test] +fn rejects_mismatched_outcome_details() { + let entry = |patch: serde_json::Value| { + let mut entry = json!({ "on": { "command": "like-post" }, "after-ticks": 1 }); + let obj = entry.as_object_mut().unwrap(); + for (k, v) in patch.as_object().unwrap() { + obj.insert(k.clone(), v.clone()); + } + json!({ "reply": [entry] }) + }; + for patch in [ + json!({ "outcome": "ok", "refusal": "rate-limited" }), + json!({ "outcome": "ok", "reason": "unreachable" }), + json!({ "outcome": "refused" }), + json!({ "outcome": "refused", "refusal": "rate-limited", "reason": "x" }), + json!({ "outcome": "unavailable" }), + json!({ "outcome": "unavailable", "reason": "unreachable", "refusal": "x" }), + json!({ "outcome": "exploded" }), + ] { + new_err(&entry(patch)); + } +} + +#[test] +fn rejects_computed_keys_where_a_literal_is_required() { + // Deliver keys are literals. + let err = new_err(&json!({ "deliver": [ + { "after-ticks": 1, "port": "feed", "projection": "feed-page", + "key": { "from": "payload.post" }, "slice": "feed.page-1" } ] })); + assert!(err.contains("reply-only"), "{err}"); + + // Reply keys accept only the exact `{ "from": "payload.<field>" }` shape. + for bad in [ + json!({ "from": "payload.post", "extra": 1 }), + json!({ "from": "post" }), + json!({ "from": "payload." }), + json!({ "from": 3 }), + ] { + let err = new_err(&json!({ "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ { "port": "feed", "projection": "for-post", + "key": bad, "slice": "feed.page-1" } ] } ] })); + assert!(err.contains("payload.<field>"), "{err}"); + } +} + +#[test] +fn rejects_floats_everywhere() { + let err = FixtureDriver::new(r#"{ "feed": { "bad": 1.5 } }"#, "{}").unwrap_err(); + assert!(err.contains("floats"), "{err}"); + + let err = FixtureDriver::new(FIXTURE, r#"{ "reply": [ { "on": { "command": "like-post", "where": { "x": 1.5 } }, "after-ticks": 1, "outcome": "ok" } ] }"#) + .unwrap_err(); + assert!(err.contains("floats"), "{err}"); + + let mut d = driver(&json!({})); + let cmd = r#"{ "kind": "command", "port": "feed", "command": "like-post", + "correlation": "c-1", "payload": { "x": 1.5 } }"#; + let err = d.deliver(cmd).unwrap_err(); + assert!(err.contains("floats"), "{err}"); +} + +#[test] +fn rejects_malformed_fixture_shapes() { + let err = FixtureDriver::new("[]", "{}").unwrap_err(); + assert!(err.contains("<ns>"), "{err}"); + let err = FixtureDriver::new(r#"{ "feed": [] }"#, "{}").unwrap_err(); + assert!(err.contains("object of slices"), "{err}"); +} + +// ── toml_to_json ──────────────────────────────────────────────────────── + +#[test] +fn toml_converts_the_value_model() { + let json = toml_to_json( + r#" +count = 3 +open = true + +[names] +first = "lena" +tags = ["a", "b"] +"#, + ) + .unwrap(); + assert_eq!( + json, + json!({ + "count": 3, "open": true, + "names": { "first": "lena", "tags": ["a", "b"] } + }) + ); +} + +#[test] +fn toml_refuses_floats_and_datetimes() { + assert_eq!( + toml_to_json("x = 1.5").unwrap_err(), + "floats do not exist in fixture data (§7.5)" + ); + assert_eq!( + toml_to_json("when = 1979-05-27T07:32:00Z").unwrap_err(), + "no clocks: time labels are provider-formatted text (§9.1)" + ); + assert_eq!( + toml_to_json("day = 1979-05-27").unwrap_err(), + "no clocks: time labels are provider-formatted text (§9.1)" + ); +} + +#[test] +fn payload_data_equal_to_a_marker_string_echoes_verbatim() { + // §9.5: payload echo is VERBATIM — wire data that happens to spell + // "@fresh-id" (or another marker) must not be re-interpreted, and the + // mint counter must not advance on it. + let mut d = driver(&json!({ + "reply": [ + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ { "port": "feed", "projection": "feed-page", + "slice": "feed.needs-payload" } ] }, + { "on": { "command": "like-post" }, "after-ticks": 1, "outcome": "ok", + "updates": [ { "port": "feed", "projection": "feed-page", + "slice": "feed.fresh-comment" } ] } + ] + })); + d.deliver(&like("@fresh-id", "c-1")).expect("scripted"); + let msgs = d.tick(); + let outcome = parse(&msgs[0]); + assert_eq!( + outcome["updates"][0]["value"], + json!({ "post": "@fresh-id" }), + "spliced payload data is untouched" + ); + // The next authored marker still mints from 1 — wire data never + // advanced the counter. + d.deliver(&like("post-x", "c-2")).expect("scripted"); + let msgs = d.tick(); + let outcome = parse(&msgs[0]); + assert_eq!( + outcome["updates"][0]["value"], + json!({ "a-id": "fresh-1", "b-ids": ["fresh-2"] }) + ); +} diff --git a/uhura/crates/uhura-port/Cargo.toml b/uhura/crates/uhura-port/Cargo.toml new file mode 100644 index 0000000..838a9af --- /dev/null +++ b/uhura/crates/uhura-port/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "uhura-port" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[features] +toml = ["dep:toml"] + +[dependencies] +uhura-base = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true, optional = true } diff --git a/uhura/crates/uhura-port/clippy.toml b/uhura/crates/uhura-port/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-port/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-port/src/contract.rs b/uhura/crates/uhura-port/src/contract.rs new file mode 100644 index 0000000..cce0442 --- /dev/null +++ b/uhura/crates/uhura-port/src/contract.rs @@ -0,0 +1,459 @@ +//! The port contract model (design §9.1) — the data a `ports/*.port.toml` +//! file declares, its well-formedness rules, the canonical form whose +//! SHA-256 is pinned in `uhura.lock`, and contract-typed JSON decoding +//! (the L8 primitive shared by fixtures and projection application). + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::{Ident, Value, hash_json}; + +use crate::types::{RESERVED_TYPE_NAMES, TypeExpr}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortContract { + pub name: Ident, + pub version: String, + pub types: BTreeMap<Ident, TypeDecl>, + pub projections: BTreeMap<Ident, ProjectionDecl>, + pub refusals: BTreeSet<Ident>, + pub commands: BTreeMap<Ident, CommandDecl>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TypeDecl { + Record { + fields: BTreeMap<Ident, TypeExpr>, + }, + /// Closed, exhaustively matchable. Each variant carries a field record. + Union { + variants: BTreeMap<Ident, BTreeMap<Ident, TypeExpr>>, + }, + Enum { + values: BTreeSet<Ident>, + }, + /// A named nominal identity type. + Id, + /// Echoable, never inspectable (cursors). Wire values are strings in + /// the spike. + Opaque, + /// A named asset-reference type. + Asset, +} + +impl TypeDecl { + pub fn kind_str(&self) -> &'static str { + match self { + TypeDecl::Record { .. } => "record", + TypeDecl::Union { .. } => "union", + TypeDecl::Enum { .. } => "enum", + TypeDecl::Id => "id", + TypeDecl::Opaque => "opaque", + TypeDecl::Asset => "asset", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectionDecl { + pub ty: TypeExpr, + /// Keyed projections: the key's id type (`key = "<id-type>"`). + pub key: Option<TypeExpr>, + /// Delivered before `Init`; bare reads are legal (design §9.2). + pub boot: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandDecl { + pub payload: BTreeMap<Ident, TypeExpr>, + /// Declared refusals; the outcome union is implicitly extended by + /// `unavailable { reason: text }`. Ok payloads are empty for every + /// spike command (§9.1). + pub refusals: BTreeSet<Ident>, +} + +/// A contract-level problem, located by TOML key path (contracts have no +/// source spans; the check pass wraps these into file-scoped diagnostics). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContractIssue { + /// e.g. `commands.like-post.refusals` + pub path: String, + pub message: String, +} + +impl ContractIssue { + fn new(path: impl Into<String>, message: impl Into<String>) -> Self { + ContractIssue { + path: path.into(), + message: message.into(), + } + } +} + +impl PortContract { + /// Contract-local well-formedness (cross-source linking is the check + /// pipeline's job): reserved names, dangling type references, key/boot + /// legality, refusal references, and value-recursion (rejected outright + /// in the spike — no contract type may reach itself). + pub fn well_formedness(&self) -> Vec<ContractIssue> { + let mut issues = Vec::new(); + + for name in self.types.keys() { + if RESERVED_TYPE_NAMES.contains(&name.as_str()) { + issues.push(ContractIssue::new( + format!("types.{name}"), + format!("`{name}` is reserved by the type grammar"), + )); + } + } + + let check_expr = |path: &str, ty: &TypeExpr, issues: &mut Vec<ContractIssue>| { + let mut refs = Vec::new(); + ty.named_refs(&mut refs); + for r in refs { + if !self.types.contains_key(r) { + issues.push(ContractIssue::new( + path.to_string(), + format!("type `{r}` is not declared in port `{}`", self.name), + )); + } + } + }; + + for (name, decl) in &self.types { + match decl { + TypeDecl::Record { fields } => { + for (f, ty) in fields { + check_expr(&format!("types.{name}.fields.{f}"), ty, &mut issues); + } + } + TypeDecl::Union { variants } => { + if variants.is_empty() { + issues.push(ContractIssue::new( + format!("types.{name}"), + "a union needs at least one variant", + )); + } + for (v, fields) in variants { + for (f, ty) in fields { + check_expr(&format!("types.{name}.variants.{v}.{f}"), ty, &mut issues); + } + } + } + TypeDecl::Enum { values } => { + if values.is_empty() { + issues.push(ContractIssue::new( + format!("types.{name}"), + "an enum needs at least one value", + )); + } + } + TypeDecl::Id | TypeDecl::Opaque | TypeDecl::Asset => {} + } + } + + for (name, proj) in &self.projections { + check_expr(&format!("projections.{name}.type"), &proj.ty, &mut issues); + if let Some(key) = &proj.key { + check_expr(&format!("projections.{name}.key"), key, &mut issues); + if !self.is_id_type(key) { + issues.push(ContractIssue::new( + format!("projections.{name}.key"), + format!("projection keys must be id types, not `{key}`"), + )); + } + if proj.boot { + issues.push(ContractIssue::new( + format!("projections.{name}"), + "a keyed projection cannot be `boot = true` \ + (boot delivery has no key to name an instance)", + )); + } + } + } + + for (name, cmd) in &self.commands { + for (f, ty) in &cmd.payload { + check_expr(&format!("commands.{name}.payload.{f}"), ty, &mut issues); + } + for r in &cmd.refusals { + if !self.refusals.contains(r) { + issues.push(ContractIssue::new( + format!("commands.{name}.refusals"), + format!("refusal `{r}` is not declared in port `{}`", self.name), + )); + } + } + } + + issues.extend(self.recursion_issues()); + issues + } + + /// True if `ty` denotes identity: the builtin `id` or a declared + /// `kind = "id"` type. + pub fn is_id_type(&self, ty: &TypeExpr) -> bool { + match ty { + TypeExpr::Id => true, + TypeExpr::Named(n) => matches!(self.types.get(n), Some(TypeDecl::Id)), + _ => false, + } + } + + /// Rejects every cycle through `[types]` — a self-reaching contract + /// type has no finite canonical form (spike micro-decision; the corpus + /// needs none). + fn recursion_issues(&self) -> Vec<ContractIssue> { + let mut issues = Vec::new(); + for start in self.types.keys() { + let mut stack = vec![start]; + let mut visited = BTreeSet::new(); + let mut cyclic = false; + while let Some(name) = stack.pop() { + if !visited.insert(name) { + continue; + } + for referenced in self.direct_refs(name) { + if referenced == start { + cyclic = true; + } + stack.push(referenced); + } + } + if cyclic { + issues.push(ContractIssue::new( + format!("types.{start}"), + format!("type `{start}` is recursive; contract types must be finite"), + )); + } + } + issues + } + + fn direct_refs(&self, name: &Ident) -> Vec<&Ident> { + let mut out = Vec::new(); + match self.types.get(name) { + Some(TypeDecl::Record { fields }) => { + for ty in fields.values() { + ty.named_refs(&mut out); + } + } + Some(TypeDecl::Union { variants }) => { + for fields in variants.values() { + for ty in fields.values() { + ty.named_refs(&mut out); + } + } + } + _ => {} + } + out + } + + /// The canonical JSON form — sorted keys by construction (`BTreeMap` + /// backing), type expressions in their strict string grammar. This is + /// the byte form `uhura.lock` pins. + pub fn to_canonical_json(&self) -> serde_json::Value { + use serde_json::{Map, Value as J, json}; + let types: Map<String, J> = self + .types + .iter() + .map(|(name, decl)| { + let body = match decl { + TypeDecl::Record { fields } => json!({ + "kind": "record", + "fields": type_map_json(fields), + }), + TypeDecl::Union { variants } => json!({ + "kind": "union", + "variants": variants + .iter() + .map(|(v, fields)| (v.to_string(), J::Object(type_map_json(fields)))) + .collect::<Map<String, J>>(), + }), + TypeDecl::Enum { values } => json!({ + "kind": "enum", + "values": values.iter().map(ToString::to_string).collect::<Vec<_>>(), + }), + TypeDecl::Id | TypeDecl::Opaque | TypeDecl::Asset => json!({ + "kind": decl.kind_str(), + }), + }; + (name.to_string(), body) + }) + .collect(); + let projections: Map<String, J> = self + .projections + .iter() + .map(|(name, p)| { + ( + name.to_string(), + json!({ + "type": p.ty.to_string(), + "key": p.key.as_ref().map(ToString::to_string), + "boot": p.boot, + }), + ) + }) + .collect(); + let commands: Map<String, J> = self + .commands + .iter() + .map(|(name, c)| { + ( + name.to_string(), + json!({ + "payload": type_map_json(&c.payload), + "refusals": c.refusals.iter().map(ToString::to_string).collect::<Vec<_>>(), + }), + ) + }) + .collect(); + json!({ + "contract": "uhura-port/0", + "name": self.name.to_string(), + "version": self.version, + "types": types, + "projections": projections, + "refusals": self.refusals.iter().map(ToString::to_string).collect::<Vec<_>>(), + "commands": commands, + }) + } + + /// SHA-256 of the canonical form — the `uhura.lock` pin (§9.1). + pub fn canonical_hash(&self) -> String { + hash_json(&self.to_canonical_json()) + } + + /// Decodes wire/fixture JSON against a contract type into a typed + /// `Value`. Strict: no missing or extra record fields, integers only, + /// unions as single-key `{ "<variant>": { fields } }` objects, enums + /// as declared value strings, id/asset/opaque as strings. + pub fn decode_value(&self, json: &serde_json::Value, ty: &TypeExpr) -> Result<Value, String> { + use serde_json::Value as J; + match ty { + TypeExpr::Bool => match json { + J::Bool(b) => Ok(Value::Bool(*b)), + other => Err(type_mismatch("bool", other)), + }, + TypeExpr::Int => match json { + J::Number(n) => n + .as_i64() + .map(Value::Int) + .ok_or_else(|| format!("`{n}` is not an i64 (floats do not exist)")), + other => Err(type_mismatch("int", other)), + }, + TypeExpr::Text => match json { + J::String(s) => Ok(Value::Text(s.clone())), + other => Err(type_mismatch("text", other)), + }, + TypeExpr::Id | TypeExpr::Asset => match json { + J::String(s) => Ok(Value::Id(s.clone())), + other => Err(type_mismatch(&ty.to_string(), other)), + }, + // Optionals decode BARE (`Value::None` or the inner value — + // `Value::Some` is never constructed; options do not nest, + // §7.1), matching the runtime representation everywhere. + TypeExpr::Option(inner) => match json { + J::Null => Ok(Value::None), + present => self.decode_value(present, inner), + }, + TypeExpr::List(inner) => match json { + J::Array(items) => items + .iter() + .map(|item| self.decode_value(item, inner)) + .collect::<Result<Vec<_>, _>>() + .map(Value::List), + other => Err(type_mismatch(&ty.to_string(), other)), + }, + TypeExpr::Named(name) => match self.types.get(name) { + None => Err(format!("type `{name}` is not declared")), + Some(TypeDecl::Id | TypeDecl::Opaque | TypeDecl::Asset) => match json { + J::String(s) => Ok(Value::Id(s.clone())), + other => Err(type_mismatch(name.as_str(), other)), + }, + Some(TypeDecl::Enum { values }) => match json { + J::String(s) if values.iter().any(|v| v.as_str() == s) => { + Ok(Value::Text(s.clone())) + } + J::String(s) => Err(format!("`{s}` is not a value of enum `{name}`")), + other => Err(type_mismatch(name.as_str(), other)), + }, + Some(TypeDecl::Record { fields }) => { + self.decode_record(json, name.as_str(), fields) + } + Some(TypeDecl::Union { variants }) => { + let J::Object(obj) = json else { + return Err(type_mismatch(name.as_str(), json)); + }; + if obj.len() != 1 { + return Err(format!( + "a `{name}` union value is a single-variant object, got {} keys", + obj.len() + )); + } + let (variant, body) = obj.iter().next().expect("len checked"); + let Ok(variant_ident) = Ident::new(variant) else { + return Err(format!("`{variant}` is not a variant of `{name}`")); + }; + let Some(fields) = variants.get(&variant_ident) else { + return Err(format!("`{variant}` is not a variant of `{name}`")); + }; + let payload = self.decode_record(body, variant, fields)?; + let mut rec = BTreeMap::new(); + rec.insert(variant_ident, payload); + Ok(Value::Record(rec)) + } + }, + } + } + + fn decode_record( + &self, + json: &serde_json::Value, + name: &str, + fields: &BTreeMap<Ident, TypeExpr>, + ) -> Result<Value, String> { + let serde_json::Value::Object(obj) = json else { + return Err(type_mismatch(name, json)); + }; + for key in obj.keys() { + if Ident::new(key).map_or(true, |k| !fields.contains_key(&k)) { + return Err(format!("`{key}` is not a field of `{name}`")); + } + } + let mut rec = BTreeMap::new(); + for (field, ty) in fields { + let Some(field_json) = obj.get(field.as_str()) else { + if matches!(ty, TypeExpr::Option(_)) { + rec.insert(field.clone(), Value::None); + continue; + } + return Err(format!("`{name}` is missing required field `{field}`")); + }; + let value = self + .decode_value(field_json, ty) + .map_err(|e| format!("in `{name}.{field}`: {e}"))?; + rec.insert(field.clone(), value); + } + Ok(Value::Record(rec)) + } +} + +fn type_map_json(fields: &BTreeMap<Ident, TypeExpr>) -> serde_json::Map<String, serde_json::Value> { + fields + .iter() + .map(|(f, ty)| (f.to_string(), serde_json::Value::String(ty.to_string()))) + .collect() +} + +fn type_mismatch(expected: &str, got: &serde_json::Value) -> String { + let shape = match got { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "a bool", + serde_json::Value::Number(_) => "a number", + serde_json::Value::String(_) => "a string", + serde_json::Value::Array(_) => "a list", + serde_json::Value::Object(_) => "an object", + }; + format!("expected `{expected}`, got {shape}") +} diff --git a/uhura/crates/uhura-port/src/envelope.rs b/uhura/crates/uhura-port/src/envelope.rs new file mode 100644 index 0000000..8c2611c --- /dev/null +++ b/uhura/crates/uhura-port/src/envelope.rs @@ -0,0 +1,294 @@ +//! The `uhura-provider/0` wire messages (design §9.3) — hand-written stable +//! JSON shapes (plan micro-decision #14), never serde-tagged enums. +//! +//! Payload/value fields cross the boundary as raw canonical JSON: the typed +//! side (core building a command, the harness applying an update) converts +//! at its edge via `Value::to_json` / `PortContract::decode_value`. + +use uhura_base::Ident; + +/// Asserted at shell boot (§12.3); messages themselves carry no protocol +/// field. +pub const PROVIDER_PROTOCOL: &str = "uhura-provider/0"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProviderMsg { + Command(CommandEnvelope), + Projection(ProjectionUpdate), + ProjectionFailed { + port: Ident, + projection: Ident, + key: Option<serde_json::Value>, + reason: String, + }, + Outcome(OutcomeEnvelope), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CommandEnvelope { + pub port: Ident, + pub command: Ident, + /// Core-minted, opaque, echoed verbatim (`"c-<n>"`). + pub correlation: String, + pub payload: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectionUpdate { + pub port: Ident, + pub projection: Ident, + pub key: Option<serde_json::Value>, + /// Strictly increasing per (projection, key); stale deliveries are + /// dropped with a diagnostic (§9.3). + pub revision: u64, + pub value: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutcomeEnvelope { + pub correlation: String, + pub outcome: OutcomeResult, + /// Settlement updates, applied atomically before the outcome + /// dispatches (§9.4). + pub updates: Vec<ProjectionUpdate>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OutcomeResult { + Ok, + Refused { + refusal: Ident, + }, + /// The implicit extension of every outcome union; routes to `.err`. + Unavailable { + reason: String, + }, +} + +impl ProjectionUpdate { + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "port": self.port.to_string(), + "projection": self.projection.to_string(), + "key": self.key, + "revision": self.revision, + "value": self.value, + }) + } + + pub fn from_json(json: &serde_json::Value) -> Result<Self, String> { + Ok(ProjectionUpdate { + port: ident_field(json, "port")?, + projection: ident_field(json, "projection")?, + key: key_field(json)?, + revision: json + .get("revision") + .and_then(serde_json::Value::as_u64) + .ok_or("`revision` must be a non-negative integer")?, + value: json.get("value").cloned().ok_or("missing `value`")?, + }) + } +} + +impl OutcomeResult { + pub fn to_json(&self) -> serde_json::Value { + match self { + OutcomeResult::Ok => serde_json::json!({ "ok": {} }), + OutcomeResult::Refused { refusal } => serde_json::json!({ + "refused": { "refusal": refusal.to_string() } + }), + OutcomeResult::Unavailable { reason } => serde_json::json!({ + "unavailable": { "reason": reason } + }), + } + } + + pub fn from_json(json: &serde_json::Value) -> Result<Self, String> { + let obj = json + .as_object() + .filter(|o| o.len() == 1) + .ok_or("`outcome` must be a single-variant object")?; + let (variant, body) = obj.iter().next().expect("len checked"); + match variant.as_str() { + "ok" => Ok(OutcomeResult::Ok), + "refused" => { + let refusal = body + .get("refusal") + .and_then(serde_json::Value::as_str) + .ok_or("`refused` needs a `refusal` name")?; + Ok(OutcomeResult::Refused { + refusal: Ident::new(refusal).map_err(|e| e.to_string())?, + }) + } + "unavailable" => { + let reason = body + .get("reason") + .and_then(serde_json::Value::as_str) + .ok_or("`unavailable` needs a `reason` text")?; + Ok(OutcomeResult::Unavailable { + reason: reason.to_string(), + }) + } + other => Err(format!("`{other}` is not an outcome variant")), + } + } +} + +impl ProviderMsg { + pub fn to_json(&self) -> serde_json::Value { + match self { + ProviderMsg::Command(c) => serde_json::json!({ + "kind": "command", + "port": c.port.to_string(), + "command": c.command.to_string(), + "correlation": c.correlation, + "payload": c.payload, + }), + ProviderMsg::Projection(u) => { + let mut json = u.to_json(); + json["kind"] = serde_json::Value::String("projection".into()); + json + } + ProviderMsg::ProjectionFailed { + port, + projection, + key, + reason, + } => serde_json::json!({ + "kind": "projection-failed", + "port": port.to_string(), + "projection": projection.to_string(), + "key": key, + "reason": reason, + }), + ProviderMsg::Outcome(o) => serde_json::json!({ + "kind": "outcome", + "correlation": o.correlation, + "outcome": o.outcome.to_json(), + "updates": o.updates.iter().map(ProjectionUpdate::to_json).collect::<Vec<_>>(), + }), + } + } + + pub fn from_json(json: &serde_json::Value) -> Result<Self, String> { + let kind = json + .get("kind") + .and_then(serde_json::Value::as_str) + .ok_or("a provider message needs a `kind`")?; + match kind { + "command" => Ok(ProviderMsg::Command(CommandEnvelope { + port: ident_field(json, "port")?, + command: ident_field(json, "command")?, + correlation: str_field(json, "correlation")?, + payload: json.get("payload").cloned().ok_or("missing `payload`")?, + })), + "projection" => Ok(ProviderMsg::Projection(ProjectionUpdate::from_json(json)?)), + "projection-failed" => Ok(ProviderMsg::ProjectionFailed { + port: ident_field(json, "port")?, + projection: ident_field(json, "projection")?, + key: key_field(json)?, + reason: str_field(json, "reason")?, + }), + "outcome" => { + let updates = match json.get("updates") { + None => Vec::new(), + Some(serde_json::Value::Array(items)) => items + .iter() + .map(ProjectionUpdate::from_json) + .collect::<Result<Vec<_>, _>>()?, + Some(_) => return Err("`updates` must be a list".to_string()), + }; + Ok(ProviderMsg::Outcome(OutcomeEnvelope { + correlation: str_field(json, "correlation")?, + outcome: OutcomeResult::from_json( + json.get("outcome").ok_or("missing `outcome`")?, + )?, + updates, + })) + } + other => Err(format!("`{other}` is not a provider message kind")), + } + } +} + +fn str_field(json: &serde_json::Value, field: &str) -> Result<String, String> { + json.get(field) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| format!("missing or non-text `{field}`")) +} + +fn ident_field(json: &serde_json::Value, field: &str) -> Result<Ident, String> { + Ident::new(&str_field(json, field)?).map_err(|e| e.to_string()) +} + +fn key_field(json: &serde_json::Value) -> Result<Option<serde_json::Value>, String> { + match json.get("key") { + None | Some(serde_json::Value::Null) => Ok(None), + Some(k) => Ok(Some(k.clone())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ident(s: &str) -> Ident { + Ident::new(s).unwrap() + } + + #[test] + fn round_trips_every_kind() { + let msgs = [ + ProviderMsg::Command(CommandEnvelope { + port: ident("feed"), + command: ident("like-post"), + correlation: "c-4".into(), + payload: serde_json::json!({ "post": "post-lena-glaze" }), + }), + ProviderMsg::Projection(ProjectionUpdate { + port: ident("feed"), + projection: ident("feed-page"), + key: None, + revision: 2, + value: serde_json::json!({ "has-more": true }), + }), + ProviderMsg::ProjectionFailed { + port: ident("feed"), + projection: ident("feed-page"), + key: None, + reason: "unreachable".into(), + }, + ProviderMsg::Outcome(OutcomeEnvelope { + correlation: "c-4".into(), + outcome: OutcomeResult::Refused { + refusal: ident("rate-limited"), + }, + updates: vec![ProjectionUpdate { + port: ident("comments"), + projection: ident("for-post"), + key: Some(serde_json::json!("post-lena-glaze")), + revision: 3, + value: serde_json::json!({ "comments": [] }), + }], + }), + ]; + for msg in msgs { + let json = msg.to_json(); + assert_eq!(ProviderMsg::from_json(&json).unwrap(), msg, "{json}"); + } + } + + #[test] + fn outcome_shapes_match_the_design() { + assert_eq!(OutcomeResult::Ok.to_json().to_string(), r#"{"ok":{}}"#); + assert_eq!( + OutcomeResult::Refused { + refusal: ident("rate-limited") + } + .to_json() + .to_string(), + r#"{"refused":{"refusal":"rate-limited"}}"# + ); + } +} diff --git a/uhura/crates/uhura-port/src/lib.rs b/uhura/crates/uhura-port/src/lib.rs new file mode 100644 index 0000000..f192271 --- /dev/null +++ b/uhura/crates/uhura-port/src/lib.rs @@ -0,0 +1,19 @@ +//! uhura-port: the Spock seam — port contract model, closed type grammar, +//! and the `uhura-provider/0` envelopes. This crate is exactly what a future +//! real Spock adapter implements against (design §9). +#![deny(clippy::float_arithmetic)] + +pub mod contract; +pub mod envelope; +#[cfg(feature = "toml")] +pub mod load; +pub mod types; + +pub use contract::{CommandDecl, ContractIssue, PortContract, ProjectionDecl, TypeDecl}; +pub use envelope::{ + CommandEnvelope, OutcomeEnvelope, OutcomeResult, PROVIDER_PROTOCOL, ProjectionUpdate, + ProviderMsg, +}; +#[cfg(feature = "toml")] +pub use load::load_port_contract; +pub use types::TypeExpr; diff --git a/uhura/crates/uhura-port/src/load.rs b/uhura/crates/uhura-port/src/load.rs new file mode 100644 index 0000000..df422f1 --- /dev/null +++ b/uhura/crates/uhura-port/src/load.rs @@ -0,0 +1,351 @@ +//! `ports/*.port.toml` → `PortContract` (feature `toml`; core never +//! compiles this — the purity suite proves it). Strict walking: unknown +//! keys are errors, every issue carries its TOML key path. + +use std::collections::{BTreeMap, BTreeSet}; + +use uhura_base::Ident; + +use crate::contract::{CommandDecl, ContractIssue, PortContract, ProjectionDecl, TypeDecl}; +use crate::types::TypeExpr; + +/// Loads and fully validates one port contract. `Err` carries every issue +/// found (structural and well-formedness); `Ok` contracts are clean. +pub fn load_port_contract(text: &str) -> Result<PortContract, Vec<ContractIssue>> { + let table: toml::Table = match text.parse() { + Ok(t) => t, + Err(e) => { + return Err(vec![ContractIssue { + path: String::new(), + message: format!("invalid TOML: {e}"), + }]); + } + }; + let mut issues = Vec::new(); + let mut walker = Walker { + issues: &mut issues, + }; + let contract = walker.contract(&table); + match contract { + Some(c) if issues.is_empty() => { + let wf = c.well_formedness(); + if wf.is_empty() { Ok(c) } else { Err(wf) } + } + _ => Err(issues), + } +} + +struct Walker<'a> { + issues: &'a mut Vec<ContractIssue>, +} + +impl Walker<'_> { + fn push(&mut self, path: &str, message: impl Into<String>) { + self.issues.push(ContractIssue { + path: path.to_string(), + message: message.into(), + }); + } + + fn contract(&mut self, table: &toml::Table) -> Option<PortContract> { + self.expect_keys( + "", + table, + &["port", "types", "projections", "refusals", "commands"], + ); + + let port = self.table_at("port", table.get("port"))?; + self.expect_keys("port", port, &["name", "version"]); + let name = self.ident("port.name", port.get("name"))?; + let version = self.string("port.version", port.get("version"))?; + + let mut types = BTreeMap::new(); + if let Some(v) = table.get("types") { + for (type_name, decl) in self.named_tables("types", v) { + let path = format!("types.{type_name}"); + let Some(type_ident) = self.ident_key(&path, &type_name) else { + continue; + }; + if let Some(decl) = self.type_decl(&path, decl) { + types.insert(type_ident, decl); + } + } + } + + let mut projections = BTreeMap::new(); + if let Some(v) = table.get("projections") { + for (proj_name, decl) in self.named_tables("projections", v) { + let path = format!("projections.{proj_name}"); + let Some(proj_ident) = self.ident_key(&path, &proj_name) else { + continue; + }; + if let Some(decl) = self.projection_decl(&path, decl) { + projections.insert(proj_ident, decl); + } + } + } + + let mut refusals = BTreeSet::new(); + if let Some(v) = table.get("refusals") { + for (refusal_name, decl) in self.named_tables("refusals", v) { + let path = format!("refusals.{refusal_name}"); + if !decl.is_empty() { + self.push(&path, "refusals carry no fields in the spike (§9.1)"); + } + if let Some(ident) = self.ident_key(&path, &refusal_name) { + refusals.insert(ident); + } + } + } + + let mut commands = BTreeMap::new(); + if let Some(v) = table.get("commands") { + for (cmd_name, decl) in self.named_tables("commands", v) { + let path = format!("commands.{cmd_name}"); + let Some(cmd_ident) = self.ident_key(&path, &cmd_name) else { + continue; + }; + if let Some(decl) = self.command_decl(&path, decl) { + commands.insert(cmd_ident, decl); + } + } + } + + Some(PortContract { + name, + version, + types, + projections, + refusals, + commands, + }) + } + + fn type_decl(&mut self, path: &str, table: &toml::Table) -> Option<TypeDecl> { + let kind = self.string(&format!("{path}.kind"), table.get("kind"))?; + match kind.as_str() { + "record" => { + self.expect_keys(path, table, &["kind", "fields"]); + let fields = match table.get("fields") { + Some(v) => self.type_map(&format!("{path}.fields"), Some(v))?, + None => BTreeMap::new(), + }; + Some(TypeDecl::Record { fields }) + } + "union" => { + self.expect_keys(path, table, &["kind", "variants"]); + let mut variants = BTreeMap::new(); + if let Some(v) = table.get("variants") { + for (variant_name, fields) in self.named_tables(&format!("{path}.variants"), v) + { + let vpath = format!("{path}.variants.{variant_name}"); + let Some(variant_ident) = self.ident_key(&vpath, &variant_name) else { + continue; + }; + let fields_value = toml::Value::Table(fields.clone()); + if let Some(fields) = self.type_map(&vpath, Some(&fields_value)) { + variants.insert(variant_ident, fields); + } + } + } + Some(TypeDecl::Union { variants }) + } + "enum" => { + self.expect_keys(path, table, &["kind", "values"]); + let mut values = BTreeSet::new(); + match table.get("values") { + Some(toml::Value::Array(items)) => { + for (i, item) in items.iter().enumerate() { + let vpath = format!("{path}.values[{i}]"); + if let Some(s) = self.string_value(&vpath, item) + && let Some(ident) = self.ident_key(&vpath, &s) + { + values.insert(ident); + } + } + } + _ => self.push(path, "an enum needs a `values` array"), + } + Some(TypeDecl::Enum { values }) + } + "id" => { + self.expect_keys(path, table, &["kind"]); + Some(TypeDecl::Id) + } + "opaque" => { + self.expect_keys(path, table, &["kind"]); + Some(TypeDecl::Opaque) + } + "asset" => { + self.expect_keys(path, table, &["kind"]); + Some(TypeDecl::Asset) + } + other => { + self.push( + path, + format!( + "`{other}` is not a type kind \ + (record | union | enum | id | opaque | asset)" + ), + ); + None + } + } + } + + fn projection_decl(&mut self, path: &str, table: &toml::Table) -> Option<ProjectionDecl> { + self.expect_keys(path, table, &["type", "key", "boot"]); + let ty = self.type_expr(&format!("{path}.type"), table.get("type"))?; + let key = match table.get("key") { + None => None, + some => Some(self.type_expr(&format!("{path}.key"), some)?), + }; + let boot = match table.get("boot") { + None => false, + Some(toml::Value::Boolean(b)) => *b, + Some(_) => { + self.push(&format!("{path}.boot"), "`boot` must be a bool"); + false + } + }; + Some(ProjectionDecl { ty, key, boot }) + } + + fn command_decl(&mut self, path: &str, table: &toml::Table) -> Option<CommandDecl> { + self.expect_keys(path, table, &["payload", "refusals"]); + let payload = self.type_map(&format!("{path}.payload"), table.get("payload"))?; + let mut refusals = BTreeSet::new(); + match table.get("refusals") { + None => {} + Some(toml::Value::Array(items)) => { + for (i, item) in items.iter().enumerate() { + let rpath = format!("{path}.refusals[{i}]"); + if let Some(s) = self.string_value(&rpath, item) + && let Some(ident) = self.ident_key(&rpath, &s) + && !refusals.insert(ident) + { + self.push(&rpath, format!("duplicate refusal `{s}`")); + } + } + } + Some(_) => self.push(&format!("{path}.refusals"), "`refusals` must be an array"), + } + Some(CommandDecl { payload, refusals }) + } + + fn type_map( + &mut self, + path: &str, + value: Option<&toml::Value>, + ) -> Option<BTreeMap<Ident, TypeExpr>> { + let table = self.table_at(path, value)?; + let mut out = BTreeMap::new(); + for (field, v) in table { + let fpath = format!("{path}.{field}"); + let Some(field_ident) = self.ident_key(&fpath, field) else { + continue; + }; + if let Some(ty) = self.type_expr(&fpath, Some(v)) { + out.insert(field_ident, ty); + } + } + Some(out) + } + + fn type_expr(&mut self, path: &str, value: Option<&toml::Value>) -> Option<TypeExpr> { + let s = self.string(path, value)?; + match TypeExpr::parse(&s) { + Ok(ty) => Some(ty), + Err(e) => { + self.push(path, e); + None + } + } + } + + /// The (name, table) entries of a table-of-tables section. + fn named_tables<'v>( + &mut self, + path: &str, + value: &'v toml::Value, + ) -> Vec<(String, &'v toml::Table)> { + let Some(table) = value.as_table() else { + self.push(path, "expected a table"); + return Vec::new(); + }; + let mut out = Vec::new(); + for (name, v) in table { + match v.as_table() { + Some(t) => out.push((name.clone(), t)), + None => self.push(&format!("{path}.{name}"), "expected a table"), + } + } + out + } + + fn table_at<'v>( + &mut self, + path: &str, + value: Option<&'v toml::Value>, + ) -> Option<&'v toml::Table> { + match value { + Some(toml::Value::Table(t)) => Some(t), + Some(_) => { + self.push(path, "expected a table"); + None + } + None => { + self.push(path, "missing required section"); + None + } + } + } + + fn string(&mut self, path: &str, value: Option<&toml::Value>) -> Option<String> { + match value { + Some(v) => self.string_value(path, v), + None => { + self.push(path, "missing required string"); + None + } + } + } + + fn string_value(&mut self, path: &str, value: &toml::Value) -> Option<String> { + match value.as_str() { + Some(s) => Some(s.to_string()), + None => { + self.push(path, "expected a string"); + None + } + } + } + + fn ident(&mut self, path: &str, value: Option<&toml::Value>) -> Option<Ident> { + let s = self.string(path, value)?; + self.ident_key(path, &s) + } + + fn ident_key(&mut self, path: &str, s: &str) -> Option<Ident> { + match Ident::new(s) { + Ok(i) => Some(i), + Err(e) => { + self.push(path, e.to_string()); + None + } + } + } + + fn expect_keys(&mut self, path: &str, table: &toml::Table, allowed: &[&str]) { + for key in table.keys() { + if !allowed.contains(&key.as_str()) { + let full = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + self.push(&full, format!("unknown key `{key}`")); + } + } + } +} diff --git a/uhura/crates/uhura-port/src/types.rs b/uhura/crates/uhura-port/src/types.rs new file mode 100644 index 0000000..301f778 --- /dev/null +++ b/uhura/crates/uhura-port/src/types.rs @@ -0,0 +1,118 @@ +//! The closed port-contract type grammar (design §9.1): +//! `bool | int | text | id | asset | option<T> | list<T> | <declared>`. +//! +//! `id` and `asset` are ambient builtins (the normative §9.1 TOML uses them +//! bare); `kind = "id" | "opaque" | "asset"` additionally lets a contract +//! declare *named* nominal types of those kinds (`feed-cursor`). `tag` is +//! core-minted and can never appear in a contract. + +use std::fmt; + +use uhura_base::Ident; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TypeExpr { + Bool, + Int, + Text, + /// The ambient nominal-identity builtin. + Id, + /// The ambient asset-reference builtin. + Asset, + Option(Box<TypeExpr>), + List(Box<TypeExpr>), + /// A contract-declared type name, resolved against the port's `[types]`. + Named(Ident), +} + +impl TypeExpr { + /// Parses the strict wire grammar. No interior whitespace; single- + /// argument generics only. + pub fn parse(s: &str) -> Result<TypeExpr, String> { + match s { + "bool" => return Ok(TypeExpr::Bool), + "int" => return Ok(TypeExpr::Int), + "text" => return Ok(TypeExpr::Text), + "id" => return Ok(TypeExpr::Id), + "asset" => return Ok(TypeExpr::Asset), + _ => {} + } + if let Some(inner) = generic_arg(s, "option") { + return Ok(TypeExpr::Option(Box::new(TypeExpr::parse(inner)?))); + } + if let Some(inner) = generic_arg(s, "list") { + return Ok(TypeExpr::List(Box::new(TypeExpr::parse(inner)?))); + } + match Ident::new(s) { + Ok(name) => Ok(TypeExpr::Named(name)), + Err(_) => Err(format!( + "`{s}` is not a type: expected `bool`, `int`, `text`, `id`, \ + `asset`, `option<T>`, `list<T>`, or a declared type name" + )), + } + } + + /// The declared type names this expression references (for resolution + /// and cycle checks). + pub fn named_refs<'a>(&'a self, out: &mut Vec<&'a Ident>) { + match self { + TypeExpr::Named(n) => out.push(n), + TypeExpr::Option(t) | TypeExpr::List(t) => t.named_refs(out), + _ => {} + } + } +} + +fn generic_arg<'a>(s: &'a str, head: &str) -> Option<&'a str> { + let rest = s.strip_prefix(head)?; + let rest = rest.strip_prefix('<')?; + rest.strip_suffix('>') +} + +impl fmt::Display for TypeExpr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TypeExpr::Bool => write!(f, "bool"), + TypeExpr::Int => write!(f, "int"), + TypeExpr::Text => write!(f, "text"), + TypeExpr::Id => write!(f, "id"), + TypeExpr::Asset => write!(f, "asset"), + TypeExpr::Option(t) => write!(f, "option<{t}>"), + TypeExpr::List(t) => write!(f, "list<{t}>"), + TypeExpr::Named(n) => write!(f, "{n}"), + } + } +} + +/// Names a contract may not declare a type under (they are grammar). +pub const RESERVED_TYPE_NAMES: &[&str] = &[ + "bool", "int", "text", "id", "asset", "tag", "option", "list", "map", "none", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_builtins_and_generics() { + assert_eq!(TypeExpr::parse("bool").unwrap(), TypeExpr::Bool); + assert_eq!( + TypeExpr::parse("option<feed-cursor>").unwrap().to_string(), + "option<feed-cursor>" + ); + assert_eq!( + TypeExpr::parse("list<option<id>>").unwrap(), + TypeExpr::List(Box::new(TypeExpr::Option(Box::new(TypeExpr::Id)))) + ); + } + + #[test] + fn rejects_junk() { + for bad in ["", "Bool", "list<", "list<>", "option<a b>", "map<id>"] { + assert!(TypeExpr::parse(bad).is_err(), "{bad}"); + } + // `map` alone parses as an ident-shaped name; the reserved-name check + // in contract validation rejects declaring it. + assert!(TypeExpr::parse("map").is_ok()); + } +} diff --git a/uhura/crates/uhura-project/Cargo.toml b/uhura/crates/uhura-project/Cargo.toml new file mode 100644 index 0000000..628a4bc --- /dev/null +++ b/uhura/crates/uhura-project/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "uhura-project" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } +uhura-core = { workspace = true } +serde_json = { workspace = true } diff --git a/uhura/crates/uhura-project/clippy.toml b/uhura/crates/uhura-project/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-project/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-project/src/icons.rs b/uhura/crates/uhura-project/src/icons.rs new file mode 100644 index 0000000..ab83e4c --- /dev/null +++ b/uhura/crates/uhura-project/src/icons.rs @@ -0,0 +1,53 @@ +//! The base catalog's closed icon set as inline SVG glyphs (24×24, +//! stroke/fill from `currentColor`). Hand-authored: the static canvas is +//! self-contained by contract (§8.3) — no icon font, no fetches. + +/// The inner SVG markup for a catalog icon name, if we ship a glyph. +pub fn glyph(name: &str) -> Option<&'static str> { + let body = match name { + "home" => { + r#"<path d="M4 11 12 4l8 7v8a1 1 0 0 1-1 1h-4v-6h-6v6H5a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>"# + } + "search" => { + r#"<circle cx="10.5" cy="10.5" r="6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m15.5 15.5 5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>"# + } + "plus" => { + r#"<rect x="3.5" y="3.5" width="17" height="17" rx="4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 8v8M8 12h8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>"# + } + "reels" => { + r#"<rect x="3.5" y="3.5" width="17" height="17" rx="4" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3.5 8.5h17M8.5 3.5l3 5M14 3.5l3 5" stroke="currentColor" stroke-width="1.6"/><path d="m10.5 12.2 4.4 2.6-4.4 2.6z" fill="currentColor"/>"# + } + "profile" => { + r#"<circle cx="12" cy="8.6" r="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M4.8 20a7.4 7.4 0 0 1 14.4 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>"# + } + "heart" => { + r#"<path d="M12 20.3 5 13.6a4.6 4.6 0 0 1 6.5-6.5l.5.5.5-.5a4.6 4.6 0 0 1 6.5 6.5z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>"# + } + "heart-filled" => { + r#"<path d="M12 20.3 5 13.6a4.6 4.6 0 0 1 6.5-6.5l.5.5.5-.5a4.6 4.6 0 0 1 6.5 6.5z" fill="currentColor"/>"# + } + "comment" => { + r#"<path d="M20 11.6A8 8 0 1 0 7 17.9L4.5 20l.6-3.2A8 8 0 0 0 20 11.6z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>"# + } + "close" => { + r#"<path d="m6 6 12 12M18 6 6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>"# + } + "back" => { + r#"<path d="M14.5 5 8 12l6.5 7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>"# + } + "grid" => { + r#"<path d="M4 4h16v16H4zM4 10.7h16M4 17.3h16M10.7 4v16M17.3 4v16" fill="none" stroke="currentColor" stroke-width="1.5"/>"# + } + "layers" => { + r#"<path d="m12 4 8 4.5-8 4.5-8-4.5z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m5.2 12.8 6.8 3.8 6.8-3.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m5.2 16.3 6.8 3.8 6.8-3.8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>"# + } + "video-off" => { + r#"<path d="M4 7.5A1.5 1.5 0 0 1 5.5 6h8A1.5 1.5 0 0 1 15 7.5v9a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 4 16.5zM15 10.5l5-2.5v8l-5-2.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m3.5 3.5 17 17" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>"# + } + "progress" => { + r#"<circle cx="12" cy="12" r="7.5" fill="none" stroke="currentColor" stroke-width="1.8" opacity="0.25"/><path d="M12 4.5a7.5 7.5 0 0 1 7.5 7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>"# + } + _ => return None, + }; + Some(body) +} diff --git a/uhura/crates/uhura-project/src/lib.rs b/uhura/crates/uhura-project/src/lib.rs new file mode 100644 index 0000000..355afc0 --- /dev/null +++ b/uhura/crates/uhura-project/src/lib.rs @@ -0,0 +1,544 @@ +//! uhura-project: static projection — resolved example previews → HTML +//! frames → one self-contained canvas.html. Pure string production; all +//! file I/O lives in uhura-cli (design §8.3). +//! +//! The mapping is plain HTML per §8.3: real elements with a correct a11y +//! tree inside `inert` frames, authored classes passed through beside a +//! stable `uh-<element>` base class, assets inlined exactly once as +//! `--asset-<id>` custom properties, and prerendered `data-note` hover +//! chrome ("would emit like-toggled {…}"). Zero transitions, zero +//! commands, zero network — structurally: nothing here can emit. + +pub mod icons; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; + +use uhura_core::view::{Descriptor, Node, Snapshot, VValue}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FrameKind { + Page, + Surface, + Component, +} + +pub struct PreviewFrame { + pub kind: FrameKind, + pub subject: String, + pub example: String, + pub is_default: bool, + pub pinned: bool, + /// Resolved by timeline replay — the caption shows the derivation. + pub derived: bool, + /// Commands unsettled at the end of a derived timeline (§6.2). + pub in_flight: usize, + pub from: Option<String>, + pub note: Option<String>, + pub content: FrameContent, +} + +pub enum FrameContent { + /// A page example: the full snapshot (surface stack included). + Snapshot(Snapshot), + /// A component or standalone surface example. + Fragment(Node), +} + +/// One embeddable asset. `data_uri` is the complete `data:image/…;base64,` +/// string; `alt` comes from the manifest (required, §8.3). +pub struct Asset { + pub data_uri: String, + pub alt: String, +} + +/// Renders the whole board. Assets are embedded once each — only the ids +/// the frames actually reference; unknown ids get the duotone-SVG +/// fallback (§8.3). +pub fn render_canvas( + app: &str, + frames: &[PreviewFrame], + stylesheet: &str, + assets: &BTreeMap<String, Asset>, +) -> String { + let mut used_assets = BTreeSet::new(); + for frame in frames { + match &frame.content { + FrameContent::Snapshot(s) => { + collect_assets(&s.page.root, &mut used_assets); + for surface in &s.surfaces { + collect_assets(&surface.root, &mut used_assets); + } + } + FrameContent::Fragment(node) => collect_assets(node, &mut used_assets), + } + } + + let mut asset_vars = String::new(); + for id in &used_assets { + let uri = assets + .get(id) + .map(|a| a.data_uri.clone()) + .unwrap_or_else(|| fallback_asset(id)); + let _ = writeln!(asset_vars, " --asset-{id}: url(\"{uri}\");"); + } + + // ── group frames into board rows (§6.3) ──────────────────────────── + let mut rows: Vec<(String, String, Vec<&PreviewFrame>)> = Vec::new(); + for frame in frames { + let kind_label = match frame.kind { + FrameKind::Page => "page", + FrameKind::Surface => "surface", + FrameKind::Component => "component", + }; + let row_key = format!("{kind_label} {}", frame.subject); + match rows.iter_mut().find(|(key, _, _)| *key == row_key) { + Some((_, _, list)) => list.push(frame), + None => rows.push((row_key, kind_label.to_string(), vec![frame])), + } + } + + let mut body = String::new(); + for (row_key, kind_label, list) in &rows { + let _ = writeln!( + body, + "<section class=\"row row-{kind_label}\">\n<h2 class=\"row-title\">{}</h2>\n<div class=\"row-frames\">", + esc(row_key) + ); + for frame in list { + body.push_str(&render_frame(frame)); + } + body.push_str("</div>\n</section>\n"); + } + + format!( + r#"<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{app} — uhura canvas</title> +<style> +{base_css} +/* ── compiled app stylesheet (theme.css + <style> blocks, §4.5) ── */ +{stylesheet} +</style> +<style> +:root {{ +{asset_vars}}} +</style> +</head> +<body> +<header class="canvas-bar"> + <strong>{app}</strong><span class="canvas-sub">uhura canvas — {frame_count} previews</span> + <span class="canvas-hint">drag to pan · wheel to zoom · double-click to fit</span> + <button id="fit" type="button">fit</button> +</header> +<div id="viewport"><div id="board"> +{body} +</div></div> +<script> +{chrome_js} +</script> +</body> +</html> +"#, + app = esc(app), + base_css = BASE_CSS, + stylesheet = stylesheet, + asset_vars = asset_vars, + frame_count = frames.len(), + body = body, + chrome_js = CHROME_JS, + ) +} + +fn render_frame(frame: &PreviewFrame) -> String { + let mut badges = String::new(); + if frame.is_default { + badges.push_str("<span class=\"badge badge-default\">default</span>"); + } + let provenance = match (&frame.from, frame.derived) { + (Some(parent), true) => format!("from {parent} → events…"), + (Some(parent), false) => format!("from {parent}"), + (None, true) => "derived".to_string(), + (None, false) => "pinned".to_string(), + }; + if frame.pinned { + badges.push_str("<span class=\"badge badge-pinned\">pinned</span>"); + } + if frame.in_flight > 0 { + let _ = write!( + badges, + "<span class=\"badge badge-in-flight\">{} in flight</span>", + frame.in_flight + ); + } + + let (frame_class, content) = match &frame.content { + FrameContent::Snapshot(snapshot) => { + let mut html = format!( + "<div class=\"screen-root\" inert>{}</div>", + node_html(&snapshot.page.root, false) + ); + for surface in &snapshot.surfaces { + let _ = write!( + html, + "<div class=\"uh-surface-overlay\"><div class=\"uh-scrim\"></div>\ + <div class=\"uh-surface uh-modality-{}\" role=\"dialog\" aria-modal=\"true\" inert>{}</div></div>", + esc(&surface.modality), + node_html(&surface.root, false) + ); + } + ("shell device", html) + } + FrameContent::Fragment(node) => { + let class = match frame.kind { + FrameKind::Surface => "shell sheet", + _ => "shell component", + }; + ( + class, + format!( + "<div class=\"fragment-root\" inert>{}</div>", + node_html(node, false) + ), + ) + } + }; + + let note = frame + .note + .as_ref() + .map(|n| format!("<p class=\"caption-note\">{}</p>", esc(n))) + .unwrap_or_default(); + format!( + "<figure class=\"frame\">\n<div class=\"{frame_class}\">{content}</div>\n\ + <figcaption><span class=\"caption-title\">{subject} / {example}</span>{badges}\ + <span class=\"caption-prov\">{prov}</span>{note}</figcaption>\n</figure>\n", + frame_class = frame_class, + content = content, + subject = esc(&frame.subject), + example = esc(&frame.example), + badges = badges, + prov = esc(&provenance), + note = note, + ) +} + +// ── V → HTML (§8.3) ───────────────────────────────────────────────────── + +fn node_html(node: &Node, parent_is_list: bool) -> String { + let element = node.element.as_str(); + let mut classes = format!("uh-{element}"); + if let Some(authored) = &node.class { + classes.push(' '); + classes.push_str(authored); + } + + let mut attrs = String::new(); + let _ = write!( + attrs, + " class=\"{}\" data-key=\"{}\"", + esc(&classes), + esc(&node.key) + ); + if parent_is_list { + attrs.push_str(" role=\"listitem\""); + } + if let Some(note) = descriptor_note(&node.on) { + let _ = write!(attrs, " data-note=\"{}\"", esc(&note)); + } + + let prop_text = |name: &str| -> Option<String> { + node.props.iter().find_map(|(k, v)| { + if k.as_str() != name { + return None; + } + match v { + VValue::Text(s) | VValue::Plain(s) => Some(s.clone()), + VValue::Bool(b) => Some(b.to_string()), + VValue::Int(i) => Some(i.to_string()), + VValue::Image(a) => Some(a.clone()), + } + }) + }; + let prop_bool = |name: &str| -> bool { + node.props + .iter() + .any(|(k, v)| k.as_str() == name && matches!(v, VValue::Bool(true))) + }; + + let is_list = prop_text("role").as_deref() == Some("list"); + let children: String = node + .children + .iter() + .map(|child| node_html(child, is_list)) + .collect(); + + match element { + "view" => { + match prop_text("role").as_deref() { + Some("list") => attrs.push_str(" role=\"list\""), + Some("navigation") => attrs.push_str(" role=\"navigation\""), + Some("tablist") => attrs.push_str(" role=\"tablist\""), + _ => {} + } + format!("<div{attrs}>{children}</div>") + } + "scroll" => { + let direction = prop_text("direction").unwrap_or_else(|| "vertical".into()); + format!( + "<div{attrs} data-direction=\"{}\">{children}</div>", + esc(&direction) + ) + } + "pager" => { + if let Some(label) = prop_text("label") { + let _ = write!(attrs, " role=\"group\" aria-label=\"{}\"", esc(&label)); + } + let dots = if prop_text("indicator").as_deref() == Some("dots") { + let count = node.children.len(); + let mut dots = String::from("<div class=\"uh-dots\" aria-hidden=\"true\">"); + for i in 0..count { + dots.push_str(if i == 0 { + "<span class=\"uh-dot on\"></span>" + } else { + "<span class=\"uh-dot\"></span>" + }); + } + dots.push_str("</div>"); + dots + } else { + String::new() + }; + format!("<div{attrs}><div class=\"uh-track\">{children}</div>{dots}</div>") + } + "text" => { + let content = prop_text("content").unwrap_or_default(); + format!("<p{attrs}>{}</p>", esc(&content)) + } + "image" => { + let asset = prop_text("src").unwrap_or_default(); + let _ = write!( + attrs, + " style=\"background-image: var(--asset-{})\"", + esc(&asset) + ); + if prop_bool("decorative") { + attrs.push_str(" aria-hidden=\"true\""); + } else if let Some(alt) = prop_text("alt") { + let _ = write!(attrs, " role=\"img\" aria-label=\"{}\"", esc(&alt)); + } + format!("<div{attrs}></div>") + } + "icon" => { + let name = prop_text("name").unwrap_or_default(); + let glyph = icons::glyph(&name).unwrap_or( + r#"<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8"/>"#, + ); + format!( + "<span{attrs} aria-hidden=\"true\"><svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\">{glyph}</svg></span>" + ) + } + "button" => { + if prop_bool("disabled") { + attrs.push_str(" disabled"); + } + if prop_bool("busy") { + attrs.push_str(" aria-busy=\"true\""); + } + if let Some((_, VValue::Bool(pressed))) = + node.props.iter().find(|(k, _)| k.as_str() == "pressed") + { + let _ = write!(attrs, " aria-pressed=\"{pressed}\""); + } + if prop_bool("current") { + attrs.push_str(" aria-current=\"true\""); + } + if let Some(label) = prop_text("label") { + let _ = write!(attrs, " aria-label=\"{}\"", esc(&label)); + } + format!("<button type=\"button\"{attrs}>{children}</button>") + } + "text-field" => { + let mut input_attrs = String::new(); + if let Some(value) = prop_text("value") { + let _ = write!(input_attrs, " value=\"{}\"", esc(&value)); + } + if let Some(placeholder) = prop_text("placeholder") { + let _ = write!(input_attrs, " placeholder=\"{}\"", esc(&placeholder)); + } + if let Some(label) = prop_text("label") { + let _ = write!(input_attrs, " aria-label=\"{}\"", esc(&label)); + } + if prop_bool("disabled") { + input_attrs.push_str(" disabled"); + } + format!("<div{attrs}><input type=\"text\"{input_attrs}></div>") + } + "region" => { + if let Some(label) = prop_text("label") { + let _ = write!(attrs, " aria-label=\"{}\"", esc(&label)); + } + format!("<div{attrs} role=\"button\" tabindex=\"0\">{children}</div>") + } + // Honest labeled placeholder for anything unsupported (§8.2). + other => format!( + "<div{attrs} data-unsupported=\"{}\">{children}</div>", + esc(other) + ), + } +} + +fn descriptor_note(on: &[Descriptor]) -> Option<String> { + if on.is_empty() { + return None; + } + let parts: Vec<String> = on + .iter() + .map(|d| format!("would emit {} {}", d.emit, d.payload)) + .collect(); + Some(parts.join(" · ")) +} + +fn collect_assets(node: &Node, out: &mut BTreeSet<String>) { + for value in node.props.values() { + if let VValue::Image(asset) = value { + out.insert(asset.clone()); + } + } + for child in &node.children { + collect_assets(child, out); + } +} + +/// Duotone-SVG fallback for a missing asset id (§8.3) — deterministic hue +/// from the id bytes. +fn fallback_asset(id: &str) -> String { + let hue: u32 = id + .bytes() + .fold(0u32, |h, b| (h.wrapping_mul(31) + b as u32) % 360); + let svg = format!( + "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>\ + <rect width='64' height='64' fill='hsl({hue},35%,72%)'/>\ + <path d='M0 64 64 0v64z' fill='hsl({hue},40%,48%)'/></svg>" + ); + format!("data:image/svg+xml;utf8,{}", svg.replace('#', "%23")) +} + +fn esc(s: &str) -> String { + s.replace('&', "&amp;") + .replace('<', "&lt;") + .replace('>', "&gt;") + .replace('"', "&quot;") +} + +// ── canvas chrome ─────────────────────────────────────────────────────── + +/// Base element affordances + board chrome. Authored CSS is embedded after +/// this block, so the app always wins on shared selectors. +const BASE_CSS: &str = r#" +* { box-sizing: border-box; margin: 0; } +html, body { height: 100%; } +body { font: 14px/1.45 -apple-system, "Segoe UI", sans-serif; background: #14151a; color: #e8e8ec; overflow: hidden; } + +/* semantic element bases — layout/aesthetics stay authored (§10) */ +.uh-view { display: block; min-inline-size: 0; } +.uh-scroll { overflow-y: auto; overflow-x: hidden; min-block-size: 0; } +.uh-scroll[data-direction="horizontal"] { overflow-x: auto; overflow-y: hidden; } +.uh-text { margin: 0; overflow-wrap: anywhere; } +.uh-image { background-size: cover; background-position: center; background-color: #d9d9de; display: block; } +.uh-icon { display: inline-flex; } +.uh-icon svg { display: block; } +button.uh-button { appearance: none; background: none; border: 0; padding: 6px; font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; border-radius: 8px; } +button.uh-button[disabled] { opacity: 0.35; } +button.uh-button[aria-busy="true"] { opacity: 0.6; } +.uh-text-field input { font: inherit; inline-size: 100%; border: 1px solid #d5d5da; border-radius: 999px; padding: 8px 14px; background: #fff; color: #222; } +.uh-region { display: block; } +.uh-pager .uh-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } +.uh-pager .uh-track > * { flex: 0 0 100%; scroll-snap-align: center; } +.uh-pager { position: relative; } +.uh-dots { position: absolute; inset-block-end: 10px; inset-inline: 0; display: flex; justify-content: center; gap: 5px; } +.uh-dot { inline-size: 6px; block-size: 6px; border-radius: 999px; background: rgba(255,255,255,0.55); } +.uh-dot.on { background: #fff; } + +/* surface overlay inside a device frame */ +.uh-surface-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; } +.uh-scrim { position: absolute; inset: 0; background: rgba(0,0,0,0.4); } +.uh-surface { position: relative; background: #fff; border-radius: 16px 16px 0 0; max-block-size: 72%; block-size: 72%; box-shadow: 0 -8px 32px rgba(0,0,0,0.35); } + +/* hover chrome: prerendered notes (§8.3) */ +[data-note] { position: relative; } +[data-note]:hover::after { content: attr(data-note); position: absolute; inset-block-start: 100%; inset-inline-start: 0; z-index: 30; background: #111; color: #9fe0a8; font: 11px/1.4 ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; white-space: pre; max-inline-size: 320px; overflow: hidden; text-overflow: ellipsis; pointer-events: none; } + +/* board */ +.canvas-bar { position: fixed; inset-block-start: 0; inset-inline: 0; z-index: 50; display: flex; gap: 12px; align-items: baseline; padding: 10px 16px; background: rgba(20,21,26,0.9); backdrop-filter: blur(6px); border-block-end: 1px solid #2a2b33; } +.canvas-sub { color: #9a9aa5; } +.canvas-hint { margin-inline-start: auto; color: #6d6d78; font-size: 12px; } +.canvas-bar button { font: inherit; background: #2a2b33; color: #e8e8ec; border: 0; border-radius: 6px; padding: 3px 12px; } +#viewport { position: absolute; inset: 0; cursor: grab; } +#viewport.panning { cursor: grabbing; } +#board { position: absolute; transform-origin: 0 0; padding: 72px 48px 48px; } +.row { margin-block-end: 56px; } +.row-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em; color: #8a8a96; margin-block-end: 14px; } +.row-frames { display: flex; align-items: flex-start; gap: 32px; } +.frame { flex: none; } +.shell { background: #fff; color: #16181c; border-radius: 24px; box-shadow: 0 12px 48px rgba(0,0,0,0.5), 0 0 0 1px #2e2f38; overflow: hidden; position: relative; } +.shell.device { inline-size: 390px; block-size: 844px; } +.shell.device .screen-root { block-size: 100%; overflow: hidden; } +.shell.device .screen-root > * { block-size: 100%; } +.shell.sheet { inline-size: 390px; block-size: 560px; border-radius: 16px; } +.shell.sheet .fragment-root { block-size: 100%; } +.shell.sheet .fragment-root > * { block-size: 100%; } +.shell.component { inline-size: 390px; border-radius: 12px; background: + #fff radial-gradient(circle, #d8d8de 1px, transparent 1px); + background-size: 16px 16px; } +figcaption { margin-block-start: 10px; max-inline-size: 390px; } +.caption-title { font-weight: 600; margin-inline-end: 8px; } +.badge { font-size: 11px; border-radius: 999px; padding: 1px 8px; margin-inline-end: 6px; } +.badge-default { background: #2e5c34; color: #b7edbe; } +.badge-pinned { background: #4a4430; color: #e6d9a3; } +.badge-in-flight { background: #2f3f52; color: #a3c6e6; } +.caption-prov { color: #8a8a96; font-size: 12px; } +.caption-note { color: #a9a9b4; font-size: 12px; font-style: italic; margin-block-start: 2px; } +"#; + +/// Pan/zoom/fit. Never reads V or Uhura data (§8.3) — pure chrome. +const CHROME_JS: &str = r#" +(() => { + const viewport = document.getElementById("viewport"); + const board = document.getElementById("board"); + let x = 0, y = 0, scale = 1; + const apply = () => { board.style.transform = `translate(${x}px, ${y}px) scale(${scale})`; }; + const fit = () => { + const rect = board.getBoundingClientRect(); + const w = rect.width / scale, h = rect.height / scale; + scale = Math.min(viewport.clientWidth / w, viewport.clientHeight / h, 1) * 0.96; + x = (viewport.clientWidth - w * scale) / 2; + y = 24; + apply(); + }; + let panning = false, px = 0, py = 0; + viewport.addEventListener("pointerdown", (e) => { + panning = true; px = e.clientX - x; py = e.clientY - y; + viewport.classList.add("panning"); viewport.setPointerCapture(e.pointerId); + }); + viewport.addEventListener("pointermove", (e) => { + if (!panning) return; + x = e.clientX - px; y = e.clientY - py; apply(); + }); + viewport.addEventListener("pointerup", () => { panning = false; viewport.classList.remove("panning"); }); + viewport.addEventListener("wheel", (e) => { + e.preventDefault(); + const factor = Math.exp(-e.deltaY * 0.0015); + const next = Math.min(Math.max(scale * factor, 0.08), 3); + x = e.clientX - (e.clientX - x) * (next / scale); + y = e.clientY - (e.clientY - y) * (next / scale); + scale = next; apply(); + }, { passive: false }); + viewport.addEventListener("dblclick", fit); + document.getElementById("fit").addEventListener("click", fit); + fit(); +})(); +"#; diff --git a/uhura/crates/uhura-syntax/Cargo.toml b/uhura/crates/uhura-syntax/Cargo.toml new file mode 100644 index 0000000..990bef9 --- /dev/null +++ b/uhura/crates/uhura-syntax/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "uhura-syntax" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +uhura-base = { workspace = true } diff --git a/uhura/crates/uhura-syntax/clippy.toml b/uhura/crates/uhura-syntax/clippy.toml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/uhura/crates/uhura-syntax/clippy.toml @@ -0,0 +1,20 @@ +# Purity enforcement (design §12.1): pure crates may not reach the OS. +# The cargo-metadata allowlist test is the structural check; this is the +# in-crate usage check. CI runs clippy with -D warnings. +disallowed-types = [ + "std::collections::HashMap", + "std::collections::HashSet", + "std::time::Instant", + "std::time::SystemTime", +] +disallowed-methods = [ + "std::fs::read", + "std::fs::read_to_string", + "std::fs::write", + "std::fs::File::open", + "std::fs::File::create", + "std::env::var", + "std::env::vars", + "std::thread::spawn", + "std::net::TcpStream::connect", +] diff --git a/uhura/crates/uhura-syntax/src/ast.rs b/uhura/crates/uhura-syntax/src/ast.rs new file mode 100644 index 0000000..ac6234f --- /dev/null +++ b/uhura/crates/uhura-syntax/src/ast.rs @@ -0,0 +1,533 @@ +//! The AST for `.uhura` files (design §4) and `.examples.uhura` files +//! (design §6.1). Parsing always yields a tree; unparseable regions become +//! `…::Error` nodes so downstream passes and the formatter keep working. + +use uhura_base::Span; + +use crate::token::Comment; + +// ── file ──────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct File { + pub kind: DefKind, + pub uses: Vec<Use>, + pub props: Vec<PropDecl>, + pub emits: Vec<EmitDecl>, + pub params: Vec<ParamDecl>, + pub store: Option<Store>, + pub markup: Vec<Node>, + pub style: Option<StyleBlock>, +} + +#[derive(Debug)] +pub enum DefKind { + Component { + name: String, + span: Span, + }, + Page { + span: Span, + }, + Surface { + name: String, + modality: Option<String>, + span: Span, + }, + /// Header failed to parse; the rest of the file is still attempted. + Error { + span: Span, + }, +} + +#[derive(Debug)] +pub enum Use { + Component { + name: String, + span: Span, + leading: Vec<Comment>, + }, + Surface { + name: String, + span: Span, + leading: Vec<Comment>, + }, + Port { + name: String, + items: Vec<PortItem>, + span: Span, + leading: Vec<Comment>, + }, + /// `.examples.uhura` only. + Fixture { + name: String, + span: Span, + leading: Vec<Comment>, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PortItemKind { + Projection, + Command, + Type, +} + +#[derive(Debug)] +pub struct PortItem { + pub kind: PortItemKind, + pub name: String, + pub span: Span, +} + +#[derive(Debug)] +pub struct PropDecl { + pub name: String, + pub ty: TypeExpr, + pub span: Span, + pub leading: Vec<Comment>, +} + +#[derive(Debug)] +pub struct EmitDecl { + pub name: String, + pub params: Vec<(String, TypeExpr)>, + pub span: Span, + pub leading: Vec<Comment>, +} + +#[derive(Debug)] +pub struct ParamDecl { + pub name: String, + pub ty: TypeExpr, + pub span: Span, + pub leading: Vec<Comment>, +} + +// ── types ─────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct TypeExpr { + pub kind: TypeKind, + pub span: Span, +} + +#[derive(Debug)] +pub enum TypeKind { + /// `bool`, `int`, `text`, `id`, `tag`, or a port-declared type name. + Name(String), + /// `list[T]` + List(Box<TypeExpr>), + /// `map[K]V` — K is a name (`id` | `tag`, checked later). + Map(String, Box<TypeExpr>), + /// `T?` + Option(Box<TypeExpr>), + Error, +} + +// ── store ─────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct Store { + pub state: Vec<StateField>, + pub handlers: Vec<Handler>, + pub span: Span, + pub leading: Vec<Comment>, +} + +#[derive(Debug)] +pub struct StateField { + pub name: String, + pub ty: TypeExpr, + pub init: Literal, + pub span: Span, + pub leading: Vec<Comment>, +} + +/// State initializers are literals only (design §4.3). +#[derive(Debug, Clone, PartialEq)] +pub enum Literal { + Int(i64), + Str(String), + Bool(bool), + None, + /// `{}` — the empty map. + EmptyMap, + Error, +} + +#[derive(Debug)] +pub struct Handler { + pub event: EventRef, + /// UI-event handlers declare typed params; outcome handlers are + /// name-only (`ty` is `None`, types come from the contract — §4.2). + pub params: Vec<HandlerParam>, + pub guard: Option<Expr>, + pub body: Vec<Stmt>, + pub span: Span, + pub leading: Vec<Comment>, +} + +#[derive(Debug)] +pub struct HandlerParam { + pub name: String, + pub ty: Option<TypeExpr>, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OutcomeKind { + Ok, + Err, +} + +#[derive(Debug)] +pub enum EventRef { + /// `on like-toggled(...)` + Semantic { name: String, span: Span }, + /// `on like-post.ok(...)` / `.err(...)` + Outcome { + command: String, + which: OutcomeKind, + span: Span, + }, +} + +/// The five statements — closed set (design §4.2). +#[derive(Debug)] +pub enum Stmt { + Set { + path: SetPath, + value: Expr, + span: Span, + leading: Vec<Comment>, + }, + Send { + command: String, + args: Vec<Arg>, + bind: Option<String>, + span: Span, + leading: Vec<Comment>, + }, + OpenSurface { + name: String, + args: Vec<Arg>, + span: Span, + leading: Vec<Comment>, + }, + Dismiss { + span: Span, + leading: Vec<Comment>, + }, + Navigate { + target: NavTarget, + span: Span, + leading: Vec<Comment>, + }, + Error { + span: Span, + }, +} + +#[derive(Debug)] +pub struct SetPath { + pub field: String, + /// `field[key]` — at most one level (micro-decision: paths are `field` + /// or `field[key]` only). + pub key: Option<Expr>, + pub span: Span, +} + +#[derive(Debug)] +pub enum NavTarget { + Route { name: String, args: Vec<Arg> }, + Back, +} + +/// A named argument (`name: expr`) — all argument lists are named. +#[derive(Debug)] +pub struct Arg { + pub name: String, + pub value: Expr, + pub span: Span, +} + +// ── expressions ───────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct Expr { + pub kind: ExprKind, + pub span: Span, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnaryOp { + Not, + Neg, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryOp { + Add, + Sub, + Concat, + Eq, + NotEq, + Lt, + Le, + Gt, + Ge, + And, + Or, + Coalesce, +} + +#[derive(Debug)] +pub enum ExprKind { + /// Name reference: state field, prop, param, binding, or projection. + Ident(String), + Int(i64), + Str(String), + Bool(bool), + None, + /// `base.field` + Field { + base: Box<Expr>, + name: String, + }, + /// `base[key]` — option-returning (§4.3). + Index { + base: Box<Expr>, + key: Box<Expr>, + }, + /// `name(args…)` — builtins (`to-text`, `count`) and keyed projection + /// reads (`for-post(post)`); resolution decides which. + Call { + name: String, + args: Vec<Expr>, + }, + Unary { + op: UnaryOp, + expr: Box<Expr>, + }, + Binary { + op: BinaryOp, + lhs: Box<Expr>, + rhs: Box<Expr>, + }, + /// `if c then a else b` + If { + cond: Box<Expr>, + then: Box<Expr>, + els: Box<Expr>, + }, + /// `{ field: expr, … }` — legal on `set` rhs and in example pins. + Record(Vec<(String, Expr)>), + Error, +} + +// ── markup ────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum Node { + Element(Element), + /// Text content — only meaningful inside `<text>` (checked later). + Text { + runs: Vec<TextRun>, + span: Span, + }, + If { + cond: Expr, + then: Vec<Node>, + els: Option<Vec<Node>>, + span: Span, + }, + Each { + item: String, + seq: Expr, + key: Expr, + body: Vec<Node>, + span: Span, + }, + Match { + scrutinee: Expr, + arms: Vec<MatchArm>, + span: Span, + }, + Error { + span: Span, + }, +} + +#[derive(Debug)] +pub struct MatchArm { + pub pattern: MatchPattern, + /// `{:when carousel c}` — the optional value binding. + pub binding: Option<String>, + pub body: Vec<Node>, + pub span: Span, +} + +#[derive(Debug)] +pub enum MatchPattern { + /// A union variant, or an availability arm (`loading` / `failed` / + /// `ready`) — resolution decides which (design §9.2). + Variant(String), + Else, +} + +#[derive(Debug)] +pub struct Element { + /// Catalog element or imported component name. + pub name: String, + pub attrs: Vec<Attr>, + pub events: Vec<EventAttr>, + pub children: Vec<Node>, + pub self_closing: bool, + pub span: Span, +} + +#[derive(Debug)] +pub enum TextRun { + Literal(String), + Interp(Expr), +} + +#[derive(Debug)] +pub struct Attr { + pub name: String, + pub value: AttrValue, + pub span: Span, +} + +#[derive(Debug)] +pub enum AttrValue { + /// `attr="literal"` + Literal(String), + /// `attr={expr}` + Expr(Expr), + /// bare `attr` — boolean true. + Bare, +} + +#[derive(Debug)] +pub struct EventAttr { + /// The event name after `on:` — a catalog event on elements, an emit + /// name on components. + pub event: String, + pub binding: EventBinding, + pub span: Span, +} + +#[derive(Debug)] +pub enum EventBinding { + /// `on:press={emit like-toggled(post: p.id)}` + Emit { name: String, args: Vec<Arg> }, + /// Bare `on:like-toggled` — forwards same name + payload to the + /// enclosing machine scope (component emits only, §4.4). + Forward, +} + +// ── style ─────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct StyleBlock { + pub rules: Vec<StyleRule>, + /// The whole `<style>…</style>` inner text, verbatim. + pub raw: String, + pub span: Span, +} + +#[derive(Debug)] +pub struct StyleRule { + /// Selector text, verbatim (normalized whitespace). + pub selector: String, + /// Class names referenced by the selector, for rooting/existence checks. + pub classes: Vec<String>, + /// Declaration block, verbatim, without the outer braces. + pub decls: String, + pub span: Span, +} + +// ── examples files (design §6.1) ──────────────────────────────────────────── + +#[derive(Debug)] +pub struct ExamplesFile { + pub uses: Vec<Use>, + pub examples: Vec<ExampleDecl>, +} + +#[derive(Debug)] +pub struct ExampleDecl { + pub name: String, + pub is_default: bool, + pub clauses: Vec<ExampleClause>, + pub span: Span, + pub leading: Vec<Comment>, +} + +#[derive(Debug)] +pub enum ExampleClause { + From { + name: String, + span: Span, + }, + Note { + text: String, + span: Span, + }, + /// `params { user = "…" }` (pages with dynamic segments). + Params { + entries: Vec<(String, Expr)>, + span: Span, + }, + /// `props { post = fixture.posts.x, … }` (components/surfaces). + Props { + entries: Vec<(String, Expr)>, + span: Span, + }, + /// `state { field = expr }` — literal state pin. + State { + entries: Vec<(String, Expr)>, + span: Span, + }, + /// `projection feed.feed-page = fixture.feed.page-1` + /// `projection comments.for-post("post-1") = fixture.comments.x` + Projection(ProjectionPin), + /// `events [ … ]` — the derivation timeline. + Events { + entries: Vec<ExampleEvent>, + span: Span, + }, + Error { + span: Span, + }, +} + +#[derive(Debug)] +pub struct ProjectionPin { + pub port: String, + pub projection: String, + pub key: Option<Expr>, + pub value: Expr, + pub span: Span, +} + +#[derive(Debug)] +pub enum ExampleEvent { + /// `like-toggled(post: "post-1", now-liked: true)` + Semantic { + name: String, + args: Vec<Arg>, + span: Span, + }, + /// `outcome like-post.err(refusal: rate-limited)` + Outcome { + command: String, + which: OutcomeKind, + args: Vec<Arg>, + span: Span, + }, + /// `projection feed.feed-page = fixture.feed.pages-1-2` + Projection(ProjectionPin), +} diff --git a/uhura/crates/uhura-syntax/src/css.rs b/uhura/crates/uhura-syntax/src/css.rs new file mode 100644 index 0000000..11253e7 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/css.rs @@ -0,0 +1,134 @@ +//! CSS handling (design §4.5): a selector tokenizer plus verbatim +//! balanced-brace declaration capture. The checker's whole CSS surface is +//! selector shape — declarations pass through untouched. Also used by +//! uhura-check on `styles/theme.css`. + +use uhura_base::{FileId, Span}; + +use crate::ast::StyleRule; + +/// Parses stylesheet text into rules. `base` is the byte offset of `text` +/// within the containing file (0 for standalone .css files) so spans line up. +pub fn parse_stylesheet(file: FileId, base: u32, text: &str) -> Vec<StyleRule> { + let mut rules = Vec::new(); + let bytes = text.as_bytes(); + let mut i = 0usize; + + while i < bytes.len() { + // Skip whitespace and /* … */ comments. + if bytes[i].is_ascii_whitespace() { + i += 1; + continue; + } + if text[i..].starts_with("/*") { + i = text[i..] + .find("*/") + .map(|j| i + j + 2) + .unwrap_or(bytes.len()); + continue; + } + // Selector runs to the next `{` (or EOF for garbage). + let sel_start = i; + let Some(rel_brace) = text[i..].find('{') else { + break; + }; + let sel_end = i + rel_brace; + let selector_raw = text[sel_start..sel_end].trim(); + // Declaration block: balanced braces (handles @media nesting by + // capturing the whole inner block verbatim). + let mut depth = 0usize; + let mut j = sel_end; + let decl_start = sel_end + 1; + let mut decl_end = bytes.len(); + while j < bytes.len() { + match bytes[j] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + decl_end = j; + break; + } + } + _ => {} + } + j += 1; + } + let decls = text[decl_start..decl_end.min(bytes.len())].trim(); + let selector = normalize_ws(selector_raw); + // For @-rules the class references live in the nested inner rules, + // which are captured verbatim inside `decls`. + let classes = if selector.starts_with('@') { + extract_classes(decls) + } else { + extract_classes(&selector) + }; + rules.push(StyleRule { + selector, + classes, + decls: decls.to_string(), + span: Span::new( + file, + base + sel_start as u32, + base + decl_end.min(bytes.len()) as u32, + ), + }); + i = decl_end.saturating_add(1); + } + rules +} + +fn normalize_ws(s: &str) -> String { + s.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// Class names referenced anywhere in a selector (`.post-card` → `post-card`). +pub fn extract_classes(selector: &str) -> Vec<String> { + let mut out = Vec::new(); + let bytes = selector.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + if bytes[i] == b'.' { + let start = i + 1; + let mut end = start; + while end < bytes.len() + && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-' || bytes[end] == b'_') + { + end += 1; + } + if end > start { + let name = &selector[start..end]; + if !out.iter().any(|c| c == name) { + out.push(name.to_string()); + } + } + i = end; + } else { + i += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rules_and_classes() { + let css = " +/* tokens */ +.post-card { display: flex; } +.post-card .avatar, .muted { color: var(--x); } +@media (min-width: 600px) { .post-card { gap: 8px; } } +"; + let rules = parse_stylesheet(FileId(0), 0, css); + assert_eq!(rules.len(), 3); + assert_eq!(rules[0].selector, ".post-card"); + assert_eq!(rules[0].classes, vec!["post-card"]); + assert_eq!(rules[1].classes, vec!["post-card", "avatar", "muted"]); + assert!(rules[2].selector.starts_with("@media")); + assert_eq!(rules[2].classes, vec!["post-card"]); + assert_eq!(rules[1].decls, "color: var(--x);"); + } +} diff --git a/uhura/crates/uhura-syntax/src/cursor.rs b/uhura/crates/uhura-syntax/src/cursor.rs new file mode 100644 index 0000000..906ee99 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/cursor.rs @@ -0,0 +1,424 @@ +//! The character cursor shared by every surface parser, plus the DSL +//! tokenizer. Mode ownership is structural: parsers call the tokenizer +//! function for the surface they are in (design §4, plan risk #1), so a +//! token can never be lexed in the wrong mode. + +use uhura_base::{Diagnostic, FileId, Span, codes}; + +use crate::token::{Comment, Token, TokenKind}; + +pub struct Cursor<'src> { + pub file: FileId, + text: &'src str, + pos: u32, + pub diagnostics: Vec<Diagnostic>, +} + +impl<'src> Cursor<'src> { + pub fn new(file: FileId, text: &'src str) -> Self { + Cursor { + file, + text, + pos: 0, + diagnostics: Vec::new(), + } + } + + pub fn pos(&self) -> u32 { + self.pos + } + + /// Rewind/seek — used by parsers to resync after speculative reads. + pub fn set_pos(&mut self, pos: u32) { + debug_assert!(pos as usize <= self.text.len()); + self.pos = pos; + } + + pub fn is_eof(&self) -> bool { + self.pos as usize >= self.text.len() + } + + pub fn rest(&self) -> &'src str { + &self.text[self.pos as usize..] + } + + pub fn peek(&self) -> Option<char> { + self.rest().chars().next() + } + + pub fn peek2(&self) -> Option<char> { + let mut it = self.rest().chars(); + it.next(); + it.next() + } + + pub fn bump(&mut self) -> Option<char> { + let c = self.peek()?; + self.pos += c.len_utf8() as u32; + Some(c) + } + + pub fn eat(&mut self, c: char) -> bool { + if self.peek() == Some(c) { + self.bump(); + true + } else { + false + } + } + + pub fn eat_str(&mut self, s: &str) -> bool { + if self.rest().starts_with(s) { + self.pos += s.len() as u32; + true + } else { + false + } + } + + pub fn span_from(&self, start: u32) -> Span { + Span::new(self.file, start, self.pos) + } + + /// The text consumed since `start`, as an owned string. + pub fn rest_from(&self, start: u32) -> String { + self.text[start as usize..self.pos as usize].to_string() + } + + pub fn error(&mut self, code: codes::Code, message: impl Into<String>, span: Span) { + self.diagnostics + .push(Diagnostic::error(code.0, code.1, message, span)); + } + + /// Skips whitespace and `//` comments, returning the comments in order. + pub fn skip_trivia(&mut self) -> Vec<Comment> { + let mut comments = Vec::new(); + loop { + match self.peek() { + Some(c) if c.is_whitespace() => { + self.bump(); + } + Some('/') if self.peek2() == Some('/') => { + let start = self.pos; + self.bump(); + self.bump(); + let text_start = self.pos as usize; + while let Some(c) = self.peek() { + if c == '\n' { + break; + } + self.bump(); + } + comments.push(Comment { + span: self.span_from(start), + text: self.text[text_start..self.pos as usize].to_string(), + }); + } + _ => break, + } + } + comments + } + + // ── DSL tokenizer ────────────────────────────────────────────────────── + + /// Lexes one DSL token (header / store / expression surfaces). + pub fn dsl_token(&mut self) -> Token { + let leading = self.skip_trivia(); + let start = self.pos; + let kind = self.dsl_token_kind(start); + Token { + kind, + span: self.span_from(start), + leading, + } + } + + fn dsl_token_kind(&mut self, start: u32) -> TokenKind { + let Some(c) = self.bump() else { + return TokenKind::Eof; + }; + match c { + 'a'..='z' => { + // Kebab identifier: `-` joins iff immediately between + // ident-continue characters (micro-decision #1). + while let Some(n) = self.peek() { + match n { + 'a'..='z' | '0'..='9' => { + self.bump(); + } + '-' if matches!(self.peek2(), Some('a'..='z' | '0'..='9')) => { + self.bump(); + } + _ => break, + } + } + let text = &self.text[start as usize..self.pos as usize]; + if text.len() > 64 { + self.error( + codes::INVALID_IDENT, + format!("identifier `{text}` exceeds 64 characters"), + self.span_from(start), + ); + } + TokenKind::Ident(text.to_string()) + } + '0'..='9' => { + while matches!(self.peek(), Some('0'..='9')) { + self.bump(); + } + let text = &self.text[start as usize..self.pos as usize]; + match text.parse::<i64>() { + Ok(i) => TokenKind::Int(i), + Err(_) => { + self.error( + codes::UNEXPECTED_TOKEN, + format!("integer literal `{text}` overflows i64"), + self.span_from(start), + ); + TokenKind::Int(i64::MAX) + } + } + } + '"' => self.lex_string(start), + '{' => TokenKind::LBrace, + '}' => TokenKind::RBrace, + '(' => TokenKind::LParen, + ')' => TokenKind::RParen, + '[' => TokenKind::LBracket, + ']' => TokenKind::RBracket, + ',' => TokenKind::Comma, + ':' => TokenKind::Colon, + '.' => TokenKind::Dot, + '=' => { + if self.eat('=') { + TokenKind::EqEq + } else { + TokenKind::Eq + } + } + '!' => { + if self.eat('=') { + TokenKind::NotEq + } else { + TokenKind::Bang + } + } + '<' => { + if self.eat('=') { + TokenKind::Le + } else { + TokenKind::Lt + } + } + '>' => { + if self.eat('=') { + TokenKind::Ge + } else { + TokenKind::Gt + } + } + '+' => { + if self.eat('+') { + TokenKind::PlusPlus + } else { + TokenKind::Plus + } + } + '-' => TokenKind::Minus, + '&' => { + if self.eat('&') { + TokenKind::AndAnd + } else { + self.error( + codes::UNEXPECTED_TOKEN, + "single `&` — did you mean `&&`?", + self.span_from(start), + ); + TokenKind::Error + } + } + '|' => { + if self.eat('|') { + TokenKind::OrOr + } else { + self.error( + codes::UNEXPECTED_TOKEN, + "single `|` — did you mean `||`?", + self.span_from(start), + ); + TokenKind::Error + } + } + '?' => { + if self.eat('?') { + TokenKind::Coalesce + } else { + TokenKind::Question + } + } + other => { + self.error( + codes::UNEXPECTED_TOKEN, + format!("unexpected character `{other}`"), + self.span_from(start), + ); + TokenKind::Error + } + } + } + + fn lex_string(&mut self, start: u32) -> TokenKind { + let mut out = String::new(); + loop { + match self.peek() { + None | Some('\n') => { + self.error( + codes::UNTERMINATED_STRING, + "unterminated string literal (no raw newlines in strings)", + self.span_from(start), + ); + return TokenKind::Str(out); + } + Some('"') => { + self.bump(); + return TokenKind::Str(out); + } + Some('\\') => { + self.bump(); + match self.bump() { + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('u') => { + if self.eat('{') { + let hex_start = self.pos as usize; + while matches!(self.peek(), Some(c) if c.is_ascii_hexdigit()) { + self.bump(); + } + let hex = &self.text[hex_start..self.pos as usize]; + let ok = self.eat('}'); + match ( + ok, + u32::from_str_radix(hex, 16).ok().and_then(char::from_u32), + ) { + (true, Some(c)) => out.push(c), + _ => self.error( + codes::UNTERMINATED_STRING, + "invalid `\\u{…}` escape", + self.span_from(start), + ), + } + } else { + self.error( + codes::UNTERMINATED_STRING, + "`\\u` escape requires `{hex}`", + self.span_from(start), + ); + } + } + other => { + self.error( + codes::UNTERMINATED_STRING, + format!( + "unknown escape `\\{}`", + other.map(String::from).unwrap_or_default() + ), + self.span_from(start), + ); + } + } + } + Some(_) => { + out.push(self.bump().unwrap()); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token::TokenKind as T; + + fn lex_all(src: &str) -> Vec<TokenKind> { + let mut c = Cursor::new(FileId(0), src); + let mut out = Vec::new(); + loop { + let t = c.dsl_token(); + let eof = t.kind == T::Eof; + out.push(t.kind); + if eof { + break; + } + } + out.pop(); // drop Eof + out + } + + #[test] + fn kebab_vs_minus() { + assert_eq!( + lex_all("like-pending"), + vec![T::Ident("like-pending".into())] + ); + assert_eq!(lex_all("0 - 1"), vec![T::Int(0), T::Minus, T::Int(1)]); + // `a -b` is subtraction: `-` starts a fresh token after whitespace. + assert_eq!( + lex_all("a -b"), + vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] + ); + // `a- b`: the dash is not followed by an ident char, so it detaches. + assert_eq!( + lex_all("a- b"), + vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] + ); + } + + #[test] + fn operators() { + assert_eq!( + lex_all("a ?? b != c ++ \"x\" && !d"), + vec![ + T::Ident("a".into()), + T::Coalesce, + T::Ident("b".into()), + T::NotEq, + T::Ident("c".into()), + T::PlusPlus, + T::Str("x".into()), + T::AndAnd, + T::Bang, + T::Ident("d".into()), + ] + ); + } + + #[test] + fn string_escapes() { + assert_eq!( + lex_all(r#""a\n\"b\" \u{e9}""#), + vec![T::Str("a\n\"b\" é".into())] + ); + } + + #[test] + fn comments_are_leading_trivia() { + let mut c = Cursor::new(FileId(0), "// hi\n// there\nset"); + let t = c.dsl_token(); + assert_eq!(t.kind, T::Ident("set".into())); + assert_eq!(t.leading.len(), 2); + assert_eq!(t.leading[0].text, " hi"); + } + + #[test] + fn unterminated_string_diagnoses() { + let mut c = Cursor::new(FileId(0), "\"abc\nx"); + let t = c.dsl_token(); + assert!(matches!(t.kind, T::Str(_))); + assert_eq!(c.diagnostics.len(), 1); + assert_eq!(c.diagnostics[0].code, "UH0002"); + } +} diff --git a/uhura/crates/uhura-syntax/src/format.rs b/uhura/crates/uhura-syntax/src/format.rs new file mode 100644 index 0000000..5fa1f2f --- /dev/null +++ b/uhura/crates/uhura-syntax/src/format.rs @@ -0,0 +1,637 @@ +//! The one canonical formatter — zero options (design §4). Layout is fully +//! deterministic and width-independent: attributes, guards, and expressions +//! render on one line; children/bodies indent by two spaces. Comments attach +//! before their item. CSS declarations pass through verbatim (§4.5). + +use crate::ast::*; +use crate::token::Comment; + +const INDENT: &str = " "; + +pub fn format_module(f: &File) -> String { + let mut out = String::new(); + + // ── header ────────────────────────────────────────────────────────── + match &f.kind { + DefKind::Component { name, .. } => out.push_str(&format!("component {name}\n")), + DefKind::Page { .. } => out.push_str("page\n"), + DefKind::Surface { name, modality, .. } => match modality { + Some(m) => out.push_str(&format!("surface {name} modality {m}\n")), + None => out.push_str(&format!("surface {name}\n")), + }, + DefKind::Error { .. } => {} + } + + if !f.uses.is_empty() { + out.push('\n'); + for u in &f.uses { + fmt_use(u, &mut out); + } + } + + if !f.props.is_empty() { + out.push('\n'); + out.push_str("props {\n"); + for p in &f.props { + fmt_comments(&p.leading, 1, &mut out); + out.push_str(&format!("{INDENT}{}: {}\n", p.name, type_str(&p.ty))); + } + out.push_str("}\n"); + } + + if !f.emits.is_empty() { + out.push('\n'); + out.push_str("emits {\n"); + for e in &f.emits { + fmt_comments(&e.leading, 1, &mut out); + let params = e + .params + .iter() + .map(|(n, t)| format!("{n}: {}", type_str(t))) + .collect::<Vec<_>>() + .join(", "); + out.push_str(&format!("{INDENT}{}({params})\n", e.name)); + } + out.push_str("}\n"); + } + + for p in &f.params { + out.push('\n'); + fmt_comments(&p.leading, 0, &mut out); + out.push_str(&format!("param {}: {}\n", p.name, type_str(&p.ty))); + } + + if let Some(store) = &f.store { + out.push('\n'); + fmt_comments(&store.leading, 0, &mut out); + out.push_str("store {\n"); + if !store.state.is_empty() { + out.push_str(&format!("{INDENT}state {{\n")); + for sf in &store.state { + fmt_comments(&sf.leading, 2, &mut out); + out.push_str(&format!( + "{INDENT}{INDENT}{}: {} = {}\n", + sf.name, + type_str(&sf.ty), + literal_str(&sf.init) + )); + } + out.push_str(&format!("{INDENT}}}\n")); + } + for h in &store.handlers { + out.push('\n'); + fmt_handler(h, &mut out); + } + out.push_str("}\n"); + } + + if !f.markup.is_empty() { + out.push('\n'); + for n in &f.markup { + fmt_node(n, 0, &mut out); + } + } + + if let Some(style) = &f.style { + out.push('\n'); + out.push_str("<style>\n"); + let trimmed = style.raw.trim_matches('\n'); + if !trimmed.is_empty() { + out.push_str(trimmed); + out.push('\n'); + } + out.push_str("</style>\n"); + } + + out +} + +pub fn format_examples(f: &ExamplesFile) -> String { + let mut out = String::new(); + for u in &f.uses { + fmt_use(u, &mut out); + } + for e in &f.examples { + out.push('\n'); + fmt_comments(&e.leading, 0, &mut out); + let default = if e.is_default { " default" } else { "" }; + out.push_str(&format!("example {}{default} {{\n", e.name)); + for c in &e.clauses { + fmt_example_clause(c, &mut out); + } + out.push_str("}\n"); + } + out +} + +// ── pieces ────────────────────────────────────────────────────────────────── + +fn fmt_comments(comments: &[Comment], depth: usize, out: &mut String) { + for c in comments { + out.push_str(&INDENT.repeat(depth)); + out.push_str(&format!("//{}\n", c.text)); + } +} + +fn fmt_use(u: &Use, out: &mut String) { + match u { + Use::Component { name, leading, .. } => { + fmt_comments(leading, 0, out); + out.push_str(&format!("use component {name}\n")); + } + Use::Surface { name, leading, .. } => { + fmt_comments(leading, 0, out); + out.push_str(&format!("use surface {name}\n")); + } + Use::Fixture { name, leading, .. } => { + fmt_comments(leading, 0, out); + out.push_str(&format!("use fixture {name}\n")); + } + Use::Port { + name, + items, + leading, + .. + } => { + fmt_comments(leading, 0, out); + // ≤ 3 items inline; otherwise one per line (deterministic by + // count, not width). + let rendered: Vec<String> = items + .iter() + .map(|i| { + let kind = match i.kind { + PortItemKind::Projection => "projection", + PortItemKind::Command => "command", + PortItemKind::Type => "type", + }; + format!("{kind} {}", i.name) + }) + .collect(); + if rendered.len() <= 3 { + out.push_str(&format!("use port {name} {{ {} }}\n", rendered.join(", "))); + } else { + out.push_str(&format!("use port {name} {{\n")); + for r in rendered { + out.push_str(&format!("{INDENT}{r}\n")); + } + out.push_str("}\n"); + } + } + } +} + +fn fmt_handler(h: &Handler, out: &mut String) { + fmt_comments(&h.leading, 1, out); + let event = match &h.event { + EventRef::Semantic { name, .. } => name.clone(), + EventRef::Outcome { command, which, .. } => format!( + "{command}.{}", + if *which == OutcomeKind::Ok { + "ok" + } else { + "err" + } + ), + }; + let params = h + .params + .iter() + .map(|p| match &p.ty { + Some(t) => format!("{}: {}", p.name, type_str(t)), + None => p.name.clone(), + }) + .collect::<Vec<_>>() + .join(", "); + let guard = match &h.guard { + Some(g) => format!(" when {}", expr_str(g)), + None => String::new(), + }; + out.push_str(&format!("{INDENT}on {event}({params}){guard} {{\n")); + for st in &h.body { + fmt_stmt(st, out); + } + out.push_str(&format!("{INDENT}}}\n")); +} + +fn fmt_stmt(st: &Stmt, out: &mut String) { + let pad = INDENT.repeat(2); + match st { + Stmt::Set { + path, + value, + leading, + .. + } => { + fmt_comments(leading, 2, out); + let key = match &path.key { + Some(k) => format!("[{}]", expr_str(k)), + None => String::new(), + }; + out.push_str(&format!( + "{pad}set {}{key} = {}\n", + path.field, + expr_str(value) + )); + } + Stmt::Send { + command, + args, + bind, + leading, + .. + } => { + fmt_comments(leading, 2, out); + let bind = match bind { + Some(b) => format!(" as {b}"), + None => String::new(), + }; + out.push_str(&format!("{pad}send {command}({}){bind}\n", args_str(args))); + } + Stmt::OpenSurface { + name, + args, + leading, + .. + } => { + fmt_comments(leading, 2, out); + out.push_str(&format!("{pad}open-surface {name}({})\n", args_str(args))); + } + Stmt::Dismiss { leading, .. } => { + fmt_comments(leading, 2, out); + out.push_str(&format!("{pad}dismiss\n")); + } + Stmt::Navigate { + target, leading, .. + } => { + fmt_comments(leading, 2, out); + match target { + NavTarget::Back => out.push_str(&format!("{pad}navigate back\n")), + NavTarget::Route { name, args } => { + if args.is_empty() { + out.push_str(&format!("{pad}navigate {name}()\n")); + } else { + out.push_str(&format!("{pad}navigate {name}({})\n", args_str(args))); + } + } + } + } + Stmt::Error { .. } => {} + } +} + +fn fmt_node(n: &Node, depth: usize, out: &mut String) { + let pad = INDENT.repeat(depth); + match n { + Node::Element(e) => { + let mut head = format!("<{}", e.name); + for a in &e.attrs { + match &a.value { + AttrValue::Bare => head.push_str(&format!(" {}", a.name)), + AttrValue::Literal(v) => head.push_str(&format!(" {}=\"{v}\"", a.name)), + AttrValue::Expr(x) => { + head.push_str(&format!(" {}={{{}}}", a.name, expr_str(x))) + } + } + } + for ev in &e.events { + match &ev.binding { + EventBinding::Forward => head.push_str(&format!(" on:{}", ev.event)), + EventBinding::Emit { name, args } => head.push_str(&format!( + " on:{}={{emit {name}({})}}", + ev.event, + args_str(args) + )), + } + } + if e.self_closing || e.children.is_empty() { + out.push_str(&format!("{pad}{head} />\n")); + } else if is_inline_text_only(e) { + // `<text …>{expr} literal</text>` stays on one line. + let mut line = format!("{pad}{head}>"); + if let Node::Text { runs, .. } = &e.children[0] { + line.push_str(&text_runs_str(runs)); + } + line.push_str(&format!("</{}>\n", e.name)); + out.push_str(&line); + } else { + out.push_str(&format!("{pad}{head}>\n")); + for c in &e.children { + fmt_node(c, depth + 1, out); + } + out.push_str(&format!("{pad}</{}>\n", e.name)); + } + } + Node::Text { runs, .. } => { + out.push_str(&format!("{pad}{}\n", text_runs_str(runs))); + } + Node::If { + cond, then, els, .. + } => { + out.push_str(&format!("{pad}{{#if {}}}\n", expr_str(cond))); + for c in then { + fmt_node(c, depth + 1, out); + } + if let Some(els) = els { + out.push_str(&format!("{pad}{{:else}}\n")); + for c in els { + fmt_node(c, depth + 1, out); + } + } + out.push_str(&format!("{pad}{{/if}}\n")); + } + Node::Each { + item, + seq, + key, + body, + .. + } => { + out.push_str(&format!( + "{pad}{{#each {} as {item} ({})}}\n", + expr_str(seq), + expr_str(key) + )); + for c in body { + fmt_node(c, depth + 1, out); + } + out.push_str(&format!("{pad}{{/each}}\n")); + } + Node::Match { + scrutinee, arms, .. + } => { + out.push_str(&format!("{pad}{{#match {}}}\n", expr_str(scrutinee))); + for a in arms { + match &a.pattern { + MatchPattern::Variant(v) => match &a.binding { + Some(b) => out.push_str(&format!("{pad}{INDENT}{{:when {v} {b}}}\n")), + None => out.push_str(&format!("{pad}{INDENT}{{:when {v}}}\n")), + }, + MatchPattern::Else => out.push_str(&format!("{pad}{INDENT}{{:else}}\n")), + } + for c in &a.body { + fmt_node(c, depth + 2, out); + } + } + out.push_str(&format!("{pad}{{/match}}\n")); + } + Node::Error { .. } => {} + } +} + +fn is_inline_text_only(e: &Element) -> bool { + e.children.len() == 1 && matches!(&e.children[0], Node::Text { .. }) +} + +fn text_runs_str(runs: &[TextRun]) -> String { + let mut out = String::new(); + for r in runs { + match r { + TextRun::Literal(t) => out.push_str(&normalize_text(t)), + TextRun::Interp(x) => out.push_str(&format!("{{{}}}", expr_str(x))), + } + } + out +} + +/// Collapses internal whitespace runs; preserves single spaces. +fn normalize_text(t: &str) -> String { + let has_lead = t.starts_with(char::is_whitespace); + let has_trail = t.ends_with(char::is_whitespace); + let core = t.split_whitespace().collect::<Vec<_>>().join(" "); + format!( + "{}{core}{}", + if has_lead && !core.is_empty() { + " " + } else { + "" + }, + if has_trail && !core.is_empty() { + " " + } else { + "" + } + ) +} + +fn fmt_example_clause(c: &ExampleClause, out: &mut String) { + match c { + ExampleClause::From { name, .. } => out.push_str(&format!("{INDENT}from {name}\n")), + ExampleClause::Note { text, .. } => { + out.push_str(&format!("{INDENT}note {}\n", quote(text))); + } + ExampleClause::Params { entries, .. } => fmt_assign_block("params", entries, out), + ExampleClause::Props { entries, .. } => fmt_assign_block("props", entries, out), + ExampleClause::State { entries, .. } => fmt_assign_block("state", entries, out), + ExampleClause::Projection(p) => { + out.push_str(&format!("{INDENT}{}\n", projection_pin_str(p))); + } + ExampleClause::Events { entries, .. } => { + if entries.len() == 1 { + out.push_str(&format!( + "{INDENT}events [ {} ]\n", + example_event_str(&entries[0]) + )); + } else { + out.push_str(&format!("{INDENT}events [\n")); + for e in entries { + out.push_str(&format!("{INDENT}{INDENT}{}\n", example_event_str(e))); + } + out.push_str(&format!("{INDENT}]\n")); + } + } + ExampleClause::Error { .. } => {} + } +} + +fn fmt_assign_block(kw: &str, entries: &[(String, Expr)], out: &mut String) { + if entries.len() == 1 { + out.push_str(&format!( + "{INDENT}{kw} {{ {} = {} }}\n", + entries[0].0, + expr_str(&entries[0].1) + )); + return; + } + out.push_str(&format!("{INDENT}{kw} {{\n")); + for (n, v) in entries { + out.push_str(&format!("{INDENT}{INDENT}{n} = {}\n", expr_str(v))); + } + out.push_str(&format!("{INDENT}}}\n")); +} + +fn projection_pin_str(p: &ProjectionPin) -> String { + let key = match &p.key { + Some(k) => format!("({})", expr_str(k)), + None => String::new(), + }; + format!( + "projection {}.{}{key} = {}", + p.port, + p.projection, + expr_str(&p.value) + ) +} + +fn example_event_str(e: &ExampleEvent) -> String { + match e { + ExampleEvent::Semantic { name, args, .. } => format!("{name}({})", args_str(args)), + ExampleEvent::Outcome { + command, + which, + args, + .. + } => format!( + "outcome {command}.{}({})", + if *which == OutcomeKind::Ok { + "ok" + } else { + "err" + }, + args_str(args) + ), + ExampleEvent::Projection(p) => projection_pin_str(p), + } +} + +// ── leaf renderers ────────────────────────────────────────────────────────── + +pub fn type_str(t: &TypeExpr) -> String { + match &t.kind { + TypeKind::Name(n) => n.clone(), + TypeKind::List(inner) => format!("list[{}]", type_str(inner)), + TypeKind::Map(k, v) => format!("map[{k}]{}", type_str(v)), + TypeKind::Option(inner) => format!("{}?", type_str(inner)), + TypeKind::Error => "<error>".to_string(), + } +} + +fn literal_str(l: &Literal) -> String { + match l { + Literal::Int(i) => i.to_string(), + Literal::Str(s) => quote(s), + Literal::Bool(b) => b.to_string(), + Literal::None => "none".to_string(), + Literal::EmptyMap => "{}".to_string(), + Literal::Error => "<error>".to_string(), + } +} + +fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +fn args_str(args: &[Arg]) -> String { + args.iter() + .map(|a| format!("{}: {}", a.name, expr_str(&a.value))) + .collect::<Vec<_>>() + .join(", ") +} + +/// Renders an expression with minimal parentheses (by precedence). +pub fn expr_str(e: &Expr) -> String { + render_expr(e, 0) +} + +/// Precedence levels, loosest → tightest (must mirror the parser). +fn level(e: &Expr) -> u8 { + match &e.kind { + ExprKind::If { .. } => 0, + ExprKind::Binary { op, .. } => match op { + BinaryOp::Or => 1, + BinaryOp::And => 2, + BinaryOp::Eq + | BinaryOp::NotEq + | BinaryOp::Lt + | BinaryOp::Le + | BinaryOp::Gt + | BinaryOp::Ge => 3, + BinaryOp::Coalesce => 4, + BinaryOp::Add | BinaryOp::Sub | BinaryOp::Concat => 5, + }, + ExprKind::Unary { .. } => 6, + _ => 7, + } +} + +fn render_expr(e: &Expr, min_level: u8) -> String { + let mine = level(e); + let body = match &e.kind { + ExprKind::Ident(n) => n.clone(), + ExprKind::Int(i) => i.to_string(), + ExprKind::Str(s) => quote(s), + ExprKind::Bool(b) => b.to_string(), + ExprKind::None => "none".to_string(), + ExprKind::Field { base, name } => format!("{}.{name}", render_expr(base, 7)), + ExprKind::Index { base, key } => { + format!("{}[{}]", render_expr(base, 7), render_expr(key, 0)) + } + ExprKind::Call { name, args } => format!( + "{name}({})", + args.iter() + .map(|a| render_expr(a, 0)) + .collect::<Vec<_>>() + .join(", ") + ), + ExprKind::Unary { op, expr } => { + let sym = match op { + UnaryOp::Not => "!", + UnaryOp::Neg => "-", + }; + format!("{sym}{}", render_expr(expr, 6)) + } + ExprKind::Binary { op, lhs, rhs } => { + let sym = match op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Concat => "++", + BinaryOp::Eq => "==", + BinaryOp::NotEq => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::And => "&&", + BinaryOp::Or => "||", + BinaryOp::Coalesce => "??", + }; + // Left-associative: rhs needs one level tighter. + format!( + "{} {sym} {}", + render_expr(lhs, mine), + render_expr(rhs, mine + 1) + ) + } + ExprKind::If { cond, then, els } => format!( + "if {} then {} else {}", + render_expr(cond, 1), + render_expr(then, 0), + render_expr(els, 0) + ), + ExprKind::Record(fields) => format!( + "{{ {} }}", + fields + .iter() + .map(|(n, v)| format!("{n}: {}", render_expr(v, 0))) + .collect::<Vec<_>>() + .join(", ") + ), + ExprKind::Error => "<error>".to_string(), + }; + if mine < min_level { + format!("({body})") + } else { + body + } +} diff --git a/uhura/crates/uhura-syntax/src/lib.rs b/uhura/crates/uhura-syntax/src/lib.rs new file mode 100644 index 0000000..6b89c2c --- /dev/null +++ b/uhura/crates/uhura-syntax/src/lib.rs @@ -0,0 +1,15 @@ +//! uhura-syntax: mode-switching lexer (Dsl / Markup / Expr / Style / +//! Examples), recursive-descent parsers with recovery, AST, and the one +//! canonical trivia-preserving formatter (design §4, §12.2). + +pub mod ast; +pub mod css; +mod cursor; +mod format; +mod parser; +mod token; + +pub use cursor::Cursor; +pub use format::{expr_str, format_examples, format_module, type_str}; +pub use parser::{ParseOutput, Parsed, SourceKind, parse}; +pub use token::{Comment, Token, TokenKind}; diff --git a/uhura/crates/uhura-syntax/src/parser/dsl.rs b/uhura/crates/uhura-syntax/src/parser/dsl.rs new file mode 100644 index 0000000..7a0e4e7 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/dsl.rs @@ -0,0 +1,650 @@ +//! Header and store parsing (design §4.1–§4.2). These surfaces are pure +//! DSL; the file-level driver (`mod.rs`) decides when markup begins. + +use uhura_base::{Span, codes}; + +use crate::ast::*; +use crate::token::TokenKind as T; + +use super::expr::{parse_args, parse_expr, parse_type}; +use super::stream::DslStream; + +/// Sync set for header/store recovery: skip until one of these idents (at +/// nesting depth 0) or EOF. +fn sync_to(s: &mut DslStream, targets: &[&str]) { + let mut depth = 0i32; + loop { + match s.peek() { + T::Eof => return, + T::LBrace => { + depth += 1; + s.bump(); + } + T::RBrace => { + if depth == 0 { + return; + } + depth -= 1; + s.bump(); + } + T::Ident(name) if depth == 0 && targets.iter().any(|t| t == name) => return, + _ => { + s.bump(); + } + } + } +} + +// ── header declarations ───────────────────────────────────────────────────── + +pub fn parse_use(s: &mut DslStream) -> Option<Use> { + let leading = s.take_leading(); + let start = s.peek_span(); + if !s.eat_ident("use") { + return None; + } + let Some((kind, _)) = s.expect_ident("after `use` (component | surface | port | fixture)") + else { + sync_to(s, &["use", "props", "emits", "param", "store", "example"]); + return None; + }; + match kind.as_str() { + "component" => { + let (name, nspan) = s.expect_ident("as the component name")?; + Some(Use::Component { + name, + span: start.to(nspan), + leading, + }) + } + "surface" => { + let (name, nspan) = s.expect_ident("as the surface name")?; + Some(Use::Surface { + name, + span: start.to(nspan), + leading, + }) + } + "fixture" => { + let (name, nspan) = s.expect_ident("as the fixture name")?; + Some(Use::Fixture { + name, + span: start.to(nspan), + leading, + }) + } + "port" => { + let (name, _) = s.expect_ident("as the port name")?; + s.expect(&T::LBrace, "to open the port import list"); + let mut items = Vec::new(); + loop { + match s.peek().clone() { + T::RBrace => { + break; + } + T::Eof => break, + T::Comma => { + s.bump(); + } + T::Ident(k) if matches!(k.as_str(), "projection" | "command" | "type") => { + let kspan = s.peek_span(); + s.bump(); + let kind = match k.as_str() { + "projection" => PortItemKind::Projection, + "command" => PortItemKind::Command, + _ => PortItemKind::Type, + }; + if let Some((iname, ispan)) = s.expect_ident("as the imported item name") { + items.push(PortItem { + kind, + name: iname, + span: kspan.to(ispan), + }); + } + } + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!( + "expected `projection`, `command`, or `type` in the port \ + import list, found {desc}" + ), + span, + ); + s.bump(); + } + } + } + let end = s.peek_span(); + s.expect(&T::RBrace, "to close the port import list"); + Some(Use::Port { + name, + items, + span: start.to(end), + leading, + }) + } + other => { + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("`use {other}` is not an import kind (component | surface | port)"), + span, + ); + sync_to(s, &["use", "props", "emits", "param", "store"]); + None + } + } +} + +/// `props { name: type, … }` — brace block of typed names. +pub fn parse_props_block(s: &mut DslStream) -> Vec<PropDecl> { + parse_typed_block(s, "props") + .into_iter() + .map(|(name, ty, span, leading)| PropDecl { + name, + ty, + span, + leading, + }) + .collect() +} + +fn parse_typed_block( + s: &mut DslStream, + what: &str, +) -> Vec<(String, TypeExpr, Span, Vec<crate::token::Comment>)> { + let mut out = Vec::new(); + s.expect(&T::LBrace, &format!("to open the `{what}` block")); + loop { + match s.peek() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => break, + T::Comma => { + s.bump(); + } + _ => { + let leading = s.take_leading(); + let start = s.peek_span(); + let Some((name, _)) = s.expect_ident(&format!("as a {what} name")) else { + sync_to(s, &[]); + break; + }; + s.expect(&T::Colon, "before the type"); + let ty = parse_type(s); + let span = start.to(ty.span); + out.push((name, ty, span, leading)); + } + } + } + out +} + +/// `emits { name(field: type, …), … }` +pub fn parse_emits_block(s: &mut DslStream) -> Vec<EmitDecl> { + let mut out = Vec::new(); + s.expect(&T::LBrace, "to open the `emits` block"); + loop { + match s.peek() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => break, + T::Comma => { + s.bump(); + } + _ => { + let leading = s.take_leading(); + let start = s.peek_span(); + let Some((name, mut end)) = s.expect_ident("as an emit name") else { + sync_to(s, &[]); + break; + }; + let mut params = Vec::new(); + if *s.peek() == T::LParen { + s.bump(); + if *s.peek() != T::RParen { + loop { + let Some((pname, _)) = s.expect_ident("as a payload field name") else { + break; + }; + s.expect(&T::Colon, "before the field type"); + let ty = parse_type(s); + params.push((pname, ty)); + if !s.eat(&T::Comma) { + break; + } + } + } + end = s.peek_span(); + s.expect(&T::RParen, "to close the emit payload"); + } + out.push(EmitDecl { + name, + params, + span: start.to(end), + leading, + }); + } + } + } + out +} + +/// `param user: id` +pub fn parse_param(s: &mut DslStream) -> Option<ParamDecl> { + let leading = s.take_leading(); + let start = s.peek_span(); + if !s.eat_ident("param") { + return None; + } + let (name, _) = s.expect_ident("as the route parameter name")?; + s.expect(&T::Colon, "before the parameter type"); + let ty = parse_type(s); + let span = start.to(ty.span); + Some(ParamDecl { + name, + ty, + span, + leading, + }) +} + +// ── store ─────────────────────────────────────────────────────────────────── + +pub fn parse_store(s: &mut DslStream) -> Store { + let leading = s.take_leading(); + let start = s.peek_span(); + s.eat_ident("store"); + s.expect(&T::LBrace, "to open the store block"); + let mut state = Vec::new(); + let mut handlers = Vec::new(); + loop { + match s.peek().clone() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => { + let span = s.peek_span(); + s.cur + .error(codes::UNCLOSED_BLOCK, "unclosed `store { … }`", span); + break; + } + T::Ident(k) if k == "state" => { + s.bump(); + parse_state_block(s, &mut state); + } + T::Ident(k) if k == "on" => { + if let Some(h) = parse_handler(s) { + handlers.push(h); + } + } + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected `state` or `on` in the store, found {desc}"), + span, + ); + sync_to(s, &["state", "on"]); + } + } + } + let span = start.to(s.peek_span()); + Store { + state, + handlers, + span, + leading, + } +} + +fn parse_state_block(s: &mut DslStream, out: &mut Vec<StateField>) { + s.expect(&T::LBrace, "to open the state block"); + loop { + match s.peek() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => break, + T::Comma => { + s.bump(); + } + _ => { + let leading = s.take_leading(); + let start = s.peek_span(); + let Some((name, _)) = s.expect_ident("as a state field name") else { + sync_to(s, &["on"]); + break; + }; + s.expect(&T::Colon, "before the field type"); + let ty = parse_type(s); + s.expect( + &T::Eq, + "before the initial value (state initializers are literals)", + ); + let (init, end) = parse_literal(s); + out.push(StateField { + name, + ty, + init, + span: start.to(end), + leading, + }); + } + } + } +} + +fn parse_literal(s: &mut DslStream) -> (Literal, Span) { + let span = s.peek_span(); + let lit = match s.peek().clone() { + T::Int(i) => { + s.bump(); + Literal::Int(i) + } + T::Str(v) => { + s.bump(); + Literal::Str(v) + } + T::Ident(name) => match name.as_str() { + "true" => { + s.bump(); + Literal::Bool(true) + } + "false" => { + s.bump(); + Literal::Bool(false) + } + "none" => { + s.bump(); + Literal::None + } + _ => { + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("state initializers are literals only (§4.3), found `{name}`"), + span, + ); + s.bump(); + Literal::Error + } + }, + T::LBrace => { + s.bump(); + let end = s.peek_span(); + if s.expect( + &T::RBrace, + "— `{}` (the empty map) is the only brace literal here", + ) + .is_some() + { + return (Literal::EmptyMap, span.to(end)); + } + Literal::Error + } + T::Minus => { + // Negative integer literal. + s.bump(); + if let T::Int(i) = s.peek().clone() { + let end = s.peek_span(); + s.bump(); + return (Literal::Int(-i), span.to(end)); + } + s.cur.error( + codes::UNEXPECTED_TOKEN, + "expected an integer after `-`", + span, + ); + Literal::Error + } + other => { + let desc = other.describe(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected a literal initializer, found {desc}"), + span, + ); + s.bump(); + Literal::Error + } + }; + (lit, span) +} + +fn parse_handler(s: &mut DslStream) -> Option<Handler> { + let leading = s.take_leading(); + let start = s.peek_span(); + s.eat_ident("on"); + let (first, fspan) = s.expect_ident("as the event name")?; + + // `on <command>.ok(…)` / `.err(…)` — outcome handlers. + let event = if *s.peek() == T::Dot { + s.bump(); + let (which, wspan) = s.expect_ident("(`ok` or `err`) after `.`")?; + let kind = match which.as_str() { + "ok" => OutcomeKind::Ok, + "err" => OutcomeKind::Err, + other => { + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("outcome handlers are `.ok` or `.err`, found `.{other}`"), + wspan, + ); + OutcomeKind::Err + } + }; + EventRef::Outcome { + command: first, + which: kind, + span: fspan.to(wspan), + } + } else { + EventRef::Semantic { + name: first, + span: fspan, + } + }; + + // Parameter list: UI events declare `name: type`; outcome handlers are + // name-only. + let mut params = Vec::new(); + if *s.peek() == T::LParen { + s.bump(); + if *s.peek() != T::RParen { + loop { + let pstart = s.peek_span(); + let Some((pname, pspan)) = s.expect_ident("as a handler parameter") else { + break; + }; + let ty = if s.eat(&T::Colon) { + Some(parse_type(s)) + } else { + None + }; + let span = ty.as_ref().map_or(pspan, |t| pstart.to(t.span)); + params.push(HandlerParam { + name: pname, + ty, + span, + }); + if !s.eat(&T::Comma) { + break; + } + } + } + s.expect(&T::RParen, "to close the handler parameters"); + } + + let guard = if s.eat_ident("when") { + Some(parse_expr(s)) + } else { + None + }; + + s.expect(&T::LBrace, "to open the handler body"); + let mut body = Vec::new(); + loop { + match s.peek().clone() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => { + let span = s.peek_span(); + s.cur + .error(codes::UNCLOSED_BLOCK, "unclosed handler body", span); + break; + } + _ => match parse_stmt(s) { + Some(st) => body.push(st), + None => { + sync_to( + s, + &["set", "send", "open-surface", "dismiss", "navigate", "on"], + ); + if s.peek().is_ident("on") { + // Missing `}` — let the store loop pick the next handler. + let span = s.peek_span(); + s.cur + .error(codes::UNCLOSED_BLOCK, "handler body not closed", span); + break; + } + } + }, + } + } + let span = start.to(s.peek_span()); + Some(Handler { + event, + params, + guard, + body, + span, + leading, + }) +} + +/// The five statements (design §4.2). +fn parse_stmt(s: &mut DslStream) -> Option<Stmt> { + let leading = s.take_leading(); + let start = s.peek_span(); + let T::Ident(kw) = s.peek().clone() else { + let desc = s.peek().describe(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected a statement (set | send | open-surface | dismiss | navigate), found {desc}"), + start, + ); + return None; + }; + match kw.as_str() { + "set" => { + s.bump(); + let (field, fspan) = s.expect_ident("as the state field")?; + let key = if s.eat(&T::LBracket) { + let k = parse_expr(s); + s.expect(&T::RBracket, "to close the map key"); + Some(k) + } else { + None + }; + let path_span = fspan; + s.expect(&T::Eq, "in `set <path> = <expr>`"); + let value = parse_expr(s); + let span = start.to(value.span); + Some(Stmt::Set { + path: SetPath { + field, + key, + span: path_span, + }, + value, + span, + leading, + }) + } + "send" => { + s.bump(); + let (command, _) = s.expect_ident("as the command name")?; + let args = parse_args(s); + let bind = if s.eat_ident("as") { + s.expect_ident("as the tag binding name").map(|(n, _)| n) + } else { + None + }; + let span = start.to(s.peek_span()); + Some(Stmt::Send { + command, + args, + bind, + span, + leading, + }) + } + "open-surface" => { + s.bump(); + let (name, _) = s.expect_ident("as the surface name")?; + let args = parse_args(s); + let span = start.to(s.peek_span()); + Some(Stmt::OpenSurface { + name, + args, + span, + leading, + }) + } + "dismiss" => { + s.bump(); + Some(Stmt::Dismiss { + span: start, + leading, + }) + } + "navigate" => { + s.bump(); + let (target_name, tspan) = s.expect_ident("as a route name or `back`")?; + if target_name == "back" { + return Some(Stmt::Navigate { + target: NavTarget::Back, + span: start.to(tspan), + leading, + }); + } + let args = if *s.peek() == T::LParen { + parse_args(s) + } else { + Vec::new() + }; + let span = start.to(s.peek_span()); + Some(Stmt::Navigate { + target: NavTarget::Route { + name: target_name, + args, + }, + span, + leading, + }) + } + other => { + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!( + "`{other}` is not a statement — the closed set is \ + set | send | open-surface | dismiss | navigate (§4.2)" + ), + start, + ); + None + } + } +} diff --git a/uhura/crates/uhura-syntax/src/parser/examples.rs b/uhura/crates/uhura-syntax/src/parser/examples.rs new file mode 100644 index 0000000..9b6c0ef --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/examples.rs @@ -0,0 +1,296 @@ +//! `.examples.uhura` files (design §6.1): `use fixture …` imports plus +//! `example <name> [default] { clauses }` declarations. Pure DSL surface. + +use uhura_base::codes; + +use crate::ast::*; +use crate::token::TokenKind as T; + +use super::expr::{parse_args, parse_expr}; +use super::stream::DslStream; + +pub fn parse_examples(s: &mut DslStream) -> ExamplesFile { + let mut uses = Vec::new(); + let mut examples = Vec::new(); + loop { + match s.peek().clone() { + T::Eof => break, + T::Ident(k) if k == "use" => { + if let Some(u) = super::dsl::parse_use(s) { + uses.push(u); + } + } + T::Ident(k) if k == "example" => { + if let Some(e) = parse_example(s) { + examples.push(e); + } + } + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected `use fixture …` or `example …`, found {desc}"), + span, + ); + s.bump(); + } + } + } + ExamplesFile { uses, examples } +} + +fn parse_example(s: &mut DslStream) -> Option<ExampleDecl> { + let leading = s.take_leading(); + let start = s.peek_span(); + s.eat_ident("example"); + let (name, _) = s.expect_ident("as the example name")?; + let is_default = s.eat_ident("default"); + s.expect(&T::LBrace, "to open the example body"); + + let mut clauses = Vec::new(); + loop { + match s.peek().clone() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => { + let span = s.peek_span(); + s.cur + .error(codes::UNCLOSED_BLOCK, "unclosed example body", span); + break; + } + T::Ident(k) => { + let cstart = s.peek_span(); + match k.as_str() { + "from" => { + s.bump(); + if let Some((from, fspan)) = s.expect_ident("as the parent example") { + clauses.push(ExampleClause::From { + name: from, + span: cstart.to(fspan), + }); + } + } + "note" => { + s.bump(); + if let T::Str(text) = s.peek().clone() { + let tspan = s.peek_span(); + s.bump(); + clauses.push(ExampleClause::Note { + text, + span: cstart.to(tspan), + }); + } else { + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + "`note` takes a string literal", + span, + ); + } + } + "params" | "props" | "state" => { + s.bump(); + let entries = parse_assign_block(s, &k); + let span = cstart.to(s.peek_span()); + clauses.push(match k.as_str() { + "params" => ExampleClause::Params { entries, span }, + "props" => ExampleClause::Props { entries, span }, + _ => ExampleClause::State { entries, span }, + }); + } + "projection" => { + s.bump(); + if let Some(pin) = parse_projection_pin(s) { + clauses.push(ExampleClause::Projection(pin)); + } + } + "events" => { + s.bump(); + let entries = parse_events_list(s); + let span = cstart.to(s.peek_span()); + clauses.push(ExampleClause::Events { entries, span }); + } + other => { + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!( + "unknown example clause `{other}` — clauses are from | note | \ + params | props | state | projection | events" + ), + cstart, + ); + s.bump(); + } + } + } + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected an example clause, found {desc}"), + span, + ); + s.bump(); + } + } + } + let span = start.to(s.peek_span()); + Some(ExampleDecl { + name, + is_default, + clauses, + span, + leading, + }) +} + +/// `{ name = expr, … }` for params / props / state clauses. +fn parse_assign_block(s: &mut DslStream, what: &str) -> Vec<(String, Expr)> { + let mut out = Vec::new(); + if s.expect(&T::LBrace, &format!("to open the `{what}` clause")) + .is_none() + { + return out; + } + loop { + match s.peek() { + T::RBrace => { + s.bump(); + break; + } + T::Eof => break, + T::Comma => { + s.bump(); + } + _ => { + let Some((name, _)) = s.expect_ident(&format!("as a {what} entry name")) else { + break; + }; + s.expect(&T::Eq, "before the value"); + out.push((name, parse_expr(s))); + } + } + } + out +} + +/// `feed.feed-page = expr` or `comments.for-post("post-1") = expr` +/// (the leading `projection` keyword is already consumed). +fn parse_projection_pin(s: &mut DslStream) -> Option<ProjectionPin> { + let start = s.peek_span(); + let (port, _) = s.expect_ident("as the port name")?; + s.expect(&T::Dot, "between port and projection"); + let (projection, _) = s.expect_ident("as the projection name")?; + let key = if *s.peek() == T::LParen { + s.bump(); + let k = parse_expr(s); + s.expect(&T::RParen, "to close the projection key"); + Some(k) + } else { + None + }; + s.expect(&T::Eq, "before the pinned value"); + let value = parse_expr(s); + let span = start.to(value.span); + Some(ProjectionPin { + port, + projection, + key, + value, + span, + }) +} + +/// `[ entry … ]` — the derivation timeline (design §6.2). +fn parse_events_list(s: &mut DslStream) -> Vec<ExampleEvent> { + let mut out = Vec::new(); + if s.expect(&T::LBracket, "to open the events timeline") + .is_none() + { + return out; + } + loop { + match s.peek().clone() { + T::RBracket => { + s.bump(); + break; + } + T::Eof => { + let span = s.peek_span(); + s.cur + .error(codes::UNCLOSED_BLOCK, "unclosed events timeline", span); + break; + } + T::Comma => { + s.bump(); + } + T::Ident(k) if k == "outcome" => { + let start = s.peek_span(); + s.bump(); + let Some((command, _)) = s.expect_ident("as the command name") else { + continue; + }; + s.expect(&T::Dot, "before `ok` or `err`"); + let which = match s.expect_ident("(`ok` or `err`)") { + Some((w, wspan)) => match w.as_str() { + "ok" => OutcomeKind::Ok, + "err" => OutcomeKind::Err, + other => { + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected `ok` or `err`, found `{other}`"), + wspan, + ); + OutcomeKind::Err + } + }, + None => OutcomeKind::Err, + }; + let args = if *s.peek() == T::LParen { + parse_args(s) + } else { + Vec::new() + }; + let span = start.to(s.peek_span()); + out.push(ExampleEvent::Outcome { + command, + which, + args, + span, + }); + } + T::Ident(k) if k == "projection" => { + s.bump(); + if let Some(pin) = parse_projection_pin(s) { + out.push(ExampleEvent::Projection(pin)); + } + } + T::Ident(_) => { + let start = s.peek_span(); + let (name, _) = s.expect_ident("as the event name").unwrap(); + let args = if *s.peek() == T::LParen { + parse_args(s) + } else { + Vec::new() + }; + let span = start.to(s.peek_span()); + out.push(ExampleEvent::Semantic { name, args, span }); + } + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected a timeline entry, found {desc}"), + span, + ); + s.bump(); + } + } + } + out +} diff --git a/uhura/crates/uhura-syntax/src/parser/expr.rs b/uhura/crates/uhura-syntax/src/parser/expr.rs new file mode 100644 index 0000000..be64d01 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/expr.rs @@ -0,0 +1,436 @@ +//! The total, tiny, closed expression language (design §4.3) and type +//! expressions. Precedence, loosest → tightest (micro-decision #4): +//! +//! `if-then-else` < `||` < `&&` < comparison (non-assoc) < `??` +//! < `+ - ++` < unary `! -` < postfix `.field` `[k]` `(call)` + +use uhura_base::codes; + +use crate::ast::{Arg, BinaryOp, Expr, ExprKind, TypeExpr, TypeKind, UnaryOp}; +use crate::token::TokenKind as T; + +use super::stream::DslStream; + +pub fn parse_expr(s: &mut DslStream) -> Expr { + parse_if_expr(s) +} + +fn parse_if_expr(s: &mut DslStream) -> Expr { + let start = s.peek_span(); + if s.peek().is_ident("if") { + s.bump(); + let cond = parse_or(s); + s.expect(&T::Ident("then".into()), "in `if … then … else …`"); + let then = parse_if_expr(s); + let els = if s.eat_ident("else") { + parse_if_expr(s) + } else { + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + "`if` expressions require an `else` branch (§4.3: both branches, same type)", + span, + ); + Expr { + kind: ExprKind::Error, + span, + } + }; + let span = start.to(els.span); + return Expr { + kind: ExprKind::If { + cond: Box::new(cond), + then: Box::new(then), + els: Box::new(els), + }, + span, + }; + } + parse_or(s) +} + +fn parse_or(s: &mut DslStream) -> Expr { + let mut lhs = parse_and(s); + while *s.peek() == T::OrOr { + s.bump(); + let rhs = parse_and(s); + let span = lhs.span.to(rhs.span); + lhs = Expr { + kind: ExprKind::Binary { + op: BinaryOp::Or, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span, + }; + } + lhs +} + +fn parse_and(s: &mut DslStream) -> Expr { + let mut lhs = parse_cmp(s); + while *s.peek() == T::AndAnd { + s.bump(); + let rhs = parse_cmp(s); + let span = lhs.span.to(rhs.span); + lhs = Expr { + kind: ExprKind::Binary { + op: BinaryOp::And, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span, + }; + } + lhs +} + +fn cmp_op(t: &T) -> Option<BinaryOp> { + match t { + T::EqEq => Some(BinaryOp::Eq), + T::NotEq => Some(BinaryOp::NotEq), + T::Lt => Some(BinaryOp::Lt), + T::Le => Some(BinaryOp::Le), + T::Gt => Some(BinaryOp::Gt), + T::Ge => Some(BinaryOp::Ge), + _ => None, + } +} + +fn parse_cmp(s: &mut DslStream) -> Expr { + let lhs = parse_coalesce(s); + let Some(op) = cmp_op(s.peek()) else { + return lhs; + }; + s.bump(); + let rhs = parse_coalesce(s); + let span = lhs.span.to(rhs.span); + let out = Expr { + kind: ExprKind::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span, + }; + // Comparison is non-associative: a second comparison operator here is + // a hard parse error (design §4.3). + if cmp_op(s.peek()).is_some() { + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + "comparison operators do not chain — parenthesize explicitly", + span, + ); + s.bump(); + let _ = parse_coalesce(s); + } + out +} + +fn parse_coalesce(s: &mut DslStream) -> Expr { + let mut lhs = parse_additive(s); + while *s.peek() == T::Coalesce { + s.bump(); + let rhs = parse_additive(s); + let span = lhs.span.to(rhs.span); + lhs = Expr { + kind: ExprKind::Binary { + op: BinaryOp::Coalesce, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span, + }; + } + lhs +} + +fn parse_additive(s: &mut DslStream) -> Expr { + let mut lhs = parse_unary(s); + loop { + let op = match s.peek() { + T::Plus => BinaryOp::Add, + T::Minus => BinaryOp::Sub, + T::PlusPlus => BinaryOp::Concat, + _ => break, + }; + s.bump(); + let rhs = parse_unary(s); + let span = lhs.span.to(rhs.span); + lhs = Expr { + kind: ExprKind::Binary { + op, + lhs: Box::new(lhs), + rhs: Box::new(rhs), + }, + span, + }; + } + lhs +} + +fn parse_unary(s: &mut DslStream) -> Expr { + let start = s.peek_span(); + let op = match s.peek() { + T::Bang => Some(UnaryOp::Not), + T::Minus => Some(UnaryOp::Neg), + _ => None, + }; + if let Some(op) = op { + s.bump(); + let expr = parse_unary(s); + let span = start.to(expr.span); + return Expr { + kind: ExprKind::Unary { + op, + expr: Box::new(expr), + }, + span, + }; + } + parse_postfix(s) +} + +fn parse_postfix(s: &mut DslStream) -> Expr { + let mut expr = parse_primary(s); + loop { + match s.peek() { + T::Dot => { + s.bump(); + if let Some((name, nspan)) = s.expect_ident("after `.`") { + let span = expr.span.to(nspan); + expr = Expr { + kind: ExprKind::Field { + base: Box::new(expr), + name, + }, + span, + }; + } else { + break; + } + } + T::LBracket => { + s.bump(); + let key = parse_expr(s); + let end = s.peek_span(); + s.expect(&T::RBracket, "to close the index"); + let span = expr.span.to(end); + expr = Expr { + kind: ExprKind::Index { + base: Box::new(expr), + key: Box::new(key), + }, + span, + }; + } + _ => break, + } + } + expr +} + +fn parse_primary(s: &mut DslStream) -> Expr { + let t = s.peek_token(); + let span = t.span; + match s.peek().clone() { + T::Int(i) => { + s.bump(); + Expr { + kind: ExprKind::Int(i), + span, + } + } + T::Str(v) => { + s.bump(); + Expr { + kind: ExprKind::Str(v), + span, + } + } + T::Ident(name) => { + match name.as_str() { + "true" => { + s.bump(); + return Expr { + kind: ExprKind::Bool(true), + span, + }; + } + "false" => { + s.bump(); + return Expr { + kind: ExprKind::Bool(false), + span, + }; + } + "none" => { + s.bump(); + return Expr { + kind: ExprKind::None, + span, + }; + } + _ => {} + } + s.bump(); + // Call form: `name(expr, …)` — builtins and keyed projections. + if *s.peek() == T::LParen { + s.bump(); + let mut args = Vec::new(); + if *s.peek() != T::RParen { + loop { + args.push(parse_expr(s)); + if !s.eat(&T::Comma) { + break; + } + } + } + let end = s.peek_span(); + s.expect(&T::RParen, "to close the call"); + return Expr { + kind: ExprKind::Call { name, args }, + span: span.to(end), + }; + } + Expr { + kind: ExprKind::Ident(name), + span, + } + } + T::LParen => { + s.bump(); + let inner = parse_expr(s); + s.expect(&T::RParen, "to close the group"); + inner + } + T::LBrace => { + // Record literal `{ field: expr, … }` (set-rhs and example pins). + s.bump(); + let mut fields = Vec::new(); + if *s.peek() != T::RBrace { + loop { + let Some((name, _)) = s.expect_ident("as a record field name") else { + break; + }; + s.expect(&T::Colon, "after the field name"); + fields.push((name, parse_expr(s))); + if !s.eat(&T::Comma) { + break; + } + } + } + let end = s.peek_span(); + s.expect(&T::RBrace, "to close the record literal"); + Expr { + kind: ExprKind::Record(fields), + span: span.to(end), + } + } + other => { + let desc = other.describe(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected an expression, found {desc}"), + span, + ); + s.bump(); + Expr { + kind: ExprKind::Error, + span, + } + } + } +} + +/// Named argument list: `(name: expr, …)` — the opening paren is expected +/// by the caller's context description. +pub fn parse_args(s: &mut DslStream) -> Vec<Arg> { + let mut args = Vec::new(); + if s.expect(&T::LParen, "to open the argument list").is_none() { + return args; + } + if *s.peek() != T::RParen { + loop { + let start = s.peek_span(); + let Some((name, _)) = s.expect_ident("as an argument name") else { + break; + }; + s.expect( + &T::Colon, + "after the argument name (all arguments are named)", + ); + let value = parse_expr(s); + let span = start.to(value.span); + args.push(Arg { name, value, span }); + if !s.eat(&T::Comma) { + break; + } + } + } + s.expect(&T::RParen, "to close the argument list"); + args +} + +/// Type expressions: `name`, `list[T]`, `map[K]V`, suffix `?`. +pub fn parse_type(s: &mut DslStream) -> TypeExpr { + let start = s.peek_span(); + let base = match s.peek().clone() { + T::Ident(name) => { + s.bump(); + match name.as_str() { + "list" if *s.peek() == T::LBracket => { + s.bump(); + let inner = parse_type(s); + let end = s.peek_span(); + s.expect(&T::RBracket, "to close `list[…]`"); + TypeExpr { + kind: TypeKind::List(Box::new(inner)), + span: start.to(end), + } + } + "map" if *s.peek() == T::LBracket => { + s.bump(); + let key = match s.expect_ident("as the map key type") { + Some((k, _)) => k, + None => "id".to_string(), + }; + s.expect(&T::RBracket, "to close the map key"); + let value = parse_type(s); + let span = start.to(value.span); + TypeExpr { + kind: TypeKind::Map(key, Box::new(value)), + span, + } + } + _ => TypeExpr { + kind: TypeKind::Name(name), + span: start, + }, + } + } + other => { + let desc = other.describe(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected a type, found {desc}"), + start, + ); + TypeExpr { + kind: TypeKind::Error, + span: start, + } + } + }; + if *s.peek() == T::Question { + let end = s.peek_span(); + s.bump(); + let span = base.span.to(end); + return TypeExpr { + kind: TypeKind::Option(Box::new(base)), + span, + }; + } + base +} diff --git a/uhura/crates/uhura-syntax/src/parser/markup.rs b/uhura/crates/uhura-syntax/src/parser/markup.rs new file mode 100644 index 0000000..d2093eb --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/markup.rs @@ -0,0 +1,664 @@ +//! The markup surface (design §4.4): elements, `{#if}` / `{#each}` / +//! `{#match}` blocks, `{expr}` interpolation, `on:` event bindings. Parsed +//! char-wise off the shared cursor; every `{…}` expression region drops into +//! the DSL parser and resyncs on its closing brace. + +use uhura_base::codes; + +use crate::ast::*; +use crate::cursor::Cursor; +use crate::token::TokenKind as T; + +use super::expr::{parse_args, parse_expr}; +use super::stream::DslStream; + +/// Why `parse_nodes` stopped. +#[derive(Debug, PartialEq, Eq)] +pub enum Stop { + /// `</name>` — left unconsumed for the caller to match. + CloseTag, + /// `{:…}` — an arm marker; left unconsumed. + ArmMarker, + /// `{/…}` — a block close; left unconsumed. + BlockClose, + /// `<style>` boundary (top level); left unconsumed. + Style, + Eof, +} + +fn peek_markup_boundary(cur: &Cursor) -> Option<Stop> { + let rest = cur.rest(); + if rest.is_empty() { + return Some(Stop::Eof); + } + if rest.starts_with("</") { + return Some(Stop::CloseTag); + } + if rest.starts_with("<style") { + return Some(Stop::Style); + } + if rest.starts_with("{:") { + return Some(Stop::ArmMarker); + } + if rest.starts_with("{/") { + return Some(Stop::BlockClose); + } + None +} + +fn skip_markup_ws(cur: &mut Cursor) { + while matches!(cur.peek(), Some(c) if c.is_whitespace()) { + cur.bump(); + } +} + +/// Kebab identifier at the cursor (markup-side twin of the DSL rule). +fn markup_ident(cur: &mut Cursor) -> Option<String> { + let start = cur.pos(); + match cur.peek() { + Some('a'..='z') => { + cur.bump(); + } + _ => return None, + } + while let Some(c) = cur.peek() { + match c { + 'a'..='z' | '0'..='9' => { + cur.bump(); + } + '-' if matches!(cur.peek2(), Some('a'..='z' | '0'..='9')) => { + cur.bump(); + } + _ => break, + } + } + Some(cur.rest_from(start)) +} + +/// Parses sibling nodes until a boundary; the boundary is not consumed. +pub fn parse_nodes(cur: &mut Cursor) -> (Vec<Node>, Stop) { + let mut nodes = Vec::new(); + loop { + skip_markup_ws(cur); + if let Some(stop) = peek_markup_boundary(cur) { + return (nodes, stop); + } + match cur.peek() { + None => return (nodes, Stop::Eof), + Some('<') => { + if let Some(el) = parse_element(cur) { + nodes.push(el); + } + } + Some('{') if matches!(cur.peek2(), Some('#')) => { + if let Some(block) = parse_block(cur) { + nodes.push(block); + } + } + Some(_) => { + // A text run (literal text + interpolations). + if let Some(text) = parse_text(cur) { + nodes.push(text); + } + } + } + } +} + +fn parse_text(cur: &mut Cursor) -> Option<Node> { + let start = cur.pos(); + let mut runs: Vec<TextRun> = Vec::new(); + let mut literal = String::new(); + let mut lit_start = cur.pos(); + + loop { + match cur.peek() { + None | Some('<') => break, + Some('{') => { + match cur.peek2() { + Some('#') | Some(':') | Some('/') => break, + _ => { + // Interpolation. + if !literal.trim().is_empty() { + runs.push(TextRun::Literal(std::mem::take(&mut literal))); + } else { + literal.clear(); + } + cur.bump(); // `{` + let expr = parse_braced_expr(cur); + runs.push(TextRun::Interp(expr)); + lit_start = cur.pos(); + } + } + } + Some('}') => { + let at = cur.pos(); + cur.bump(); + cur.error( + codes::RAW_BRACE_IN_TEXT, + "raw `}` in markup text — human-readable content is typed data; \ + use an interpolation or move the text into an expression string", + uhura_base::Span::new(cur.file, at, cur.pos()), + ); + } + Some(_) => { + literal.push(cur.bump().unwrap()); + } + } + } + let _ = lit_start; + if !literal.trim().is_empty() { + runs.push(TextRun::Literal(literal)); + } + if runs.is_empty() { + return None; + } + Some(Node::Text { + runs, + span: cur.span_from(start), + }) +} + +/// `{` already consumed; parses `expr }` and resyncs. +fn parse_braced_expr(cur: &mut Cursor) -> Expr { + let mut s = DslStream::new(cur); + let expr = parse_expr(&mut s); + s.expect(&T::RBrace, "to close the interpolation"); + s.finish(); + expr +} + +fn parse_element(cur: &mut Cursor) -> Option<Node> { + let start = cur.pos(); + cur.bump(); // `<` + let Some(name) = markup_ident(cur) else { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected an element or component name after `<`", + cur.span_from(start), + ); + // Skip to `>` to avoid loops. + while let Some(c) = cur.bump() { + if c == '>' { + break; + } + } + return Some(Node::Error { + span: cur.span_from(start), + }); + }; + + let mut attrs = Vec::new(); + let mut events = Vec::new(); + let mut self_closing = false; + + loop { + skip_markup_ws(cur); + match cur.peek() { + None => { + cur.error( + codes::UNCLOSED_TAG, + format!("`<{name}` is never closed"), + cur.span_from(start), + ); + return Some(Node::Element(Element { + name, + attrs, + events, + children: Vec::new(), + self_closing: true, + span: cur.span_from(start), + })); + } + Some('>') => { + cur.bump(); + break; + } + Some('/') => { + cur.bump(); + if cur.eat('>') { + self_closing = true; + break; + } + cur.error( + codes::UNEXPECTED_TOKEN, + "expected `/>`", + cur.span_from(cur.pos() - 1), + ); + } + Some(_) => { + let astart = cur.pos(); + if cur.eat_str("on:") { + // Event attribute. + let Some(event) = markup_ident(cur) else { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected an event name after `on:`", + cur.span_from(astart), + ); + cur.bump(); + continue; + }; + let binding = if cur.eat('=') { + if cur.eat('{') { + parse_event_binding(cur) + } else { + cur.error( + codes::UNEXPECTED_TOKEN, + "event bindings are `on:event={emit …}` or bare `on:event`", + cur.span_from(astart), + ); + EventBinding::Forward + } + } else { + EventBinding::Forward + }; + events.push(EventAttr { + event, + binding, + span: cur.span_from(astart), + }); + } else if let Some(aname) = markup_ident(cur) { + let value = if cur.eat('=') { + match cur.peek() { + Some('"') => { + cur.bump(); + let vstart = cur.pos(); + while let Some(c) = cur.peek() { + if c == '"' || c == '\n' { + break; + } + cur.bump(); + } + let text = cur.rest_from(vstart); + if !cur.eat('"') { + cur.error( + codes::UNTERMINATED_STRING, + "unterminated attribute string", + cur.span_from(astart), + ); + } + AttrValue::Literal(text) + } + Some('{') => { + cur.bump(); + AttrValue::Expr(parse_braced_expr(cur)) + } + _ => { + cur.error( + codes::UNEXPECTED_TOKEN, + "attribute values are `\"literal\"` or `{expr}`", + cur.span_from(astart), + ); + AttrValue::Bare + } + } + } else { + AttrValue::Bare + }; + attrs.push(Attr { + name: aname, + value, + span: cur.span_from(astart), + }); + } else { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected an attribute, `on:` binding, `>` or `/>`", + cur.span_from(astart), + ); + cur.bump(); + } + } + } + } + + let mut children = Vec::new(); + if !self_closing { + let (kids, stop) = parse_nodes(cur); + children = kids; + match stop { + Stop::CloseTag => { + let close_start = cur.pos(); + // Speculative: consume `</ident>` only if the name matches; + // otherwise leave it for an ancestor element to match. + let rest = cur.rest(); + let tag_len = rest[2..].find('>').map(|i| i + 2).unwrap_or(rest.len()); + let close_name = rest[2..tag_len].trim().to_string(); + if close_name == name { + cur.set_pos(close_start + tag_len as u32); + cur.eat('>'); + } else { + cur.error( + codes::UNCLOSED_TAG, + format!( + "`<{name}>` is closed by `</{close_name}>` — closing `<{name}>` here" + ), + cur.span_from(close_start), + ); + // Do not consume; an ancestor may match it. + } + } + Stop::Eof | Stop::Style => { + cur.error( + codes::UNCLOSED_TAG, + format!("`<{name}>` is never closed"), + cur.span_from(start), + ); + } + Stop::ArmMarker | Stop::BlockClose => { + cur.error( + codes::UNCLOSED_TAG, + format!("`<{name}>` is not closed before the enclosing block ends"), + cur.span_from(start), + ); + } + } + } + + Some(Node::Element(Element { + name, + attrs, + events, + children, + self_closing, + span: cur.span_from(start), + })) +} + +/// Inside `on:x={` — parses `emit name(args)` up to the closing `}`. +fn parse_event_binding(cur: &mut Cursor) -> EventBinding { + let mut s = DslStream::new(cur); + let binding = if s.eat_ident("emit") { + match s.expect_ident("as the emitted event name") { + Some((name, _)) => { + let args = if *s.peek() == T::LParen { + parse_args(&mut s) + } else { + Vec::new() + }; + EventBinding::Emit { name, args } + } + None => EventBinding::Forward, + } + } else { + let span = s.peek_span(); + s.cur.error( + codes::UNEXPECTED_TOKEN, + "event bindings start with `emit` (`on:press={emit like-toggled(…)}`)", + span, + ); + EventBinding::Forward + }; + s.expect(&T::RBrace, "to close the event binding"); + s.finish(); + binding +} + +/// `{#…` blocks. The `{` and `#` are unconsumed on entry. +fn parse_block(cur: &mut Cursor) -> Option<Node> { + let start = cur.pos(); + cur.bump(); // `{` + cur.bump(); // `#` + let Some(kw) = markup_ident(cur) else { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected `if`, `each`, or `match` after `{#`", + cur.span_from(start), + ); + return Some(Node::Error { + span: cur.span_from(start), + }); + }; + match kw.as_str() { + "if" => parse_if_block(cur, start), + "each" => parse_each_block(cur, start), + "match" => parse_match_block(cur, start), + other => { + cur.error( + codes::UNEXPECTED_TOKEN, + format!("unknown block `{{#{other}}}` — blocks are if | each | match"), + cur.span_from(start), + ); + skip_past_brace(cur); + Some(Node::Error { + span: cur.span_from(start), + }) + } + } +} + +fn skip_past_brace(cur: &mut Cursor) { + while let Some(c) = cur.bump() { + if c == '}' { + break; + } + } +} + +/// Consumes a `{:marker}` or `{/marker}` head and returns the marker word. +fn consume_marker(cur: &mut Cursor) -> Option<String> { + cur.bump(); // `{` + cur.bump(); // `:` or `/` + // Anything up to `}` beyond the word is handled by specific callers + // (e.g. `{:when variant binding}`); default: expect `}` right away. + markup_ident(cur) +} + +fn parse_if_block(cur: &mut Cursor, start: u32) -> Option<Node> { + let cond = { + let mut s = DslStream::new(cur); + let e = parse_expr(&mut s); + s.expect(&T::RBrace, "to close the `{#if …}` head"); + s.finish(); + e + }; + let (then, stop) = parse_nodes(cur); + let mut els = None; + match stop { + Stop::ArmMarker => { + let m_start = cur.pos(); + let word = consume_marker(cur); + if word.as_deref() == Some("else") { + if !cur.eat('}') { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected `}` after `{:else`", + cur.span_from(m_start), + ); + skip_past_brace(cur); + } + let (e, stop2) = parse_nodes(cur); + els = Some(e); + expect_block_close(cur, stop2, "if"); + } else { + cur.error( + codes::UNEXPECTED_TOKEN, + "only `{:else}` is valid inside `{#if}`", + cur.span_from(m_start), + ); + skip_past_brace(cur); + let (_, stop2) = parse_nodes(cur); + expect_block_close(cur, stop2, "if"); + } + } + other => expect_block_close(cur, other, "if"), + } + Some(Node::If { + cond, + then, + els, + span: cur.span_from(start), + }) +} + +fn expect_block_close(cur: &mut Cursor, stop: Stop, kw: &str) { + match stop { + Stop::BlockClose => { + let start = cur.pos(); + let word = consume_marker(cur); + if word.as_deref() != Some(kw) { + cur.error( + codes::UNCLOSED_BLOCK, + format!( + "expected `{{/{kw}}}`, found `{{/{}}}`", + word.unwrap_or_default() + ), + cur.span_from(start), + ); + } + if !cur.eat('}') { + skip_past_brace(cur); + } + } + _ => { + let span = cur.span_from(cur.pos()); + cur.error( + codes::UNCLOSED_BLOCK, + format!("`{{#{kw}}}` is never closed"), + span, + ); + } + } +} + +fn parse_each_block(cur: &mut Cursor, start: u32) -> Option<Node> { + let mut s = DslStream::new(cur); + let seq = parse_expr(&mut s); + s.expect(&T::Ident("as".into()), "in `{#each list as item (key)}`"); + let item = s + .expect_ident("as the item binding") + .map(|(n, _)| n) + .unwrap_or_else(|| "item".to_string()); + // The parenthesized key is grammar, not lint (design §4.4). + let key = if *s.peek() == T::LParen { + s.bump(); + let k = parse_expr(&mut s); + s.expect(&T::RParen, "to close the key"); + k + } else { + let span = s.peek_span(); + s.cur.error( + codes::UNKEYED_EACH, + "`{#each}` requires a key: `{#each list as item (item.id)}`", + span, + ); + Expr { + kind: ExprKind::Error, + span, + } + }; + s.expect(&T::RBrace, "to close the `{#each …}` head"); + s.finish(); + + let (body, stop) = parse_nodes(cur); + expect_block_close(cur, stop, "each"); + Some(Node::Each { + item, + seq, + key, + body, + span: cur.span_from(start), + }) +} + +fn parse_match_block(cur: &mut Cursor, start: u32) -> Option<Node> { + let scrutinee = { + let mut s = DslStream::new(cur); + let e = parse_expr(&mut s); + s.expect(&T::RBrace, "to close the `{#match …}` head"); + s.finish(); + e + }; + + let mut arms = Vec::new(); + // Nodes directly inside `{#match}` before the first arm are ignored + // whitespace or an error. + let (stray, mut stop) = parse_nodes(cur); + if !stray.is_empty() { + let span = cur.span_from(start); + cur.error( + codes::UNEXPECTED_TOKEN, + "content inside `{#match}` must live under a `{:when …}` arm", + span, + ); + } + loop { + match stop { + Stop::ArmMarker => { + let a_start = cur.pos(); + let word = consume_marker(cur); + match word.as_deref() { + Some("when") => { + skip_markup_ws(cur); + let Some(variant) = markup_ident(cur) else { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected a variant name after `{:when`", + cur.span_from(a_start), + ); + skip_past_brace(cur); + let (_, s2) = parse_nodes(cur); + stop = s2; + continue; + }; + skip_markup_ws(cur); + let binding = markup_ident(cur); + skip_markup_ws(cur); + if !cur.eat('}') { + cur.error( + codes::UNEXPECTED_TOKEN, + "expected `}` to close the `{:when …}` arm head", + cur.span_from(a_start), + ); + skip_past_brace(cur); + } + let (body, s2) = parse_nodes(cur); + arms.push(MatchArm { + pattern: MatchPattern::Variant(variant), + binding, + body, + span: cur.span_from(a_start), + }); + stop = s2; + } + Some("else") => { + if !cur.eat('}') { + skip_past_brace(cur); + } + let (body, s2) = parse_nodes(cur); + arms.push(MatchArm { + pattern: MatchPattern::Else, + binding: None, + body, + span: cur.span_from(a_start), + }); + stop = s2; + } + other => { + cur.error( + codes::UNEXPECTED_TOKEN, + format!( + "unknown arm `{{:{}}}` — arms are `{{:when …}}` or `{{:else}}`", + other.unwrap_or_default() + ), + cur.span_from(a_start), + ); + skip_past_brace(cur); + let (_, s2) = parse_nodes(cur); + stop = s2; + } + } + } + other => { + expect_block_close(cur, other, "match"); + break; + } + } + } + Some(Node::Match { + scrutinee, + arms, + span: cur.span_from(start), + }) +} diff --git a/uhura/crates/uhura-syntax/src/parser/mod.rs b/uhura/crates/uhura-syntax/src/parser/mod.rs new file mode 100644 index 0000000..5af5d45 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/mod.rs @@ -0,0 +1,336 @@ +//! The file-level parse driver. A `.uhura` file has strictly ordered +//! sections — header → optional `store {}` → markup → optional `<style>` — +//! so the driver owns all mode transitions (design §4, plan risk #1). + +mod dsl; +mod examples; +mod expr; +mod markup; +mod stream; + +use uhura_base::{Diagnostic, FileId, Span, codes}; + +use crate::ast::*; +use crate::css; +use crate::cursor::Cursor; +use crate::token::TokenKind as T; + +use stream::DslStream; + +/// Which grammar a source file uses (decided by filename, `.examples.uhura` +/// vs `.uhura` — the caller knows). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SourceKind { + Module, + Examples, +} + +#[derive(Debug)] +pub enum Parsed { + Module(Box<File>), + Examples(ExamplesFile), +} + +pub struct ParseOutput { + pub parsed: Parsed, + pub diagnostics: Vec<Diagnostic>, +} + +/// Checker-enforced bounds (design §4). +const MAX_FILE_BYTES: usize = 256 * 1024; +const MAX_NESTING: usize = 32; +const MAX_VIEW_NODES: usize = 512; +const MAX_HANDLERS: usize = 128; + +pub fn parse(file: FileId, text: &str, kind: SourceKind) -> ParseOutput { + let mut cur = Cursor::new(file, text); + + if text.len() > MAX_FILE_BYTES { + cur.error( + codes::FILE_TOO_LARGE, + format!( + "file is {} bytes; the bound is {MAX_FILE_BYTES}", + text.len() + ), + Span::new(file, 0, 0), + ); + } + + let parsed = match kind { + SourceKind::Examples => { + let mut s = DslStream::new(&mut cur); + let ex = examples::parse_examples(&mut s); + s.finish(); + Parsed::Examples(ex) + } + SourceKind::Module => Parsed::Module(Box::new(parse_module(&mut cur))), + }; + + if let Parsed::Module(f) = &parsed { + enforce_bounds(&mut cur, f); + } + + ParseOutput { + parsed, + diagnostics: cur.diagnostics, + } +} + +fn parse_module(cur: &mut Cursor) -> File { + // ── header ────────────────────────────────────────────────────────── + let mut s = DslStream::new(cur); + let kind = parse_def_kind(&mut s); + + let mut uses = Vec::new(); + let mut props = Vec::new(); + let mut emits = Vec::new(); + let mut params = Vec::new(); + let mut store = None; + + loop { + match s.peek().clone() { + T::Ident(k) if k == "use" => { + if let Some(u) = dsl::parse_use(&mut s) { + uses.push(u); + } + } + T::Ident(k) if k == "props" => { + s.bump(); + props = dsl::parse_props_block(&mut s); + } + T::Ident(k) if k == "emits" => { + s.bump(); + emits = dsl::parse_emits_block(&mut s); + } + T::Ident(k) if k == "param" => { + if let Some(p) = dsl::parse_param(&mut s) { + params.push(p); + } + } + T::Ident(k) if k == "store" => { + store = Some(dsl::parse_store(&mut s)); + } + T::Lt | T::Eof => break, + other => { + let desc = other.describe(); + let span = s.peek_span(); + s.cur.error( + codes::MISPLACED_SECTION, + format!( + "expected a header declaration (use | props | emits | param | store) \ + or markup, found {desc}" + ), + span, + ); + s.bump(); + } + } + } + s.finish(); + + // ── markup ────────────────────────────────────────────────────────── + let (markup, stop) = markup::parse_nodes(cur); + + // ── style ─────────────────────────────────────────────────────────── + let mut style = None; + if stop == markup::Stop::Style { + style = parse_style_section(cur); + // Anything after </style> other than whitespace is misplaced. + let tail_start = cur.pos(); + let tail = cur.rest().trim(); + if !tail.is_empty() { + cur.error( + codes::MISPLACED_SECTION, + "content after `</style>` — the style block ends the file", + Span::new(cur.file, tail_start, tail_start + 1), + ); + } + } + + File { + kind, + uses, + props, + emits, + params, + store, + markup, + style, + } +} + +fn parse_def_kind(s: &mut DslStream) -> DefKind { + let start = s.peek_span(); + let T::Ident(kw) = s.peek().clone() else { + let span = s.peek_span(); + s.cur.error( + codes::MISPLACED_SECTION, + "a .uhura file starts with `component <name>`, `page`, or `surface <name>`", + span, + ); + return DefKind::Error { span: start }; + }; + match kw.as_str() { + "component" => { + s.bump(); + match s.expect_ident("as the component name") { + Some((name, nspan)) => DefKind::Component { + name, + span: start.to(nspan), + }, + None => DefKind::Error { span: start }, + } + } + "page" => { + s.bump(); + DefKind::Page { span: start } + } + "surface" => { + s.bump(); + let Some((name, mut end)) = s.expect_ident("as the surface name") else { + return DefKind::Error { span: start }; + }; + let modality = if s.eat_ident("modality") { + match s.expect_ident("as the modality (`sheet`)") { + Some((m, mspan)) => { + end = mspan; + Some(m) + } + None => None, + } + } else { + None + }; + DefKind::Surface { + name, + modality, + span: start.to(end), + } + } + other => { + s.cur.error( + codes::MISPLACED_SECTION, + format!( + "`{other}` is not a definition kind — a .uhura file starts with \ + `component <name>`, `page`, or `surface <name>`" + ), + start, + ); + DefKind::Error { span: start } + } + } +} + +fn parse_style_section(cur: &mut Cursor) -> Option<StyleBlock> { + let start = cur.pos(); + if !cur.eat_str("<style") { + return None; + } + // Allow only optional whitespace before `>`. + while matches!(cur.peek(), Some(c) if c.is_whitespace()) { + cur.bump(); + } + if !cur.eat('>') { + cur.error( + codes::INVALID_STYLE_BLOCK, + "`<style>` takes no attributes", + cur.span_from(start), + ); + while let Some(c) = cur.bump() { + if c == '>' { + break; + } + } + } + let inner_start = cur.pos(); + let rest = cur.rest(); + let (inner_len, closed) = match rest.find("</style>") { + Some(i) => (i, true), + None => (rest.len(), false), + }; + let raw = rest[..inner_len].to_string(); + cur.set_pos(inner_start + inner_len as u32); + if closed { + cur.eat_str("</style>"); + } else { + cur.error( + codes::INVALID_STYLE_BLOCK, + "`<style>` is never closed", + cur.span_from(start), + ); + } + let rules = css::parse_stylesheet(cur.file, inner_start, &raw); + Some(StyleBlock { + rules, + raw, + span: cur.span_from(start), + }) +} + +// ── bounds (design §4) ────────────────────────────────────────────────────── + +fn enforce_bounds(cur: &mut Cursor, f: &File) { + if let Some(store) = &f.store + && store.handlers.len() > MAX_HANDLERS + { + cur.error( + codes::TOO_MANY_HANDLERS, + format!( + "{} handlers; the bound is {MAX_HANDLERS}", + store.handlers.len() + ), + store.span, + ); + } + let mut count = 0usize; + let mut too_deep: Option<Span> = None; + for n in &f.markup { + walk(n, 1, &mut count, &mut too_deep); + } + if count > MAX_VIEW_NODES { + let span = f + .markup + .first() + .map_or(Span::new(cur.file, 0, 0), node_span); + cur.error( + codes::TOO_MANY_NODES, + format!("{count} view nodes; the bound is {MAX_VIEW_NODES}"), + span, + ); + } + if let Some(span) = too_deep { + cur.error( + codes::NESTING_TOO_DEEP, + format!("markup nesting exceeds {MAX_NESTING}"), + span, + ); + } +} + +fn node_span(n: &Node) -> Span { + match n { + Node::Element(e) => e.span, + Node::Text { span, .. } + | Node::If { span, .. } + | Node::Each { span, .. } + | Node::Match { span, .. } + | Node::Error { span } => *span, + } +} + +fn walk(n: &Node, depth: usize, count: &mut usize, too_deep: &mut Option<Span>) { + *count += 1; + if depth > MAX_NESTING && too_deep.is_none() { + *too_deep = Some(node_span(n)); + } + let kids: Vec<&Node> = match n { + Node::Element(e) => e.children.iter().collect(), + Node::If { then, els, .. } => then.iter().chain(els.iter().flatten()).collect(), + Node::Each { body, .. } => body.iter().collect(), + Node::Match { arms, .. } => arms.iter().flat_map(|a| a.body.iter()).collect(), + _ => Vec::new(), + }; + for k in kids { + walk(k, depth + 1, count, too_deep); + } +} diff --git a/uhura/crates/uhura-syntax/src/parser/stream.rs b/uhura/crates/uhura-syntax/src/parser/stream.rs new file mode 100644 index 0000000..15cd522 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/parser/stream.rs @@ -0,0 +1,131 @@ +//! A DSL token stream over the shared cursor, with bounded lookahead and +//! position resync — so a parser can leave DSL mode (say, at the `}` closing +//! an interpolation) without ever having lexed markup text as DSL tokens. + +use uhura_base::{Span, codes}; + +use crate::cursor::Cursor; +use crate::token::{Comment, Token, TokenKind}; + +pub struct DslStream<'a, 'src> { + pub cur: &'a mut Cursor<'src>, + buf: Vec<Token>, + /// End offset of the last consumed token (resync point). + last_end: u32, +} + +impl<'a, 'src> DslStream<'a, 'src> { + pub fn new(cur: &'a mut Cursor<'src>) -> Self { + let last_end = cur.pos(); + DslStream { + cur, + buf: Vec::new(), + last_end, + } + } + + fn fill(&mut self, n: usize) { + while self.buf.len() < n { + let t = self.cur.dsl_token(); + self.buf.push(t); + } + } + + pub fn peek(&mut self) -> &TokenKind { + self.fill(1); + &self.buf[0].kind + } + + /// Two-token lookahead (kept for later parser phases). + #[allow(dead_code)] + pub fn peek2(&mut self) -> &TokenKind { + self.fill(2); + &self.buf[1].kind + } + + pub fn peek_token(&mut self) -> &Token { + self.fill(1); + &self.buf[0] + } + + pub fn bump(&mut self) -> Token { + self.fill(1); + let t = self.buf.remove(0); + self.last_end = t.span.end; + t + } + + /// Consumes the next token if it is exactly `kind`. + pub fn eat(&mut self, kind: &TokenKind) -> bool { + if self.peek() == kind { + self.bump(); + true + } else { + false + } + } + + /// Consumes the next token if it is the ident `text`. + pub fn eat_ident(&mut self, text: &str) -> bool { + if self.peek().is_ident(text) { + self.bump(); + true + } else { + false + } + } + + /// Expects `kind`; on mismatch diagnoses and returns `None` without + /// consuming (the caller decides how to recover). + pub fn expect(&mut self, kind: &TokenKind, context: &str) -> Option<Token> { + if self.peek() == kind { + Some(self.bump()) + } else { + let found = self.peek_token(); + let (span, desc) = (found.span, found.kind.describe()); + self.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected {} {context}, found {desc}", kind.describe()), + span, + ); + None + } + } + + /// Expects any identifier; returns its text. + pub fn expect_ident(&mut self, context: &str) -> Option<(String, Span)> { + if let TokenKind::Ident(_) = self.peek() { + let t = self.bump(); + let TokenKind::Ident(s) = t.kind else { + unreachable!() + }; + Some((s, t.span)) + } else { + let found = self.peek_token(); + let (span, desc) = (found.span, found.kind.describe()); + self.cur.error( + codes::UNEXPECTED_TOKEN, + format!("expected an identifier {context}, found {desc}"), + span, + ); + None + } + } + + pub fn peek_span(&mut self) -> Span { + self.peek_token().span + } + + /// Leading comments of the next token (drains them). + pub fn take_leading(&mut self) -> Vec<Comment> { + self.fill(1); + std::mem::take(&mut self.buf[0].leading) + } + + /// Ends DSL mode: rewinds the cursor to just after the last consumed + /// token so unconsumed lookahead (and its trivia) re-lexes in whatever + /// mode the caller enters next. + pub fn finish(self) { + self.cur.set_pos(self.last_end); + } +} diff --git a/uhura/crates/uhura-syntax/src/token.rs b/uhura/crates/uhura-syntax/src/token.rs new file mode 100644 index 0000000..775b130 --- /dev/null +++ b/uhura/crates/uhura-syntax/src/token.rs @@ -0,0 +1,105 @@ +//! DSL token model. Markup and style surfaces are parsed char-wise by their +//! own parsers (design §4.4–§4.5); these tokens cover the header, the store +//! block, and every `{expr}` region. + +use uhura_base::Span; + +/// A `//` line comment. Comments are trivia: collected by the lexer and +/// attached by parsers to the *following* AST item (formatter contract). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Comment { + pub span: Span, + /// Text after `//`, untrimmed. + pub text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TokenKind { + /// Kebab-case identifier or contextual keyword (`store`, `on`, `when`, + /// `set`, `send`, `if`, `true`, …) — keywords are resolved by parsers, + /// never by the lexer. + Ident(String), + /// Integer literal. Overflow is diagnosed at lex time and saturates. + Int(i64), + /// String literal (unescaped content). + Str(String), + + LBrace, + RBrace, + LParen, + RParen, + LBracket, + RBracket, + Comma, + Colon, + Question, + Dot, + Eq, + EqEq, + NotEq, + Lt, + Le, + Gt, + Ge, + Plus, + PlusPlus, + Minus, + Bang, + AndAnd, + OrOr, + /// `??` + Coalesce, + + Eof, + /// Lex error already diagnosed; parsers skip it. + Error, +} + +impl TokenKind { + /// Human name for diagnostics ("expected `}`, found …"). + pub fn describe(&self) -> String { + match self { + TokenKind::Ident(s) => format!("`{s}`"), + TokenKind::Int(i) => format!("`{i}`"), + TokenKind::Str(_) => "string literal".to_string(), + TokenKind::LBrace => "`{`".into(), + TokenKind::RBrace => "`}`".into(), + TokenKind::LParen => "`(`".into(), + TokenKind::RParen => "`)`".into(), + TokenKind::LBracket => "`[`".into(), + TokenKind::RBracket => "`]`".into(), + TokenKind::Comma => "`,`".into(), + TokenKind::Colon => "`:`".into(), + TokenKind::Question => "`?`".into(), + TokenKind::Dot => "`.`".into(), + TokenKind::Eq => "`=`".into(), + TokenKind::EqEq => "`==`".into(), + TokenKind::NotEq => "`!=`".into(), + TokenKind::Lt => "`<`".into(), + TokenKind::Le => "`<=`".into(), + TokenKind::Gt => "`>`".into(), + TokenKind::Ge => "`>=`".into(), + TokenKind::Plus => "`+`".into(), + TokenKind::PlusPlus => "`++`".into(), + TokenKind::Minus => "`-`".into(), + TokenKind::Bang => "`!`".into(), + TokenKind::AndAnd => "`&&`".into(), + TokenKind::OrOr => "`||`".into(), + TokenKind::Coalesce => "`??`".into(), + TokenKind::Eof => "end of file".into(), + TokenKind::Error => "invalid token".into(), + } + } + + pub fn is_ident(&self, text: &str) -> bool { + matches!(self, TokenKind::Ident(s) if s == text) + } +} + +#[derive(Clone, Debug)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, + /// Comments collected immediately before this token. + pub leading: Vec<Comment>, +} diff --git a/uhura/crates/uhura-syntax/tests/common/normative_sources.rs b/uhura/crates/uhura-syntax/tests/common/normative_sources.rs new file mode 100644 index 0000000..415e66e --- /dev/null +++ b/uhura/crates/uhura-syntax/tests/common/normative_sources.rs @@ -0,0 +1,182 @@ +// Shared normative sources (design §4.6/§4.7/§6.1), include!()d by the +// parse and formatter test files. + +const POST_CARD: &str = r#"component post-card + +use port feed { type post-summary } + +props { + post: post-summary + liked: bool + like-pending: bool +} + +emits { + like-toggled(post: id, now-liked: bool) + comments-requested(post: id) + author-tapped(user: id) +} + +<view class="post-card"> + <region label="View profile" + on:activate={emit author-tapped(user: post.author.id)}> + <view class="author-row"> + <image class="avatar" src={post.author.avatar.src} alt={post.author.avatar.alt} /> + <text class="username">{post.author.username}</text> + </view> + </region> + + {#match post.media} + {:when image m} + <region label="Like this post" supplementary + on:activate-double={emit like-toggled(post: post.id, now-liked: true)}> + <image class="media" src={m.image.src} alt={m.image.alt} /> + </region> + {:when carousel c} + <pager class="media" indicator="dots" label="Photo carousel"> + {#each c.slides as s (s.id)} + <image src={s.src} alt={s.alt} /> + {/each} + </pager> + {:when video v} + <view class="media video-fallback"> + <image src={v.poster.src} alt={v.poster.alt} /> + <view class="video-fallback-badge"> + <icon name="video-off" /> + <text>Video isn't supported in this preview</text> + </view> + </view> + {/match} + + <view class="action-row"> + <button pressed={liked} busy={like-pending} + label={if liked then "Unlike" else "Like"} + on:press={emit like-toggled(post: post.id, now-liked: !liked)}> + <icon name={if liked then "heart-filled" else "heart"} /> + </button> + <button label="Comments" + on:press={emit comments-requested(post: post.id)}> + <icon name="comment" /> + </button> + </view> + + <text class="likes">{to-text(post.like-count + + (if liked && !post.viewer-has-liked then 1 + else if !liked && post.viewer-has-liked then 0 - 1 + else 0)) ++ " likes"}</text> + <text class="caption">{post.caption}</text> +</view> + +<style> + .post-card { display: flex; flex-direction: column; gap: var(--space-2); } + .post-card .avatar { inline-size: 32px; border-radius: var(--radius-full); } +</style> +"#; + +const FEED_STORE: &str = r#"page + +use component post-card +use surface comments-sheet +use port feed { + projection feed-page, projection viewer, + command like-post, command unlike-post, + command load-next-page, command reload +} + +store { + state { + like-overlay: map[id]bool = {} + like-pending: map[id]bool = {} + load-pending: bool = false + notice: text? = none + } + + // like / unlike: guard-ordered multi-handler dispatch + on like-toggled(post: id, now-liked: bool) + when now-liked && !(like-pending[post] ?? false) { + set like-overlay[post] = true + set like-pending[post] = true + send like-post(post: post) + } + on like-post.ok(tag, cmd) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + } + on like-post.err(tag, cmd, refusal) { + set like-pending[cmd.post] = none + set notice = "Couldn't like this post. Try again." + } + on feed-near-end() + when !load-pending && feed-page.has-more && feed-page.cursor != none { + set load-pending = true + send load-next-page(cursor: feed-page.cursor) + } + on comments-requested(post: id) { open-surface comments-sheet(post: post) } + on author-tapped(user: id) { navigate profile(user: user) } + on submit-requested() when draft != "" { + send add-comment(post: post, body: draft) as t + set pending-appends[t] = draft + set draft = "" + } + on dismiss-requested() { dismiss } + on back-tapped() { navigate back } +} + +<view class="screen feed-page"> + {#if notice != none} + <notice-bar text={notice ?? ""} on:dismissed={emit notice-dismissed()} /> + {/if} + {#match feed-page} + {:when loading} + <view class="fill-center"><text class="muted">Loading your feed…</text></view> + {:when ready f} + <scroll class="feed-scroll" on:near-end={emit feed-near-end()}> + <view role="list" class="post-list"> + {#each f.posts as p (p.id)} + <post-card post={p} + liked={like-overlay[p.id] ?? p.viewer-has-liked} + like-pending={like-pending[p.id] ?? false} + on:like-toggled on:comments-requested on:author-tapped /> + {/each} + </view> + {#if !f.has-more} + <text class="muted">You're all caught up.</text> + {/if} + </scroll> + {/match} + <bottom-nav current="feed" on:tab-selected /> +</view> +"#; + +const FEED_EXAMPLES: &str = r#"use fixture standard + +example loading { + note "cold start — nothing delivered yet" +} + +example first-page default { + projection feed.viewer = fixture.users.mira + projection feed.feed-page = fixture.feed.page-1 +} + +example like-pending { + from first-page + events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] + note "optimistic heart + count while like-post is in flight" +} + +example comments-open { + from first-page + projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze + events [ comments-requested(post: "post-lena-glaze") ] +} + +example appended { + from first-page + events [ + feed-near-end() + projection feed.feed-page = fixture.feed.pages-1-2 + outcome load-next-page.ok() + ] +} +"#; diff --git a/uhura/crates/uhura-syntax/tests/fmt_roundtrip.rs b/uhura/crates/uhura-syntax/tests/fmt_roundtrip.rs new file mode 100644 index 0000000..1aaf976 --- /dev/null +++ b/uhura/crates/uhura-syntax/tests/fmt_roundtrip.rs @@ -0,0 +1,54 @@ +//! Formatter contract (design §12.5): formatting is idempotent, and the +//! formatted corpus reparses without diagnostics. Fixpoint stability implies +//! reparse-equality for everything the formatter renders. + +use uhura_base::FileId; +use uhura_syntax::{Parsed, SourceKind, format_examples, format_module, parse}; + +fn fmt(src: &str, kind: SourceKind) -> String { + let out = parse(FileId(0), src, kind); + assert!( + out.diagnostics.is_empty(), + "input must parse clean: {:?}", + out.diagnostics + ); + match out.parsed { + Parsed::Module(f) => format_module(&f), + Parsed::Examples(e) => format_examples(&e), + } +} + +fn assert_fixpoint(src: &str, kind: SourceKind) { + let once = fmt(src, kind); + let twice = fmt(&once, kind); + assert_eq!(once, twice, "formatter is not idempotent"); +} + +// Reuse the normative sources by including the sibling test file's constants +// via a tiny duplication-free include. +include!("common/normative_sources.rs"); + +#[test] +fn post_card_roundtrips() { + assert_fixpoint(POST_CARD, SourceKind::Module); +} + +#[test] +fn feed_page_roundtrips() { + assert_fixpoint(FEED_STORE, SourceKind::Module); +} + +#[test] +fn examples_roundtrip() { + assert_fixpoint(FEED_EXAMPLES, SourceKind::Examples); +} + +#[test] +fn comment_attachment_survives() { + let src = "page\n\nstore {\n state {\n // the overlay\n x: bool = false\n }\n\n // fires on tap\n on tapped() {\n // write it\n set x = true\n }\n}\n\n<view />\n"; + let once = fmt(src, SourceKind::Module); + assert!(once.contains("// the overlay"), "{once}"); + assert!(once.contains("// fires on tap"), "{once}"); + assert!(once.contains("// write it"), "{once}"); + assert_fixpoint(src, SourceKind::Module); +} diff --git a/uhura/crates/uhura-syntax/tests/parse_normative.rs b/uhura/crates/uhura-syntax/tests/parse_normative.rs new file mode 100644 index 0000000..029bebe --- /dev/null +++ b/uhura/crates/uhura-syntax/tests/parse_normative.rs @@ -0,0 +1,209 @@ +//! The design doc's normative sources (§4.6, §4.7, §6.1) must parse with +//! zero diagnostics — the grammar is validated against the doc's own text +//! (plan risk #1 mitigation). + +use uhura_base::FileId; +use uhura_syntax::ast::*; +use uhura_syntax::{Parsed, SourceKind, parse}; + +include!("common/normative_sources.rs"); + +fn assert_clean(diags: &[uhura_base::Diagnostic]) { + assert!( + diags.is_empty(), + "expected zero diagnostics, got:\n{}", + diags + .iter() + .map(|d| format!( + " [{}] {} @{}..{}", + d.code, d.message, d.span.start, d.span.end + )) + .collect::<Vec<_>>() + .join("\n") + ); +} + +#[test] +fn post_card_parses_clean() { + let out = parse(FileId(0), POST_CARD, SourceKind::Module); + assert_clean(&out.diagnostics); + let Parsed::Module(f) = out.parsed else { + panic!() + }; + + let DefKind::Component { name, .. } = &f.kind else { + panic!("expected component") + }; + assert_eq!(name, "post-card"); + assert_eq!(f.props.len(), 3); + assert_eq!(f.emits.len(), 3); + assert_eq!(f.uses.len(), 1); + assert!(f.store.is_none()); + assert_eq!(f.markup.len(), 1, "component has exactly one root"); + + let Node::Element(root) = &f.markup[0] else { + panic!() + }; + assert_eq!(root.name, "view"); + // match block with three arms sits among the children + let match_node = root + .children + .iter() + .find_map(|n| match n { + Node::Match { arms, .. } => Some(arms), + _ => None, + }) + .expect("media match"); + assert_eq!(match_node.len(), 3); + assert!(matches!(&match_node[0].pattern, MatchPattern::Variant(v) if v == "image")); + assert_eq!(match_node[1].binding.as_deref(), Some("c")); + + let style = f.style.expect("style block"); + assert_eq!(style.rules.len(), 2); + assert_eq!(style.rules[0].classes, vec!["post-card"]); +} + +#[test] +fn feed_page_parses_clean() { + let out = parse(FileId(0), FEED_STORE, SourceKind::Module); + assert_clean(&out.diagnostics); + let Parsed::Module(f) = out.parsed else { + panic!() + }; + + assert!(matches!(f.kind, DefKind::Page { .. })); + let store = f.store.expect("store"); + assert_eq!(store.state.len(), 4); + assert_eq!(store.handlers.len(), 9); + + // Multi-handler + guard + outcome signatures. + let h0 = &store.handlers[0]; + assert!(matches!(&h0.event, EventRef::Semantic { name, .. } if name == "like-toggled")); + assert!(h0.guard.is_some()); + assert_eq!(h0.body.len(), 3); + let h1 = &store.handlers[1]; + assert!(matches!( + &h1.event, + EventRef::Outcome { command, which: OutcomeKind::Ok, .. } if command == "like-post" + )); + // Outcome params are name-only. + assert!(h1.params.iter().all(|p| p.ty.is_none())); + + // `send … as t` binding. + let submit = &store.handlers[6]; + assert!(matches!( + &submit.body[0], + Stmt::Send { bind: Some(b), .. } if b == "t" + )); + // `navigate back`. + let back = &store.handlers[8]; + assert!(matches!( + &back.body[0], + Stmt::Navigate { + target: NavTarget::Back, + .. + } + )); + + // Markup: forwarding event attrs on the component call. + let Node::Element(root) = &f.markup[0] else { + panic!() + }; + fn find_element<'a>(nodes: &'a [Node], name: &str) -> Option<&'a Element> { + for n in nodes { + let kids: &[Node] = match n { + Node::Element(e) => { + if e.name == name { + return Some(e); + } + &e.children + } + Node::If { then, .. } => then, + Node::Each { body, .. } => body, + Node::Match { arms, .. } => { + for a in arms { + if let Some(e) = find_element(&a.body, name) { + return Some(e); + } + } + &[] + } + _ => &[], + }; + if let Some(e) = find_element(kids, name) { + return Some(e); + } + } + None + } + let card = find_element(&root.children, "post-card").expect("post-card call"); + assert_eq!(card.events.len(), 3); + assert!( + card.events + .iter() + .all(|e| matches!(e.binding, EventBinding::Forward)) + ); + assert!(card.self_closing); +} + +#[test] +fn examples_file_parses_clean() { + let out = parse(FileId(0), FEED_EXAMPLES, SourceKind::Examples); + assert_clean(&out.diagnostics); + let Parsed::Examples(ex) = out.parsed else { + panic!() + }; + + assert_eq!(ex.examples.len(), 5); + assert!(ex.examples[1].is_default); + + // Keyed projection pin. + let comments_open = &ex.examples[3]; + assert!(comments_open.clauses.iter().any(|c| matches!( + c, + ExampleClause::Projection(p) if p.projection == "for-post" && p.key.is_some() + ))); + + // Timeline with all three entry kinds. + let appended = &ex.examples[4]; + let events = appended + .clauses + .iter() + .find_map(|c| match c { + ExampleClause::Events { entries, .. } => Some(entries), + _ => None, + }) + .expect("events clause"); + assert_eq!(events.len(), 3); + assert!(matches!(&events[0], ExampleEvent::Semantic { name, .. } if name == "feed-near-end")); + assert!(matches!(&events[1], ExampleEvent::Projection(_))); + assert!(matches!( + &events[2], + ExampleEvent::Outcome { + which: OutcomeKind::Ok, + .. + } + )); +} + +#[test] +fn planted_errors_diagnose() { + // Unkeyed each is a parse error (§4.4). + let src = "component x\n<view>{#each xs as x}<text>{x}</text>{/each}</view>\n"; + let out = parse(FileId(0), src, SourceKind::Module); + assert!( + out.diagnostics.iter().any(|d| d.code == "UH0003"), + "{:?}", + out.diagnostics + ); + + // Unknown statement keyword. + let src = "page\nstore { on x() { mutate y = 1 } }\n<view />\n"; + let out = parse(FileId(0), src, SourceKind::Module); + assert!(out.diagnostics.iter().any(|d| d.code == "UH0001")); + + // Mismatched close tag. + let src = "component x\n<view><text>hello</view>\n"; + let out = parse(FileId(0), src, SourceKind::Module); + assert!(out.diagnostics.iter().any(|d| d.code == "UH0004")); +} diff --git a/uhura/crates/uhura-tests/Cargo.toml b/uhura/crates/uhura-tests/Cargo.toml new file mode 100644 index 0000000..0a9f6ee --- /dev/null +++ b/uhura/crates/uhura-tests/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "uhura-tests" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +uhura-base = { workspace = true } +uhura-syntax = { workspace = true } +uhura-port = { workspace = true, features = ["toml"] } +uhura-check = { workspace = true } +uhura-core = { workspace = true } +uhura-fixture = { workspace = true } +uhura-project = { workspace = true } +uhura-cli = { workspace = true } +cargo_metadata = { workspace = true } +serde_json = { workspace = true } diff --git a/uhura/crates/uhura-tests/goldens/m2/instagram-ir.json b/uhura/crates/uhura-tests/goldens/m2/instagram-ir.json new file mode 100644 index 0000000..07fca07 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/instagram-ir.json @@ -0,0 +1 @@ +{"app":"instagram","catalog":{"hash":"120729b4cd31a07275d76ee2184ae989ed949c6544d1243cb29f42dfd65520d3","name":"base","version":"0.1.0"},"components":{"bottom-nav":{"emits":["tab-selected"],"events":{},"handlers":[],"modality":null,"params":[],"props":["current"],"root":{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":2,"props":[{"name":"name","value":{"text":"home"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"section","value":{"text":"feed"}}],"emit":"tab-selected","event":"press"}],"ord":1,"props":[{"name":"label","value":{"text":"Home"}},{"name":"current","value":{"binary":{"lhs":{"prop-ref":"current"},"op":"eq","rhs":{"text":"feed"}}}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":4,"props":[{"name":"name","value":{"text":"search"}}],"text":[]}}],"class":null,"element":"button","events":[],"ord":3,"props":[{"name":"label","value":{"text":"Search"}},{"name":"disabled","value":{"bool":true}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":6,"props":[{"name":"name","value":{"text":"plus"}}],"text":[]}}],"class":null,"element":"button","events":[],"ord":5,"props":[{"name":"label","value":{"text":"Create"}},{"name":"disabled","value":{"bool":true}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":8,"props":[{"name":"name","value":{"text":"reels"}}],"text":[]}}],"class":null,"element":"button","events":[],"ord":7,"props":[{"name":"label","value":{"text":"Reels"}},{"name":"disabled","value":{"bool":true}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":10,"props":[{"name":"name","value":{"text":"profile"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"section","value":{"text":"profile"}}],"emit":"tab-selected","event":"press"}],"ord":9,"props":[{"name":"label","value":{"text":"Profile"}},{"name":"current","value":{"binary":{"lhs":{"prop-ref":"current"},"op":"eq","rhs":{"text":"profile"}}}}],"text":[]}}],"class":{"text":"bottom-nav"},"element":"view","events":[],"ord":0,"props":[{"name":"role","value":{"text":"navigation"}}],"text":[]}},"state":{}},"comment-row":{"emits":[],"events":{},"handlers":[],"modality":null,"params":[],"props":["avatar","username","body","time-label","pending"],"root":{"element":{"children":[{"element":{"children":[],"class":{"text":"avatar"},"element":"image","events":[],"ord":1,"props":[{"name":"src","value":{"field":{"base":{"prop-ref":"avatar"},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"prop-ref":"avatar"},"name":"alt"}}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":{"text":"comment-head"},"element":"text","events":[],"ord":3,"props":[],"text":[{"interp":{"binary":{"lhs":{"binary":{"lhs":{"prop-ref":"username"},"op":"concat","rhs":{"text":" · "}}},"op":"concat","rhs":{"prop-ref":"time-label"}}}}]}},{"element":{"children":[],"class":{"text":"comment-text"},"element":"text","events":[],"ord":4,"props":[],"text":[{"interp":{"prop-ref":"body"}}]}}],"class":{"text":"comment-body"},"element":"view","events":[],"ord":2,"props":[],"text":[]}}],"class":{"if":{"cond":{"prop-ref":"pending"},"els":{"text":"comment-row"},"then":{"text":"comment-row pending"}}},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{}},"notice-bar":{"emits":["dismissed"],"events":{},"handlers":[],"modality":null,"params":[],"props":["text"],"root":{"element":{"children":[{"element":{"children":[],"class":{"text":"notice-text"},"element":"text","events":[],"ord":1,"props":[],"text":[{"interp":{"prop-ref":"text"}}]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":3,"props":[{"name":"name","value":{"text":"close"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[],"emit":"dismissed","event":"press"}],"ord":2,"props":[{"name":"label","value":{"text":"Dismiss"}}],"text":[]}}],"class":{"text":"notice-bar"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{}},"post-card":{"emits":["like-toggled","comments-requested","author-tapped"],"events":{},"handlers":[],"modality":null,"params":[],"props":["post","liked","like-pending"],"root":{"element":{"children":[{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":{"text":"avatar"},"element":"image","events":[],"ord":3,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"field":{"base":{"prop-ref":"post"},"name":"author"}},"name":"avatar"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"field":{"base":{"prop-ref":"post"},"name":"author"}},"name":"avatar"}},"name":"alt"}}}],"text":[]}},{"element":{"children":[],"class":{"text":"username"},"element":"text","events":[],"ord":4,"props":[],"text":[{"interp":{"field":{"base":{"field":{"base":{"prop-ref":"post"},"name":"author"}},"name":"username"}}}]}}],"class":{"text":"author-row"},"element":"view","events":[],"ord":2,"props":[],"text":[]}}],"class":null,"element":"region","events":[{"args":[{"name":"user","value":{"field":{"base":{"field":{"base":{"prop-ref":"post"},"name":"author"}},"name":"id"}}}],"emit":"author-tapped","event":"activate"}],"ord":1,"props":[{"name":"label","value":{"text":"View profile"}}],"text":[]}},{"match":{"arms":[{"binding":"m","body":[{"element":{"children":[{"element":{"children":[],"class":{"text":"media"},"element":"image","events":[],"ord":6,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"binding-ref":"m"},"name":"image"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"binding-ref":"m"},"name":"image"}},"name":"alt"}}}],"text":[]}}],"class":null,"element":"region","events":[{"args":[{"name":"post","value":{"field":{"base":{"prop-ref":"post"},"name":"id"}}},{"name":"now-liked","value":{"bool":true}}],"emit":"like-toggled","event":"activate-double"}],"ord":5,"props":[{"name":"label","value":{"text":"Like this post"}},{"name":"supplementary","value":{"bool":true}}],"text":[]}}],"variant":"image"},{"binding":"c","body":[{"element":{"children":[{"element":{"children":[{"each":{"body":[{"element":{"children":[],"class":{"text":"slide"},"element":"image","events":[],"ord":10,"props":[{"name":"src","value":{"field":{"base":{"binding-ref":"s"},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"binding-ref":"s"},"name":"alt"}}}],"text":[]}}],"item":"s","key":{"field":{"base":{"binding-ref":"s"},"name":"id"}},"ord":9,"over":"list","seq":{"field":{"base":{"binding-ref":"c"},"name":"slides"}}}}],"class":{"text":"media"},"element":"pager","events":[],"ord":8,"props":[{"name":"indicator","value":{"text":"dots"}},{"name":"label","value":{"text":"Photo carousel"}}],"text":[]}}],"class":null,"element":"region","events":[{"args":[{"name":"post","value":{"field":{"base":{"prop-ref":"post"},"name":"id"}}},{"name":"now-liked","value":{"bool":true}}],"emit":"like-toggled","event":"activate-double"}],"ord":7,"props":[{"name":"label","value":{"text":"Like this post"}},{"name":"supplementary","value":{"bool":true}}],"text":[]}}],"variant":"carousel"},{"binding":"v","body":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"image","events":[],"ord":12,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"binding-ref":"v"},"name":"poster"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"binding-ref":"v"},"name":"poster"}},"name":"alt"}}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":14,"props":[{"name":"name","value":{"text":"video-off"}}],"text":[]}},{"element":{"children":[],"class":null,"element":"text","events":[],"ord":15,"props":[],"text":[{"literal":"Video isn't supported in this preview"}]}}],"class":{"text":"video-fallback-badge"},"element":"view","events":[],"ord":13,"props":[],"text":[]}}],"class":{"text":"media video-fallback"},"element":"view","events":[],"ord":11,"props":[],"text":[]}}],"variant":"video"}],"source":{"union":{"value":{"field":{"base":{"prop-ref":"post"},"name":"media"}}}}}},{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":18,"props":[{"name":"name","value":{"if":{"cond":{"prop-ref":"liked"},"els":{"text":"heart"},"then":{"text":"heart-filled"}}}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"post","value":{"field":{"base":{"prop-ref":"post"},"name":"id"}}},{"name":"now-liked","value":{"unary":{"expr":{"prop-ref":"liked"},"op":"not"}}}],"emit":"like-toggled","event":"press"}],"ord":17,"props":[{"name":"pressed","value":{"prop-ref":"liked"}},{"name":"busy","value":{"prop-ref":"like-pending"}},{"name":"label","value":{"if":{"cond":{"prop-ref":"liked"},"els":{"text":"Like"},"then":{"text":"Unlike"}}}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":20,"props":[{"name":"name","value":{"text":"comment"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"post","value":{"field":{"base":{"prop-ref":"post"},"name":"id"}}}],"emit":"comments-requested","event":"press"}],"ord":19,"props":[{"name":"label","value":{"text":"Comments"}}],"text":[]}}],"class":{"text":"action-row"},"element":"view","events":[],"ord":16,"props":[],"text":[]}},{"element":{"children":[],"class":{"text":"likes"},"element":"text","events":[],"ord":21,"props":[],"text":[{"interp":{"binary":{"lhs":{"to-text":{"binary":{"lhs":{"field":{"base":{"prop-ref":"post"},"name":"like-count"}},"op":"add","rhs":{"if":{"cond":{"binary":{"lhs":{"prop-ref":"liked"},"op":"and","rhs":{"unary":{"expr":{"field":{"base":{"prop-ref":"post"},"name":"viewer-has-liked"}},"op":"not"}}}},"els":{"if":{"cond":{"binary":{"lhs":{"unary":{"expr":{"prop-ref":"liked"},"op":"not"}},"op":"and","rhs":{"field":{"base":{"prop-ref":"post"},"name":"viewer-has-liked"}}}},"els":{"int":0},"then":{"binary":{"lhs":{"int":0},"op":"sub","rhs":{"int":1}}}}},"then":{"int":1}}}}}},"op":"concat","rhs":{"text":" likes"}}}}]}},{"element":{"children":[],"class":{"text":"caption"},"element":"text","events":[],"ord":22,"props":[],"text":[{"interp":{"field":{"base":{"prop-ref":"post"},"name":"caption"}}}]}},{"element":{"children":[],"class":{"text":"meta"},"element":"text","events":[],"ord":23,"props":[],"text":[{"interp":{"binary":{"lhs":{"binary":{"lhs":{"to-text":{"field":{"base":{"prop-ref":"post"},"name":"comment-count"}}},"op":"concat","rhs":{"text":" comments · "}}},"op":"concat","rhs":{"field":{"base":{"prop-ref":"post"},"name":"posted-label"}}}}}]}}],"class":{"text":"post-card"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{}},"profile-header":{"emits":[],"events":{},"handlers":[],"modality":null,"params":[],"props":["user","bio","posts-label","followers-label","following-label"],"root":{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":{"text":"avatar"},"element":"image","events":[],"ord":2,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"prop-ref":"user"},"name":"avatar"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"prop-ref":"user"},"name":"avatar"}},"name":"alt"}}}],"text":[]}},{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":{"text":"stat-num"},"element":"text","events":[],"ord":5,"props":[],"text":[{"interp":{"prop-ref":"posts-label"}}]}},{"element":{"children":[],"class":{"text":"stat-name"},"element":"text","events":[],"ord":6,"props":[],"text":[{"literal":"Posts"}]}}],"class":{"text":"stat"},"element":"view","events":[],"ord":4,"props":[],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":{"text":"stat-num"},"element":"text","events":[],"ord":8,"props":[],"text":[{"interp":{"prop-ref":"followers-label"}}]}},{"element":{"children":[],"class":{"text":"stat-name"},"element":"text","events":[],"ord":9,"props":[],"text":[{"literal":"Followers"}]}}],"class":{"text":"stat"},"element":"view","events":[],"ord":7,"props":[],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":{"text":"stat-num"},"element":"text","events":[],"ord":11,"props":[],"text":[{"interp":{"prop-ref":"following-label"}}]}},{"element":{"children":[],"class":{"text":"stat-name"},"element":"text","events":[],"ord":12,"props":[],"text":[{"literal":"Following"}]}}],"class":{"text":"stat"},"element":"view","events":[],"ord":10,"props":[],"text":[]}}],"class":{"text":"stats"},"element":"view","events":[],"ord":3,"props":[],"text":[]}}],"class":{"text":"head-row"},"element":"view","events":[],"ord":1,"props":[],"text":[]}},{"element":{"children":[],"class":{"text":"display-name"},"element":"text","events":[],"ord":13,"props":[],"text":[{"interp":{"field":{"base":{"prop-ref":"user"},"name":"display-name"}}}]}},{"element":{"children":[],"class":{"text":"bio"},"element":"text","events":[],"ord":14,"props":[],"text":[{"interp":{"prop-ref":"bio"}}]}}],"class":{"text":"profile-header"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{}},"stories-tray":{"emits":[],"events":{},"handlers":[],"modality":null,"params":[],"props":["stories"],"root":{"element":{"children":[{"element":{"children":[{"each":{"body":[{"element":{"children":[{"element":{"children":[],"class":{"if":{"cond":{"field":{"base":{"binding-ref":"story"},"name":"has-unseen"}},"els":{"text":"ring seen"},"then":{"text":"ring unseen"}}},"element":"image","events":[],"ord":4,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"field":{"base":{"binding-ref":"story"},"name":"user"}},"name":"avatar"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"field":{"base":{"binding-ref":"story"},"name":"user"}},"name":"avatar"}},"name":"alt"}}}],"text":[]}},{"element":{"children":[],"class":{"text":"ring-name"},"element":"text","events":[],"ord":5,"props":[],"text":[{"interp":{"if":{"cond":{"field":{"base":{"binding-ref":"story"},"name":"is-self"}},"els":{"field":{"base":{"field":{"base":{"binding-ref":"story"},"name":"user"}},"name":"username"}},"then":{"text":"Your story"}}}}]}}],"class":{"text":"ring-item"},"element":"view","events":[],"ord":3,"props":[],"text":[]}}],"item":"story","key":{"field":{"base":{"binding-ref":"story"},"name":"id"}},"ord":2,"over":"list","seq":{"prop-ref":"stories"}}}],"class":{"text":"ring-row"},"element":"view","events":[],"ord":1,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}}],"class":{"text":"stories-tray"},"element":"scroll","events":[],"ord":0,"props":[{"name":"direction","value":{"text":"horizontal"}}],"text":[]}},"state":{}}},"element-events":{"button":{"press":{"carries":{},"kind":"input"}},"pager":{"page-change":{"carries":{},"kind":"observe"}},"region":{"activate":{"carries":{},"kind":"input"},"activate-double":{"carries":{},"kind":"input"}},"scroll":{"near-end":{"carries":{},"kind":"observe"}},"text-field":{"change":{"carries":{"value":"text"},"kind":"input"},"submit":{"carries":{},"kind":"input"}}},"element-props":{"button":{"busy":"bool","current":"bool","disabled":"bool","label":"plain","pressed":"bool"},"icon":{"name":"token"},"image":{"alt":"plain","decorative":"bool","src":"asset"},"pager":{"indicator":"token","label":"plain"},"region":{"label":"plain","supplementary":"bool"},"scroll":{"direction":"token"},"text-field":{"disabled":"bool","label":"plain","placeholder":"plain","value":"plain"},"view":{"role":"token"}},"entry":"feed","pages":{"feed":{"emits":[],"events":{"author-tapped":[{"name":"user","ty":"id"}],"comments-requested":[{"name":"post","ty":"id"}],"feed-near-end":[],"like-toggled":[{"name":"post","ty":"id"},{"name":"now-liked","ty":"bool"}],"notice-dismissed":[],"retry-load-tapped":[],"retry-reload-tapped":[],"tab-selected":[{"name":"section","ty":"text"}]},"handlers":[{"body":[{"set":{"field":"like-overlay","key":{"binding-ref":"post"},"value":{"bool":true}}},{"set":{"field":"like-pending","key":{"binding-ref":"post"},"value":{"bool":true}}},{"send":{"args":[{"name":"post","value":{"binding-ref":"post"}}],"bind":null,"command":"like-post","port":"feed"}}],"guard":{"binary":{"lhs":{"binding-ref":"now-liked"},"op":"and","rhs":{"unary":{"expr":{"binary":{"lhs":{"index":{"base":{"state-ref":"like-pending"},"key":{"binding-ref":"post"}}},"op":"coalesce","rhs":{"bool":false}}},"op":"not"}}}},"on":{"semantic":{"event":"like-toggled"}},"params":["post","now-liked"]},{"body":[{"set":{"field":"like-overlay","key":{"binding-ref":"post"},"value":{"bool":false}}},{"set":{"field":"like-pending","key":{"binding-ref":"post"},"value":{"bool":true}}},{"send":{"args":[{"name":"post","value":{"binding-ref":"post"}}],"bind":null,"command":"unlike-post","port":"feed"}}],"guard":{"binary":{"lhs":{"unary":{"expr":{"binding-ref":"now-liked"},"op":"not"}},"op":"and","rhs":{"unary":{"expr":{"binary":{"lhs":{"index":{"base":{"state-ref":"like-pending"},"key":{"binding-ref":"post"}}},"op":"coalesce","rhs":{"bool":false}}},"op":"not"}}}},"on":{"semantic":{"event":"like-toggled"}},"params":["post","now-liked"]},{"body":[{"set":{"field":"like-pending","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"like-overlay","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}}],"guard":null,"on":{"outcome":{"command":"like-post","which":"ok"}},"params":["tag","cmd"]},{"body":[{"set":{"field":"like-pending","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"like-overlay","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"notice","key":null,"value":{"text":"Couldn't like this post. Try again."}}}],"guard":null,"on":{"outcome":{"command":"like-post","which":"err"}},"params":["tag","cmd","refusal"]},{"body":[{"set":{"field":"like-pending","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"like-overlay","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}}],"guard":null,"on":{"outcome":{"command":"unlike-post","which":"ok"}},"params":["tag","cmd"]},{"body":[{"set":{"field":"like-pending","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"like-overlay","key":{"field":{"base":{"binding-ref":"cmd"},"name":"post"}},"value":"none"}},{"set":{"field":"notice","key":null,"value":{"text":"Couldn't unlike this post."}}}],"guard":null,"on":{"outcome":{"command":"unlike-post","which":"err"}},"params":["tag","cmd","refusal"]},{"body":[{"set":{"field":"load-pending","key":null,"value":{"bool":true}}},{"send":{"args":[{"name":"cursor","value":{"field":{"base":{"projection-ref":"feed-page"},"name":"cursor"}}}],"bind":null,"command":"load-next-page","port":"feed"}}],"guard":{"binary":{"lhs":{"binary":{"lhs":{"binary":{"lhs":{"unary":{"expr":{"state-ref":"load-pending"},"op":"not"}},"op":"and","rhs":{"unary":{"expr":{"state-ref":"load-failed"},"op":"not"}}}},"op":"and","rhs":{"field":{"base":{"projection-ref":"feed-page"},"name":"has-more"}}}},"op":"and","rhs":{"binary":{"lhs":{"field":{"base":{"projection-ref":"feed-page"},"name":"cursor"}},"op":"not-eq","rhs":"none"}}}},"on":{"semantic":{"event":"feed-near-end"}},"params":[]},{"body":[{"set":{"field":"load-pending","key":null,"value":{"bool":false}}}],"guard":null,"on":{"outcome":{"command":"load-next-page","which":"ok"}},"params":["tag","cmd"]},{"body":[{"set":{"field":"load-pending","key":null,"value":{"bool":false}}},{"set":{"field":"load-failed","key":null,"value":{"bool":true}}}],"guard":null,"on":{"outcome":{"command":"load-next-page","which":"err"}},"params":["tag","cmd","refusal"]},{"body":[{"set":{"field":"load-failed","key":null,"value":{"bool":false}}},{"set":{"field":"load-pending","key":null,"value":{"bool":true}}},{"send":{"args":[{"name":"cursor","value":{"field":{"base":{"projection-ref":"feed-page"},"name":"cursor"}}}],"bind":null,"command":"load-next-page","port":"feed"}}],"guard":{"binary":{"lhs":{"state-ref":"load-failed"},"op":"and","rhs":{"binary":{"lhs":{"field":{"base":{"projection-ref":"feed-page"},"name":"cursor"}},"op":"not-eq","rhs":"none"}}}},"on":{"semantic":{"event":"retry-load-tapped"}},"params":[]},{"body":[{"set":{"field":"reload-pending","key":null,"value":{"bool":true}}},{"send":{"args":[],"bind":null,"command":"reload","port":"feed"}}],"guard":{"unary":{"expr":{"state-ref":"reload-pending"},"op":"not"}},"on":{"semantic":{"event":"retry-reload-tapped"}},"params":[]},{"body":[{"set":{"field":"reload-pending","key":null,"value":{"bool":false}}}],"guard":null,"on":{"outcome":{"command":"reload","which":"ok"}},"params":["tag","cmd"]},{"body":[{"set":{"field":"reload-pending","key":null,"value":{"bool":false}}}],"guard":null,"on":{"outcome":{"command":"reload","which":"err"}},"params":["tag","cmd","refusal"]},{"body":[{"open-surface":{"args":[{"name":"post","value":{"binding-ref":"post"}}],"surface":"comments-sheet"}}],"guard":null,"on":{"semantic":{"event":"comments-requested"}},"params":["post"]},{"body":[{"navigate":{"args":[{"name":"user","value":{"binding-ref":"user"}}],"route":"profile"}}],"guard":null,"on":{"semantic":{"event":"author-tapped"}},"params":["user"]},{"body":[{"navigate":{"args":[{"name":"user","value":{"field":{"base":{"projection-ref":"viewer"},"name":"id"}}}],"route":"profile"}}],"guard":{"binary":{"lhs":{"binding-ref":"section"},"op":"eq","rhs":{"text":"profile"}}},"on":{"semantic":{"event":"tab-selected"}},"params":["section"]},{"body":[{"set":{"field":"notice","key":null,"value":"none"}}],"guard":null,"on":{"semantic":{"event":"notice-dismissed"}},"params":[]}],"modality":null,"params":[],"props":[],"root":{"element":{"children":[{"if":{"cond":{"binary":{"lhs":{"state-ref":"notice"},"op":"not-eq","rhs":"none"}},"els":[],"then":[{"component":{"component":"notice-bar","emits":[{"emit":"dismissed","target":{"rebind":{"args":[],"event":"notice-dismissed"}}}],"ord":1,"props":[{"name":"text","value":{"binary":{"lhs":{"state-ref":"notice"},"op":"coalesce","rhs":{"text":""}}}}]}}]}},{"match":{"arms":[{"binding":null,"body":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":3,"props":[],"text":[{"literal":"Loading your feed…"}]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":2,"props":[],"text":[]}}],"variant":"loading"},{"binding":"reason","body":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"text","events":[],"ord":5,"props":[],"text":[{"literal":"Your feed didn't load."}]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"text","events":[],"ord":7,"props":[],"text":[{"literal":"Retry"}]}}],"class":null,"element":"button","events":[{"args":[],"emit":"retry-reload-tapped","event":"press"}],"ord":6,"props":[{"name":"label","value":{"text":"Retry"}},{"name":"busy","value":{"state-ref":"reload-pending"}}],"text":[]}}],"class":{"text":"fill-center stack-sm"},"element":"view","events":[],"ord":4,"props":[],"text":[]}}],"variant":"failed"},{"binding":"f","body":[{"if":{"cond":{"binary":{"lhs":{"count":{"field":{"base":{"binding-ref":"f"},"name":"posts"}}},"op":"eq","rhs":{"int":0}}},"els":[{"element":{"children":[{"component":{"component":"stories-tray","emits":[],"ord":11,"props":[{"name":"stories","value":{"field":{"base":{"binding-ref":"f"},"name":"stories"}}}]}},{"element":{"children":[{"each":{"body":[{"component":{"component":"post-card","emits":[{"emit":"like-toggled","target":"forward"},{"emit":"comments-requested","target":"forward"},{"emit":"author-tapped","target":"forward"}],"ord":14,"props":[{"name":"post","value":{"binding-ref":"p"}},{"name":"liked","value":{"binary":{"lhs":{"index":{"base":{"state-ref":"like-overlay"},"key":{"field":{"base":{"binding-ref":"p"},"name":"id"}}}},"op":"coalesce","rhs":{"field":{"base":{"binding-ref":"p"},"name":"viewer-has-liked"}}}}},{"name":"like-pending","value":{"binary":{"lhs":{"index":{"base":{"state-ref":"like-pending"},"key":{"field":{"base":{"binding-ref":"p"},"name":"id"}}}},"op":"coalesce","rhs":{"bool":false}}}}]}}],"item":"p","key":{"field":{"base":{"binding-ref":"p"},"name":"id"}},"ord":13,"over":"list","seq":{"field":{"base":{"binding-ref":"f"},"name":"posts"}}}}],"class":{"text":"post-list"},"element":"view","events":[],"ord":12,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}},{"if":{"cond":{"state-ref":"load-pending"},"els":[],"then":[{"element":{"children":[],"class":{"text":"muted row-center"},"element":"text","events":[],"ord":15,"props":[],"text":[{"literal":"Loading more…"}]}}]}},{"if":{"cond":{"state-ref":"load-failed"},"els":[],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":17,"props":[],"text":[{"literal":"Couldn't load more."}]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"text","events":[],"ord":19,"props":[],"text":[{"literal":"Retry"}]}}],"class":null,"element":"button","events":[{"args":[],"emit":"retry-load-tapped","event":"press"}],"ord":18,"props":[{"name":"label","value":{"text":"Retry"}}],"text":[]}}],"class":{"text":"row-center gap-sm"},"element":"view","events":[],"ord":16,"props":[],"text":[]}}]}},{"if":{"cond":{"unary":{"expr":{"field":{"base":{"binding-ref":"f"},"name":"has-more"}},"op":"not"}},"els":[],"then":[{"element":{"children":[],"class":{"text":"muted row-center pad-md"},"element":"text","events":[],"ord":20,"props":[],"text":[{"literal":"You're all caught up."}]}}]}}],"class":{"text":"feed-scroll"},"element":"scroll","events":[{"args":[],"emit":"feed-near-end","event":"near-end"}],"ord":10,"props":[],"text":[]}}],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":9,"props":[],"text":[{"literal":"Follow people to fill your feed."}]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":8,"props":[],"text":[]}}]}}],"variant":"ready"}],"source":{"availability":{"key":null,"projection":"feed-page"}}}},{"component":{"component":"bottom-nav","emits":[{"emit":"tab-selected","target":"forward"}],"ord":21,"props":[{"name":"current","value":{"text":"feed"}}]}}],"class":{"text":"screen feed-page"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{"like-overlay":"empty-map","like-pending":"empty-map","load-failed":{"bool":false},"load-pending":{"bool":false},"notice":"none","reload-pending":{"bool":false}}},"profile":{"emits":[],"events":{"back-tapped":[],"profile-tab-selected":[{"name":"tab","ty":"text"}],"tab-selected":[{"name":"section","ty":"text"}]},"handlers":[{"body":[{"set":{"field":"active-tab","key":null,"value":{"binding-ref":"tab"}}}],"guard":null,"on":{"semantic":{"event":"profile-tab-selected"}},"params":["tab"]},{"body":["navigate-back"],"guard":null,"on":{"semantic":{"event":"back-tapped"}},"params":[]},{"body":[{"navigate":{"args":[],"route":"feed"}}],"guard":{"binary":{"lhs":{"binding-ref":"section"},"op":"eq","rhs":{"text":"feed"}}},"on":{"semantic":{"event":"tab-selected"}},"params":["section"]}],"modality":null,"params":["user"],"props":[],"root":{"element":{"children":[{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":3,"props":[{"name":"name","value":{"text":"back"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[],"emit":"back-tapped","event":"press"}],"ord":2,"props":[{"name":"label","value":{"text":"Back"}}],"text":[]}},{"element":{"children":[],"class":{"text":"title"},"element":"text","events":[],"ord":4,"props":[],"text":[{"literal":"Profile"}]}}],"class":{"text":"top-bar"},"element":"view","events":[],"ord":1,"props":[],"text":[]}},{"match":{"arms":[{"binding":null,"body":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":6,"props":[],"text":[{"literal":"Loading profile…"}]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":5,"props":[],"text":[]}}],"variant":"loading"},{"binding":"reason","body":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":8,"props":[],"text":[{"literal":"This profile didn't load."}]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":7,"props":[],"text":[]}}],"variant":"failed"},{"binding":"pr","body":[{"element":{"children":[{"component":{"component":"profile-header","emits":[],"ord":10,"props":[{"name":"user","value":{"field":{"base":{"binding-ref":"pr"},"name":"user"}}},{"name":"bio","value":{"field":{"base":{"binding-ref":"pr"},"name":"bio"}}},{"name":"posts-label","value":{"field":{"base":{"binding-ref":"pr"},"name":"posts-label"}}},{"name":"followers-label","value":{"field":{"base":{"binding-ref":"pr"},"name":"followers-label"}}},{"name":"following-label","value":{"field":{"base":{"binding-ref":"pr"},"name":"following-label"}}}]}},{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":13,"props":[{"name":"name","value":{"text":"grid"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"tab","value":{"text":"posts"}}],"emit":"profile-tab-selected","event":"press"}],"ord":12,"props":[{"name":"label","value":{"text":"Posts"}},{"name":"current","value":{"binary":{"lhs":{"state-ref":"active-tab"},"op":"eq","rhs":{"text":"posts"}}}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":15,"props":[{"name":"name","value":{"text":"profile"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[{"name":"tab","value":{"text":"tagged"}}],"emit":"profile-tab-selected","event":"press"}],"ord":14,"props":[{"name":"label","value":{"text":"Tagged"}},{"name":"current","value":{"binary":{"lhs":{"state-ref":"active-tab"},"op":"eq","rhs":{"text":"tagged"}}}}],"text":[]}}],"class":{"text":"profile-tabs"},"element":"view","events":[],"ord":11,"props":[{"name":"role","value":{"text":"tablist"}}],"text":[]}},{"if":{"cond":{"binary":{"lhs":{"state-ref":"active-tab"},"op":"eq","rhs":{"text":"posts"}}},"els":[{"if":{"cond":{"binary":{"lhs":{"count":{"field":{"base":{"binding-ref":"pr"},"name":"tagged"}}},"op":"eq","rhs":{"int":0}}},"els":[{"element":{"children":[{"each":{"body":[{"element":{"children":[],"class":{"text":"grid-tile"},"element":"image","events":[],"ord":25,"props":[{"name":"src","value":{"field":{"base":{"binding-ref":"th"},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"binding-ref":"th"},"name":"alt"}}}],"text":[]}}],"item":"th","key":{"field":{"base":{"binding-ref":"th"},"name":"id"}},"ord":24,"over":"list","seq":{"field":{"base":{"binding-ref":"pr"},"name":"tagged"}}}}],"class":{"text":"post-grid"},"element":"view","events":[],"ord":23,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}}],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":22,"props":[],"text":[{"literal":"No tagged posts yet."}]}}],"class":{"text":"fill-center pad-lg"},"element":"view","events":[],"ord":21,"props":[],"text":[]}}]}}],"then":[{"if":{"cond":{"binary":{"lhs":{"count":{"field":{"base":{"binding-ref":"pr"},"name":"posts"}}},"op":"eq","rhs":{"int":0}}},"els":[{"element":{"children":[{"each":{"body":[{"element":{"children":[],"class":{"text":"grid-tile"},"element":"image","events":[],"ord":20,"props":[{"name":"src","value":{"field":{"base":{"binding-ref":"th"},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"binding-ref":"th"},"name":"alt"}}}],"text":[]}}],"item":"th","key":{"field":{"base":{"binding-ref":"th"},"name":"id"}},"ord":19,"over":"list","seq":{"field":{"base":{"binding-ref":"pr"},"name":"posts"}}}}],"class":{"text":"post-grid"},"element":"view","events":[],"ord":18,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}}],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":17,"props":[],"text":[{"literal":"No posts yet."}]}}],"class":{"text":"fill-center pad-lg"},"element":"view","events":[],"ord":16,"props":[],"text":[]}}]}}]}}],"class":{"text":"profile-scroll"},"element":"scroll","events":[],"ord":9,"props":[],"text":[]}}],"variant":"ready"}],"source":{"availability":{"key":{"param-ref":"user"},"projection":"profile"}}}},{"component":{"component":"bottom-nav","emits":[{"emit":"tab-selected","target":"forward"}],"ord":26,"props":[{"name":"current","value":{"text":"profile"}}]}}],"class":{"text":"screen profile-page"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{"active-tab":{"text":"posts"}}}},"ports":{"comments":{"hash":"51c7928689f79af601788bb003fb7ec7139ca2f927bde43a05c4329f97fc4b11","version":"0.1.0"},"feed":{"hash":"3aca73f8d88096fb23ba5c1aa3c02ef44443ac775cfd2fe9953da8dad0b42ce6","version":"0.1.0"},"profile":{"hash":"99a41a60d0fd23baa07f67ecafd1eb96a7bb63ac7d9ec55c180477efe7783366","version":"0.1.0"}},"projections":{"feed-page":{"boot":false,"key":null,"port":"feed","ty":{"record":{"cursor":{"option":"id"},"has-more":"bool","posts":{"list":{"record":{"author":{"record":{"avatar":{"record":{"alt":"text","src":"asset"}},"display-name":"text","id":"id","username":"text"}},"caption":"text","comment-count":"int","id":"id","like-count":"int","media":{"union":{"carousel":{"slides":{"list":{"record":{"alt":"text","id":"id","src":"asset"}}}},"image":{"image":{"record":{"alt":"text","src":"asset"}}},"video":{"poster":{"record":{"alt":"text","src":"asset"}}}}},"posted-label":"text","viewer-has-liked":"bool"}}},"stories":{"list":{"record":{"has-unseen":"bool","id":"id","is-self":"bool","user":{"record":{"avatar":{"record":{"alt":"text","src":"asset"}},"display-name":"text","id":"id","username":"text"}}}}}}}},"for-post":{"boot":false,"key":"id","port":"comments","ty":{"record":{"comments":{"list":{"record":{"author":{"record":{"avatar":{"record":{"alt":"text","src":"asset"}},"display-name":"text","id":"id","username":"text"}},"body":"text","id":"id","posted-label":"text"}}}}}},"profile":{"boot":false,"key":"id","port":"profile","ty":{"record":{"bio":"text","followers-label":"text","following-label":"text","posts":{"list":{"record":{"alt":"text","id":"id","src":"asset"}}},"posts-label":"text","tagged":{"list":{"record":{"alt":"text","id":"id","src":"asset"}}},"user":{"record":{"avatar":{"record":{"alt":"text","src":"asset"}},"display-name":"text","id":"id","username":"text"}}}}},"viewer":{"boot":true,"key":null,"port":"feed","ty":{"record":{"avatar":{"record":{"alt":"text","src":"asset"}},"display-name":"text","id":"id","username":"text"}}}},"protocol":"uhura-ir/0","routes":{"feed":{"params":[],"segments":[{"static":"feed"}]},"profile":{"params":["user"],"segments":[{"static":"profile"},{"param":"user"}]}},"surfaces":{"comments-sheet":{"emits":[],"events":{"composer-changed":[{"name":"value","ty":"text"}],"dismiss-requested":[],"notice-dismissed":[],"submit-requested":[]},"handlers":[{"body":[{"set":{"field":"draft","key":null,"value":{"binding-ref":"value"}}}],"guard":null,"on":{"semantic":{"event":"composer-changed"}},"params":["value"]},{"body":[{"send":{"args":[{"name":"post","value":{"prop-ref":"post"}},{"name":"body","value":{"state-ref":"draft"}}],"bind":"t","command":"add-comment","port":"comments"}},{"set":{"field":"pending-appends","key":{"binding-ref":"t"},"value":{"state-ref":"draft"}}},{"set":{"field":"draft","key":null,"value":{"text":""}}}],"guard":{"binary":{"lhs":{"state-ref":"draft"},"op":"not-eq","rhs":{"text":""}}},"on":{"semantic":{"event":"submit-requested"}},"params":[]},{"body":[{"set":{"field":"pending-appends","key":{"binding-ref":"tag"},"value":"none"}}],"guard":null,"on":{"outcome":{"command":"add-comment","which":"ok"}},"params":["tag","cmd"]},{"body":[{"set":{"field":"pending-appends","key":{"binding-ref":"tag"},"value":"none"}},{"set":{"field":"draft","key":null,"value":{"field":{"base":{"binding-ref":"cmd"},"name":"body"}}}},{"set":{"field":"notice","key":null,"value":{"text":"Couldn't post your comment. Try again."}}}],"guard":null,"on":{"outcome":{"command":"add-comment","which":"err"}},"params":["tag","cmd","refusal"]},{"body":["dismiss"],"guard":null,"on":{"semantic":{"event":"dismiss-requested"}},"params":[]},{"body":[{"set":{"field":"notice","key":null,"value":"none"}}],"guard":null,"on":{"semantic":{"event":"notice-dismissed"}},"params":[]}],"modality":"sheet","params":[],"props":["post"],"root":{"element":{"children":[{"element":{"children":[{"element":{"children":[],"class":{"text":"sheet-title"},"element":"text","events":[],"ord":2,"props":[],"text":[{"literal":"Comments"}]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":4,"props":[{"name":"name","value":{"text":"close"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[],"emit":"dismiss-requested","event":"press"}],"ord":3,"props":[{"name":"label","value":{"text":"Close"}}],"text":[]}}],"class":{"text":"sheet-head"},"element":"view","events":[],"ord":1,"props":[],"text":[]}},{"if":{"cond":{"binary":{"lhs":{"state-ref":"notice"},"op":"not-eq","rhs":"none"}},"els":[],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":6,"props":[],"text":[{"interp":{"binary":{"lhs":{"state-ref":"notice"},"op":"coalesce","rhs":{"text":""}}}}]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":8,"props":[{"name":"name","value":{"text":"close"}}],"text":[]}}],"class":null,"element":"button","events":[{"args":[],"emit":"notice-dismissed","event":"press"}],"ord":7,"props":[{"name":"label","value":{"text":"Dismiss notice"}}],"text":[]}}],"class":{"text":"notice"},"element":"view","events":[],"ord":5,"props":[],"text":[]}}]}},{"match":{"arms":[{"binding":null,"body":[{"element":{"children":[{"element":{"children":[],"class":null,"element":"icon","events":[],"ord":10,"props":[{"name":"name","value":{"text":"progress"}}],"text":[]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":9,"props":[],"text":[]}}],"variant":"loading"},{"binding":"reason","body":[{"element":{"children":[{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":12,"props":[],"text":[{"literal":"Comments couldn't load."}]}}],"class":{"text":"fill-center"},"element":"view","events":[],"ord":11,"props":[],"text":[]}}],"variant":"failed"},{"binding":"t","body":[{"element":{"children":[{"if":{"cond":{"binary":{"lhs":{"count":{"field":{"base":{"binding-ref":"t"},"name":"comments"}}},"op":"eq","rhs":{"int":0}}},"els":[{"element":{"children":[{"each":{"body":[{"component":{"component":"comment-row","emits":[],"ord":19,"props":[{"name":"avatar","value":{"field":{"base":{"field":{"base":{"binding-ref":"c"},"name":"author"}},"name":"avatar"}}},{"name":"username","value":{"field":{"base":{"field":{"base":{"binding-ref":"c"},"name":"author"}},"name":"username"}}},{"name":"body","value":{"field":{"base":{"binding-ref":"c"},"name":"body"}}},{"name":"time-label","value":{"field":{"base":{"binding-ref":"c"},"name":"posted-label"}}},{"name":"pending","value":{"bool":false}}]}}],"item":"c","key":{"field":{"base":{"binding-ref":"c"},"name":"id"}},"ord":18,"over":"list","seq":{"field":{"base":{"binding-ref":"t"},"name":"comments"}}}}],"class":null,"element":"view","events":[],"ord":17,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}}],"then":[{"element":{"children":[{"element":{"children":[],"class":{"text":"empty-title"},"element":"text","events":[],"ord":15,"props":[],"text":[{"literal":"No comments yet"}]}},{"element":{"children":[],"class":{"text":"muted"},"element":"text","events":[],"ord":16,"props":[],"text":[{"literal":"Start the conversation."}]}}],"class":{"text":"fill-center stack-sm"},"element":"view","events":[],"ord":14,"props":[],"text":[]}}]}},{"element":{"children":[{"each":{"body":[{"component":{"component":"comment-row","emits":[],"ord":22,"props":[{"name":"avatar","value":{"field":{"base":{"projection-ref":"viewer"},"name":"avatar"}}},{"name":"username","value":{"field":{"base":{"projection-ref":"viewer"},"name":"username"}}},{"name":"body","value":{"binary":{"lhs":{"index":{"base":{"state-ref":"pending-appends"},"key":{"binding-ref":"pending-tag"}}},"op":"coalesce","rhs":{"text":""}}}},{"name":"time-label","value":{"text":"Posting…"}},{"name":"pending","value":{"bool":true}}]}}],"item":"pending-tag","key":{"binding-ref":"pending-tag"},"ord":21,"over":"map-tag-keys","seq":{"state-ref":"pending-appends"}}}],"class":null,"element":"view","events":[],"ord":20,"props":[{"name":"role","value":{"text":"list"}}],"text":[]}}],"class":{"text":"comment-list"},"element":"scroll","events":[],"ord":13,"props":[],"text":[]}}],"variant":"ready"}],"source":{"availability":{"key":{"prop-ref":"post"},"projection":"for-post"}}}},{"element":{"children":[{"element":{"children":[],"class":{"text":"avatar"},"element":"image","events":[],"ord":24,"props":[{"name":"src","value":{"field":{"base":{"field":{"base":{"projection-ref":"viewer"},"name":"avatar"}},"name":"src"}}},{"name":"alt","value":{"field":{"base":{"field":{"base":{"projection-ref":"viewer"},"name":"avatar"}},"name":"alt"}}}],"text":[]}},{"element":{"children":[],"class":null,"element":"text-field","events":[{"args":[],"emit":"composer-changed","event":"change"},{"args":[],"emit":"submit-requested","event":"submit"}],"ord":25,"props":[{"name":"value","value":{"state-ref":"draft"}},{"name":"label","value":{"text":"Add a comment"}},{"name":"placeholder","value":{"text":"Add a comment…"}}],"text":[]}},{"element":{"children":[{"element":{"children":[],"class":null,"element":"text","events":[],"ord":27,"props":[],"text":[{"literal":"Post"}]}}],"class":null,"element":"button","events":[{"args":[],"emit":"submit-requested","event":"press"}],"ord":26,"props":[{"name":"label","value":{"text":"Post"}},{"name":"disabled","value":{"binary":{"lhs":{"state-ref":"draft"},"op":"eq","rhs":{"text":""}}}}],"text":[]}}],"class":{"text":"composer"},"element":"view","events":[],"ord":23,"props":[],"text":[]}}],"class":{"text":"comments-sheet"},"element":"view","events":[],"ord":0,"props":[],"text":[]}},"state":{"draft":{"text":""},"notice":"none","pending-appends":"empty-map"}}}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-alt-xor-decorative.txt b/uhura/crates/uhura-tests/goldens/m2/reject-alt-xor-decorative.txt new file mode 100644 index 0000000..f939659 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-alt-xor-decorative.txt @@ -0,0 +1,2 @@ +error[UH5009 markup/a11y-alt]: `<image>` takes exactly one of alt / decorative + --> components/post-card.uhura:27:9 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-class-rooting-violation.txt b/uhura/crates/uhura-tests/goldens/m2/reject-class-rooting-violation.txt new file mode 100644 index 0000000..2145e61 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-class-rooting-violation.txt @@ -0,0 +1,3 @@ +error[UH6001 style/class-rooting]: `article` is not class-rooted — scoped CSS is deferred, so rooting is the isolation contract (§4.5) + --> components/post-card.uhura:60:1 + note: root it under the subject's class: `.post-card …` diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-controlled-promotion.txt b/uhura/crates/uhura-tests/goldens/m2/reject-controlled-promotion.txt new file mode 100644 index 0000000..7bfda8a --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-controlled-promotion.txt @@ -0,0 +1,2 @@ +error[UH5008 markup/controlled-promotion]: binding `value` makes `<text-field>` controlled — it must handle `on:change` (§10) + --> surfaces/comments-sheet.uhura:94:5 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-event-on-layout.txt b/uhura/crates/uhura-tests/goldens/m2/reject-event-on-layout.txt new file mode 100644 index 0000000..e0893d7 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-event-on-layout.txt @@ -0,0 +1,3 @@ +error[UH5002 markup/event-not-declared]: `<view>` declares no `press` event + --> app/feed/page.uhura:137:47 + note: `on:` never attaches to layout elements — wrap the content in `<region>` (§4.8; never auto-repaired) diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-missing-availability-arm.txt b/uhura/crates/uhura-tests/goldens/m2/reject-missing-availability-arm.txt new file mode 100644 index 0000000..6be1b6c --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-missing-availability-arm.txt @@ -0,0 +1,2 @@ +error[UH5015 markup/bad-availability-arms]: availability match is missing its `{:when loading}` arm — absence is a state the design must show (§9.2) + --> app/profile/[user]/page.uhura:34:3 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-nested-interactive.txt b/uhura/crates/uhura-tests/goldens/m2/reject-nested-interactive.txt new file mode 100644 index 0000000..dcdfbe3 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-nested-interactive.txt @@ -0,0 +1,4 @@ +error[UH5006 markup/bad-children]: `<button>` children are content elements (text / image / icon) + --> components/post-card.uhura:51:7 +error[UH5007 markup/nested-interactive]: `<button>` cannot nest inside another interactive element (§10) + --> components/post-card.uhura:51:7 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-non-exhaustive-union-match.txt b/uhura/crates/uhura-tests/goldens/m2/reject-non-exhaustive-union-match.txt new file mode 100644 index 0000000..be98d5f --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-non-exhaustive-union-match.txt @@ -0,0 +1,8 @@ +error[UH5016 markup/bad-union-arms]: duplicate `{:when carousel}` arm + --> components/post-card.uhura:37:5 +error[UH3003 types/unresolved-name]: nothing named `v` is in scope + --> components/post-card.uhura:39:21 +error[UH3003 types/unresolved-name]: nothing named `v` is in scope + --> components/post-card.uhura:39:40 +error[UH5016 markup/bad-union-arms]: non-exhaustive match: `video` is unhandled (add the arm or `{:else}`) + --> components/post-card.uhura:24:3 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unbound-required-prop.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unbound-required-prop.txt new file mode 100644 index 0000000..242ad04 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unbound-required-prop.txt @@ -0,0 +1,2 @@ +error[UH5004 markup/missing-required-prop]: `<notice-bar>` requires `text` + --> app/feed/page.uhura:115:5 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-undeclared-component-emit.txt b/uhura/crates/uhura-tests/goldens/m2/reject-undeclared-component-emit.txt new file mode 100644 index 0000000..e066a3f --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-undeclared-component-emit.txt @@ -0,0 +1,4 @@ +error[UH5003 markup/undeclared-emit]: `<notice-bar>` declares no emit `closed` + --> app/feed/page.uhura:115:37 +warning[UH5013 markup/unhandled-event]: `<notice-bar>` emits `dismissed` but this call site leaves it unbound — the control will be dead (§4.4) + --> app/feed/page.uhura:115:5 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-undefined-class-warning.txt b/uhura/crates/uhura-tests/goldens/m2/reject-undefined-class-warning.txt new file mode 100644 index 0000000..a8891d7 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-undefined-class-warning.txt @@ -0,0 +1,2 @@ +warning[UH6002 style/undefined-class]: class `feed-scrolll` is styled nowhere (theme.css or any `<style>` block) + --> app/feed/page.uhura:135:17 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unguarded-projection-read.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unguarded-projection-read.txt new file mode 100644 index 0000000..4cd2e6f --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unguarded-projection-read.txt @@ -0,0 +1,2 @@ +error[UH3009 types/unguarded-projection-read]: `feed-page` is absent until delivered — in markup, read it through `{#match feed-page}` availability arms (§9.2) + --> app/feed/page.uhura:117:8 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unkeyed-each.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unkeyed-each.txt new file mode 100644 index 0000000..ec5e049 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unkeyed-each.txt @@ -0,0 +1,2 @@ +error[UH0003 syntax/unkeyed-each]: `{#each}` requires a key: `{#each list as item (item.id)}` + --> app/feed/page.uhura:138:32 diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unknown-element.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unknown-element.txt new file mode 100644 index 0000000..e74fec8 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unknown-element.txt @@ -0,0 +1,3 @@ +error[UH5001 markup/unknown-element]: `<avatar>` is neither a catalog element nor an imported component + --> components/post-card.uhura:20:7 + note: the avatar pattern is `<image class=…>` — see docs/patterns diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unknown-icon.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unknown-icon.txt new file mode 100644 index 0000000..76cfc2d --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unknown-icon.txt @@ -0,0 +1,3 @@ +error[UH5017 markup/unknown-icon]: `reel` is not in the catalog icon set + --> components/bottom-nav.uhura:22:11 + note: did you mean `reels`? diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unreachable-handler.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unreachable-handler.txt new file mode 100644 index 0000000..1adab7b --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unreachable-handler.txt @@ -0,0 +1,3 @@ +error[UH4005 machine/unreachable-handler]: this `on like-toggled` handler is unreachable + --> app/feed/page.uhura:34:3 + = an unguarded handler above always wins (app/feed/page.uhura:28:3) diff --git a/uhura/crates/uhura-tests/goldens/m2/reject-unresolved-name-did-you-mean.txt b/uhura/crates/uhura-tests/goldens/m2/reject-unresolved-name-did-you-mean.txt new file mode 100644 index 0000000..6d413a6 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m2/reject-unresolved-name-did-you-mean.txt @@ -0,0 +1,3 @@ +error[UH3003 types/unresolved-name]: nothing named `reload-pendin` is in scope + --> app/feed/page.uhura:83:34 + note: did you mean `reload-pending`? diff --git a/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-feed-active.json b/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-feed-active.json new file mode 100644 index 0000000..60bb6dd --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-feed-active.json @@ -0,0 +1 @@ +{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"fragment:0"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"fragment:0"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"0","props":{"role":"navigation"}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-profile-active.json b/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-profile-active.json new file mode 100644 index 0000000..4627b65 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-bottom-nav-profile-active.json @@ -0,0 +1 @@ +{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"fragment:0"}],"props":{"current":false,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"fragment:0"}],"props":{"current":true,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"0","props":{"role":"navigation"}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-comment-row-pending.json b/uhura/crates/uhura-tests/goldens/m3/v-comment-row-pending.json new file mode 100644 index 0000000..c0e9e61 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-comment-row-pending.json @@ -0,0 +1 @@ +{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"mira.santos · Posting…"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Saving this palette for my kitchen reno — stunning work!"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row pending","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-comment-row-settled.json b/uhura/crates/uhura-tests/goldens/m3/v-comment-row-settled.json new file mode 100644 index 0000000..15ddcca --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-comment-row-settled.json @@ -0,0 +1 @@ +{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"kenji.rides · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"That copper red is unreal. What cone are you firing to?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-empty.json b/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-empty.json new file mode 100644 index 0000000..0a44db4 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-empty.json @@ -0,0 +1 @@ +{"children":[{"children":[{"class":"sheet-title","element":"text","key":"2","props":{"content":{"t":"plain","v":"Comments"}}},{"children":[{"element":"icon","key":"4","props":{"name":"close"}}],"element":"button","key":"3","on":[{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Close"}}}],"class":"sheet-head","element":"view","key":"1","props":{}},{"children":[{"children":[{"class":"empty-title","element":"text","key":"15","props":{"content":{"t":"plain","v":"No comments yet"}}},{"class":"muted","element":"text","key":"16","props":{"content":{"t":"plain","v":"Start the conversation."}}}],"class":"fill-center stack-sm","element":"view","key":"14","props":{}},{"element":"view","key":"20","props":{"role":"list"}}],"class":"comment-list","element":"scroll","key":"13","props":{}},{"children":[{"class":"avatar","element":"image","key":"24","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"element":"text-field","key":"25","on":[{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"fragment:0"},{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Add a comment"},"placeholder":{"t":"plain","v":"Add a comment…"},"value":{"t":"plain","v":""}}},{"children":[{"element":"text","key":"27","props":{"content":{"t":"plain","v":"Post"}}}],"element":"button","key":"26","on":[{"emit":"submit-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"disabled":true,"label":{"t":"plain","v":"Post"}}}],"class":"composer","element":"view","key":"23","props":{}}],"class":"comments-sheet","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-populated.json b/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-populated.json new file mode 100644 index 0000000..e74ffd3 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-comments-sheet-populated.json @@ -0,0 +1 @@ +{"children":[{"children":[{"class":"sheet-title","element":"text","key":"2","props":{"content":{"t":"plain","v":"Comments"}}},{"children":[{"element":"icon","key":"4","props":{"name":"close"}}],"element":"button","key":"3","on":[{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Close"}}}],"class":"sheet-head","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"kenji.rides · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"That copper red is unreal. What cone are you firing to?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-1","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"priya.raman · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"The third tile down — that speckle! Saving this for glaze inspiration."}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-2","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"june.park · 45m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Would buy the whole batch honestly. Seconds sale when?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-3","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"theo.okafor · 20m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"These would look wild as a court-side mosaic. Collab?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-4","props":{}}],"element":"view","key":"17","props":{"role":"list"}},{"element":"view","key":"20","props":{"role":"list"}}],"class":"comment-list","element":"scroll","key":"13","props":{}},{"children":[{"class":"avatar","element":"image","key":"24","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"element":"text-field","key":"25","on":[{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"fragment:0"},{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Add a comment"},"placeholder":{"t":"plain","v":"Add a comment…"},"value":{"t":"plain","v":""}}},{"children":[{"element":"text","key":"27","props":{"content":{"t":"plain","v":"Post"}}}],"element":"button","key":"26","on":[{"emit":"submit-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"disabled":true,"label":{"t":"plain","v":"Post"}}}],"class":"composer","element":"view","key":"23","props":{}}],"class":"comments-sheet","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-feed-empty.json b/uhura/crates/uhura-tests/goldens/m3/v-feed-empty.json new file mode 100644 index 0000000..9c005c1 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-feed-empty.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"class":"muted","element":"text","key":"9","props":{"content":{"t":"plain","v":"Follow people to fill your feed."}}}],"class":"fill-center","element":"view","key":"8","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-feed-exhausted.json b/uhura/crates/uhura-tests/goldens/m3/v-feed-exhausted.json new file mode 100644 index 0000000..f4681e5 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-feed-exhausted.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Ayla Demir"},"src":{"asset":"avatar-ayla","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"ayla.demir"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-ayla"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Ferry deck railing over blue water, city skyline behind"},"src":{"asset":"media-ayla-ferry","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"340 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"0 comments · 1d"}}}],"class":"post-card","element":"view","key":"13.post-ayla-ferry","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-june"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Folded linen garments stacked by shade on a workbench"},"src":{"asset":"media-june-lookbook","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"521 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Studio lookbook, page one. Linen in every weight we could mill."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"18 comments · 1d"}}}],"class":"post-card","element":"view","key":"13.post-june-lookbook","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"theo.okafor"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-theo"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Basketball court painted with bold geometric shapes"},"src":{"asset":"media-theo-court","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-theo-court"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-theo-court"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-theo-court"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"774 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Finished the mural at the 9th street court. Paint holds up better than my jumper."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"26 comments · 2d"}}}],"class":"post-card","element":"view","key":"13.post-theo-court","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-kenji"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Road bike leaning on a stone wall at a mountain pass"},"src":{"asset":"media-kenji-copper","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart-filled"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":false,"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Unlike"},"pressed":true}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"1108 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"120km of switchbacks and one very smug goat. Copper Pass, you were worth it."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"44 comments · 2d"}}}],"class":"post-card","element":"view","key":"13.post-kenji-copper","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}},{"class":"muted row-center pad-md","element":"text","key":"20","props":{"content":{"t":"plain","v":"You're all caught up."}}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-feed-failed.json b/uhura/crates/uhura-tests/goldens/m3/v-feed-failed.json new file mode 100644 index 0000000..48a79c0 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-feed-failed.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"element":"text","key":"5","props":{"content":{"t":"plain","v":"Your feed didn't load."}}},{"children":[{"element":"text","key":"7","props":{"content":{"t":"plain","v":"Retry"}}}],"element":"button","key":"6","on":[{"emit":"retry-reload-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Retry"}}}],"class":"fill-center stack-sm","element":"view","key":"4","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-feed-first-page.json b/uhura/crates/uhura-tests/goldens/m3/v-feed-first-page.json new file mode 100644 index 0000000..d20cf7f --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-feed-first-page.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-feed-loading.json b/uhura/crates/uhura-tests/goldens/m3/v-feed-loading.json new file mode 100644 index 0000000..14f0260 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-feed-loading.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"class":"muted","element":"text","key":"3","props":{"content":{"t":"plain","v":"Loading your feed…"}}}],"class":"fill-center","element":"view","key":"2","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-notice-bar-refusal.json b/uhura/crates/uhura-tests/goldens/m3/v-notice-bar-refusal.json new file mode 100644 index 0000000..9497724 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-notice-bar-refusal.json @@ -0,0 +1 @@ +{"children":[{"class":"notice-text","element":"text","key":"1","props":{"content":{"t":"plain","v":"Couldn't like this post. Try again."}}},{"children":[{"element":"icon","key":"3","props":{"name":"close"}}],"element":"button","key":"2","on":[{"emit":"dismissed","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Dismiss"}}}],"class":"notice-bar","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-post-card-carousel-liked.json b/uhura/crates/uhura-tests/goldens/m3/v-post-card-carousel-liked.json new file mode 100644 index 0000000..d616118 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-post-card-carousel-liked.json @@ -0,0 +1 @@ +{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart-filled"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":false,"post":"post-marco-baja"},"scope":"fragment:0"}],"props":{"busy":false,"label":{"t":"plain","v":"Unlike"},"pressed":true}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"893 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-post-card-image-post.json b/uhura/crates/uhura-tests/goldens/m3/v-post-card-image-post.json new file mode 100644 index 0000000..b810fcc --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-post-card-image-post.json @@ -0,0 +1 @@ +{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-post-card-like-pending.json b/uhura/crates/uhura-tests/goldens/m3/v-post-card-like-pending.json new file mode 100644 index 0000000..ab8b4b3 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-post-card-like-pending.json @@ -0,0 +1 @@ +{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart-filled"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":false,"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"busy":true,"label":{"t":"plain","v":"Unlike"},"pressed":true}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"215 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-post-card-video-poster.json b/uhura/crates/uhura-tests/goldens/m3/v-post-card-video-poster.json new file mode 100644 index 0000000..d95a2c1 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-post-card-video-poster.json @@ -0,0 +1 @@ +{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"fragment:0"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-profile-header-lena.json b/uhura/crates/uhura-tests/goldens/m3/v-profile-header-lena.json new file mode 100644 index 0000000..36dd434 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-profile-header-lena.json @@ -0,0 +1 @@ +{"children":[{"children":[{"class":"avatar","element":"image","key":"2","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"children":[{"children":[{"class":"stat-num","element":"text","key":"5","props":{"content":{"t":"plain","v":"214"}}},{"class":"stat-name","element":"text","key":"6","props":{"content":{"t":"plain","v":"Posts"}}}],"class":"stat","element":"view","key":"4","props":{}},{"children":[{"class":"stat-num","element":"text","key":"8","props":{"content":{"t":"plain","v":"18.2k"}}},{"class":"stat-name","element":"text","key":"9","props":{"content":{"t":"plain","v":"Followers"}}}],"class":"stat","element":"view","key":"7","props":{}},{"children":[{"class":"stat-num","element":"text","key":"11","props":{"content":{"t":"plain","v":"512"}}},{"class":"stat-name","element":"text","key":"12","props":{"content":{"t":"plain","v":"Following"}}}],"class":"stat","element":"view","key":"10","props":{}}],"class":"stats","element":"view","key":"3","props":{}}],"class":"head-row","element":"view","key":"1","props":{}},{"class":"display-name","element":"text","key":"13","props":{"content":{"t":"plain","v":"Lena Holt"}}},{"class":"bio","element":"text","key":"14","props":{"content":{"t":"plain","v":"Ceramics + slow mornings. Studio in SE Portland. Seconds sale every first Sunday."}}}],"class":"profile-header","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-profile-lena-posts.json b/uhura/crates/uhura-tests/goldens/m3/v-profile-lena-posts.json new file mode 100644 index 0000000..1ba0300 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-profile-lena-posts.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"element":"icon","key":"3","props":{"name":"back"}}],"element":"button","key":"2","on":[{"emit":"back-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Back"}}},{"class":"title","element":"text","key":"4","props":{"content":{"t":"plain","v":"Profile"}}}],"class":"top-bar","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"2","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"children":[{"children":[{"class":"stat-num","element":"text","key":"5","props":{"content":{"t":"plain","v":"214"}}},{"class":"stat-name","element":"text","key":"6","props":{"content":{"t":"plain","v":"Posts"}}}],"class":"stat","element":"view","key":"4","props":{}},{"children":[{"class":"stat-num","element":"text","key":"8","props":{"content":{"t":"plain","v":"18.2k"}}},{"class":"stat-name","element":"text","key":"9","props":{"content":{"t":"plain","v":"Followers"}}}],"class":"stat","element":"view","key":"7","props":{}},{"children":[{"class":"stat-num","element":"text","key":"11","props":{"content":{"t":"plain","v":"512"}}},{"class":"stat-name","element":"text","key":"12","props":{"content":{"t":"plain","v":"Following"}}}],"class":"stat","element":"view","key":"10","props":{}}],"class":"stats","element":"view","key":"3","props":{}}],"class":"head-row","element":"view","key":"1","props":{}},{"class":"display-name","element":"text","key":"13","props":{"content":{"t":"plain","v":"Lena Holt"}}},{"class":"bio","element":"text","key":"14","props":{"content":{"t":"plain","v":"Ceramics + slow mornings. Studio in SE Portland. Seconds sale every first Sunday."}}}],"class":"profile-header","element":"view","key":"10","props":{}},{"children":[{"children":[{"element":"icon","key":"13","props":{"name":"grid"}}],"element":"button","key":"12","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"posts"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Posts"}}},{"children":[{"element":"icon","key":"15","props":{"name":"profile"}}],"element":"button","key":"14","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"tagged"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Tagged"}}}],"class":"profile-tabs","element":"view","key":"11","props":{"role":"tablist"}},{"children":[{"class":"grid-tile","element":"image","key":"19.thumb-lena-1","props":{"alt":{"t":"plain","v":"Copper-red glaze tiles"},"src":{"asset":"thumb-lena-1","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-2","props":{"alt":{"t":"plain","v":"Stack of unglazed bowls"},"src":{"asset":"thumb-lena-2","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-3","props":{"alt":{"t":"plain","v":"Kiln shelf mid-load"},"src":{"asset":"thumb-lena-3","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-4","props":{"alt":{"t":"plain","v":"Celadon test cups"},"src":{"asset":"thumb-lena-4","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-5","props":{"alt":{"t":"plain","v":"Wedging table with fresh clay"},"src":{"asset":"thumb-lena-5","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-6","props":{"alt":{"t":"plain","v":"Iron-speckled dinner plates"},"src":{"asset":"thumb-lena-6","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-7","props":{"alt":{"t":"plain","v":"Throwing a tall cylinder"},"src":{"asset":"thumb-lena-7","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-8","props":{"alt":{"t":"plain","v":"Glaze buckets labelled by cone"},"src":{"asset":"thumb-lena-8","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-lena-9","props":{"alt":{"t":"plain","v":"Morning light across the studio bench"},"src":{"asset":"thumb-lena-9","t":"image"}}}],"class":"post-grid","element":"view","key":"18","props":{"role":"list"}}],"class":"profile-scroll","element":"scroll","key":"9","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"26","props":{"role":"navigation"}}],"class":"screen profile-page","element":"view","key":"0","props":{}},"route":"profile"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-profile-loading.json b/uhura/crates/uhura-tests/goldens/m3/v-profile-loading.json new file mode 100644 index 0000000..bfb9f91 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-profile-loading.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"element":"icon","key":"3","props":{"name":"back"}}],"element":"button","key":"2","on":[{"emit":"back-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Back"}}},{"class":"title","element":"text","key":"4","props":{"content":{"t":"plain","v":"Profile"}}}],"class":"top-bar","element":"view","key":"1","props":{}},{"children":[{"class":"muted","element":"text","key":"6","props":{"content":{"t":"plain","v":"Loading profile…"}}}],"class":"fill-center","element":"view","key":"5","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"26","props":{"role":"navigation"}}],"class":"screen profile-page","element":"view","key":"0","props":{}},"route":"profile"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-profile-self.json b/uhura/crates/uhura-tests/goldens/m3/v-profile-self.json new file mode 100644 index 0000000..efc2762 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-profile-self.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"element":"icon","key":"3","props":{"name":"back"}}],"element":"button","key":"2","on":[{"emit":"back-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Back"}}},{"class":"title","element":"text","key":"4","props":{"content":{"t":"plain","v":"Profile"}}}],"class":"top-bar","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"2","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"children":[{"children":[{"class":"stat-num","element":"text","key":"5","props":{"content":{"t":"plain","v":"87"}}},{"class":"stat-name","element":"text","key":"6","props":{"content":{"t":"plain","v":"Posts"}}}],"class":"stat","element":"view","key":"4","props":{}},{"children":[{"class":"stat-num","element":"text","key":"8","props":{"content":{"t":"plain","v":"9.4k"}}},{"class":"stat-name","element":"text","key":"9","props":{"content":{"t":"plain","v":"Followers"}}}],"class":"stat","element":"view","key":"7","props":{}},{"children":[{"class":"stat-num","element":"text","key":"11","props":{"content":{"t":"plain","v":"301"}}},{"class":"stat-name","element":"text","key":"12","props":{"content":{"t":"plain","v":"Following"}}}],"class":"stat","element":"view","key":"10","props":{}}],"class":"stats","element":"view","key":"3","props":{}}],"class":"head-row","element":"view","key":"1","props":{}},{"class":"display-name","element":"text","key":"13","props":{"content":{"t":"plain","v":"Mira Santos"}}},{"class":"bio","element":"text","key":"14","props":{"content":{"t":"plain","v":"Food & travel photographer. Lisbon, mostly. Shooting the small hours."}}}],"class":"profile-header","element":"view","key":"10","props":{}},{"children":[{"children":[{"element":"icon","key":"13","props":{"name":"grid"}}],"element":"button","key":"12","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"posts"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Posts"}}},{"children":[{"element":"icon","key":"15","props":{"name":"profile"}}],"element":"button","key":"14","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"tagged"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Tagged"}}}],"class":"profile-tabs","element":"view","key":"11","props":{"role":"tablist"}},{"children":[{"class":"grid-tile","element":"image","key":"19.thumb-mira-1","props":{"alt":{"t":"plain","v":"Pastéis de nata on a marble counter"},"src":{"asset":"thumb-mira-1","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-mira-2","props":{"alt":{"t":"plain","v":"Tram rails catching dawn light"},"src":{"asset":"thumb-mira-2","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-mira-3","props":{"alt":{"t":"plain","v":"Market citrus stacked in crates"},"src":{"asset":"thumb-mira-3","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-mira-4","props":{"alt":{"t":"plain","v":"Tiled facade in alternating blues"},"src":{"asset":"thumb-mira-4","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-mira-5","props":{"alt":{"t":"plain","v":"Grilled sardines over coals"},"src":{"asset":"thumb-mira-5","t":"image"}}},{"class":"grid-tile","element":"image","key":"19.thumb-mira-6","props":{"alt":{"t":"plain","v":"Ferry wake at golden hour"},"src":{"asset":"thumb-mira-6","t":"image"}}}],"class":"post-grid","element":"view","key":"18","props":{"role":"list"}}],"class":"profile-scroll","element":"scroll","key":"9","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"26","props":{"role":"navigation"}}],"class":"screen profile-page","element":"view","key":"0","props":{}},"route":"profile"},"protocol":"uhura-view/0","revision":0,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m3/v-stories-tray-tray.json b/uhura/crates/uhura-tests/goldens/m3/v-stories-tray-tray.json new file mode 100644 index 0000000..e4aa5ff --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m3/v-stories-tray-tray.json @@ -0,0 +1 @@ +{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"0","props":{"direction":"horizontal"}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-comment-ok.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-comment-ok.jsonl new file mode 100644 index 0000000..950194f --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-comment-ok.jsonl @@ -0,0 +1,8 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"event":{"kind":"projection","updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"144711cfa06f1ab5d3df33937e504f4519d6b047670eb696d036c2a66b81d504"} +{"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":13}],"on":"comments-requested","scope":"page:1","selected":13},"event":{"descriptor":{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"},"kind":"ui","view-rev":2},"structural":[{"op":"open-surface","opener":"page:1","surface":"comments-sheet:1"}],"u-hash":"a2fffadf140c27d23f8bc5edc1c05be94a2d082570036eac2512240784dc8afa","v-hash":"34004db33f615b86515dac4901837d0f0fed20c674e8995322a1e1a139e81552"} +{"applies":[{"apply":"applied","key":"post-lena-glaze","projection":"for-post","revision":2}],"event":{"kind":"projection","updates":[{"key":"post-lena-glaze","port":"comments","projection":"for-post","revision":2,"value":{"comments":[{"author":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"},"body":"That copper red is unreal. What cone are you firing to?","id":"comment-lena-glaze-1","posted-label":"1h"},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"body":"The third tile down — that speckle! Saving this for glaze inspiration.","id":"comment-lena-glaze-2","posted-label":"1h"},{"author":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"},"body":"Would buy the whole batch honestly. Seconds sale when?","id":"comment-lena-glaze-3","posted-label":"45m"},{"author":{"avatar":{"alt":"Theo Okafor","src":"avatar-theo"},"display-name":"Theo Okafor","id":"user-theo","username":"theo.okafor"},"body":"These would look wild as a court-side mosaic. Collab?","id":"comment-lena-glaze-4","posted-label":"20m"}]}}]},"u-hash":"21312726c7c1c6009b60a8de5902f4d09b63c16b095269e356d23b0fc3bb8eaf","v-hash":"5538d7e3cb31f0d900bbd01dc0478c6c47efca4bea6b998083ec7e783dad1a9b"} +{"dispatch":{"definition":"comments-sheet","guards":[{"guard":"satisfied","handler":0}],"on":"composer-changed","scope":"surface:1","selected":0,"writes":[{"field":"draft","value":"Saving this palette for my kitchen reno — stunning work!"}]},"event":{"data":{"value":"Saving this palette for my kitchen reno — stunning work!"},"descriptor":{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"surface:1"},"kind":"ui","view-rev":4},"u-hash":"aee22438015ce025fa3ee43eac2ce560e16795bc75f2a7b6a2ac871f0ebcce8f","v-hash":"55b1ab3e15c7b3124d25ee9e68bd4d39a1b9d6bc6d5491a39757fee106b7de55"} +{"c":[{"command":"add-comment","correlation":"c-1","kind":"command","payload":{"body":"Saving this palette for my kitchen reno — stunning work!","post":"post-lena-glaze"},"port":"comments"}],"dispatch":{"definition":"comments-sheet","guards":[{"guard":"satisfied","handler":1}],"on":"submit-requested","scope":"surface:1","selected":1,"writes":[{"field":"pending-appends","key":"t-1","value":"Saving this palette for my kitchen reno — stunning work!"},{"field":"draft","value":""}]},"event":{"descriptor":{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"surface:1"},"kind":"ui","view-rev":5},"u-hash":"b279cc188e7754c3e442b80506d8d8dbfb14ed46b1e6bb0531cf33c49594bd94","v-hash":"fdf5bc96c22fa7e07e3bdd5eda48d212cf7eb67fc0e5fbce82a38067c8366bd0"} +{"applies":[{"apply":"applied","key":"post-lena-glaze","projection":"for-post","revision":3}],"dispatch":{"definition":"comments-sheet","guards":[{"guard":"satisfied","handler":2}],"on":"add-comment.ok","scope":"surface:1","selected":2,"writes":[{"field":"pending-appends","key":"t-1","value":null}]},"event":{"correlation":"c-1","kind":"outcome","outcome":{"ok":{}},"updates":[{"key":"post-lena-glaze","port":"comments","projection":"for-post","revision":3,"value":{"comments":[{"author":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"},"body":"That copper red is unreal. What cone are you firing to?","id":"comment-lena-glaze-1","posted-label":"1h"},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"body":"The third tile down — that speckle! Saving this for glaze inspiration.","id":"comment-lena-glaze-2","posted-label":"1h"},{"author":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"},"body":"Would buy the whole batch honestly. Seconds sale when?","id":"comment-lena-glaze-3","posted-label":"45m"},{"author":{"avatar":{"alt":"Theo Okafor","src":"avatar-theo"},"display-name":"Theo Okafor","id":"user-theo","username":"theo.okafor"},"body":"These would look wild as a court-side mosaic. Collab?","id":"comment-lena-glaze-4","posted-label":"20m"},{"author":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"},"body":"Saving this palette for my kitchen reno — stunning work!","id":"fresh-1","posted-label":"now"}]}}]},"u-hash":"702084a9d057196df4e57c8ab5337ac461af93069e507e61349873760a7cd4e3","v-hash":"b414f4c91ac99f949be26b33e7f9e588ab7e4ef3caa81d4a3d6a4466ba5ad4eb"} +{"dispatch":{"definition":"comments-sheet","guards":[{"guard":"satisfied","handler":4}],"on":"dismiss-requested","scope":"surface:1","selected":4},"event":{"descriptor":{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"surface:1"},"kind":"ui","view-rev":7},"i":[{"intent":"focus-restore","key-path":"page:1/0/10/12/13.post-lena-glaze/16/19"}],"structural":[{"op":"dismiss","surface":"comments-sheet:1","top":true}],"u-hash":"2d46474037511654c372d4f8b58f32211b2e39eb42b552e5d05bffd14376090c","v-hash":"915e4ccb90038ff93552a80630a9301ae93e8c81a17393a0e467b606856c1f20"} diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-feed-empty.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-feed-empty.jsonl new file mode 100644 index 0000000..c511e2c --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-feed-empty.jsonl @@ -0,0 +1,2 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"event":{"kind":"projection","updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"has-more":false,"posts":[],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}}]}}]},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"95962ba3dffc54eff1d78e6685018e29909997e7697ab077f153ea900ea77fa4"} diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-feed-failed.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-feed-failed.jsonl new file mode 100644 index 0000000..f1d56b2 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-feed-failed.jsonl @@ -0,0 +1,4 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"failed","projection":"feed-page","reason":"unreachable"}],"event":{"key":null,"kind":"projection-failed","port":"feed","projection":"feed-page","reason":"unreachable"},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"1994c12afc53c3c588e79e896f10df3bb356db944c37b6bba7cace761bd918b3"} +{"c":[{"command":"reload","correlation":"c-1","kind":"command","payload":{},"port":"feed"}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":10}],"on":"retry-reload-tapped","scope":"page:1","selected":10,"writes":[{"field":"reload-pending","value":true}]},"event":{"descriptor":{"emit":"retry-reload-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"},"kind":"ui","view-rev":2},"u-hash":"9880aaf715f66c22a600a1311c871bc2c38601268d3040d430b69b67600975c5","v-hash":"e2ea9de18e2ba7c88b8a638d01c29dd8c88da89d85d25488339c8d5b159c0332"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":11}],"on":"reload.ok","scope":"page:1","selected":11,"writes":[{"field":"reload-pending","value":false}]},"event":{"correlation":"c-1","kind":"outcome","outcome":{"ok":{}},"updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"b51ce60896a7c048cb69dbb7996d3ff49f624475ee8ee0b272b242626c39dd6d","v-hash":"b61864566d23ab77fbe41aa242d2794103f60714810a91263971c18101a6abf7"} diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-like-ok.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-like-ok.jsonl new file mode 100644 index 0000000..6f5242e --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-like-ok.jsonl @@ -0,0 +1,4 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"event":{"kind":"projection","updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"144711cfa06f1ab5d3df33937e504f4519d6b047670eb696d036c2a66b81d504"} +{"c":[{"command":"like-post","correlation":"c-1","kind":"command","payload":{"post":"post-lena-glaze"},"port":"feed"}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":0}],"on":"like-toggled","scope":"page:1","selected":0,"writes":[{"field":"like-overlay","key":"post-lena-glaze","value":true},{"field":"like-pending","key":"post-lena-glaze","value":true}]},"event":{"descriptor":{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"},"kind":"ui","view-rev":2},"u-hash":"b0603a2a9cb0b99226a4ca3e24da0b48fd525d93ad1d5d9de7848eb12b0babfb","v-hash":"1c8da92cb808500e26be86eb8ce22761e13594b436ee9da5cb4eb03706e9210d"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":3}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":2}],"on":"like-post.ok","scope":"page:1","selected":2,"writes":[{"field":"like-pending","key":"post-lena-glaze","value":null},{"field":"like-overlay","key":"post-lena-glaze","value":null}]},"event":{"correlation":"c-1","kind":"outcome","outcome":{"ok":{}},"updates":[{"key":null,"port":"feed","projection":"feed-page","revision":3,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":215,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":true},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"b51ce60896a7c048cb69dbb7996d3ff49f624475ee8ee0b272b242626c39dd6d","v-hash":"7c941f6cb29489ed226ea601a3413383165a01d919c0c007af717c0311733f35"} diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-like-refused.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-like-refused.jsonl new file mode 100644 index 0000000..02398b2 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-like-refused.jsonl @@ -0,0 +1,5 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"event":{"kind":"projection","updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"144711cfa06f1ab5d3df33937e504f4519d6b047670eb696d036c2a66b81d504"} +{"c":[{"command":"like-post","correlation":"c-1","kind":"command","payload":{"post":"post-lena-glaze"},"port":"feed"}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":0}],"on":"like-toggled","scope":"page:1","selected":0,"writes":[{"field":"like-overlay","key":"post-lena-glaze","value":true},{"field":"like-pending","key":"post-lena-glaze","value":true}]},"event":{"descriptor":{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"},"kind":"ui","view-rev":2},"u-hash":"b0603a2a9cb0b99226a4ca3e24da0b48fd525d93ad1d5d9de7848eb12b0babfb","v-hash":"1c8da92cb808500e26be86eb8ce22761e13594b436ee9da5cb4eb03706e9210d"} +{"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":3}],"on":"like-post.err","scope":"page:1","selected":3,"writes":[{"field":"like-pending","key":"post-lena-glaze","value":null},{"field":"like-overlay","key":"post-lena-glaze","value":null},{"field":"notice","value":"Couldn't like this post. Try again."}]},"event":{"correlation":"c-1","kind":"outcome","outcome":{"refused":{"refusal":"rate-limited"}},"updates":[]},"u-hash":"47b6ca5de80ca4908194657be7382cefa3ca0861854476da1b168effb9413acc","v-hash":"2cd6abb052ea6430dc7e4103caa1c8d043b694df8afa5d42ccbe92bdff0d3b97"} +{"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":16}],"on":"notice-dismissed","scope":"page:1","selected":16,"writes":[{"field":"notice","value":null}]},"event":{"descriptor":{"emit":"notice-dismissed","event":"press","kind":"input","payload":{},"scope":"page:1"},"kind":"ui","view-rev":4},"u-hash":"827c994e826af2817a6bb2c85d5cd2e98f7c5c974c7cf053a878283ee09468ef","v-hash":"5854e2065bdf8fe244671b5aa7bfb4b90b45f112b571bc9670fe32257ffb63ac"} diff --git a/uhura/crates/uhura-tests/goldens/m4/trace-paginate.jsonl b/uhura/crates/uhura-tests/goldens/m4/trace-paginate.jsonl new file mode 100644 index 0000000..060bcca --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/trace-paginate.jsonl @@ -0,0 +1,5 @@ +{"applies":[{"apply":"applied","projection":"viewer","revision":1}],"event":{"kind":"init","params":{},"route":"feed"},"structural":[{"op":"init","route":"feed","serial":1}],"u-hash":"8da341cd2ebe15a08f285b8a6b459b2ff278091e036624af209462df5057f573","v-hash":"c907d19570d158b80ea2b2062cdb28237f48c0a4cc3a9d3a166e9e2ff1ba9861"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":2}],"event":{"kind":"projection","updates":[{"key":null,"port":"feed","projection":"feed-page","revision":2,"value":{"cursor":"cursor-page-2","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"0051b119d148a302062bee64d60d7f79ebeee9e70a795e2cf8f522984fc22099","v-hash":"144711cfa06f1ab5d3df33937e504f4519d6b047670eb696d036c2a66b81d504"} +{"c":[{"command":"load-next-page","correlation":"c-1","kind":"command","payload":{"cursor":"cursor-page-2"},"port":"feed"}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":6}],"on":"feed-near-end","scope":"page:1","selected":6,"writes":[{"field":"load-pending","value":true}]},"event":{"descriptor":{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"},"kind":"ui","view-rev":2},"u-hash":"f5f929152f051a316728f554b35395fbc87c6be13b1ceb1c27f89390f5c17a08","v-hash":"0d980e7f0a14b508af2c621a1481c8e22b60ac4d6e4824846a7aea7a14fcc72e"} +{"dispatch":{"definition":"feed","guards":[{"guard":"unsatisfied","handler":6}],"on":"feed-near-end","scope":"page:1","selected":null},"drop":"no-handler","event":{"descriptor":{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"},"kind":"ui","view-rev":3},"u-hash":"f5972e675f12a5fb00469a9bba30092d08ea2811181ec671efea308c0fd5dd50","v-hash":"9773fd02b915a948116e06a4016d745a00e4f519e3d994966ad5c285d9eb8651"} +{"applies":[{"apply":"applied","projection":"feed-page","revision":3}],"dispatch":{"definition":"feed","guards":[{"guard":"satisfied","handler":7}],"on":"load-next-page.ok","scope":"page:1","selected":7,"writes":[{"field":"load-pending","value":false}]},"event":{"correlation":"c-1","kind":"outcome","outcome":{"ok":{}},"updates":[{"key":null,"port":"feed","projection":"feed-page","revision":3,"value":{"cursor":"cursor-page-3","has-more":true,"posts":[{"author":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"},"caption":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.","comment-count":4,"id":"post-lena-glaze","like-count":214,"media":{"image":{"image":{"alt":"Grid of copper-red glaze test tiles on a maple bench","src":"media-lena-glaze"}}},"posted-label":"2h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"},"caption":"Three days down the Baja coast. Swell arrived on the last morning, as it always does.","comment-count":31,"id":"post-marco-baja","like-count":892,"media":{"carousel":{"slides":[{"alt":"Long left-hand wave peeling along a desert point","id":"slide-marco-baja-1","src":"media-marco-baja-1"},{"alt":"Campfire on the bluff above the break at dusk","id":"slide-marco-baja-2","src":"media-marco-baja-2"},{"alt":"Board fins silhouetted against the sunrise","id":"slide-marco-baja-3","src":"media-marco-baja-3"}]}},"posted-label":"5h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Nils Bergman","src":"avatar-nils"},"display-name":"Nils Bergman","id":"user-nils","username":"nils.bergman"},"caption":"Aurora over the fjord last night — the whole sky was breathing. Sound on.","comment-count":87,"id":"post-nils-aurora","like-count":2431,"media":{"video":{"poster":{"alt":"Green aurora curtains over a dark fjord","src":"media-nils-aurora-poster"}}},"posted-label":"9h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"},"caption":"Day 400 of the starter. She's earned a name: Clint Yeastwood.","comment-count":12,"id":"post-priya-starter","like-count":156,"media":{"image":{"image":{"alt":"Open crumb of a sourdough loaf, sliced on a flour-dusted board","src":"media-priya-starter"}}},"posted-label":"12h","viewer-has-liked":false},{"author":{"avatar":{"alt":"Ayla Demir","src":"avatar-ayla"},"display-name":"Ayla Demir","id":"user-ayla","username":"ayla.demir"},"caption":"Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon.","comment-count":0,"id":"post-ayla-ferry","like-count":340,"media":{"image":{"image":{"alt":"Ferry deck railing over blue water, city skyline behind","src":"media-ayla-ferry"}}},"posted-label":"1d","viewer-has-liked":false},{"author":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"},"caption":"Studio lookbook, page one. Linen in every weight we could mill.","comment-count":18,"id":"post-june-lookbook","like-count":521,"media":{"image":{"image":{"alt":"Folded linen garments stacked by shade on a workbench","src":"media-june-lookbook"}}},"posted-label":"1d","viewer-has-liked":false},{"author":{"avatar":{"alt":"Theo Okafor","src":"avatar-theo"},"display-name":"Theo Okafor","id":"user-theo","username":"theo.okafor"},"caption":"Finished the mural at the 9th street court. Paint holds up better than my jumper.","comment-count":26,"id":"post-theo-court","like-count":774,"media":{"image":{"image":{"alt":"Basketball court painted with bold geometric shapes","src":"media-theo-court"}}},"posted-label":"2d","viewer-has-liked":false},{"author":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"},"caption":"120km of switchbacks and one very smug goat. Copper Pass, you were worth it.","comment-count":44,"id":"post-kenji-copper","like-count":1108,"media":{"image":{"image":{"alt":"Road bike leaning on a stone wall at a mountain pass","src":"media-kenji-copper"}}},"posted-label":"2d","viewer-has-liked":true}],"stories":[{"has-unseen":false,"id":"ring-mira","is-self":true,"user":{"avatar":{"alt":"Mira Santos","src":"avatar-mira"},"display-name":"Mira Santos","id":"user-mira","username":"mira.santos"}},{"has-unseen":true,"id":"ring-lena","is-self":false,"user":{"avatar":{"alt":"Lena Holt","src":"avatar-lena"},"display-name":"Lena Holt","id":"user-lena","username":"lena.holt"}},{"has-unseen":true,"id":"ring-marco","is-self":false,"user":{"avatar":{"alt":"Marco Reyes","src":"avatar-marco"},"display-name":"Marco Reyes","id":"user-marco","username":"marco.reyes"}},{"has-unseen":false,"id":"ring-priya","is-self":false,"user":{"avatar":{"alt":"Priya Raman","src":"avatar-priya"},"display-name":"Priya Raman","id":"user-priya","username":"priya.raman"}},{"has-unseen":true,"id":"ring-june","is-self":false,"user":{"avatar":{"alt":"June Park","src":"avatar-june"},"display-name":"June Park","id":"user-june","username":"june.park"}},{"has-unseen":false,"id":"ring-kenji","is-self":false,"user":{"avatar":{"alt":"Kenji Tanaka","src":"avatar-kenji"},"display-name":"Kenji Tanaka","id":"user-kenji","username":"kenji.rides"}}]}}]},"u-hash":"827c994e826af2817a6bb2c85d5cd2e98f7c5c974c7cf053a878283ee09468ef","v-hash":"750e1b54c6d4f5b585bb3eff6b978d5583f0efdcc1ee738e5d771af24b49f001"} diff --git a/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-composing.json b/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-composing.json new file mode 100644 index 0000000..0c864c6 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-composing.json @@ -0,0 +1 @@ +{"children":[{"children":[{"class":"sheet-title","element":"text","key":"2","props":{"content":{"t":"plain","v":"Comments"}}},{"children":[{"element":"icon","key":"4","props":{"name":"close"}}],"element":"button","key":"3","on":[{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Close"}}}],"class":"sheet-head","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"kenji.rides · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"That copper red is unreal. What cone are you firing to?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-1","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"priya.raman · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"The third tile down — that speckle! Saving this for glaze inspiration."}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-2","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"june.park · 45m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Would buy the whole batch honestly. Seconds sale when?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-3","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"theo.okafor · 20m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"These would look wild as a court-side mosaic. Collab?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-4","props":{}}],"element":"view","key":"17","props":{"role":"list"}},{"element":"view","key":"20","props":{"role":"list"}}],"class":"comment-list","element":"scroll","key":"13","props":{}},{"children":[{"class":"avatar","element":"image","key":"24","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"element":"text-field","key":"25","on":[{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"fragment:0"},{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Add a comment"},"placeholder":{"t":"plain","v":"Add a comment…"},"value":{"t":"plain","v":"Saving this palette for my kitchen reno"}}},{"children":[{"element":"text","key":"27","props":{"content":{"t":"plain","v":"Post"}}}],"element":"button","key":"26","on":[{"emit":"submit-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"disabled":false,"label":{"t":"plain","v":"Post"}}}],"class":"composer","element":"view","key":"23","props":{}}],"class":"comments-sheet","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-pending-append.json b/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-pending-append.json new file mode 100644 index 0000000..6d08410 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-comments-sheet-pending-append.json @@ -0,0 +1 @@ +{"children":[{"children":[{"class":"sheet-title","element":"text","key":"2","props":{"content":{"t":"plain","v":"Comments"}}},{"children":[{"element":"icon","key":"4","props":{"name":"close"}}],"element":"button","key":"3","on":[{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Close"}}}],"class":"sheet-head","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"kenji.rides · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"That copper red is unreal. What cone are you firing to?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-1","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"priya.raman · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"The third tile down — that speckle! Saving this for glaze inspiration."}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-2","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"june.park · 45m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Would buy the whole batch honestly. Seconds sale when?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-3","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"theo.okafor · 20m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"These would look wild as a court-side mosaic. Collab?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-4","props":{}}],"element":"view","key":"17","props":{"role":"list"}},{"children":[{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"mira.santos · Posting…"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Saving this palette for my kitchen reno"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row pending","element":"view","key":"21.t-1","props":{}}],"element":"view","key":"20","props":{"role":"list"}}],"class":"comment-list","element":"scroll","key":"13","props":{}},{"children":[{"class":"avatar","element":"image","key":"24","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"element":"text-field","key":"25","on":[{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"fragment:0"},{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"label":{"t":"plain","v":"Add a comment"},"placeholder":{"t":"plain","v":"Add a comment…"},"value":{"t":"plain","v":""}}},{"children":[{"element":"text","key":"27","props":{"content":{"t":"plain","v":"Post"}}}],"element":"button","key":"26","on":[{"emit":"submit-requested","event":"press","kind":"input","payload":{},"scope":"fragment:0"}],"props":{"disabled":true,"label":{"t":"plain","v":"Post"}}}],"class":"composer","element":"view","key":"23","props":{}}],"class":"comments-sheet","element":"view","key":"0","props":{}} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-appended.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-appended.json new file mode 100644 index 0000000..f4c5055 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-appended.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Ayla Demir"},"src":{"asset":"avatar-ayla","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"ayla.demir"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-ayla"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Ferry deck railing over blue water, city skyline behind"},"src":{"asset":"media-ayla-ferry","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-ayla-ferry"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"340 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"0 comments · 1d"}}}],"class":"post-card","element":"view","key":"13.post-ayla-ferry","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-june"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Folded linen garments stacked by shade on a workbench"},"src":{"asset":"media-june-lookbook","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-june-lookbook"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"521 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Studio lookbook, page one. Linen in every weight we could mill."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"18 comments · 1d"}}}],"class":"post-card","element":"view","key":"13.post-june-lookbook","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"theo.okafor"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-theo"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Basketball court painted with bold geometric shapes"},"src":{"asset":"media-theo-court","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-theo-court"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-theo-court"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-theo-court"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"774 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Finished the mural at the 9th street court. Paint holds up better than my jumper."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"26 comments · 2d"}}}],"class":"post-card","element":"view","key":"13.post-theo-court","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-kenji"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Road bike leaning on a stone wall at a mountain pass"},"src":{"asset":"media-kenji-copper","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart-filled"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":false,"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Unlike"},"pressed":true}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-kenji-copper"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"1108 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"120km of switchbacks and one very smug goat. Copper Pass, you were worth it."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"44 comments · 2d"}}}],"class":"post-card","element":"view","key":"13.post-kenji-copper","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":4,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-comments-open.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-comments-open.json new file mode 100644 index 0000000..af5ea9c --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-comments-open.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":2,"surfaces":[{"definition":"comments-sheet","dismiss":{"emit":"dismiss","event":"dismiss","kind":"input","payload":{},"scope":"surface:1"},"key":"comments-sheet:1","modality":"sheet","restore-focus":"page:1/0/10/12/13.post-lena-glaze/16/19","root":{"children":[{"children":[{"class":"sheet-title","element":"text","key":"2","props":{"content":{"t":"plain","v":"Comments"}}},{"children":[{"element":"icon","key":"4","props":{"name":"close"}}],"element":"button","key":"3","on":[{"emit":"dismiss-requested","event":"press","kind":"input","payload":{},"scope":"surface:1"}],"props":{"label":{"t":"plain","v":"Close"}}}],"class":"sheet-head","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"kenji.rides · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"That copper red is unreal. What cone are you firing to?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-1","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"priya.raman · 1h"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"The third tile down — that speckle! Saving this for glaze inspiration."}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-2","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"june.park · 45m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"Would buy the whole batch honestly. Seconds sale when?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-3","props":{}},{"children":[{"class":"avatar","element":"image","key":"1","props":{"alt":{"t":"plain","v":"Theo Okafor"},"src":{"asset":"avatar-theo","t":"image"}}},{"children":[{"class":"comment-head","element":"text","key":"3","props":{"content":{"t":"plain","v":"theo.okafor · 20m"}}},{"class":"comment-text","element":"text","key":"4","props":{"content":{"t":"plain","v":"These would look wild as a court-side mosaic. Collab?"}}}],"class":"comment-body","element":"view","key":"2","props":{}}],"class":"comment-row","element":"view","key":"18.comment-lena-glaze-4","props":{}}],"element":"view","key":"17","props":{"role":"list"}},{"element":"view","key":"20","props":{"role":"list"}}],"class":"comment-list","element":"scroll","key":"13","props":{}},{"children":[{"class":"avatar","element":"image","key":"24","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"element":"text-field","key":"25","on":[{"carries":{"value":"text"},"emit":"composer-changed","event":"change","kind":"input","payload":{},"scope":"surface:1"},{"emit":"submit-requested","event":"submit","kind":"input","payload":{},"scope":"surface:1"}],"props":{"label":{"t":"plain","v":"Add a comment"},"placeholder":{"t":"plain","v":"Add a comment…"},"value":{"t":"plain","v":""}}},{"children":[{"element":"text","key":"27","props":{"content":{"t":"plain","v":"Post"}}}],"element":"button","key":"26","on":[{"emit":"submit-requested","event":"press","kind":"input","payload":{},"scope":"surface:1"}],"props":{"disabled":true,"label":{"t":"plain","v":"Post"}}}],"class":"composer","element":"view","key":"23","props":{}}],"class":"comments-sheet","element":"view","key":"0","props":{}}}]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-like-pending.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-like-pending.json new file mode 100644 index 0000000..47aa0ea --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-like-pending.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart-filled"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":false,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":true,"label":{"t":"plain","v":"Unlike"},"pressed":true}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"215 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":2,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-like-refused.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-like-refused.json new file mode 100644 index 0000000..d86ed6e --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-like-refused.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"class":"notice-text","element":"text","key":"1","props":{"content":{"t":"plain","v":"Couldn't like this post. Try again."}}},{"children":[{"element":"icon","key":"3","props":{"name":"close"}}],"element":"button","key":"2","on":[{"emit":"notice-dismissed","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Dismiss"}}}],"class":"notice-bar","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":3,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-load-failed.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-load-failed.json new file mode 100644 index 0000000..965a31a --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-load-failed.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}},{"children":[{"class":"muted","element":"text","key":"17","props":{"content":{"t":"plain","v":"Couldn't load more."}}},{"children":[{"element":"text","key":"19","props":{"content":{"t":"plain","v":"Retry"}}}],"element":"button","key":"18","on":[{"emit":"retry-load-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Retry"}}}],"class":"row-center gap-sm","element":"view","key":"16","props":{}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":3,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-feed-load-pending.json b/uhura/crates/uhura-tests/goldens/m4/v-feed-load-pending.json new file mode 100644 index 0000000..2cf7897 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-feed-load-pending.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"children":[{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Mira Santos"},"src":{"asset":"avatar-mira","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"Your story"}}}],"class":"ring-item","element":"view","key":"2.ring-mira","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"ring-item","element":"view","key":"2.ring-lena","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"ring-item","element":"view","key":"2.ring-marco","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"ring-item","element":"view","key":"2.ring-priya","props":{}},{"children":[{"class":"ring unseen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"June Park"},"src":{"asset":"avatar-june","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"june.park"}}}],"class":"ring-item","element":"view","key":"2.ring-june","props":{}},{"children":[{"class":"ring seen","element":"image","key":"4","props":{"alt":{"t":"plain","v":"Kenji Tanaka"},"src":{"asset":"avatar-kenji","t":"image"}}},{"class":"ring-name","element":"text","key":"5","props":{"content":{"t":"plain","v":"kenji.rides"}}}],"class":"ring-item","element":"view","key":"2.ring-kenji","props":{}}],"class":"ring-row","element":"view","key":"1","props":{"role":"list"}}],"class":"stories-tray","element":"scroll","key":"11","props":{"direction":"horizontal"}},{"children":[{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"lena.holt"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-lena"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Grid of copper-red glaze test tiles on a maple bench"},"src":{"asset":"media-lena-glaze","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"214 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"4 comments · 2h"}}}],"class":"post-card","element":"view","key":"13.post-lena-glaze","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Marco Reyes"},"src":{"asset":"avatar-marco","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"marco.reyes"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-marco"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"children":[{"class":"slide","element":"image","key":"9.slide-marco-baja-1","props":{"alt":{"t":"plain","v":"Long left-hand wave peeling along a desert point"},"src":{"asset":"media-marco-baja-1","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-2","props":{"alt":{"t":"plain","v":"Campfire on the bluff above the break at dusk"},"src":{"asset":"media-marco-baja-2","t":"image"}}},{"class":"slide","element":"image","key":"9.slide-marco-baja-3","props":{"alt":{"t":"plain","v":"Board fins silhouetted against the sunrise"},"src":{"asset":"media-marco-baja-3","t":"image"}}}],"class":"media","element":"pager","key":"8","props":{"indicator":"dots","label":{"t":"plain","v":"Photo carousel"}}}],"element":"region","key":"7","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-marco-baja"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-marco-baja"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"892 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Three days down the Baja coast. Swell arrived on the last morning, as it always does."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"31 comments · 5h"}}}],"class":"post-card","element":"view","key":"13.post-marco-baja","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Nils Bergman"},"src":{"asset":"avatar-nils","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"nils.bergman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-nils"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"element":"image","key":"12","props":{"alt":{"t":"plain","v":"Green aurora curtains over a dark fjord"},"src":{"asset":"media-nils-aurora-poster","t":"image"}}},{"children":[{"element":"icon","key":"14","props":{"name":"video-off"}},{"element":"text","key":"15","props":{"content":{"t":"plain","v":"Video isn't supported in this preview"}}}],"class":"video-fallback-badge","element":"view","key":"13","props":{}}],"class":"media video-fallback","element":"view","key":"11","props":{}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-nils-aurora"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"2431 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Aurora over the fjord last night — the whole sky was breathing. Sound on."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"87 comments · 9h"}}}],"class":"post-card","element":"view","key":"13.post-nils-aurora","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"3","props":{"alt":{"t":"plain","v":"Priya Raman"},"src":{"asset":"avatar-priya","t":"image"}}},{"class":"username","element":"text","key":"4","props":{"content":{"t":"plain","v":"priya.raman"}}}],"class":"author-row","element":"view","key":"2","props":{}}],"element":"region","key":"1","on":[{"emit":"author-tapped","event":"activate","kind":"input","payload":{"user":"user-priya"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"View profile"}}},{"children":[{"class":"media","element":"image","key":"6","props":{"alt":{"t":"plain","v":"Open crumb of a sourdough loaf, sliced on a flour-dusted board"},"src":{"asset":"media-priya-starter","t":"image"}}}],"element":"region","key":"5","on":[{"emit":"like-toggled","event":"activate-double","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Like this post"},"supplementary":true}},{"children":[{"children":[{"element":"icon","key":"18","props":{"name":"heart"}}],"element":"button","key":"17","on":[{"emit":"like-toggled","event":"press","kind":"input","payload":{"now-liked":true,"post":"post-priya-starter"},"scope":"page:1"}],"props":{"busy":false,"label":{"t":"plain","v":"Like"},"pressed":false}},{"children":[{"element":"icon","key":"20","props":{"name":"comment"}}],"element":"button","key":"19","on":[{"emit":"comments-requested","event":"press","kind":"input","payload":{"post":"post-priya-starter"},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Comments"}}}],"class":"action-row","element":"view","key":"16","props":{}},{"class":"likes","element":"text","key":"21","props":{"content":{"t":"plain","v":"156 likes"}}},{"class":"caption","element":"text","key":"22","props":{"content":{"t":"plain","v":"Day 400 of the starter. She's earned a name: Clint Yeastwood."}}},{"class":"meta","element":"text","key":"23","props":{"content":{"t":"plain","v":"12 comments · 12h"}}}],"class":"post-card","element":"view","key":"13.post-priya-starter","props":{}}],"class":"post-list","element":"view","key":"12","props":{"role":"list"}},{"class":"muted row-center","element":"text","key":"15","props":{"content":{"t":"plain","v":"Loading more…"}}}],"class":"feed-scroll","element":"scroll","key":"10","on":[{"emit":"feed-near-end","event":"near-end","kind":"observe","payload":{},"scope":"page:1"}],"props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"21","props":{"role":"navigation"}}],"class":"screen feed-page","element":"view","key":"0","props":{}},"route":"feed"},"protocol":"uhura-view/0","revision":2,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/goldens/m4/v-profile-tagged-empty.json b/uhura/crates/uhura-tests/goldens/m4/v-profile-tagged-empty.json new file mode 100644 index 0000000..4756277 --- /dev/null +++ b/uhura/crates/uhura-tests/goldens/m4/v-profile-tagged-empty.json @@ -0,0 +1 @@ +{"page":{"root":{"children":[{"children":[{"children":[{"element":"icon","key":"3","props":{"name":"back"}}],"element":"button","key":"2","on":[{"emit":"back-tapped","event":"press","kind":"input","payload":{},"scope":"page:1"}],"props":{"label":{"t":"plain","v":"Back"}}},{"class":"title","element":"text","key":"4","props":{"content":{"t":"plain","v":"Profile"}}}],"class":"top-bar","element":"view","key":"1","props":{}},{"children":[{"children":[{"children":[{"class":"avatar","element":"image","key":"2","props":{"alt":{"t":"plain","v":"Lena Holt"},"src":{"asset":"avatar-lena","t":"image"}}},{"children":[{"children":[{"class":"stat-num","element":"text","key":"5","props":{"content":{"t":"plain","v":"214"}}},{"class":"stat-name","element":"text","key":"6","props":{"content":{"t":"plain","v":"Posts"}}}],"class":"stat","element":"view","key":"4","props":{}},{"children":[{"class":"stat-num","element":"text","key":"8","props":{"content":{"t":"plain","v":"18.2k"}}},{"class":"stat-name","element":"text","key":"9","props":{"content":{"t":"plain","v":"Followers"}}}],"class":"stat","element":"view","key":"7","props":{}},{"children":[{"class":"stat-num","element":"text","key":"11","props":{"content":{"t":"plain","v":"512"}}},{"class":"stat-name","element":"text","key":"12","props":{"content":{"t":"plain","v":"Following"}}}],"class":"stat","element":"view","key":"10","props":{}}],"class":"stats","element":"view","key":"3","props":{}}],"class":"head-row","element":"view","key":"1","props":{}},{"class":"display-name","element":"text","key":"13","props":{"content":{"t":"plain","v":"Lena Holt"}}},{"class":"bio","element":"text","key":"14","props":{"content":{"t":"plain","v":"Ceramics + slow mornings. Studio in SE Portland. Seconds sale every first Sunday."}}}],"class":"profile-header","element":"view","key":"10","props":{}},{"children":[{"children":[{"element":"icon","key":"13","props":{"name":"grid"}}],"element":"button","key":"12","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"posts"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Posts"}}},{"children":[{"element":"icon","key":"15","props":{"name":"profile"}}],"element":"button","key":"14","on":[{"emit":"profile-tab-selected","event":"press","kind":"input","payload":{"tab":"tagged"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Tagged"}}}],"class":"profile-tabs","element":"view","key":"11","props":{"role":"tablist"}},{"children":[{"class":"muted","element":"text","key":"22","props":{"content":{"t":"plain","v":"No tagged posts yet."}}}],"class":"fill-center pad-lg","element":"view","key":"21","props":{}}],"class":"profile-scroll","element":"scroll","key":"9","props":{}},{"children":[{"children":[{"element":"icon","key":"2","props":{"name":"home"}}],"element":"button","key":"1","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"feed"},"scope":"page:1"}],"props":{"current":false,"label":{"t":"plain","v":"Home"}}},{"children":[{"element":"icon","key":"4","props":{"name":"search"}}],"element":"button","key":"3","props":{"disabled":true,"label":{"t":"plain","v":"Search"}}},{"children":[{"element":"icon","key":"6","props":{"name":"plus"}}],"element":"button","key":"5","props":{"disabled":true,"label":{"t":"plain","v":"Create"}}},{"children":[{"element":"icon","key":"8","props":{"name":"reels"}}],"element":"button","key":"7","props":{"disabled":true,"label":{"t":"plain","v":"Reels"}}},{"children":[{"element":"icon","key":"10","props":{"name":"profile"}}],"element":"button","key":"9","on":[{"emit":"tab-selected","event":"press","kind":"input","payload":{"section":"profile"},"scope":"page:1"}],"props":{"current":true,"label":{"t":"plain","v":"Profile"}}}],"class":"bottom-nav","element":"view","key":"26","props":{"role":"navigation"}}],"class":"screen profile-page","element":"view","key":"0","props":{}},"route":"profile"},"protocol":"uhura-view/0","revision":2,"surfaces":[]} \ No newline at end of file diff --git a/uhura/crates/uhura-tests/src/lib.rs b/uhura/crates/uhura-tests/src/lib.rs new file mode 100644 index 0000000..b012e75 --- /dev/null +++ b/uhura/crates/uhura-tests/src/lib.rs @@ -0,0 +1,2 @@ +//! uhura-tests: golden harness, purity enforcement, and the acceptance +//! integration test. Never published; dev-dependencies only. diff --git a/uhura/crates/uhura-tests/tests/acceptance_feed.rs b/uhura/crates/uhura-tests/tests/acceptance_feed.rs new file mode 100644 index 0000000..3bc29eb --- /dev/null +++ b/uhura/crates/uhura-tests/tests/acceptance_feed.rs @@ -0,0 +1,813 @@ +//! §13 acceptance — ONE executable integration test (design §12.5, M6): +//! the eight criteria over the canonical scripts. M4's gates pin most +//! invariants piecemeal (m4_gates.rs); this battery re-states them AS +//! §13 — acceptance is one `cargo test` away from the design text — and +//! adds what only lands here: +//! +//! exact FocusRestore key-path equality (not just a prefix); flicker +//! freedom across the whole like-settlement window; pagination +//! failure → retry; a post-exhaustion near-end refused by the guard +//! (the observation descriptor is markup-authored and STAYS in V — +//! projection truth rejects the fetch, register #67); feed page state +//! surviving the profile round trip byte-identically; the real +//! `uhura project` run twice to byte-equal canvases; uncorrelated +//! outcome injection as a check error; IR-bytes invariance with and +//! without `*.examples.uhura`; and native ↔ wasm parity through +//! `scripts/parity.mjs` against the real wasm32 binary. +//! +//! Parity needs a working `node` and both files of +//! `crates/uhura-wasm/pkg/node` (built by `scripts/build-wasm.sh`). When +//! either is missing that criterion is skipped — and libtest swallows the +//! skip notice unless `--nocapture`, so a green default run does NOT +//! prove parity ran. CI must set `UHURA_REQUIRE_PARITY=1` to turn the +//! skip into a failure (`0` and empty count as unset). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uhura_base::render_text; +use uhura_check::fixture::load_fixture; +use uhura_check::manifest::load_manifest; +use uhura_check::{CheckInput, CheckOutput, SourceInput, check}; +use uhura_cli::cmd::dev::boot_envelope; +use uhura_cli::cmd::trace::{fixture_slices_json, run_script}; +use uhura_core::ir::ProgramIr; +use uhura_syntax::SourceKind; + +include!("common/corpus.rs"); + +#[test] +fn acceptance_walkthrough() { + // §13.1 — the corpus checks clean. Of §4.8's documented rejections, + // ten are pinned as source-level goldens in m2_gates (REJECTIONS); + // the value-dependent eleventh — duplicate keys — and the §13.6 + // clause that had no pin anywhere — uncorrelated outcome injection — + // are asserted below. + let out = check(&corpus_input(true, &identity)); + assert_clean(&out); + let program = &out.lowered.as_ref().expect("clean check lowers").program; + + criterion_1_duplicate_keys(); + criterion_2_like(program); + criterion_3_comments(program); + criterion_4_pagination(program); + criterion_5_navigation(program); + criterion_6_projection(&out); + criterion_7_parity(program); + criterion_8_ir_invariance(&out); +} + +// ── §13.1 — duplicate keys: the value-dependent documented rejection ──────── + +fn criterion_1_duplicate_keys() { + // §4.8 lists "duplicate keys" among the checker's rejections, but the + // collision lives in DATA — only evaluation sees it, so the check + // catches it through derived-example replay (§6.2's self-verifying + // design): collide two post ids in the fixture and the feed's derived + // examples fail with the eval rejection spanned to the example. + let mutated = check(&corpus_input(true, &|rel, text| { + if rel == "fixtures/standard.toml" { + text.replace("id = \"post-marco-baja\"", "id = \"post-lena-glaze\"") + } else { + text + } + })); + assert!( + mutated.diagnostics.iter().any(|d| { + d.severity == uhura_base::Severity::Error + && d.code == "UH7012" + && d.message.contains("duplicate key `post-lena-glaze`") + }), + "§13.1/§4.8: colliding each-keys are a check error: {:?}", + mutated + .diagnostics + .iter() + .map(|d| format!("{}: {}", d.code, d.message)) + .collect::<Vec<_>>() + ); +} + +// ── §13.2 — like: one command, optimism first, rollback, no flicker ───────── + +fn criterion_2_like(program: &ProgramIr) { + let steps = trace(program, "like-ok", true); + assert_eq!( + commands(&steps, "like-post").len(), + 1, + "§13.2: exactly one typed command per press" + ); + let press = step_index(&steps, "§13.2 like-ok press", |s| { + s["event"]["descriptor"]["emit"] == "like-toggled" + }); + let outcome = step_index(&steps, "§13.2 like-ok outcome", |s| { + s["event"]["kind"] == "outcome" + }); + assert!( + press < outcome, + "§13.2: the optimistic step precedes settlement" + ); + + // Pre-press: the authored resting state, pinned on the LIKE button + // itself (found by its heart icon, not by position). + let card = lena_card(&steps[press - 1]["v"]["page"]["root"], "§13.2 pre-press"); + assert!( + find_text(card, "214 likes"), + "pre-like shows the authored count" + ); + let like = like_button(card); + assert_eq!( + icon_names(like), + vec!["heart"], + "pre-like heart is unfilled" + ); + assert_eq!( + like["props"]["pressed"].as_bool(), + Some(false), + "pre-like the like button is unpressed" + ); + + // The press itself: heart AND computed count flip before any provider + // word arrives (§11.4 step 2). + let card = lena_card(&steps[press]["v"]["page"]["root"], "§13.2 press"); + assert!( + find_text(card, "215 likes"), + "the optimistic count computes in-card" + ); + let like = like_button(card); + assert_eq!( + icon_names(like), + vec!["heart-filled"], + "the optimistic heart fills" + ); + assert_eq!( + like["props"]["pressed"].as_bool(), + Some(true), + "the like button — that button — reports pressed" + ); + + // Settlement never flickers (§13.2): from the press to the end of the + // script — through the piggybacked update AND the `.ok` dispatch — the + // whole optimistic view (heart AND count AND pressed, the criterion + // names all of it) never once regresses. + for (i, step) in steps.iter().enumerate().skip(press) { + let card = lena_card( + &step["v"]["page"]["root"], + &format!("§13.2 flicker window, step {i}"), + ); + assert!( + find_text(card, "215 likes") && !find_text(card, "214 likes"), + "§13.2: step {i} flickered the count" + ); + let like = like_button(card); + assert_eq!( + icon_names(like), + vec!["heart-filled"], + "§13.2: step {i} flickered the heart" + ); + assert_eq!( + like["props"]["pressed"].as_bool(), + Some(true), + "§13.2: step {i} flickered the pressed state" + ); + } + + // Refusal: rollback to byte-equality with the pre-like render, + // asserted after notice dismissal (§11.4 step 3). + let steps = trace(program, "like-refused", true); + let press = step_index(&steps, "§13.2 like-refused press", |s| { + s["event"]["descriptor"]["emit"] == "like-toggled" + }); + let dismissed = step_index(&steps, "§13.2 like-refused dismissal", |s| { + s["event"]["descriptor"]["emit"] == "notice-dismissed" + }); + assert!( + find_text( + &steps[dismissed - 1]["v"]["page"]["root"], + "Couldn't like this post" + ), + "the notice explains before dismissal" + ); + assert_eq!( + uhura_base::to_canonical_json(&steps[press - 1]["v"]["page"]["root"]), + uhura_base::to_canonical_json(&steps[dismissed]["v"]["page"]["root"]), + "§13.2: post-dismissal, the feed subtree is byte-identical to pre-like" + ); +} + +// ── §13.3 — comments: one keyed surface, optimism ≥ 1 tick, focus back ────── + +fn criterion_3_comments(program: &ProgramIr) { + let steps = trace(program, "comment-ok", true); + + let open = step_index(&steps, "§13.3 open", |s| { + s["event"]["descriptor"]["emit"] == "comments-requested" + }); + let surfaces = steps[open]["v"]["surfaces"].as_array().expect("surfaces"); + assert_eq!(surfaces.len(), 1, "§13.3: open mounts ONE surface instance"); + assert!( + surfaces[0]["key"] + .as_str() + .is_some_and(|k| k.starts_with("comments-sheet:")), + "the instance is keyed" + ); + let mut nodes = Vec::new(); + walk_nodes(&surfaces[0]["root"], &mut nodes); + assert!( + nodes.iter().any(|n| n["element"] == "text-field"), + "§13.3: the sheet carries the typed input" + ); + + // The optimistic append is visible for at least one full step before + // the authority settles it away. + let submit = step_index(&steps, "§13.3 submit", |s| { + s["event"]["descriptor"]["emit"] == "submit-requested" + }); + let outcome = step_index(&steps, "§13.3 outcome", |s| { + s["event"]["kind"] == "outcome" + }); + assert!(submit < outcome, "the pending row exists before settlement"); + assert!( + find_text(&steps[submit]["v"]["surfaces"][0]["root"], "Posting…"), + "§13.3: the optimistic row renders on submit" + ); + let settled = &steps[outcome]["v"]["surfaces"][0]["root"]; + assert!( + !find_text(settled, "Posting…"), + "settlement replaces it atomically" + ); + assert!( + find_text(settled, "Saving this palette for my kitchen reno"), + "the authoritative comment echoes the typed body" + ); + + // Dismissal restores focus — the intent's key-path EQUALS the path of + // the Comments button that opened the sheet, recomputed independently + // from the view the open event was emitted against (micro-decision + // #42: the pre-commit view). + let dismissal = step_index(&steps, "§13.3 dismissal", |s| { + s["event"]["descriptor"]["emit"] == "dismiss-requested" + }); + let focus = steps[dismissal]["i"] + .as_array() + .into_iter() + .flatten() + .find(|i| i["intent"] == "focus-restore") + .expect("§13.3: topmost dismissal emits FocusRestore"); + let expected = + key_path_of(&steps[open - 1]["v"]["page"]["root"], "page:1", &|n| { + n["on"].as_array().into_iter().flatten().any(|d| { + d["emit"] == "comments-requested" && d["payload"]["post"] == "post-lena-glaze" + }) + }) + .expect("the trigger node exists in the pre-open view"); + assert_eq!( + focus["key-path"].as_str().expect("key-path"), + expected, + "§13.3: FocusRestore points exactly at the trigger node" + ); +} + +// ── §13.4 — pagination: dedupe, failure → retry, exhaustion is truth ──────── + +/// Test-local script (the canonical list stays closed): the first +/// `load-next-page` is refused, the author-visible retry sends the +/// second, page 2 appends. File order is the fixture's reply order. +const PAGINATE_RETRY: &str = r#" +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.page-1" + +[[reply]] +on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } +after-ticks = 1 +outcome = "refused" +refusal = "rate-limited" + +[[reply]] +on = { command = "load-next-page", where = { cursor = "cursor-page-2" } } +after-ticks = 2 +outcome = "ok" + +[[reply.updates]] +port = "feed" +projection = "feed-page" +slice = "feed.pages-1-2" + +[[ui]] +at-tick = 2 +emit = "feed-near-end" + +[[ui]] +at-tick = 4 +emit = "retry-load-tapped" +"#; + +/// Test-local script: boot straight into the EXHAUSTED feed +/// (`feed.final`: 8 posts, `has-more = false`, no cursor) and press +/// near-end once. No timeline coupling to any canonical script. +const EXHAUSTED: &str = r#" +[[deliver]] +after-ticks = 1 +port = "feed" +projection = "feed-page" +slice = "feed.final" + +[[ui]] +at-tick = 2 +emit = "feed-near-end" +"#; + +fn criterion_4_pagination(program: &ProgramIr) { + // One near-end episode = one command; the wiggle re-observation drops + // with the guard traced (the guard IS the dedupe). + let steps = trace(program, "paginate", true); + assert_eq!( + commands(&steps, "load-next-page").len(), + 1, + "§13.4: one per episode" + ); + let drops: Vec<_> = steps.iter().filter(|s| s["drop"] == "no-handler").collect(); + assert_eq!( + drops.len(), + 1, + "§13.4: the duplicate is guard-rejected and traced" + ); + + // Append preserves existing keys in order. + let ready = step_index(&steps, "§13.4 paginate page-1", |s| { + s["event"]["kind"] == "projection" + }); + let before = post_keys(&steps[ready]["v"]["page"]["root"]); + let after = post_keys(&steps[steps.len() - 1]["v"]["page"]["root"]); + assert_eq!((before.len(), after.len()), (4, 8)); + assert_eq!( + &after[..4], + &before[..], + "§13.4: existing keys are a strict prefix" + ); + + // Failure → retry: refusal renders the retry affordance; the retry is + // a NEW episode (second command), and the append still lands. The + // prefix is asserted against THIS trace's own page-1 keys. + let steps = trace_text(program, "PAGINATE_RETRY", PAGINATE_RETRY, true); + assert_eq!( + commands(&steps, "load-next-page").len(), + 2, + "§13.4: the failure and the retry are one command each" + ); + let ready = step_index(&steps, "§13.4 retry page-1", |s| { + s["event"]["kind"] == "projection" + }); + let retry_before = post_keys(&steps[ready]["v"]["page"]["root"]); + assert_eq!(retry_before.len(), 4); + let refused = step_index(&steps, "§13.4 retry refusal", |s| { + s["event"]["kind"] == "outcome" + }); + assert!( + find_text(&steps[refused]["v"]["page"]["root"], "Couldn't load more."), + "the refusal renders the retry affordance" + ); + let last = &steps[steps.len() - 1]["v"]["page"]["root"]; + assert!( + !find_text(last, "Couldn't load more."), + "the retry recovers" + ); + assert_eq!(post_keys(last).len(), 8, "page 2 appends after the retry"); + assert_eq!( + &post_keys(last)[..4], + &retry_before[..], + "keys still a prefix" + ); + + // Exhausted derives from projection truth: with `has-more = false` + // the end cap renders and a near-end press fires into an unsatisfied + // guard — zero commands. The observation descriptor is markup-authored + // and deliberately still in V (register #67); it is the MACHINE that + // refuses. + let steps = trace_text(program, "EXHAUSTED", EXHAUSTED, true); + assert!( + commands(&steps, "load-next-page").is_empty(), + "§13.4: exhaustion refuses to fetch" + ); + let last = steps.last().expect("steps"); + assert_eq!( + last["event"]["descriptor"]["emit"], "feed-near-end", + "the post-exhaustion press is the final step" + ); + assert_eq!( + last["drop"], "no-handler", + "…and drops with the guard traced" + ); + let root = &last["v"]["page"]["root"]; + assert!( + find_text(root, "You're all caught up."), + "the end cap renders from `!has-more`" + ); + assert!( + has_near_end_descriptor(root), + "register #67: the markup-authored observation descriptor stays in V" + ); + + // The empty feed renders its empty state and observes nothing at all + // (§8.1): the positive anchor keeps the negative check honest. + let steps = trace(program, "feed-empty", true); + let root = &steps[steps.len() - 1]["v"]["page"]["root"]; + assert!( + find_text(root, "Follow people to fill your feed."), + "§13.4: the empty state actually renders" + ); + assert!( + !has_near_end_descriptor(root), + "§13.4/§8.1: an empty feed subscribes to nothing" + ); +} + +// ── §13.5 — navigation: the round trip returns the same feed ──────────────── + +fn criterion_5_navigation(program: &ProgramIr) { + let steps = trace(program, "demo", true); + let nav = step_index(&steps, "§13.5 navigate", |s| { + s["event"]["descriptor"]["emit"] == "author-tapped" + }); + let back = step_index(&steps, "§13.5 back", |s| { + s["event"]["descriptor"]["emit"] == "back-tapped" + }); + assert!(nav < back); + assert_eq!( + uhura_base::to_canonical_json(&steps[nav - 1]["v"]["page"]["root"]), + uhura_base::to_canonical_json(&steps[back]["v"]["page"]["root"]), + "§13.5: feed → profile → back retains the feed page byte-identically" + ); + + // History intents are emitted and traced, in walkthrough order: + // dismiss, navigate, back. (§13.5's "executed as no-ops" half is the + // SHELL's contract — §7.4, shell/main.js — outside this headless + // battery; no automated test pins it.) + let intents: Vec<String> = steps + .iter() + .flat_map(|s| s["i"].as_array().into_iter().flatten()) + .map(|i| i["intent"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!( + intents, + vec!["focus-restore", "history-push", "history-back"], + "§13.5: the traced intent sequence" + ); +} + +// ── §13.6 — projection: eval-only, deterministic, injection rejected ──────── + +fn criterion_6_projection(out: &CheckOutput) { + // `uhura project` executes zero transitions and zero I/O at projection + // time: its render path is eval-only by construction — no driver is + // even constructible from its inputs. The executable witnesses are + // determinism and coverage: the REAL command, twice, to the byte. + let tmp = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("acceptance-project"); + let mut canvases = Vec::new(); + for run in ["a", "b"] { + let dir = tmp.join(run); + let args = uhura_cli::CommonArgs { + root: corpus_root(), + format_json: false, + deny_warnings: false, + emit_ir: false, + }; + let code = uhura_cli::cmd::project::run(&args, dir.to_str()); + assert_eq!( + format!("{code:?}"), + format!("{:?}", std::process::ExitCode::SUCCESS), + "§13.6: `uhura project` succeeds" + ); + canvases.push(std::fs::read(dir.join("canvas.html")).expect("canvas.html")); + } + assert_eq!( + canvases[0], canvases[1], + "§13.6: projection is byte-deterministic" + ); + + // Every resolved preview — pinned and replay-derived — is on the + // board, matched by the exact caption markup render_frame emits (a + // bare-name contains() is satisfiable by CSS rules, provenance badges, + // and same-named examples of OTHER subjects). + let canvas = String::from_utf8(canvases.pop().expect("one canvas")).expect("utf8"); + assert!(!out.previews.is_empty()); + for preview in &out.previews { + let caption = format!( + "<span class=\"caption-title\">{} / {}</span>", + preview.subject.name(), + preview.example + ); + assert!( + canvas.contains(&caption), + "§13.6: preview `{}/{}` is framed (missing caption `{caption}`)", + preview.subject.name(), + preview.example + ); + } + + // Uncorrelated outcome injection is a check error (§13.6; the + // reachability half — a guard edit fails the derived example — is + // pinned in m4_gates). + let mutated = check(&corpus_input(true, &|rel, text| { + if rel == "app/feed/page.examples.uhura" { + format!( + "{text}\nexample uncorrelated {{\n from first-page\n \ + events [ outcome like-post.ok() ]\n}}\n" + ) + } else { + text + } + })); + assert!( + mutated.diagnostics.iter().any(|d| { + d.severity == uhura_base::Severity::Error + && d.code == "UH7012" + && d.message.contains("no unsettled") + }), + "§13.6: an outcome with no unsettled command is a check error: {:?}", + mutated + .diagnostics + .iter() + .map(|d| format!("{}: {}", d.code, d.message)) + .collect::<Vec<_>>() + ); +} + +// ── §13.7 — native ↔ wasm parity (scripts/parity.mjs) ─────────────────────── + +const PARITY_SCRIPTS: [&str; 7] = [ + "like-ok", + "like-refused", + "comment-ok", + "paginate", + "feed-failed", + "feed-empty", + "demo", +]; + +fn criterion_7_parity(program: &ProgramIr) { + // Gate on BOTH package files (a half-built pkg would fail seven + // scripts confusingly) and on node actually RUNNING (a broken shim + // spawns fine and exits nonzero — spawn success alone proves nothing). + let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../uhura-wasm/pkg/node"); + let pkg_ready = pkg.join("uhura_wasm.js").exists() && pkg.join("uhura_wasm_bg.wasm").exists(); + let node_ok = std::process::Command::new("node") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + let required = !matches!( + std::env::var("UHURA_REQUIRE_PARITY").ok().as_deref(), + None | Some("") | Some("0") + ); + if !pkg_ready || !node_ok { + let missing = if node_ok { + "crates/uhura-wasm/pkg/node (uhura_wasm.js + uhura_wasm_bg.wasm)" + } else { + "a working `node`" + }; + assert!( + !required, + "§13.7: UHURA_REQUIRE_PARITY is set but {missing} is missing \ + (build with scripts/build-wasm.sh)" + ); + eprintln!( + "§13.7 parity SKIPPED: {missing} is missing — build with \ + scripts/build-wasm.sh; set UHURA_REQUIRE_PARITY=1 to hard-fail \ + (this notice is only visible under --nocapture)" + ); + return; + } + + let parity_mjs = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/parity.mjs"); + let fixture_text = read_corpus("fixtures/standard.toml"); + let fixture = load_fixture(&fixture_text).expect("fixture loads"); + let tmp = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("acceptance-parity"); + + for script in PARITY_SCRIPTS { + // The prepared artifact directory parity.mjs documents — every + // file from the same producers `uhura dev` serves from. + let dir = tmp.join(script); + std::fs::create_dir_all(&dir).expect("parity dir"); + let script_text = read_corpus(&format!("fixtures/scripts/{script}.toml")); + let script_json = uhura_fixture::toml_to_json(&script_text).expect("script parses"); + let native = run_script(program, &fixture_text, &script_text, false) + .unwrap_or_else(|e| panic!("{script}: {e}")); + let write = |name: &str, bytes: &[u8]| { + std::fs::write(dir.join(name), bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + }; + write("ir.json", program.to_canonical_string().as_bytes()); + write("fixture.json", fixture_slices_json(&fixture).as_bytes()); + write( + "script.json", + uhura_base::to_canonical_json(&script_json).as_bytes(), + ); + write( + "boot.json", + boot_envelope(program, &fixture) + .expect("boot envelope") + .as_bytes(), + ); + write( + "native.jsonl", + format!("{}\n", native.join("\n")).as_bytes(), + ); + + let out = std::process::Command::new("node") + .arg(&parity_mjs) + .arg(&dir) + .output() + .expect("node runs"); + assert!( + out.status.success(), + "§13.7: {script} diverged:\n{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("byte-identical"), + "§13.7: {script}: unexpected parity report: {stdout}" + ); + } +} + +// ── §13.8 — the IR is blind to examples ───────────────────────────────────── + +fn criterion_8_ir_invariance(with_examples: &CheckOutput) { + let without = check(&corpus_input(false, &identity)); + assert_clean(&without); + assert_eq!( + with_examples + .lowered + .as_ref() + .expect("clean") + .program + .to_canonical_string(), + without + .lowered + .as_ref() + .expect("clean") + .program + .to_canonical_string(), + "§13.8: IR bytes are identical with and without *.examples.uhura" + ); +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn identity(_: &str, text: String) -> String { + text +} + +fn assert_clean(out: &CheckOutput) { + assert!( + !out.diagnostics + .iter() + .any(|d| d.severity == uhura_base::Severity::Error), + "§13.1: the corpus must check clean:\n{}", + render_text(&out.diagnostics, &out.source_map) + ); +} + +fn read_corpus(rel: &str) -> String { + std::fs::read_to_string(corpus_root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +fn trace(program: &ProgramIr, script: &str, expanded: bool) -> Vec<serde_json::Value> { + trace_text( + program, + script, + &read_corpus(&format!("fixtures/scripts/{script}.toml")), + expanded, + ) +} + +fn trace_text( + program: &ProgramIr, + label: &str, + script_text: &str, + expanded: bool, +) -> Vec<serde_json::Value> { + let fixture = read_corpus("fixtures/standard.toml"); + run_script(program, &fixture, script_text, expanded) + .unwrap_or_else(|e| panic!("{label}: {e}")) + .iter() + .map(|line| serde_json::from_str(line).expect("trace lines are JSON")) + .collect() +} + +fn commands<'a>(steps: &'a [serde_json::Value], name: &str) -> Vec<&'a serde_json::Value> { + steps + .iter() + .flat_map(|s| s["c"].as_array().into_iter().flatten()) + .filter(|c| c["command"] == name) + .collect() +} + +fn step_index( + steps: &[serde_json::Value], + label: &str, + pred: impl Fn(&serde_json::Value) -> bool, +) -> usize { + steps + .iter() + .position(pred) + .unwrap_or_else(|| panic!("{label}: no step matches")) +} + +fn walk_nodes<'a>(node: &'a serde_json::Value, out: &mut Vec<&'a serde_json::Value>) { + out.push(node); + for child in node["children"].as_array().into_iter().flatten() { + walk_nodes(child, out); + } +} + +fn find_text(node: &serde_json::Value, needle: &str) -> bool { + let mut nodes = Vec::new(); + walk_nodes(node, &mut nodes); + nodes.iter().any(|n| { + n["props"]["content"]["v"] + .as_str() + .is_some_and(|s| s.contains(needle)) + }) +} + +fn has_near_end_descriptor(root: &serde_json::Value) -> bool { + let mut nodes = Vec::new(); + walk_nodes(root, &mut nodes); + nodes.iter().any(|n| { + n["on"] + .as_array() + .into_iter() + .flatten() + .any(|d| d["emit"] == "feed-near-end") + }) +} + +/// Lena's post-card subtree (each-keys are `<ordinal>.<key>` — §8.1). +fn lena_card<'a>(root: &'a serde_json::Value, label: &str) -> &'a serde_json::Value { + let mut nodes = Vec::new(); + walk_nodes(root, &mut nodes); + nodes + .into_iter() + .find(|n| { + n["key"] + .as_str() + .is_some_and(|k| k.ends_with(".post-lena-glaze")) + }) + .unwrap_or_else(|| panic!("{label}: Lena's card is in the feed")) +} + +/// The card's LIKE button, identified by its heart icon (never by +/// position): the pressed/heart asserts must name that button, not any +/// pressed button in the subtree. +fn like_button(card: &serde_json::Value) -> &serde_json::Value { + let mut nodes = Vec::new(); + walk_nodes(card, &mut nodes); + nodes + .into_iter() + .find(|n| n["element"] == "button" && icon_names(n).iter().any(|i| i.starts_with("heart"))) + .expect("the like button carries the heart icon") +} + +fn icon_names(node: &serde_json::Value) -> Vec<String> { + let mut nodes = Vec::new(); + walk_nodes(node, &mut nodes); + nodes + .iter() + .filter(|n| n["element"] == "icon") + .filter_map(|n| n["props"]["name"].as_str().map(ToString::to_string)) + .collect() +} + +fn post_keys(root: &serde_json::Value) -> Vec<String> { + let mut nodes = Vec::new(); + walk_nodes(root, &mut nodes); + nodes + .iter() + .filter(|n| n["class"].as_str().is_some_and(|c| c.contains("post-card"))) + .filter_map(|n| n["key"].as_str().map(ToString::to_string)) + .collect() +} + +/// Recomputes a node's key-path exactly the way the core does +/// (`find_descriptor`, step.rs): scope, then every node key root → target, +/// first match depth-first. +fn key_path_of( + node: &serde_json::Value, + prefix: &str, + pred: &dyn Fn(&serde_json::Value) -> bool, +) -> Option<String> { + let path = format!("{prefix}/{}", node["key"].as_str()?); + if pred(node) { + return Some(path); + } + node["children"] + .as_array() + .into_iter() + .flatten() + .find_map(|child| key_path_of(child, &path, pred)) +} diff --git a/uhura/crates/uhura-tests/tests/common/corpus.rs b/uhura/crates/uhura-tests/tests/common/corpus.rs new file mode 100644 index 0000000..24fad0e --- /dev/null +++ b/uhura/crates/uhura-tests/tests/common/corpus.rs @@ -0,0 +1,104 @@ +// Shared corpus loading for gate tests — included via include!() so it is +// not its own test target (same trick as uhura-syntax's normative sources). + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples/instagram-uhura") +} + +/// Assembles the pipeline input exactly the way the CLI does, with an +/// optional per-file mutation and an examples on/off switch. +fn corpus_input(include_examples: bool, mutate: &dyn Fn(&str, String) -> String) -> CheckInput { + let root = corpus_root(); + let read = |rel: &str| -> String { + std::fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("read {rel}: {e}")) + }; + + let manifest_text = read("uhura.toml"); + let manifest = load_manifest(&manifest_text).expect("corpus manifest is valid"); + + let catalog_file = ( + manifest.catalog_path.clone(), + Some(mutate(&manifest.catalog_path, read(&manifest.catalog_path))), + ); + let port_files: BTreeMap<_, _> = manifest + .ports + .iter() + .map(|(name, rel)| (name.clone(), (rel.clone(), Some(mutate(rel, read(rel)))))) + .collect(); + + let mut sources = Vec::new(); + for dir in ["app", "components", "surfaces"] { + walk(&root, &root.join(dir), &mut sources); + } + sources.sort_by(|a: &SourceInput, b| a.rel_path.cmp(&b.rel_path)); + let sources = sources + .into_iter() + .filter(|s| include_examples || s.kind == SourceKind::Module) + .map(|s| SourceInput { + text: mutate(&s.rel_path, s.text), + ..s + }) + .collect(); + + let fixture_files = manifest + .fixtures + .iter() + .map(|(name, rel)| { + ( + name.clone(), + ( + rel.clone(), + std::fs::read_to_string(root.join(rel)).ok().map(|t| mutate(rel, t)), + ), + ) + }) + .collect(); + CheckInput { + manifest, + manifest_rel_path: "uhura.toml".into(), + manifest_text, + catalog_file, + port_files, + sources, + theme_css: Some(("styles/theme.css".into(), read("styles/theme.css"))), + fixture_files, + lock_text: std::fs::read_to_string(root.join("uhura.lock")).ok(), + } +} + +fn walk(root: &Path, dir: &Path, out: &mut Vec<SourceInput>) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + walk(root, &path, out); + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if !name.ends_with(".uhura") { + continue; + } + let kind = if name.ends_with(".examples.uhura") { + SourceKind::Examples + } else { + SourceKind::Module + }; + let rel_path = path + .strip_prefix(root) + .expect("under root") + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::<Vec<_>>() + .join("/"); + out.push(SourceInput { + rel_path, + text: std::fs::read_to_string(&path).expect("readable"), + kind, + }); + } +} + diff --git a/uhura/crates/uhura-tests/tests/m2_gates.rs b/uhura/crates/uhura-tests/tests/m2_gates.rs new file mode 100644 index 0000000..43af838 --- /dev/null +++ b/uhura/crates/uhura-tests/tests/m2_gates.rs @@ -0,0 +1,282 @@ +//! The M2 gate (plan §M2): `check` clean over the corpus + deterministic +//! IR golden; every §4.8 rejection as a diagnostics golden; the +//! examples-invariance property (IR bytes identical with and without +//! `*.examples.uhura`); lock drift as a link error. +//! +//! Bless goldens with `UPDATE_GOLDEN=1 cargo test -p uhura-tests`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uhura_base::{Severity, render_text}; +use uhura_check::manifest::load_manifest; +use uhura_check::{CheckInput, CheckOutput, LockStatus, SourceInput, check}; +use uhura_syntax::SourceKind; + +include!("common/corpus.rs"); + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("goldens/m2") +} + +fn check_corpus(mutate: &dyn Fn(&str, String) -> String) -> CheckOutput { + check(&corpus_input(true, mutate)) +} + +fn identity(_: &str, text: String) -> String { + text +} + +fn assert_golden(name: &str, actual: &str) { + let path = golden_dir().join(name); + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_dir()).expect("golden dir"); + std::fs::write(&path, actual).expect("write golden"); + return; + } + let expected = std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("missing golden {name}; bless with UPDATE_GOLDEN=1")); + assert_eq!( + actual, expected, + "golden `{name}` drifted; bless intentionally with UPDATE_GOLDEN=1" + ); +} + +// ── gate 1: clean corpus + deterministic IR golden ───────────────────────── + +#[test] +fn corpus_checks_clean_and_ir_matches_golden() { + let out = check_corpus(&identity); + let rendered = render_text(&out.diagnostics, &out.source_map); + assert!( + out.diagnostics.is_empty(), + "corpus must check clean:\n{rendered}" + ); + let lowered = out.lowered.expect("clean check lowers"); + assert_golden("instagram-ir.json", &lowered.program.to_canonical_string()); + + // Determinism: a second full run produces the same bytes. + let again = check_corpus(&identity); + assert_eq!( + again.lowered.expect("clean").program.to_canonical_string(), + lowered.program.to_canonical_string() + ); +} + +// ── gate 2: examples-invariance (§6.1) ───────────────────────────────────── + +#[test] +fn ir_bytes_identical_with_and_without_examples_files() { + let with = check(&corpus_input(true, &identity)); + let without = check(&corpus_input(false, &identity)); + let with_ir = with.lowered.expect("clean").program.to_canonical_string(); + let without_ir = without + .lowered + .expect("clean") + .program + .to_canonical_string(); + assert_eq!( + with_ir, without_ir, + "examples files are design artifacts — they may never reach the runtime bundle" + ); +} + +// ── gate 3: lock drift is a link error ───────────────────────────────────── + +#[test] +fn lock_drift_is_diagnosed() { + let mut input = corpus_input(true, &identity); + input.lock_text = Some( + input + .lock_text + .expect("corpus lock exists") + .replace("sha256:", "sha256:0"), + ); + let out = check(&input); + assert_eq!(out.lock_status, LockStatus::Drift); + assert!( + out.diagnostics.iter().any(|d| d.code == "UH2007"), + "drift must be an error, never silent (§9.1)" + ); +} + +// ── gate 4: every §4.8 rejection as a diagnostics golden ─────────────────── + +/// One rejection case: a corpus mutation, the code it must raise, and the +/// golden holding the full rendered diagnostics. +struct Rejection { + name: &'static str, + file: &'static str, + from: &'static str, + to: &'static str, + expect_code: &'static str, + expect_severity: Severity, +} + +const REJECTIONS: &[Rejection] = &[ + Rejection { + name: "unknown-element", + file: "components/post-card.uhura", + from: "<image class=\"avatar\" src={post.author.avatar.src} alt={post.author.avatar.alt} />", + to: "<avatar class=\"avatar\" src={post.author.avatar.src} alt={post.author.avatar.alt} />", + expect_code: "UH5001", + expect_severity: Severity::Error, + }, + Rejection { + name: "unkeyed-each", + file: "app/feed/page.uhura", + from: "{#each f.posts as p (p.id)}", + to: "{#each f.posts as p}", + expect_code: "UH0003", + expect_severity: Severity::Error, + }, + Rejection { + name: "event-on-layout", + file: "app/feed/page.uhura", + from: "<view role=\"list\" class=\"post-list\">", + to: "<view role=\"list\" class=\"post-list\" on:press={emit notice-dismissed()}>", + expect_code: "UH5002", + expect_severity: Severity::Error, + }, + Rejection { + name: "undeclared-component-emit", + file: "app/feed/page.uhura", + from: "on:dismissed={emit notice-dismissed()}", + to: "on:closed={emit notice-dismissed()}", + expect_code: "UH5003", + expect_severity: Severity::Error, + }, + Rejection { + name: "unbound-required-prop", + file: "app/feed/page.uhura", + from: "<notice-bar text={notice ?? \"\"} on:dismissed={emit notice-dismissed()} />", + to: "<notice-bar on:dismissed={emit notice-dismissed()} />", + expect_code: "UH5004", + expect_severity: Severity::Error, + }, + Rejection { + name: "unresolved-name-did-you-mean", + file: "app/feed/page.uhura", + from: "on retry-reload-tapped() when !reload-pending {", + to: "on retry-reload-tapped() when !reload-pendin {", + expect_code: "UH3003", + expect_severity: Severity::Error, + }, + Rejection { + name: "unreachable-handler", + file: "app/feed/page.uhura", + from: "on like-toggled(post: id, now-liked: bool) when now-liked && !(like-pending[post] ?? false) {", + to: "on like-toggled(post: id, now-liked: bool) {", + expect_code: "UH4005", + expect_severity: Severity::Error, + }, + Rejection { + name: "unguarded-projection-read", + file: "app/feed/page.uhura", + from: "{#match feed-page}", + to: "{#if feed-page.has-more}\n <text>more soon</text>\n {/if}\n {#match feed-page}", + expect_code: "UH3009", + expect_severity: Severity::Error, + }, + Rejection { + name: "class-rooting-violation", + file: "components/post-card.uhura", + from: ".post-card { display: flex; flex-direction: column; gap: var(--space-2); }", + to: "article { display: flex; flex-direction: column; gap: var(--space-2); }", + expect_code: "UH6001", + expect_severity: Severity::Error, + }, + Rejection { + name: "undefined-class-warning", + file: "app/feed/page.uhura", + from: "<scroll class=\"feed-scroll\" on:near-end={emit feed-near-end()}>", + to: "<scroll class=\"feed-scrolll\" on:near-end={emit feed-near-end()}>", + expect_code: "UH6002", + expect_severity: Severity::Warning, + }, + Rejection { + name: "missing-availability-arm", + file: "app/profile/[user]/page.uhura", + from: "{:when loading}\n <view class=\"fill-center\">\n <text class=\"muted\">Loading profile…</text>\n </view>\n {:when failed reason}", + to: "{:when failed reason}", + expect_code: "UH5015", + expect_severity: Severity::Error, + }, + Rejection { + name: "controlled-promotion", + file: "surfaces/comments-sheet.uhura", + from: "on:change={emit composer-changed()} ", + to: "", + expect_code: "UH5008", + expect_severity: Severity::Error, + }, + Rejection { + name: "nested-interactive", + file: "components/post-card.uhura", + from: "<button label=\"Comments\" on:press={emit comments-requested(post: post.id)}>\n <icon name=\"comment\" />\n </button>", + to: "<button label=\"Comments\" on:press={emit comments-requested(post: post.id)}>\n <button label=\"Inner\" on:press={emit comments-requested(post: post.id)}>\n <icon name=\"comment\" />\n </button>\n </button>", + expect_code: "UH5007", + expect_severity: Severity::Error, + }, + Rejection { + name: "alt-xor-decorative", + file: "components/post-card.uhura", + from: "<image class=\"media\" src={m.image.src} alt={m.image.alt} />", + to: "<image class=\"media\" src={m.image.src} alt={m.image.alt} decorative />", + expect_code: "UH5009", + expect_severity: Severity::Error, + }, + Rejection { + name: "unknown-icon", + file: "components/bottom-nav.uhura", + from: "<icon name=\"reels\" />", + to: "<icon name=\"reel\" />", + expect_code: "UH5017", + expect_severity: Severity::Error, + }, + Rejection { + name: "non-exhaustive-union-match", + file: "components/post-card.uhura", + from: "{:when video v}", + to: "{:when carousel v-two}", + expect_code: "UH5016", + expect_severity: Severity::Error, + }, +]; + +#[test] +fn every_rejection_raises_its_code_and_matches_golden() { + for case in REJECTIONS { + let out = check_corpus(&|rel, text| { + if rel == case.file { + assert!( + text.contains(case.from), + "{}: mutation anchor not found in {}", + case.name, + case.file + ); + text.replace(case.from, case.to) + } else { + text + } + }); + let rendered = render_text(&out.diagnostics, &out.source_map); + assert!( + out.diagnostics + .iter() + .any(|d| d.code == case.expect_code && d.severity == case.expect_severity), + "{}: expected {} ({:?}), got:\n{rendered}", + case.name, + case.expect_code, + case.expect_severity, + ); + if case.expect_severity == Severity::Error { + assert!( + out.lowered.is_none(), + "{}: lowering must be zero-error gated", + case.name + ); + } + assert_golden(&format!("reject-{}.txt", case.name), &rendered); + } +} diff --git a/uhura/crates/uhura-tests/tests/m3_gates.rs b/uhura/crates/uhura-tests/tests/m3_gates.rs new file mode 100644 index 0000000..69c8f57 --- /dev/null +++ b/uhura/crates/uhura-tests/tests/m3_gates.rs @@ -0,0 +1,193 @@ +//! The M3 gate (plan §M3): every pinned example resolves and evaluates to +//! a deterministic V snapshot (per-preview goldens); boot projections +//! auto-bind; `eval_view` emits no commands by construction (its output +//! type has nowhere to put one — the zero-commands property of +//! `uhura project` is structural). Derived examples resolve too since M4; +//! their replay goldens live in `m4_gates`. +//! +//! Bless goldens with `UPDATE_GOLDEN=1 cargo test -p uhura-tests`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uhura_base::render_text; +use uhura_check::manifest::load_manifest; +use uhura_check::preview::PreviewPayload; +use uhura_check::{CheckInput, SourceInput, check}; +use uhura_core::eval::{eval_fragment, eval_view}; +use uhura_core::state::Projections; +use uhura_core::view::{Node, PageView, Snapshot}; +use uhura_syntax::SourceKind; + +include!("common/corpus.rs"); + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("goldens/m3") +} + +fn assert_golden(name: &str, actual: &str) { + let path = golden_dir().join(name); + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_dir()).expect("golden dir"); + std::fs::write(&path, actual).expect("write golden"); + return; + } + let expected = std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("missing golden {name}; bless with UPDATE_GOLDEN=1")); + assert_eq!( + actual, expected, + "golden `{name}` drifted; bless intentionally with UPDATE_GOLDEN=1" + ); +} + +fn identity(_: &str, text: String) -> String { + text +} + +#[test] +fn every_pinned_preview_evaluates_to_its_v_golden() { + let out = check(&corpus_input(true, &identity)); + assert!( + !out.diagnostics + .iter() + .any(|d| d.severity == uhura_base::Severity::Error), + "corpus must check clean:\n{}", + render_text(&out.diagnostics, &out.source_map) + ); + let program = &out.lowered.as_ref().expect("clean check lowers").program; + + let mut pinned = 0usize; + let mut derived = 0usize; + let mut golden_names = Vec::new(); + + for preview in &out.previews { + let subject = preview.subject.name(); + let name = format!("v-{}-{}.json", subject, preview.example); + if preview.derived { + // Replay-resolved — golden-pinned by the M4 gate. + derived += 1; + continue; + } + pinned += 1; + let v_json = match &preview.payload { + PreviewPayload::Page { u, x, .. } => { + let snapshot = eval_view(program, u, x).unwrap_or_else(|e| panic!("{name}: {e}")); + snapshot.to_canonical_string() + } + PreviewPayload::Fragment { + surface, + name: def_name, + props, + state, + x, + } => { + let def = if *surface { + &program.surfaces[def_name] + } else { + &program.components[def_name] + }; + let node = eval_fragment(program, def, props, state, x) + .unwrap_or_else(|e| panic!("{name}: {e}")); + uhura_base::to_canonical_json(&node.to_json()) + } + }; + assert_golden(&name, &v_json); + golden_names.push(name); + } + + // The corpus example sets are fixed (§11.3): 10 page/surface pinned + + // 11 component pinned = 21; 9 resolve by replay (M4). + assert_eq!(pinned, 21, "pinned preview count"); + assert_eq!(derived, 9, "derived preview count"); + + // Determinism: a fresh full run yields byte-identical V for a sample. + let again = check(&corpus_input(true, &identity)); + let first_pinned = |previews: &[uhura_check::preview::ResolvedPreview]| -> String { + previews + .iter() + .filter(|p| !p.derived) + .find_map(|p| match &p.payload { + PreviewPayload::Page { u, x, .. } => Some( + eval_view(program, u, x) + .expect("evaluates") + .to_canonical_string(), + ), + _ => None, + }) + .expect("a pinned page preview exists") + }; + assert_eq!(first_pinned(&out.previews), first_pinned(&again.previews)); +} + +#[test] +fn boot_projections_auto_bind_so_loading_previews_evaluate() { + let out = check(&corpus_input(true, &identity)); + let program = &out.lowered.as_ref().expect("clean").program; + // The feed `loading` example pins nothing — viewer comes from + // `boot.viewer` (§6.1) and the availability match renders its loading + // arm. + let loading = out + .previews + .iter() + .find(|p| p.subject.name().as_str() == "feed" && p.example == "loading") + .expect("feed loading example"); + let PreviewPayload::Page { u, x, .. } = &loading.payload else { + panic!("loading is a page preview"); + }; + assert!( + x.snapshots + .keys() + .any(|(name, _)| name.as_str() == "viewer"), + "viewer auto-bound from boot.viewer" + ); + let snapshot: Snapshot = eval_view(program, u, x).expect("evaluates"); + let PageView { root, .. } = &snapshot.page; + let rendered = snapshot.to_canonical_string(); + assert!( + rendered.contains("Loading your feed…"), + "the loading arm renders" + ); + assert_no_dangling_keys(root, &mut BTreeMap::new()); +} + +/// Keys are sibling-unique everywhere (§8.1 — collisions unrepresentable). +fn assert_no_dangling_keys(node: &Node, _seen: &mut BTreeMap<String, ()>) { + let mut seen = BTreeMap::new(); + for child in &node.children { + assert!( + seen.insert(child.key.clone(), ()).is_none(), + "duplicate sibling key `{}` under `{}`", + child.key, + node.key + ); + assert_no_dangling_keys(child, &mut seen); + } +} + +#[test] +fn fragments_render_prebuilt_descriptor_payloads() { + let out = check(&corpus_input(true, &identity)); + let program = &out.lowered.as_ref().expect("clean").program; + // post-card image-post: the like button's descriptor payload is + // prebuilt with the real post id — never a template (§8.1). + let preview = out + .previews + .iter() + .find(|p| p.subject.name().as_str() == "post-card" && p.example == "image-post") + .expect("post-card image-post"); + let PreviewPayload::Fragment { + props, state, x, .. + } = &preview.payload + else { + panic!("image-post is a fragment preview"); + }; + let def = &program.components[&uhura_base::Ident::new("post-card").unwrap()]; + let node = eval_fragment(program, def, props, state, x).expect("evaluates"); + let rendered = uhura_base::to_canonical_json(&node.to_json()); + assert!( + rendered.contains(r#""payload":{"now-liked":true,"post":"post-lena-glaze"}"#), + "prebuilt payload with the concrete post id:\n{rendered}" + ); + let empty = Projections::default(); + let _ = empty; // fragments carry their own X; nothing global leaks in +} diff --git a/uhura/crates/uhura-tests/tests/m4_gates.rs b/uhura/crates/uhura-tests/tests/m4_gates.rs new file mode 100644 index 0000000..5e8c406 --- /dev/null +++ b/uhura/crates/uhura-tests/tests/m4_gates.rs @@ -0,0 +1,443 @@ +//! The M4 gate (plan §M4): golden JSONL traces for the six CI scripts +//! (§11.4) through the REAL harness (`uhura_cli::cmd::trace::run_script` — +//! the exact code the binary runs), structural asserts over those traces, +//! replay-derived preview V goldens, a demo smoke run, and the +//! self-verifying-design check: a guard edit in the feed page fails a +//! derived example. +//! +//! Bless goldens with `UPDATE_GOLDEN=1 cargo test -p uhura-tests`. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uhura_base::render_text; +use uhura_check::manifest::load_manifest; +use uhura_check::preview::PreviewPayload; +use uhura_check::{CheckInput, SourceInput, check}; +use uhura_cli::cmd::trace::run_script; +use uhura_core::eval::{eval_fragment, eval_view}; +use uhura_core::ir::ProgramIr; +use uhura_syntax::SourceKind; + +include!("common/corpus.rs"); + +fn golden_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("goldens/m4") +} + +fn assert_golden(name: &str, actual: &str) { + let path = golden_dir().join(name); + if std::env::var("UPDATE_GOLDEN").is_ok() { + std::fs::create_dir_all(golden_dir()).expect("golden dir"); + std::fs::write(&path, actual).expect("write golden"); + return; + } + let expected = std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("missing golden {name}; bless with UPDATE_GOLDEN=1")); + assert_eq!( + actual, expected, + "golden `{name}` drifted; bless intentionally with UPDATE_GOLDEN=1" + ); +} + +fn identity(_: &str, text: String) -> String { + text +} + +fn checked_program() -> ProgramIr { + let out = check(&corpus_input(true, &identity)); + assert!( + !out.diagnostics + .iter() + .any(|d| d.severity == uhura_base::Severity::Error), + "corpus must check clean:\n{}", + render_text(&out.diagnostics, &out.source_map) + ); + out.lowered.expect("clean check lowers").program +} + +fn read_corpus(rel: &str) -> String { + std::fs::read_to_string(corpus_root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +/// Runs a script through the CLI harness; `expanded` embeds full V per +/// step (used by structural asserts, never goldened). +fn trace(program: &ProgramIr, script: &str, expanded: bool) -> Vec<serde_json::Value> { + let fixture = read_corpus("fixtures/standard.toml"); + let script_text = read_corpus(&format!("fixtures/scripts/{script}.toml")); + run_script(program, &fixture, &script_text, expanded) + .unwrap_or_else(|e| panic!("{script}: {e}")) + .iter() + .map(|line| serde_json::from_str(line).expect("trace lines are JSON")) + .collect() +} + +// ── golden traces (the conformance artifact, §7.5) ────────────────────────── + +#[test] +fn the_six_ci_scripts_trace_to_their_goldens() { + let program = checked_program(); + for script in [ + "like-ok", + "like-refused", + "comment-ok", + "paginate", + "feed-failed", + "feed-empty", + ] { + let fixture = read_corpus("fixtures/standard.toml"); + let script_text = read_corpus(&format!("fixtures/scripts/{script}.toml")); + let lines = run_script(&program, &fixture, &script_text, false) + .unwrap_or_else(|e| panic!("{script}: {e}")); + let mut jsonl = lines.join("\n"); + jsonl.push('\n'); + assert_golden(&format!("trace-{script}.jsonl"), &jsonl); + + // Byte-determinism: the same fold twice. + let again = run_script(&program, &fixture, &script_text, false).expect("reruns"); + assert_eq!(lines, again, "{script}: traces are byte-deterministic"); + } +} + +// ── structural asserts (§11.4 / §13) ──────────────────────────────────────── + +fn commands<'a>(steps: &'a [serde_json::Value], name: &str) -> Vec<&'a serde_json::Value> { + steps + .iter() + .flat_map(|s| s["c"].as_array().into_iter().flatten()) + .filter(|c| c["command"] == name) + .collect() +} + +fn step_index(steps: &[serde_json::Value], pred: impl Fn(&serde_json::Value) -> bool) -> usize { + steps.iter().position(pred).expect("a matching step exists") +} + +/// Collects every node depth-first from an expanded-V subtree. +fn walk_nodes<'a>(node: &'a serde_json::Value, out: &mut Vec<&'a serde_json::Value>) { + out.push(node); + for child in node["children"].as_array().into_iter().flatten() { + walk_nodes(child, out); + } +} + +fn find_text(node: &serde_json::Value, needle: &str) -> bool { + let mut nodes = Vec::new(); + walk_nodes(node, &mut nodes); + nodes.iter().any(|n| { + n["props"]["content"]["v"] + .as_str() + .is_some_and(|s| s.contains(needle)) + }) +} + +#[test] +fn like_ok_sends_exactly_one_command_and_optimism_precedes_settlement() { + let program = checked_program(); + let steps = trace(&program, "like-ok", false); + assert_eq!( + commands(&steps, "like-post").len(), + 1, + "one command per press" + ); + + let press = step_index(&steps, |s| { + s["event"]["descriptor"]["emit"] == "like-toggled" + }); + let outcome = step_index(&steps, |s| s["event"]["kind"] == "outcome"); + assert!(press < outcome, "the optimistic step precedes settlement"); + let writes = steps[press]["dispatch"]["writes"] + .as_array() + .expect("writes"); + assert!( + writes.iter().any(|w| w["field"] == "like-overlay"), + "the press writes the optimistic overlay" + ); +} + +#[test] +fn like_refused_rolls_back_to_a_byte_identical_feed_subtree() { + let program = checked_program(); + let steps = trace(&program, "like-refused", true); + + // Pre-like: the step where the feed page settled (last before the + // press). Post: after the notice dismisses. + let press = step_index(&steps, |s| { + s["event"]["descriptor"]["emit"] == "like-toggled" + }); + let pre_root = &steps[press - 1]["v"]["page"]["root"]; + let dismissed = step_index(&steps, |s| { + s["event"]["descriptor"]["emit"] == "notice-dismissed" + }); + let post_root = &steps[dismissed]["v"]["page"]["root"]; + assert!( + find_text( + &steps[dismissed - 1]["v"]["page"]["root"], + "Couldn't like this post" + ), + "the notice explains before dismissal" + ); + assert_eq!( + uhura_base::to_canonical_json(pre_root), + uhura_base::to_canonical_json(post_root), + "after dismissing the notice, the feed subtree is byte-identical \ + to pre-like (§11.4 step 3)" + ); +} + +#[test] +fn comment_ok_swaps_the_optimistic_row_atomically_and_restores_focus() { + let program = checked_program(); + let steps = trace(&program, "comment-ok", true); + + // After submit: a dimmed pending row (the fixture author renders + // `Posting…`), the composer cleared. + let submit = step_index(&steps, |s| { + s["event"]["descriptor"]["emit"] == "submit-requested" + }); + let sheet = &steps[submit]["v"]["surfaces"][0]["root"]; + assert!(find_text(sheet, "Posting…"), "optimistic row present"); + + // After the ok settles (piggybacked update applied first): exactly + // five comments, no pending row, the echoed body authoritative. + let outcome = step_index(&steps, |s| s["event"]["kind"] == "outcome"); + assert!(outcome > submit); + let sheet = &steps[outcome]["v"]["surfaces"][0]["root"]; + assert!(!find_text(sheet, "Posting…"), "pending row settled away"); + assert!( + find_text(sheet, "Saving this palette for my kitchen reno"), + "the authoritative comment echoes the typed body" + ); + let mut nodes = Vec::new(); + walk_nodes(sheet, &mut nodes); + let comment_rows = nodes + .iter() + .filter(|n| { + n["class"] + .as_str() + .is_some_and(|c| c.contains("comment-row")) + }) + .count(); + assert_eq!(comment_rows, 5, "four authored comments plus Mira's"); + + // Dismissal restores focus to the opener's triggering node. + let dismissal = step_index(&steps, |s| { + s["event"]["descriptor"]["emit"] == "dismiss-requested" + }); + let intents = steps[dismissal]["i"].as_array().expect("intents"); + let focus = intents + .iter() + .find(|i| i["intent"] == "focus-restore") + .expect("FocusRestore intent on topmost dismissal"); + assert!( + focus["key-path"] + .as_str() + .is_some_and(|p| p.starts_with("page:1/")), + "the key-path points into the page: {focus}" + ); +} + +#[test] +fn paginate_dedupes_via_the_guard_and_appends_preserving_keys() { + let program = checked_program(); + let steps = trace(&program, "paginate", true); + assert_eq!( + commands(&steps, "load-next-page").len(), + 1, + "wiggle-scroll re-observation must not send a second command" + ); + // The second near-end drops with the guard traced unsatisfied. + let drops: Vec<_> = steps.iter().filter(|s| s["drop"] == "no-handler").collect(); + assert_eq!(drops.len(), 1, "the wiggle drops, traced"); + + // Keys before the append are a strict prefix of keys after. + let post_keys = |root: &serde_json::Value| -> Vec<String> { + let mut nodes = Vec::new(); + walk_nodes(root, &mut nodes); + nodes + .iter() + .filter(|n| n["class"].as_str().is_some_and(|c| c.contains("post-card"))) + .filter_map(|n| n["key"].as_str().map(ToString::to_string)) + .collect() + }; + let ready = step_index(&steps, |s| s["event"]["kind"] == "projection"); + let before = post_keys(&steps[ready]["v"]["page"]["root"]); + let after = post_keys(&steps[steps.len() - 1]["v"]["page"]["root"]); + assert_eq!(before.len(), 4); + assert_eq!(after.len(), 8); + assert_eq!( + &after[..4], + &before[..], + "the append preserves existing keys as a prefix (identity, §8.1)" + ); +} + +#[test] +fn feed_failed_renders_the_failed_arm_then_recovers() { + let program = checked_program(); + let steps = trace(&program, "feed-failed", true); + let failed = step_index(&steps, |s| s["event"]["kind"] == "projection-failed"); + assert!( + find_text( + &steps[failed]["v"]["page"]["root"], + "Your feed didn't load." + ), + "the failed arm renders" + ); + assert_eq!(commands(&steps, "reload").len(), 1); + let last = &steps[steps.len() - 1]["v"]["page"]["root"]; + assert!( + !find_text(last, "Your feed didn't load."), + "the retry recovers to the ready arm" + ); +} + +#[test] +fn feed_empty_renders_the_empty_state_with_nothing_to_observe() { + let program = checked_program(); + let steps = trace(&program, "feed-empty", true); + let last = &steps[steps.len() - 1]["v"]["page"]["root"]; + assert!(find_text(last, "Follow people to fill your feed.")); + let mut nodes = Vec::new(); + walk_nodes(last, &mut nodes); + let near_end = nodes.iter().any(|n| { + n["on"] + .as_array() + .into_iter() + .flatten() + .any(|d| d["emit"] == "feed-near-end") + }); + assert!(!near_end, "an empty feed subscribes to nothing (§8.1)"); +} + +#[test] +fn the_demo_walkthrough_runs_headlessly_without_drops() { + let program = checked_program(); + let steps = trace(&program, "demo", false); + assert!(steps.len() >= 15, "the full walkthrough steps"); + assert!( + steps.iter().all(|s| s.get("drop").is_none()), + "the demo drops nothing" + ); + // It ends back on the feed with history intents traced. + let intents: Vec<_> = steps + .iter() + .flat_map(|s| s["i"].as_array().into_iter().flatten()) + .map(|i| i["intent"].as_str().unwrap_or_default().to_string()) + .collect(); + assert_eq!( + intents, + vec!["focus-restore", "history-push", "history-back"], + "dismiss, navigate, back — in walkthrough order" + ); +} + +// ── replay-derived preview goldens (§6.2) ─────────────────────────────────── + +#[test] +fn every_derived_preview_replays_to_its_v_golden() { + let out = check(&corpus_input(true, &identity)); + assert!( + !out.diagnostics + .iter() + .any(|d| d.severity == uhura_base::Severity::Error), + "corpus must check clean:\n{}", + render_text(&out.diagnostics, &out.source_map) + ); + let program = &out.lowered.as_ref().expect("clean").program; + + let mut derived = 0usize; + for preview in &out.previews { + if !preview.derived { + continue; + } + derived += 1; + let name = format!("v-{}-{}.json", preview.subject.name(), preview.example); + let v_json = match &preview.payload { + PreviewPayload::Page { u, x, .. } => eval_view(program, u, x) + .unwrap_or_else(|e| panic!("{name}: {e}")) + .to_canonical_string(), + PreviewPayload::Fragment { + surface, + name: def_name, + props, + state, + x, + } => { + let def = if *surface { + &program.surfaces[def_name] + } else { + &program.components[def_name] + }; + let node = eval_fragment(program, def, props, state, x) + .unwrap_or_else(|e| panic!("{name}: {e}")); + uhura_base::to_canonical_json(&node.to_json()) + } + }; + assert_golden(&name, &v_json); + } + assert_eq!(derived, 9, "nine derived examples replay"); + + // The optimistic like really is in the derived like-pending state. + let like_pending = out + .previews + .iter() + .find(|p| p.subject.name().as_str() == "feed" && p.example == "like-pending") + .expect("feed like-pending"); + assert_eq!(like_pending.in_flight, 1, "one command in flight"); + + // comments-open mounted the sheet through the machine. + let comments_open = out + .previews + .iter() + .find(|p| p.subject.name().as_str() == "feed" && p.example == "comments-open") + .expect("feed comments-open"); + let PreviewPayload::Page { u, .. } = &comments_open.payload else { + panic!("page preview"); + }; + assert_eq!( + u.surfaces.len(), + 1, + "the sheet mounts because the machine mounted it" + ); + assert!( + u.surfaces[0].restore_focus.is_some(), + "the opener's trigger node is recorded" + ); +} + +// ── self-verifying design (§6.2) ──────────────────────────────────────────── + +#[test] +fn a_guard_change_in_the_feed_page_fails_a_derived_example() { + let out = check(&corpus_input(true, &|rel, text| { + if rel == "app/feed/page.uhura" { + // Invert the like guard: the recorded `like-toggled` event no + // longer finds a satisfied handler. + text.replace( + "on like-toggled(post: id, now-liked: bool) when now-liked && !(like-pending[post] ?? false) {", + "on like-toggled(post: id, now-liked: bool) when !now-liked && !(like-pending[post] ?? false) {", + ) + } else { + text + } + })); + let errors: Vec<String> = out + .diagnostics + .iter() + .filter(|d| d.severity == uhura_base::Severity::Error) + .map(|d| format!("{}: {}", d.code, d.message)) + .collect(); + assert!( + errors + .iter() + .any(|e| e.contains("UH7012") && e.contains("like-pending")), + "the derived example fails the check: {errors:?}" + ); + assert!( + errors + .iter() + .any(|e| e.contains("UH7013") && e.contains("like-refused")), + "its descendant reports blocked-by-ancestor: {errors:?}" + ); +} diff --git a/uhura/crates/uhura-tests/tests/purity.rs b/uhura/crates/uhura-tests/tests/purity.rs new file mode 100644 index 0000000..1c45b27 --- /dev/null +++ b/uhura/crates/uhura-tests/tests/purity.rs @@ -0,0 +1,184 @@ +//! Purity is a failing test, not a convention (design §12.1, §9.6). +//! +//! Asserts, via `cargo metadata`, that: +//! 1. uhura-core's transitive normal-dependency closure is exactly +//! {uhura-base, uhura-port} — the foundation and the seam, nothing else; +//! 2. uhura-core can never reach uhura-fixture (Spock-replaceability: the +//! core has no provider-identity input); +//! 3. no I/O-capable external crate leaks into the closures of the pure +//! crates (core, check, view, ir, value, diag, port, syntax). + +use std::collections::{BTreeMap, BTreeSet}; + +use cargo_metadata::{DependencyKind, Metadata, MetadataCommand}; + +fn workspace_metadata() -> Metadata { + let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.toml"); + MetadataCommand::new() + .manifest_path(manifest) + .exec() + .expect("cargo metadata") +} + +/// Transitive closure of normal (non-dev, non-build) dependencies of `root`, +/// as package names. Excludes `root` itself. +fn normal_dep_closure(meta: &Metadata, root: &str) -> BTreeSet<String> { + let resolve = meta.resolve.as_ref().expect("resolve graph"); + let name_of: BTreeMap<_, _> = meta + .packages + .iter() + .map(|p| (p.id.clone(), p.name.clone())) + .collect(); + let nodes: BTreeMap<_, _> = resolve.nodes.iter().map(|n| (n.id.clone(), n)).collect(); + + let root_id = meta + .packages + .iter() + .find(|p| p.name == root) + .unwrap_or_else(|| panic!("package {root} not found")) + .id + .clone(); + + let mut seen = BTreeSet::new(); + let mut stack = vec![root_id]; + while let Some(id) = stack.pop() { + let Some(node) = nodes.get(&id) else { continue }; + for dep in &node.deps { + let normal = dep.dep_kinds.is_empty() + || dep + .dep_kinds + .iter() + .any(|k| k.kind == DependencyKind::Normal); + if !normal { + continue; + } + let name = name_of[&dep.pkg].clone(); + if seen.insert(name) { + stack.push(dep.pkg.clone()); + } + } + } + seen +} + +fn workspace_names(meta: &Metadata) -> BTreeSet<String> { + let members: BTreeSet<_> = meta.workspace_members.iter().cloned().collect(); + meta.packages + .iter() + .filter(|p| members.contains(&p.id)) + .map(|p| p.name.clone()) + .collect() +} + +/// External crates that must never appear in a pure crate's closure. This is +/// the meaningful boundary: filesystem/network/clock/UI capability. +const DENIED_EXTERNALS: &[&str] = &[ + "tokio", + "async-std", + "notify", + "tiny_http", + "hyper", + "reqwest", + "clap", + "wasm-bindgen", + "image", + "rand", + "chrono", + "time", + "ureq", + "mio", +]; + +#[test] +fn core_closure_is_exactly_the_seam() { + let meta = workspace_metadata(); + let ws = workspace_names(&meta); + let closure = normal_dep_closure(&meta, "uhura-core"); + + let ws_in_closure: BTreeSet<_> = closure + .iter() + .filter(|n| ws.contains(*n)) + .cloned() + .collect(); + let expected: BTreeSet<String> = ["uhura-base", "uhura-port"] + .into_iter() + .map(String::from) + .collect(); + + assert_eq!( + ws_in_closure, expected, + "uhura-core's workspace dependency closure changed — the design \ + (§12.1) fixes it to exactly {{base, port}}" + ); + assert!( + !closure.contains("uhura-fixture"), + "uhura-core must never reach uhura-fixture (§9.6 Spock-replaceability)" + ); +} + +/// The port crate's `toml` feature must be OFF on core's edge (§12.1). +/// +/// `cargo metadata`'s resolve graph is workspace-feature-unified (uhura-check +/// legitimately enables uhura-port/toml), so it cannot answer this question. +/// `cargo tree -p uhura-core` resolves features for core's build alone. +#[test] +fn core_build_alone_never_compiles_toml() { + let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/../../Cargo.toml"); + let out = std::process::Command::new(env!("CARGO")) + .args([ + "tree", + "-p", + "uhura-core", + "-e", + "normal", + "--prefix", + "none", + ]) + .arg("--manifest-path") + .arg(manifest) + .output() + .expect("cargo tree"); + assert!(out.status.success(), "cargo tree failed"); + let tree = String::from_utf8_lossy(&out.stdout); + assert!( + !tree + .lines() + .any(|l| l.starts_with("toml ") || l.starts_with("toml_")), + "toml is compiled into a core-only build — uhura-port's toml feature \ + must stay off on the core edge (§12.1)\n{tree}" + ); +} + +#[test] +fn pure_crates_reach_no_io_capable_externals() { + let meta = workspace_metadata(); + for krate in [ + "uhura-core", + "uhura-check", + "uhura-base", + "uhura-port", + "uhura-syntax", + "uhura-fixture", + "uhura-project", + ] { + let closure = normal_dep_closure(&meta, krate); + for denied in DENIED_EXTERNALS { + assert!( + !closure.contains(*denied), + "{krate} transitively depends on {denied} — pure crates may \ + not reach I/O-capable externals (§12.1)" + ); + } + } +} + +#[test] +fn check_never_reaches_the_fixture_driver() { + let meta = workspace_metadata(); + let closure = normal_dep_closure(&meta, "uhura-check"); + assert!( + !closure.contains("uhura-fixture"), + "uhura-check reads fixture DATA (its own parser) but must never link \ + the fixture DRIVER — example replay folds public step_u only (§6.2)" + ); +} diff --git a/uhura/crates/uhura-wasm/Cargo.toml b/uhura/crates/uhura-wasm/Cargo.toml new file mode 100644 index 0000000..98ebed9 --- /dev/null +++ b/uhura/crates/uhura-wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "uhura-wasm" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +uhura-base = { workspace = true } +uhura-core = { workspace = true } +uhura-fixture = { workspace = true } +uhura-port = { workspace = true } +wasm-bindgen = { workspace = true } +serde_json = { workspace = true } + +# The ABI contract test replays the corpus scripts through `Session` + +# `FixtureDriver` natively (the crate is also an rlib) and asserts byte +# equality against the native trace harness. +[dev-dependencies] +uhura-check = { workspace = true } +uhura-cli = { workspace = true } diff --git a/uhura/crates/uhura-wasm/src/lib.rs b/uhura/crates/uhura-wasm/src/lib.rs new file mode 100644 index 0000000..1c24891 --- /dev/null +++ b/uhura/crates/uhura-wasm/src/lib.rs @@ -0,0 +1,191 @@ +//! uhura-wasm: thin wasm-bindgen wrappers — `Session` over uhura-core and +//! `FixtureDriver` over uhura-fixture. JSON strings across the boundary; +//! no timers, fetch, or DOM inside wasm (design §12.3). The envelope +//! shapes are frozen by `tests/abi_contract.rs` (plan micro-decision #14): +//! the same bytes the native trace harness prints. Errors cross the +//! boundary as thrown STRINGS (`Result<_, String>` — constructing a +//! `JsError` calls a JS import, which would poison the native rlib the +//! contract test runs against). + +use wasm_bindgen::prelude::*; + +use uhura_base::to_canonical_json; +use uhura_core::event::{ApplyNote, Event, apply_failure, apply_updates}; +use uhura_core::ir::{IR_PROTOCOL, ProgramIr, load_program}; +use uhura_core::state::{Projections, UiState}; +use uhura_core::step::step_u; +use uhura_core::view::{Snapshot, VIEW_PROTOCOL}; +use uhura_port::envelope::{PROVIDER_PROTOCOL, ProjectionUpdate}; + +/// The three protocol versions this build speaks, as one JSON object — +/// the shell hard-asserts all of them at boot (§12.3). +#[wasm_bindgen] +pub fn protocols() -> String { + to_canonical_json(&serde_json::json!({ + "ir": IR_PROTOCOL, + "view": VIEW_PROTOCOL, + "provider": PROVIDER_PROTOCOL, + })) +} + +/// One machine: owns `U` and `X`, steps on dispatched events. The §7.2 +/// ordering contract lives INSIDE `dispatch`: provider updates carried by +/// an event apply to `X` (revision-checked) before `step_u` runs — the +/// shell never touches the projection store. +#[wasm_bindgen] +pub struct Session { + program: ProgramIr, + u: UiState, + x: Projections, + v: Option<Snapshot>, + /// Boot apply notes attach to the next step's trace record, mirroring + /// the native harness (boot deliveries precede `Init` — §9.2). + pending_applies: Vec<ApplyNote>, +} + +#[wasm_bindgen] +impl Session { + /// `ir_json` is the canonical `uhura-ir/0` artifact (`/ir.json` from + /// `uhura dev`); anything else is refused before deserialization. + #[wasm_bindgen(constructor)] + pub fn new(ir_json: &str) -> Result<Session, String> { + let program = load_program(ir_json)?; + Ok(Session { + program, + u: UiState::boot(), + x: Projections::default(), + v: None, + pending_applies: Vec::new(), + }) + } + + /// Applies the boot deliveries (`{"updates": […]}` — revision-1 + /// projection updates, §9.2) before the first `Init` dispatch. + pub fn boot(&mut self, boot_json: &str) -> Result<(), String> { + let json: serde_json::Value = + serde_json::from_str(boot_json).map_err(|e| format!("boot: {e}"))?; + let updates = match json.get("updates") { + Some(serde_json::Value::Array(items)) => items + .iter() + .map(ProjectionUpdate::from_json) + .collect::<Result<Vec<_>, _>>() + .map_err(|e| format!("boot: {e}"))?, + _ => return Err("boot needs an `updates` list".to_string()), + }; + let notes = apply_updates(&self.program, &mut self.x, &updates) + .map_err(|e| format!("boot: {e}"))?; + self.pending_applies.extend(notes); + Ok(()) + } + + /// One step: parse the event, apply carried provider updates to `X` + /// (§7.2/§9.4 ordering), run `step_u`, return the frozen step-result + /// envelope (`{"c": …, "g": …, "i": …, "t": …, "v": …}`) as canonical + /// JSON. + /// + /// TRANSACTIONAL against the session (§4.2's discipline extended to + /// the wrapper): everything runs against staged copies and commits + /// only on success, so a thrown string means the input was refused + /// and the Session still holds exactly what it held before — the + /// shell keeps pumping into a consistent machine (decision #59). + pub fn dispatch(&mut self, event_json: &str) -> Result<String, String> { + let json: serde_json::Value = + serde_json::from_str(event_json).map_err(|e| format!("event: {e}"))?; + let event = Event::from_json(&json)?; + + let mut x = self.x.clone(); + let mut applies = self.pending_applies.clone(); + match &event { + Event::Outcome { updates, .. } | Event::Projection { updates } => { + // Piggybacked settlement updates land BEFORE the outcome + // dispatches — flicker-free by construction (§9.4). A + // mid-batch failure aborts whole: X never half-applies. + let notes = apply_updates(&self.program, &mut x, updates)?; + applies.extend(notes); + } + Event::ProjectionFailed { + port, + projection, + key, + reason, + } => { + let note = apply_failure( + &self.program, + &mut x, + port, + projection, + key.as_ref(), + reason, + )?; + applies.push(note); + } + Event::Ui { .. } | Event::Init { .. } => {} + } + + let mut result = + step_u(&self.program, self.u.clone(), &x, event).map_err(|e| e.to_string())?; + result.t.applies = applies.iter().map(ApplyNote::to_json).collect(); + let out = to_canonical_json(&result.to_json()); + // ── commit ────────────────────────────────────────────────────── + self.pending_applies.clear(); + self.x = x; + self.u = result.u; + self.v = Some(result.v); + Ok(out) + } + + /// The current `uhura-view/0` snapshot, canonical JSON. There is no + /// view before the first dispatch (`Init` mounts the entry page). + pub fn view(&self) -> Result<String, String> { + match &self.v { + Some(v) => Ok(v.to_canonical_string()), + None => Err("no view before the first dispatch (§9.2)".to_string()), + } + } + + /// The machine revision (`U.rev` — `+1` every step). Lossless as a JS + /// number for any session a browser can hold. + pub fn revision(&self) -> f64 { + self.u.rev as f64 + } + + /// The IR protocol this session loaded (`"uhura-ir/0"`). + pub fn ir_version(&self) -> String { + self.program.protocol.clone() + } +} + +/// The scripted provider (§9.5), wrapped 1:1 — the shell wires `Session` +/// and `FixtureDriver` together by passing envelope JSON, so the Spock +/// seam stays visible in the browser too (§9.6). +#[wasm_bindgen(js_name = FixtureDriver)] +pub struct FixtureDriverJs { + inner: uhura_fixture::FixtureDriver, +} + +#[wasm_bindgen(js_class = FixtureDriver)] +impl FixtureDriverJs { + /// `fixture_json`: the resolved slice tree (`/fixture.json`); + /// `script_json`: the closed script grammar as JSON (`/script.json`). + #[wasm_bindgen(constructor)] + pub fn new(fixture_json: &str, script_json: &str) -> Result<FixtureDriverJs, String> { + uhura_fixture::FixtureDriver::new(fixture_json, script_json) + .map(|inner| FixtureDriverJs { inner }) + } + + /// Accepts one command envelope (wire form, `kind: "command"`). + pub fn deliver(&mut self, cmd_json: &str) -> Result<(), String> { + self.inner.deliver(cmd_json) + } + + /// Advances one tick and returns the provider messages due — the + /// shell maps wall time to tick ordinals (§8.4). + pub fn tick(&mut self) -> Vec<String> { + self.inner.tick() + } + + /// True when nothing is scheduled or in flight. + pub fn idle(&self) -> bool { + self.inner.idle() + } +} diff --git a/uhura/crates/uhura-wasm/tests/abi_contract.rs b/uhura/crates/uhura-wasm/tests/abi_contract.rs new file mode 100644 index 0000000..258f52a --- /dev/null +++ b/uhura/crates/uhura-wasm/tests/abi_contract.rs @@ -0,0 +1,425 @@ +//! The frozen ABI contract (design §12.3, plan micro-decision #14). +//! +//! Two layers: +//! 1. **Shape goldens** — the hand-written JSON forms of events, the +//! step-result envelope, and the protocol triple, pinned as literal +//! strings. `shell/types.js` mirrors these typedefs; a change here is +//! a protocol version bump, not an edit. +//! 2. **Pump parity** — every canonical corpus script replayed through +//! `Session` + `FixtureDriver` (natively — the crate is an rlib too) +//! against the native trace harness, asserting BYTE-equal trace lines. +//! This is the native half of the M6 wasm parity gate: the wasm build +//! wraps exactly this code, so what's proven here is the semantics; +//! `scripts/parity.mjs` proves the compiled artifact. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use uhura_base::{Ident, Severity, Value, to_canonical_json}; +use uhura_check::fixture::load_fixture; +use uhura_cli::cmd::trace::{boot_updates, fixture_slices_json, run_script}; +use uhura_core::event::Event; +use uhura_core::view::{Descriptor, DescriptorKind}; +use uhura_port::envelope::{OutcomeResult, ProjectionUpdate}; +use uhura_wasm::{FixtureDriverJs, Session, protocols}; + +fn ident(s: &str) -> Ident { + Ident::new(s).unwrap() +} + +// ── layer 1: shape goldens ────────────────────────────────────────────────── + +#[test] +fn protocol_triple_is_pinned() { + assert_eq!( + protocols(), + r#"{"ir":"uhura-ir/0","provider":"uhura-provider/0","view":"uhura-view/0"}"# + ); +} + +#[test] +fn event_wire_shapes_are_pinned_and_round_trip() { + let descriptor = Descriptor { + kind: DescriptorKind::Input, + event: ident("press"), + emit: ident("like-toggled"), + scope: "page:1".into(), + payload: serde_json::json!({ "post": "post-lena-glaze" }), + carries: BTreeMap::new(), + }; + let ui = Event::Ui { + descriptor, + data: None, + view_rev: 4, + }; + assert_eq!( + to_canonical_json(&ui.to_json()), + r#"{"descriptor":{"emit":"like-toggled","event":"press","kind":"input","payload":{"post":"post-lena-glaze"},"scope":"page:1"},"kind":"ui","view-rev":4}"# + ); + + let mut carries = BTreeMap::new(); + carries.insert(ident("value"), "text".to_string()); + let mut data = BTreeMap::new(); + data.insert(ident("value"), Value::Text("Saving this recipe".into())); + let ui_carried = Event::Ui { + descriptor: Descriptor { + kind: DescriptorKind::Input, + event: ident("change"), + emit: ident("draft-changed"), + scope: "surface:1".into(), + payload: serde_json::json!({}), + carries, + }, + data: Some(Value::Record(data)), + view_rev: 9, + }; + assert_eq!( + to_canonical_json(&ui_carried.to_json()), + r#"{"data":{"value":"Saving this recipe"},"descriptor":{"carries":{"value":"text"},"emit":"draft-changed","event":"change","kind":"input","payload":{},"scope":"surface:1"},"kind":"ui","view-rev":9}"# + ); + + let outcome = Event::Outcome { + correlation: "c-1".into(), + result: OutcomeResult::Refused { + refusal: ident("rate-limited"), + }, + updates: vec![ProjectionUpdate { + port: ident("feed"), + projection: ident("feed-page"), + key: None, + revision: 3, + value: serde_json::json!({ "has-more": false }), + }], + }; + assert_eq!( + to_canonical_json(&outcome.to_json()), + r#"{"correlation":"c-1","kind":"outcome","outcome":{"refused":{"refusal":"rate-limited"}},"updates":[{"key":null,"port":"feed","projection":"feed-page","revision":3,"value":{"has-more":false}}]}"# + ); + + let failed = Event::ProjectionFailed { + port: ident("feed"), + projection: ident("feed-page"), + key: None, + reason: "unreachable".into(), + }; + assert_eq!( + to_canonical_json(&failed.to_json()), + r#"{"key":null,"kind":"projection-failed","port":"feed","projection":"feed-page","reason":"unreachable"}"# + ); + + let mut params = BTreeMap::new(); + params.insert(ident("user"), Value::Id("user-lena".into())); + let init = Event::Init { + route: ident("profile"), + params, + }; + assert_eq!( + to_canonical_json(&init.to_json()), + r#"{"kind":"init","params":{"user":"user-lena"},"route":"profile"}"# + ); + + // Every variant round-trips through the wire form. + for event in [ + ui, + ui_carried, + outcome, + Event::Projection { + updates: vec![ProjectionUpdate { + port: ident("comments"), + projection: ident("for-post"), + key: Some(serde_json::json!("post-lena-glaze")), + revision: 2, + value: serde_json::json!({ "comments": [] }), + }], + }, + failed, + init, + ] { + let json = event.to_json(); + assert_eq!(Event::from_json(&json).unwrap(), event, "{json}"); + } +} + +#[test] +fn wire_init_params_decode_strings_as_ids() { + // Micro-decision #56: route params are entity references (§3). + let event = Event::from_json(&serde_json::json!({ + "kind": "init", + "route": "profile", + "params": { "user": "user-lena" }, + })) + .unwrap(); + let Event::Init { params, .. } = event else { + panic!("parsed a non-init event"); + }; + assert_eq!(params[&ident("user")], Value::Id("user-lena".into())); +} + +#[test] +fn carried_data_is_typed_by_the_descriptor() { + // A field the descriptor does not declare is refused — the renderer + // trust boundary is exactly the declared carries (§4.2). + let err = Event::from_json(&serde_json::json!({ + "kind": "ui", + "descriptor": { + "kind": "input", "event": "press", "emit": "like-toggled", + "scope": "page:1", "payload": {}, + }, + "data": { "value": "sneaky" }, + "view-rev": 1, + })) + .unwrap_err(); + assert!(err.contains("does not carry"), "{err}"); +} + +// ── layer 2: pump parity against the native trace harness ────────────────── + +fn corpus_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples/instagram-uhura") +} + +fn checked_program() -> uhura_core::ir::ProgramIr { + let input = uhura_cli::cmd::assemble_input(&corpus_root()).expect("corpus reads"); + let output = uhura_check::check(&input); + assert!( + !output + .diagnostics + .iter() + .any(|d| d.severity == Severity::Error), + "the corpus must check clean" + ); + output.lowered.expect("lowered program").program +} + +struct Stimulus { + at_tick: u64, + emit: String, + where_: serde_json::Map<String, serde_json::Value>, + data: serde_json::Map<String, serde_json::Value>, +} + +fn parse_stimuli(script: &serde_json::Value) -> Vec<Stimulus> { + let Some(entries) = script.get("ui").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + entries + .iter() + .map(|entry| Stimulus { + at_tick: entry + .get("at-tick") + .and_then(serde_json::Value::as_u64) + .unwrap(), + emit: entry + .get("emit") + .and_then(serde_json::Value::as_str) + .unwrap() + .to_string(), + where_: match entry.get("where") { + Some(serde_json::Value::Object(m)) => m.clone(), + _ => serde_json::Map::new(), + }, + data: match entry.get("data") { + Some(serde_json::Value::Object(m)) => m.clone(), + _ => serde_json::Map::new(), + }, + }) + .collect() +} + +fn descriptor_matches( + d: &serde_json::Value, + emit: &str, + where_: &serde_json::Map<String, serde_json::Value>, +) -> bool { + d.get("emit").and_then(serde_json::Value::as_str) == Some(emit) + && where_ + .iter() + .all(|(k, v)| d.get("payload").and_then(|p| p.get(k)) == Some(v)) +} + +fn collect_matches<'a>( + node: &'a serde_json::Value, + emit: &str, + where_: &serde_json::Map<String, serde_json::Value>, + out: &mut Vec<&'a serde_json::Value>, +) { + if let Some(on) = node.get("on").and_then(serde_json::Value::as_array) { + for d in on { + if descriptor_matches(d, emit, where_) { + out.push(d); + } + } + } + if let Some(children) = node.get("children").and_then(serde_json::Value::as_array) { + for child in children { + collect_matches(child, emit, where_, out); + } + } +} + +/// The JSON-only pump — exactly what the play shell does, minus the DOM: +/// find the stimulus descriptor in the CURRENT view JSON, echo it into a +/// `ui` event, forward emitted commands to the driver. +fn find_descriptor(view: &serde_json::Value, stim: &Stimulus) -> serde_json::Value { + let mut matches = Vec::new(); + collect_matches( + &view["page"]["root"], + &stim.emit, + &stim.where_, + &mut matches, + ); + for surface in view["surfaces"].as_array().unwrap() { + collect_matches(&surface["root"], &stim.emit, &stim.where_, &mut matches); + if descriptor_matches(&surface["dismiss"], &stim.emit, &stim.where_) { + matches.push(&surface["dismiss"]); + } + } + let mut distinct: Vec<&serde_json::Value> = Vec::new(); + for d in matches { + if !distinct.iter().any(|seen| { + seen["emit"] == d["emit"] + && seen["scope"] == d["scope"] + && seen["payload"] == d["payload"] + }) { + distinct.push(d); + } + } + assert_eq!( + distinct.len(), + 1, + "stimulus `{}` must match exactly one distinct descriptor", + stim.emit + ); + distinct[0].clone() +} + +/// Replays one script through the wasm-facing Session/Driver pair and +/// returns the canonical trace lines (`t` of each step-result). +fn run_session(program: &uhura_core::ir::ProgramIr, script_name: &str) -> Vec<String> { + let root = corpus_root(); + let fixture_text = std::fs::read_to_string(root.join("fixtures/standard.toml")).unwrap(); + let script_text = + std::fs::read_to_string(root.join(format!("fixtures/scripts/{script_name}.toml"))).unwrap(); + let fixture = load_fixture(&fixture_text).expect("fixture loads"); + let script_json = uhura_fixture::toml_to_json(&script_text).expect("script converts"); + let stimuli = parse_stimuli(&script_json); + + let mut session = Session::new(&program.to_canonical_string()).expect("IR loads"); + let boot = boot_updates(program, &fixture).expect("boot slices"); + let boot_json = serde_json::json!({ + "updates": boot.iter().map(ProjectionUpdate::to_json).collect::<Vec<_>>(), + }); + session + .boot(&to_canonical_json(&boot_json)) + .expect("boot applies"); + let mut driver = FixtureDriverJs::new( + &fixture_slices_json(&fixture), + &to_canonical_json(&script_json), + ) + .expect("driver builds"); + + let mut lines = Vec::new(); + let mut dispatch = |session: &mut Session, + driver: &mut FixtureDriverJs, + event: serde_json::Value| + -> serde_json::Value { + let out = session + .dispatch(&to_canonical_json(&event)) + .expect("dispatch succeeds"); + let result: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!( + result.as_object().unwrap().keys().collect::<Vec<_>>(), + ["c", "g", "i", "t", "v"], + "the step-result envelope is frozen" + ); + lines.push(to_canonical_json(&result["t"])); + for c in result["c"].as_array().unwrap() { + driver.deliver(&to_canonical_json(c)).expect("deliver"); + } + result + }; + + let mut view = dispatch( + &mut session, + &mut driver, + serde_json::json!({ "kind": "init", "route": program.entry.to_string(), "params": {} }), + )["v"] + .clone(); + + let mut tick = 0u64; + let mut next = 0usize; + loop { + if driver.idle() && next >= stimuli.len() { + break; + } + tick += 1; + assert!(tick <= 10_000, "the script must quiesce"); + + for msg_json in driver.tick() { + let msg: serde_json::Value = serde_json::from_str(&msg_json).unwrap(); + // Provider messages map to events: a standalone projection + // update wraps into an `updates` list; outcome and + // projection-failed pass through shape-identical. + let event = match msg["kind"].as_str().unwrap() { + "projection" => serde_json::json!({ "kind": "projection", "updates": [msg] }), + "outcome" | "projection-failed" => msg, + other => panic!("the driver emitted `{other}`"), + }; + view = dispatch(&mut session, &mut driver, event)["v"].clone(); + } + + while next < stimuli.len() && stimuli[next].at_tick == tick { + let stim = &stimuli[next]; + next += 1; + let descriptor = find_descriptor(&view, stim); + let mut event = serde_json::json!({ + "kind": "ui", + "descriptor": descriptor, + "view-rev": view["revision"], + }); + if !stim.data.is_empty() { + event["data"] = serde_json::Value::Object(stim.data.clone()); + } + view = dispatch(&mut session, &mut driver, event)["v"].clone(); + } + } + + assert_eq!( + session.view().expect("a view exists"), + to_canonical_json(&view), + "`view()` returns the last step's snapshot" + ); + lines +} + +#[test] +fn session_pump_matches_the_native_harness_byte_for_byte() { + let program = checked_program(); + let root = corpus_root(); + let fixture_text = std::fs::read_to_string(root.join("fixtures/standard.toml")).unwrap(); + for script in [ + "like-ok", + "like-refused", + "comment-ok", + "paginate", + "feed-failed", + "feed-empty", + "demo", + ] { + let script_text = + std::fs::read_to_string(root.join(format!("fixtures/scripts/{script}.toml"))).unwrap(); + let native = run_script(&program, &fixture_text, &script_text, false).expect("native run"); + let session = run_session(&program, script); + assert_eq!(session, native, "trace lines diverged for `{script}`"); + } +} + +#[test] +fn session_refuses_foreign_ir_and_premature_views() { + assert!(Session::new(r#"{"protocol":"uhura-ir/1"}"#).is_err()); + let program = checked_program(); + let session = Session::new(&program.to_canonical_string()).unwrap(); + assert_eq!(session.ir_version(), "uhura-ir/0"); + assert_eq!(session.revision(), 0.0); + assert!(session.view().is_err(), "no view before the first dispatch"); +} diff --git a/uhura/docs/README.md b/uhura/docs/README.md new file mode 100644 index 0000000..8e7a7d7 --- /dev/null +++ b/uhura/docs/README.md @@ -0,0 +1,47 @@ +# Uhura documentation + +- **Status:** Incubating +- **Master specification:** [Uhura specification](spec/README.md) +- **Foundational proposal:** [RFC 0001](rfcs/0001-project-foundation.md) +- **Research and stewardship:** [Uhura Working Group](working-group/README.md) +- **Historical requirements input:** [Frame stress-test handoff](working-group/frame-stress-test-handoff.md) + +This directory is the documentation root for the standalone Uhura project. +The project landing page provides orientation; the living specification defines +the current target; accepted RFCs record durable decisions and rationale. + +## Authority + +Uhura does not yet have an accepted language or runtime version. Until it does, +all normative-looking statements are proposed requirements. + +Once versioned work begins, authority will follow this order: + +1. Accepted RFCs establish durable decisions. +2. A versioned specification and executable conformance suite define observable + language and runtime behavior. +3. Implementations conform to those artifacts; implementation behavior is not + specification by accident. +4. Renderer, host-driver, widget-catalog, message, Spock-binding, and bundle + contracts remain independently versioned even when maintained here. +5. NCC links compatible artifacts but cannot override their semantics. + +Examples are non-normative unless a specification explicitly promotes them to +conformance fixtures. + +## Planned document families + +Only the foundation is present today. Future RFCs may establish: + +- source grammar, module rules, and canonical formatting for `.uhura` source; +- the checked intermediate representation and extraction model; +- UI machine semantics and event processing; +- the headless core runtime ABI and snapshot format; +- semantic widget catalogs and renderer conformance; +- host capabilities and effect-driver protocols; +- Spock contract imports and linking; +- messages and localization, including whether to adopt MessageFormat 2; +- bounded static projection for NCC's infinite canvas; and +- implementation language, packaging, compatibility, and release policy. + +These topics are intentionally not resolved merely by creating a directory. diff --git a/uhura/docs/rfcs/0001-project-foundation.md b/uhura/docs/rfcs/0001-project-foundation.md new file mode 100644 index 0000000..c5db8d7 --- /dev/null +++ b/uhura/docs/rfcs/0001-project-foundation.md @@ -0,0 +1,230 @@ +# RFC 0001 — Project foundation: UI language and headless experience runtime + +- **Status:** Draft +- **Scope:** Project identity, responsibility boundaries, repository posture, + and the minimum semantic model +- **Supersedes:** None +- **Related work:** Frame language foundation and Spock language/runtime + +## 1. Proposal + +Establish **Uhura** as an independently buildable project incubating at the +`uhura/` repository root. + +Uhura will own both: + +1. a declarative language for presentation, non-authoritative UI-session state, + experience transitions, bindings, and typed external requirements; and +2. a deterministic headless core runtime that checks and evaluates those + semantics without performing concrete rendering or I/O. + +Uhura is a greenfield proposed successor to the Frame workstream. It is not a +Frame rename, Frame 2 syntax commitment, or source-compatible continuation. + +The canonical source suffix is `.uhura`. Exact syntax is deferred. If a future +RFC adds an XML interchange serialization, it should use an explicit compound +suffix such as `.uhura.xml` rather than hiding XML behind `.uhura`. + +## 2. Motivation + +A presentation language that delegates all UI state and transitions to an +unspecified host creates the wrong abstraction boundary. Tabs, modal stacks, +form drafts, pending and error states, optimistic overlays, local navigation, +and pagination coordination are portable experience semantics. If Uhura does +not own them, each renderer or application host must recreate them, preventing +consistent checking, playback, tracing, and multi-renderer conformance. + +Putting these concerns in Spock is also incorrect. Spock is the authority for +durable backend truth and guarded product behavior. Making a server runtime +the mechanic for open modals, selected tabs, field dirtiness, or scroll-driven +loading would introduce network coupling and mix discardable interface state +with durable product state. + +Uhura therefore owns the experience machine while remaining a consumer of +authoritative product projections. + +## 3. Responsibility split + +The founding rule is: + +> Uhura decides what the interface presents and requests next. Spock decides +> whether an authoritative product operation is valid and commits its result. + +| Concern | Owner | +|---|---| +| Durable product state, policy, transactions, backend workflows | Spock | +| UI-session state, experience transitions, semantic view evaluation | Uhura | +| Layout, paint, native widget mechanics, device integration | Renderer/host drivers | +| Authoring, linking, scenarios, canvas projection, playback | NCC | + +No state may be authoritative in both Spock and Uhura. An optimistic Uhura +overlay remains provisional until an authoritative Spock outcome or projection +update settles it. + +## 4. Core semantic shape + +Uhura Core is modeled as a deterministic, I/O-free step: + +```text +step-u(program, ui-state, external-projections, event) + -> next-ui-state + + semantic-view + + service-commands + + platform-intents + + diagnostics + + trace +``` + +The core evaluates source semantics into a stable-keyed, target-neutral +semantic view. A concrete renderer maps that view to static shapes, web +controls, or native controls and returns semantic events. + +Service and platform effects are requested explicitly. Drivers execute them +and return outcomes or environment changes as later events. Time, randomness, +network, storage, URL/history, clipboard, and device state are never ambient +core inputs. + +## 5. Contract model + +Uhura owns: + +- language and checked-IR semantics; +- UI state, transition, event, and trace semantics; +- imported port requirement and linking rules; +- the semantic-view renderer protocol; and +- service-command and platform-intent envelopes. + +Spock owns the semantics of the projections, commands, outcomes, and refusals +it exports. Uhura imports those contracts without redefining them. The linker +checks satisfaction using a language-neutral representation so fixtures and +other providers can implement the same ports. + +NCC owns selection and composition of concrete artifacts. It must not override +the semantics of either side to make an invalid link appear valid. + +## 6. Separation inside Uhura + +One project does not imply one mixed source layer. Uhura must keep these +concerns explicitly separable: + +- presentation and component/template structure; +- UI machine state and transitions; +- required external projections and command ports; +- platform capability requirements; +- widget catalogs; +- compiler/checker and checked IR; +- pure core runtime; and +- renderer and host-driver adapters. + +Behavior must not be hidden inside renderer callbacks or arbitrary code in +widget markup. + +## 7. Relationship to Frame + +Frame's greenfield proposal established useful requirements: deterministic +parsing, explicit typed dependencies, pure templates, stable diagnostics, +portable widget semantics, static projection, and lowercase kebab-case source. +Uhura should retain those properties where they survive scrutiny. + +Uhura deliberately rejects Frame's strongest consumer-only restriction. A +Uhura program may define non-authoritative UI state and transitions, and Uhura +Core executes them. Durable application state, business behavior, policy, and +effects remain external. + +Existing Frame XML and Wire v4 formats remain governed by their current +documents. Any future migration requires an explicit adapter and diagnostics; +this RFC promises no automatic or lossless conversion. + +## 8. Relationship to NCC + +**NCC** is the current repository and product name; the orchestration layer was +renamed from **Wire** to make the architecture explicit: + +```text +NCC +├── authors and links Spock contracts +├── authors and links Uhura programs +├── supplies fixtures and scenarios +├── projects checked states onto an infinite canvas +└── plays the linked system through conforming runtimes and adapters +``` + +That rename was not performed by this RFC; it was carried out under its own +migration plan covering repository identity, packages, command names, +documentation, persisted files, and compatibility. See the +[Wire → NCC migration note](https://github.com/gridaco/ncc/blob/main/docs/migration-wire-to-ncc.md). + +`NCC = Spock + Uhura` is useful shorthand, but incomplete: NCC contributes the +human-facing authoring and orchestration layer and does not own either runtime. + +## 9. Repository posture + +During incubation, `uhura/` is co-located but isolated: + +- no nested Git repository or submodule; +- no implicit dependency on root workspace packages or configuration; +- independent manifests, lockfiles, toolchains, tests, and CI when + implementation begins; +- integration only through versioned public contracts and fixtures; and +- a clean path for later history extraction or repository promotion. + +“Standalone” means independently buildable and extractable. It does not mean +independent Git history, governance, license, or release process today. + +No implementation manifest is added by this RFC. Rust is the preferred initial +core direction because it supports a portable native/Wasm engine and reinforces +the boundary, but implementation language and ABI are separate decisions. + +## 10. Consequences + +Expected benefits: + +- one portable and testable owner for UI-state semantics; +- deterministic playback, replay, tracing, and static scenario projection; +- renderers that implement a semantic protocol rather than reinterpret source; +- a sharp authority boundary between UI optimism and backend truth; +- a language-neutral contract seam between Spock and Uhura; and +- an independently extractable project with no premature NCC coupling. + +Expected costs: + +- two cooperating state machines require explicit command correlation, + revisioning, cancellation, refusal, and stale-update rules; +- the core/renderer boundary needs stable identity and reconciliation semantics; +- Rust/Wasm integration may slow early iteration compared with a TypeScript-only + spike; +- UI checkpoints and language evolution require migration rules; and +- NCC must link multiple independently versioned artifacts. + +These costs are architectural work, not reasons to move UI state into Spock or +renderer-specific code. + +## 11. Deferred decisions + +This RFC does not accept: + +- a source grammar or XML/non-XML choice; +- exact machine, expression, component, or module semantics; +- a widget taxonomy; +- a runtime ABI or Wasm serialization; +- an event ordering or concurrency model; +- a Spock interface-description format; +- a renderer implementation; +- a package/repository name in public registries; +- a license or release policy; or +- the Wire-to-NCC rename itself. + +Each requires a focused RFC and conformance evidence. + +## 12. Acceptance criteria + +This foundation can be accepted when the project agrees that: + +1. Uhura owns both UI language semantics and a headless core runtime. +2. Spock remains authoritative for durable product state and behavior. +3. Renderers receive a semantic view and never reinterpret Uhura source. +4. All I/O occurs through explicit commands or platform intents. +5. Spock and Uhura contracts are linked through a language-neutral boundary. +6. Uhura remains isolated and independently extractable during incubation. +7. The Frame relationship and the NCC rename are described without implying + compatibility or prematurely performing migration. diff --git a/uhura/docs/rfcs/README.md b/uhura/docs/rfcs/README.md new file mode 100644 index 0000000..720e0b0 --- /dev/null +++ b/uhura/docs/rfcs/README.md @@ -0,0 +1,12 @@ +# Uhura RFCs + +RFCs record durable Uhura project, language, runtime, and contract decisions. +They are historical decision records, not substitutes for the living +[specification](../spec/README.md) or executable conformance tests. + +| RFC | Title | Status | +|---|---|---| +| [0001](0001-project-foundation.md) | Project foundation: UI language and headless experience runtime | Draft | + +RFC numbers are local to the Uhura project, zero-padded, and never reused after +a proposal has been shared. The status inside an RFC is authoritative. diff --git a/uhura/docs/spec/README.md b/uhura/docs/spec/README.md new file mode 100644 index 0000000..4de38b9 --- /dev/null +++ b/uhura/docs/spec/README.md @@ -0,0 +1,333 @@ +# Uhura specification + +- **Status:** Pre-specification working draft +- **Target:** Unversioned +- **Owner:** [Uhura Working Group](../working-group/README.md) +- **Foundational RFC:** [RFC 0001](../rfcs/0001-project-foundation.md) +- **Historical workstream:** Frame + +This is the living master document for **Uhura**, a declarative UI language, +checker/compiler, and deterministic headless experience runtime. Its canonical +source suffix is `.uhura`; the grammar and serialization are not yet accepted. + +Uhura owns the semantics of non-authoritative UI-session state. It consumes +typed external projections, receives semantic events and command outcomes, +advances an experience machine, and produces a renderer-neutral semantic view +plus explicit commands and platform intents. + +Uhura does not own authoritative domain state, authorization, transactions, +backend effects, or concrete rendering. + +> **Maturity warning:** this document fixes a project boundary and a model for +> research. It does not define usable source syntax or a conforming runtime. +> Terms and examples below are conceptual until accepted by an RFC and backed +> by executable tests. + +## 1. Normative language + +The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, +and **MAY** are interpreted as described by +[BCP 14](https://www.rfc-editor.org/info/bcp14) only when capitalized. + +Before Uhura 1.0, capitalized requirements are proposals. Stable semantics +require an accepted RFC, a versioned specification, and conformance tests. + +## 2. Product model + +The system has four independent responsibilities: + +| Layer | Owns | Does not own | +|---|---|---| +| Spock | Durable product truth, guarded business transitions, authorization, transactions, server workflows, and backend effects | UI-session state or rendering | +| Uhura | Presentation semantics, UI-session state and transitions, external port requirements, semantic view evaluation, and UI traces | Durable domain authority, direct I/O, or pixels | +| Renderer and host drivers | Layout, paint, native controls, input normalization, device mechanics, and execution of explicit platform intents | Product truth or experience transition semantics | +| NCC | Human-facing authoring, contract linking, fixtures/scenarios, provenance, infinite-canvas projection, cross-artifact diagnostics, and playback | Redefining Spock or Uhura semantics | + +Spock and Uhura are sibling language/runtime projects. Either can be checked or +tested independently. NCC composes them through versioned contracts. + +Uhura originated in the **NCC** repository (itself renamed from Wire under a +separate migration that defined repository, package, CLI, documentation, and +compatibility scope — see the +[Wire → NCC migration note](https://github.com/gridaco/ncc/blob/main/docs/migration-wire-to-ncc.md)) +and now lives in the Spock repository as a sibling workspace. + +## 3. Uhura project components + +Uhura is one semantic project but not one undifferentiated implementation. +Conforming work is expected to separate: + +1. **Source language:** presentation, components/templates, explicit UI + machines, bindings, and imported ports. +2. **Checker/compiler:** parsing, resolution, type checking, static analysis, + extraction, lowering, formatting, and diagnostics. +3. **Checked IR:** a versioned, target-neutral executable representation. +4. **Uhura Core:** the deterministic and I/O-free experience runtime. +5. **Renderer protocol:** semantic view snapshots or patches and semantic input + events. +6. **Host-driver protocol:** service commands and platform intents whose + effects occur outside the core. +7. **Bindings:** adapters for Spock contracts, fixtures, and other compatible + service providers. +8. **Widget catalogs:** independently versioned semantic capabilities informed + by proven implementations without copying their runtime APIs. + +Keeping these artifacts in one project gives them one semantic authority. +Keeping their contracts separate prevents UI behavior, platform behavior, and +backend behavior from leaking into each other. + +Presentation, UI machines, and external ports SHOULD remain separately visible +in source or modules. Co-location MUST NOT make widget event attributes a +hidden general-purpose programming language. + +## 4. What Uhura may describe + +Subject to future grammar RFCs, Uhura is intended to describe: + +- semantic widget trees, properties, slots, accessibility, and interaction + intent; +- reusable components or finite presentation templates; +- immutable lexical values, collection rendering, and structural selection; +- typed reads from explicit external projections; +- non-authoritative UI-session state and its initial values; +- event-driven UI transitions, guards, and local derived values; +- pages, logical routes, and surface orchestration such as dialogs, sheets, + popovers, menus, tooltips, prompts, and toasts; +- form drafts, local validation presentation, submission lifecycle, pending and + error states; +- optimistic overlays that remain subordinate to versioned external truth; +- typed service commands and platform intents, without implementing their + effects; +- typed handling of command outcomes and external projection updates; +- renderer-independent focus, scroll, navigation, and announcement intents; +- references to external localized messages; and +- bounded fixture/scenario projection for a static infinite canvas. + +Runtime interaction may continue indefinitely across events. Every individual +core step, source check, and bounded static projection must terminate within +specified resource limits. + +## 5. What Uhura cannot own + +Uhura source and Core MUST NOT: + +- define authoritative records, database schemas, permissions, or security + policy; +- commit a transaction or claim a business operation succeeded before its + authoritative outcome arrives; +- own durable cross-user or cross-device product truth; +- perform hidden network, storage, filesystem, clock, randomness, clipboard, + URL-history, or device I/O; +- contain secrets or transport credentials; +- lay out, paint, hit-test, or instantiate platform-native controls; +- depend on DOM, React, Flutter, SwiftUI, or another renderer's object model; +- execute arbitrary host-language code, reflection, or ambient callbacks; or +- redefine an imported Spock command, projection, refusal, or outcome. + +Uhura checkpoints may preserve a UI session without making that state +authoritative. Persistence alone is not authority: the question is whether the +state can alter product truth or another actor's reality. + +## 6. State ownership + +No fact may be authoritative in both Uhura and an external system. + +| Concern | Semantic owner | +|---|---| +| Selected tab, modal/surface stack, local filter | Uhura | +| Form value, dirty/touched state, local validation display | Uhura | +| Submission pending state and correlation identifiers | Uhura | +| Optimistic overlay and rollback/rebase policy | Uhura | +| Logical page, route, or navigation state | Uhura | +| Durable record, accepted mutation, authorization result | Spock or another authoritative service | +| Shared workflow or resumable cross-device progress | Spock or another authoritative service | +| Hover, pressed animation, caret, IME, pointer capture | Renderer | +| Physical scroll offset, measurement, row realization | Renderer | +| URL/history, clock, network, storage, clipboard | Explicit host driver | + +A concern can cross boundaries without gaining two owners: + +- An optimistic `liked` flag is a correlated Uhura overlay until Spock accepts + it; the accepted flag is Spock truth. +- A form draft is Uhura state; authoritative validation and commit are Spock + behavior. A deliberately server-saved draft becomes Spock state. +- Infinite scrolling uses renderer viewport observations, Uhura request/window + coordination, and Spock-owned query/cursor semantics and canonical records. +- Logical navigation belongs to Uhura; browser history is driven through an + explicit host intent and returns location changes as events. + +## 7. Formal core + +Let: + +- `P` be a checked Uhura program; +- `U` be the current non-authoritative UI-session state; +- `X` be the latest typed external projection set; +- `E` be one semantic event, external update, or command outcome; +- `V` be a stable-keyed, renderer-neutral semantic view; +- `C` be a finite ordered list of typed service commands; +- `I` be a finite ordered list of platform intents; +- `G` be diagnostics; and +- `T` be an observable execution trace. + +The abstract Uhura Core step is: + +```text +step-u(P, U, X, E) -> (U', V, C, I, G, T) +``` + +For fixed versioned inputs, the result must be deterministic. Core has no +ambient I/O. Drivers execute `C` and `I`; results return as later events or +projection updates. + +An authoritative service such as Spock evolves separately: + +```text +invoke-s(S, command) -> (S', outcome, projection-update, effects) +``` + +Uhura may predict a presentation result, but it cannot substitute that +prediction for `S'` or the authoritative outcome. + +Concrete event queueing, run-to-completion semantics, conflict/reentrancy +rules, cancellation, command correlation, stale projection handling, and +checkpoint migration remain open RFC topics. + +## 8. Headless rendering boundary + +Headless does not mean handing raw state and source to each renderer. Uhura Core +must evaluate components, templates, collection rendering, structural +selection, bindings, and UI state into `V`. Otherwise every renderer would +silently become another Uhura interpreter. + +`V` describes semantic controls and stable identity. A renderer maps it to +static shapes, web controls, or native controls and emits catalog-defined +semantic events. + +The following remain renderer concerns unless a future portable contract +promotes a semantic intent: + +- layout algorithms and text measurement; +- paint and compositing; +- native accessibility API mapping; +- hit testing and pointer capture; +- caret, selection, and IME mechanics; +- animation frames and physical scroll position; and +- virtualization and platform resource lifetime. + +Canonical runtime behavior should be specified using revisioned full view +snapshots. View patches may be an optional transport optimization with an +explicit base revision and snapshot fallback. + +## 9. Contract ownership + +“Uhura is the contract holder” means that Uhura owns: + +- source and checked-IR semantics; +- UI state, event, transition, and trace semantics; +- the types and linking rules for required external ports; +- semantic view and renderer protocols; +- host command/intent envelopes and settlement rules; and +- checkpoints, diagnostics, revisions, and compatibility rules. + +It does not mean that Uhura owns every imported contract. Spock owns the +meaning of the projections, commands, outcomes, and refusals it exports. Uhura +declares requirements against those exports. A language-neutral linker checks +that a selected provider satisfies them. + +Fixtures and alternative service implementations may satisfy the same required +ports. Uhura Core must not require Spock runtime objects or Spock source syntax. + +NCC may host the linker and surface its diagnostics, but the linker should be +usable independently and must not invent compatibility. + +## 10. Vocabulary + +These terms are deliberately distinct: + +| Term | Meaning | +|---|---| +| **projection** | A typed, externally owned read model supplied to Uhura | +| **event** | A fact delivered to Uhura, such as `save-requested` or `location-changed` | +| **transition** | A deterministic change to Uhura-owned UI state | +| **command** | A typed request to an authoritative service | +| **outcome** | The service-owned result or refusal returned for a command | +| **intent** | A typed request to a platform capability such as history, focus, or clipboard | +| **semantic view** | The evaluated, renderer-neutral widget tree | +| **renderer** | An implementation that realizes a semantic view on a target platform | +| **driver** | An implementation of explicit service or platform capabilities | + +The generic term `action` should not collapse events, transitions, commands, +intents, and outcomes into one ambiguous mechanism. + +## 11. Checkability and extraction + +The toolchain is expected to parse and check, without running application code: + +- grammar and module integrity; +- names, types, component/template calls, slots, and widget contracts; +- bindings against imported projection types; +- event and command payloads; +- machine states, reachable transitions, exhaustiveness, and obvious dead ends; +- command/outcome correlation shapes; +- stable keys for collection and component identity; +- required renderer and host capabilities; +- dependency and capability closure; and +- explicit resource limits for static projection. + +It should be possible to extract a graph of views, UI states, events, +transitions, external reads, commands, outcomes, messages, and capabilities. +When linked with Spock exports, NCC can infer and validate where flows connect +and how authoritative data is consumed. Inference may propose links; only +explicit or mechanically proven links become part of a checked bundle. + +Checkability does not prove usability, aesthetics, backend security, service +availability, or that a business workflow is desirable. + +## 12. Authoring principles + +Uhura is intended for human and LLM authors. The language should prefer: + +- lowercase kebab-case names wherever the host grammar permits; +- explicit imports and lexical scope; +- a closed, regular grammar with no ambient context; +- one canonical formatter and stable source locations; +- structured, machine-readable diagnostics with deterministic repair spans; +- bounded constructs and explicit capabilities; +- semantic names rather than renderer-specific implementation names; +- separable presentation, machine, and port definitions; and +- version-pinned catalogs and contracts instead of implicit latest behavior. + +The source format must be selected on these properties, not on novelty. The +`.uhura` suffix is reserved for canonical Uhura source. If a future RFC adds an +XML interchange serialization, it should use an explicit compound suffix such +as `.uhura.xml` rather than make XML the hidden meaning of `.uhura`. + +## 13. Open decisions + +The foundation deliberately leaves these unsettled: + +- exact source syntax and serialization; +- component versus pure-template semantics; +- reducer, statechart, or hybrid UI machine model; +- expression language and totality restrictions; +- async command ordering, concurrency, cancellation, and retries; +- checkpoint and hot-reload compatibility; +- checked IR and stable ABI representation; +- widget taxonomy and catalog versioning; +- message and localization model, including MessageFormat 2; +- renderer and host capability negotiation; +- Spock import/export schema and linker ownership packaging; +- static projection bounds and scenario format; +- Rust/Wasm and TypeScript host packaging; and +- public naming, licensing, release, and repository extraction policy. + +Each material decision requires focused research, examples, counterexamples, +and executable conformance cases before it enters a versioned specification. + +## 14. Historical research evidence + +The [Frame application-scale stress-test handoff](../working-group/frame-stress-test-handoff.md) +records which findings from the closed Frame workstream remain valid, which +responsibilities move into Uhura Core, and which Frame assumptions and syntax +must not be carried forward. diff --git a/uhura/docs/working-group/README.md b/uhura/docs/working-group/README.md new file mode 100644 index 0000000..886e782 --- /dev/null +++ b/uhura/docs/working-group/README.md @@ -0,0 +1,68 @@ +# Uhura Working Group + +- **Status:** Forming +- **Scope:** Uhura language, checker/compiler, headless core runtime, semantic + view protocol, host ports, widget catalogs, and conformance +- **Master document:** [Uhura specification](../spec/README.md) +- **Decision process:** [Uhura RFCs](../rfcs/README.md) + +The Uhura Working Group is the stewardship and research space for the project. +It may prepare RFCs, examples, counterexamples, formal models, prototypes, and +conformance fixtures. Working-group material is non-normative until promoted +through an accepted RFC and incorporated into a versioned specification and +test suite. + +## Responsibilities + +The group is responsible for: + +- preserving the authority boundary between Uhura, Spock, renderers, drivers, + and NCC; +- researching source grammar and deterministic LLM/human authoring; +- specifying UI-machine and core runtime semantics; +- specifying semantic view identity and renderer interoperability; +- defining typed external-port and host-capability contracts; +- curating widget taxonomies with evidence from proven implementations; +- producing realistic application stress cases before freezing syntax; +- maintaining machine-readable diagnostics and conformance fixtures; and +- documenting tradeoffs, rejected alternatives, and unresolved questions. + +## Working method + +Substantial proposals should include: + +1. the problem and explicit non-goals; +2. ownership and authority analysis; +3. a small formal or operational model where behavior is involved; +4. realistic examples and adversarial counterexamples; +5. static checking and runtime consequences; +6. renderer, host, Spock, and NCC boundary effects; +7. migration and compatibility consequences; and +8. executable conformance cases before acceptance. + +The group should prefer lowercase kebab-case names wherever the selected host +grammar allows it. Examples must be marked proposed until their syntax has been +accepted. + +## Research inputs + +- [Frame application-scale stress-test handoff](frame-stress-test-handoff.md) + preserves reusable requirements and explicitly reassigns state ownership for + Uhura without treating historical Frame XML as Uhura syntax. + +## Immediate research queue + +- UI machine model: statecharts, reducers, or a constrained hybrid +- command ordering, cancellation, concurrency, correlation, and refusal +- checked IR and Rust/Wasm host ABI +- source syntax, module system, and canonical formatting for `.uhura` +- component/template identity and collection reconciliation +- semantic widget taxonomy and surface primitives +- forms, navigation, optimistic UI, offline behavior, and infinite scrolling +- Spock export/import contract representation +- deterministic static scenario projection for NCC +- message and localization model, including MessageFormat 2 +- compatibility strategy for Frame and Wire v4 inputs + +Implementation should follow accepted semantics closely enough to produce +evidence, but prototypes do not become specification by being first. diff --git a/uhura/docs/working-group/frame-stress-test-handoff.md b/uhura/docs/working-group/frame-stress-test-handoff.md new file mode 100644 index 0000000..1493e92 --- /dev/null +++ b/uhura/docs/working-group/frame-stress-test-handoff.md @@ -0,0 +1,132 @@ +# Frame application-scale stress-test handoff + +- **Status:** Non-normative research handoff +- **Source workstream:** Historical Frame proposal +- **Destination:** Uhura Working Group +- **Evidence:** [Frame examples](../../../docs/frame/examples/README.md), + [Instagram-scale corpus](../../../docs/frame/examples/instagram/README.md), + [feedback](../../../docs/frame/examples/instagram/FEEDBACK.md), and + [research questions](../../../docs/frame/working-group/application-scale-stress-test-questions.md) + +This note carries reusable evidence from the closed Frame workstream into +Uhura without carrying forward Frame syntax or its consumer-only state boundary. + +The source corpus is intentionally preserved under `docs/frame/`. Its +`.frame.xml` files are historical, non-runnable stress fixtures. They are not +`.uhura` examples, migration promises, or accepted widget catalogs. + +## Why the corpus remains useful + +The Instagram-scale source exercised 16 view roots, 10 surface roots, 15 pure +templates, 253 distinct external invocation names, 215 distinct external model +paths, 36 collection expansions, 44 structural matches, and 294 bindings. + +Those counts do not validate Frame. They expose specification pressure across: + +- feeds, pagination, refresh, and windowed collections; +- Stories, Reels, media playback, calls, and live presentation; +- forms, composers, creation flows, drafts, and publishing status; +- navigation, tabs, routes, modal surfaces, and transient feedback; +- search, maps, charts, analytics, and privileged platform capabilities; +- user-authored structured text, localization, accessibility, and IME; and +- optimistic interactions, realtime updates, cancellation, and failures. + +The feedback is therefore useful as a requirements corpus even though Uhura +assigns several concerns to different owners. + +## Findings retained directly + +The following mechanism-neutral findings remain valid for Uhura: + +1. **Explicit inputs:** time, locale, visibility, viewport facts, capability + availability, randomness, and external outcomes must not be ambient core + state. +2. **Deterministic composition:** modules and imports must be finite, pinned, + acyclic, resource-bounded, source-located, and provenance-preserving. +3. **Visible dependencies:** components/templates cannot discover behavior or + data through an active page, surface, ancestor, renderer object, or implicit + binding context. +4. **Stable identity:** repeated content requires stable keys, duplicate-key + diagnostics, and an explicit reconciliation contract. +5. **Catalog authority:** source cannot invent widget properties, slots, + children, events, accessibility behavior, or renderer capabilities merely by + naming them. +6. **Event eligibility:** generic layout nodes do not silently become + interactive controls. Semantic events require declared keyboard, focus, + pointer, and assistive-technology behavior. +7. **Safe content:** user- and service-authored content is inert typed data, not + HTML, executable markup, or MessageFormat source. +8. **Surface identity:** a reusable surface definition is distinct from a + mounted instance, its anchor, result correlation, modality, and focus + restoration. +9. **Capability honesty:** media, maps, visualization, editors, drag/drop, and + privileged device functions need explicit versioned capabilities and honest + unsupported behavior. +10. **Projection honesty:** a static infinite canvas presents finite pinned + scenarios. It cannot imply that time passed, I/O completed, or an effect + occurred. +11. **Renderer separation:** layout, paint, measurement, hit testing, + virtualization mechanics, caret, IME, and platform resource lifetimes stay + outside the language core. +12. **Conformance before convenience:** shorthand may be accepted only after a + canonical semantic model, stable diagnostics, and round-trip behavior exist. + +## Findings reassigned for Uhura + +Frame externalized all application state to a Host Contract. Uhura deliberately +uses a three-owner model instead: + +| Stress-test concern | Uhura disposition | +|---|---| +| Selected tabs, logical navigation, modal stack | Uhura UI-session state | +| Form drafts, dirty/touched state, local validation display | Uhura UI-session state | +| Pending/error state, command correlation, optimistic overlays | Uhura, subordinate to authoritative outcomes | +| Pagination request/window coordination and local query state | Uhura | +| Durable records, permissions, transactions, accepted operations | Spock or another authoritative service | +| Query execution, canonical cursor meaning, shared workflow state | Spock or another authoritative service | +| Viewport observation, row realization, physical scroll anchor | Renderer/host protocol | +| Layout, paint, gesture recognition, caret, IME, device APIs | Renderer/host protocol | +| Contract selection, version pinning, fixtures, cross-link diagnostics | NCC | + +The ownership test from the Uhura foundation remains decisive: + +> If discarding the UI session could change product truth, authorization, a +> transaction, or another client's reality, the state is external authority. +> If it coordinates one experience and can be reconstructed without corrupting +> that truth, it belongs to Uhura. + +## Findings not carried forward + +Uhura does not inherit: + +- Frame XML vocabulary, namespaces, widgets, properties, or event names; +- the claim that every page-, form-, tab-, route-, or surface-local transition + must be declared outside the UI language; +- the omnibus Frame Host Contract as one owner for UI state, product behavior, + routes, surfaces, effects, and outcomes; +- the assumption that every one of the corpus's 253 invocation names is an + external action—many should become local events and Uhura transitions; +- `ui:list` as an accepted combination of semantics, scrolling, windowing, and + continuation observation; or +- any implication that well-formed XML is checked, renderable, or playable. + +## Required re-test + +A future Uhura application-scale corpus should be authored only after the +source grammar, UI-machine model, checked IR, widget catalogs, and core/renderer +protocol reach a testable draft. It should then revisit at least: + +- append, prepend, refresh, cancellation, stale outcomes, and anchor + preservation; +- multiple instances of one surface and disappearing anchors; +- controlled input during IME composition and external replacement; +- optimistic commit, refusal, rollback, and projection rebase; +- logical navigation versus browser/native history; +- media visibility, playback, interruption, and accessibility; +- supported and unsupported privileged capabilities; +- closed and extensible value evolution; +- deterministic module resolution and hot-reload identity; and +- static scenarios versus live runtime traces. + +Until then, the historical corpus is requirements evidence only. Uhura syntax +must emerge from its own RFCs and conformance work. diff --git a/uhura/docs/working-group/instagram-spike-design.md b/uhura/docs/working-group/instagram-spike-design.md new file mode 100644 index 0000000..040a2a6 --- /dev/null +++ b/uhura/docs/working-group/instagram-spike-design.md @@ -0,0 +1,1322 @@ +# Uhura spike design — Instagram vertical slice (v2, reframed) + +- **Status:** Working-group design draft — non-normative, pre-RFC, pre-implementation +- **Owner:** [Uhura Working Group](README.md) +- **Foundation:** [RFC 0001](../rfcs/0001-project-foundation.md), [Uhura specification](../spec/README.md) +- **Requirements evidence:** [Frame stress-test handoff](frame-stress-test-handoff.md), + [Instagram corpus](../../../docs/frame/examples/instagram/README.md) and its + [feedback](../../../docs/frame/examples/instagram/FEEDBACK.md) +- **Provenance:** v1 was synthesized from seven parallel subsystem designs + + an examples-system design, reconciled against ~90 adversarial critique + findings. **v2 reframes the view layer** after a direction challenge + ("successor of HTML/CSS + widgets; a minimal Svelte that does not support + JS") and folds in a final verification pass. §15 logs every adjudication. + +**Elevator pitch:** Uhura is a minimal Svelte without JavaScript — Svelte- +flavored markup and bindings over a closed, total transition language and +typed service ports, compiled by a Rust checker into a deterministic, +replayable, headless machine. Styling is real CSS. The machine is the spec. + +--- + +## 1. Mission and scope + +**The spike must prove, end to end:** + +1. A closed, checkable `.uhura` source language — Svelte-flavored markup + + a `store` machine block — under a meta-framework file convention + (`app/feed/page.uhura`, `components/post-card.uhura`). +2. A deterministic, I/O-free core: `step-u(P, U, X, E) → (U', V, C, I, G, T)`. +3. A renderer-neutral **semantic** view `V` (styling is deliberately + web-native CSS — see adjudication #21) consumed by two dumb renderers — + static HTML canvas and a live wasm-driven play shell. +4. A language-neutral port seam a real Spock provider could satisfy without + touching Uhura source, IR, or core (§9.6). +5. A **small, closed-but-extensible semantic element set** with a checked + ruleset — interaction/content semantics only; layout and aesthetics + belong to CSS and are free to drift. +6. **Example-defined design**: named, checked example states per + page/component/surface, rendered as previews on an infinite canvas (§6). + +**Content: the Instagram main flow.** In: home feed (stories tray, image + +carousel posts, one video post as an honest unsupported poster), optimistic +like with refusal rollback, per-post comments sheet with optimistic append, +near-end pagination with dedupe/failure/retry/exhausted, profile page +(header + tabs + grid) with a dynamic route, bottom tab navigation, +loading / failed / empty states throughout. + +Out: video playback, Stories viewer, Reels, DMs/realtime, creation/camera, +search, follow, maps, charts, auth, i18n, post-detail page, rich text. + +### Why not Svelte itself (recorded once) + +Svelte's templates and handlers are arbitrary JS: nothing is enumerable, +provable, replayable, or headless — rendering *is* its semantics. Building +on `svelte/compiler` would define Uhura by **subtraction** over a JS-shaped, +non-semver-stable AST (v4→v5 rewrote it), with semantics-by-implementation +and no Rust/wasm core. What Svelte *does* contribute is its authoring +surface — single-file components, `{#each}`/`{#if}` blocks, moustache +expressions, `on:` bindings, co-located `<style>` — and Uhura adopts that +surface while owning the grammar. The three properties no existing framework +provides, and which are the entire reason Uhura exists: total no-JS +semantics, a deterministic headless step with traces, and typed ports. + +--- + +## 2. Architecture overview + +``` + .uhura source ──► uhura-check ──► checked IR (versioned canonical JSON) + (+ CSS) │ │ + │ ┌────────┴──────────┐ + style sheet ▼ ▼ + (validated, uhura-core example resolver + passed thru) step-u/eval_view (replay = fold step-u) + │ │ │ + ▼ ▼ ▼ + ┌───────────────────┐ uhura-project + │ play shell (dumb) │ previews → canvas.html + │ keyed V→DOM + CSS │ + └───────────────────┘ + ▲ + provider envelope (JSON, the seam) + ▼ + fixture driver (scripted, ticks) ⇄ future Spock adapter +``` + +Hard boundaries, each enforced mechanically (§12): core interprets, V +carries, renderers realize; the provider envelope is the Spock seam; no +ambient anything in core (clock, randomness, floats, unordered maps, +network, geometry). + +### Reconciliation constitution — one owner per contract + +| Contract | Owner section | +|---|---| +| Source syntax, file convention, expression/machine language | §4 | +| Example files and replay semantics | §6 | +| Step, dispatch, identity, state semantics | §7 | +| V JSON, descriptor shape, renderer obligations | §8 | +| Port TOML schema, provider envelope, fixture format | §9 | +| Semantic element catalog + extension rules | §10 | +| Content: screens, port inventory, fixtures, cast | §11 | +| Crate layout, wasm ABI, CLI, diagnostics envelope, CI | §12 | + +--- + +## 3. File convention + +**Path defines; `use` references.** Element names resolve against the pinned +catalog; route names against the closed route table derived from `app/`. + +``` +examples/instagram/ + uhura.toml # manifest: entry route, catalog pin, + # play profiles (fixture + script) + app/ + feed/page.uhura # route "feed" → /feed + feed/page.examples.uhura + profile/[user]/page.uhura # route "profile" → /profile/:user, param user: id + profile/[user]/page.examples.uhura + components/ # pure templates: props in, declared emits out + post-card.uhura # (+ .examples.uhura siblings) + stories-tray.uhura comment-row.uhura profile-header.uhura + bottom-nav.uhura notice-bar.uhura + surfaces/ + comments-sheet.uhura # + comments-sheet.examples.uhura + ports/ + feed.port.toml comments.port.toml profile.port.toml + fixtures/ + standard.toml # named typed data slices (fixture.feed.page-1, …) + scripts/ # canonical script list (§11.4): + demo.toml like-ok.toml like-refused.toml comment-ok.toml + paginate.toml feed-failed.toml feed-empty.toml + assets/ # ~37 local JPEGs + manifest.toml (id → file, alt) + styles/ + theme.css # design tokens as custom properties + app styles + catalog/base.toml # semantic element catalog DATA (pinned) +``` + +Rules: `app/**/page.uhura` defines a route (folder path = route, `[seg]` = +dynamic segment, page declares `param <seg>: <type>`). One definition per +file; header matches basename; components/surfaces corpus-public; pages +never importable; import graph is a DAG; shadowing forbidden. +`*.examples.uhura` siblings are design artifacts excluded from the runtime +bundle (checked-IR bytes are identical with and without them). + +--- + +## 4. Source language + +A `.uhura` file has up to four parts, in order: **header** (kind + `use` + +`props`/`emits`/`param` declarations), **`store { }`** (pages/surfaces only: +state + handlers — "the model + controller"), **markup** (the view), and an +optional **`<style>`** block of real CSS. Kebab-case names, UTF-8, `//` +comments outside markup. One canonical formatter, zero options. Bounds: +file ≤ 256 KiB, nesting ≤ 32, ≤ 512 nodes/view, ≤ 128 handlers/page. + +### 4.1 Header + +``` +component post-card // or: page | surface comments-sheet [modality sheet] + +use component comment-row // explicit, item-exact imports +use surface comments-sheet +use port feed { projection feed-page, command like-post, type post-summary } + +props { post: post-summary, liked: bool } // components/surfaces +emits { like-toggled(post: id, now-liked: bool) } +param user: id // pages with dynamic segments +``` + +Port imports bring items into file scope unqualified; `type` is a legal +import kind; an imported command makes `send` legal and generates two +handleable events, `<command>.ok` / `<command>.err`. + +### 4.2 The `store` block — statements closed at five + +`state { field: type = literal, … }` plus `on` handlers. + +| Statement | Semantics | +|---|---| +| `set <field>[key]? = <expr>` | Write to own-scope state. `= none` removes a map entry / optional. Record literal `{…}` legal on rhs. Paths: `field` or `field[key]` only. | +| `send <command>(args) [as <t>]` | Mints a core tag (`U.counters`), emits a command envelope, records pending. `as t` binds the tag (type `tag`) for keying optimistic state. Duplicate identical in-flight send → warning; suppression is the author's guard's job. | +| `open-surface <name>(args)` | Structural, applied at dispatch end. Idempotent per (definition, canonical context). Records opener + triggering node for focus restore. | +| `dismiss` | Surface scope only. Pops the instance; `FocusRestore` intent when topmost. No result plumbing (deferred). | +| `navigate <route>(named-args)` / `navigate back` | Structural; ≤ 1/step. Closed route table; args cover dynamic segments exactly. `back` pops; revealed page keeps state; the popped page's surfaces force-close. | + +No `emit` statement, no internal events, no lifecycle events — boot data is +provider-seeded (§9), so every step dispatches exactly one external event. + +**Handlers.** `on <event>(params) [when guard] { statements }`. Multiple +handlers per event: identical signatures, source order, first satisfied +guard wins, none ⇒ dropped with a trace note; unguarded-above-guarded is +`unreachable-handler`. Outcome handlers have fixed name-only signatures: +`on <command>.ok(tag, cmd)` / `on <command>.err(tag, cmd, refusal)` — `cmd` +is the echoed payload (rollback needs no bookkeeping); `unavailable` routes +to `.err`. + +**Handler dispatch is transactional.** Writes are visible sequentially +*within* the handler but commit only if the handler completes. A not-ready +projection read anywhere in guard or body **aborts the dispatch**: guard +position ⇒ guard is false; body position ⇒ no writes commit, no commands +emit, trace note `projection-not-ready`. (Guards should still cover +readiness explicitly; the abort is the deterministic backstop.) + +**Carried fields:** for catalog events declaring `carries` +(`text-field` `change { value: text }`), the emitted payload is author args +∪ carried fields; the author may not name a carried field; the handler +signature must include it. + +### 4.3 Expressions — total, tiny, closed + +Types: `bool`, `int` (i64, saturating), `text`, `id`, `tag`, port +records/unions/enums, catalog token enums (icon names and similar closed +sets typecheck as enum values), `list[T]`, `map[K]V` (K ∈ {id, tag}), `T?`. +**No floats.** Operators: `.field`, `x[k]` (option-returning), `??`, `!`, +unary `-`, `+ -`, `++` (text), comparisons, `&& ||`, +`if c then a else b`; builtins `to-text`, `count`. No `* / %`, user +functions, loops, recursion. State initializers are literals only. `for` +over a map iterates keys in deterministic order. + +### 4.4 Markup + +Svelte-flavored, closed: + +- **Elements** — semantic catalog names (§10) or imported components: + `<button …>`, `<post-card …/>`. Attributes: `attr="literal"`, + `attr={expr}`, bare `attr` (= true). `class` is an ordinary attribute. +- **Text** — literal text and `{expr}` interpolation inside `<text>` (and + only there — human-readable content is always typed data). +- **Blocks** — `{#if e}…{:else}…{/if}`; + `{#each list as x (key-expr)}…{/each}` (the parenthesized key is + **required** — Svelte's own keyed-each syntax, made mandatory); + `{#match e}{:when variant [binding]}…{:else}…{/match}` for port unions + and projection availability. +- **Widget events** — `on:<event>={emit <machine-event>(named-args)}`. + Legal only where the catalog declares the event for that element (event + eligibility); `on:` never attaches to `view` or other layout-class nodes. +- **Component emits (one model, explicit)** — a component's declared emits + are consumed at the call site: + `on:dismissed={emit notice-dismissed()}` rebinds with args; + bare `on:like-toggled` **forwards** the event, same name and payload, to + the enclosing machine scope. An unbound emit is the `unhandled-event` + warning. There is no implicit propagation. +- A component's markup has **exactly one root element**; expansion is + transparent (the root receives the call-site key; a `{#match}` root keeps + the call-site key across arms, widget change ⇒ renderer replacement). + +### 4.5 Styling — real CSS, checked shallowly + +Per-file `<style>` blocks and `styles/theme.css` are **actual CSS**, passed +through to both renderers. Design tokens are custom properties in +`theme.css`. The checker's whole CSS surface is: (1) selectors in a +component/page `<style>` must be class-rooted, with a lint recommending the +subject's root class (`.post-card`, `.feed-page`); (2) a class referenced in +markup that appears in no style source is a warning; (3) everything inside +declarations is passed through verbatim. Aesthetics are *free to drift* by +doctrine — the contract covers semantics, not spacing. Scoped-CSS +transformation (Svelte-style hashing) is deferred; rooting-by-convention is +the spike policy. + +### 4.6 Normative source — `components/post-card.uhura` + +``` +component post-card + +use port feed { type post-summary } + +props { + post: post-summary + liked: bool + like-pending: bool +} + +emits { + like-toggled(post: id, now-liked: bool) + comments-requested(post: id) + author-tapped(user: id) +} + +<view class="post-card"> + <region label="View profile" + on:activate={emit author-tapped(user: post.author.id)}> + <view class="author-row"> + <image class="avatar" src={post.author.avatar.src} alt={post.author.avatar.alt} /> + <text class="username">{post.author.username}</text> + </view> + </region> + + {#match post.media} + {:when image m} + <region label="Like this post" supplementary + on:activate-double={emit like-toggled(post: post.id, now-liked: true)}> + <image class="media" src={m.image.src} alt={m.image.alt} /> + </region> + {:when carousel c} + <region label="Like this post" supplementary + on:activate-double={emit like-toggled(post: post.id, now-liked: true)}> + <pager class="media" indicator="dots" label="Photo carousel"> + {#each c.slides as s (s.id)} + <image src={s.src} alt={s.alt} /> + {/each} + </pager> + </region> + {:when video v} + <view class="media video-fallback"> + <image src={v.poster.src} alt={v.poster.alt} /> + <view class="video-fallback-badge"> + <icon name="video-off" /> + <text>Video isn't supported in this preview</text> + </view> + </view> + {/match} + + <view class="action-row"> + <button pressed={liked} busy={like-pending} + label={if liked then "Unlike" else "Like"} + on:press={emit like-toggled(post: post.id, now-liked: !liked)}> + <icon name={if liked then "heart-filled" else "heart"} /> + </button> + <button label="Comments" + on:press={emit comments-requested(post: post.id)}> + <icon name="comment" /> + </button> + </view> + + <text class="likes">{to-text(post.like-count + + (if liked && !post.viewer-has-liked then 1 + else if !liked && post.viewer-has-liked then 0 - 1 + else 0)) ++ " likes"}</text> + <text class="caption">{post.caption}</text> + <text class="meta">{to-text(post.comment-count) ++ " comments · " ++ post.posted-label}</text> +</view> + +<style> + .post-card { display: flex; flex-direction: column; gap: var(--space-2); } + .post-card .author-row { display: flex; align-items: center; + gap: var(--space-2); padding: 0 var(--space-4); } + .post-card .avatar { inline-size: 32px; block-size: 32px; + border-radius: var(--radius-full); object-fit: cover; } + .post-card .media { aspect-ratio: 1; inline-size: 100%; object-fit: cover; } + .post-card .video-fallback { display: grid; } + .post-card .video-fallback > * { grid-area: 1 / 1; } + .post-card .video-fallback-badge { place-self: center; display: flex; + flex-direction: column; align-items: center; gap: var(--space-1); + color: var(--color-on-media); } + .post-card .action-row { display: flex; gap: var(--space-4); + padding: 0 var(--space-4); } + .post-card .likes { font-weight: 600; padding: 0 var(--space-4); } + .post-card .caption { padding: 0 var(--space-4); + display: -webkit-box; -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; } + .post-card .meta { color: var(--color-ink-subtle); padding: 0 var(--space-4); } +</style> +``` + +Note what moved to CSS: the entire former layout/style taxonomy (`column`, +`row`, `gap=sm`, `ratio=square`, `shape=circle`, `lines=3`, button `kind`). +The optimistic like-count is computed in the view from `liked` vs projection +truth — the count moves the instant the overlay does. + +### 4.7 Normative source — `app/feed/page.uhura` + +``` +page + +use component bottom-nav +use component post-card +use component stories-tray +use component notice-bar +use surface comments-sheet +use port feed { + projection feed-page, projection viewer, + command like-post, command unlike-post, + command load-next-page, command reload +} + +store { + state { + like-overlay: map[id]bool = {} + like-pending: map[id]bool = {} + load-pending: bool = false + load-failed: bool = false + reload-pending: bool = false + notice: text? = none + } + + // like / unlike: optimistic overlay; guard-ordered multi-handler dispatch + on like-toggled(post: id, now-liked: bool) + when now-liked && !(like-pending[post] ?? false) { + set like-overlay[post] = true + set like-pending[post] = true + send like-post(post: post) + } + on like-toggled(post: id, now-liked: bool) + when !now-liked && !(like-pending[post] ?? false) { + set like-overlay[post] = false + set like-pending[post] = true + send unlike-post(post: post) + } + on like-post.ok(tag, cmd) { set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none } + on like-post.err(tag, cmd, refusal) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + set notice = "Couldn't like this post. Try again." + } + on unlike-post.ok(tag, cmd) { set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none } + on unlike-post.err(tag, cmd, refusal) { + set like-pending[cmd.post] = none + set like-overlay[cmd.post] = none + set notice = "Couldn't unlike this post." + } + + // pagination: the guard IS the dedupe; exhausted derives from truth + on feed-near-end() + when !load-pending && !load-failed + && feed-page.has-more && feed-page.cursor != none { + set load-pending = true + send load-next-page(cursor: feed-page.cursor) + } + on load-next-page.ok(tag, cmd) { set load-pending = false } + on load-next-page.err(tag, cmd, refusal) { set load-pending = false + set load-failed = true } + on retry-load-tapped() + when load-failed && feed-page.cursor != none { + set load-failed = false + set load-pending = true + send load-next-page(cursor: feed-page.cursor) + } + + on retry-reload-tapped() when !reload-pending { + set reload-pending = true + send reload() + } + on reload.ok(tag, cmd) { set reload-pending = false } + on reload.err(tag, cmd, refusal) { set reload-pending = false } + + on comments-requested(post: id) { open-surface comments-sheet(post: post) } + on author-tapped(user: id) { navigate profile(user: user) } + on tab-selected(section: text) when section == "profile" { + navigate profile(user: viewer.id) // viewer is a boot projection + } + on notice-dismissed() { set notice = none } +} + +<view class="screen feed-page"> + {#if notice != none} + <notice-bar text={notice ?? ""} on:dismissed={emit notice-dismissed()} /> + {/if} + + {#match feed-page} + {:when loading} + <view class="fill-center"><text class="muted">Loading your feed…</text></view> + {:when failed reason} + <view class="fill-center stack-sm"> + <text>Your feed didn't load.</text> + <button label="Retry" busy={reload-pending} + on:press={emit retry-reload-tapped()}> + <text>Retry</text> + </button> + </view> + {:when ready f} + {#if count(f.posts) == 0} + <view class="fill-center"> + <text class="muted">Follow people to fill your feed.</text> + </view> + {:else} + <scroll class="feed-scroll" on:near-end={emit feed-near-end()}> + <stories-tray stories={f.stories} /> + <view role="list" class="post-list"> + {#each f.posts as p (p.id)} + <post-card post={p} + liked={like-overlay[p.id] ?? p.viewer-has-liked} + like-pending={like-pending[p.id] ?? false} + on:like-toggled on:comments-requested on:author-tapped /> + {/each} + </view> + {#if load-pending} + <text class="muted row-center">Loading more…</text> + {/if} + {#if load-failed} + <view class="row-center gap-sm"> + <text class="muted">Couldn't load more.</text> + <button label="Retry" on:press={emit retry-load-tapped()}> + <text>Retry</text> + </button> + </view> + {/if} + {#if !f.has-more} + <text class="muted row-center pad-md">You're all caught up.</text> + {/if} + </scroll> + {/if} + {/match} + + <bottom-nav current="feed" on:tab-selected /> +</view> + +<style> + .feed-page { display: flex; flex-direction: column; block-size: 100%; } + .feed-scroll { flex: 1; min-block-size: 0; } + .post-list { display: flex; flex-direction: column; gap: var(--space-6); } + /* fill-center / row-center / stack-sm / gap-sm / pad-md / muted live in theme.css */ +</style> +``` + +`surfaces/comments-sheet.uhura` follows the same shape: header +`surface comments-sheet modality sheet`, `props { post: id }`, store +`{ draft: text = "", pending-appends: map[tag]text = {}, notice: text? = none }`; +`send add-comment(post: post, body: draft) as t` keys the optimistic row; +`.err` restores `draft = cmd.body` from the echo; `on dismiss-requested() +{ dismiss }`. The composer: +`<text-field value={draft} label="Add a comment" on:change={emit +composer-changed()} on:submit={emit submit-requested()} />` (`change` +carries `value: text`); pending rows render via +`{#each pending-appends as t (t)}`. + +### 4.8 What the checker rejects (highlights) + +Unknown element/component (`<avatar>` → "the avatar pattern is `<image +class=…>` — see docs/patterns"); unkeyed `{#each}` (parse error — the key is +grammar, not lint); `on:` on `view` or any layout-class node ("wrap in +`<region>`"; never auto-repaired); undeclared component emit; unbound +required prop; unresolved bind with did-you-mean; duplicate keys; unreachable +handler; a view-position projection read outside `{#match}` availability; +a class-rooting violation in `<style>`; a markup-referenced class defined +nowhere (warning). Diagnostics are structured JSON (§12.4) with spans, +labels, notes, and safe mechanical fixes only. + +--- + +## 5. (Merged into §14.) + +--- + +## 6. Examples — example-defined design + +Unchanged from v1 in substance; the term decisions, file convention, and +replay semantics survive the reframe untouched (examples address the +machine, not markup syntax). + +**Terms:** an **example** is the authored artifact (pinned or derived +presentation state of a page/component/surface); a **preview** is one +rendered example frame on the canvas. Rejected: *snapshot* (collides with +V), *scenario* (reserved for NCC flows), *story* (borrowed), *variant* +(collides with props). + +### 6.1 Files and grammar + +`<basename>.examples.uhura` siblings; excluded from the runtime bundle; +**checked-IR bytes identical with and without them**; spec files can never +reference example declarations. (Excerpt — the full feed set is §11.3.) + +``` +use fixture standard + +example loading { + note "cold start — nothing delivered yet" + // boot projections (viewer) are auto-bound from the fixture by the resolver +} + +example first-page default { + projection feed.feed-page = fixture.feed.page-1 +} + +example like-pending { + from first-page + events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] + note "optimistic heart + count while like-post is in flight" +} + +example like-refused { + from like-pending + events [ outcome like-post.err(refusal: rate-limited) ] + note "authority refused — rollback, notice explains" +} + +example comments-open { + from first-page + projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze + events [ comments-requested(post: "post-lena-glaze") ] + note "the sheet mounts because the machine mounted it" +} + +example appended { + from first-page + events [ + feed-near-end() + projection feed.feed-page = fixture.feed.pages-1-2 + outcome load-next-page.ok() + ] +} +``` + +Components: `props { … }` bindings from fixture slices; `from` = +props-merge. Pages with dynamic segments: `params { user = "user-lena" }`. +Clause legality matrix (checker-enforced): components — `props/from/note/ +default`; pages — `params/projection/state/from/events/note/default`; +surfaces — pages' set plus `props`, and a derived surface example may not +dismiss its own subject. `default`: at most one (error if two), zero → info +lint, cover falls back to first declared. Multiple namespaced `use fixture` +imports are legal. **Boot projections are auto-bound from the fixture** so +the bare-read permission (§9.2) holds during replay. + +### 6.2 Semantics — pinned and derived + +- **Pinned** — literal bindings. May express unreachable states; captions + mark them `pinned`; pinned-ness propagates through `from` chains + (state-pins taint; projection-pins do not — any projection value is a + legitimate external input). +- **Derived** — `from` + `events`: replay is a left fold of pure `step-u` + over the timeline (semantic events, projection updates, injected + outcomes). Rules: an injected outcome must settle the **oldest unsettled + matching command** in the replay prefix (uncorrelated injection is a check + error — "pin this state instead"); leaving the subject route at any step + is a check error; evaluation order = parent chain → pins → timeline fold; + per-step `C`/`I` are recorded, and captions surface unsettled commands + ("1 command in flight"); failures attribute to the first failing step, + descendants report "blocked by ancestor"; no cycles/forward refs; depth + capped. + +Derived examples are **self-verifying** — change the machine and +`uhura check` fails them. The modal case falls out for free: `comments-open` +fires the real event; in Figma that connection is a wire that can drift, +here it *is* the transition. **Replay is a build/check phase** resolving +each example to a frozen `(route, U, X, surface stack)` snapshot; +`uhura project` consumes only resolved snapshots and executes zero +transitions. Entity-id payloads are linted against bound fixture slices. + +### 6.3 Canvas presentation + +One row per page (390×844 device frames, declaration order, `default` +badged); components content-sized on a dotted backdrop; surfaces standalone ++ in-context under the deriving page example. Captions: `subject / +example-name`, provenance (`pinned` | `from X → events…`), in-flight +commands, `note`. No examples ⇒ page renders initial state + info lint; +component absent from canvas + same lint. + +--- + +## 7. Core semantics + +Unchanged by the reframe. One pure Rust function over owned, ordered data. + +### 7.1 Step and state + +```rust +pub fn step_u(p: &CheckedProgram, u: UiState, x: &Projections, e: Event) -> StepResult; + +pub struct StepResult { + pub u: UiState, // rev = old + 1, always + pub v: ViewSnapshot, // full snapshot; v.revision == u.rev + pub c: Vec<CommandEnvelope>, + pub i: Vec<IntentEnvelope>, + pub g: Vec<Diagnostic>, + pub t: StepTrace, // one JSONL record; the conformance artifact +} + +pub struct Projections { // ABSENT until delivered; keyed instances + pub snapshots: BTreeMap<(Ident, Option<Value>), ProjectionSnapshot>, + pub failed: BTreeMap<(Ident, Option<Value>), String>, +} +``` + +`UiState`: `rev`, `nav` stack (entries own page state), `surfaces` +(bottom→top; instance = minted serial + def + canonical context + opener + +restore-focus node + fields), core-internal `pending` command table +(correlation, payload echo, origin scope), and `counters` (all identity — +replay mints identical ids). `Value`: unit, bool, i64, text, id, tag, list, +record (`BTreeMap`), none/some — **no floats**; `HashMap` banned in anything +feeding `U/V/C/I/G/T`. + +### 7.2 Events, acceptance, dispatch + +```rust +pub enum Event { + Ui { descriptor: Descriptor, data: Option<Value>, view_rev: Rev }, + Outcome { correlation: String, result: OutcomeResult, updates: Vec<ProjectionUpdate> }, + Projection { updates: Vec<ProjectionUpdate> }, + ProjectionFailed { reference: ProjectionRef, reason: String }, + Init { route: RouteId, params: BTreeMap<Ident, Value> }, +} +pub enum OutcomeResult { Ok, Refused { refusal: Ident }, Unavailable { reason: String } } +``` + +`Ui` acceptance in order (first failure drops with a trace record): scope +alive → else `stale-scope`; top-surface modality → else `occluded`; emit +declared with matching payload → else `ineligible`. **Stale `view_rev` is +accepted** — descriptors are self-contained, guards are the backstop; +rejecting by revision would eat taps on recomputes. + +`Outcome` dispatches to the pending entry's origin scope; unmounted origin ⇒ +`stale-outcome` drop (pending removed, truth already lives in `X`). +Piggybacked `updates` apply (revision-checked) **before** the outcome +dispatches — clear-overlay-on-ok is flicker-free (§9.4). + +One external event per step; multi-handler guard selection runs at most one +handler; the handler is **transactional** (§4.2); structural statements +apply at dispatch end; `V` evaluates once from final `U'` and `X`. No +internal queue ⇒ termination by construction. + +### 7.3 Optimistic overlays + +An overlay is an **authored state diff over projection truth**; the view is +a pure function of (overlay, base); settlement and rollback are both diff +deletion; rebase is free recomputation. Core contributes exactly-once, +origin-addressed, payload-echoing outcome delivery — never hidden policy. + +### 7.4 Navigation and history + +`nav` is core state; the host's history moves only via intents. `navigate` +pushes + `HistoryPush`; `back` pops (destroys popped page state, +force-closes its surfaces) + `HistoryBack`; revealed pages keep state. +**Spike shell executes history intents as no-ops (traced) and never +synthesizes `LocationChanged`** — the reconcile design is deferred RFC +material; the contract stays visible in `T`. + +### 7.5 Determinism and trace + +Forbidden inputs: clocks, randomness, unordered iteration, floats, +locale/env, network/storage/URL/clipboard, renderer geometry (only +catalog-declared semantic observations enter, as events), scheduling, +pointer identity. Canonical JSON (sorted keys, integers only) + SHA-256. +`StepTrace` (JSONL: input event, dispatch record with per-handler guard +results and writes, structural ops, `C`/`I`, drops, diagnostics, +`u-hash`/`v-hash`) is the golden format; `uhura trace --expanded` renders +full V per step as presentation. + +--- + +## 8. The semantic view protocol and both renderers + +### 8.1 Snapshot (`uhura-view/0`) + +```text +Snapshot := { protocol: "uhura-view/0", revision: u64, + page: { route: text, root: Node }, + surfaces: SurfaceInstance[] } // bottom → top +SurfaceInstance := { key: text, // "comments-sheet:2" (def:serial) + definition: text, modality: "sheet", + restore-focus?: KeyPath, + dismiss: Descriptor, // first-class; Escape/scrim wire here + root: Node } +Node := { key: Key, element: text, // catalog name + class?: text, // authored CSS classes, passed through + props: { [name]: Value }, // SEMANTIC props only + children?: Node[], on?: Descriptor[], debug?: {…} } +Value := bool | i64 | text + | { t: "plain", v: text } // inert human text — never markup + | { t: "image", asset: text } // opaque asset id; core never fetches +Descriptor := { kind: "input" | "observe", + event: text, emit: text, + scope: text, // "page:1" | "surface:2" — minted serials + payload: InertJson, // PREBUILT by core + carries?: { [field]: "text" } } +``` + +Full snapshots only; revision +1 per step; apply-in-order/drop-stale. +Payloads are prebuilt, never templates (a template hands the renderer a data +context — rebuilding the interpreter). **Descriptor presence is the +subscription** (feed exhausts ⇒ `near-end` descriptor disappears ⇒ renderer +stops observing). `class` is an opaque string V carries verbatim — styling +is deliberately not part of the semantic contract (adjudication #21). + +Keys: sibling-unique; key-path is global identity; static nodes take source +ordinals, `{#each}` items take `"<ordinal>.<key-value>"` — collisions +unrepresentable, appends identity-preserving by construction. Component +expansion is transparent; one-root rule per §4.4. + +### 8.2 Renderer contract + +MUST: realize every supported element (honest labeled placeholder +otherwise); preserve identity by key-path; insert `{t:"plain"}` via text +nodes only; emit events only from present descriptors, echoing `payload` +verbatim + declared `carries`; apply snapshots atomically in revision order; +preserve focus across applies; meet the a11y minimums (button +`aria-pressed`/`aria-busy`, image `alt` xor `decorative`, surface +`role=dialog aria-modal` + page `inert` + focus restore, region keyboard +synthesis; DOM order = V order; no positive tabindex); apply authored CSS +as-is. + +MUST NOT: read source/IR/fixtures/projections; hold semantic state; +synthesize events or attach them to ineligible elements; disable a `busy` +control on its own initiative; reinterpret older snapshots. + +Near-end contract: descriptor presence = someone listens (core); physical +proximity = remaining extent below **100% of one viewport extent** (integer +percentage, stated once in the catalog); edge-triggered latch re-armed on +content-extent growth (renderer); exactly-one in-flight command (core +guard); cursor meaning (port). The four-way split that discharges the Frame +`ui:list` P0 — semantics (`role="list"` + keyed each), viewport (`scroll`), +windowing (renderer license), pagination observation (split as above). + +### 8.3 Static renderer — `uhura project` + +Checked program → resolved example snapshots (§6.2) → `eval_view` → V → +HTML per preview → one self-contained `renders/canvas.html`. Zero +transitions, commands, network. V maps to plain HTML: `view` → `div` (+ +role), `text` → `p/span`, `button` → `button`, etc., with authored classes +attached and the compiled stylesheet (theme.css + concatenated `<style>` +blocks) embedded once. Interactive elements render real (correct a11y tree) +inside `inert` frames with a prerendered `data-note` ("would emit +like-toggled {post: …}") for hover chrome. Board chrome: ~120 lines vanilla +JS (pan/zoom/fit/hover) that never reads V or Uhura data. **Assets: real +JPEGs**, each inlined exactly once as a data-URI custom property +(`--asset-lena-glaze`); duotone-SVG fallback for missing assets; manifest +alt text required. + +### 8.4 Play renderer — the TS shell + +`uhura dev` serves index, `shell.js` (hand-rolled, no deps, JSDoc-typed, +zero build step), wasm bundle, IR JSON, fixture JSON, and the compiled +stylesheet. React remains a legitimate future renderer engine below the V +protocol; the spike hand-rolls to keep the contract visible. + +- **Reconciler:** keyed `insertBefore`-sweep (~350 lines): element change ⇒ + replace; semantic-prop appliers + class swap; keyed child reconciliation + with zero-move appends. Route change remounts the page subtree; a + per-route scroll cache restores position on back. +- **Reentrancy (normative):** renderer emissions always **enqueue**; a + `pumping` flag makes nested pumps no-ops; post-apply observation checks + run in a microtask after the pump drains (prevents the wasm `RefCell` + re-entry panic). +- **text-field (normative):** core owns the draft; renderer owns caret/IME. + Per field, a counter of in-flight change emissions; while nonzero, + external replacement never applies (stashed) — a tick-scheduled outcome + landing mid-typing cannot eat keystrokes. IME composition buffers locally, + one `change` at `compositionend`. +- **Ticks:** the fixture driver schedules outcomes in integer ticks + (`after-ticks ≥ 1`, so optimistic states are always observable); the shell + maps wall time to `driver.tick()`; traces depend only on tick ordinals. + +### 8.5 Theme + +`styles/theme.css` owns the tokens (4px space scale as `--space-*`, type +scale, `--radius-*`, neutral ink ramp, one accent `#ed4956` used only where +it means something) and the shared utility classes the slice uses +(`fill-center`, `row-center`, `muted`, …). Good-looking is achieved by an +**authored** theme rather than a generated class system — consistent with +"aesthetics are free to drift"; the checker never validates a declaration. + +--- + +## 9. Ports, the provider envelope, and the fixture driver + +Unchanged by the reframe (the seam never touched markup). + +### 9.1 Contract schema — `ports/*.port.toml` + +Kind-tagged type tables; closed type grammar (`bool | int | text | +option<T> | list<T> | <declared>`); kinds `record`, `union` (closed, +exhaustively matchable), `enum`, `id`, `opaque` (cursors — echoable, never +inspectable), `asset`. Projections singleton or keyed (`key = "<id-type>"`). +Commands declare a payload record and declared refusals; every outcome +union is implicitly extended by `unavailable { reason: text }`. **Ok +payloads are empty for every spike command** — settlement data travels as +projection updates; no fact has two carriers. Refusals carry no fields in +the spike (`Refused { refusal }`). + +```toml +# ports/feed.port.toml (abridged) +[port] name = "feed" version = "0.1.0" + +[types.image-ref] kind = "record" +[types.image-ref.fields] src = "asset" alt = "text" + +[types.user-ref] kind = "record" +[types.user-ref.fields] +id = "id" username = "text" display-name = "text" avatar = "image-ref" + +[types.slide] kind = "record" +[types.slide.fields] id = "id" src = "asset" alt = "text" + +[types.story-ring] kind = "record" +[types.story-ring.fields] +id = "id" user = "user-ref" has-unseen = "bool" is-self = "bool" + +[types.media] kind = "union" +[types.media.variants.image] image = "image-ref" +[types.media.variants.carousel] slides = "list<slide>" +[types.media.variants.video] poster = "image-ref" + +[types.post-summary] kind = "record" +[types.post-summary.fields] +id = "id" author = "user-ref" media = "media" caption = "text" +like-count = "int" comment-count = "int" viewer-has-liked = "bool" +posted-label = "text" # provider-formatted; core has no clock + +[types.feed-cursor] kind = "opaque" +[types.feed-page] kind = "record" +[types.feed-page.fields] +stories = "list<story-ring>" posts = "list<post-summary>" +cursor = "option<feed-cursor>" has-more = "bool" + +[projections.viewer] type = "user-ref" boot = true # delivered before Init +[projections.feed-page] type = "feed-page" + +[refusals.not-authorized] [refusals.rate-limited] [refusals.not-found] + +[commands.like-post] payload = { post = "id" } +refusals = ["not-authorized", "rate-limited", "not-found"] +[commands.unlike-post] payload = { post = "id" } +refusals = ["not-authorized", "not-found"] +[commands.load-next-page] payload = { cursor = "option<feed-cursor>" } +[commands.reload] payload = {} +``` + +`comments.port.toml`: keyed projection `for-post` + `add-comment`; +`profile.port.toml`: keyed projection `profile`. **No `loading`/`failed` +variants exist in any projection type** — a Spock read model cannot honestly +export non-delivery or transport failure as data (adjudication #1). +Versioning: canonical-form hash pinned in `uhura.lock`; drift is a link +error, never silent. + +### 9.2 Availability — session truth, not contract truth + +Projections are **absent until delivered**. View-position reads must sit +inside an availability `{#match}` (`loading | failed reason | ready v` — +language-level arms, not contract types). Keyed reads: `for-post(post)`, +`profile(user)`. Guard/body reads short-circuit per §4.2's transactional +rule. `boot = true` projections (viewer) are delivered before `Init`; bare +reads of boot projections are legal; the examples resolver auto-binds them. +No subscription-interest machinery in the spike — the fixture delivers +authored instances eagerly on scripted ticks (interest derivation is a +documented future refinement; the sheet's loading arm is exercised by +examples and a delayed-delivery trace script). + +### 9.3 The provider envelope (`uhura-provider/0`) + +```json +{ "kind": "command", "port": "feed", "command": "like-post", + "correlation": "c-4", "payload": { "post": "post-lena-glaze" } } + +{ "kind": "projection", "port": "feed", "projection": "feed-page", + "key": null, "revision": 2, "value": { … } } +{ "kind": "projection-failed", "port": "feed", "projection": "feed-page", + "key": null, "reason": "unreachable" } +{ "kind": "outcome", "correlation": "c-4", + "outcome": { "ok": {} }, + "updates": [ { "port": "feed", "projection": "feed-page", + "key": null, "revision": 3, "value": { … } } ] } +``` + +Rules: exactly one outcome per command, eventually (adapters map transport +failure to `unavailable`; core has no timeouts); one ordered stream, never +re-entrant; projection revisions strictly increase per (projection, key), +stale drops diagnosed; correlation ids are core-minted, opaque, echoed +verbatim — author-level anything never crosses the wire. + +### 9.4 Settlement — enforceable, not fixture-lore + +The projection consequence of an accepted command arrives **either as an +earlier standalone update or piggybacked on the outcome's `updates`**, +applied atomically before the outcome dispatches. A real adapter satisfies +this by carrying the command's resulting read-model fragment in the command +response (ordinary CQRS) or holding the outcome until its subscription +catches up — checkable per outcome. Clear-overlay-on-`.ok` is flicker-free +by construction; the conformance suite asserts it against any provider. + +### 9.5 The fixture driver + +Data slices in `fixtures/standard.toml` (named, typed; L8-validated against +contracts at link time — an ill-typed fixture is a link error). Scripts in +`fixtures/scripts/*.toml`: entries match `on = { command, where }` in file +order among unconsumed entries, one-shot unless `repeat = true`; +`after-ticks ≥ 1`; `on-unscripted = "error"` (a duplicate in-flight +load-next **is** the dedupe assertion). Replies reference **whole authored +snapshot slices** — no set-op path language; the only substitutions are +`{ from = "payload.<field>" }` and `fresh-id`. **One driver implementation** +(`uhura-fixture`, Rust), compiled to wasm for play and linked natively for +traces; the TS shell owns only the tick pump, and envelope JSON between +`Session` and `FixtureDriver` stays visible in the shell. + +### 9.6 The Spock-replaceability argument + +Provably unchanged when Spock replaces the fixture: port contracts (hash +pin), every `.uhura` file (no fixture vocabulary in the grammar — +grep-provable), checked IR and core (no provider-identity input; +`uhura-core`'s dependency closure excludes `uhura-fixture`, CI-enforced), +the envelope and its rules (conformance suite runs against any driver), V, +both renderers, the stylesheet, examples, and replay determinism. What +changes: one `uhura.lock` binding line per port; the driver implementation +(ticks vanish; wall-clock lives only in the adapter); live delivery order +becomes nondeterministic (per-step determinism untouched; the fixture stays +the permanent CI test double); fixture-only semantics disappear. + +--- + +## 10. The semantic element catalog + +**Nine elements, three classes, checked by generic rules; layout and +aesthetics belong to CSS.** The catalog is data (`catalog/base.toml`, +versioned + hash-pinned); source cannot invent an element, prop, or event by +naming it; the checker validates the catalog against a meta-schema (input +events only on interactive elements; observation events only on viewports). + +| Element | Class | Semantic props | Events | +|---|---|---|---| +| `view` | layout | `role(none\|list\|navigation\|tablist)` | — (never) | +| `scroll` | layout/viewport | `direction(vertical\|horizontal)` | `near-end` (observe) | +| `pager` | layout/viewport | `indicator(none\|dots)`, `label`; children from one keyed each; **uncontrolled** | (`page-change` when controlled — unused) | +| `text` | content | content = typed data / interpolation | — | +| `image` | content | `src` (asset ref), `alt` xor `decorative` | — | +| `icon` | content | `name` (closed set), decorative by default | — | +| `button` | interactive | `label`, `disabled`, `busy`, `pressed?`, `current?`; content children, no interactive descendants | `press` | +| `text-field` | interactive | controlled `value`, `placeholder`, `label`, `disabled` | `change{value}`, `submit` | +| `region` | interactive | `label` (required), `supplementary`; one child, no interactive descendants | `activate`, `activate-double` | + +Every element additionally takes `class` (opaque, CSS-owned). Icon set +(closed, recomputed from slice usage): `home search plus reels profile heart +heart-filled comment close back grid layers video-off progress`. + +**What is deliberately NOT an element:** `column/row/stack/grid/spacer` +(CSS layout on `view`), `card/avatar/tab-bar/app-bar/list` (documented +patterns in `docs/patterns/`, golden-checked), `sheet/dialog` (core surface +stack), `video` (poster + `video-off` fallback pattern), any styling prop +(`gap/pad/ratio/shape/kind/size/color/lines` — all CSS now). + +**Extension is first-class in design, deferred in exercise:** a catalog is +data, so a user/renderer pair may register additional elements with full +signatures (props, events, a11y contract, projection behavior); the checker +enforces them identically; a renderer must declare which catalogs it +implements. The spike ships only `base`; the mechanism is specified so +"users can define their own widgets" is true at both levels — components +(composition, today) and catalog elements (new primitives, with a renderer). + +The checker rules survive unchanged in spirit: catalog authority, event +eligibility (`on:` only where declared — a `view` can never become +interactive), children models, **required keyed each**, no nested +interactives, controlled-state promotion (binding `value` obligates handling +`change`), a11y completeness (alt xor decorative; accessible names; +`role=list` requires one keyed each), viewport sanity. Style-prop closure is +**deleted** — replaced by the shallow CSS checks of §4.5. + +**Double-tap-to-like** stays owned by `region` — the catalog-declared +accessible gesture owner (focusable, named, keyboard synthesis, AT-exposed +action). `supplementary` regions need a same-named emit reachable from a +focusable element in the same component (name-level check; payload equality +is a lint). + +--- + +## 11. The Instagram slice + +### 11.1 Inventory + +Two routed pages (`/feed`, `/profile/[user]`), one surface +(`comments-sheet`), bottom-nav chrome (5 tabs, 3 honestly disabled), +notice bar (persists until dismissed — no timers exist). Out: post-detail, +follow, viewer/creation/realtime. Profile grid tiles are **plain images** — +no dead affordances. + +### 11.2 The cast (no lorem ipsum) + +Viewer: **Mira Santos** (`mira.santos`), food & travel photographer, +Lisbon. Feed page 1: **Lena Holt** (ceramicist — glaze tiles, 214 likes, 4 +comments), **Marco Reyes** (surfer — 3-slide Baja carousel, 892), **Nils +Bergman** (night-sky — aurora *video* → poster placeholder, 2,431), **Priya +Raman** (baker — "Day 400 of the starter. She's earned a name: Clint +Yeastwood."). Page 2: Ayla Demir, June Park, Theo Okafor, **Kenji Tanaka** +(pre-liked — proves projection-truth hearts without overlays). Mira's demo +comment: *"Saving this palette for my kitchen reno — stunning work!"* +Time/count labels are provider-formatted strings ("2h", "18.2k"). ~37 local +JPEGs with manifest-required alt text. + +### 11.3 Example sets (canvas board) + +Feed: `loading · first-page (default) · like-pending · like-refused · +comments-open · load-pending · load-failed · appended · exhausted · empty · +failed`. Profile: `loading · lena-posts (default) · tagged-empty · self`. +Comments-sheet standalone: `populated (default) · composing · +pending-append · empty`. Components: post-card (image / carousel-liked / +video-poster / long-caption), stories-tray, comment-row (settled/pending), +bottom-nav, notice-bar, profile-header. + +### 11.4 Demo walkthrough (play mode) and CI scripts + +1. Launch → loading → feed settles (tray, image, carousel, video poster, + Priya's post). +2. Like Lena's post → heart fills **and count reads 215** instantly (the + count is computed from the overlay in post-card, §4.6), button busy; ok + settles via piggybacked update; trace shows exactly one command. +3. Like Marco's → optimistic beat → `rate-limited` refusal → heart and + count roll back; notice bar appears; **after dismissing the notice, the + feed subtree is byte-identical to pre-like** (the scoped invariant). +4. Double-tap Priya's photo → same event via `region` (keyboard: focus, + Enter). +5. Open comments on Lena's post → sheet mounts, feed inert, focus enters; + 4 authored comments. +6. Type Mira's comment, Post → dimmed "Posting…" row instantly; the + authoritative comment replaces it atomically; post-card meta shows + 5 comments. +7. Close sheet → focus returns to the comment button (FocusRestore intent + traced). +8. Scroll to bottom → one `load-next-page`; wiggle-scroll → no second + command (guard). +9. Failure → retry → page 2 appends with zero scroll jump, keys preserved; + Kenji pre-filled; end cap; zero further commands. +10. Tap `lena.holt` → profile (header, stats, grid); Tagged → empty; back → + feed intact (page 2 present, Lena still liked). +11. Bottom tab → self profile; Home → intact feed; disabled tabs + unfocusable. + +**Canonical trace scripts** (one list, used by §3, CI, and goldens): +`like-ok`, `like-refused`, `comment-ok`, `paginate`, `feed-failed`, +`feed-empty`, plus `demo` (play only). + +### 11.5 Coverage + +Every element, machine feature, and core mechanic is forced at least once; +the matrix lives next to the acceptance test. Three `scroll` uses (feed +with observation; stories tray horizontal without; comments without) prove +the list-concern split in both directions. + +--- + +## 12. Toolchain and workspace + +### 12.1 Crates + +Cargo workspace under `uhura/`; pinned Rust toolchain only; no pnpm/node +coupling; `edition = "2024"`, `unsafe_code = "forbid"`, all `publish = false`. + +``` +uhura/crates/ + uhura-base # foundation: Value/Ident + the canonical-JSON/SHA-256 choke + # point, spans, SourceMap, diagnostics, UHnxxx registry + uhura-syntax # lexer + RD parsers (markup, store DSL, CSS-selector + # tokenizer for the shallow style checks), AST, formatter + uhura-port # contract model + provider envelopes — the Spock seam crate + uhura-check # the front half: resolve, routes, catalog-as-data (module), + # typecheck, ports L1–L8, style checks, examples replay, lower + uhura-core # checked IR (module `ir`), view protocol (module `view`), + # step_u + eval_view; dep closure = {base, port} + uhura-fixture # scripted driver (native + wasm) + uhura-project # resolved examples → HTML + embedded CSS → canvas.html + uhura-wasm # wasm-bindgen: Session + FixtureDriver, JSON-string ABI + uhura-cli # bin `uhura`: check | fmt | project | dev | trace (all I/O here) + uhura-tests # goldens, purity tests, acceptance integration test +``` + +Topology note (post-M1 simplification): earlier drafts listed 14 crates; the +implemented workspace is these **10**. Crate boundaries exist only where they +are load-bearing — mechanically enforced theses (core purity, the port seam, +the fixture outside core, I/O quarantined in the CLI) or technical necessity +(wasm cdylib). `value`+`diag` merged into `uhura-base`; `catalog` became a +module of `uhura-check`; `ir` and `view` became modules of `uhura-core` +(every consumer of those types already depends on core, and the purity +allowlist got simpler). `syntax` stays separate so checker edits never +recompile the parser and the formatter is reusable alone. + +Core purity is a failing test, not a convention: dependency-DAG allowlist +asserted via `cargo metadata` (`uhura-core` closure == `{base, port}`) plus a +`cargo tree` check that a core-only build never compiles `toml`; per-crate +`clippy.toml` disallowing `std::{fs,net,time,thread,env}` in pure crates; +CI `-D warnings`. + +### 12.2 Parser and IR + +Hand-rolled recursive descent for both the store DSL and the markup (exact +diagnostic codes, trivia-preserving formatter round-trip, recovery trees; +the closed grammar makes generators overhead). CSS handling is a selector +tokenizer only — declarations pass through verbatim. The checked IR **is +serialized** (versioned canonical JSON, hard version check) and is the +artifact shipped to the browser: `uhura dev` checks natively and ships IR + +compiled stylesheet, so `.uhura`/CSS edits never trigger a wasm rebuild. + +### 12.3 Wasm ABI + +```rust +#[wasm_bindgen] pub struct Session { … } // new(ir_json) · boot(boot_json) + // · dispatch(event_json) → step-result JSON + // · view() · revision() · ir_version() +#[wasm_bindgen] pub struct FixtureDriver {…} // new(fixture_json, script_json) + // · deliver(cmd_json) · tick() → event-json[] · idle() +``` + +JSON strings across the boundary; the shell wires the two by passing +envelope JSON — the seam stays visible. No timers/fetch/DOM inside wasm. + +### 12.4 CLI and diagnostics + +`uhura check [--emit-ir]` · `uhura fmt [--check]` · `uhura project [--out]` +· `uhura dev [--port]` (tiny_http + SSE; watch → recheck → **full-restart +hot reload on last-good IR** + diagnostics overlay — state-preserving reload +is an open RFC topic the spike must not fake) · `uhura trace --script +[--expanded]`. Exit codes 0/1/2; `--deny-warnings` in CI. One versioned +diagnostics envelope (`uhura-diagnostics/0`: `code UHnxxx` + `rule` slug, +span, labels, notes, `fix{title, edits}`). + +### 12.5 Tests + +Unit tests per crate; goldens (`UPDATE_GOLDEN=1` blessing): fmt round-trip, +diagnostics, whole-app IR, resolved example → V per preview, and the +canonical trace scripts (§11.4) in `StepTrace` hash form. **The acceptance +trace is one executable integration test** asserting golden traces plus +structural invariants: exactly one like command per press; optimistic view +precedes outcome; **post-refusal, after `notice-dismissed()`, the feed +subtree hash equals pre-like**; one in-flight load-next; append preserves +key order; dismiss emits FocusRestore; `uhura project` emits zero commands; +IR bytes identical with/without examples files. CI adds fmt --check, +clippy, wasm build, `project` on the example app, canvas.html as a PR +artifact. + +### 12.6 Milestones (each ends demoable) + +| # | Builds | Demo after it | +|---|---|---| +| M0 | Workspace skeleton, toolchain pin, purity tests, CI green | the boundary exists before any feature | +| M1 | Lexer/parser (store DSL + markup + CSS selectors), formatter; `uhura fmt`, parse-only `check` | format the whole example; spanned diagnostic on a planted error | +| M2 | Full check: routes, types, catalog rules, ports L1–L8, style checks, IR emit | `check` clean on the slice; `on:press` on a `view` fails correctly | +| M3 | `eval_view` + stylesheet compile + **pinned** examples + `uhura project` | **the good-looking canvas** — pan/zoom every pinned preview | +| M4 | step_u (dispatch, guards, sends, overlays, surfaces), fixture driver, `uhura trace`, **derived-example replay** | headless like→optimistic→refusal→rollback as diffable golden JSON; derived previews join the canvas | +| M5 | wasm Session + Driver, play shell (reconciler, pump, text-field), `uhura dev` | live prototype; edit a file → hot restart | +| M6 | Pagination, profile route, focus restore, full acceptance test | the complete walkthrough in CI and on screen | + +(Derived examples fold `step_u`, so replay lands in M4, after the machine +exists; M3's canvas is pinned examples only.) + +--- + +## 13. Acceptance criteria + +1. `uhura check examples/instagram` is clean; each documented rejection + produces its exact diagnostic. +2. Like emits exactly one typed command per press; the optimistic view + (heart *and* computed count) precedes the outcome; refusal rolls the + affected post-card subtree back to byte-equality with its pre-like + render (asserted after notice dismissal); settlement via piggybacked + update never flickers. +3. Comments: open mounts one keyed surface instance with typed input; + optimistic append visible ≥ 1 tick; dismissal restores focus (intent + asserted in trace). +4. Pagination: one `load-next-page` per near-end episode, duplicates + guard-rejected and traced; failure → retry; append preserves existing + keys in order; exhausted derives from projection truth. +5. Navigation: feed → profile → back retains feed page state; history + intents are emitted and traced (and executed as no-ops). +6. `uhura project` renders every example preview executing zero transitions + and zero I/O at projection time (derivation is a checked build step over + pure `step-u`); derived examples fail check when the machine no longer + reaches them; uncorrelated outcome injection is a check error. +7. Fixed inputs reproduce byte-identical traces and V hashes across native + and wasm runs. +8. The checked IR is byte-identical with and without `*.examples.uhura`. + +--- + +## 14. Deferred register + +Language: component slots/children; shared layouts; import aliasing; +surface results to opener; match-in-expressions; local enum declarations +(tab sections use `text` guards); string builtins; floats; division; +scoped-CSS transformation (rooting-by-convention for the spike). Core: +command cancellation/timeouts; browser-history reconcile + +`LocationChanged`; projection-update handlers; checkpoint/hot-reload state +migration; pending garbage policy. View: patches; visibility observation; +annotated/rich text; controlled pager exercise; prepend/refresh anchoring; +capability negotiation; dark theme; aria-live conventions. Ports: +subset/superset provider satisfaction; contract evolution; subscription +interest sets; realtime `[[push]]` scripting; client-minted ids. Examples: +cross-page journey strips (NCC flows); `expect` assertions over V; board +curation. Toolchain: incremental CST; binary IR/ABI; release policy. +Catalog: third-party catalog exercise (mechanism specified, §10). + +--- + +## 15. Adjudication log + +### v1 — reconciling seven parallel designs (all upheld in v2 unless noted) + +| # | Conflict | Resolution | +|---|---|---| +| 1 | `loading/failed` inside projection contract types vs derived availability vs epoch-0 seeding | Contracts carry only authoritative data; availability is session/protocol truth; boot projections declared in the contract | +| 2 | Settlement ordering only a fixture could honor | Piggybacked `updates` on the outcome envelope, applied before dispatch — checkable per outcome | +| 3 | Subscription-interest set vs un-keyed projections | Keyed projections; interest machinery cut; eager fixture delivery | +| 4 | Four correlation-tag models | Core-minted tags (`send … as t`), payload echo to `.ok(tag, cmd)` / `.err(tag, cmd, refusal)`; wire carries opaque `c-<n>` only | +| 5 | Handler multiplicity | Multi-handler, source order, first satisfied guard | +| 6 | Two "closed" statement sets; internal events | Five statements; no internal events at all | +| 7 | Widget vocabulary drift | v1: taxonomy catalog normative. **v2: superseded by #21–#23** | +| 8 | Controlled carousel demo vs uncontrolled pager | Uncontrolled; false demo beats deleted | +| 9 | Floats | None anywhere; near-end threshold restated as an integer percentage | +| 10 | Overlay shadowing projection truth after `.ok` | Overlays deleted on both `.ok` and `.err` | +| 11 | Dismiss descriptor on a layout node | First-class `dismiss` on `SurfaceInstance` | +| 12 | Stale-event policy | Core's rule: scope-alive + occlusion + guards | +| 13 | Scope/instance identity | Core-minted serials; logical (def, context) key core-internal | +| 14 | Competing schemas (port TOML, fixture, ABI, diagnostics, traces) | One owner each (ports / ports / toolchain / toolchain / core) | +| 15 | Dual Rust+TS fixture drivers | Single Rust driver, compiled to wasm for play | +| 16 | Generated-SVG vs JPEG assets | JPEGs, deduped data-URI custom properties; SVG fallback | +| 17 | Browser-history scope | Intents traced, executed as no-ops; reconcile deferred | +| 18 | Examples: outcome laundering, projection injection, route params, pinned propagation | Correlation required; timeline projection entries; `params`; taint propagation | +| 19 | Ok payloads carrying data | All empty — data travels only as projection updates | +| 20 | Grid tiles as buttons to nowhere | Plain images — no dead affordances | + +### v2 — the direction reframe ("successor of HTML/CSS + widgets") + +| # | Challenge | Resolution | +|---|---|---| +| 21 | "Let authors leverage CSS; the token/layout taxonomy is verbose reinvention" | **Conceded.** Styling is web-native CSS (`class`, co-located `<style>`, tokens as custom properties); style-prop closure and the generated class system deleted; checker does shallow selector/rooting/existence checks only. Consequence, named plainly: renderer neutrality is retained for *semantics* (V) and relinquished for *styling* (CSS is web-targeted; a native renderer would need its own style layer). | +| 22 | "Drop the layout taxonomy" | **Conceded.** `column/row/stack/grid/spacer` and every style prop removed; `view` (+`role`) is the only container; CSS does layout. The element set shrinks to nine, covering exactly what must stay checkable: interaction, content safety, a11y, observation. | +| 23 | "View should be extensible; users define their own widgets; core is just xml/html" | **Half-conceded.** Markup-shaped Svelte-flavored syntax adopted; catalog extension specified as first-class data (user-registered elements with full signatures + a renderer that implements them). **Held:** the element set stays closed-but-extensible, never raw HTML — `<div onclick>` must not typecheck, because event eligibility, inert user content, a11y contracts, and the corpus's invented-signature P0s all die otherwise. | +| 24 | "Why not Svelte / build on its compiler" | Svelte's surface adopted (SFC shape, blocks, `on:`, keyed each, co-located styles); its compiler rejected as a foundation (JS-hosted semantics, spec-by-implementation, unstable AST, no Rust/wasm core). Recorded in §1. | +| 25 | "Uhura store + uhura view" split | Adopted as the file anatomy: `store { }` is the model/controller (unchanged machine language); markup is the view. The reframe confirmed rather than changed the machine design. | +| 26 | "Minimal Svelte that does not support JS" | Adopted as the project's elevator pitch — with the addition that makes it Uhura: a deterministic, replayable, headless machine and typed ports where the JS would have been. | + +### v2 — verification-pass repairs (final-gate verifier, all folded) + +Component-emit consumption defined (one explicit call-site model: rebind or +bare-forward; no implicit propagation) — was the blocker. Optimistic +like-count computed in post-card from the overlay (demo step 2 now +implementable). Rollback byte-equality scoped to the post subtree / +post-dismissal. `type` added to port-import grammar. Handler dispatch made +transactional (not-ready reads abort atomically). M3/M4 split (pinned +examples vs derived replay). `story-ring`/`slide` declared; `Refused.data` +dropped; carried-field type unified to `text`; canonical trace-script list; +boot-projection auto-binding in the examples resolver; `notice = none` +initializer; token-enum values admitted to the type system; post-card +renders `posted-label`/`comment-count`; threshold restated as integer; the +TERM-rule and `pad`-on-`text` findings were mooted by the markup/CSS +reframe. diff --git a/uhura/rust-toolchain.toml b/uhura/rust-toolchain.toml new file mode 100644 index 0000000..ccb4b7e --- /dev/null +++ b/uhura/rust-toolchain.toml @@ -0,0 +1,6 @@ +[toolchain] +# Exact pin, never "stable" (design doc §12.1). Bumping is a deliberate commit. +channel = "1.92.0" +components = ["rustfmt", "clippy"] +# wasm target pinned from M0 so this file stays immutable through M5. +targets = ["wasm32-unknown-unknown"] diff --git a/uhura/scripts/build-wasm.sh b/uhura/scripts/build-wasm.sh new file mode 100755 index 0000000..a7815c2 --- /dev/null +++ b/uhura/scripts/build-wasm.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Builds the uhura-wasm bundle for both targets (design §12.3): +# crates/uhura-wasm/pkg/web/ — ES module for the play shell +# crates/uhura-wasm/pkg/node/ — CommonJS for scripts/parity.mjs +# +# wasm-bindgen-cli MUST match the workspace's wasm-bindgen pin exactly +# (Cargo.lock) — the CLI and the crate write two halves of one ABI. +set -euo pipefail +cd "$(dirname "$0")/.." + +WBG_VERSION="$(grep -A1 'name = "wasm-bindgen"' Cargo.lock | grep version | head -1 | cut -d'"' -f2)" + +WBG="${WASM_BINDGEN:-}" +if [ -z "$WBG" ]; then + if [ -x "target/tools/bin/wasm-bindgen" ]; then + WBG="target/tools/bin/wasm-bindgen" + elif command -v wasm-bindgen >/dev/null 2>&1; then + WBG="$(command -v wasm-bindgen)" + else + echo "wasm-bindgen-cli $WBG_VERSION is required. Install it project-local:" >&2 + echo " cargo install wasm-bindgen-cli --version $WBG_VERSION --locked --root target/tools" >&2 + exit 2 + fi +fi + +ACTUAL="$("$WBG" --version | awk '{print $2}')" +if [ "$ACTUAL" != "$WBG_VERSION" ]; then + echo "wasm-bindgen-cli is $ACTUAL but the workspace pins $WBG_VERSION (Cargo.lock)" >&2 + echo " cargo install wasm-bindgen-cli --version $WBG_VERSION --locked --root target/tools" >&2 + exit 2 +fi + +cargo build --locked -p uhura-wasm --target wasm32-unknown-unknown --release + +WASM="target/wasm32-unknown-unknown/release/uhura_wasm.wasm" +rm -rf crates/uhura-wasm/pkg +"$WBG" --target web --out-dir crates/uhura-wasm/pkg/web --no-typescript "$WASM" +"$WBG" --target nodejs --out-dir crates/uhura-wasm/pkg/node --no-typescript "$WASM" + +# The repo root's package.json says `"type": "module"`; pin each bundle's +# own module kind so node never misparses the CommonJS output. +printf '{ "type": "commonjs" }\n' > crates/uhura-wasm/pkg/node/package.json +printf '{ "type": "module" }\n' > crates/uhura-wasm/pkg/web/package.json + +echo "built pkg/web + pkg/node (wasm-bindgen $WBG_VERSION)" diff --git a/uhura/scripts/parity.mjs b/uhura/scripts/parity.mjs new file mode 100644 index 0000000..5148cf0 --- /dev/null +++ b/uhura/scripts/parity.mjs @@ -0,0 +1,217 @@ +// Native ↔ wasm parity (design §12.5, §13): replays a script through the +// REAL wasm32 binary (pkg/node, built by scripts/build-wasm.sh) with the +// same JSON-only pump the play shell and the native ABI-contract test +// use, and diffs the per-step trace lines byte-for-byte against the +// native harness's output. +// +// Inputs (a directory of prepared artifacts): +// ir.json — canonical uhura-ir/0 (uhura check --emit-ir) +// fixture.json — resolved slices (what `uhura dev` serves) +// script.json — the script as JSON +// boot.json — {"updates": […]} boot deliveries +// native.jsonl — `uhura trace --script=<name>` output +// +// Usage: node scripts/parity.mjs <artifact-dir> +// M6 automates artifact preparation per canonical script; until then the +// quickest source is a running `uhura dev` (curl the four endpoints) plus +// `uhura trace` for native.jsonl. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +const here = fileURLToPath(new URL(".", import.meta.url)); +const require = createRequire(import.meta.url); +const { Session, FixtureDriver, protocols } = require( + join(here, "../crates/uhura-wasm/pkg/node/uhura_wasm.js"), +); + +const dir = process.argv[2]; +if (!dir) { + console.error("usage: node scripts/parity.mjs <artifact-dir>"); + process.exit(2); +} +const read = (name) => readFileSync(join(dir, name), "utf8"); + +const spoken = JSON.parse(protocols()); +if (spoken.ir !== "uhura-ir/0" || spoken.view !== "uhura-view/0" || spoken.provider !== "uhura-provider/0") { + console.error(`protocol mismatch: ${JSON.stringify(spoken)}`); + process.exit(1); +} + +const irText = read("ir.json"); +const script = JSON.parse(read("script.json")); +const native = read("native.jsonl").trim().split("\n"); + +// ── the same pump as shell/main.js and abi_contract.rs ────────────────── +const session = new Session(irText); +session.boot(read("boot.json")); +const driver = new FixtureDriver(read("fixture.json"), JSON.stringify(script)); + +const stimuli = (script.ui ?? []).map((entry) => ({ + atTick: entry["at-tick"], + emit: entry.emit, + where: entry.where ?? {}, + data: entry.data ?? {}, +})); + +const lines = []; +function dispatch(event) { + const raw = session.dispatch(JSON.stringify(event)); + // The compared artifacts are extracted from the RAW canonical envelope + // bytes — never round-tripped through JS numbers, so the byte-parity + // verdict is faithful for the whole i64 domain. + lines.push(extractTop(raw, "t")); + for (const c of arrayElements(extractTop(raw, "c"))) driver.deliver(c); + return JSON.parse(raw).v; +} + +/** + * The raw value substring of a top-level key in one canonical JSON + * object (canonical ⇒ object/array/string/number/bool/null, no floats). + */ +function extractTop(raw, key) { + const needle = `"${key}":`; + let depth = 0; + let inStr = false; + let esc = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { + if (depth === 1 && raw.startsWith(needle, i)) { + return sliceValue(raw, i + needle.length); + } + inStr = true; + } else if (ch === "{" || ch === "[") depth += 1; + else if (ch === "}" || ch === "]") depth -= 1; + } + throw new Error(`no top-level "${key}" in the step result`); +} + +function sliceValue(raw, start) { + let depth = 0; + let inStr = false; + let esc = false; + for (let i = start; i < raw.length; i += 1) { + const ch = raw[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + } else if (ch === '"') inStr = true; + else if (ch === "{" || ch === "[") depth += 1; + else if (ch === "}" || ch === "]") { + depth -= 1; + if (depth === 0) return raw.slice(start, i + 1); + } else if (depth === 0 && (ch === "," || ch === "}")) { + return raw.slice(start, i); // bare scalar value + } + } + throw new Error("unbalanced JSON value"); +} + +/** Splits a raw canonical JSON array into raw element substrings. */ +function arrayElements(rawArray) { + const out = []; + let depth = 0; + let inStr = false; + let esc = false; + let start = -1; + for (let i = 0; i < rawArray.length; i += 1) { + const ch = rawArray[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') inStr = true; + else if (ch === "{" || ch === "[") { + depth += 1; + if (depth === 2) start = i; + } else if (ch === "}" || ch === "]") { + if (depth === 2 && start >= 0) { + out.push(rawArray.slice(start, i + 1)); + start = -1; + } + depth -= 1; + } + } + return out; +} + +const matches = (d, stim) => + d.emit === stim.emit && + Object.entries(stim.where).every(([k, v]) => JSON.stringify(d.payload?.[k]) === JSON.stringify(v)); + +function findDescriptor(view, stim) { + const found = []; + const walk = (node) => { + for (const d of node.on ?? []) if (matches(d, stim)) found.push(d); + for (const child of node.children ?? []) walk(child); + }; + walk(view.page.root); + for (const surface of view.surfaces) { + walk(surface.root); + if (matches(surface.dismiss, stim)) found.push(surface.dismiss); + } + const distinct = found.filter( + (d, i) => + found.findIndex( + (o) => o.emit === d.emit && o.scope === d.scope && canonical(o.payload) === canonical(d.payload), + ) === i, + ); + if (distinct.length !== 1) { + throw new Error(`stimulus \`${stim.emit}\` matched ${distinct.length} descriptors`); + } + return distinct[0]; +} + +let view = dispatch({ kind: "init", route: JSON.parse(irText).entry, params: {} }); +let tick = 0; +let next = 0; +while (!(driver.idle() && next >= stimuli.length)) { + tick += 1; + if (tick > 10_000) throw new Error("the script did not quiesce"); + for (const msgJson of driver.tick()) { + const msg = JSON.parse(msgJson); + view = dispatch(msg.kind === "projection" ? { kind: "projection", updates: [msg] } : msg); + } + while (next < stimuli.length && stimuli[next].atTick === tick) { + const stim = stimuli[next++]; + const event = { kind: "ui", descriptor: findDescriptor(view, stim), "view-rev": view.revision }; + if (Object.keys(stim.data).length > 0) event.data = stim.data; + view = dispatch(event); + } +} + +// ── canonical JSON (stimulus matching only — trace lines never pass +// through here) ───────────────────────────────────────────────────────── +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value !== null && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +// ── diff ───────────────────────────────────────────────────────────────── +if (lines.length !== native.length) { + console.error(`step count diverged: wasm ${lines.length}, native ${native.length}`); + process.exit(1); +} +for (let i = 0; i < lines.length; i += 1) { + if (lines[i] !== native[i]) { + console.error(`step ${i} diverged:\n wasm: ${lines[i]}\n native: ${native[i]}`); + process.exit(1); + } +} +console.log(`parity: ${lines.length} steps byte-identical (native ↔ wasm32)`); diff --git a/uhura/shell/appliers.js b/uhura/shell/appliers.js new file mode 100644 index 0000000..d229433 --- /dev/null +++ b/uhura/shell/appliers.js @@ -0,0 +1,227 @@ +// Semantic props → DOM/ARIA, one applier per catalog element (§8.2). +// Mirrors the static renderer in crates/uhura-project (same `uh-<element>` +// bases, same ARIA mapping) so both renderers stay two consumers of one +// protocol. Human text lands via TEXT NODES / attribute strings only — +// never markup. + +/** @param {import("./types.js").VValue | undefined} v */ +export function textOf(v) { + if (typeof v === "string") return v; + if (typeof v === "object" && v !== null && "t" in v && v.t === "plain") return v.v; + return undefined; +} + +/** @param {import("./types.js").VValue | undefined} v */ +function boolOf(v) { + return v === true; +} + +/** @param {import("./types.js").VValue | undefined} v */ +function assetOf(v) { + if (typeof v === "object" && v !== null && "t" in v && v.t === "image") return v.asset; + return undefined; +} + +/** Asset ids resolve against the dev server (§12.4). */ +function assetUrl(/** @type {string} */ id) { + return `/assets/${encodeURIComponent(id)}.jpg`; +} + +/** + * @param {HTMLElement} el + * @param {string} name + * @param {string | undefined} value set when defined, remove when not + */ +function setAttr(el, name, value) { + if (value === undefined) el.removeAttribute(name); + else if (el.getAttribute(name) !== value) el.setAttribute(name, value); +} + +/** + * The DOM tag an element renders as (everything else is a div). + * @param {string} element + */ +export function tagFor(element) { + switch (element) { + case "text": + return "p"; + case "icon": + return "span"; + case "button": + return "button"; + default: + return "div"; + } +} + +/** + * Applies one node's semantic props. Structure-owning elements + * (text-field's input, pager's track/dots, icon's svg) manage their + * mechanic children here; the reconciler owns keyed children. + * @param {HTMLElement} el + * @param {import("./types.js").VNode} node + * @param {{ glyphs: Record<string, string>, + * textFields: ReturnType<import("./textfield.js").createTextFields>, + * holderOf: (el: HTMLElement) => { path: string, + * on: Record<string, import("./types.js").Descriptor> } }} ctx + */ +export function applyProps(el, node, ctx) { + const props = node.props; + switch (node.element) { + case "view": { + const role = textOf(props["role"]); + setAttr( + el, + "role", + role === "list" || role === "navigation" || role === "tablist" ? role : undefined, + ); + break; + } + + case "scroll": + setAttr(el, "data-direction", textOf(props["direction"]) ?? "vertical"); + break; + + case "pager": { + const label = textOf(props["label"]); + setAttr(el, "role", "group"); + setAttr(el, "aria-label", label); + // Keyed children live inside .uh-track (the reconciler asks for it + // via childHost); dots are pure renderer mechanics. + const track = ensureTrack(el); + if (textOf(props["indicator"]) === "dots") { + const dots = ensureDots(el); + const count = node.children?.length ?? 0; + while (dots.children.length > count) dots.lastElementChild?.remove(); + while (dots.children.length < count) { + const dot = document.createElement("span"); + dot.className = "uh-dot"; + dots.append(dot); + } + updateDots(el, track); + } else { + el.querySelector(":scope > .uh-dots")?.remove(); + } + break; + } + + case "text": + // Interpolations were evaluated by core into one plain `content` + // prop; a text node is the only carrier (§8.2). + if (el.textContent !== (textOf(props["content"]) ?? "")) { + el.textContent = textOf(props["content"]) ?? ""; + } + break; + + case "image": { + const asset = assetOf(props["src"]); + el.style.backgroundImage = asset ? `url("${assetUrl(asset)}")` : ""; + if (boolOf(props["decorative"])) { + setAttr(el, "aria-hidden", "true"); + setAttr(el, "role", undefined); + setAttr(el, "aria-label", undefined); + } else { + setAttr(el, "role", "img"); + setAttr(el, "aria-label", textOf(props["alt"])); + setAttr(el, "aria-hidden", undefined); + } + break; + } + + case "icon": { + const name = textOf(props["name"]) ?? ""; + setAttr(el, "aria-hidden", "true"); + if (el.getAttribute("data-icon") !== name) { + setAttr(el, "data-icon", name); + const glyph = + ctx.glyphs[name] ?? + '<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.8"/>'; + // Trusted markup: the glyph table is the checked catalog's icon + // set served by `uhura dev` — never author or wire data. + el.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24">${glyph}</svg>`; + } + break; + } + + case "button": { + const button = /** @type {HTMLButtonElement} */ (el); + button.disabled = boolOf(props["disabled"]); + setAttr(el, "aria-busy", boolOf(props["busy"]) ? "true" : undefined); + setAttr( + el, + "aria-pressed", + typeof props["pressed"] === "boolean" ? String(props["pressed"]) : undefined, + ); + setAttr(el, "aria-current", boolOf(props["current"]) ? "true" : undefined); + setAttr(el, "aria-label", textOf(props["label"])); + break; + } + + case "text-field": { + const existing = el.querySelector(":scope > input"); + /** @type {HTMLInputElement} */ + let input; + if (existing instanceof HTMLInputElement) { + input = existing; + } else { + input = document.createElement("input"); + input.type = "text"; + input.setAttribute("data-uh-mechanic", "input"); + el.append(input); + ctx.textFields.wire(input, ctx.holderOf(el)); + } + setAttr(input, "placeholder", textOf(props["placeholder"])); + setAttr(input, "aria-label", textOf(props["label"])); + input.disabled = boolOf(props["disabled"]); + // Core's draft applies through the in-flight gate — never directly. + ctx.textFields.applyValue(input, textOf(props["value"]) ?? ""); + break; + } + + case "region": + setAttr(el, "role", "button"); + setAttr(el, "tabindex", "0"); + setAttr(el, "aria-label", textOf(props["label"])); + break; + + default: + // Honest labeled placeholder for anything unsupported (§8.2). + setAttr(el, "data-unsupported", node.element); + } +} + +/** @param {HTMLElement} el */ +function ensureTrack(el) { + const found = el.querySelector(":scope > .uh-track"); + if (found instanceof HTMLElement) return found; + const track = document.createElement("div"); + track.className = "uh-track"; + track.setAttribute("data-uh-mechanic", "track"); + el.append(track); + track.addEventListener("scroll", () => updateDots(el, track), { passive: true }); + return track; +} + +/** @param {HTMLElement} el */ +function ensureDots(el) { + const found = el.querySelector(":scope > .uh-dots"); + if (found instanceof HTMLElement) return found; + const dots = document.createElement("div"); + dots.className = "uh-dots"; + dots.setAttribute("data-uh-mechanic", "dots"); + dots.setAttribute("aria-hidden", "true"); + el.append(dots); + return dots; +} + +/** @param {HTMLElement} pager @param {HTMLElement} track */ +function updateDots(pager, track) { + const dots = pager.querySelector(":scope > .uh-dots"); + if (!dots) return; + const width = track.clientWidth || 1; + const active = Math.min( + dots.children.length - 1, + Math.max(0, Math.round(track.scrollLeft / width)), + ); + [...dots.children].forEach((dot, i) => dot.classList.toggle("on", i === active)); +} diff --git a/uhura/shell/focus.js b/uhura/shell/focus.js new file mode 100644 index 0000000..df52206 --- /dev/null +++ b/uhura/shell/focus.js @@ -0,0 +1,41 @@ +// Focus mechanics (§8.4): the machine owns WHAT gets focus back +// (`focus-restore` intents with a key-path); the renderer owns HOW. +// Key-paths are `<scope>/<node keys joined "/">` (§8.1) — the reconciler +// stamps them as `data-path`. + +const FOCUSABLE = 'button, input, [tabindex="0"]'; + +/** + * Executes one step's intents. History intents are visible no-ops in the + * spike shell — the machine already owns the nav stack (§7.4); the + * contract stays visible in the trace. + * @param {import("./types.js").Intent[]} intents + */ +export function handleIntents(intents) { + for (const intent of intents) { + if (intent.intent === "focus-restore") { + const path = intent["key-path"]; + // After the pump drains: the dismissing step's DOM must be in + // place before the node search runs. + queueMicrotask(() => restoreFocus(path)); + } + } +} + +/** @param {string} keyPath */ +export function restoreFocus(keyPath) { + const el = document.querySelector(`[data-path="${CSS.escape(keyPath)}"]`); + if (!(el instanceof HTMLElement)) return; + const target = el.matches(FOCUSABLE) ? el : el.querySelector(FOCUSABLE); + if (target instanceof HTMLElement) target.focus(); +} + +/** + * A freshly mounted surface takes focus (§11.4 step 5: "focus enters"). + * @param {HTMLElement} surfaceEl + */ +export function enterSurface(surfaceEl) { + const target = surfaceEl.querySelector(FOCUSABLE); + if (target instanceof HTMLElement) target.focus(); + else surfaceEl.focus(); +} diff --git a/uhura/shell/index.html b/uhura/shell/index.html new file mode 100644 index 0000000..7de1458 --- /dev/null +++ b/uhura/shell/index.html @@ -0,0 +1,20 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>uhura play</title> + <!-- shell bases first, compiled app stylesheet second: the app always + wins on shared selectors (same order as the static canvas). --> + <link rel="stylesheet" href="/shell/shell.css" /> + <link rel="stylesheet" href="/stylesheet.css" /> + </head> + <body> + <div id="uh-app"> + <div id="uh-page"></div> + <div id="uh-surfaces"></div> + </div> + <div id="uh-overlay" hidden></div> + <script type="module" src="/shell/main.js"></script> + </body> +</html> diff --git a/uhura/shell/jsconfig.json b/uhura/shell/jsconfig.json new file mode 100644 index 0000000..a3c9c64 --- /dev/null +++ b/uhura/shell/jsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "checkJs": true, + "strict": true, + "noEmit": true, + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "lib": ["es2022", "dom", "dom.iterable"], + "types": [], + "baseUrl": ".", + "paths": { "/wasm/uhura_wasm.js": ["./wasm.d.ts"] } + }, + "include": ["*.js", "wasm.d.ts"] +} diff --git a/uhura/shell/main.js b/uhura/shell/main.js new file mode 100644 index 0000000..cbf9ac0 --- /dev/null +++ b/uhura/shell/main.js @@ -0,0 +1,221 @@ +// uhura play (§8.4, §12.3): wires the wasm Session to the FixtureDriver +// by passing envelope JSON — the Spock seam stays visible in DevTools. +// Boot: fetch artifacts → assert protocols → boot deliveries → Init → +// tick cadence. `uhura dev` pushes recheck results over SSE: a good edit +// full-restart reloads onto the new IR; a broken one overlays diagnostics +// over the still-running last-good app. + +import init, { FixtureDriver, Session, protocols } from "/wasm/uhura_wasm.js"; +import * as focus from "./focus.js"; +import { createOverlay } from "./overlay.js"; +import { createPump, providerMsgToEvent } from "./pump.js"; +import { createReconciler, findScope } from "./reconciler.js"; +import { createScrolls } from "./scroll.js"; +import { createSurfaces } from "./surfaces.js"; +import { createTextFields } from "./textfield.js"; +import { createTicks, tickMillis } from "./ticks.js"; + +/** @param {string} id */ +function el(id) { + const found = document.getElementById(id); + if (!found) throw new Error(`index.html lost #${id}`); + return found; +} + +const overlay = createOverlay(el("uh-overlay")); +const pageHost = el("uh-page"); +const surfaceHost = el("uh-surfaces"); + +/** @param {string} url */ +async function fetchText(url) { + const response = await fetch(url); + const text = await response.text(); + if (!response.ok) throw new Error(`${url}: ${response.status}\n${text}`); + return text; +} + +/** + * Fetches the five build artifacts and REFUSES a mixed-generation set (a + * recheck can land between fetches): one silent retry via reload, then + * fatal. The dev server stamps `X-Uhura-Generation` on each. + * @param {string[]} urls + */ +async function fetchArtifacts(urls) { + const responses = await Promise.all(urls.map((url) => fetch(url))); + const texts = await Promise.all(responses.map((r) => r.text())); + responses.forEach((r, i) => { + if (!r.ok) throw new Error(`${urls[i]}: ${r.status}\n${texts[i]}`); + }); + const generations = new Set( + responses.map((r) => r.headers.get("x-uhura-generation")).filter((g) => g !== null), + ); + if (generations.size > 1) { + if (sessionStorage.getItem("uh-gen-retry") === null) { + sessionStorage.setItem("uh-gen-retry", "1"); + location.reload(); + await new Promise(() => {}); // reloading — never resolve + } + throw new Error("artifact fetches spanned a recheck twice — reload the page"); + } + sessionStorage.removeItem("uh-gen-retry"); + return texts; +} + +// ── dev events first: even a failed boot hears the fix and reloads ───────── +/** @type {number | null} */ +let baseGeneration = null; +let everOpened = false; +const events = new EventSource("/events"); +events.onopen = () => { + // A RECONNECT means the server restarted (fresh generation counter) or + // this page slept through events — reload onto whatever is current. + if (everOpened) location.reload(); + everOpened = true; +}; +events.onmessage = (/** @type {MessageEvent<string>} */ message) => { + /** @type {import("./types.js").DevEvent} */ + const dev = JSON.parse(message.data); + if (baseGeneration === null) baseGeneration = dev.generation; + if (dev.ok) { + if (dev.generation > baseGeneration) { + location.reload(); // full-restart hot reload (§12.4) — never faked + } else { + overlay.hideDiagnostics(); // never un-covers a dead (fatal) page + } + } else if (dev.diagnostics) { + overlay.showDiagnostics(dev.diagnostics); + } +}; + +boot().catch((error) => overlay.showFatal(String(error))); + +async function boot() { + const [irText, bootText, fixtureText, scriptText, iconsText] = await fetchArtifacts([ + "/ir.json", + "/boot.json", + "/fixture.json", + "/script.json", + "/icons.json", + ]); + await init(); + + // ── the §12.3 boot-protocol assertion: hard fail, no guessing ────────── + const spoken = JSON.parse(protocols()); + const expected = { ir: "uhura-ir/0", view: "uhura-view/0", provider: "uhura-provider/0" }; + for (const [name, version] of Object.entries(expected)) { + if (spoken[name] !== version) { + throw new Error( + `protocol mismatch: this shell speaks ${name} ${version}, the wasm build speaks ${spoken[name]} — rebuild with scripts/build-wasm.sh`, + ); + } + } + + const session = new Session(irText); + session.boot(bootText); + const driver = new FixtureDriver(fixtureText, scriptText); + /** @type {Record<string, string>} */ + const glyphs = JSON.parse(iconsText); + + let currentRevision = 0; + /** @type {string | null} */ + let currentNavKey = null; + /** @type {HTMLElement | null} */ + let pageEl = null; + // The machine owns nav (§7.4); its history intents mirror the stack + // here so page instances get identity: route + depth + params + // (register #17). Same key ⇒ same instance ⇒ scroll restores. + /** @type {string[]} */ + const navParams = ["{}"]; + + /** + * @param {import("./types.js").Descriptor} descriptor + * @param {Record<string, unknown>} [data] + * @param {() => void} [onApplied] + */ + function emit(descriptor, data, onApplied) { + /** @type {Record<string, unknown>} */ + const event = { kind: "ui", descriptor, "view-rev": currentRevision }; + if (data) event["data"] = data; + pump.enqueue(JSON.stringify(event), onApplied); + } + + const textFields = createTextFields({ emit }); + const scrolls = createScrolls({ emit }); + const reconciler = createReconciler({ emit, glyphs, textFields, scrolls }); + const surfaces = createSurfaces({ + host: surfaceHost, + pageHost, + emit, + reconcileChildren: reconciler.reconcileChildren, + }); + + /** @param {import("./types.js").Snapshot} snapshot */ + function renderPage(snapshot) { + const scope = findScope(snapshot.page.root) ?? "page"; + const navKey = `${snapshot.page.route}|${navParams.length}|${navParams.at(-1) ?? "{}"}`; + if (!pageEl || currentNavKey !== navKey) { + // A page-instance change remounts the subtree (§8.4); scroll + // positions round-trip through the per-instance cache. + if (pageEl && currentNavKey !== null) scrolls.savePositions(currentNavKey, pageEl); + pageHost.replaceChildren(); + pageEl = document.createElement("div"); + pageEl.className = "uh-page-root"; + pageHost.append(pageEl); + reconciler.reconcileChildren(pageEl, [snapshot.page.root], scope, false); + scrolls.restorePositions(navKey, pageEl); + currentNavKey = navKey; + } else { + reconciler.reconcileChildren(pageEl, [snapshot.page.root], scope, false); + } + } + + /** @type {Record<string, unknown>[]} */ + const steps = []; + + /** @param {import("./types.js").StepResult} result */ + function onStep(result) { + currentRevision = result.v.revision; + // Nav intents first: renderPage keys the incoming page instance off + // the mirrored stack (this step's push/back belongs to this view). + for (const intent of result.i) { + if (intent.intent === "history-push") { + navParams.push(JSON.stringify(intent.params ?? {})); + } else if (intent.intent === "history-back" && navParams.length > 1) { + navParams.pop(); + } + } + renderPage(result.v); + surfaces.render(result.v); + focus.handleIntents(result.i); + for (const g of result.g) console.warn(`uhura ${g.code} ${g.rule}: ${g.message}`); + steps.push(result.t); + console.debug("uhura-step", JSON.stringify(result.t)); + } + + const pump = createPump({ + dispatch: (eventJson) => session.dispatch(eventJson), + deliver: (cmdJson) => driver.deliver(cmdJson), + onStep, + onError: (error, eventJson) => { + console.error("uhura dispatch failed", error, eventJson); + overlay.showFatal(`dispatch failed: ${String(error)}\n\nevent: ${eventJson}`); + }, + }); + + const ticks = createTicks({ + tick: () => driver.tick(), + idle: () => driver.idle(), + enqueue: (eventJson) => pump.enqueue(eventJson), + toEvent: providerMsgToEvent, + intervalMs: tickMillis(location.search), + }); + + // Boot projections are already applied — bare reads are legal from the + // first view (§9.2). Init mounts the entry route. + const entry = String(JSON.parse(irText).entry); + pump.enqueue(JSON.stringify({ kind: "init", route: entry, params: {} })); + ticks.start(); + + // Debug/verification handle (the M5 gate reads this). + /** @type {any} */ (window).__uhura = { session, driver, steps, ticks }; +} diff --git a/uhura/shell/overlay.js b/uhura/shell/overlay.js new file mode 100644 index 0000000..6f0b1df --- /dev/null +++ b/uhura/shell/overlay.js @@ -0,0 +1,99 @@ +// The diagnostics overlay (§12.4): a broken edit renders its +// `uhura-diagnostics/0` envelope OVER the still-running last-good app — +// state-preserving reload is an open RFC topic the spike must not fake, +// so the overlay never touches the app underneath. + +/** @param {HTMLElement} host */ +export function createOverlay(host) { + host.className = "uh-dev-overlay"; + host.hidden = true; + // Fatal overlays cover a page with NOTHING running underneath — only a + // reload clears them; diagnostics overlays sit over the last-good app. + let fatal = false; + // The backdrop dismisses — the app underneath is still running. + host.addEventListener("click", (event) => { + if (event.target === host) host.hidden = true; + }); + + /** @param {string} title @param {HTMLElement[]} body */ + function show(title, body) { + host.replaceChildren(); + const panel = document.createElement("div"); + panel.className = "uh-dev-panel"; + const heading = document.createElement("h2"); + heading.textContent = title; + const hint = document.createElement("p"); + hint.className = "uh-dev-hint"; + hint.textContent = "click the backdrop to dismiss"; + panel.append(heading, ...body, hint); + host.append(panel); + host.hidden = false; + } + + return { + /** + * @param {Record<string, unknown>} envelope `uhura-diagnostics/0` + */ + showDiagnostics(envelope) { + fatal = false; + const diags = Array.isArray(envelope["diagnostics"]) + ? /** @type {Record<string, unknown>[]} */ (envelope["diagnostics"]) + : []; + const items = diags.map((d) => { + const item = document.createElement("div"); + item.className = `uh-diag uh-diag-${String(d["severity"] ?? "error")}`; + const head = document.createElement("div"); + head.className = "uh-diag-head"; + const where = describeSpan(d); + head.textContent = + `${String(d["code"] ?? "UH????")} ${String(d["rule"] ?? "")}` + + (where ? ` — ${where}` : ""); + const message = document.createElement("div"); + message.className = "uh-diag-message"; + message.textContent = String(d["message"] ?? ""); + item.append(head, message); + return item; + }); + if (items.length === 0) { + const item = document.createElement("div"); + item.className = "uh-diag"; + item.textContent = "the check failed with no diagnostics (?)"; + items.push(item); + } + show(`check failed — running the last good build (${items.length})`, items); + }, + + /** A boot-fatal condition: nothing is running underneath. */ + showFatal(/** @type {string} */ message) { + fatal = true; + const body = document.createElement("pre"); + body.className = "uh-diag-message"; + body.textContent = message; + show("uhura play failed to boot", [body]); + }, + + hide() { + host.hidden = true; + host.replaceChildren(); + }, + + /** SSE "all clear": never dismisses a fatal overlay — the dead page + * under it would just be blank. */ + hideDiagnostics() { + if (!fatal) { + host.hidden = true; + host.replaceChildren(); + } + }, + }; +} + +/** @param {Record<string, unknown>} d */ +function describeSpan(d) { + const span = d["span"]; + if (typeof span !== "object" || span === null) return ""; + const s = /** @type {Record<string, unknown>} */ (span); + const file = typeof s["file"] === "string" ? s["file"] : ""; + const line = typeof s["line"] === "number" ? `:${s["line"]}` : ""; + return file ? `${file}${line}` : ""; +} diff --git a/uhura/shell/pump.js b/uhura/shell/pump.js new file mode 100644 index 0000000..d2b9768 --- /dev/null +++ b/uhura/shell/pump.js @@ -0,0 +1,101 @@ +// The event pump (§8.4, normative): renderer emissions always ENQUEUE; +// a `pumping` flag makes nested pumps no-ops — the wasm Session is +// single-borrow, so re-entering `dispatch` from inside `onStep` would +// panic. Post-drain observation checks run in a microtask. + +/** + * @param {{ + * dispatch: (eventJson: string) => string, + * deliver: (cmdJson: string) => void, + * onStep: (result: import("./types.js").StepResult) => void, + * onError: (error: unknown, eventJson: string) => void, + * onDrained?: () => void, + * }} wiring + */ +export function createPump({ dispatch, deliver, onStep, onError, onDrained }) { + /** @type {{ eventJson: string, onApplied?: () => void }[]} */ + const queue = []; + let pumping = false; + + /** + * The ONLY entry point — everything (user input, driver ticks, Init) + * goes through the queue, so step order is arrival order. + * @param {string} eventJson + * @param {() => void} [onApplied] runs right after this event's step + * lands (the text-field in-flight accounting hangs off this) + */ + function enqueue(eventJson, onApplied) { + queue.push({ eventJson, onApplied }); + pump(); + } + + function pump() { + if (pumping) return; + pumping = true; + try { + while (queue.length > 0) { + const item = queue.shift(); + if (!item) break; + /** @type {string} */ + let resultJson; + try { + resultJson = dispatch(item.eventJson); + } catch (error) { + item.onApplied?.(); + onError(error, item.eventJson); + continue; + } + /** @type {import("./types.js").StepResult} */ + const result = JSON.parse(resultJson); + // Emitted commands go to the provider as they appear (§7.2). A + // provider refusal to ACCEPT one (unscripted command, §9.5) is + // reported but must not skip the render below: the machine + // stepped — the DOM tracks the session, never the provider. + for (const c of result.c) { + const cmdJson = JSON.stringify(c); + try { + deliver(cmdJson); + } catch (error) { + onError(error, cmdJson); + } + } + // onStep reconciles the DOM; anything it provokes (focus events, + // observation flips) re-enters via enqueue and drains here. + onStep(result); + item.onApplied?.(); + } + } finally { + pumping = false; + } + if (queue.length > 0) { + pump(); // an emission slipped in during the finally window + } else if (onDrained) { + queueMicrotask(() => { + if (!pumping && queue.length === 0) onDrained(); + }); + } + } + + return { enqueue }; +} + +/** + * Maps one provider wire message to its external event (§7.2): a + * standalone projection update wraps into an `updates` list; `outcome` + * and `projection-failed` are shape-identical pass-throughs. + * @param {string} msgJson + * @returns {string} event JSON for `Session.dispatch` + */ +export function providerMsgToEvent(msgJson) { + /** @type {import("./types.js").ProviderMsg} */ + const msg = JSON.parse(msgJson); + switch (msg.kind) { + case "projection": + return JSON.stringify({ kind: "projection", updates: [msg] }); + case "outcome": + case "projection-failed": + return msgJson; + default: + throw new Error(`the driver emitted a \`${msg.kind}\` message`); + } +} diff --git a/uhura/shell/reconciler.js b/uhura/shell/reconciler.js new file mode 100644 index 0000000..4f8b532 --- /dev/null +++ b/uhura/shell/reconciler.js @@ -0,0 +1,219 @@ +// Keyed V→DOM reconciliation (§8.2, §8.4): one insertBefore sweep per +// sibling list — element change replaces, key match patches in place, +// appends never move existing nodes. Every node carries `data-key` +// (sibling identity) and `data-path` (`<scope>/<keys…>` — global identity, +// what focus-restore key-paths resolve against). +// +// Renderer state lives on the DOM elements themselves (`el.__uh` holder): +// listeners are attached once and read the CURRENT descriptors at fire +// time, so re-renders never re-bind. + +import { applyProps, tagFor, textOf } from "./appliers.js"; + +/** + * @typedef {Object} Holder + * @property {string} path + * @property {import("./types.js").VNode} node + * @property {Record<string, import("./types.js").Descriptor>} on + * @property {boolean} wiredPress + * @property {{ sentinel: HTMLElement, io: IntersectionObserver, armed: boolean, + * lastHeight: number }} [nearEnd] + */ + +/** @param {HTMLElement} el @returns {Holder} */ +function holderOf(el) { + const anyEl = /** @type {any} */ (el); + if (!anyEl.__uh) { + anyEl.__uh = { path: "", node: undefined, on: {}, wiredPress: false }; + } + return anyEl.__uh; +} + +/** + * The first descriptor scope under a root — a page/surface root's scope + * prefix for key-paths. V itself never names the page serial; its + * descriptors do (§8.1). + * @param {import("./types.js").VNode} node + * @returns {string | undefined} + */ +export function findScope(node) { + const first = node.on?.[0]; + if (first) return first.scope; + for (const child of node.children ?? []) { + const scope = findScope(child); + if (scope) return scope; + } + return undefined; +} + +/** + * @param {{ + * emit: (descriptor: import("./types.js").Descriptor, + * data?: Record<string, unknown>, + * onApplied?: () => void) => void, + * glyphs: Record<string, string>, + * textFields: ReturnType<typeof import("./textfield.js").createTextFields>, + * scrolls: ReturnType<typeof import("./scroll.js").createScrolls>, + * }} ctx + */ +export function createReconciler(ctx) { + /** @param {HTMLElement} el */ + function wireInput(el) { + const holder = holderOf(el); + if (holder.wiredPress) return; + holder.wiredPress = true; + el.addEventListener("click", () => { + const d = holder.on["press"] ?? holder.on["activate"]; + if (d) ctx.emit(d); + }); + el.addEventListener("dblclick", (event) => { + const d = holder.on["activate-double"]; + if (d) { + event.preventDefault(); + ctx.emit(d); + } + }); + if (holder.node.element === "region") { + // Keyboard path for non-native interactives (§11.4 step 4): + // focus + Enter/Space activates — double-only regions included. + el.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + const d = + holder.on["activate"] ?? holder.on["press"] ?? holder.on["activate-double"]; + if (d) { + event.preventDefault(); + ctx.emit(d); + } + }); + } + } + + /** + * Where a node's KEYED children live (pager wraps them in its track). + * @param {HTMLElement} el @param {string} element + */ + function childHost(el, element) { + if (element === "pager") { + const track = el.querySelector(":scope > .uh-track"); + if (track instanceof HTMLElement) return track; + } + return el; + } + + /** + * Patches one element in place: identity attributes, semantic props, + * event wiring, keyed children. + * @param {HTMLElement} el + * @param {import("./types.js").VNode} node + * @param {string} parentPath + * @param {boolean} listItem + */ + function applyNode(el, node, parentPath, listItem) { + const holder = holderOf(el); + holder.path = `${parentPath}/${node.key}`; + holder.node = node; + holder.on = {}; + for (const d of node.on ?? []) holder.on[d.event] = d; + + const className = `uh-${node.element}${node.class ? ` ${node.class}` : ""}`; + if (el.className !== className) el.className = className; + if (el.getAttribute("data-key") !== node.key) el.setAttribute("data-key", node.key); + if (el.getAttribute("data-path") !== holder.path) { + el.setAttribute("data-path", holder.path); + } + + applyProps(el, node, { + glyphs: ctx.glyphs, + textFields: ctx.textFields, + holderOf, + }); + + // A list's children are its items — set AFTER element roles so the + // list semantics win (mirrors the static renderer); a role the list + // stamped earlier is withdrawn when the parent stops being a list. + if (listItem) el.setAttribute("role", "listitem"); + else if (el.getAttribute("role") === "listitem") el.removeAttribute("role"); + + if ( + holder.on["press"] || + holder.on["activate"] || + holder.on["activate-double"] || + node.element === "region" + ) { + wireInput(el); + } + + if (node.element !== "text") { + const isList = node.element === "view" && textOf(node.props["role"]) === "list"; + reconcileChildren( + childHost(el, node.element), + node.children ?? [], + holder.path, + isList, + ); + } + if (node.element === "scroll") { + // After children: the sentinel must trail appended rows, and the + // observation tears down when the descriptor is gone. + ctx.scrolls.sync(el, holder); + } + } + + /** + * The keyed insertBefore sweep. Mechanic children (`data-uh-mechanic`: + * text-field inputs, pager track/dots, scroll sentinels) are invisible + * to it. + * @param {HTMLElement} host + * @param {import("./types.js").VNode[]} nodes + * @param {string} parentPath + * @param {boolean} parentIsList + */ + function reconcileChildren(host, nodes, parentPath, parentIsList) { + /** @type {Map<string, HTMLElement>} */ + const existing = new Map(); + for (const child of [...host.children]) { + if (!(child instanceof HTMLElement)) continue; + if (child.hasAttribute("data-uh-mechanic")) continue; + const key = child.getAttribute("data-key"); + if (key !== null) existing.set(key, child); + } + + /** @type {HTMLElement | null} */ + let prev = null; + for (const node of nodes) { + let el = existing.get(node.key); + if (el && holderOf(el).node?.element !== node.element) { + // Element change ⇒ replace: identity is (key, element) — a key + // that changes element is a different thing (§8.2). + existing.delete(node.key); + el.remove(); + el = undefined; + } + if (el) { + existing.delete(node.key); + } else { + el = document.createElement(tagFor(node.element)); + if (node.element === "button") { + /** @type {HTMLButtonElement} */ (el).type = "button"; + } + } + applyNode(el, node, parentPath, parentIsList); + + /** @type {ChildNode | null} */ + const desired = prev ? prev.nextSibling : host.firstChild; + if (el !== desired) { + // insertBefore MOVES the node, and moving blurs any focus inside + // it — put focus back where the user had it. + const active = document.activeElement; + const hadFocus = + active instanceof HTMLElement && (el === active || el.contains(active)); + host.insertBefore(el, desired); + if (hadFocus && document.activeElement !== active) active.focus(); + } + prev = el; + } + for (const leftover of existing.values()) leftover.remove(); + } + + return { reconcileChildren, applyNode }; +} diff --git a/uhura/shell/scroll.js b/uhura/shell/scroll.js new file mode 100644 index 0000000..cbbb20b --- /dev/null +++ b/uhura/shell/scroll.js @@ -0,0 +1,123 @@ +// scroll mechanics (§8.4): near-end observation via a sentinel + +// IntersectionObserver (rootMargin 100% — the catalog's stated +// threshold), with an EDGE LATCH: one emission per entry into the +// near-end zone; re-arms only after the sentinel leaves. Wiggle-scroll +// at the bottom emits nothing (the machine's guard is the backstop, the +// latch keeps the trace clean). Plus the per-route scroll cache +// (micro-decision #17). + +/** + * @param {{ emit: (descriptor: import("./types.js").Descriptor) => void }} wiring + */ +export function createScrolls({ emit }) { + /** @type {Map<string, Map<string, { top: number, left: number }>>} */ + const routeCache = new Map(); + + /** + * Keeps one scroll element's near-end observation in sync with its + * CURRENT descriptors. `holder.on` rotates per step; descriptor + * absence (exhausted feed) tears the sentinel down — descriptor + * presence IS the subscription (§8.1). + * @param {HTMLElement} el + * @param {{ path: string, + * on: Record<string, import("./types.js").Descriptor>, + * nearEnd?: { sentinel: HTMLElement, io: IntersectionObserver, + * armed: boolean, lastHeight: number } }} holder + */ + function sync(el, holder) { + const descriptor = holder.on["near-end"]; + if (!descriptor) { + if (holder.nearEnd) { + holder.nearEnd.io.disconnect(); + holder.nearEnd.sentinel.remove(); + holder.nearEnd = undefined; + } + return; + } + if (!holder.nearEnd) { + const sentinel = document.createElement("div"); + sentinel.setAttribute("data-uh-mechanic", "sentinel"); + sentinel.style.cssText = "block-size:1px;flex:none;"; + el.append(sentinel); + const nearEnd = { + sentinel, + armed: true, + lastHeight: -1, + io: new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && nearEnd.armed) { + nearEnd.armed = false; + const d = holder.on["near-end"]; + if (d) emit(d); + } else if (!entry.isIntersecting) { + nearEnd.armed = true; // left the zone — re-arm the latch + } + } + }, + { root: el, rootMargin: "100%" }, + ), + }; + nearEnd.io.observe(sentinel); + holder.nearEnd = nearEnd; + } + const nearEnd = holder.nearEnd; + if (nearEnd.sentinel !== el.lastElementChild) { + el.append(nearEnd.sentinel); // keep it after appended rows + } + if (el.scrollHeight !== nearEnd.lastHeight) { + // Content changed. The catalog's near-end threshold is a STATE + // (remaining extent below one viewport — §10), not an edge: re-arm + // and take a fresh observation, so a feed still inside the zone + // after a short append keeps paginating instead of deadlocking. + // The machine's guard is the backstop against spam. + nearEnd.lastHeight = el.scrollHeight; + nearEnd.armed = true; + nearEnd.io.unobserve(nearEnd.sentinel); + nearEnd.io.observe(nearEnd.sentinel); + } + } + + /** + * Saves every scroll position under the outgoing page instance before + * the page subtree remounts. The key is main.js's nav key + * (route + depth + params — register #17), so two `profile/[user]` + * instances never share positions. + * @param {string} navKey @param {HTMLElement} pageEl + */ + function savePositions(navKey, pageEl) { + /** @type {Map<string, { top: number, left: number }>} */ + const positions = new Map(); + for (const el of pageEl.querySelectorAll(".uh-scroll")) { + // Keyed by data-key, NOT data-path: node keys are stable source + // ordinals, while paths embed the page serial, which is freshly + // minted on every remount. + const key = el.getAttribute("data-key"); + if (key && el instanceof HTMLElement) { + positions.set(key, { top: el.scrollTop, left: el.scrollLeft }); + } + } + routeCache.set(navKey, positions); + } + + /** + * Restores cached positions after a page remount (back → the feed + * exactly where it was). Unknown keys stay at 0 — a freshly pushed + * instance starts at the top. + * @param {string} navKey @param {HTMLElement} pageEl + */ + function restorePositions(navKey, pageEl) { + const positions = routeCache.get(navKey); + if (!positions) return; + for (const el of pageEl.querySelectorAll(".uh-scroll")) { + const key = el.getAttribute("data-key"); + const saved = key ? positions.get(key) : undefined; + if (saved && el instanceof HTMLElement) { + el.scrollTop = saved.top; + el.scrollLeft = saved.left; + } + } + } + + return { sync, savePositions, restorePositions }; +} diff --git a/uhura/shell/shell.css b/uhura/shell/shell.css new file mode 100644 index 0000000..0195e96 --- /dev/null +++ b/uhura/shell/shell.css @@ -0,0 +1,64 @@ +/* Shell chrome + semantic element bases (§8.4). The uh-* bases mirror the + static renderer's BASE_CSS 1:1 — layout/aesthetics stay authored (§10); + the compiled app stylesheet loads after this file and wins. */ + +* { box-sizing: border-box; margin: 0; } +html, body { height: 100%; } +body { + font: 14px/1.45 -apple-system, "Segoe UI", sans-serif; + background: #14151a; + display: flex; + justify-content: center; +} + +/* the device: one phone-sized app frame */ +#uh-app { + position: relative; + inline-size: min(100vw, 430px); + block-size: 100dvh; + background: #fff; + color: #16181c; + overflow: hidden; +} +#uh-page, .uh-page-root { block-size: 100%; min-block-size: 0; } +.uh-page-root > * { block-size: 100%; } + +/* semantic element bases — identical to the canvas renderer */ +.uh-view { display: block; min-inline-size: 0; } +.uh-scroll { overflow-y: auto; overflow-x: hidden; min-block-size: 0; } +.uh-scroll[data-direction="horizontal"] { overflow-x: auto; overflow-y: hidden; } +.uh-text { margin: 0; overflow-wrap: anywhere; } +.uh-image { background-size: cover; background-position: center; background-color: #d9d9de; display: block; } +.uh-icon { display: inline-flex; } +.uh-icon svg { display: block; } +button.uh-button { appearance: none; background: none; border: 0; padding: 6px; font: inherit; color: inherit; display: inline-flex; align-items: center; gap: 6px; border-radius: 8px; cursor: pointer; } +button.uh-button[disabled] { opacity: 0.35; cursor: default; } +button.uh-button[aria-busy="true"] { opacity: 0.6; } +.uh-text-field input { font: inherit; inline-size: 100%; border: 1px solid #d5d5da; border-radius: 999px; padding: 8px 14px; background: #fff; color: #222; } +.uh-region { display: block; cursor: pointer; } +.uh-pager .uh-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; } +.uh-pager .uh-track > * { flex: 0 0 100%; scroll-snap-align: center; } +.uh-pager { position: relative; } +.uh-dots { position: absolute; inset-block-end: 10px; inset-inline: 0; display: flex; justify-content: center; gap: 5px; } +.uh-dot { inline-size: 6px; block-size: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.55); } +.uh-dot.on { background: #fff; } + +/* the surface stack (live: interactive, unlike the canvas frames) */ +.uh-surface-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; z-index: 10; } +.uh-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.4); } +.uh-surface { position: relative; background: #fff; border-radius: 16px 16px 0 0; max-block-size: 72%; block-size: 72%; box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35); outline: none; } +.uh-surface > * { block-size: 100%; } + +/* keyboard affordance for non-native interactives */ +.uh-region:focus-visible, button.uh-button:focus-visible { outline: 2px solid #4a90e2; outline-offset: 2px; } + +/* the dev diagnostics overlay (§12.4) — over the app, never instead of it */ +.uh-dev-overlay { position: fixed; inset: 0; z-index: 100; background: rgba(10, 11, 14, 0.82); display: flex; align-items: flex-start; justify-content: center; padding: 48px 16px; overflow-y: auto; } +.uh-dev-overlay[hidden] { display: none; } /* author display beats the UA hidden rule */ +.uh-dev-panel { background: #14151a; color: #e8e8ec; border: 1px solid #2a2b33; border-radius: 12px; max-inline-size: 720px; inline-size: 100%; padding: 20px 24px; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; } +.uh-dev-panel h2 { font-size: 14px; margin-block-end: 14px; color: #ff8a8a; } +.uh-diag { border-block-start: 1px solid #2a2b33; padding-block: 10px; } +.uh-diag-head { color: #ffd479; } +.uh-diag-warning .uh-diag-head { color: #9fc6ff; } +.uh-diag-message { white-space: pre-wrap; color: #c9c9d1; margin-block-start: 4px; } +.uh-dev-hint { margin-block-start: 14px; color: #6d6d78; font-size: 11px; } diff --git a/uhura/shell/surfaces.js b/uhura/shell/surfaces.js new file mode 100644 index 0000000..018acc2 --- /dev/null +++ b/uhura/shell/surfaces.js @@ -0,0 +1,95 @@ +// The surface stack (§8.1, §8.4): overlays keyed by `"definition:serial"`, +// scrim + Escape wired to the surface's FIRST-CLASS dismiss descriptor — +// the shell never invents a dismiss event, it presses the one core +// published. The page (and every non-top surface) is `inert` while +// anything is stacked above it. + +import * as focus from "./focus.js"; +import { findScope } from "./reconciler.js"; + +/** + * @param {{ + * host: HTMLElement, + * pageHost: HTMLElement, + * emit: (descriptor: import("./types.js").Descriptor) => void, + * reconcileChildren: (host: HTMLElement, + * nodes: import("./types.js").VNode[], + * parentPath: string, parentIsList: boolean) => void, + * }} ctx + */ +export function createSurfaces(ctx) { + /** @type {import("./types.js").SurfaceView[]} */ + let stack = []; + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || stack.length === 0) return; + // An Escape that cancels an IME composition is not a dismiss gesture + // (WebKit stamps keyCode 229 even after compositionend). + if (event.isComposing || event.keyCode === 229) return; + const top = stack[stack.length - 1]; + if (top) { + event.preventDefault(); + ctx.emit(top.dismiss); + } + }); + + /** @param {import("./types.js").Snapshot} snapshot */ + function render(snapshot) { + stack = snapshot.surfaces; + const wanted = new Set(stack.map((s) => s.key)); + for (const el of [...ctx.host.children]) { + if (el instanceof HTMLElement && !wanted.has(el.dataset["surfaceKey"] ?? "")) { + el.remove(); + } + } + + stack.forEach((surface, index) => { + let overlay = /** @type {HTMLElement | null} */ ( + ctx.host.querySelector( + `:scope > [data-surface-key="${CSS.escape(surface.key)}"]`, + ) + ); + let mounted = false; + if (!overlay) { + mounted = true; + overlay = document.createElement("div"); + overlay.className = "uh-surface-overlay"; + overlay.dataset["surfaceKey"] = surface.key; + + const scrim = document.createElement("div"); + scrim.className = "uh-scrim"; + scrim.addEventListener("click", () => { + const current = stack.find((s) => s.key === overlay?.dataset["surfaceKey"]); + if (current) ctx.emit(current.dismiss); + }); + + const panel = document.createElement("div"); + panel.className = `uh-surface uh-modality-${surface.modality}`; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-modal", "true"); + panel.setAttribute("tabindex", "-1"); + + overlay.append(scrim, panel); + ctx.host.append(overlay); + } + + // Stack order: bottom → top in host order. + const atIndex = ctx.host.children[index]; + if (atIndex !== overlay) ctx.host.insertBefore(overlay, atIndex ?? null); + + const panel = /** @type {HTMLElement} */ (overlay.lastElementChild); + // The dismiss descriptor names the surface scope even when the + // content has no interactive nodes. + const scope = findScope(surface.root) ?? surface.dismiss.scope; + ctx.reconcileChildren(panel, [surface.root], scope, false); + + // Only the top surface is interactive; everything below waits. + overlay.inert = index !== stack.length - 1; + if (mounted && index === stack.length - 1) focus.enterSurface(panel); + }); + + ctx.pageHost.inert = stack.length > 0; + } + + return { render }; +} diff --git a/uhura/shell/textfield.js b/uhura/shell/textfield.js new file mode 100644 index 0000000..35b237f --- /dev/null +++ b/uhura/shell/textfield.js @@ -0,0 +1,104 @@ +// text-field mechanics (§8.4, normative): core owns the DRAFT; the +// renderer owns caret and IME. Per field, a counter of in-flight change +// emissions; while nonzero, external replacement never applies (stashed) +// — a tick-scheduled outcome landing mid-typing cannot eat keystrokes. +// IME composition buffers locally and emits one `change` at +// `compositionend`. +// +// State is keyed by the INPUT ELEMENT (WeakMap): teardown is automatic +// with the DOM node, and a remounted field starts fresh — paths embed +// freshly minted page serials, so they never made a stable key. + +/** + * @typedef {Object} FieldState + * @property {number} inFlight change emissions not yet stepped + * @property {string | undefined} stash last external value seen while typing + * @property {boolean} composing + */ + +/** + * @param {{ emit: (descriptor: import("./types.js").Descriptor, + * data?: Record<string, unknown>, + * onApplied?: () => void) => void }} wiring + */ +export function createTextFields({ emit }) { + /** @type {WeakMap<HTMLInputElement, FieldState>} */ + const fields = new WeakMap(); + + /** @param {HTMLInputElement} input */ + function state(input) { + let s = fields.get(input); + if (!s) { + s = { inFlight: 0, stash: undefined, composing: false }; + fields.set(input, s); + } + return s; + } + + /** + * Wires one `<input>`; called once per mounted field. Descriptors are + * read from `holder.on` at FIRE time — payloads rotate across steps. + * @param {HTMLInputElement} input + * @param {{ on: Record<string, import("./types.js").Descriptor> }} holder + */ + function wire(input, holder) { + const emitChange = () => { + const descriptor = holder.on["change"]; + if (!descriptor) return; + const s = state(input); + s.inFlight += 1; + emit(descriptor, { value: input.value }, () => { + s.inFlight -= 1; + if (s.inFlight === 0 && s.stash !== undefined) { + // The last external value wins only once typing settled AND + // core did not echo the draft back meanwhile. + if (s.stash !== input.value) input.value = s.stash; + s.stash = undefined; + } + }); + }; + + input.addEventListener("input", () => { + if (state(input).composing) return; + emitChange(); + }); + input.addEventListener("compositionstart", () => { + state(input).composing = true; + }); + input.addEventListener("compositionend", () => { + state(input).composing = false; + emitChange(); + }); + input.addEventListener("keydown", (event) => { + // An Enter that commits an IME conversion is not a submit gesture. + // WebKit fires it AFTER compositionend (isComposing false), but + // stamps keyCode 229 — both tells are required (§8.4 normative: + // one `change` per composition, nothing else). + if (event.isComposing || event.keyCode === 229) return; + if (event.key !== "Enter" || state(input).composing) return; + const descriptor = holder.on["submit"]; + if (descriptor) { + event.preventDefault(); + emit(descriptor); + } + }); + } + + /** + * Applies core's draft to the DOM — the reconciler's value applier. + * @param {HTMLInputElement} input + * @param {string} value + */ + function applyValue(input, value) { + const s = state(input); + if (s.composing || s.inFlight > 0) { + // The user is ahead of the machine: never clobber, stash. + s.stash = value; + return; + } + s.stash = undefined; + if (input.value !== value) input.value = value; + } + + return { wire, applyValue }; +} diff --git a/uhura/shell/ticks.js b/uhura/shell/ticks.js new file mode 100644 index 0000000..5ad6166 --- /dev/null +++ b/uhura/shell/ticks.js @@ -0,0 +1,49 @@ +// Wall time → driver ticks (§8.4): the fixture driver schedules in +// integer ticks (`after-ticks ≥ 1`, so optimistic states are always +// observable); the shell fires `driver.tick()` on a fixed cadence — +// 250 ms default, `?tick=<ms>` override (plan micro-decision #18). +// Traces depend only on tick ordinals, never on wall time. + +export const DEFAULT_TICK_MS = 250; + +/** @param {string} search e.g. `location.search` */ +export function tickMillis(search) { + const raw = new URLSearchParams(search).get("tick"); + const ms = raw === null ? Number.NaN : Number(raw); + return Number.isInteger(ms) && ms > 0 ? ms : DEFAULT_TICK_MS; +} + +/** + * @param {{ + * tick: () => string[], + * idle: () => boolean, + * enqueue: (eventJson: string) => void, + * toEvent: (msgJson: string) => string, + * intervalMs: number, + * }} wiring + */ +export function createTicks({ tick, idle, enqueue, toEvent, intervalMs }) { + /** @type {number | undefined} */ + let timer; + + function fire() { + // An idle driver still ticks: [[deliver]] entries are scheduled on + // absolute ticks, and `idle()` only reports the current schedule. + for (const msgJson of tick()) enqueue(toEvent(msgJson)); + } + + return { + start() { + if (timer === undefined) timer = setInterval(fire, intervalMs); + }, + stop() { + if (timer !== undefined) clearInterval(timer); + timer = undefined; + }, + /** Test/debug hook: advance N ticks synchronously. */ + advance(/** @type {number} */ n) { + for (let i = 0; i < n; i += 1) fire(); + }, + idle, + }; +} diff --git a/uhura/shell/types.js b/uhura/shell/types.js new file mode 100644 index 0000000..01c2fe1 --- /dev/null +++ b/uhura/shell/types.js @@ -0,0 +1,95 @@ +// The frozen ABI shapes (design §8.1, §9.3, §12.3), mirrored from +// crates/uhura-wasm/tests/abi_contract.rs — plan micro-decision #14. +// These typedefs ARE the contract the shell compiles against; a change +// here is a protocol version bump, not an edit. + +/** + * @typedef {Object} Descriptor + * @property {"input" | "observe"} kind + * @property {string} event catalog event (`press`, `near-end`, `change`) + * @property {string} emit the machine event this emits + * @property {string} scope `"page:1"` | `"surface:2"` + * @property {unknown} payload prebuilt by core — inert, echoed verbatim + * @property {Record<string, "text" | "bool" | "int">} [carries] + */ + +/** + * @typedef {Object} VNode + * @property {string} key sibling-unique; key-path is global identity + * @property {string} element catalog element name + * @property {string} [class] authored CSS classes, verbatim + * @property {Record<string, VValue>} props + * @property {VNode[]} [children] + * @property {Descriptor[]} [on] + */ + +/** + * §8.1 value: bool | int | bare token | inert human text | asset ref. + * @typedef {boolean | number | string + * | { t: "plain", v: string } + * | { t: "image", asset: string }} VValue + */ + +/** + * @typedef {Object} SurfaceView + * @property {string} key `"comments-sheet:2"` (definition:serial) + * @property {string} definition + * @property {string} modality + * @property {string} [restore-focus] + * @property {Descriptor} dismiss first-class: Escape/scrim wire here + * @property {VNode} root + */ + +/** + * @typedef {Object} Snapshot + * @property {"uhura-view/0"} protocol + * @property {number} revision + * @property {{ route: string, root: VNode }} page + * @property {SurfaceView[]} surfaces bottom → top + */ + +/** + * One provider wire message (`uhura-provider/0` — §9.3). + * @typedef {Object} ProviderMsg + * @property {"command" | "projection" | "projection-failed" | "outcome"} kind + * @property {string} [port] + * @property {string} [command] + * @property {string} [correlation] + * @property {unknown} [payload] + * @property {string} [projection] + * @property {unknown} [key] + * @property {number} [revision] + * @property {unknown} [value] + * @property {string} [reason] + * @property {unknown} [outcome] + * @property {unknown[]} [updates] + */ + +/** + * A host intent (§7.4) — the spike shell executes `focus-restore` and + * treats history intents as visible no-ops. + * @typedef {{ intent: "history-push", route: string, params: Record<string, unknown> } + * | { intent: "history-back" } + * | { intent: "focus-restore", "key-path": string }} Intent + */ + +/** + * The step-result envelope `Session.dispatch` returns (§12.3): every key + * always present. + * @typedef {Object} StepResult + * @property {ProviderMsg[]} c command envelopes to forward to the driver + * @property {Intent[]} i + * @property {{ code: string, rule: string, message: string }[]} g + * @property {Record<string, unknown>} t canonical trace record (§7.5) + * @property {Snapshot} v + */ + +/** + * One `/events` SSE payload from `uhura dev` (§12.4). + * @typedef {Object} DevEvent + * @property {number} generation monotonic recheck counter + * @property {boolean} ok false ⇒ diagnostics, app keeps last-good + * @property {Record<string, unknown>} [diagnostics] `uhura-diagnostics/0` + */ + +export {}; diff --git a/uhura/shell/wasm.d.ts b/uhura/shell/wasm.d.ts new file mode 100644 index 0000000..d5835e7 --- /dev/null +++ b/uhura/shell/wasm.d.ts @@ -0,0 +1,38 @@ +// Typing for the wasm-bindgen bundle `uhura dev` serves at +// /wasm/uhura_wasm.js (built by scripts/build-wasm.sh, --target web). +// jsconfig.json `paths` maps the runtime specifier here. Mirrors +// crates/uhura-wasm/src/lib.rs — JSON strings across the boundary; +// errors are THROWN STRINGS (§12.3, plan micro-decision #14). + +/** Loads and instantiates the wasm binary (fetches uhura_wasm_bg.wasm). */ +export default function init(module_or_path?: unknown): Promise<unknown>; + +/** `{"ir":"uhura-ir/0","provider":"uhura-provider/0","view":"uhura-view/0"}` */ +export function protocols(): string; + +export class Session { + /** `ir_json`: the canonical `uhura-ir/0` artifact. Throws a string. */ + constructor(ir_json: string); + /** Applies `{"updates": [...]}` boot deliveries before `Init` (§9.2). */ + boot(boot_json: string): void; + /** One step; returns the step-result envelope as canonical JSON. */ + dispatch(event_json: string): string; + /** Current `uhura-view/0` snapshot, canonical JSON. */ + view(): string; + /** `U.rev` — `+1` every step. */ + revision(): number; + /** `"uhura-ir/0"` */ + ir_version(): string; + free(): void; +} + +export class FixtureDriver { + constructor(fixture_json: string, script_json: string); + /** Accepts one command envelope (wire form). Throws a string. */ + deliver(cmd_json: string): void; + /** Advances one tick; returns the provider messages due. */ + tick(): string[]; + /** True when nothing is scheduled or in flight. */ + idle(): boolean; + free(): void; +}

d6Lz6!&i*56gOHqRg0^Le_! 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 x8QSSqf_R~kD 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 literal 0 HcmV?d00001 diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-4.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5450c59993c970d985f6161f0373f4f1a2734164 GIT binary patch literal 45983 zcmbTdWmr_v_s2U(3xb4%q=d8xC?PR`N-8KVEh*9<9RngMjdY2C2na|Hjf4z6q%=bh zT|*2oagV>>|9|gu@2h+7;ej{%aL!(P?Y+)-t^L{f8T>NnwuXwj3W(qa2t)w8y9zsA&LPADLMomUW&3T9R4(I>p z3*Q6c0QyNx@Sh9xKNrCbLLy=kQZn+J6u=Gjw?Q`u2nlZhodtRe+#Lk8gNSH|Y43?C zkcy|BLif$@pSslU@RvvVt}YwH`E$gS<2;}g{B+4%+f^6H;m z1R%oyng#s*uVMcWy8w3(+yHz)MEcJzf*U@-FCh&P@jWpTTBX;dZ{6v*#6!vGm6Hmp zJIT2vbdK&=d5qm;;CZydd;HI||5*0_&ajXFzbyMd4g0_BngdZ15&+YPkOl+>o#H`k z=tJD?sy#diof_1!pJ$JOo-{bkU8SCA@X=IV6O;IZJ01F6m1+$Lc?LP}eyg zRBD0;@pW9A*`6hEPvSc9ppyJZ3{Cm*a&0qk1JB^E(8i^s1#j#k_@^IjjZi~d$qF7s zq=!DLloAa;A>hV=1H!`aAYp82kaeFUm$ts{5gzoB2h9F#?DF%9%1u8V*T^ARj