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} + + + + + {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"} + {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 0000000..ced0025 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-ayla.jpg differ 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 0000000..c415d61 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-june.jpg differ 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 0000000..f6ec02c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-kenji.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/avatar-lena.jpg b/examples/instagram-uhura/fixtures/assets/avatar-lena.jpg new file mode 100644 index 0000000..9d66ca5 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-lena.jpg differ 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 0000000..2696823 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-marco.jpg differ 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 0000000..363e12e Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-mira.jpg differ 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 0000000..68742ab Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-nils.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/avatar-priya.jpg b/examples/instagram-uhura/fixtures/assets/avatar-priya.jpg new file mode 100644 index 0000000..feecd98 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-priya.jpg differ 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 0000000..d4a300c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/avatar-theo.jpg differ 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 0000000..0ab982d Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-ayla-ferry.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/media-june-lookbook.jpg b/examples/instagram-uhura/fixtures/assets/media-june-lookbook.jpg new file mode 100644 index 0000000..c09d0e0 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-june-lookbook.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/media-kenji-copper.jpg b/examples/instagram-uhura/fixtures/assets/media-kenji-copper.jpg new file mode 100644 index 0000000..fb75d9e Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-kenji-copper.jpg differ 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 0000000..6055270 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-lena-glaze.jpg differ 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 0000000..fd98e95 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-marco-baja-1.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/media-marco-baja-2.jpg b/examples/instagram-uhura/fixtures/assets/media-marco-baja-2.jpg new file mode 100644 index 0000000..1b6f52c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-marco-baja-2.jpg differ 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 0000000..a97bc22 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-marco-baja-3.jpg differ 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 0000000..2fcc8ab Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-nils-aurora-poster.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/media-priya-starter.jpg b/examples/instagram-uhura/fixtures/assets/media-priya-starter.jpg new file mode 100644 index 0000000..f34b7a6 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-priya-starter.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/media-theo-court.jpg b/examples/instagram-uhura/fixtures/assets/media-theo-court.jpg new file mode 100644 index 0000000..597c14c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/media-theo-court.jpg differ 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 0000000..453601a Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-1.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-2.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-2.jpg new file mode 100644 index 0000000..e7a807c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-2.jpg differ diff --git a/examples/instagram-uhura/fixtures/assets/thumb-lena-3.jpg b/examples/instagram-uhura/fixtures/assets/thumb-lena-3.jpg new file mode 100644 index 0000000..5122ed0 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-3.jpg differ 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 0000000..5450c59 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-4.jpg differ 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 0000000..1ab12ce Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-5.jpg differ 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 0000000..ceb732e Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-6.jpg differ 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 0000000..0783509 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-7.jpg differ 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 0000000..f4ae586 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-8.jpg differ 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 0000000..8987ac1 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-lena-9.jpg differ 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 0000000..b620672 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-1.jpg differ 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 0000000..15dc794 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-2.jpg differ 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 0000000..230a26c Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-3.jpg differ 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 0000000..e4ae4f9 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-4.jpg differ 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 0000000..2161a0e Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-5.jpg differ 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 0000000..cc3b2f7 Binary files /dev/null and b/examples/instagram-uhura/fixtures/assets/thumb-mira-6.jpg differ 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). `"@."` 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" + +# 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" + +[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" +posts = "list" +cursor = "option" +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" } + +[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" +tagged = "list" + +# 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 + } +} + + + + Comments + + + {#if notice != none} + + {notice ?? ""} + + + {/if} + {#match for-post(post)} + {:when loading} + + + + {:when failed reason} + + Comments couldn't load. + + {:when ready t} + + {#if count(t.comments) == 0} + + No comments yet + Start the conversation. + + {:else} + + {#each t.comments as c (c.id)} + + {/each} + + {/if} + + {#each pending-appends as pending-tag (pending-tag)} + + {/each} + + + {/match} + + {viewer.avatar.alt} + + + + + + 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, +} + +#[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