From 7a7ff0bb6e2d5155073c21bcf5d77efb7af50163 Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 20 Jul 2026 17:52:38 +0900 Subject: [PATCH 01/14] test(harness): preserve the Uhura 0.3 baselines --- docs/spec/drafts/relay-b3/README.md | 32 + .../answers/uhura-0.3/README.md | 43 + .../answers/uhura-0.3/conformance.uhura | 685 ++++++ .../answers/uhura-0.3/host.toml | 15 + .../answers/uhura-0.3/machine.uhura | 1756 +++++++++++++++ .../answers/uhura-0.3/provider.mjs | 163 ++ .../answers/uhura-0.3/web.uhura | 387 ++++ .../a0-return-desk/reference-oracle/README.md | 62 + .../a0-return-desk/reference-oracle/model.mjs | 1879 +++++++++++++++++ .../reference-oracle/validate.mjs | 1865 ++++++++++++++++ examples/programs/answers/uhura-0.3/README.md | 24 + .../programs/answers/uhura-0.3/programs.uhura | 439 ++++ 12 files changed, 7350 insertions(+) create mode 100644 docs/spec/drafts/relay-b3/README.md create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/README.md create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/conformance.uhura create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/host.toml create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/machine.uhura create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/provider.mjs create mode 100644 examples/applications/a0-return-desk/answers/uhura-0.3/web.uhura create mode 100644 examples/applications/a0-return-desk/reference-oracle/README.md create mode 100644 examples/applications/a0-return-desk/reference-oracle/model.mjs create mode 100644 examples/applications/a0-return-desk/reference-oracle/validate.mjs create mode 100644 examples/programs/answers/uhura-0.3/README.md create mode 100644 examples/programs/answers/uhura-0.3/programs.uhura diff --git a/docs/spec/drafts/relay-b3/README.md b/docs/spec/drafts/relay-b3/README.md new file mode 100644 index 0000000..16a0a4e --- /dev/null +++ b/docs/spec/drafts/relay-b3/README.md @@ -0,0 +1,32 @@ +# Relay B3 historical record + +- **Status:** Completed, superseded implementation experiment +- **Authority:** Historical provenance only +- **Implemented result:** Uhura 0.3 +- **Successor design:** [Uhura 0.4 incubation candidate](../0.4/) + +Relay B3 was the disposable candidate that established the transactional +machine model implemented as Uhura 0.3: + +```text +one admitted input + -> one finite non-reentrant reaction + -> one declared commit/abort outcome or fixed program fault + -> atomic state and ordered command publication + -> pure observation +``` + +The experiment was implemented directly in Uhura. Relay never became a +separate runtime, module tree, authored language, file extension, or product. +The checked-in Uhura 0.3 answers and implementation tests are the executable +record of its result. + +The former multi-document candidate contract mixed pre-implementation gates, +post-implementation records, concrete syntax, application semantics, and +kernel claims under contradictory authority labels. It has been removed from +normal navigation. Git history retains the exact grammar, design review, +implementation record, and evidence when historical reconstruction is useful. + +No current design inherits Relay's source tokens, file ordering, source-token +hash, monolithic application layout, or documentation topology. The successor +candidate restates every retained kernel property in source-neutral terms. diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/README.md b/examples/applications/a0-return-desk/answers/uhura-0.3/README.md new file mode 100644 index 0000000..f253ce2 --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/README.md @@ -0,0 +1,43 @@ +# Uhura 0.3 answer to A0 Return Desk + +- **Status:** Executable Uhura 0.3 answer sheet +- **Language and system records:** [Uhura specification index](../../../../../docs/spec/) +- **Problem authority:** [A0 Return Desk](../..) +- **Independent evidence:** [A0 reference oracle](../../reference-oracle/) + +The answer is split by authority: + +```text +machine.uhura headless application-session coordinator +web.uhura use ui; pure checked web projection +conformance.uhura use evidence; fixtures, scenarios, pins, checkpoints +host.toml explicit live instance and port bindings +provider.mjs application-owned live port adapters +``` + +These files are Uhura source, not pseudocode. The Uhura machine kernel parses, +checks, lowers, executes, renders, and hosts this exact project. The A0 Markdown +remains authoritative if this answer omits or changes a requirement. + +The reference oracle tests the application behavior independently. It does +not prove that these source files parse or lower to the same machine. + +`host.toml` is admitted against the closed Uhura deployment schema. Browser +history is injected by the standard `web.history` capability. The application +module does not implement or wrap it. The order observation and return request +ports use the generic `app.provider` boundary implemented by `provider.mjs`. +That module returns exactly those two adapters. The browser admits them against +the exact adapter, checked contract, and instance identities in +`uhura-play-config/1` +before any command crosses the boundary. Adapter deliveries enter the +`uhura-browser/2` machine boundary through the browser runtime's deferred FIFO +bridge, so foreign code cannot synchronously reenter a machine reaction. + +The provider owns its live seed order and accepted return settlement +independently from `conformance.uhura`. Removing evidence source removes +previews and conformance artifacts without changing the live Play dependency +graph. `ReturnDesk` has `Unit` configuration, so this answer intentionally +omits the manifest's optional `configuration` field. A non-Unit machine must +instead provide one TOML string containing canonical tagged Uhura value JSON; +the host type-decodes and genesis-preflights that exact value before Play is +available. diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/conformance.uhura b/examples/applications/a0-return-desk/answers/uhura-0.3/conformance.uhura new file mode 100644 index 0000000..547fa02 --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/conformance.uhura @@ -0,0 +1,685 @@ +language uhura 0.3 +module app.return_desk.conformance@1 + +use evidence + +import { + LineId, + Location, + OrderId, + OrderWire, + Reason, + RequestId, + ReturnDesk, + ReturnId, + ReturnMethod, + Step, + SurfaceId, + lamp, + mug, + order_100, + return_routes, +} from "app.return_desk.machine@1" +import { Token } from "uhura.boundary@1" +import { Observation } from "uhura.observation@1" +import { RequestPort } from "uhura.ports@1" +import { Router } from "uhura.web_router@1" + + +const order_7: OrderWire = { + id: order_100, + revision: 7, + lines: [ + { + id: lamp, + title: "Desk lamp", + purchased_quantity: 2, + returnable_quantity: 2, + policy_summary: + "Return the lamp in protective packaging.", + }, + { + id: mug, + title: "Stoneware mug", + purchased_quantity: 1, + returnable_quantity: 1, + policy_summary: + "Wrap the mug to prevent breakage in transit.", + }, + ], + allowed_methods: ["drop-off", "pickup"], +} + +const order_8: OrderWire = { + id: order_100, + revision: 8, + lines: [ + { + id: lamp, + title: "Desk lamp", + purchased_quantity: 2, + returnable_quantity: 0, + policy_summary: + "Return the lamp in protective packaging.", + }, + { + id: mug, + title: "Stoneware mug", + purchased_quantity: 1, + returnable_quantity: 1, + policy_summary: + "Wrap the mug to prevent breakage in transit.", + }, + ], + allowed_methods: ["drop-off", "pickup"], +} + +const order_9_without_lamp: OrderWire = { + id: order_100, + revision: 9, + lines: [ + { + id: mug, + title: "Stoneware mug", + purchased_quantity: 1, + returnable_quantity: 1, + policy_summary: + "Wrap the mug to prevent breakage in transit.", + }, + ], + allowed_methods: ["drop-off", "pickup"], +} + +const items_location: Location = + flow(order_100, some("items")) +const method_location: Location = + flow(order_100, some("method")) +const review_location: Location = + flow(order_100, some("review")) +const order_location: Location = order(order_100) +const return_900: ReturnId = ReturnId("return-900") +const receipt_location: Location = receipt(return_900) + + +scenario canonical for ReturnDesk { + bind router = Router.fixture(return_routes) + bind orders = Observation.fixture() + bind returns = RequestPort.fixture() + + start + expect observation { + location: none, + order: none, + draft: none, + ... + } + + deliver router.changed(review_location) + expect accepted commands [ + router.replace(items_location), + ] + + deliver router.changed(items_location) + expect accepted commands [] + pin waiting_at_items + + deliver orders.observed(order_7) + expect accepted commands [] + + send choose_quantity(lamp, finite(1)) + expect accepted commands [] + pin incomplete_items + + send choose_reason(lamp, known(damaged)) + expect accepted commands [] + pin complete_items + + send open_policy(lamp) + expect accepted commands [] + pin open_policy_surface + + send dismiss_policy(SurfaceId(1)) + expect accepted commands [] + + send go_to_step(known(method)) + expect accepted commands [ + router.push(method_location), + ] + + deliver router.changed(method_location) + expect accepted commands [] + + send choose_method(known(drop_off)) + expect accepted commands [] + pin method_selection + + send go_to_step(known(review)) + expect accepted commands [ + router.push(review_location), + ] + + deliver router.changed(review_location) + expect accepted commands [] + pin review + + send submit + expect accepted commands [ + returns.request( + RequestId(1), + { + order: order_100, + expected_revision: 7, + selections: { + lamp: { + quantity: 1, + reason: damaged, + }, + }, + method: drop_off, + }, + ), + ] + pin request_1_pending + + send submit + expect duplicate commands [] + + deliver router.changed(method_location) + expect accepted commands [] + + deliver returns.settled( + RequestId(1), + refused_order_changed(8), + ) + expect accepted commands [] + pin domain_refusal + + deliver orders.observed(order_8) + expect accepted commands [ + router.replace(items_location), + ] + pin source_changed_conflict + + deliver router.changed(items_location) + expect accepted commands [] + + send restart_from_current_order + expect accepted commands [] + pin restarted_at_revision_8 + + send choose_quantity(mug, finite(1)) + expect accepted commands [] + + send choose_reason(mug, known(not_needed)) + expect accepted commands [] + + send go_to_step(known(method)) + expect accepted commands [ + router.push(method_location), + ] + + deliver router.changed(method_location) + expect accepted commands [] + + send choose_method(known(pickup)) + expect accepted commands [] + + send go_to_step(known(review)) + expect accepted commands [ + router.push(review_location), + ] + + deliver router.changed(review_location) + expect accepted commands [] + + send submit + expect accepted commands [ + returns.request( + RequestId(2), + { + order: order_100, + expected_revision: 8, + selections: { + mug: { + quantity: 1, + reason: not_needed, + }, + }, + method: pickup, + }, + ), + ] + + deliver router.changed(method_location) + expect accepted commands [] + pin request_2_pending_after_back + + deliver returns.settled( + RequestId(2), + accepted("return-900"), + ) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver router.changed(receipt_location) + expect accepted commands [] + pin receipt + + deliver returns.settled( + RequestId(2), + accepted("return-900"), + ) + expect stale commands [] +} + + +scenario complete_items_without_method + from canonical::complete_items +{ + deliver router.changed(review_location) + expect accepted commands [ + router.replace(method_location), + ] +} + +scenario missing_and_unknown_step for ReturnDesk { + bind router = Router.fixture(return_routes) + bind orders = Observation.fixture() + bind returns = RequestPort.fixture() + + start + + deliver router.changed( + flow(order_100, none), + ) + expect accepted commands [ + router.replace(items_location), + ] + + deliver router.changed( + flow(order_100, some("unknown")), + ) + expect accepted commands [ + router.replace(items_location), + ] +} + +scenario browser_forward_after_restart + from canonical::restarted_at_revision_8 +{ + deliver router.changed(review_location) + expect accepted commands [ + router.replace(items_location), + ] +} + +scenario stale_order_revision + from canonical::source_changed_conflict +{ + deliver orders.observed(order_7) + expect stale commands [] +} + +scenario removed_selected_line + from canonical::open_policy_surface +{ + deliver orders.observed(order_9_without_lamp) + expect accepted commands [] + expect observation { + policy: none, + conflict: some(_), + ... + } + expect observation where + draft is some(current) + and current.selections.get(lamp) is some(_) +} + +scenario stale_surface_cannot_close_newer + from canonical::open_policy_surface +{ + send go_to_step(known(method)) + expect accepted commands [ + router.push(method_location), + ] + + deliver router.changed(method_location) + expect accepted commands [] + + deliver router.changed(items_location) + expect accepted commands [] + + send open_policy(mug) + expect accepted commands [] + + send dismiss_policy(SurfaceId(1)) + expect stale commands [] + + expect observation { + policy: some({ + line: mug, + ... + }), + ... + } + pin newer_surface_after_stale_dismiss +} + +scenario unavailable_then_retry + from canonical::request_1_pending +{ + deliver returns.settled( + RequestId(1), + unavailable, + ) + expect accepted commands [] + pin retryable_unavailability + + send submit + expect accepted commands [ + returns.request( + RequestId(2), + { + order: order_100, + expected_revision: 7, + selections: { + lamp: { + quantity: 1, + reason: damaged, + }, + }, + method: drop_off, + }, + ), + ] +} + +scenario invalid_refusal_keeps_pending + from canonical::request_1_pending +{ + deliver returns.settled( + RequestId(1), + refused_order_changed(7), + ) + expect invalid(invalid_refusal_revision) + commands [] + expect inspection { + submission: pending({ + id: RequestId(1), + ... + }), + ... + } +} + +scenario malformed_settlement_precedes_stale + from canonical::request_1_pending +{ + deliver returns.settled( + RequestId(99), + refused_order_changed(0), + ) + expect invalid(invalid_refusal_revision) + commands [] + + deliver returns.settled( + RequestId(99), + accepted(""), + ) + expect invalid(invalid_return_id) + commands [] + + expect inspection { + submission: pending({ + id: RequestId(1), + ... + }), + ... + } +} + +scenario fence_requires_observation_and_restart + from canonical::domain_refusal +{ + deliver router.changed(review_location) + expect accepted commands [] + + send submit + expect blocked(source_changed) commands [] + + deliver router.changed(items_location) + expect accepted commands [] + + send choose_quantity(lamp, finite(2)) + expect blocked(source_changed) commands [] + + deliver orders.observed(order_8) + expect accepted commands [] + + send choose_quantity(mug, finite(1)) + expect blocked(source_changed) commands [] + + send restart_from_current_order + expect accepted commands [] +} + +scenario late_acceptance_outside_flow + from canonical::request_1_pending +{ + deliver router.changed(order_location) + expect accepted commands [] + + deliver returns.settled( + RequestId(1), + accepted("return-900"), + ) + expect accepted commands [] + pin completion_notice_outside_flow + + send follow_receipt_link + expect accepted commands [ + router.push(receipt_location), + ] + + deliver router.changed(receipt_location) + expect accepted commands [] + expect observation { + completion_notice: none, + ... + } + + send follow_order_link + expect accepted commands [ + router.push(order_location), + ] + + deliver router.changed(order_location) + expect accepted commands [] + expect observation { + completion_notice: none, + ... + } +} + +scenario unknown_and_conflicting_settlements + from canonical::request_1_pending +{ + deliver returns.settled( + RequestId(99), + accepted("return-unknown"), + ) + expect stale commands [] + + deliver returns.settled( + RequestId(1), + accepted("return-900"), + ) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver returns.settled( + RequestId(1), + refused_order_changed(8), + ) + expect stale commands [] +} + +scenario old_flow_after_completion + from canonical::receipt +{ + deliver router.changed(review_location) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver router.changed(review_location) + expect duplicate commands [] +} + +scenario receipt_replacement_superseded + from canonical::request_1_pending +{ + deliver returns.settled( + RequestId(1), + accepted("return-900"), + ) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver router.changed(order_location) + expect accepted commands [] + expect observation { + completion_notice: some(return_900), + actions: { + follow_receipt: true, + ... + }, + ... + } + + send follow_receipt_link + expect accepted commands [ + router.push(receipt_location), + ] +} + +scenario repeated_normalization for ReturnDesk { + bind router = Router.fixture(return_routes) + bind orders = Observation.fixture() + bind returns = RequestPort.fixture() + + start + + deliver router.changed(review_location) + expect accepted commands [ + router.replace(items_location), + ] + + deliver router.changed(review_location) + expect duplicate commands [] +} + +scenario one_navigation_intent + from canonical::complete_items +{ + send go_to_step(known(method)) + expect accepted commands [ + router.push(method_location), + ] + + send go_to_step(known(method)) + expect duplicate commands [] + + send follow_order_link + expect blocked(navigation_pending) commands [] + + deliver router.changed(method_location) + expect accepted commands [] + + send go_to_step(known(items)) + expect accepted commands [ + router.push(items_location), + ] +} + + +example waiting_at_items = + canonical::waiting_at_items + +example incomplete_items = + canonical::incomplete_items + +example complete_items = + canonical::complete_items + +example method_selection = + canonical::method_selection + +example review = + canonical::review + +example open_policy_surface = + canonical::open_policy_surface + +example pending_after_back = + canonical::request_2_pending_after_back + +example source_changed_conflict = + canonical::source_changed_conflict + +example domain_refusal = + canonical::domain_refusal + +example retryable_unavailability = + unavailable_then_retry::retryable_unavailability + +example completion_notice_outside_flow = + late_acceptance_outside_flow:: + completion_notice_outside_flow + +example receipt = + canonical::receipt + + +checkpoint request_2_pending = + canonical::request_2_pending_after_back + +scenario replay_accepted_suffix + from request_2_pending +{ + expect restore commands [] + + deliver returns.settled( + RequestId(2), + accepted("return-900"), + ) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver router.changed(receipt_location) + expect accepted commands [] + pin replay_final +} + +scenario replay_accepted_suffix_again + from request_2_pending +{ + expect restore commands [] + + deliver returns.settled( + RequestId(2), + accepted("return-900"), + ) + expect accepted commands [ + router.replace(receipt_location), + ] + + deliver router.changed(receipt_location) + expect accepted commands [] + expect snapshot == + replay_accepted_suffix::replay_final +} diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/host.toml b/examples/applications/a0-return-desk/answers/uhura-0.3/host.toml new file mode 100644 index 0000000..fa72777 --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/host.toml @@ -0,0 +1,15 @@ +[entry.return-desk] +machine = "app.return_desk.machine@1::ReturnDesk" +presentation = "app.return_desk.web@1::ReturnDeskWeb" +lifetime = "application-session" + +[entry.return-desk.ports] +router = "web.history" +orders = "app.provider" +returns = "app.provider" + +[entry.return-desk.provider] +module = "provider.mjs" + +[entry.return-desk.provider.config] +return_id = "return-900" diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/machine.uhura b/examples/applications/a0-return-desk/answers/uhura-0.3/machine.uhura new file mode 100644 index 0000000..9b0724c --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/machine.uhura @@ -0,0 +1,1756 @@ +language uhura 0.3 +module app.return_desk.machine@1 + +import { Token } from "uhura.boundary@1" +import { Observation } from "uhura.observation@1" +import { RequestPort } from "uhura.ports@1" +import { Router, Routes, routes } from "uhura.web_router@1" + + +key OrderId over Text +key LineId over Text +key ReturnId over Text +key RequestId over PositiveInt +key ScopeId over PositiveInt +key SurfaceId over PositiveInt + +const order_100: OrderId = OrderId("order-100") +const lamp: LineId = LineId("lamp") +const mug: LineId = LineId("mug") + +type Step = items | method | review +type ReturnMethod = drop_off | pickup +type Reason = damaged | not_needed + +type Location = + | flow(order: OrderId, step: Option) + | order(order: OrderId) + | receipt(return_id: ReturnId) + +const return_routes: Routes = routes({ + flow: "/orders/{order}/return?step={step?}", + order: "/orders/{order}", + receipt: "/returns/{return_id}", +}) + +type NavigationIntent = + | user_push(Location) + | required_replace(Location) + +type OrderLineWire = { + id: LineId, + title: Text, + purchased_quantity: Int, + returnable_quantity: Int, + policy_summary: Text, +} + +type OrderWire = { + id: OrderId, + revision: Int, + lines: Seq, + allowed_methods: Seq, +} + +type OrderLine = { + id: LineId, + title: Text, + purchased_quantity: Nat, + returnable_quantity: Nat, + policy_summary: Text, +} + +type Order = { + id: OrderId, + revision: PositiveInt, + lines: Map, + allowed_methods: Set, +} + +type Selection = { + quantity: PositiveInt, + reason: Option, +} + +type ReturnDraft = { + order: OrderId, + base_revision: PositiveInt, + selections: Map, + method: Option, +} + +type SubmittedSelection = { + quantity: PositiveInt, + reason: Reason, +} + +type ReturnPayload = { + order: OrderId, + expected_revision: PositiveInt, + selections: Map, + method: ReturnMethod, +} + +type PendingRequest = { + id: RequestId, + payload: ReturnPayload, +} + +type Settlement = + | accepted(return_id: Text) + | refused_order_changed(current_revision: Int) + | unavailable + +type Submission = + | idle + | pending(PendingRequest) + | refused( + request: PendingRequest, + required_revision: PositiveInt, + ) + | unavailable_for(request: PendingRequest) + | completed(request: RequestId, return_id: ReturnId) + +type ReceiptAccess = + | no_completion + | redirecting + | offered + | acknowledged + +type PolicySurface = { + id: SurfaceId, + owner: ScopeId, + line: LineId, +} + +type SourceConflict = { + draft_revision: PositiveInt, + observed_revision: PositiveInt, + required_revision: Option, +} + +type BlockReason = + | wrong_location + | normalizing + | order_waiting + | pending_submission + | completed_return + | source_changed + | navigation_pending + | incomplete_items + | incomplete_method + | revision_fence + | submission_phase + | no_completion_notice + +type InvalidReason = + | location_outside_domain + | order_structure + | equal_revision_order + | unknown_line + | invalid_quantity + | unselected_line + | unknown_reason + | unknown_method + | disallowed_method + | unknown_step + | invalid_return_id + | invalid_refusal_revision + +type CheckedOrder = + | valid(Order) + | invalid(InvalidReason) + +type UserNavigation = + | ready + | repeated + | pending_other + | unavailable_while_normalizing + +type Page = + | no_location + | normalizing_to(Location) + | items_page + | method_page + | review_page + | order_page + | receipt_page(ReturnId) + +type Actions = { + quantity_lines: Set, + reason_lines: Set, + methods: Set, + steps: Set, + policy_lines: Set, + dismiss_surface: Option, + restart: Bool, + submit: Bool, + follow_order: Bool, + follow_receipt: Bool, +} + +type PolicyView = { + instance: SurfaceId, + line: LineId, + title: Text, + summary: Text, +} + + +machine ReturnDesk { + port router: Router(return_routes) + port orders: Observation + port returns: + RequestPort + + input = + | choose_quantity( + line: LineId, + value: BoundaryNumber, + ) + | choose_reason( + line: LineId, + value: Token, + ) + | choose_method(value: Token) + | go_to_step(value: Token) + | open_policy(line: LineId) + | dismiss_policy(surface: SurfaceId) + | restart_from_current_order + | submit + | follow_order_link + | follow_receipt_link + + command = Never + + outcome = + | accepted commit + | blocked(BlockReason) abort + | duplicate abort + | stale abort + | invalid(InvalidReason) abort + + state { + location: Option = none + order: Option = none + draft: Option = none + submission: Submission = idle + receipt_access: ReceiptAccess = no_completion + navigation: Option = none + route_scope: Option = none + surface: Option = none + settled: Set = Set.empty + next_request: Nat = 0 + next_scope: Nat = 0 + next_surface: Nat = 0 + } + + fn parse_step(value: Text) -> Option = + match value { + "items" => some(items) + "method" => some(method) + "review" => some(review) + _ => none + } + + fn step_text(value: Step) -> Text = + match value { + items => "items" + method => "method" + review => "review" + } + + fn parse_method(value: Text) -> Option = + match value { + "drop-off" => some(drop_off) + "pickup" => some(pickup) + _ => none + } + + fn is_flow(value: Option) -> Bool = + value is some(flow(order, _)) and order == order_100 + + fn completed_return( + phase: Submission, + ) -> Option = + match phase { + completed(_, return_id) => some(return_id) + _ => none + } + + fn completion_notice( + phase: Submission, + access: ReceiptAccess, + ) -> Option = + match (phase, access) { + (completed(_, return_id), offered) => + some(return_id) + _ => none + } + + fn navigation_target( + intent: NavigationIntent, + ) -> Location = + match intent { + user_push(target) | required_replace(target) => + target + } + + fn refusal_fence( + phase: Submission, + ) -> Option = + match phase { + refused(_, required) => some(required) + _ => none + } + + fn line_of( + current: Option, + line: LineId, + ) -> Option = + match current { + none => none + some(order) => order.lines.get(line) + } + + fn blank_draft(order: Order) -> ReturnDraft = { + order: order.id, + base_revision: order.revision, + selections: Map.empty, + method: none, + } + + fn is_blank(value: ReturnDraft) -> Bool = + value.selections.is_empty and value.method == none + + fn return_payload( + value: ReturnDraft, + ) -> Option = { + if value.selections.is_empty { + none + } else { + let selections = + value.selections.try_map_values( + (_, selection) => + match selection.reason { + none => none + some(reason) => + some({ + quantity: selection.quantity, + reason: reason, + }) + } + ) + + match (selections, value.method) { + (some(selections), some(method)) => + some({ + order: value.order, + expected_revision: value.base_revision, + selections: selections, + method: method, + }) + + _ => none + } + } + } + + fn reason_complete(value: Option) -> Bool = + value is some(_) + + fn items_complete( + current_order: Option, + current_draft: Option, + ) -> Bool = + match (current_order, current_draft) { + (some(order), some(draft)) => + draft.order == order.id + and draft.base_revision == order.revision + and not draft.selections.is_empty + and draft.selections.entries.all((line, selection) => + order.lines.get(line) is some(current) + and selection.quantity <= current.returnable_quantity + and reason_complete(selection.reason) + ) + + _ => false + } + + fn method_complete( + current_order: Option, + current_draft: Option, + ) -> Bool = + items_complete(current_order, current_draft) + and current_order is some(order) + and current_draft is some(draft) + and draft.method is some(chosen) + and order.allowed_methods.contains(chosen) + + fn source_conflict( + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Option = + match (current_order, current_draft) { + (some(order), some(draft)) => { + let fence = refusal_fence(phase) + if draft.base_revision != order.revision + or fence is some(_) + { + some({ + draft_revision: draft.base_revision, + observed_revision: order.revision, + required_revision: fence, + }) + } else { + none + } + } + + _ => none + } + + fn required_target( + current_location: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Option = + match current_location { + none => none + + some(flow(order_id, raw_step)) => { + match completed_return(phase) { + some(return_id) => + some(receipt(return_id)) + + none => { + let requested = + match raw_step { + none => none + some(text) => parse_step(text) + } + + match requested { + none => + some(flow(order_id, some("items"))) + + some(items) => + none + + some(method) => + if items_complete(current_order, current_draft) + then none + else some(flow(order_id, some("items"))) + + some(review) => + if not items_complete(current_order, current_draft) + then some(flow(order_id, some("items"))) + else if not method_complete( + current_order, + current_draft, + ) + then some(flow(order_id, some("method"))) + else none + } + } + } + } + + some(order(_)) | some(receipt(_)) => + none + } + + fn active_step( + current_location: Option, + current_scope: Option, + ) -> Option = + match (current_location, current_scope) { + (some(flow(order_id, some(raw))), some(_)) => { + if order_id != order_100 { + none + } else { + parse_step(raw) + } + } + + _ => none + } + + fn step_admissible( + target: Step, + current_order: Option, + current_draft: Option, + ) -> Bool = + match target { + items => true + method => items_complete(current_order, current_draft) + review => method_complete(current_order, current_draft) + } + + fn step_rank(value: Step) -> Nat = + match value { + items => 0 + method => 1 + review => 2 + } + + fn page( + current_location: Option, + current_scope: Option, + phase: Submission, + current_order: Option, + current_draft: Option, + current_navigation: Option, + ) -> Page = + match current_location { + none => no_location + + some(current) => + match current { + flow(_, _) => { + match active_step(current_location, current_scope) { + some(items) => items_page + some(method) => method_page + some(review) => review_page + none => { + let target = + match required_target( + current_location, + current_order, + current_draft, + phase, + ) { + some(target) => target + none => + match current_navigation { + some(required_replace(target)) => target + _ => current + } + } + normalizing_to(target) + } + } + } + + order(_) => + order_page + + receipt(return_id) => + match completed_return(phase) { + some(current) => { + if current == return_id + then receipt_page(return_id) + else no_location + } + none => no_location + } + } + } + + fn user_navigation( + current_location: Option, + current_navigation: Option, + current_scope: Option, + target: Location, + ) -> UserNavigation = { + if current_location == some(target) { + repeated + } else if current_navigation is some(intent) + and navigation_target(intent) == target + { + repeated + } else if current_navigation is some(_) { + pending_other + } else if is_flow(current_location) + and current_scope == none + { + unavailable_while_normalizing + } else { + ready + } + } + + fn validate_line( + wire: OrderLineWire, + ) -> Option = { + if wire.purchased_quantity < 0 + or wire.returnable_quantity < 0 + or wire.returnable_quantity > wire.purchased_quantity + { + none + } else { + some({ + id: wire.id, + title: wire.title, + purchased_quantity: wire.purchased_quantity, + returnable_quantity: wire.returnable_quantity, + policy_summary: wire.policy_summary, + }) + } + } + + fn validate_order(wire: OrderWire) -> CheckedOrder = { + if wire.id != order_100 or wire.revision <= 0 { + invalid(order_structure) + } else { + let checked_lines = + wire.lines.try_map(line => validate_line(line)) + + let checked_methods = + wire.allowed_methods.try_map(text => parse_method(text)) + + match (checked_lines, checked_methods) { + (some(lines), some(methods)) => { + let line_pairs = + lines.map(line => (line.id, line)) + + match ( + Map.from_unique(line_pairs), + Set.from_unique(methods), + ) { + (some(lines), some(allowed_methods)) => + valid({ + id: wire.id, + revision: wire.revision, + lines: lines, + allowed_methods: allowed_methods, + }) + + _ => + invalid(order_structure) + } + } + + _ => + invalid(order_structure) + } + } + } + + fn edit_block( + required_step: Step, + current_location: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Option = { + if is_flow(current_location) and current_scope == none { + some(normalizing) + } else if active_step( + current_location, + current_scope, + ) != some(required_step) + { + some(wrong_location) + } else if current_order == none or current_draft == none { + some(order_waiting) + } else if phase is pending(_) { + some(pending_submission) + } else if phase is completed(_, _) { + some(completed_return) + } else if source_conflict( + current_order, + current_draft, + phase, + ) is some(_) + { + some(source_changed) + } else { + none + } + } + + fn clear_retryable(phase: Submission) -> Submission = + match phase { + unavailable_for(_) => idle + _ => phase + } + + fn can_go( + target: Step, + current_location: Option, + current_navigation: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Bool = + user_navigation( + current_location, + current_navigation, + current_scope, + flow(order_100, some(step_text(target))), + ) == ready + and active_step( + current_location, + current_scope, + ) is some(current) + and step_admissible( + target, + current_order, + current_draft, + ) + and ( + source_conflict( + current_order, + current_draft, + phase, + ) == none + or step_rank(target) <= step_rank(current) + ) + + fn quantity_lines( + current_location: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Set = + match current_order { + none => Set.empty + some(order) => + Set { + for (line, current) in order.lines.entries + when current.returnable_quantity > 0 + when edit_block( + items, + current_location, + current_scope, + current_order, + current_draft, + phase, + ) == none + yield line + } + } + + fn reason_lines( + current_location: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Set = + match (current_order, current_draft) { + (some(order), some(draft)) => + Set { + for (line, _) in order.lines.entries + when draft.selections.get(line) is some(_) + when edit_block( + items, + current_location, + current_scope, + current_order, + current_draft, + phase, + ) == none + yield line + } + + _ => Set.empty + } + + fn method_choices( + current_location: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Set = + Set { + for candidate in [drop_off, pickup] + when current_order is some(order) + when current_draft is some(draft) + when order.allowed_methods.contains(candidate) + when draft.method != some(candidate) + when items_complete(current_order, current_draft) + when edit_block( + method, + current_location, + current_scope, + current_order, + current_draft, + phase, + ) == none + yield candidate + } + + fn step_choices( + current_location: Option, + current_navigation: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Set = + Set { + for candidate in [items, method, review] + when can_go( + candidate, + current_location, + current_navigation, + current_scope, + current_order, + current_draft, + phase, + ) + yield candidate + } + + fn surface_line( + current_surface: Option, + ) -> Option = + match current_surface { + none => none + some(surface) => some(surface.line) + } + + fn policy_lines( + current_location: Option, + current_scope: Option, + current_order: Option, + current_surface: Option, + ) -> Set = + match current_order { + none => Set.empty + some(order) => + Set { + for (line, _) in order.lines.entries + when active_step( + current_location, + current_scope, + ) == some(items) + when surface_line(current_surface) != some(line) + yield line + } + } + + fn can_restart( + current_location: Option, + current_scope: Option, + current_order: Option, + current_draft: Option, + phase: Submission, + ) -> Bool = + match current_order { + none => false + some(order) => + active_step( + current_location, + current_scope, + ) == some(items) + and not (phase is pending(_)) + and not (phase is completed(_, _)) + and ( + refusal_fence(phase) == none + or refusal_fence(phase) is some(required) + and order.revision >= required + ) + and not ( + current_draft is some(draft) + and draft.base_revision == order.revision + and is_blank(draft) + and refusal_fence(phase) == none + and phase == idle + ) + } + + derive items_ready: Bool = + items_complete(order, draft) + + derive method_ready: Bool = + method_complete(order, draft) + + derive conflict: Option = + source_conflict(order, draft, submission) + + derive notice: Option = + completion_notice(submission, receipt_access) + + derive current_page: Page = + page( + location, + route_scope, + submission, + order, + draft, + navigation, + ) + + invariant { + next_request >= settled.size, + match surface { + none => true + some(open) => + route_scope == some(open.owner) + and active_step(location, route_scope) == some(items) + and line_of(order, open.line) is some(_) + }, + match route_scope { + none => true + some(_) => + is_flow(location) + and required_target( + location, + order, + draft, + submission, + ) == none + }, + match required_target( + location, + order, + draft, + submission, + ) { + some(target) => + route_scope == none + and surface == none + and navigation == some(required_replace(target)) + + none => + if is_flow(location) + then route_scope is some(_) + else true + }, + match submission { + pending(request) => + draft is some(current) + and return_payload(current) == some(request.payload) + and not settled.contains(request.id) + and receipt_access == no_completion + refused(request, required) => + required > request.payload.expected_revision + and draft is some(current) + and return_payload(current) == some(request.payload) + and settled.contains(request.id) + and receipt_access == no_completion + unavailable_for(request) => + draft is some(current) + and return_payload(current) == some(request.payload) + and settled.contains(request.id) + and receipt_access == no_completion + completed(request, _) => + draft == none + and settled.contains(request) + and receipt_access != no_completion + idle => receipt_access == no_completion + }, + match (completed_return(submission), location, receipt_access) { + (none, _, no_completion) => true + (some(return_id), some(receipt(current)), acknowledged) => + current == return_id + (some(_), some(flow(_, _)), redirecting) => true + (some(_), some(flow(_, _)), acknowledged) => true + (some(_), some(order(_)), offered) => true + (some(_), some(order(_)), acknowledged) => true + _ => false + }, + } + + observe { + location = location + page = current_page + order = order + draft = draft + conflict = conflict + items_complete = items_ready + method_complete = method_ready + submission = submission + policy: Option = + match surface { + none => none + some(open) => + match line_of(order, open.line) { + none => none + some(line) => + some({ + instance: open.id, + line: line.id, + title: line.title, + summary: line.policy_summary, + }) + } + } + completion_notice = notice + actions: Actions = { + quantity_lines: + quantity_lines( + location, + route_scope, + order, + draft, + submission, + ), + reason_lines: + reason_lines( + location, + route_scope, + order, + draft, + submission, + ), + methods: + method_choices( + location, + route_scope, + order, + draft, + submission, + ), + steps: + step_choices( + location, + navigation, + route_scope, + order, + draft, + submission, + ), + policy_lines: + policy_lines( + location, + route_scope, + order, + surface, + ), + dismiss_surface: + match surface { + some(open) => + if route_scope == some(open.owner) + then some(open.id) + else none + none => none + }, + restart: + can_restart( + location, + route_scope, + order, + draft, + submission, + ), + submit: + active_step(location, route_scope) == some(review) + and source_conflict( + order, + draft, + submission, + ) == none + and method_complete(order, draft) + and ( + submission == idle + or submission is unavailable_for(_) + ), + follow_order: + ( + location is some(flow(order_id, _)) + and order_id == order_100 + or location is some(receipt(return_id)) + and completed_return(submission) + == some(return_id) + ) + and user_navigation( + location, + navigation, + route_scope, + order(order_100), + ) == ready, + follow_receipt: + notice is some(return_id) + and user_navigation( + location, + navigation, + route_scope, + receipt(return_id), + ) == ready, + } + } + + on router.changed(next) { + match next { + flow(order_id, _) | order(order_id) => { + if order_id != order_100 { + finish invalid(location_outside_domain) + } + } + + receipt(return_id) => { + if completed_return(submission) != some(return_id) { + finish invalid(location_outside_domain) + } + } + } + + if location == some(next) { + finish duplicate + } + + set location = some(next) + set navigation = none + set route_scope = none + set surface = none + + match completed_return(submission) { + none => {} + some(return_id) => { + set receipt_access = + match next { + receipt(current) => + if current == return_id + then acknowledged + else receipt_access + flow(_, _) => + if receipt_access == acknowledged + then acknowledged + else redirecting + order(_) => + if receipt_access == acknowledged + then acknowledged + else offered + } + } + } + + if is_flow(location) + and order is some(current_order) + and draft == none + and completed_return(submission) == none + { + set draft = some(blank_draft(current_order)) + } + + if is_flow(location) + and required_target( + location, + order, + draft, + submission, + ) == none + { + let serial: PositiveInt = next_scope + 1 + set next_scope = serial + set route_scope = some(ScopeId(serial)) + } + + finish accepted + } + + on orders.observed(wire) { + let next = + match validate_order(wire) { + valid(order) => order + invalid(reason) => finish invalid(reason) + } + + match order { + none => {} + + some(current) => { + if next.revision < current.revision { + finish stale + } + + if next.revision == current.revision { + if next == current { + finish duplicate + } + finish invalid(equal_revision_order) + } + } + } + + set order = some(next) + + if surface is some(open) + and next.lines.get(open.line) == none + { + set surface = none + } + + if is_flow(location) + and draft == none + and completed_return(submission) == none + { + set draft = some(blank_draft(next)) + } + + finish accepted + } + + on choose_quantity(line, value) { + let quantity = + match Int.from(value) { + none => finish invalid(invalid_quantity) + some(quantity) => quantity + } + + if quantity < 0 { + finish invalid(invalid_quantity) + } + + let current_line = + match line_of(order, line) { + some(line) => line + none => finish invalid(unknown_line) + } + + if quantity > current_line.returnable_quantity { + finish invalid(invalid_quantity) + } + + let current = + match draft { + none => none + some(draft) => draft.selections.get(line) + } + + if quantity == 0 and current == none { + finish duplicate + } + + if quantity > 0 + and current is some(selection) + and selection.quantity == quantity + { + finish duplicate + } + + match edit_block( + items, + location, + route_scope, + order, + draft, + submission, + ) { + some(reason) => finish blocked(reason) + none => {} + } + + let current_draft = + match draft { + some(draft) => draft + none => unreachable + } + + if quantity == 0 { + set draft = some(current_draft with { + selections: + current_draft.selections.remove(line), + }) + } else { + let reason = + match current { + none => none + some(selection) => selection.reason + } + + set draft = some(current_draft with { + selections: + current_draft.selections.put(line, { + quantity: quantity, + reason: reason, + }), + }) + } + + set submission = clear_retryable(submission) + finish accepted + } + + on choose_reason(line, value) { + let reason = + match value { + unknown(_) => finish invalid(unknown_reason) + known(reason) => reason + } + + match line_of(order, line) { + some(_) => {} + none => finish invalid(unknown_line) + } + + let current_draft = + match draft { + none => finish invalid(unselected_line) + some(draft) => draft + } + + let selection = + match current_draft.selections.get(line) { + none => finish invalid(unselected_line) + some(selection) => selection + } + + if selection.reason == some(reason) { + finish duplicate + } + + match edit_block( + items, + location, + route_scope, + order, + draft, + submission, + ) { + some(block) => finish blocked(block) + none => {} + } + + set draft = some(current_draft with { + selections: + current_draft.selections.put( + line, + selection with { reason: some(reason) }, + ), + }) + set submission = clear_retryable(submission) + finish accepted + } + + on choose_method(value) { + let chosen = + match value { + unknown(_) => finish invalid(unknown_method) + known(method) => method + } + + match order { + some(current) => { + if not current.allowed_methods.contains(chosen) { + finish invalid(disallowed_method) + } + } + none => {} + } + + if draft is some(current) + and current.method == some(chosen) + { + finish duplicate + } + + match edit_block( + method, + location, + route_scope, + order, + draft, + submission, + ) { + some(block) => finish blocked(block) + none => {} + } + + if not items_complete(order, draft) { + finish blocked(incomplete_items) + } + + let current = + match draft { + some(draft) => draft + none => unreachable + } + + set draft = some(current with { + method: some(chosen), + }) + set submission = clear_retryable(submission) + finish accepted + } + + on go_to_step(value) { + let target = + match value { + unknown(_) => finish invalid(unknown_step) + known(step) => step + } + + let target_location = + flow(order_100, some(step_text(target))) + + match user_navigation( + location, + navigation, + route_scope, + target_location, + ) { + repeated => + finish duplicate + pending_other => + finish blocked(navigation_pending) + unavailable_while_normalizing => + finish blocked(normalizing) + ready => {} + } + + let current = + match active_step(location, route_scope) { + none => finish blocked(wrong_location) + some(step) => step + } + + if not step_admissible(target, order, draft) { + finish blocked( + match target { + method => incomplete_items + review => incomplete_method + items => unreachable + } + ) + } + + if source_conflict( + order, + draft, + submission, + ) is some(_) and step_rank(target) > step_rank(current) + { + finish blocked(source_changed) + } + + let intent: NavigationIntent = user_push(target_location) + set navigation = some(intent) + emit router.push(target_location) + finish accepted + } + + on open_policy(line) { + match line_of(order, line) { + none => finish invalid(unknown_line) + some(_) => {} + } + + if surface is some(open) and open.line == line { + finish duplicate + } + + let owner = + match ( + active_step(location, route_scope), + route_scope, + ) { + (some(items), some(scope)) => scope + _ => { + if is_flow(location) and route_scope == none { + finish blocked(normalizing) + } + finish blocked(wrong_location) + } + } + + let serial: PositiveInt = next_surface + 1 + set next_surface = serial + set surface = some({ + id: SurfaceId(serial), + owner: owner, + line: line, + }) + finish accepted + } + + on dismiss_policy(id) { + match surface { + some(open) => { + if open.id != id + or route_scope != some(open.owner) + { + finish stale + } + } + none => finish stale + } + + set surface = none + finish accepted + } + + on restart_from_current_order { + if submission is pending(_) { + finish blocked(pending_submission) + } + + if active_step(location, route_scope) != some(items) { + if is_flow(location) and route_scope == none { + finish blocked(normalizing) + } + finish blocked(wrong_location) + } + + let current = + match order { + none => finish blocked(order_waiting) + some(order) => order + } + + match refusal_fence(submission) { + some(required) => { + if current.revision < required { + finish blocked(revision_fence) + } + } + none => {} + } + + if draft is some(current_draft) + and current_draft.base_revision == current.revision + and is_blank(current_draft) + and refusal_fence(submission) == none + and submission == idle + { + finish duplicate + } + + if submission is completed(_, _) { + finish blocked(completed_return) + } + + set draft = some(blank_draft(current)) + set submission = idle + finish accepted + } + + on submit { + match submission { + pending(_) => + finish duplicate + completed(_, _) => + finish blocked(completed_return) + _ => {} + } + + if active_step(location, route_scope) != some(review) { + if is_flow(location) and route_scope == none { + finish blocked(normalizing) + } + finish blocked(wrong_location) + } + + if source_conflict( + order, + draft, + submission, + ) is some(_) + { + finish blocked(source_changed) + } + + if not method_complete(order, draft) { + finish blocked(incomplete_method) + } + + match submission { + idle | unavailable_for(_) => {} + refused(_, _) => + finish blocked(submission_phase) + pending(_) | completed(_, _) => + unreachable + } + + let current = + match draft { + some(draft) => draft + none => unreachable + } + + let payload = + match return_payload(current) { + some(payload) => payload + none => unreachable + } + + let serial: PositiveInt = next_request + 1 + let request: PendingRequest = { + id: RequestId(serial), + payload: payload, + } + + set next_request = serial + set submission = pending(request) + emit returns.request(request.id, request.payload) + finish accepted + } + + on returns.settled(request_id, result) { + // Malformed delivery wins over correlation, including for stale IDs. + match result { + accepted(raw_return_id) => { + if raw_return_id.is_empty { + finish invalid(invalid_return_id) + } + } + + refused_order_changed(revision) => { + if revision <= 0 { + finish invalid(invalid_refusal_revision) + } + } + + unavailable => {} + } + + let pending_request = + match submission { + pending(request) => request + _ => finish stale + } + + if pending_request.id != request_id { + finish stale + } + + match result { + accepted(raw_return_id) => { + let return_id = ReturnId(raw_return_id) + set settled = settled.add(request_id) + set draft = none + set receipt_access = + match location { + some(flow(_, _)) => redirecting + some(receipt(current)) => + if current == return_id + then acknowledged + else offered + _ => offered + } + set submission = + completed(request_id, return_id) + finish accepted + } + + refused_order_changed(revision) => { + if revision <= pending_request.payload.expected_revision { + finish invalid(invalid_refusal_revision) + } + + set settled = settled.add(request_id) + set submission = refused( + pending_request, + revision, + ) + finish accepted + } + + unavailable => { + set settled = settled.add(request_id) + set submission = unavailable_for(pending_request) + finish accepted + } + } + } + + on follow_order_link { + match location { + some(flow(order_id, _)) => { + if order_id != order_100 { + finish blocked(wrong_location) + } + } + + some(receipt(return_id)) => { + if completed_return(submission) != some(return_id) { + finish blocked(wrong_location) + } + } + + _ => finish blocked(wrong_location) + } + + let target = order(order_100) + + match user_navigation( + location, + navigation, + route_scope, + target, + ) { + repeated => + finish duplicate + pending_other => + finish blocked(navigation_pending) + unavailable_while_normalizing => + finish blocked(normalizing) + ready => {} + } + + set navigation = some(user_push(target)) + emit router.push(target) + finish accepted + } + + on follow_receipt_link { + let return_id = + match notice { + none => finish blocked(no_completion_notice) + some(return_id) => return_id + } + + if location != some(order(order_100)) { + finish blocked(wrong_location) + } + + let target = receipt(return_id) + + match user_navigation( + location, + navigation, + route_scope, + target, + ) { + repeated => + finish duplicate + pending_other => + finish blocked(navigation_pending) + unavailable_while_normalizing => + finish blocked(normalizing) + ready => {} + } + + set navigation = some(user_push(target)) + emit router.push(target) + finish accepted + } + + before commit { + if surface is some(open) + and ( + route_scope != some(open.owner) + or line_of(order, open.line) == none + ) + { + set surface = none + } + + match required_target( + location, + order, + draft, + submission, + ) { + some(target) => { + set route_scope = none + set surface = none + + let required_intent: NavigationIntent = + required_replace(target) + + if navigation != some(required_intent) { + set navigation = some(required_intent) + emit router.replace(target) + } + } + + none => { + if not is_flow(location) { + set route_scope = none + set surface = none + } + } + } + } +} diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/provider.mjs b/examples/applications/a0-return-desk/answers/uhura-0.3/provider.mjs new file mode 100644 index 0000000..6ca56c8 --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/provider.mjs @@ -0,0 +1,163 @@ +const MODULE = "app.return_desk.machine@1"; +const MACHINE = `${MODULE}::ReturnDesk`; + +const TYPES = Object.freeze({ + lineId: `${MODULE}::LineId`, + orderId: `${MODULE}::OrderId`, + ordersReceive: `${MACHINE}::port.orders.Receive`, + returnsReceive: `${MACHINE}::port.returns.Receive`, + returnsSend: `${MACHINE}::port.returns.Send`, + settlement: `${MODULE}::Settlement`, +}); + +const text = (value) => ({ $: "Text", value }); +const integer = (value) => ({ $: "Int", value: String(value) }); +const key = (type, value) => ({ $: "key", type, value }); +const field = (name, value) => ({ name, value }); +const record = (fields) => ({ $: "record", fields }); +const variant = (type, caseName, fields = []) => ({ + $: "variant", + type, + case: caseName, + fields, +}); + +const orderLine = ( + id, + title, + purchasedQuantity, + returnableQuantity, + policySummary, +) => + record([ + field("id", key(TYPES.lineId, text(id))), + field("title", text(title)), + field("purchased_quantity", integer(purchasedQuantity)), + field("returnable_quantity", integer(returnableQuantity)), + field("policy_summary", text(policySummary)), + ]); + +const initialOrder = () => + variant(TYPES.ordersReceive, "orders.observed", [ + field( + "value", + record([ + field("id", key(TYPES.orderId, text("order-100"))), + field("revision", integer(7)), + field("lines", { + $: "seq", + items: [ + orderLine( + "lamp", + "Desk lamp", + 2, + 2, + "Return the lamp in protective packaging.", + ), + orderLine( + "mug", + "Stoneware mug", + 1, + 1, + "Wrap the mug to prevent breakage in transit.", + ), + ], + }), + field("allowed_methods", { + $: "seq", + items: [text("drop-off"), text("pickup")], + }), + ]), + ), + ]); + +const exactTextConfig = (config, name, fallback) => { + const value = config[name] ?? fallback; + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`Return Desk provider config.${name} must be nonempty text`); + } + return value; +}; + +const requiredVariant = (value, type, caseName, context) => { + if ( + value?.$ !== "variant" + || value.type !== type + || value.case !== caseName + || !Array.isArray(value.fields) + ) { + throw new TypeError(`${context} has an unexpected Uhura value`); + } + return value; +}; + +const requiredField = (value, name, context) => { + const entry = value.fields.find((candidate) => candidate?.name === name); + if (!entry || typeof entry !== "object" || !("value" in entry)) { + throw new TypeError(`${context} is missing field \`${name}\``); + } + return entry.value; +}; + +const createOrdersAdapter = (host) => { + const identity = host.port("orders"); + return { + ...identity, + start(context) { + context.deliver(initialOrder()); + }, + accept() { + throw new TypeError("Return Desk orders is an observation-only port"); + }, + }; +}; + +const createReturnsAdapter = (host, returnId) => { + const identity = host.port("returns"); + return { + ...identity, + accept(command, context) { + const request = requiredVariant( + command, + TYPES.returnsSend, + "returns.request", + "Return Desk return command", + ); + const requestId = requiredField( + request, + "id", + "Return Desk return command", + ); + requiredField(request, "payload", "Return Desk return command"); + context.deliver( + variant(TYPES.returnsReceive, "returns.settled", [ + field("id", requestId), + field( + "result", + variant(TYPES.settlement, "accepted", [ + field("return_id", text(returnId)), + ]), + ), + ]), + ); + }, + }; +}; + +/** + * A0's application-owned adapter assembly. + * + * `host.port()` supplies the exact checked identities. `context.deliver()` + * crosses the adapter host's deferred FIFO queue, so neither startup + * observations nor settlements can synchronously reenter a machine reaction. + */ +export function createUhuraAdapters(config, host) { + const returnId = exactTextConfig(config, "return_id", "return-900"); + + return { + adapters: [ + createOrdersAdapter(host), + createReturnsAdapter(host, returnId), + ], + }; +} diff --git a/examples/applications/a0-return-desk/answers/uhura-0.3/web.uhura b/examples/applications/a0-return-desk/answers/uhura-0.3/web.uhura new file mode 100644 index 0000000..8119e8a --- /dev/null +++ b/examples/applications/a0-return-desk/answers/uhura-0.3/web.uhura @@ -0,0 +1,387 @@ +language uhura 0.3 +module app.return_desk.web@1 + +use ui + +import { + LineId, + Location, + Order, + Reason, + ReturnDraft, + ReturnDesk, + ReturnMethod, + Step, + order_100, + return_routes, +} from "app.return_desk.machine@1" +import { Token } from "uhura.boundary@1" +import { Link } from "uhura.web_router@1" +import { Surface } from "uhura.ui_surface@1" + + +fn selected_quantity( + draft: Option, + line: LineId, +) -> Int = + match draft { + none => 0 + some(draft) => + match draft.selections.get(line) { + none => 0 + some(selection) => selection.quantity + } + } + +fn selected_reason( + draft: Option, + line: LineId, +) -> Option = + match draft { + none => none + some(draft) => + match draft.selections.get(line) { + none => none + some(selection) => selection.reason + } + } + +fn has_current_line( + order: Option, + line: LineId, +) -> Bool = + match order { + none => false + some(order) => order.lines.get(line) is some(_) + } + +fn reason_label(value: Reason) -> Text = + match value { + damaged => "Damaged" + not_needed => "Not needed" + } + +fn method_label(value: ReturnMethod) -> Text = + match value { + drop_off => "Drop off" + pickup => "Pickup" + } + +fn selected_reason_label( + value: Option, +) -> Text = + match value { + none => "not selected" + some(reason) => reason_label(reason) + } + +fn selected_method_label( + value: Option, +) -> Text = + match value { + none => "not selected" + some(method) => method_label(method) + } + +fn location_label(value: Option) -> Text = + match value { + none => "no delivered location" + some(flow(_, none)) => "return flow, missing step" + some(flow(_, some(step))) => step + some(order(_)) => "order" + some(receipt(_)) => "receipt" + } + + +ui ReturnDeskWeb for ReturnDesk(view) { +
+ {#match view.page} + {#case no_location} +

No return location is active.

+ + {#case normalizing_to(target)} +

+ Delivered location: {location_label(view.location)}. + Required target: {location_label(some(target))}. +

+ + {#case items_page} +

Choose items

+ + {#match view.order} + {#case none} +

Waiting for order information…

+ + {#case some(order)} + {#each order.lines.entries_by_key as (id, line) (id)} +
+

{line.title}

+ + + +
+ Reason + + + + +
+ + +
+ {/each} + {/match} + + {#if view.draft is some(draft)} + {#each + draft.selections.entries_by_key + as (line, selection) + (line) + } + {#if not has_current_line(view.order, line)} +

+ Removed item {line.value}: retained draft quantity + {selection.quantity}, + reason {selected_reason_label(selection.reason)}. +

+ {/if} + {/each} + {/if} + + {#if view.conflict is some(conflict)} +

+ The order source changed from revision + {conflict.draft_revision} + to {conflict.observed_revision}. +

+ {/if} + + {#if view.actions.restart} + + {/if} + + + + + + {#case method_page} +

Choose a return method

+ + + + + + + + + + {#case review_page} +

Review return

+ + {#if view.draft is some(draft)} +
+
Order
+
{draft.order.value}
+ +
Revision
+
{draft.base_revision}
+ +
Method
+
{selected_method_label(draft.method)}
+
+ + {#each + draft.selections.entries_by_key + as (line, selection) + (line) + } +

+ {line.value}: {selection.quantity}, + {selected_reason_label(selection.reason)} +

+ {/each} + {/if} + + + + + + + + {#case order_page} +

Order order-100

+ + {#if view.completion_notice is some(return_id)} +

Your return completed.

+ + follow_receipt_link + > + View return receipt + + {/if} + + {#case receipt_page(return_id)} +

Return completed

+

Receipt {return_id.value}

+ {/match} + + {#if + view.page is items_page + or view.page is method_page + or view.page is review_page + or view.page is receipt_page(_) + } + follow_order_link + > + View order + + {/if} + + {#match view.submission} + {#case idle} +

Ready to submit.

+ {#case pending(request)} +

Submitting request {request.id.value}…

+ {#case refused(_, required)} +

+ The service requires order revision {required}. +

+ {#case unavailable_for(_)} +

Submission is unavailable. You may retry.

+ {#case completed(_, return_id)} +

Return {return_id.value} completed.

+ {/match} + + {#if view.policy is some(policy)} + +

{policy.title}

+

Item {policy.line.value}

+

{policy.summary}

+ + +
+ {/if} +
+} diff --git a/examples/applications/a0-return-desk/reference-oracle/README.md b/examples/applications/a0-return-desk/reference-oracle/README.md new file mode 100644 index 0000000..dc79392 --- /dev/null +++ b/examples/applications/a0-return-desk/reference-oracle/README.md @@ -0,0 +1,62 @@ +# A0 Return Desk reference oracle + +- **Status:** Executable, language-independent application evidence over a + bounded transport domain +- **Problem authority:** [A0 Return Desk](..) +- **Uhura answers:** [Uhura 0.3](../answers/uhura-0.3/) and + [Uhura 0.4](../answers/uhura-0.4/) +- **Not:** An Uhura parser, checker, interpreter, backend, or conformance result + +This oracle makes one exact interpretation of A0 executable before a language +implementation exists. Its base-domain traces provide a differential target +for future candidates without making JavaScript the authoring language or +semantic authority. The same model also contains the C1 +`Reason.other(note)` extension; that constructor is outside the base Uhura +answer and must be excluded when comparing that answer. + +The JavaScript boundary accepts external integers only through the safe-integer +transport subset. It is therefore not a differential oracle for Uhura values +outside that subset; arbitrary-precision integer cases remain a separate Uhura +conformance gate. Internal identity counters promote to `BigInt` before +precision loss and receive tagged canonical checkpoint encoding. + +Run either: + +```sh +bun examples/applications/a0-return-desk/reference-oracle/validate.mjs +node examples/applications/a0-return-desk/reference-oracle/validate.mjs +``` + +Both currently report: + +```text +PASS 25 validation groups: canonical 0..31, 15 adversarial scenarios, 12 static pins, checkpoint replay, and C1. +``` + +Coverage includes: + +- every canonical row's result and ordered consequences; +- all 14 A0 scenarios plus superseded-receipt navigation recovery; +- 12 required static pins; +- atomic rollback for non-accepted results; +- request, route-scope, and surface identity behavior; +- closed boundary records and malformed-delivery precedence; +- safe external integer admission and exact internal counters; +- receipt encoding and redirecting/offered/acknowledged lifecycle; +- checkpoint, restore, rerun, and semantic tamper rejection; and +- the controlled `Reason.other(note)` extension as a separate, post-base study. + +The C1 pass is edit-locality evidence for the application model only. It is +not Uhura controlled-change evidence: the checked-in Uhura answer intentionally +remains the base two-reason program, and the current machine implementation +must first pass that base before a separate C1 source change is authored and +measured. + +The model deliberately does not: + +- parse or execute Uhura source; +- prove compiler-enforced totality, termination, ownership, or port authority; +- validate checked `ui` syntax or browser mechanics; +- implement module resolution or missing-binding diagnostics; +- restore physical host effects; or +- replace an independent implementation or formal proof. diff --git a/examples/applications/a0-return-desk/reference-oracle/model.mjs b/examples/applications/a0-return-desk/reference-oracle/model.mjs new file mode 100644 index 0000000..0dd6c12 --- /dev/null +++ b/examples/applications/a0-return-desk/reference-oracle/model.mjs @@ -0,0 +1,1879 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +const ORACLE_PROGRAM_HASH = "a0-return-desk-reference-oracle-v2"; + +const ORDER_ID = "order-100"; +const FLOW_PREFIX = `/orders/${ORDER_ID}/return`; +const ORDER_URL = `/orders/${ORDER_ID}`; +const STEPS = new Set(["items", "method", "review"]); +const METHODS = new Set(["drop-off", "pickup"]); +const BASE_REASONS = new Set(["damaged", "not-needed"]); + +export const order7 = Object.freeze({ + id: ORDER_ID, + revision: 7, + lines: Object.freeze([ + Object.freeze({ + id: "lamp", + title: "Desk lamp", + purchasedQuantity: 2, + returnableQuantity: 2, + policySummary: "Return the lamp in protective packaging.", + }), + Object.freeze({ + id: "mug", + title: "Stoneware mug", + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: "Wrap the mug to prevent breakage in transit.", + }), + ]), + allowedMethods: Object.freeze(["drop-off", "pickup"]), +}); + +export const order8 = Object.freeze({ + id: ORDER_ID, + revision: 8, + lines: Object.freeze([ + Object.freeze({ + id: "lamp", + title: "Desk lamp", + purchasedQuantity: 2, + returnableQuantity: 0, + policySummary: "Return the lamp in protective packaging.", + }), + Object.freeze({ + id: "mug", + title: "Stoneware mug", + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: "Wrap the mug to prevent breakage in transit.", + }), + ]), + allowedMethods: Object.freeze(["drop-off", "pickup"]), +}); + +export const order9WithoutLamp = Object.freeze({ + id: ORDER_ID, + revision: 9, + lines: Object.freeze([ + Object.freeze({ + id: "mug", + title: "Stoneware mug", + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: "Wrap the mug to prevent breakage in transit.", + }), + ]), + allowedMethods: Object.freeze(["drop-off", "pickup"]), +}); + +function encodeOpaquePathSegment(value) { + const encoded = encodeURIComponent(value); + if ( + encoded === "" || + encoded === "." || + encoded === ".." || + encoded.startsWith("~") + ) { + return `~${Buffer.from(value, "utf8").toString("base64url")}`; + } + return encoded; +} + +function decodeOpaquePathSegment(segment) { + try { + const value = segment.startsWith("~") + ? Buffer.from(segment.slice(1), "base64url").toString("utf8") + : decodeURIComponent(segment); + return encodeOpaquePathSegment(value) === segment ? value : null; + } catch { + return null; + } +} + +export const urls = Object.freeze({ + items: `${FLOW_PREFIX}?step=items`, + method: `${FLOW_PREFIX}?step=method`, + review: `${FLOW_PREFIX}?step=review`, + missingStep: FLOW_PREFIX, + unknownStep: `${FLOW_PREFIX}?step=unknown`, + order: ORDER_URL, + receipt: (returnId) => `/returns/${encodeOpaquePathSegment(returnId)}`, +}); + +export function other(note) { + return { kind: "other", note }; +} + +function clone(value) { + return structuredClone(value); +} + +function ownEnumerableDataKeys(value) { + try { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + ![Object.prototype, null].includes(Object.getPrototypeOf(value)) + ) { + return null; + } + + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string")) return null; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !Object.hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + return null; + } + } + return keys; + } catch { + return null; + } +} + +function hasExactOwnKeys(value, keys) { + const actual = ownEnumerableDataKeys(value); + if (actual === null) return false; + const sortedActual = [...actual].sort(); + const expected = [...keys].sort(); + return ( + sortedActual.length === expected.length && + sortedActual.every((key, index) => key === expected[index]) + ); +} + +function isDataRecord(value) { + return ownEnumerableDataKeys(value) !== null; +} + +function isDenseDataArray(value) { + try { + if (!Array.isArray(value)) return false; + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string")) return false; + if (keys.length !== value.length + 1 || !keys.includes("length")) { + return false; + } + const indexKeys = keys.filter((key) => key !== "length"); + for (const key of indexKeys) { + if (!/^(0|[1-9]\d*)$/.test(key)) return false; + const index = Number(key); + if ( + !Number.isSafeInteger(index) || + index < 0 || + index >= value.length || + String(index) !== key + ) { + return false; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !Object.hasOwn(descriptor, "value") || + descriptor.enumerable !== true + ) { + return false; + } + } + const length = Object.getOwnPropertyDescriptor(value, "length"); + return ( + length !== undefined && + Object.hasOwn(length, "value") && + length.enumerable === false + ); + } catch { + return false; + } +} + +function isClosedDataTree(value, seen = new Set()) { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return true; + } + if (typeof value === "number") { + return Number.isFinite(value) && + (!Number.isInteger(value) || Number.isSafeInteger(value)); + } + if (typeof value === "bigint") return true; + if (typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + + if (Array.isArray(value)) { + if (!isDenseDataArray(value)) return false; + return value.every((item) => isClosedDataTree(item, seen)); + } + + const keys = ownEnumerableDataKeys(value); + if (keys === null) return false; + return keys.every((key) => isClosedDataTree(value[key], seen)); +} + +function isSafePositiveInteger(value) { + return Number.isSafeInteger(value) && value > 0; +} + +function isSafeNonNegativeInteger(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +function isPositiveCounter(value) { + return ( + isSafePositiveInteger(value) || + (typeof value === "bigint" && value > 0n) + ); +} + +function counterBigInt(value) { + assert.equal(isPositiveCounter(value), true); + return typeof value === "bigint" ? value : BigInt(value); +} + +function incrementCounter(value) { + const next = counterBigInt(value) + 1n; + return next <= BigInt(Number.MAX_SAFE_INTEGER) + ? Number(next) + : next; +} + +function sameCounter(left, right) { + return ( + isPositiveCounter(left) && + isPositiveCounter(right) && + counterBigInt(left) === counterBigInt(right) + ); +} + +function isUnicodeScalarText(value) { + if (typeof value !== "string") return false; + + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +function compareText(left, right) { + return Buffer.compare( + Buffer.from(left, "utf8"), + Buffer.from(right, "utf8"), + ); +} + +function canonical(value) { + if (value === null) return ["null"]; + if (typeof value === "boolean") return ["boolean", value]; + if (typeof value === "string") return ["string", value]; + if (typeof value === "number") return ["number", value]; + if (typeof value === "bigint") return ["bigint", value.toString()]; + if (Array.isArray(value)) { + return ["array", value.map(canonical)]; + } + if (value && typeof value === "object") { + return [ + "record", + Object.keys(value) + .sort(compareText) + .map((key) => [key, canonical(value[key])]), + ]; + } + throw new TypeError("unsupported canonical value"); +} + +function same(a, b) { + return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b)); +} + +function digest(value) { + return createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"); +} + +function result(kind, reason = null) { + return reason === null ? kind : `${kind}(${reason})`; +} + +function accepted() { + return "accepted"; +} + +function blocked(reason) { + return result("blocked", reason); +} + +function invalid(reason) { + return result("invalid", reason); +} + +function routeFor(url) { + if (!isUnicodeScalarText(url)) return null; + if (url === ORDER_URL) return { kind: "order" }; + + const receipt = /^\/returns\/([^/?#]+)$/.exec(url); + if (receipt) { + const returnId = decodeOpaquePathSegment(receipt[1]); + if (!isUnicodeScalarText(returnId) || returnId.length === 0) return null; + return { kind: "receipt", returnId }; + } + + const flow = /^\/orders\/order-100\/return(?:\?([^#]*))?$/.exec(url); + if (!flow) return null; + + const params = new URLSearchParams(flow[1] ?? ""); + const step = params.has("step") ? params.get("step") : null; + return { + kind: "flow", + step: STEPS.has(step) ? step : null, + rawStep: step, + }; +} + +function canonicalOrder(order) { + return { + id: order.id, + revision: order.revision, + lines: [...order.lines] + .map((line) => ({ + id: line.id, + title: line.title, + purchasedQuantity: line.purchasedQuantity, + returnableQuantity: line.returnableQuantity, + policySummary: line.policySummary, + })) + .sort((a, b) => compareText(a.id, b.id)), + allowedMethods: [...order.allowedMethods].sort(compareText), + }; +} + +function validOrder(order) { + try { + if ( + !hasExactOwnKeys(order, [ + "id", + "revision", + "lines", + "allowedMethods", + ]) + ) { + return false; + } + if (order.id !== ORDER_ID) return false; + if (!isSafePositiveInteger(order.revision)) return false; + if ( + !isDenseDataArray(order.lines) || + !isDenseDataArray(order.allowedMethods) + ) { + return false; + } + + const lineIds = new Set(); + for (const line of order.lines) { + if ( + !hasExactOwnKeys(line, [ + "id", + "title", + "purchasedQuantity", + "returnableQuantity", + "policySummary", + ]) + ) { + return false; + } + if (!isUnicodeScalarText(line.id)) return false; + if (lineIds.has(line.id)) return false; + lineIds.add(line.id); + if (!isUnicodeScalarText(line.title)) return false; + if (!isUnicodeScalarText(line.policySummary)) return false; + if (!isSafeNonNegativeInteger(line.purchasedQuantity)) return false; + if (!isSafeNonNegativeInteger(line.returnableQuantity)) return false; + if (line.returnableQuantity > line.purchasedQuantity) return false; + } + + const methods = new Set(); + for (const method of order.allowedMethods) { + if (!METHODS.has(method) || methods.has(method)) return false; + methods.add(method); + } + return true; + } catch { + return false; + } +} + +function reasonValid(reason) { + if (BASE_REASONS.has(reason)) return true; + return ( + hasExactOwnKeys(reason, ["kind", "note"]) && + reason.kind === "other" && + isUnicodeScalarText(reason.note) + ); +} + +function reasonComplete(reason) { + if (BASE_REASONS.has(reason)) return true; + return reasonValid(reason) && reason.kind === "other" && reason.note.length > 0; +} + +function lineOf(order, lineId) { + return order?.lines.find((line) => line.id === lineId) ?? null; +} + +function blankDraft(revision) { + return { + orderId: ORDER_ID, + baseRevision: revision, + selections: {}, + method: null, + }; +} + +function isBlankDraft(draft) { + return ( + draft !== null && + Object.keys(draft.selections).length === 0 && + draft.method === null + ); +} + +function selectionOf(draft, lineId) { + if ( + draft === null || + !Object.hasOwn(draft.selections, lineId) + ) { + return null; + } + return draft.selections[lineId]; +} + +function putSelection(draft, lineId, selection) { + Object.defineProperty(draft.selections, lineId, { + value: selection, + enumerable: true, + configurable: true, + writable: true, + }); +} + +function putOwn(record, key, value) { + Object.defineProperty(record, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +} + +function snapshotFromDraft(draft, requestId) { + return { + requestId, + orderId: draft.orderId, + expectedRevision: draft.baseRevision, + selections: Object.entries(draft.selections) + .map(([lineId, selection]) => ({ + lineId, + quantity: selection.quantity, + reason: clone(selection.reason), + })) + .sort((a, b) => compareText(a.lineId, b.lineId)), + method: draft.method, + }; +} + +function requestSnapshotValid(snapshot) { + if ( + !hasExactOwnKeys(snapshot, [ + "requestId", + "orderId", + "expectedRevision", + "selections", + "method", + ]) + ) { + return false; + } + const number = requestNumber(snapshot.requestId); + if (number === null || snapshot.requestId !== `request-${number}`) { + return false; + } + if (snapshot.orderId !== ORDER_ID) return false; + if ( + !Number.isSafeInteger(snapshot.expectedRevision) || + snapshot.expectedRevision <= 0 || + !isDenseDataArray(snapshot.selections) || + snapshot.selections.length === 0 || + !METHODS.has(snapshot.method) + ) { + return false; + } + + const ids = []; + for (const selection of snapshot.selections) { + if ( + !hasExactOwnKeys(selection, [ + "lineId", + "quantity", + "reason", + ]) || + !isUnicodeScalarText(selection.lineId) || + !Number.isSafeInteger(selection.quantity) || + selection.quantity <= 0 || + !reasonComplete(selection.reason) + ) { + return false; + } + ids.push(selection.lineId); + } + if (new Set(ids).size !== ids.length) return false; + return same(ids, [...ids].sort(compareText)); +} + +function requestNumber(id) { + if (typeof id !== "string") return null; + const match = /^request-(0|[1-9]\d*)$/.exec(id); + if (match === null) return null; + const value = BigInt(match[1]); + return value > 0n ? value : null; +} + +function surfaceNumber(id) { + if (typeof id !== "string") return null; + const match = /^surface-(0|[1-9]\d*)$/.exec(id); + if (match === null) return null; + const value = BigInt(match[1]); + return value > 0n ? value : null; +} + +function routeScopeNumber(id) { + if (typeof id !== "string") return null; + const match = /^scope-(0|[1-9]\d*)$/.exec(id); + if (match === null) return null; + const value = BigInt(match[1]); + return value > 0n ? value : null; +} + +export class ReturnDeskOracle { + constructor(state = null) { + this.state = + state === null + ? { + location: null, + order: null, + draft: null, + phase: { kind: "idle" }, + revisionFence: null, + completedReturn: null, + receiptAccess: null, + routeScope: null, + nextRouteScope: 1, + surface: null, + nextSurface: 1, + navigationIntent: null, + nextRequest: 1, + settledRequests: [], + } + : clone(state); + this._assertInvariants(); + } + + snapshot() { + return clone(this.state); + } + + checkpoint(provenance) { + assert.equal( + isClosedDataTree(provenance), + true, + "checkpoint provenance is closed serializable data", + ); + const state = this.snapshot(); + return { + format: 1, + programHash: ORACLE_PROGRAM_HASH, + stateHash: digest(state), + state, + provenance: clone(provenance), + }; + } + + static restore(checkpoint) { + assert.equal( + hasExactOwnKeys(checkpoint, [ + "format", + "programHash", + "stateHash", + "state", + "provenance", + ]), + true, + "checkpoint envelope", + ); + assert.equal( + isClosedDataTree(checkpoint), + true, + "checkpoint is closed serializable data", + ); + assert.equal(checkpoint?.format, 1, "checkpoint format"); + assert.equal( + checkpoint?.programHash, + ORACLE_PROGRAM_HASH, + "checkpoint program", + ); + assert.equal( + checkpoint?.stateHash, + digest(checkpoint?.state), + "checkpoint state hash", + ); + return new ReturnDeskOracle(checkpoint.state); + } + + presentation() { + const normalization = this._normalizationTarget(); + const route = this.state.location?.route ?? null; + const offeredReturn = + this.state.receiptAccess === "offered" + ? this.state.completedReturn + : null; + const activeStep = + route?.kind === "flow" && normalization === null ? route.step : null; + const conflict = this._conflict(); + const surfaceLine = + this.state.surface === null + ? null + : lineOf(this.state.order, this.state.surface.lineId); + + return { + deliveredLocation: this.state.location?.url ?? null, + activeStep, + normalizing: normalization !== null, + orderStatus: + this.state.order === null + ? { kind: "waiting" } + : { kind: "available", order: clone(this.state.order) }, + draft: clone(this.state.draft), + conflict, + itemsComplete: this._itemsComplete(), + methodComplete: this._methodComplete(), + submission: clone(this.state.phase), + policySurface: + this.state.surface === null || surfaceLine === null + ? null + : { + lineId: surfaceLine.id, + title: surfaceLine.title, + policySummary: surfaceLine.policySummary, + }, + completionNotice: + offeredReturn === null + ? null + : { + returnId: offeredReturn, + receipt: urls.receipt(offeredReturn), + }, + receipt: + route?.kind === "receipt" && + route.returnId === this.state.completedReturn + ? { returnId: route.returnId, status: "completed" } + : null, + actions: this._actionAvailability(), + }; + } + + inspect() { + return { + ...this.snapshot(), + presentation: this.presentation(), + }; + } + + deliverLocation(url) { + return this._atomic("location", (ctx) => { + const route = routeFor(url); + if (route === null) return invalid("location"); + if ( + route.kind === "receipt" && + route.returnId !== this.state.completedReturn + ) { + return invalid("location"); + } + if (this.state.location?.url === url) return "duplicate"; + + this.state.location = { url, route }; + this.state.navigationIntent = null; + this.state.routeScope = null; + this.state.surface = null; + ctx.locationChanged = true; + + if ( + route.kind === "flow" && + this.state.order !== null && + this.state.draft === null && + this.state.completedReturn === null + ) { + this.state.draft = blankDraft(this.state.order.revision); + } + + if ( + route.kind === "receipt" && + route.returnId === this.state.completedReturn + ) { + this.state.receiptAccess = "acknowledged"; + } else if ( + this.state.completedReturn !== null && + route.kind === "flow" && + this.state.receiptAccess !== "acknowledged" + ) { + this.state.receiptAccess = "redirecting"; + } else if ( + this.state.completedReturn !== null && + this.state.receiptAccess === "redirecting" && + route.kind !== "flow" + ) { + this.state.receiptAccess = "offered"; + } + + return accepted(); + }); + } + + deliverOrder(order) { + return this._atomic("order", () => { + if (!validOrder(order)) return invalid("order"); + const normalized = canonicalOrder(order); + + if (this.state.order !== null) { + if (normalized.revision < this.state.order.revision) return "stale"; + if (normalized.revision === this.state.order.revision) { + return same(normalized, this.state.order) + ? "duplicate" + : invalid("equal-revision-order"); + } + } + + this.state.order = normalized; + + if ( + this.state.surface !== null && + lineOf(normalized, this.state.surface.lineId) === null + ) { + this.state.surface = null; + } + + if ( + this.state.location?.route.kind === "flow" && + this.state.draft === null && + this.state.completedReturn === null + ) { + this.state.draft = blankDraft(normalized.revision); + } + + return accepted(); + }); + } + + chooseQuantity(lineId, quantity) { + return this._atomic("choose-quantity", () => { + if (!Number.isSafeInteger(quantity)) return invalid("quantity"); + + const orderLine = lineOf(this.state.order, lineId); + if (orderLine === null) { + return invalid("unknown-line"); + } + if (quantity < 0 || quantity > orderLine.returnableQuantity) { + return invalid("quantity"); + } + + const current = selectionOf(this.state.draft, lineId); + if ( + (quantity === 0 && current === null) || + (quantity > 0 && current?.quantity === quantity) + ) { + return "duplicate"; + } + + const editBlock = this._editBlock("items"); + if (editBlock !== null) return blocked(editBlock); + + if (quantity === 0) { + delete this.state.draft.selections[lineId]; + } else { + putSelection(this.state.draft, lineId, { + quantity, + reason: current?.reason ?? null, + }); + } + this._clearRetryablePhaseAfterEdit(); + return accepted(); + }); + } + + chooseReason(lineId, reason) { + return this._atomic("choose-reason", () => { + if (!reasonValid(reason)) return invalid("reason"); + + const orderLine = lineOf(this.state.order, lineId); + if (orderLine === null) { + return invalid("unknown-line"); + } + + const selection = selectionOf(this.state.draft, lineId); + if (selection === null) return invalid("unselected-line"); + if (same(selection.reason, reason)) return "duplicate"; + + const editBlock = this._editBlock("items"); + if (editBlock !== null) return blocked(editBlock); + + selection.reason = clone(reason); + this._clearRetryablePhaseAfterEdit(); + return accepted(); + }); + } + + chooseMethod(method) { + return this._atomic("choose-method", () => { + if (!METHODS.has(method)) return invalid("method"); + if ( + this.state.order !== null && + !this.state.order.allowedMethods.includes(method) + ) { + return invalid("disallowed-method"); + } + if (this.state.draft?.method === method) return "duplicate"; + + const editBlock = this._editBlock("method"); + if (editBlock !== null) return blocked(editBlock); + if (!this._itemsComplete()) return blocked("items-incomplete"); + + this.state.draft.method = method; + this._clearRetryablePhaseAfterEdit(); + return accepted(); + }); + } + + goToStep(step) { + return this._atomic("go-to-step", (ctx) => { + if (!STEPS.has(step)) return invalid("step"); + const target = urls[step]; + const navigationCheck = this._checkUserNavigation(target); + if (navigationCheck !== null) return navigationCheck; + + const current = this._activeStep(); + if (current === null) return blocked("wrong-location"); + if (!this._stepAdmissible(step)) return blocked("incomplete-prerequisite"); + if (this._conflict() !== null && this._rank(step) > this._rank(current)) { + return blocked("source-changed"); + } + this._emitNavigation(ctx, "push", target); + return accepted(); + }); + } + + openPolicy(lineId) { + return this._atomic("open-policy", () => { + const line = lineOf(this.state.order, lineId); + if (line === null) return invalid("unknown-line"); + if ( + this.state.surface !== null && + this.state.surface.lineId === lineId + ) { + return "duplicate"; + } + if (this._activeStep() !== "items" || this.state.routeScope === null) { + return blocked( + this._normalizationTarget() === null + ? "wrong-location" + : "normalizing", + ); + } + const id = `surface-${this.state.nextSurface}`; + this.state.nextSurface = incrementCounter(this.state.nextSurface); + this.state.surface = { + id, + lineId, + ownerScope: this.state.routeScope, + }; + return accepted(); + }); + } + + dismissPolicy(surfaceId) { + return this._atomic("dismiss-policy", () => { + if ( + this.state.surface === null || + this.state.surface.id !== surfaceId || + this.state.surface.ownerScope !== this.state.routeScope + ) { + return "stale"; + } + this.state.surface = null; + return accepted(); + }); + } + + restartFromCurrentOrder() { + return this._atomic("restart", () => { + if (this.state.phase.kind === "pending") return blocked("pending"); + if (this._activeStep() !== "items") { + return blocked( + this._normalizationTarget() === null + ? "wrong-location" + : "normalizing", + ); + } + if (this.state.order === null) return blocked("order-waiting"); + if ( + this.state.revisionFence !== null && + this.state.order.revision < this.state.revisionFence + ) { + return blocked("revision-fence"); + } + if ( + this.state.draft !== null && + this.state.draft.baseRevision === this.state.order.revision && + isBlankDraft(this.state.draft) && + this.state.revisionFence === null && + this.state.phase.kind === "idle" + ) { + return "duplicate"; + } + if (this.state.completedReturn !== null) return blocked("completed"); + + this.state.draft = blankDraft(this.state.order.revision); + this.state.revisionFence = null; + this.state.phase = { kind: "idle" }; + return accepted(); + }); + } + + submit() { + return this._atomic("submit", (ctx) => { + if (this.state.phase.kind === "pending") return "duplicate"; + if (this.state.completedReturn !== null) return blocked("completed"); + if (this._activeStep() !== "review") { + return blocked( + this._normalizationTarget() === null + ? "wrong-location" + : "normalizing", + ); + } + if (this._conflict() !== null) return blocked("source-changed"); + if (!this._methodComplete()) return blocked("incomplete"); + if ( + this.state.phase.kind !== "idle" && + this.state.phase.kind !== "unavailable" + ) { + return blocked("submission-phase"); + } + const requestId = `request-${this.state.nextRequest}`; + this.state.nextRequest = incrementCounter(this.state.nextRequest); + const snapshot = snapshotFromDraft(this.state.draft, requestId); + this.state.phase = { kind: "pending", snapshot: clone(snapshot) }; + ctx.consequences.push({ kind: "submit-return", snapshot: clone(snapshot) }); + return accepted(); + }); + } + + settle(requestId, settlement) { + return this._atomic("settlement", (ctx) => { + if ( + !hasExactOwnKeys(settlement, ["kind"]) && + !hasExactOwnKeys(settlement, ["kind", "returnId"]) && + !hasExactOwnKeys(settlement, ["kind", "currentRevision"]) + ) { + return invalid("settlement"); + } + + if ( + !["accepted", "refused", "unavailable"].includes(settlement.kind) + ) { + return invalid("settlement"); + } + + if ( + (settlement.kind === "accepted" && + !hasExactOwnKeys(settlement, ["kind", "returnId"])) || + (settlement.kind === "refused" && + !hasExactOwnKeys(settlement, ["kind", "currentRevision"])) || + (settlement.kind === "unavailable" && + !hasExactOwnKeys(settlement, ["kind"])) + ) { + return invalid("settlement"); + } + + if ( + settlement.kind === "accepted" && + (!isUnicodeScalarText(settlement.returnId) || + settlement.returnId.length === 0) + ) { + return invalid("return-id"); + } + + if ( + settlement.kind === "refused" && + !isSafePositiveInteger(settlement.currentRevision) + ) { + return invalid("refusal-revision"); + } + + if (requestNumber(requestId) === null) { + return invalid("request-id"); + } + + if ( + this.state.phase.kind !== "pending" || + this.state.phase.snapshot.requestId !== requestId + ) { + return "stale"; + } + + const pending = this.state.phase.snapshot; + if (settlement.kind === "accepted") { + this.state.settledRequests.push(requestId); + this.state.completedReturn = settlement.returnId; + this.state.draft = null; + this.state.revisionFence = null; + this.state.phase = { + kind: "completed", + requestId, + returnId: settlement.returnId, + }; + + const route = this.state.location?.route ?? null; + if ( + route?.kind === "receipt" && + route.returnId === settlement.returnId + ) { + this.state.receiptAccess = "acknowledged"; + } else if (route?.kind === "flow") { + this.state.receiptAccess = "redirecting"; + } else { + this.state.receiptAccess = "offered"; + } + return accepted(); + } + + if (settlement.kind === "unavailable") { + this.state.settledRequests.push(requestId); + this.state.phase = { + kind: "unavailable", + request: clone(pending), + }; + return accepted(); + } + + if ( + settlement.currentRevision <= pending.expectedRevision + ) { + return invalid("refusal-revision"); + } + this.state.settledRequests.push(requestId); + this.state.revisionFence = settlement.currentRevision; + this.state.phase = { + kind: "refused", + request: clone(pending), + currentRevision: settlement.currentRevision, + }; + return accepted(); + }); + } + + followOrderLink() { + return this._atomic("follow-order-link", (ctx) => { + const route = this.state.location?.route ?? null; + const admittedOwner = + route?.kind === "flow" || + (route?.kind === "receipt" && + route.returnId === this.state.completedReturn); + if (!admittedOwner) return blocked("wrong-location"); + + const navigationCheck = this._checkUserNavigation(urls.order); + if (navigationCheck !== null) return navigationCheck; + + this._emitNavigation(ctx, "push", urls.order); + return accepted(); + }); + } + + followReceiptLink() { + return this._atomic("follow-receipt-link", (ctx) => { + if ( + this.state.receiptAccess !== "offered" || + this.state.completedReturn === null + ) { + return blocked("no-completion-notice"); + } + const route = this.state.location?.route ?? null; + if (route?.kind !== "order") return blocked("wrong-location"); + + const target = urls.receipt(this.state.completedReturn); + const navigationCheck = this._checkUserNavigation(target); + if (navigationCheck !== null) return navigationCheck; + + this._emitNavigation(ctx, "push", target); + return accepted(); + }); + } + + _actionAvailability() { + const activeStep = this._activeStep(); + const itemEditable = this._editBlock("items") === null; + const methodEditable = + this._editBlock("method") === null && this._itemsComplete(); + const canGo = (step) => { + const target = urls[step]; + if (this._checkUserNavigation(target) !== null) return false; + const current = this._activeStep(); + if (current === null || !this._stepAdmissible(step)) return false; + if ( + this._conflict() !== null && + this._rank(step) > this._rank(current) + ) { + return false; + } + return true; + }; + + const chooseQuantity = {}; + const chooseReason = {}; + const openPolicy = {}; + for (const line of this.state.order?.lines ?? []) { + putOwn( + chooseQuantity, + line.id, + itemEditable && line.returnableQuantity > 0, + ); + putOwn( + chooseReason, + line.id, + itemEditable && + selectionOf(this.state.draft, line.id) !== null, + ); + putOwn( + openPolicy, + line.id, + activeStep === "items" && + this.state.routeScope !== null && + this.state.surface?.lineId !== line.id, + ); + } + + const chooseMethod = {}; + for (const method of METHODS) { + chooseMethod[method] = + methodEditable && + this.state.order?.allowedMethods.includes(method) === true && + this.state.draft?.method !== method; + } + + const route = this.state.location?.route ?? null; + const followOrderOwner = + route?.kind === "flow" || + (route?.kind === "receipt" && + route.returnId === this.state.completedReturn); + const noticeTarget = + this.state.receiptAccess === "offered" && + this.state.completedReturn !== null + ? urls.receipt(this.state.completedReturn) + : null; + const fenceSatisfied = + this.state.revisionFence === null || + (this.state.order !== null && + this.state.order.revision >= this.state.revisionFence); + const restartWouldBeDuplicate = + this.state.draft !== null && + this.state.order !== null && + this.state.draft.baseRevision === this.state.order.revision && + isBlankDraft(this.state.draft) && + this.state.revisionFence === null && + this.state.phase.kind === "idle"; + + return { + chooseQuantity, + chooseReason, + chooseMethod, + goToStep: { + items: canGo("items"), + method: canGo("method"), + review: canGo("review"), + }, + openPolicy, + dismissPolicy: + this.state.surface === null + ? {} + : { [this.state.surface.id]: true }, + restart: + activeStep === "items" && + this.state.order !== null && + this.state.phase.kind !== "pending" && + this.state.completedReturn === null && + fenceSatisfied && + !restartWouldBeDuplicate, + submit: + activeStep === "review" && + this._conflict() === null && + this._methodComplete() && + ["idle", "unavailable"].includes(this.state.phase.kind), + followOrder: + followOrderOwner && + this._checkUserNavigation(urls.order) === null, + followReceipt: + noticeTarget !== null && + route?.kind !== "flow" && + this._checkUserNavigation(noticeTarget) === null, + }; + } + + _atomic(label, handler) { + const before = this.snapshot(); + const ctx = { consequences: [], locationChanged: false }; + try { + const stepResult = handler(ctx); + + if (stepResult === "accepted") { + this._reconcile(ctx); + } else { + this.state = before; + ctx.consequences = []; + assert.deepEqual( + this.state, + before, + `${label}: non-accepted step must roll back`, + ); + } + + this._assertInvariants(); + return { + result: stepResult, + consequences: clone(ctx.consequences), + state: this.snapshot(), + presentation: this.presentation(), + }; + } catch (error) { + this.state = before; + ctx.consequences = []; + throw error; + } + } + + _reconcile(ctx) { + const target = this._normalizationTarget(); + const route = this.state.location?.route ?? null; + + if (target !== null) { + this.state.routeScope = null; + this.state.surface = null; + if ( + this.state.navigationIntent?.kind !== "required-replace" || + this.state.navigationIntent?.target !== target + ) { + this._emitNavigation(ctx, "replace", target, true); + } + return; + } + + if (route?.kind === "flow") { + if (ctx.locationChanged && this.state.routeScope === null) { + this.state.routeScope = `scope-${this.state.nextRouteScope}`; + this.state.nextRouteScope = + incrementCounter(this.state.nextRouteScope); + } + return; + } + + this.state.routeScope = null; + this.state.surface = null; + } + + _emitNavigation(ctx, mode, target, supersede = false) { + const kind = + mode === "push" ? "user-push" : "required-replace"; + if ( + !supersede && + this.state.navigationIntent !== null && + (this.state.navigationIntent.kind !== kind || + this.state.navigationIntent.target !== target) + ) { + throw new Error("user navigation cannot overwrite an outstanding intent"); + } + this.state.navigationIntent = { kind, target }; + ctx.consequences.push({ kind: "navigate", mode, target }); + } + + _checkUserNavigation(target) { + if (this.state.location?.url === target) return "duplicate"; + if (this.state.navigationIntent?.target === target) return "duplicate"; + if (this.state.navigationIntent !== null) { + return blocked("navigation-pending"); + } + if (this._normalizationTarget() !== null) return blocked("normalizing"); + return null; + } + + _normalizationTarget() { + const route = this.state.location?.route ?? null; + if (route === null) return null; + + if (this.state.completedReturn !== null) { + if (route.kind === "flow") { + return urls.receipt(this.state.completedReturn); + } + return null; + } + + if (route.kind !== "flow") return null; + if (route.step === null) return urls.items; + if (route.step === "items") return null; + if (!this._itemsComplete()) return urls.items; + if (route.step === "review" && !this._methodComplete()) return urls.method; + return null; + } + + _activeStep() { + const route = this.state.location?.route ?? null; + if ( + route?.kind !== "flow" || + route.step === null || + this._normalizationTarget() !== null + ) { + return null; + } + return route.step; + } + + _stepAdmissible(step) { + if (step === "items") return true; + if (step === "method") return this._itemsComplete(); + return this._methodComplete(); + } + + _rank(step) { + return { items: 0, method: 1, review: 2 }[step]; + } + + _itemsComplete() { + const { order, draft } = this.state; + if (order === null || draft === null) return false; + if (draft.baseRevision !== order.revision) return false; + const selections = Object.entries(draft.selections); + if (selections.length === 0) return false; + + for (const [lineId, selection] of selections) { + const line = lineOf(order, lineId); + if (line === null) return false; + if ( + !isSafePositiveInteger(selection.quantity) || + selection.quantity > line.returnableQuantity + ) { + return false; + } + if (!reasonComplete(selection.reason)) return false; + } + return true; + } + + _methodComplete() { + return ( + this._itemsComplete() && + this.state.draft.method !== null && + this.state.order.allowedMethods.includes(this.state.draft.method) + ); + } + + _conflict() { + if ( + this.state.draft !== null && + this.state.order !== null && + this.state.draft.baseRevision !== this.state.order.revision + ) { + return { + kind: "source-changed", + draftRevision: this.state.draft.baseRevision, + observedRevision: this.state.order.revision, + fence: this.state.revisionFence, + }; + } + if (this.state.revisionFence !== null) { + return { + kind: "source-changed", + draftRevision: this.state.draft?.baseRevision ?? null, + observedRevision: this.state.order?.revision ?? null, + fence: this.state.revisionFence, + }; + } + return null; + } + + _editBlock(requiredStep) { + if (this._normalizationTarget() !== null) return "normalizing"; + if (this._activeStep() !== requiredStep) return "wrong-location"; + if (this.state.order === null || this.state.draft === null) { + return "order-waiting"; + } + if (this.state.phase.kind === "pending") return "pending"; + if ( + this.state.phase.kind === "completed" || + this.state.completedReturn !== null + ) { + return "completed"; + } + if (this._conflict() !== null) return "source-changed"; + return null; + } + + _clearRetryablePhaseAfterEdit() { + if ( + this.state.phase.kind === "unavailable" || + this.state.phase.kind === "refused" + ) { + this.state.phase = { kind: "idle" }; + } + } + + _assertInvariants() { + const s = this.state; + + assert.equal( + hasExactOwnKeys(s, [ + "location", + "order", + "draft", + "phase", + "revisionFence", + "completedReturn", + "receiptAccess", + "routeScope", + "nextRouteScope", + "surface", + "nextSurface", + "navigationIntent", + "nextRequest", + "settledRequests", + ]), + true, + "coordinator state has its exact closed shape", + ); + assert.equal(isDenseDataArray(s.settledRequests), true); + assert.equal( + isPositiveCounter(s.nextRequest), + true, + "request allocator is an exact positive counter", + ); + assert.equal( + isPositiveCounter(s.nextSurface), + true, + "surface allocator is an exact positive counter", + ); + assert.equal( + isPositiveCounter(s.nextRouteScope), + true, + "route-scope allocator is an exact positive counter", + ); + + assert.equal( + ["idle", "pending", "refused", "unavailable", "completed"].includes( + s.phase?.kind, + ), + true, + "submission phase is closed", + ); + const phaseKeys = { + idle: ["kind"], + pending: ["kind", "snapshot"], + refused: ["kind", "request", "currentRevision"], + unavailable: ["kind", "request"], + completed: ["kind", "requestId", "returnId"], + }; + assert.equal( + hasExactOwnKeys(s.phase, phaseKeys[s.phase.kind]), + true, + "submission phase has its exact closed shape", + ); + + if (s.location !== null) { + assert.equal(hasExactOwnKeys(s.location, ["url", "route"]), true); + assert.equal(typeof s.location.url, "string"); + const decoded = routeFor(s.location.url); + assert.notEqual(decoded, null); + assert.equal( + hasExactOwnKeys(s.location.route, Object.keys(decoded)), + true, + ); + assert.deepEqual( + s.location.route, + decoded, + "decoded route is derived from the delivered URL", + ); + if (s.location.route.kind === "receipt") { + assert.equal( + s.location.route.returnId, + s.completedReturn, + "only the retained completed receipt is admitted", + ); + } + } + + if (s.order !== null) { + assert.equal(validOrder(s.order), true); + assert.deepEqual( + s.order, + canonicalOrder(s.order), + "stored order is canonical", + ); + } + if (s.draft !== null) { + assert.equal( + hasExactOwnKeys(s.draft, [ + "orderId", + "baseRevision", + "selections", + "method", + ]), + true, + ); + assert.equal(isDataRecord(s.draft.selections), true); + assert.notEqual(s.order, null, "a draft requires an accepted order copy"); + assert.equal(s.draft.orderId, ORDER_ID); + assert.equal( + isSafePositiveInteger(s.draft.baseRevision), + true, + ); + assert.equal( + s.draft.baseRevision <= s.order.revision, + true, + "a draft cannot be based on an unobserved future revision", + ); + assert.equal( + s.draft.method === null || METHODS.has(s.draft.method), + true, + "draft method is closed", + ); + for (const [lineId, selection] of Object.entries(s.draft.selections)) { + assert.equal( + hasExactOwnKeys(selection, ["quantity", "reason"]), + true, + ); + assert.equal( + isUnicodeScalarText(lineId), + true, + ); + assert.equal( + isSafePositiveInteger(selection.quantity), + true, + ); + assert.equal( + selection.reason === null || reasonValid(selection.reason), + true, + ); + if (s.draft.baseRevision === s.order.revision) { + const currentLine = lineOf(s.order, lineId); + assert.notEqual( + currentLine, + null, + "a current-revision draft cannot select an unknown line", + ); + assert.equal( + selection.quantity <= currentLine.returnableQuantity, + true, + "a current-revision selection respects current quantity", + ); + } + } + if ( + s.draft.baseRevision === s.order.revision && + s.draft.method !== null + ) { + assert.equal( + s.order.allowedMethods.includes(s.draft.method), + true, + "a current-revision method is currently allowed", + ); + } + } + if ( + s.location?.route.kind === "flow" && + s.order !== null && + s.completedReturn === null + ) { + assert.notEqual( + s.draft, + null, + "an observed order in an incomplete flow owns a draft", + ); + } + + if (s.phase.kind === "pending") { + assert.notEqual(s.draft, null); + assert.equal(requestSnapshotValid(s.phase.snapshot), true); + assert.deepEqual( + s.phase.snapshot, + snapshotFromDraft(s.draft, s.phase.snapshot.requestId), + "pending snapshot is the canonical retained draft snapshot", + ); + } + + if (s.phase.kind === "refused") { + assert.equal(requestSnapshotValid(s.phase.request), true); + assert.equal( + isSafePositiveInteger(s.phase.currentRevision), + true, + ); + assert.equal( + s.phase.currentRevision > s.phase.request.expectedRevision, + true, + "refusal fence is newer than the submitted revision", + ); + assert.notEqual(s.draft, null); + assert.deepEqual( + s.phase.request, + snapshotFromDraft(s.draft, s.phase.request.requestId), + "refusal preserves the submitted draft snapshot", + ); + assert.equal(s.revisionFence, s.phase.currentRevision); + assert.equal( + s.settledRequests.includes(s.phase.request.requestId), + true, + ); + } else { + assert.equal(s.revisionFence, null); + } + + if (s.phase.kind === "unavailable") { + assert.equal(requestSnapshotValid(s.phase.request), true); + assert.notEqual(s.draft, null); + assert.deepEqual( + s.phase.request, + snapshotFromDraft(s.draft, s.phase.request.requestId), + "unavailability preserves the submitted draft snapshot", + ); + assert.equal( + s.settledRequests.includes(s.phase.request.requestId), + true, + ); + } + + if (s.phase.kind === "completed") { + assert.equal( + isUnicodeScalarText(s.phase.returnId) && + s.phase.returnId.length > 0, + true, + ); + assert.equal(s.completedReturn, s.phase.returnId); + assert.equal(s.settledRequests.includes(s.phase.requestId), true); + assert.equal(s.draft, null); + assert.notEqual( + s.location, + null, + "completion retains the location from which its request was submitted", + ); + assert.equal( + ["redirecting", "offered", "acknowledged"].includes( + s.receiptAccess, + ), + true, + "completed receipt access has a closed lifecycle", + ); + } else { + assert.equal(s.completedReturn, null); + assert.equal(s.receiptAccess, null); + } + + if (s.phase.kind === "completed") { + const route = s.location.route; + if (s.receiptAccess === "redirecting") { + assert.equal(route.kind, "flow"); + } else if (s.receiptAccess === "offered") { + assert.equal( + route.kind === "receipt" && + route.returnId === s.completedReturn, + false, + ); + } + if ( + route.kind === "receipt" && + route.returnId === s.completedReturn + ) { + assert.equal(s.receiptAccess, "acknowledged"); + } + } + + if (s.surface !== null) { + assert.equal( + hasExactOwnKeys(s.surface, ["id", "lineId", "ownerScope"]), + true, + ); + assert.equal(s.routeScope !== null, true); + assert.equal(s.surface.ownerScope, s.routeScope); + assert.notEqual(lineOf(s.order, s.surface.lineId), null); + assert.equal(this._activeStep(), "items"); + const surfaceId = surfaceNumber(s.surface.id); + assert.notEqual(surfaceId, null); + assert.equal( + sameCounter(incrementCounter(surfaceId), s.nextSurface), + true, + "the open surface is the latest allocated surface", + ); + } + + if (s.routeScope !== null) { + const scopeId = routeScopeNumber(s.routeScope); + assert.notEqual(scopeId, null); + assert.equal( + sameCounter(incrementCounter(scopeId), s.nextRouteScope), + true, + "the current scope is the latest allocated route scope", + ); + assert.equal(s.location?.route.kind, "flow"); + assert.equal(this._normalizationTarget(), null); + } + + const normalizationTarget = this._normalizationTarget(); + if (normalizationTarget !== null) { + assert.equal(s.routeScope, null); + assert.equal(s.surface, null); + assert.deepEqual( + s.navigationIntent, + { + kind: "required-replace", + target: normalizationTarget, + }, + "normalizing state retains its required replace intent", + ); + } else if (s.location?.route.kind === "flow") { + assert.notEqual( + s.routeScope, + null, + "an admissible flow location owns a route scope", + ); + } + + for (const [index, requestId] of s.settledRequests.entries()) { + const number = requestNumber(requestId); + assert.notEqual(number, null); + assert.equal( + requestId, + `request-${index + 1}`, + "the settled ledger is complete and allocation-ordered", + ); + } + + const lastSettled = s.settledRequests.length; + if (s.phase.kind === "pending") { + assert.equal( + s.phase.snapshot.requestId, + `request-${lastSettled + 1}`, + "the pending request is the next allocated request", + ); + assert.equal( + sameCounter(s.nextRequest, lastSettled + 2), + true, + ); + } else { + if (["refused", "unavailable", "completed"].includes(s.phase.kind)) { + const currentId = + s.phase.kind === "completed" + ? s.phase.requestId + : s.phase.request.requestId; + assert.equal(lastSettled > 0, true); + assert.equal( + currentId, + `request-${lastSettled}`, + "the visible settlement belongs to the latest request", + ); + } + assert.equal( + sameCounter(s.nextRequest, lastSettled + 1), + true, + "the request allocator follows the complete settled ledger", + ); + } + + assert.equal( + s.navigationIntent === null || + (hasExactOwnKeys(s.navigationIntent, ["kind", "target"]) && + ["user-push", "required-replace"].includes( + s.navigationIntent.kind, + ) && + typeof s.navigationIntent.target === "string" && + routeFor(s.navigationIntent.target) !== null), + true, + ); + if (s.navigationIntent?.kind === "required-replace") { + assert.notEqual(normalizationTarget, null); + assert.equal(s.navigationIntent.target, normalizationTarget); + } + if (s.navigationIntent?.kind === "user-push") { + assert.equal(normalizationTarget, null); + const route = s.location?.route ?? null; + let ownedTarget = false; + if (route?.kind === "flow") { + ownedTarget = [ + urls.items, + urls.method, + urls.review, + urls.order, + ].includes(s.navigationIntent.target); + } else if (route?.kind === "receipt") { + ownedTarget = s.navigationIntent.target === urls.order; + } else if ( + route?.kind === "order" && + s.receiptAccess === "offered" + ) { + ownedTarget = + s.navigationIntent.target === urls.receipt(s.completedReturn); + } + assert.equal( + ownedTarget, + true, + "a user navigation intent has an admitted current owner", + ); + assert.notEqual(s.navigationIntent.target, s.location.url); + } + } +} + +export function expectStep(actual, expectedResult, expectedConsequences = []) { + assert.equal(actual.result, expectedResult); + assert.deepEqual(actual.consequences, expectedConsequences); + return actual; +} + +export function nav(mode, target) { + return { kind: "navigate", mode, target }; +} + +export function submitCommand(snapshot) { + return { kind: "submit-return", snapshot }; +} + +export function refusal(currentRevision) { + return { kind: "refused", currentRevision }; +} + +export function acceptance(returnId) { + return { kind: "accepted", returnId }; +} + +export const unavailable = Object.freeze({ kind: "unavailable" }); + +export function assertSame(actual, expected, message = undefined) { + assert.deepEqual(actual, expected, message); +} diff --git a/examples/applications/a0-return-desk/reference-oracle/validate.mjs b/examples/applications/a0-return-desk/reference-oracle/validate.mjs new file mode 100644 index 0000000..390753d --- /dev/null +++ b/examples/applications/a0-return-desk/reference-oracle/validate.mjs @@ -0,0 +1,1865 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + ReturnDeskOracle, + acceptance, + assertSame, + expectStep, + nav, + order7, + order8, + order9WithoutLamp, + other, + refusal, + submitCommand, + unavailable, + urls, +} from "./model.mjs"; + +const checks = []; + +function compareText(left, right) { + return Buffer.compare( + Buffer.from(left, "utf8"), + Buffer.from(right, "utf8"), + ); +} + +function canonical(value) { + if (value === null) return ["null"]; + if (typeof value === "boolean") return ["boolean", value]; + if (typeof value === "string") return ["string", value]; + if (typeof value === "number") return ["number", value]; + if (typeof value === "bigint") return ["bigint", value.toString()]; + if (Array.isArray(value)) { + return ["array", value.map(canonical)]; + } + if (value && typeof value === "object") { + return [ + "record", + Object.keys(value) + .sort(compareText) + .map((key) => [key, canonical(value[key])]), + ]; + } + throw new TypeError("unsupported canonical value"); +} + +function digest(value) { + return createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"); +} + +function check(name, body) { + body(); + checks.push(name); +} + +function pendingSnapshot( + requestId, + revision, + lineId, + quantity, + reason, + method, +) { + return { + requestId, + orderId: "order-100", + expectedRevision: revision, + selections: [{ lineId, quantity, reason }], + method, + }; +} + +function assertNoMutation(before, step, message) { + assert.deepEqual(step.state, before, message); + assert.deepEqual(step.consequences, [], message); +} + +function readyReview({ + order = order7, + lineId = "lamp", + reason = "damaged", + method = "drop-off", +} = {}) { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order), "accepted"); + expectStep(machine.chooseQuantity(lineId, 1), "accepted"); + expectStep(machine.chooseReason(lineId, reason), "accepted"); + expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + expectStep(machine.deliverLocation(urls.method), "accepted"); + expectStep(machine.chooseMethod(method), "accepted"); + expectStep( + machine.goToStep("review"), + "accepted", + [nav("push", urls.review)], + ); + expectStep(machine.deliverLocation(urls.review), "accepted"); + return machine; +} + +const canonicalPins = {}; +let canonicalCheckpoint; +let canonicalPrefixDigest; + +check("canonical trace 0..31", () => { + const machine = new ReturnDeskOracle(); + const trace = []; + + trace.push({ + step: 0, + result: "initialized", + consequences: [], + state: machine.snapshot(), + presentation: machine.presentation(), + }); + assert.equal(machine.state.location, null); + assert.equal(machine.presentation().orderStatus.kind, "waiting"); + assert.equal(machine.state.draft, null); + + let step = expectStep( + machine.deliverLocation(urls.review), + "accepted", + [nav("replace", urls.items)], + ); + trace.push({ step: 1, ...step }); + assert.equal(step.presentation.deliveredLocation, urls.review); + assert.equal(step.presentation.normalizing, true); + assert.equal(machine.state.routeScope, null); + + step = expectStep(machine.deliverLocation(urls.items), "accepted"); + trace.push({ step: 2, ...step }); + assert.equal(step.presentation.activeStep, "items"); + assert.equal(step.presentation.orderStatus.kind, "waiting"); + canonicalPins.waitingAtItems = machine.snapshot(); + + step = expectStep(machine.deliverOrder(order7), "accepted"); + trace.push({ step: 3, ...step }); + assert.equal(machine.state.draft.baseRevision, 7); + assert.equal(Object.keys(machine.state.draft.selections).length, 0); + + step = expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + trace.push({ step: 4, ...step }); + assert.equal(step.presentation.itemsComplete, false); + canonicalPins.incompleteItems = machine.snapshot(); + + step = expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + trace.push({ step: 5, ...step }); + assert.equal(step.presentation.itemsComplete, true); + assert.equal(step.presentation.actions.chooseQuantity.lamp, true); + assert.equal(step.presentation.actions.chooseReason.lamp, true); + assert.equal(step.presentation.actions.goToStep.method, true); + assert.equal(step.presentation.actions.submit, false); + canonicalPins.completeItems = machine.snapshot(); + + step = expectStep(machine.openPolicy("lamp"), "accepted"); + trace.push({ step: 6, ...step }); + assert.equal(machine.state.surface.id, "surface-1"); + assert.equal( + step.presentation.policySurface.policySummary, + "Return the lamp in protective packaging.", + ); + canonicalPins.openPolicySurface = machine.snapshot(); + + const draftBeforeDismiss = machine.snapshot().draft; + step = expectStep(machine.dismissPolicy("surface-1"), "accepted"); + trace.push({ step: 7, ...step }); + assert.equal(machine.state.surface, null); + assert.deepEqual(machine.state.draft, draftBeforeDismiss); + + step = expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + trace.push({ step: 8, ...step }); + assert.equal(step.presentation.deliveredLocation, urls.items); + + const itemsScope = machine.state.routeScope; + step = expectStep(machine.deliverLocation(urls.method), "accepted"); + trace.push({ step: 9, ...step }); + assert.equal(step.presentation.activeStep, "method"); + assert.notEqual(machine.state.routeScope, itemsScope); + + step = expectStep(machine.chooseMethod("drop-off"), "accepted"); + trace.push({ step: 10, ...step }); + assert.equal(step.presentation.methodComplete, true); + canonicalPins.methodSelection = machine.snapshot(); + + step = expectStep( + machine.goToStep("review"), + "accepted", + [nav("push", urls.review)], + ); + trace.push({ step: 11, ...step }); + + step = expectStep(machine.deliverLocation(urls.review), "accepted"); + trace.push({ step: 12, ...step }); + assert.equal(step.presentation.activeStep, "review"); + assert.equal(step.presentation.actions.submit, true); + canonicalPins.review = machine.snapshot(); + + const request1 = pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ); + step = expectStep( + machine.submit(), + "accepted", + [submitCommand(request1)], + ); + trace.push({ step: 13, ...step }); + assert.deepEqual(machine.state.phase.snapshot, request1); + assert.equal(step.presentation.actions.submit, false); + + const beforeDuplicateSubmit = machine.snapshot(); + step = expectStep(machine.submit(), "duplicate"); + trace.push({ step: 14, ...step }); + assertNoMutation(beforeDuplicateSubmit, step, "duplicate Submit"); + + step = expectStep(machine.deliverLocation(urls.method), "accepted"); + trace.push({ step: 15, ...step }); + assert.equal(step.presentation.activeStep, "method"); + assert.deepEqual(machine.state.phase.snapshot, request1); + + const draftBeforeRefusal = machine.snapshot().draft; + const orderBeforeRefusal = machine.snapshot().order; + const scopeBeforeRefusal = machine.state.routeScope; + step = expectStep( + machine.settle("request-1", refusal(8)), + "accepted", + ); + trace.push({ step: 16, ...step }); + assert.equal(machine.state.revisionFence, 8); + assert.deepEqual(machine.state.phase, { + kind: "refused", + request: request1, + currentRevision: 8, + }); + assert.deepEqual(machine.state.draft, draftBeforeRefusal); + assert.deepEqual(machine.state.order, orderBeforeRefusal); + assert.equal(machine.state.routeScope, scopeBeforeRefusal); + assert.equal(step.presentation.activeStep, "method"); + assert.equal(step.consequences.length, 0); + canonicalPins.domainRefusal = machine.snapshot(); + + step = expectStep( + machine.deliverOrder(order8), + "accepted", + [nav("replace", urls.items)], + ); + trace.push({ step: 17, ...step }); + assert.deepEqual(machine.state.draft.selections.lamp, { + quantity: 1, + reason: "damaged", + }); + assert.equal(step.presentation.conflict.kind, "source-changed"); + assert.equal(step.presentation.deliveredLocation, urls.method); + + step = expectStep(machine.deliverLocation(urls.items), "accepted"); + trace.push({ step: 18, ...step }); + assert.equal(step.presentation.activeStep, "items"); + assert.equal(step.presentation.conflict.kind, "source-changed"); + canonicalPins.sourceChangedConflict = machine.snapshot(); + + step = expectStep(machine.restartFromCurrentOrder(), "accepted"); + trace.push({ step: 19, ...step }); + assert.equal(machine.state.draft.baseRevision, 8); + assert.equal(Object.keys(machine.state.draft.selections).length, 0); + assert.equal(machine.state.revisionFence, null); + assert.equal(machine.state.phase.kind, "idle"); + + step = expectStep(machine.chooseQuantity("mug", 1), "accepted"); + trace.push({ step: 20, ...step }); + assert.equal(step.presentation.itemsComplete, false); + + step = expectStep(machine.chooseReason("mug", "not-needed"), "accepted"); + trace.push({ step: 21, ...step }); + assert.equal(step.presentation.itemsComplete, true); + + step = expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + trace.push({ step: 22, ...step }); + + step = expectStep(machine.deliverLocation(urls.method), "accepted"); + trace.push({ step: 23, ...step }); + assert.equal(step.presentation.activeStep, "method"); + + step = expectStep(machine.chooseMethod("pickup"), "accepted"); + trace.push({ step: 24, ...step }); + + step = expectStep( + machine.goToStep("review"), + "accepted", + [nav("push", urls.review)], + ); + trace.push({ step: 25, ...step }); + + step = expectStep(machine.deliverLocation(urls.review), "accepted"); + trace.push({ step: 26, ...step }); + + const request2 = pendingSnapshot( + "request-2", + 8, + "mug", + 1, + "not-needed", + "pickup", + ); + step = expectStep( + machine.submit(), + "accepted", + [submitCommand(request2)], + ); + trace.push({ step: 27, ...step }); + + step = expectStep(machine.deliverLocation(urls.method), "accepted"); + trace.push({ step: 28, ...step }); + assert.deepEqual(machine.state.phase.snapshot, request2); + assert.equal(machine.state.navigationIntent, null); + canonicalPins.pendingAfterBack = machine.snapshot(); + canonicalPrefixDigest = digest(trace); + canonicalCheckpoint = machine.checkpoint({ + scenario: "canonical", + afterStep: 28, + prefixDigest: canonicalPrefixDigest, + }); + + step = expectStep( + machine.settle("request-2", acceptance("return-900")), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + trace.push({ step: 29, ...step }); + assert.equal(machine.state.completedReturn, "return-900"); + assert.equal(machine.state.draft, null); + assert.equal(step.presentation.deliveredLocation, urls.method); + + step = expectStep( + machine.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + trace.push({ step: 30, ...step }); + assert.deepEqual(step.presentation.receipt, { + returnId: "return-900", + status: "completed", + }); + assert.equal(step.presentation.completionNotice, null); + canonicalPins.receipt = machine.snapshot(); + + const beforeDuplicateAcceptance = machine.snapshot(); + step = expectStep( + machine.settle("request-2", acceptance("return-900")), + "stale", + ); + trace.push({ step: 31, ...step }); + assertNoMutation( + beforeDuplicateAcceptance, + step, + "duplicate acceptance must be stale and inert", + ); + + assert.equal(trace.length, 32); + assert.deepEqual( + trace.map((row) => row.step), + Array.from({ length: 32 }, (_, index) => index), + ); +}); + +check("scenario 1: complete items without method normalizes review to method", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + const step = expectStep( + machine.deliverLocation(urls.review), + "accepted", + [nav("replace", urls.method)], + ); + assert.equal(step.presentation.normalizing, true); + assert.equal(machine.state.routeScope, null); +}); + +check("scenario 2: missing and unknown step normalize to items", () => { + for (const location of [urls.missingStep, urls.unknownStep]) { + const machine = new ReturnDeskOracle(); + const step = expectStep( + machine.deliverLocation(location), + "accepted", + [nav("replace", urls.items)], + ); + assert.equal(step.presentation.normalizing, true); + assert.equal(step.presentation.deliveredLocation, location); + } +}); + +check("scenario 3: Forward redelivery after restart uses normal admissibility", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + expectStep(machine.deliverOrder(order8), "accepted"); + expectStep(machine.restartFromCurrentOrder(), "accepted"); + const step = expectStep( + machine.deliverLocation(urls.method), + "accepted", + [nav("replace", urls.items)], + ); + assert.equal(step.presentation.normalizing, true); +}); + +check("scenario 4: older order revision is stale", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverOrder(order8), "accepted"); + const before = machine.snapshot(); + const step = expectStep(machine.deliverOrder(order7), "stale"); + assertNoMutation(before, step, "older order observation"); + assert.equal(machine.state.order.revision, 8); +}); + +check("scenario 5: removed selected line remains conflicted and closes surface", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + expectStep(machine.openPolicy("lamp"), "accepted"); + const allocationBefore = machine.state.nextSurface; + + const step = expectStep(machine.deliverOrder(order9WithoutLamp), "accepted"); + assert.equal(machine.state.surface, null); + assert.equal(machine.state.nextSurface, allocationBefore); + assert.deepEqual(machine.state.draft.selections.lamp, { + quantity: 1, + reason: "damaged", + }); + assert.equal(step.presentation.conflict.kind, "source-changed"); +}); + +check("scenario 6: stale surface cannot close a newer surface", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.openPolicy("lamp"), "accepted"); + const firstScope = machine.state.routeScope; + + expectStep( + machine.followOrderLink(), + "accepted", + [nav("push", urls.order)], + ); + expectStep(machine.deliverLocation(urls.order), "accepted"); + assert.equal(machine.state.surface, null); + expectStep(machine.deliverLocation(urls.items), "accepted"); + assert.notEqual(machine.state.routeScope, firstScope); + expectStep(machine.openPolicy("mug"), "accepted"); + assert.equal(machine.state.surface.id, "surface-2"); + + const before = machine.snapshot(); + const stale = expectStep(machine.dismissPolicy("surface-1"), "stale"); + assertNoMutation(before, stale, "stale surface dismissal"); + assert.equal(machine.state.surface.id, "surface-2"); +}); + +let retryUnavailablePin; +check("scenario 7: unavailable preserves draft and retry uses fresh identity", () => { + const machine = readyReview(); + const request1 = pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ); + expectStep(machine.submit(), "accepted", [submitCommand(request1)]); + const authoredDraft = machine.snapshot().draft; + expectStep(machine.settle("request-1", unavailable), "accepted"); + assert.deepEqual(machine.state.draft, authoredDraft); + assert.equal(machine.state.phase.kind, "unavailable"); + retryUnavailablePin = machine.snapshot(); + + const request2 = { ...request1, requestId: "request-2" }; + expectStep(machine.submit(), "accepted", [submitCommand(request2)]); + assert.equal(machine.state.phase.snapshot.requestId, "request-2"); + assert.deepEqual(machine.state.phase.snapshot.selections, request1.selections); +}); + +check("scenario 8: non-newer refusal is invalid and request stays pending", () => { + const machine = readyReview(); + expectStep( + machine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + const before = machine.snapshot(); + const step = expectStep( + machine.settle("request-1", refusal(7)), + "invalid(refusal-revision)", + ); + assertNoMutation(before, step, "invalid refusal"); + assert.equal(machine.state.phase.kind, "pending"); + assert.equal(machine.state.phase.snapshot.requestId, "request-1"); +}); + +check("scenario 9: fence blocks edits and Submit until observation plus restart", () => { + const machine = readyReview(); + expectStep( + machine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep(machine.deliverLocation(urls.method), "accepted"); + expectStep(machine.settle("request-1", refusal(8)), "accepted"); + expectStep(machine.deliverLocation(urls.items), "accepted"); + + expectStep( + machine.chooseQuantity("lamp", 2), + "blocked(source-changed)", + ); + expectStep(machine.deliverLocation(urls.review), "accepted"); + expectStep(machine.submit(), "blocked(source-changed)"); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep( + machine.restartFromCurrentOrder(), + "blocked(revision-fence)", + ); + + expectStep(machine.deliverOrder(order8), "accepted"); + expectStep(machine.restartFromCurrentOrder(), "accepted"); + assert.equal(machine.state.revisionFence, null); + assert.equal(machine.state.draft.baseRevision, 8); +}); + +let offeredReceiptPin; +check("scenario 10: late outside-flow acceptance creates notice and semantic link", () => { + const machine = readyReview(); + expectStep( + machine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep( + machine.followOrderLink(), + "accepted", + [nav("push", urls.order)], + ); + expectStep(machine.deliverLocation(urls.order), "accepted"); + + const acceptedOutside = expectStep( + machine.settle("request-1", acceptance("return-900")), + "accepted", + ); + assert.deepEqual(acceptedOutside.consequences, []); + assert.equal(acceptedOutside.presentation.deliveredLocation, urls.order); + assert.deepEqual(acceptedOutside.presentation.completionNotice, { + returnId: "return-900", + receipt: urls.receipt("return-900"), + }); + offeredReceiptPin = machine.snapshot(); + + const oldFlowDetour = new ReturnDeskOracle(offeredReceiptPin); + expectStep( + oldFlowDetour.deliverLocation(urls.items), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + assert.equal(oldFlowDetour.state.receiptAccess, "redirecting"); + assert.equal(oldFlowDetour.presentation().completionNotice, null); + expectStep( + oldFlowDetour.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + assert.equal(oldFlowDetour.state.receiptAccess, "acknowledged"); + assert.equal(oldFlowDetour.presentation().completionNotice, null); + + expectStep( + machine.followReceiptLink(), + "accepted", + [nav("push", urls.receipt("return-900"))], + ); + expectStep( + machine.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + assert.equal(machine.state.receiptAccess, "acknowledged"); + assert.equal(machine.presentation().completionNotice, null); + + const supersededRedirect = readyReview(); + const supersededRequest = pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ); + expectStep( + supersededRedirect.submit(), + "accepted", + [submitCommand(supersededRequest)], + ); + expectStep( + supersededRedirect.settle( + "request-1", + acceptance("return-redirected"), + ), + "accepted", + [nav("replace", urls.receipt("return-redirected"))], + ); + assert.equal(supersededRedirect.state.receiptAccess, "redirecting"); + expectStep( + supersededRedirect.deliverLocation(urls.order), + "accepted", + ); + assert.equal(supersededRedirect.state.receiptAccess, "offered"); + assert.deepEqual( + supersededRedirect.presentation().completionNotice, + { + returnId: "return-redirected", + receipt: urls.receipt("return-redirected"), + }, + ); + expectStep( + supersededRedirect.followReceiptLink(), + "accepted", + [nav("push", urls.receipt("return-redirected"))], + ); +}); + +check("scenario 11: unknown or conflicting settlement is inert", () => { + const machine = readyReview(); + expectStep( + machine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + const before = machine.snapshot(); + const unknown = expectStep( + machine.settle("request-999", acceptance("return-evil")), + "stale", + ); + assertNoMutation(before, unknown, "unknown settlement"); + assert.equal(machine.state.completedReturn, null); + assert.equal(machine.state.phase.snapshot.requestId, "request-1"); + + expectStep(machine.settle("request-1", unavailable), "accepted"); + const settled = machine.snapshot(); + const contradictory = expectStep( + machine.settle("request-1", acceptance("return-evil")), + "stale", + ); + assertNoMutation(settled, contradictory, "contradictory settlement"); +}); + +function completedAtReceipt() { + const machine = readyReview(); + expectStep( + machine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep( + machine.settle("request-1", acceptance("return-900")), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + expectStep( + machine.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + return machine; +} + +check("scenario 12: old flow after completion replaces to retained receipt", () => { + const machine = completedAtReceipt(); + const step = expectStep( + machine.deliverLocation(urls.items), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + assert.equal(step.presentation.normalizing, true); + assert.equal(machine.state.draft, null); + assert.equal(machine.state.routeScope, null); + assert.equal(machine.state.receiptAccess, "acknowledged"); + assert.equal(machine.presentation().completionNotice, null); + + const acknowledged = completedAtReceipt(); + expectStep( + acknowledged.followOrderLink(), + "accepted", + [nav("push", urls.order)], + ); + expectStep(acknowledged.deliverLocation(urls.order), "accepted"); + assert.equal(acknowledged.state.receiptAccess, "acknowledged"); + assert.equal(acknowledged.presentation().completionNotice, null); + expectStep( + acknowledged.followReceiptLink(), + "blocked(no-completion-notice)", + ); + expectStep( + acknowledged.deliverLocation(urls.items), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + assert.equal(acknowledged.state.receiptAccess, "acknowledged"); + assert.equal(acknowledged.presentation().completionNotice, null); +}); + +check("scenario 13: repeated normalization delivery emits no second navigation", () => { + const incomplete = new ReturnDeskOracle(); + expectStep( + incomplete.deliverLocation(urls.review), + "accepted", + [nav("replace", urls.items)], + ); + const beforeIncompleteRepeat = incomplete.snapshot(); + const repeatedIncomplete = expectStep( + incomplete.deliverLocation(urls.review), + "duplicate", + ); + assertNoMutation( + beforeIncompleteRepeat, + repeatedIncomplete, + "repeated inadmissible location", + ); + + const completed = completedAtReceipt(); + expectStep( + completed.deliverLocation(urls.items), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + const beforeCompletedRepeat = completed.snapshot(); + const repeatedCompleted = expectStep( + completed.deliverLocation(urls.items), + "duplicate", + ); + assertNoMutation( + beforeCompletedRepeat, + repeatedCompleted, + "repeated completed-flow location", + ); +}); + +check("scenario 14: one navigation intent deduplicates, blocks, then clears", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + + expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + expectStep(machine.goToStep("method"), "duplicate"); + expectStep( + machine.followOrderLink(), + "blocked(navigation-pending)", + ); + expectStep(machine.deliverLocation(urls.method), "accepted"); + assert.equal(machine.state.navigationIntent, null); +}); + +check("audit regressions: invalid delivery precedence and link ownership", () => { + const pending = readyReview(); + expectStep( + pending.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep( + pending.settle("request-999", { + kind: "refused", + currentRevision: "8", + }), + "invalid(refusal-revision)", + ); + expectStep( + pending.settle("request-999", { + kind: "refused", + currentRevision: 0, + }), + "invalid(refusal-revision)", + ); + expectStep( + pending.settle("request-999", { + kind: "refused", + currentRevision: -1, + }), + "invalid(refusal-revision)", + ); + expectStep( + pending.settle("request-999", acceptance("\ud800")), + "invalid(return-id)", + ); + expectStep( + pending.settle({ malformed: true }, unavailable), + "invalid(request-id)", + ); + expectStep( + pending.settle("", unavailable), + "invalid(request-id)", + ); + expectStep( + pending.settle("valid-but-never-emitted", unavailable), + "invalid(request-id)", + ); + expectStep( + pending.settle("request-999", unavailable), + "stale", + ); + expectStep( + pending.settle( + "request-1", + refusal(Number.MAX_SAFE_INTEGER + 1), + ), + "invalid(refusal-revision)", + ); + assert.equal(pending.state.phase.snapshot.requestId, "request-1"); + + const unsafeOrder = { + ...order7, + revision: Number.MAX_SAFE_INTEGER + 1, + lines: [...order7.lines], + allowedMethods: [...order7.allowedMethods], + }; + const unsafeQuantityOrder = { + ...order7, + lines: [ + { + ...order7.lines[0], + purchasedQuantity: Number.MAX_SAFE_INTEGER + 1, + }, + ], + allowedMethods: [...order7.allowedMethods], + }; + const safeDomain = new ReturnDeskOracle(); + expectStep(safeDomain.deliverOrder(unsafeOrder), "invalid(order)"); + expectStep( + safeDomain.deliverOrder(unsafeQuantityOrder), + "invalid(order)", + ); + expectStep(safeDomain.deliverLocation(urls.items), "accepted"); + expectStep(safeDomain.deliverOrder(order7), "accepted"); + expectStep( + safeDomain.chooseQuantity( + "lamp", + Number.MAX_SAFE_INTEGER + 1, + ), + "invalid(quantity)", + ); + + const maximumSafeOrder = { + ...order7, + revision: Number.MAX_SAFE_INTEGER, + lines: [...order7.lines], + allowedMethods: [...order7.allowedMethods], + }; + const maximumSafe = readyReview({ order: maximumSafeOrder }); + const maximumSafeRequest = pendingSnapshot( + "request-1", + Number.MAX_SAFE_INTEGER, + "lamp", + 1, + "damaged", + "drop-off", + ); + expectStep( + maximumSafe.submit(), + "accepted", + [submitCommand(maximumSafeRequest)], + ); + + const surfaceAllocator = new ReturnDeskOracle(); + expectStep(surfaceAllocator.deliverLocation(urls.items), "accepted"); + expectStep(surfaceAllocator.deliverOrder(order7), "accepted"); + surfaceAllocator.state.nextSurface = Number.MAX_SAFE_INTEGER; + expectStep(surfaceAllocator.openPolicy("lamp"), "accepted"); + assert.equal( + surfaceAllocator.state.surface.id, + `surface-${Number.MAX_SAFE_INTEGER}`, + ); + assert.equal( + surfaceAllocator.state.nextSurface, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + ); + expectStep(surfaceAllocator.openPolicy("mug"), "accepted"); + assert.equal( + surfaceAllocator.state.surface.id, + `surface-${BigInt(Number.MAX_SAFE_INTEGER) + 1n}`, + ); + const promotedCheckpoint = surfaceAllocator.checkpoint({ + scenario: "exact-counter-promotion", + }); + assert.deepEqual( + ReturnDeskOracle.restore(promotedCheckpoint).snapshot(), + surfaceAllocator.snapshot(), + ); + + const routeAllocator = new ReturnDeskOracle(); + routeAllocator.state.nextRouteScope = Number.MAX_SAFE_INTEGER; + expectStep(routeAllocator.deliverLocation(urls.items), "accepted"); + assert.equal( + routeAllocator.state.routeScope, + `scope-${Number.MAX_SAFE_INTEGER}`, + ); + assert.equal( + routeAllocator.state.nextRouteScope, + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + ); + expectStep(routeAllocator.deliverOrder(order7), "accepted"); + expectStep(routeAllocator.chooseQuantity("lamp", 1), "accepted"); + expectStep(routeAllocator.chooseReason("lamp", "damaged"), "accepted"); + expectStep( + routeAllocator.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + expectStep(routeAllocator.deliverLocation(urls.method), "accepted"); + assert.equal( + routeAllocator.state.routeScope, + `scope-${BigInt(Number.MAX_SAFE_INTEGER) + 1n}`, + ); + + const outside = new ReturnDeskOracle(); + expectStep(outside.deliverLocation(urls.order), "accepted"); + expectStep( + outside.followOrderLink(), + "blocked(wrong-location)", + ); +}); + +check("audit regressions: removed current-domain line edits are invalid", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("lamp", 1), "accepted"); + expectStep(machine.chooseReason("lamp", "damaged"), "accepted"); + expectStep(machine.deliverOrder(order9WithoutLamp), "accepted"); + + expectStep( + machine.chooseQuantity("lamp", 1), + "invalid(unknown-line)", + ); + expectStep( + machine.chooseReason("lamp", "not-needed"), + "invalid(unknown-line)", + ); + + const noObservation = new ReturnDeskOracle(); + expectStep( + noObservation.chooseQuantity("lamp", 1), + "invalid(unknown-line)", + ); + expectStep( + noObservation.chooseReason("mug", "damaged"), + "invalid(unknown-line)", + ); +}); + +check("audit regressions: opaque IDs are own keys and canonically ordered", () => { + const composed = "é"; + const decomposed = "e\u0301"; + const line = (id) => ({ + id, + title: id, + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: id, + }); + const opaqueOrder = (ids) => ({ + id: "order-100", + revision: 1, + lines: ids.map(line), + allowedMethods: ["drop-off", "pickup"], + }); + + const duplicate = new ReturnDeskOracle(); + expectStep( + duplicate.deliverOrder( + opaqueOrder([composed, decomposed]), + ), + "accepted", + ); + expectStep( + duplicate.deliverOrder( + opaqueOrder([decomposed, composed]), + ), + "duplicate", + ); + + const emptyOpaqueId = new ReturnDeskOracle(); + expectStep( + emptyOpaqueId.deliverOrder(opaqueOrder([""])), + "accepted", + ); + assert.equal(emptyOpaqueId.state.order.lines[0].id, ""); + + const requestFor = (ids) => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(opaqueOrder(ids)), "accepted"); + for (const id of ids) { + expectStep(machine.chooseQuantity(id, 1), "accepted"); + expectStep(machine.chooseReason(id, "damaged"), "accepted"); + } + expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + expectStep(machine.deliverLocation(urls.method), "accepted"); + expectStep(machine.chooseMethod("drop-off"), "accepted"); + expectStep( + machine.goToStep("review"), + "accepted", + [nav("push", urls.review)], + ); + expectStep(machine.deliverLocation(urls.review), "accepted"); + const submitted = machine.submit(); + assert.equal(submitted.result, "accepted"); + return submitted.consequences; + }; + + assert.equal(requestFor([""])[0].snapshot.selections[0].lineId, ""); + + assert.deepEqual( + requestFor([composed, decomposed]), + requestFor([decomposed, composed]), + ); + + const malformedText = new ReturnDeskOracle(); + expectStep( + malformedText.deliverOrder(opaqueOrder(["\ud800"])), + "invalid(order)", + ); + + const prototypeIds = new ReturnDeskOracle(); + expectStep(prototypeIds.deliverLocation(urls.items), "accepted"); + expectStep( + prototypeIds.deliverOrder( + opaqueOrder(["constructor", "__proto__"]), + ), + "accepted", + ); + expectStep( + prototypeIds.chooseReason("constructor", "damaged"), + "invalid(unselected-line)", + ); + for (const id of ["constructor", "__proto__"]) { + expectStep(prototypeIds.chooseQuantity(id, 1), "accepted"); + expectStep(prototypeIds.chooseReason(id, "damaged"), "accepted"); + } + assert.deepEqual( + Object.keys(prototypeIds.state.draft.selections).sort(), + ["__proto__", "constructor"], + ); + const prototypePresentation = prototypeIds.presentation(); + assert.equal(prototypePresentation.itemsComplete, true); + for (const action of [ + prototypePresentation.actions.chooseQuantity, + prototypePresentation.actions.chooseReason, + prototypePresentation.actions.openPolicy, + ]) { + assert.equal(Object.hasOwn(action, "__proto__"), true); + assert.equal(typeof action.__proto__, "boolean"); + } +}); + +check("audit regressions: boundary records are closed own data", () => { + const inheritedLine = Object.create({ + id: "inherited", + title: "Inherited", + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: "Inherited", + }); + const inheritedOrder = { + id: "order-100", + revision: 1, + lines: [inheritedLine], + allowedMethods: ["drop-off"], + }; + const orderMachine = new ReturnDeskOracle(); + expectStep(orderMachine.deliverOrder(inheritedOrder), "invalid(order)"); + assert.equal(orderMachine.state.order, null); + + const extraLineOrder = { + id: "order-100", + revision: 1, + lines: [ + { + id: "extra", + title: "Extra", + purchasedQuantity: 1, + returnableQuantity: 1, + policySummary: "Extra", + helper: () => "not data", + }, + ], + allowedMethods: ["drop-off"], + }; + expectStep(orderMachine.deliverOrder(extraLineOrder), "invalid(order)"); + assert.equal(orderMachine.state.order, null); + + let revisionReads = 0; + const accessorOrder = { + id: "order-100", + lines: [...order7.lines], + allowedMethods: [...order7.allowedMethods], + }; + Object.defineProperty(accessorOrder, "revision", { + get() { + revisionReads += 1; + return 7; + }, + enumerable: true, + }); + expectStep(orderMachine.deliverOrder(accessorOrder), "invalid(order)"); + assert.equal(revisionReads, 0, "accessor is rejected before reading"); + + const symbolicOrder = { + id: "order-100", + revision: 7, + lines: [...order7.lines], + allowedMethods: [...order7.allowedMethods], + [Symbol("hidden")]: true, + }; + expectStep(orderMachine.deliverOrder(symbolicOrder), "invalid(order)"); + + let lineReads = 0; + const accessorLines = []; + Object.defineProperty(accessorLines, "0", { + get() { + lineReads += 1; + return order7.lines[0]; + }, + enumerable: true, + }); + accessorLines.length = 1; + expectStep( + orderMachine.deliverOrder({ + id: "order-100", + revision: 7, + lines: accessorLines, + allowedMethods: ["drop-off"], + }), + "invalid(order)", + ); + assert.equal(lineReads, 0, "array accessor is rejected before reading"); + + const reasonMachine = new ReturnDeskOracle(); + expectStep(reasonMachine.deliverLocation(urls.items), "accepted"); + expectStep(reasonMachine.deliverOrder(order7), "accepted"); + expectStep(reasonMachine.chooseQuantity("lamp", 1), "accepted"); + const inheritedReason = Object.create({ note: "inherited" }); + inheritedReason.kind = "other"; + inheritedReason.extra = true; + expectStep( + reasonMachine.chooseReason("lamp", inheritedReason), + "invalid(reason)", + ); + assert.equal( + reasonMachine.state.draft.selections.lamp.reason, + null, + ); + + let noteReads = 0; + const accessorReason = { kind: "other" }; + Object.defineProperty(accessorReason, "note", { + get() { + noteReads += 1; + return "hidden"; + }, + enumerable: true, + }); + expectStep( + reasonMachine.chooseReason("lamp", accessorReason), + "invalid(reason)", + ); + assert.equal(noteReads, 0, "reason accessor is rejected before reading"); +}); + +check("audit regressions: invalid restored states are rejected", () => { + const unavailableMachine = readyReview(); + expectStep( + unavailableMachine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep( + unavailableMachine.settle("request-1", unavailable), + "accepted", + ); + + const reused = unavailableMachine.snapshot(); + reused.settledRequests = []; + reused.nextRequest = 1; + assert.throws(() => new ReturnDeskOracle(reused)); + + const unknownPhase = unavailableMachine.snapshot(); + unknownPhase.phase = { kind: "nonsense" }; + assert.throws(() => new ReturnDeskOracle(unknownPhase)); + + const forgedReceiptAccess = new ReturnDeskOracle().snapshot(); + forgedReceiptAccess.receiptAccess = "offered"; + assert.throws(() => new ReturnDeskOracle(forgedReceiptAccess)); + + const extraState = new ReturnDeskOracle().snapshot(); + extraState.hidden = true; + assert.throws(() => new ReturnDeskOracle(extraState)); + + const arbitraryFreshNavigation = new ReturnDeskOracle().snapshot(); + arbitraryFreshNavigation.navigationIntent = { + kind: "user-push", + target: urls.order, + }; + assert.throws(() => new ReturnDeskOracle(arbitraryFreshNavigation)); + + const mismatchedRoute = new ReturnDeskOracle(); + expectStep( + mismatchedRoute.deliverLocation(urls.review), + "accepted", + [nav("replace", urls.items)], + ); + const contradictory = mismatchedRoute.snapshot(); + contradictory.location.url = urls.items; + assert.throws(() => new ReturnDeskOracle(contradictory)); + + const normalizing = new ReturnDeskOracle(); + expectStep( + normalizing.deliverLocation(urls.review), + "accepted", + [nav("replace", urls.items)], + ); + const missingRequiredIntent = normalizing.snapshot(); + missingRequiredIntent.navigationIntent = null; + assert.throws(() => new ReturnDeskOracle(missingRequiredIntent)); + + const activeFlow = new ReturnDeskOracle(); + expectStep(activeFlow.deliverLocation(urls.items), "accepted"); + const missingScope = activeFlow.snapshot(); + missingScope.routeScope = null; + missingScope.navigationIntent = { + kind: "required-replace", + target: urls.order, + }; + assert.throws(() => new ReturnDeskOracle(missingScope)); + + const pendingMachine = readyReview(); + expectStep( + pendingMachine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + const inconsistentPending = pendingMachine.snapshot(); + inconsistentPending.phase.snapshot.method = "pickup"; + assert.throws(() => new ReturnDeskOracle(inconsistentPending)); + + const zeroRequest = pendingMachine.snapshot(); + zeroRequest.phase.snapshot.requestId = "request-0"; + zeroRequest.nextRequest = 1; + assert.throws(() => new ReturnDeskOracle(zeroRequest)); + + const invalidDraftMethod = readyReview().snapshot(); + invalidDraftMethod.draft.method = "drone"; + assert.throws(() => new ReturnDeskOracle(invalidDraftMethod)); + + const extraDraftField = readyReview().snapshot(); + extraDraftField.draft.hidden = true; + assert.throws(() => new ReturnDeskOracle(extraDraftField)); + + const extraSelectionField = readyReview().snapshot(); + extraSelectionField.draft.selections.lamp.hidden = true; + assert.throws(() => new ReturnDeskOracle(extraSelectionField)); + + const ghostSelection = readyReview().snapshot(); + ghostSelection.draft.selections.ghost = { + quantity: 1, + reason: "damaged", + }; + assert.throws(() => new ReturnDeskOracle(ghostSelection)); + + const noncanonicalOrder = readyReview().snapshot(); + noncanonicalOrder.order.lines.reverse(); + assert.throws(() => new ReturnDeskOracle(noncanonicalOrder)); + + const observedFlowWithoutDraft = readyReview().snapshot(); + observedFlowWithoutDraft.draft = null; + assert.throws(() => new ReturnDeskOracle(observedFlowWithoutDraft)); + + const draftWithoutOrder = readyReview().snapshot(); + draftWithoutOrder.order = null; + assert.throws(() => new ReturnDeskOracle(draftWithoutOrder)); + + const surfaceMachine = new ReturnDeskOracle(); + expectStep(surfaceMachine.deliverLocation(urls.items), "accepted"); + expectStep(surfaceMachine.deliverOrder(order7), "accepted"); + expectStep(surfaceMachine.chooseQuantity("lamp", 1), "accepted"); + expectStep(surfaceMachine.chooseReason("lamp", "damaged"), "accepted"); + expectStep(surfaceMachine.openPolicy("lamp"), "accepted"); + + const surfaceOnMethod = surfaceMachine.snapshot(); + surfaceOnMethod.location = { + url: urls.method, + route: { kind: "flow", step: "method", rawStep: "method" }, + }; + assert.throws(() => new ReturnDeskOracle(surfaceOnMethod)); + + const malformedSurface = surfaceMachine.snapshot(); + malformedSurface.surface.id = "surface-garbage"; + assert.throws(() => new ReturnDeskOracle(malformedSurface)); + + const malformedScope = surfaceMachine.snapshot(); + malformedScope.routeScope = "scope-garbage"; + malformedScope.surface.ownerScope = "scope-garbage"; + assert.throws(() => new ReturnDeskOracle(malformedScope)); + + const skippedSurfaceAllocation = surfaceMachine.snapshot(); + skippedSurfaceAllocation.nextSurface = 99; + assert.throws(() => new ReturnDeskOracle(skippedSurfaceAllocation)); + + const skippedScopeAllocation = surfaceMachine.snapshot(); + skippedScopeAllocation.nextRouteScope = 99; + assert.throws(() => new ReturnDeskOracle(skippedScopeAllocation)); + + const refusedMachine = readyReview(); + expectStep( + refusedMachine.submit(), + "accepted", + [ + submitCommand( + pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ), + ), + ], + ); + expectStep( + refusedMachine.settle("request-1", refusal(8)), + "accepted", + ); + const nonNewerFence = refusedMachine.snapshot(); + nonNewerFence.phase.currentRevision = 7; + nonNewerFence.revisionFence = 7; + assert.throws(() => new ReturnDeskOracle(nonNewerFence)); + + const changedRefusedDraft = refusedMachine.snapshot(); + changedRefusedDraft.draft.selections = {}; + changedRefusedDraft.draft.method = null; + assert.throws(() => new ReturnDeskOracle(changedRefusedDraft)); + + const unavailableWithoutDraft = unavailableMachine.snapshot(); + unavailableWithoutDraft.draft = null; + assert.throws(() => new ReturnDeskOracle(unavailableWithoutDraft)); + + const changedUnavailableDraft = unavailableMachine.snapshot(); + changedUnavailableDraft.draft.selections.lamp.quantity = 2; + assert.throws(() => new ReturnDeskOracle(changedUnavailableDraft)); + + const twoSettlements = readyReview(); + const ledgerRequest1 = pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ); + expectStep( + twoSettlements.submit(), + "accepted", + [submitCommand(ledgerRequest1)], + ); + expectStep( + twoSettlements.settle("request-1", unavailable), + "accepted", + ); + const ledgerRequest2 = { + ...ledgerRequest1, + requestId: "request-2", + }; + expectStep( + twoSettlements.submit(), + "accepted", + [submitCommand(ledgerRequest2)], + ); + expectStep( + twoSettlements.settle("request-2", unavailable), + "accepted", + ); + + const reversedLedger = twoSettlements.snapshot(); + reversedLedger.settledRequests.reverse(); + assert.throws(() => new ReturnDeskOracle(reversedLedger)); + + const gappedLedger = twoSettlements.snapshot(); + gappedLedger.settledRequests[1] = "request-3"; + gappedLedger.phase.request.requestId = "request-3"; + gappedLedger.nextRequest = 4; + assert.throws(() => new ReturnDeskOracle(gappedLedger)); + + const nonCurrentSettlement = twoSettlements.snapshot(); + nonCurrentSettlement.phase.request = + unavailableMachine.snapshot().phase.request; + assert.throws(() => new ReturnDeskOracle(nonCurrentSettlement)); + + const incoherentRequestAllocator = new ReturnDeskOracle().snapshot(); + incoherentRequestAllocator.nextRequest = Number.MAX_SAFE_INTEGER; + assert.throws(() => new ReturnDeskOracle(incoherentRequestAllocator)); + + const completedWithoutOffer = completedAtReceipt().snapshot(); + completedWithoutOffer.location = { + url: urls.order, + route: { kind: "order" }, + }; + completedWithoutOffer.navigationIntent = null; + completedWithoutOffer.routeScope = null; + completedWithoutOffer.receiptAccess = "redirecting"; + assert.throws(() => new ReturnDeskOracle(completedWithoutOffer)); + + const checkpoint = unavailableMachine.checkpoint({ + scenario: "tamper-regression", + prefixDigest: "fixture", + }); + checkpoint.state.nextRequest = 1; + checkpoint.stateHash = digest(checkpoint.state); + assert.throws(() => ReturnDeskOracle.restore(checkpoint)); +}); + +check("audit regressions: opaque return IDs round-trip through receipt routes", () => { + const fresh = new ReturnDeskOracle(); + expectStep( + fresh.deliverLocation(urls.receipt("stranger")), + "invalid(location)", + ); + + const completed = completedAtReceipt(); + expectStep( + completed.deliverLocation(urls.receipt("stranger")), + "invalid(location)", + ); + + for (const opaqueReturnId of [ + "return/with/slash", + ".", + "..", + "~reserved-prefix", + ]) { + const machine = readyReview(); + const request = pendingSnapshot( + "request-1", + 7, + "lamp", + 1, + "damaged", + "drop-off", + ); + expectStep(machine.submit(), "accepted", [submitCommand(request)]); + + const receiptUrl = urls.receipt(opaqueReturnId); + assert.equal( + new URL(receiptUrl, "https://example.test").pathname, + receiptUrl, + `${opaqueReturnId}: browser path normalization is inert`, + ); + expectStep( + machine.settle("request-1", acceptance(opaqueReturnId)), + "accepted", + [nav("replace", receiptUrl)], + ); + const delivered = expectStep( + machine.deliverLocation(receiptUrl), + "accepted", + ); + assert.deepEqual(delivered.presentation.receipt, { + returnId: opaqueReturnId, + status: "completed", + }); + assert.equal(delivered.presentation.completionNotice, null); + } + assert.equal( + urls.receipt("return/with/slash"), + "/returns/return%2Fwith%2Fslash", + ); + assert.equal(urls.receipt(""), "/returns/~"); + assert.equal(urls.receipt("."), "/returns/~Lg"); + assert.equal(urls.receipt(".."), "/returns/~Li4"); + + const automaticRedirect = new ReturnDeskOracle(offeredReceiptPin); + expectStep( + automaticRedirect.deliverLocation(urls.items), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + expectStep( + automaticRedirect.followReceiptLink(), + "blocked(no-completion-notice)", + ); +}); + +check("static pins contain full semantic and lifecycle context", () => { + const requiredPins = { + waitingAtItems: canonicalPins.waitingAtItems, + incompleteItems: canonicalPins.incompleteItems, + completeItems: canonicalPins.completeItems, + methodSelection: canonicalPins.methodSelection, + review: canonicalPins.review, + openPolicySurface: canonicalPins.openPolicySurface, + pendingAfterBack: canonicalPins.pendingAfterBack, + sourceChangedConflict: canonicalPins.sourceChangedConflict, + domainRefusal: canonicalPins.domainRefusal, + retryableUnavailability: retryUnavailablePin, + completionNoticeOutsideFlow: offeredReceiptPin, + receipt: canonicalPins.receipt, + }; + + assert.equal(Object.keys(requiredPins).length, 12); + for (const [name, pin] of Object.entries(requiredPins)) { + assert.notEqual(pin, undefined, `${name}: pin exists`); + for (const key of [ + "location", + "order", + "draft", + "phase", + "revisionFence", + "completedReturn", + "receiptAccess", + "routeScope", + "nextRouteScope", + "surface", + "nextSurface", + "navigationIntent", + "nextRequest", + "settledRequests", + ]) { + assert.equal( + Object.hasOwn(pin, key), + true, + `${name}: complete context contains ${key}`, + ); + } + } + + assert.equal( + requiredPins.waitingAtItems.location.url, + urls.items, + ); + assert.equal(requiredPins.waitingAtItems.order, null); + assert.equal(requiredPins.waitingAtItems.draft, null); + assert.notEqual(requiredPins.waitingAtItems.routeScope, null); + + assert.deepEqual( + requiredPins.incompleteItems.draft.selections.lamp, + { quantity: 1, reason: null }, + ); + assert.deepEqual( + requiredPins.completeItems.draft.selections.lamp, + { quantity: 1, reason: "damaged" }, + ); + assert.equal( + requiredPins.methodSelection.draft.method, + "drop-off", + ); + assert.equal(requiredPins.methodSelection.location.url, urls.method); + assert.equal(requiredPins.review.location.url, urls.review); + + assert.equal( + requiredPins.openPolicySurface.surface.id, + "surface-1", + ); + assert.equal( + requiredPins.openPolicySurface.surface.ownerScope, + requiredPins.openPolicySurface.routeScope, + ); + + assert.equal( + requiredPins.pendingAfterBack.phase.snapshot.requestId, + "request-2", + ); + assert.equal(requiredPins.pendingAfterBack.location.url, urls.method); + assert.deepEqual( + requiredPins.pendingAfterBack.settledRequests, + ["request-1"], + ); + assert.equal(requiredPins.pendingAfterBack.navigationIntent, null); + + assert.equal( + requiredPins.sourceChangedConflict.order.revision, + 8, + ); + assert.equal( + requiredPins.sourceChangedConflict.draft.baseRevision, + 7, + ); + assert.deepEqual( + requiredPins.sourceChangedConflict.draft.selections.lamp, + { quantity: 1, reason: "damaged" }, + ); + + assert.equal(requiredPins.domainRefusal.order.revision, 7); + assert.equal(requiredPins.domainRefusal.revisionFence, 8); + assert.equal(requiredPins.domainRefusal.phase.kind, "refused"); + + assert.equal( + requiredPins.retryableUnavailability.phase.kind, + "unavailable", + ); + assert.equal( + requiredPins.retryableUnavailability.phase.request.requestId, + "request-1", + ); + + assert.equal( + requiredPins.completionNoticeOutsideFlow.location.url, + urls.order, + ); + assert.equal( + requiredPins.completionNoticeOutsideFlow.receiptAccess, + "offered", + ); + assert.deepEqual( + new ReturnDeskOracle( + requiredPins.completionNoticeOutsideFlow, + ).presentation().completionNotice, + { + returnId: "return-900", + receipt: urls.receipt("return-900"), + }, + ); + assert.equal( + requiredPins.completionNoticeOutsideFlow.draft, + null, + ); + + assert.equal( + requiredPins.receipt.location.url, + urls.receipt("return-900"), + ); + assert.equal(requiredPins.receipt.receiptAccess, "acknowledged"); + assert.equal(requiredPins.receipt.completedReturn, "return-900"); +}); + +check("checkpoint restore is silent and replay-equivalent", () => { + assert.notEqual(canonicalCheckpoint, undefined); + assert.deepEqual(canonicalCheckpoint.provenance, { + scenario: "canonical", + afterStep: 28, + prefixDigest: canonicalPrefixDigest, + }); + assert.match(canonicalCheckpoint.provenance.prefixDigest, /^[0-9a-f]{64}$/); + assert.match(canonicalCheckpoint.stateHash, /^[0-9a-f]{64}$/); + assert.equal( + canonicalCheckpoint.state.phase.snapshot.requestId, + "request-2", + ); + assert.deepEqual(canonicalCheckpoint.state.settledRequests, ["request-1"]); + assert.equal(canonicalCheckpoint.state.nextRequest, 3); + assert.equal(canonicalCheckpoint.state.nextSurface, 2); + assert.equal(canonicalCheckpoint.state.navigationIntent, null); + assert.equal(canonicalCheckpoint.state.surface, null); + + const first = ReturnDeskOracle.restore(canonicalCheckpoint); + const second = ReturnDeskOracle.restore(canonicalCheckpoint); + assert.deepEqual(first.snapshot(), canonicalCheckpoint.state); + assert.deepEqual(second.snapshot(), canonicalCheckpoint.state); + + const firstAcceptance = expectStep( + first.settle("request-2", acceptance("return-900")), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + const firstLocation = expectStep( + first.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + const secondAcceptance = expectStep( + second.settle("request-2", acceptance("return-900")), + "accepted", + [nav("replace", urls.receipt("return-900"))], + ); + const secondLocation = expectStep( + second.deliverLocation(urls.receipt("return-900")), + "accepted", + ); + + assertSame(firstAcceptance, secondAcceptance, "acceptance replay"); + assertSame(firstLocation, secondLocation, "location replay"); + assertSame(first.snapshot(), second.snapshot(), "restored final state"); + assertSame(first.presentation(), second.presentation(), "restored presentation"); +}); + +let c1IncompletePin; +check("C1: other(note) has valid empty intermediate and tagged snapshot", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("mug", 1), "accepted"); + expectStep(machine.chooseReason("mug", other("")), "accepted"); + assert.equal(machine.presentation().itemsComplete, false); + c1IncompletePin = machine.snapshot(); + + expectStep( + machine.chooseReason("mug", other("Does not fit the room")), + "accepted", + ); + assert.equal(machine.presentation().itemsComplete, true); + expectStep( + machine.goToStep("method"), + "accepted", + [nav("push", urls.method)], + ); + expectStep(machine.deliverLocation(urls.method), "accepted"); + expectStep(machine.chooseMethod("pickup"), "accepted"); + expectStep( + machine.goToStep("review"), + "accepted", + [nav("push", urls.review)], + ); + expectStep(machine.deliverLocation(urls.review), "accepted"); + + const expected = pendingSnapshot( + "request-1", + 7, + "mug", + 1, + other("Does not fit the room"), + "pickup", + ); + expectStep(machine.submit(), "accepted", [submitCommand(expected)]); + assert.deepEqual( + machine.state.phase.snapshot.selections[0].reason, + other("Does not fit the room"), + ); +}); + +check("C1: replacing other(note) with a base reason retains no hidden note", () => { + const machine = new ReturnDeskOracle(); + expectStep(machine.deliverLocation(urls.items), "accepted"); + expectStep(machine.deliverOrder(order7), "accepted"); + expectStep(machine.chooseQuantity("mug", 1), "accepted"); + expectStep(machine.chooseReason("mug", other("private note")), "accepted"); + expectStep(machine.chooseReason("mug", "damaged"), "accepted"); + assert.equal(machine.state.draft.selections.mug.reason, "damaged"); + assert.equal( + JSON.stringify(machine.state.draft.selections.mug).includes("private note"), + false, + ); + assert.notEqual(c1IncompletePin, undefined); +}); + +console.log( + `PASS ${checks.length} validation groups: canonical 0..31, 15 adversarial scenarios, 12 static pins, checkpoint replay, and C1.`, +); diff --git a/examples/programs/answers/uhura-0.3/README.md b/examples/programs/answers/uhura-0.3/README.md new file mode 100644 index 0000000..ed26877 --- /dev/null +++ b/examples/programs/answers/uhura-0.3/README.md @@ -0,0 +1,24 @@ +# Uhura 0.3 answer to L0–L2 + +- **Status:** Executable Uhura 0.3 answer sheet +- **Historical machine-model record:** [Relay B3](../../../../docs/spec/drafts/relay-b3/) +- **Problem authority:** [L0–L2 program harnesses](../../) + +[programs.uhura](programs.uhura) contains the complete Uhura 0.3 answer +for: + +- L0 Bounded Counter; +- L1 River Crossing; and +- L2 Keyed Task Supervisor. + +The Markdown problems remain authoritative. The single canonical Uhura engine +parses, checks, lowers, formats conservatively, and executes this exact source +against the L0–L2 conformance suites. The historical Relay B3 record explains +the design experiment that preceded this implementation; it is not another +runtime. A passing answer must still not be used to weaken a problem. + +This answer deliberately declares no `use evidence` module: its frozen +exhaustive and adversarial cases remain implementation conformance tests, not +part of the authoring-size comparison. Consequently `uhura trace` rejects this +project with “no evidence scenarios” instead of silently selecting a different +runtime or test script. diff --git a/examples/programs/answers/uhura-0.3/programs.uhura b/examples/programs/answers/uhura-0.3/programs.uhura new file mode 100644 index 0000000..25eedcd --- /dev/null +++ b/examples/programs/answers/uhura-0.3/programs.uhura @@ -0,0 +1,439 @@ +language uhura 0.3 +module examples.programs.uhura_0_3@1 + + +machine BoundedCounter { + config { + minimum: Int + maximum: Int + initial: Int + } + + require minimum <= initial and initial <= maximum + + input = + | increment + | decrement + | reset + + command = Never + + outcome = + | accepted commit + + state { + count: Int = initial + } + + invariant minimum <= count and count <= maximum + + observe { + count = count + at_minimum = count == minimum + at_maximum = count == maximum + } + + on increment { + set count = min(count + 1, maximum) + finish accepted + } + + on decrement { + set count = max(count - 1, minimum) + finish accepted + } + + on reset { + set count = initial + finish accepted + } +} + + +machine RiverCrossing { + type Side = left | right + type Entity = farmer | wolf | goat | cabbage + type Cargo = wolf | goat | cabbage + type Violation = wolf_with_goat | goat_with_cabbage + type Status = in_progress | solved + + type Crossing = { + passenger: Option, + departure: Side, + arrival: Side, + } + + type Refusal = + | passenger_not_with_farmer(Cargo) + | unsafe(NonEmpty) + + input = + | cross(passenger: Option) + + command = Never + + outcome = + | accepted(Crossing) commit + | refused(Refusal) abort + + state { + positions: Table = { + farmer: left, + wolf: left, + goat: left, + cabbage: left, + } + } + + fn entity(cargo: Cargo) -> Entity = + match cargo { + wolf => wolf + goat => goat + cabbage => cabbage + } + + fn opposite(side: Side) -> Side = + match side { + left => right + right => left + } + + fn violations(at: Table) -> Seq = + collect [ + when at[wolf] == at[goat] and at[farmer] != at[wolf] + => wolf_with_goat + when at[goat] == at[cabbage] and at[farmer] != at[goat] + => goat_with_cabbage + ] + + invariant violations(positions).is_empty + + observe { + positions = positions + status: Status = + if positions.values.all(side => side == right) + then solved + else in_progress + } + + on cross(passenger) { + let departure = positions[farmer] + + if passenger is some(cargo) + and positions[entity(cargo)] != departure + { + finish refused(passenger_not_with_farmer(cargo)) + } + + let arrival = opposite(departure) + let farmer_moved = positions.set(farmer, arrival) + let candidate = + match passenger { + none => farmer_moved + some(cargo) => farmer_moved.set(entity(cargo), arrival) + } + + match NonEmpty.from(violations(candidate)) { + some(harms) => + finish refused(unsafe(harms)) + + none => { + set positions = candidate + finish accepted({ + passenger: passenger, + departure: departure, + arrival: arrival, + }) + } + } + } +} + + +machine KeyedTaskSupervisor { + const limit: Nat = 2 + + key TaskId over Text + + type Terminal = success | failure + + type Phase = + | queued + | running(attempt: PositiveInt, progress: Ratio) + | succeeded + | failed + | cancelled + + type Task = { + phase: Phase, + started: Nat, + } + + input = + | submit(task: TaskId) + | cancel(task: TaskId) + | retry(task: TaskId) + | progress(task: TaskId, attempt: Int, value: BoundaryNumber) + | succeed(task: TaskId, attempt: Int) + | fail(task: TaskId, attempt: Int) + + command = + | start(task: TaskId, attempt: PositiveInt) + | cancel(task: TaskId, attempt: PositiveInt) + + outcome = + | accepted commit + | duplicate abort + | stale abort + | invalid abort + + state { + tasks: Map = Map.empty + queue: Seq = [] + } + + derive running_count: Nat = + tasks.values.count(task => task.phase is running(_, _)) + + invariant { + running_count <= limit, + queue.unique, + queue.all(id => tasks.get(id) is some({ phase: queued, ... })), + tasks.entries.all((id, task) => + (task.phase is queued) == queue.contains(id) + ), + queue.is_empty or running_count == limit, + tasks.values.all(task => + match task.phase { + running(attempt, _) => + task.started == attempt + _ => true + } + ), + } + + observe { + tasks = tasks + queue = queue + running: Set<{ + task: TaskId, + attempt: PositiveInt, + progress: Ratio, + }> = Set { + for (id, task) in tasks.entries + when task.phase is running(attempt, progress) + yield { task: id, attempt: attempt, progress: progress } + } + available_capacity: Nat = limit - running_count + } + + transition resolve_terminal( + id: TaskId, + attempt: Int, + terminal: Terminal, + ) { + if attempt <= 0 { + finish invalid + } + + let task = + match tasks.get(id) { + none => finish invalid + some(task) => task + } + + if attempt > task.started { + finish invalid + } + + if attempt < task.started { + finish stale + } + + match task.phase { + running(current_attempt, _) => { + if current_attempt != attempt { + unreachable + } + + let phase: Phase = + match terminal { + success => succeeded + failure => failed + } + + set tasks = tasks.put(id, task with { phase: phase }) + finish accepted + } + + succeeded => { + if terminal == success { + finish duplicate + } + finish stale + } + + failed => { + if terminal == failure { + finish duplicate + } + finish stale + } + + queued | cancelled => + finish stale + } + } + + on submit(id) { + match tasks.get(id) { + some(_) => + finish invalid + + none => { + set tasks = tasks.put(id, { + phase: queued, + started: 0, + }) + set queue = queue.append(id) + finish accepted + } + } + } + + on cancel(id) { + match tasks.get(id) { + none => + finish invalid + + some(task) => + match task.phase { + queued => { + set queue = queue.without(id) + set tasks = tasks.put( + id, + task with { phase: cancelled }, + ) + finish accepted + } + + running(attempt, _) => { + set tasks = tasks.put( + id, + task with { phase: cancelled }, + ) + emit cancel(id, attempt) + finish accepted + } + + cancelled => + finish duplicate + + succeeded | failed => + finish invalid + } + } + } + + on retry(id) { + match tasks.get(id) { + none => + finish invalid + + some(task) => + match task.phase { + failed | cancelled => { + set tasks = tasks.put( + id, + task with { phase: queued }, + ) + set queue = queue.append(id) + finish accepted + } + + queued | running(_, _) | succeeded => + finish invalid + } + } + } + + on progress(id, attempt, value) { + if attempt <= 0 { + finish invalid + } + + let next = + match Ratio.from(value) { + none => finish invalid + some(progress) => progress + } + + let task = + match tasks.get(id) { + none => finish invalid + some(task) => task + } + + if attempt > task.started { + finish invalid + } + + if attempt < task.started { + finish stale + } + + match task.phase { + running(current_attempt, current) => { + if current_attempt != attempt { + unreachable + } + if next < current { + finish stale + } + if next == current { + finish duplicate + } + + set tasks = tasks.put( + id, + task with { phase: running(attempt, next) }, + ) + finish accepted + } + + queued | succeeded | failed | cancelled => + finish stale + } + } + + on succeed(id, attempt) = + resolve_terminal(id, attempt, success) + + on fail(id, attempt) = + resolve_terminal(id, attempt, failure) + + before commit { + while running_count < limit + and queue.uncons is some({ head: id, tail: rest }) + decreases queue.size + { + let task = + match tasks.get(id) { + none => unreachable + some(task) => task + } + + let attempt: PositiveInt = task.started + 1 + + set queue = rest + set tasks = tasks.put( + id, + task with { + phase: running(attempt, 0), + started: attempt, + }, + ) + emit start(id, attempt) + } + } +} From ce28824dcde90413c40dfbf8d777b06260c3865e Mon Sep 17 00:00:00 2001 From: Universe Date: Mon, 20 Jul 2026 17:53:12 +0900 Subject: [PATCH 02/14] docs(language): define the Uhura 0.4 replacement --- docs/doctrine/mission.md | 21 +- docs/rfcs/0001-project-foundation.md | 3 + ...03-source-comments-docs-and-annotations.md | 272 ++- ...one-machine-core-and-source-composition.md | 129 ++ docs/rfcs/README.md | 1 + docs/spec/drafts/0.4/README.md | 174 ++ docs/spec/drafts/0.4/acquisition/README.md | 118 ++ .../0.4/acquisition/arms/rust/examples.uhura | 55 + .../0.4/acquisition/arms/rust/reference.md | 336 ++++ .../arms/rust/scaffolds/02-l1.uhura | 82 + .../arms/rust/scaffolds/03-l2.uhura | 45 + .../arms/rust/scaffolds/04-false-friends.md | 113 ++ .../arms/rust/scaffolds/05-a0.uhura | 53 + .../acquisition/arms/typescript/examples.ts | 81 + .../acquisition/arms/typescript/reference.md | 184 ++ .../arms/typescript/scaffolds/02-l1.ts | 92 + .../arms/typescript/scaffolds/03-l2.ts | 49 + .../typescript/scaffolds/04-false-friends.md | 106 ++ .../arms/typescript/scaffolds/05-a0.ts | 66 + .../0.4/acquisition/common/response-format.md | 73 + .../acquisition/common/semantic-overview.md | 103 + .../acquisition/oracles/comprehension.json | 45 + .../acquisition/oracles/false-friends.json | 109 ++ .../acquisition/oracles/semantic-rubric.json | 196 ++ .../spec/drafts/0.4/acquisition/protocol.json | 104 + .../drafts/0.4/acquisition/results/README.md | 76 + docs/spec/drafts/0.4/acquisition/run.mjs | 1015 ++++++++++ .../0.4/acquisition/tasks/00-comprehension.md | 39 + .../0.4/acquisition/tasks/01-l0-author.md | 70 + .../0.4/acquisition/tasks/02-l1-transfer.md | 35 + .../0.4/acquisition/tasks/03-l2-transfer.md | 28 + .../0.4/acquisition/tasks/04-false-friends.md | 22 + .../tasks/05-a0-change-rehearsal.md | 27 + docs/spec/drafts/0.4/application.md | 439 +++++ docs/spec/drafts/0.4/conformance.md | 446 +++++ docs/spec/drafts/0.4/grammar.ebnf | 411 ++++ docs/spec/drafts/0.4/kernel.md | 506 +++++ docs/spec/drafts/0.4/project.md | 686 +++++++ docs/spec/drafts/0.4/source.md | 1693 +++++++++++++++++ docs/spec/drafts/v0.md | 631 +----- 40 files changed, 8007 insertions(+), 727 deletions(-) create mode 100644 docs/rfcs/0004-standalone-machine-core-and-source-composition.md create mode 100644 docs/spec/drafts/0.4/README.md create mode 100644 docs/spec/drafts/0.4/acquisition/README.md create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/examples.uhura create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/reference.md create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/scaffolds/02-l1.uhura create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/scaffolds/03-l2.uhura create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/scaffolds/04-false-friends.md create mode 100644 docs/spec/drafts/0.4/acquisition/arms/rust/scaffolds/05-a0.uhura create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/examples.ts create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/reference.md create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/scaffolds/02-l1.ts create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/scaffolds/03-l2.ts create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/scaffolds/04-false-friends.md create mode 100644 docs/spec/drafts/0.4/acquisition/arms/typescript/scaffolds/05-a0.ts create mode 100644 docs/spec/drafts/0.4/acquisition/common/response-format.md create mode 100644 docs/spec/drafts/0.4/acquisition/common/semantic-overview.md create mode 100644 docs/spec/drafts/0.4/acquisition/oracles/comprehension.json create mode 100644 docs/spec/drafts/0.4/acquisition/oracles/false-friends.json create mode 100644 docs/spec/drafts/0.4/acquisition/oracles/semantic-rubric.json create mode 100644 docs/spec/drafts/0.4/acquisition/protocol.json create mode 100644 docs/spec/drafts/0.4/acquisition/results/README.md create mode 100644 docs/spec/drafts/0.4/acquisition/run.mjs create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/00-comprehension.md create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/01-l0-author.md create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/02-l1-transfer.md create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/03-l2-transfer.md create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/04-false-friends.md create mode 100644 docs/spec/drafts/0.4/acquisition/tasks/05-a0-change-rehearsal.md create mode 100644 docs/spec/drafts/0.4/application.md create mode 100644 docs/spec/drafts/0.4/conformance.md create mode 100644 docs/spec/drafts/0.4/grammar.ebnf create mode 100644 docs/spec/drafts/0.4/kernel.md create mode 100644 docs/spec/drafts/0.4/project.md create mode 100644 docs/spec/drafts/0.4/source.md diff --git a/docs/doctrine/mission.md b/docs/doctrine/mission.md index f048c35..e2916fa 100644 --- a/docs/doctrine/mission.md +++ b/docs/doctrine/mission.md @@ -9,8 +9,15 @@ ## Thesis -Uhura is a frontend-dedicated, user-facing builder language for describing -interactive experiences. +Uhura is a frontend-dedicated, user-facing builder system built on a +standalone deterministic state-machine language. + +The product is optimized for authoring Web interfaces. The core language does +not require presentation, a renderer, or a widget catalogue in order to define +and execute a complete program. Web UI remains Uhura's first-class application +domain through an explicit profile. This distinction is fixed by +[RFC 0004](../rfcs/0004-standalone-machine-core-and-source-composition.md): +product focus and core-language dependency are different design decisions. Uhura's durable design hypothesis is that an interactive experience can be understood through explicit state, causes, transitions, declared boundary @@ -38,8 +45,9 @@ transition. A named version must supply the exact operational model; doctrine requires that the model be explicit, deterministic over its declared inputs, checkable, and honest about external nondeterminism. -Presentation is computationally downstream of experience state and declared -inputs. It matters enormously to the product, but it is not an independent +When a program includes presentation, it is computationally downstream of +experience state and declared inputs. It matters enormously to the product, +but it is neither a prerequisite of the core language nor an independent behavior authority. A version may change its view syntax or rendering protocol without changing this separation. @@ -62,8 +70,9 @@ Uhura makes five connected bets: 1. An explicit behavioral model makes interface behavior more checkable, replayable, portable, and understandable. -2. A closed frontend semantic model may express recurring intent more - compactly and checkably than a general-purpose language plus libraries. +2. A closed machine model plus an explicit frontend profile may express + recurring intent more compactly and checkably than a general-purpose + language plus libraries. 3. Declarative presentation can remain independent of DOM, native-widget, and canvas object models. 4. Good defaults can make the shortest program accessible and operationally diff --git a/docs/rfcs/0001-project-foundation.md b/docs/rfcs/0001-project-foundation.md index 6f64785..62764d0 100644 --- a/docs/rfcs/0001-project-foundation.md +++ b/docs/rfcs/0001-project-foundation.md @@ -4,6 +4,9 @@ - **Scope:** Project identity, responsibility boundaries, repository posture, and the minimum semantic model - **Supersedes:** None +- **Superseded in part by:** + [RFC 0004](0004-standalone-machine-core-and-source-composition.md), which + makes Web UI an explicit profile over a standalone machine core - **Related work:** Spock language/runtime; prior art in XAML, Svelte, QML, and Elm diff --git a/docs/rfcs/0003-source-comments-docs-and-annotations.md b/docs/rfcs/0003-source-comments-docs-and-annotations.md index 3eba3ea..d36926f 100644 --- a/docs/rfcs/0003-source-comments-docs-and-annotations.md +++ b/docs/rfcs/0003-source-comments-docs-and-annotations.md @@ -8,6 +8,8 @@ - **Supersedes:** None - **Related work:** [RFC 0001](0001-project-foundation.md), [Spock RFD 0016](https://github.com/gridaco/spock/blob/main/docs/rfd/0016-doc-comments.md) +- **0.4 reconciliation:** [Core source and lowering](../spec/drafts/0.4/source.md), + [normative grammar](../spec/drafts/0.4/grammar.ebnf) ## 1. Proposal @@ -29,26 +31,38 @@ The forms are: For example: ```uhura -//! Feed page source module. -/// The feed experience and its session-local state. -page +//! Feed application source module. +use uhura::ui; + +/// The feed's deterministic state and input contract. +pub machine Feed { + events { + RetryReload, + } + + outcomes { + commit Accepted, + } -/// State owned by this page rather than the backend. -store { state { /// Whether a reload command is unsettled. - reload-pending: bool = false + reload_pending: Bool = false, } // The guard prevents duplicate commands. - on retry-reload-tapped() when !reload-pending { - set reload-pending = true - send reload() + on RetryReload { + if !reload_pending { + reload_pending = true; + } + Accepted } } +/// The Web projection of Feed observation. +pub ui FeedWeb for Feed(view) { - +} ``` Declaration docs and markup annotations are different metadata classes even @@ -73,14 +87,41 @@ Docs and annotations are checked **authoring metadata**. They do not enter canonical runtime IR or semantic view data and do not change evaluation. Ordinary comments never enter authoring metadata. +### 1.1 0.4 reconciliation and history + +RFC 0003 was accepted against Uhura's 0.3 page/component/store surface. The +metadata taxonomy, lexical sigils, normalization, forward attachment, +semantic-inertness, diagnostics, and separate authoring projection remain the +accepted decision. Uhura 0.4 replaces only the active `.uhura` target +vocabulary and comment-bearing boundaries with those of its machine/part +grammar and activated `ui` profile. + +The historical mapping is explicit: + +| Accepted 0.3 target | Active 0.4 treatment | +| --- | --- | +| `component` / `page` / `surface` header | `machine`, `part`, or activated `ui` declaration, according to what is declared | +| `props` and route parameters | UI-profile declaration parameters when that profile closes its reusable-component grammar | +| `emits` event and payload | Machine/part `events` entry and protocol payload parameter | +| `store` scope | Removed; state belongs directly to a machine or part | +| Store state field | Machine/part state field | +| Event/outcome handler | Machine/part `on` handler | +| `{#match}` block | Removed; 0.4 uses core `match` expressions and does not annotate an expression as a markup occurrence | + +Git history preserves the original spelling and rationale; this reconciliation +does not pretend those forms were always 0.4 forms. `.examples.uhura` remains +a separately versioned evidence language. Its file and example documentation +rules below are retained until that evidence grammar receives its own +replacement RFC. + ## 2. Motivation Uhura source serves two different explanatory needs. -A declaration has a durable contract. A component, prop, state field, handler, -or parameter benefits from documentation that follows it through checking and -extraction. Spock's `///`/`//!` taxonomy already gives this kind of prose a -small, deterministic source form. +A declaration has a durable contract. A machine, part, nominal type, state or +observation field, handler, update, or parameter benefits from documentation +that follows it through checking and extraction. Spock's `///`/`//!` taxonomy +already gives this kind of prose a small, deterministic source form. A markup element is different. It is one occurrence in an implementation, often repeated or conditionally present. Treating every local note as the @@ -153,9 +194,9 @@ This RFC does not define: interpolation runs, match arms, or CSS rules; or - a wire filename or JSON encoding for authoring metadata. -Uhura currently has no local `struct` or record declaration. Port record, -union, and enum definitions belong to `ports/*.port.toml`; documenting those -types requires a separate port-contract decision and is not implied here. +Port-contract declarations outside `.uhura`, catalog schemas, and their +documentation remain separate decisions. Core 0.4 `struct`, `enum`, and `key` +declarations are local source targets and are covered here. ## 4. Terminology and invariants @@ -194,9 +235,9 @@ The following invariants are mandatory: expressions, or toward any bounded construct count. 4. Adding, removing, reordering, or editing valid metadata may change source revision, source spans, and authoring-metadata output, but not canonical - `ProgramIr`, view hashes, `step-u`, commands, intents, traces, or runtime - diagnostic codes, messages, and semantic outcomes. Diagnostic source - locations may shift with the surrounding text. + program IR, program hashes, checkpoints, observations, receipts, commands, + traces, or runtime diagnostic codes, messages, and semantic outcomes. + Diagnostic source locations may shift with the surrounding text. 5. Annotation order is target-local source order and deterministic. 6. Doc and annotation bodies are inert UTF-8 text. Braces, tags, backticks, and `@` within a payload have no nested language meaning. @@ -206,9 +247,9 @@ The following invariants are mandatory: ### 5.1 Lexical classification In every DSL lexer region, the lexer classifies line comments exactly as Spock -does. This includes the header, store, examples source, and DSL streams inside -markup interpolation, braced attribute values, event bindings, arguments, and -structural block heads: +does. This includes core module and machine/part source, examples source, and +DSL streams inside activated UI interpolation, braced attribute values, event +bindings, arguments, and structural block heads: - `//!` is an inner file doc; - exactly `///`, when not followed by a fourth `/`, is an outer doc; @@ -240,21 +281,23 @@ The comment-bearing DSL boundaries are closed: | Containing context | A comment may occur immediately before | |---|---| -| Module preamble/body | the `component`/`page`/`surface` header; a complete top-level `use` declaration; a `props` or `emits` grouping head; a route `param`; `store`; the DSL-to-markup/style transition; EOF | -| `props` body | a prop; `}` | -| `emits` body | an emitted event; `}` | -| Emitted-event or handler parameter list | the first parameter; a later parameter after the preceding comma; `)` | -| `store` body | the `state` grouping head; a handler; `}` | -| `state` body | a state field; `}` | -| Handler body | a complete statement; `}` | +| Module preamble/body | a complete `use`/`pub use`; a complete top-level declaration; an activated DSL-to-markup/style transition; EOF | +| Machine or part body | a complete member declaration or grouping head; `}` | +| `struct` body | a field after the preceding comma; `}` | +| `enum` body or record-variant body | a variant or field after the preceding comma; `}` | +| Parameter or protocol-payload list | the first parameter; a later parameter after the preceding comma; `)` | +| `config`, `events`, `commands`, `outcomes`, `requires outcomes`, `state`, `observe`, or invariant body | an entry after the preceding comma; `}` | +| Function, handler, update, reconciliation, `if`, or loop body | a complete statement or final expression; `}` | +| `match` body | a complete arm after the preceding comma; `}` | | Examples module | a complete top-level `use` declaration; a named example; EOF | | Example body | a complete example clause; `}` | -The items inside `use port name { … }`, argument lists, example-clause -sub-lists, types, expressions, guards, event bindings, and the interior token -sequence of any declaration, parameter, statement, or other complete item are -not comment-bearing boundaries. A comment also may not occur between a -parameter and its separating comma. Such placement receives the existing +Import braces, part/port/constructor/ordinary call arguments, tuple and +collection literals, struct constructions and patterns, example-clause +sub-lists, types, expressions, conditions, event bindings, and the interior +token sequence of any declaration, parameter, statement, arm, or other +complete item are not comment-bearing boundaries. A comment also may not occur +between an item and its separating comma. Such placement receives the existing `UH0001 syntax/unexpected-token`, with a repair that moves it to the nearest owning boundary. @@ -269,13 +312,14 @@ there. `// @kind …` follows these same rules and remains ordinary in DSL mode. ### 5.3 File docs `//!` is legal only in the file preamble. The preamble ends at the first -non-comment syntactic item: the component/page/surface header in `.uhura`, or -the first `use`/`example` item in `.examples.uhura`. Whitespace, ordinary -comments, and other `//!` lines may coexist before that item. +non-comment syntactic item: the first `use`, `pub use`, top-level declaration, +or activated-profile declaration in `.uhura`, or the first `use`/`example` +item in `.examples.uhura`. Whitespace, ordinary comments, and other `//!` +lines may coexist before that item. In a `.uhura` file, `//!` documents the source module. In an `.examples.uhura` file, it documents the examples source module. It does not -replace `///` documentation for the component, page, surface, or example +replace `///` documentation for a machine, part, type, value, `ui`, or example declared inside that file. A non-empty `//!` after the preamble is @@ -314,46 +358,50 @@ The documentable target table is closed: | Target | Doc form | |---|---| | Source module | `//!` in the preamble | -| `component`, `page`, or `surface` declaration | `///` before the header | -| Prop declaration | `///` before the prop | -| Emitted-event declaration | `///` before the emit | -| Emitted-event payload parameter | `///` before the parameter | -| Route parameter declaration | `///` before the parameter | -| `store` scope | `///` before `store` | +| `machine`, `part`, activated `ui`, `struct`, `enum`, `key`, `const`, or `fn` declaration | `///` before the declaration | +| Part parameter or function parameter | `///` before the parameter | +| Configuration field | `///` before the field | +| Struct field, enum variant, or enum-variant field | `///` before the field or variant | +| Event, command, outcome, or required-outcome entry | `///` before the entry | +| Protocol payload parameter | `///` before the parameter | +| Part composition or port declaration | `///` before `part` or `port` | | State field | `///` before the field | -| Event or outcome handler | `///` before `on` | -| Handler parameter | `///` before the parameter | +| `computed` declaration or observation field | `///` before the member or field | +| Event handler | `///` before `on` | +| `update` declaration or update parameter | `///` before the update or parameter | +| Root reconciliation block | `///` before `before commit` | | Named example declaration | `///` before `example` | -Imports, port-import items, grouping sections, statements, expressions, -example clauses, markup occurrences, style blocks, and CSS are not -documentable. +Imports, grouping sections, requirements, invariants, handler pattern binders, +statements, expressions, match arms, example clauses, markup occurrences, +style blocks, and CSS are not documentable. Parameter docs use the existing comma-delimited parameter list. When any parameter has docs or an ordinary comment, the canonical list is multiline: ```uhura -emits { - like-toggled( +events { + LikeToggled( /// The post whose state changed. - post: id, + post: PostId, /// The requested presentation state. - now-liked: bool - ) + now_liked: Bool, + ), } -on like-toggled( +update record_toggle( /// The post whose state changed. - post: id, + post: PostId, /// The requested presentation state. - now-liked: bool + now_liked: Bool, ) { - // … + // ... } ``` -A doc before `)` with no parameter is dangling. Outcome-handler parameters -without written types are documentable by the same rule. +A doc before `)` with no parameter is dangling. Handler pattern binders are +not independently documentable; document their declared protocol payload +parameters instead. ### 5.5 Doc text normalization @@ -472,7 +520,7 @@ Metadata never crosses any of these boundaries: - a parameter-list open or close; - transition from the DSL region into markup; - ``; -- `{:else}`, `{:when}`, or a block close; or +- `{:else}` or a block close; or - transition from markup into `` inner text, verbatim. - pub raw: String, - pub span: Span, +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum ScenarioOrigin { + Machine { + machine: Name, + configuration: Option, + }, + Snapshot(EvidenceRef), } -#[derive(Debug)] -pub struct StyleRule { - /// Selector text, verbatim (normalized whitespace). - pub selector: String, - /// Class names referenced by the selector, for rooting/existence checks. - pub classes: Vec, - /// Declaration block, verbatim, without the outer braces. - pub decls: String, - pub span: Span, +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EvidenceRef { + pub path: Vec, + pub span: SourceSpan, } -// ── examples files (design §6.1) ──────────────────────────────────────────── +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct EvidenceAliasDecl { + pub name: Name, + pub presentation: Option, + pub kind: Option, + pub is_default: bool, + pub note: Option, + pub target: EvidenceRef, +} -#[derive(Debug)] -pub struct ExamplesFile { - pub preamble: DslTrivia, - pub uses: Vec, - pub examples: Vec, - pub trailing: DslTrivia, +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EvidencePresentationKind { + Page, + Component, + Surface, } -#[derive(Debug)] -pub struct ExampleDecl { - pub name: String, - pub is_default: bool, - pub clauses: Vec, - /// Parallel to `clauses`; keeps ordinary comments and rejected docs at - /// the legal clause boundary without making them semantic clauses. - pub clause_leading: Vec, - pub trailing: DslTrivia, - pub span: Span, - pub leading: DslTrivia, -} - -#[derive(Debug)] -pub enum ExampleClause { - From { - name: String, - span: Span, - }, - Note { - text: String, - span: Span, - }, - /// `params { user = "…" }` (pages with dynamic segments). - Params { - entries: Vec<(String, Expr)>, - span: Span, - }, - /// `props { post = fixture.posts.x, … }` (components/surfaces). - Props { - entries: Vec<(String, Expr)>, - span: Span, +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum EvidenceStepKind { + Bind { + port: Name, + fixture: Expr, }, - /// `state { field = expr }` — literal state pin. - State { - entries: Vec<(String, Expr)>, - span: Span, + Start, + Send(Expr), + Deliver(Expr), + ExpectReaction { + outcome: Pattern, + commands: Vec, }, - /// `projection feed.feed-page = fixture.feed.page-1` - /// `projection comments.for-post("post-1") = fixture.comments.x` - Projection(ProjectionPin), - /// `events [ … ]` — the derivation timeline. - Events { - entries: Vec, - span: Span, + ExpectObservationPattern(Pattern), + ExpectInspectionPattern(Pattern), + ExpectObservationWhere(Expr), + ExpectRestore { + commands: Vec, }, - Error { - span: Span, + ExpectSnapshot { + target: EvidenceRef, }, + Pin(Name), } -#[derive(Debug)] -pub struct ProjectionPin { - pub port: String, - pub projection: String, - pub key: Option, - pub value: Expr, - pub span: Span, -} - -#[derive(Debug)] -pub enum ExampleEvent { - /// `like-toggled(post: "post-1", now-liked: true)` - Semantic { - name: String, - args: Vec, - span: Span, - }, - /// `outcome like-post.err(refusal: rate-limited)` - Outcome { - command: String, - which: OutcomeKind, - args: Vec, - span: Span, - }, - /// `projection feed.feed-page = fixture.feed.pages-1-2` - Projection(ProjectionPin), +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Project { + pub modules: Vec, } diff --git a/crates/uhura-syntax/src/css.rs b/crates/uhura-syntax/src/css.rs deleted file mode 100644 index 11253e7..0000000 --- a/crates/uhura-syntax/src/css.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! CSS handling (design §4.5): a selector tokenizer plus verbatim -//! balanced-brace declaration capture. The checker's whole CSS surface is -//! selector shape — declarations pass through untouched. Also used by -//! uhura-check on `styles/theme.css`. - -use uhura_base::{FileId, Span}; - -use crate::ast::StyleRule; - -/// Parses stylesheet text into rules. `base` is the byte offset of `text` -/// within the containing file (0 for standalone .css files) so spans line up. -pub fn parse_stylesheet(file: FileId, base: u32, text: &str) -> Vec { - let mut rules = Vec::new(); - let bytes = text.as_bytes(); - let mut i = 0usize; - - while i < bytes.len() { - // Skip whitespace and /* … */ comments. - if bytes[i].is_ascii_whitespace() { - i += 1; - continue; - } - if text[i..].starts_with("/*") { - i = text[i..] - .find("*/") - .map(|j| i + j + 2) - .unwrap_or(bytes.len()); - continue; - } - // Selector runs to the next `{` (or EOF for garbage). - let sel_start = i; - let Some(rel_brace) = text[i..].find('{') else { - break; - }; - let sel_end = i + rel_brace; - let selector_raw = text[sel_start..sel_end].trim(); - // Declaration block: balanced braces (handles @media nesting by - // capturing the whole inner block verbatim). - let mut depth = 0usize; - let mut j = sel_end; - let decl_start = sel_end + 1; - let mut decl_end = bytes.len(); - while j < bytes.len() { - match bytes[j] { - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - decl_end = j; - break; - } - } - _ => {} - } - j += 1; - } - let decls = text[decl_start..decl_end.min(bytes.len())].trim(); - let selector = normalize_ws(selector_raw); - // For @-rules the class references live in the nested inner rules, - // which are captured verbatim inside `decls`. - let classes = if selector.starts_with('@') { - extract_classes(decls) - } else { - extract_classes(&selector) - }; - rules.push(StyleRule { - selector, - classes, - decls: decls.to_string(), - span: Span::new( - file, - base + sel_start as u32, - base + decl_end.min(bytes.len()) as u32, - ), - }); - i = decl_end.saturating_add(1); - } - rules -} - -fn normalize_ws(s: &str) -> String { - s.split_whitespace().collect::>().join(" ") -} - -/// Class names referenced anywhere in a selector (`.post-card` → `post-card`). -pub fn extract_classes(selector: &str) -> Vec { - let mut out = Vec::new(); - let bytes = selector.as_bytes(); - let mut i = 0usize; - while i < bytes.len() { - if bytes[i] == b'.' { - let start = i + 1; - let mut end = start; - while end < bytes.len() - && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-' || bytes[end] == b'_') - { - end += 1; - } - if end > start { - let name = &selector[start..end]; - if !out.iter().any(|c| c == name) { - out.push(name.to_string()); - } - } - i = end; - } else { - i += 1; - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rules_and_classes() { - let css = " -/* tokens */ -.post-card { display: flex; } -.post-card .avatar, .muted { color: var(--x); } -@media (min-width: 600px) { .post-card { gap: 8px; } } -"; - let rules = parse_stylesheet(FileId(0), 0, css); - assert_eq!(rules.len(), 3); - assert_eq!(rules[0].selector, ".post-card"); - assert_eq!(rules[0].classes, vec!["post-card"]); - assert_eq!(rules[1].classes, vec!["post-card", "avatar", "muted"]); - assert!(rules[2].selector.starts_with("@media")); - assert_eq!(rules[2].classes, vec!["post-card"]); - assert_eq!(rules[1].decls, "color: var(--x);"); - } -} diff --git a/crates/uhura-syntax/src/cursor.rs b/crates/uhura-syntax/src/cursor.rs deleted file mode 100644 index f3c166b..0000000 --- a/crates/uhura-syntax/src/cursor.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! The character cursor shared by every surface parser, plus the DSL -//! tokenizer. Mode ownership is structural: parsers call the tokenizer -//! function for the surface they are in (design §4, plan risk #1), so a -//! token can never be lexed in the wrong mode. - -use uhura_base::{Diagnostic, FileId, Span, codes}; - -use crate::token::{Comment, CommentKind, Token, TokenKind}; - -pub struct Cursor<'src> { - pub file: FileId, - text: &'src str, - pos: u32, - pub diagnostics: Vec, -} - -impl<'src> Cursor<'src> { - pub fn new(file: FileId, text: &'src str) -> Self { - Cursor { - file, - text, - pos: 0, - diagnostics: Vec::new(), - } - } - - pub fn pos(&self) -> u32 { - self.pos - } - - /// Rewind/seek — used by parsers to resync after speculative reads. - pub fn set_pos(&mut self, pos: u32) { - debug_assert!(pos as usize <= self.text.len()); - self.pos = pos; - } - - pub fn is_eof(&self) -> bool { - self.pos as usize >= self.text.len() - } - - pub fn rest(&self) -> &'src str { - &self.text[self.pos as usize..] - } - - pub fn peek(&self) -> Option { - self.rest().chars().next() - } - - pub fn peek2(&self) -> Option { - let mut it = self.rest().chars(); - it.next(); - it.next() - } - - pub fn bump(&mut self) -> Option { - let c = self.peek()?; - self.pos += c.len_utf8() as u32; - Some(c) - } - - pub fn eat(&mut self, c: char) -> bool { - if self.peek() == Some(c) { - self.bump(); - true - } else { - false - } - } - - pub fn eat_str(&mut self, s: &str) -> bool { - if self.rest().starts_with(s) { - self.pos += s.len() as u32; - true - } else { - false - } - } - - pub fn span_from(&self, start: u32) -> Span { - Span::new(self.file, start, self.pos) - } - - /// The text consumed since `start`, as an owned string. - pub fn rest_from(&self, start: u32) -> String { - self.text[start as usize..self.pos as usize].to_string() - } - - pub fn error(&mut self, code: codes::Code, message: impl Into, span: Span) { - self.diagnostics - .push(Diagnostic::error(code.0, code.1, message, span)); - } - - /// Skips whitespace and `//` comments, returning the comments in order. - pub fn skip_trivia(&mut self) -> Vec { - let mut comments = Vec::new(); - loop { - match self.peek() { - Some(c) if c.is_whitespace() => { - self.bump(); - } - Some('/') if self.peek2() == Some('/') => { - let start = self.pos; - self.bump(); - self.bump(); - let kind = if self.peek() == Some('!') { - self.bump(); - CommentKind::InnerDoc - } else if self.peek() == Some('/') { - self.bump(); - if self.peek() == Some('/') { - // Four or more slashes are ordinary. Put the - // third slash back into the body logically. - self.pos -= 1; - CommentKind::Ordinary - } else { - CommentKind::OuterDoc - } - } else { - CommentKind::Ordinary - }; - let text_start = self.pos as usize; - while let Some(c) = self.peek() { - if c == '\n' || c == '\r' { - break; - } - self.bump(); - } - comments.push(Comment { - span: self.span_from(start), - kind, - text: self.text[text_start..self.pos as usize].to_string(), - }); - } - _ => break, - } - } - comments - } - - // ── DSL tokenizer ────────────────────────────────────────────────────── - - /// Lexes one DSL token (header / store / expression surfaces). - pub fn dsl_token(&mut self) -> Token { - self.dsl_token_mode(false) - } - - /// Module-level DSL lexing stops before an XML-shaped markup comment so - /// the file driver can perform the DSL-to-markup transition first. - pub(crate) fn module_dsl_token(&mut self) -> Token { - self.dsl_token_mode(true) - } - - fn dsl_token_mode(&mut self, allow_markup_transition: bool) -> Token { - let leading = self.skip_trivia(); - let start = self.pos; - let kind = if allow_markup_transition && self.rest().starts_with("") else { - let recovery = self.rest().find('}').unwrap_or(self.rest().len()); - self.set_pos(body_start + recovery as u32); - self.error( - codes::MALFORMED_MARKUP_COMMENT, - "unterminated markup comment", - self.span_from(start), - ); - return TokenKind::Error; - }; - let body = self.rest()[..close].to_string(); - self.set_pos(body_start + close as u32); - self.eat_str("-->"); - let normalized = body.replace("\r\n", "\n").replace('\r', "\n"); - let malformed_xml = body.contains("--") || body.ends_with('-'); - let malformed_marker = malformed_annotation_marker(&normalized); - if malformed_xml || malformed_marker { - self.error( - codes::MALFORMED_MARKUP_COMMENT, - "malformed XML-shaped comment or annotation marker", - self.span_from(start), - ); - } else { - self.error( - codes::UNEXPECTED_TOKEN, - "XML-shaped comments are only legal at markup sibling positions", - self.span_from(start), - ); - } - TokenKind::Error - } - - fn lex_string(&mut self, start: u32) -> TokenKind { - let mut out = String::new(); - loop { - match self.peek() { - None | Some('\n') => { - self.error( - codes::UNTERMINATED_STRING, - "unterminated string literal (no raw newlines in strings)", - self.span_from(start), - ); - return TokenKind::Str(out); - } - Some('"') => { - self.bump(); - return TokenKind::Str(out); - } - Some('\\') => { - self.bump(); - match self.bump() { - Some('"') => out.push('"'), - Some('\\') => out.push('\\'), - Some('n') => out.push('\n'), - Some('t') => out.push('\t'), - Some('u') => { - if self.eat('{') { - let hex_start = self.pos as usize; - while matches!(self.peek(), Some(c) if c.is_ascii_hexdigit()) { - self.bump(); - } - let hex = &self.text[hex_start..self.pos as usize]; - let ok = self.eat('}'); - match ( - ok, - u32::from_str_radix(hex, 16).ok().and_then(char::from_u32), - ) { - (true, Some(c)) => out.push(c), - _ => self.error( - codes::UNTERMINATED_STRING, - "invalid `\\u{…}` escape", - self.span_from(start), - ), - } - } else { - self.error( - codes::UNTERMINATED_STRING, - "`\\u` escape requires `{hex}`", - self.span_from(start), - ); - } - } - other => { - self.error( - codes::UNTERMINATED_STRING, - format!( - "unknown escape `\\{}`", - other.map(String::from).unwrap_or_default() - ), - self.span_from(start), - ); - } - } - } - Some(_) => { - out.push(self.bump().unwrap()); - } - } - } - } -} - -fn malformed_annotation_marker(body: &str) -> bool { - let body = body.trim_start_matches([' ', '\t', '\n']); - let Some(marker) = body.strip_prefix('@') else { - return false; - }; - let Some((kind_end, separator)) = marker - .char_indices() - .find(|(_, ch)| matches!(ch, ' ' | '\t' | '\n')) - else { - return true; - }; - let kind = &marker[..kind_end]; - let payload = &marker[kind_end + separator.len_utf8()..]; - !valid_annotation_kind(kind) || payload.trim_matches([' ', '\t', '\n']).is_empty() -} - -fn valid_annotation_kind(kind: &str) -> bool { - if kind.is_empty() || kind.len() > 64 || !kind.is_ascii() { - return false; - } - let bytes = kind.as_bytes(); - if !bytes[0].is_ascii_lowercase() || bytes.last() == Some(&b'-') { - return false; - } - let mut previous_dash = false; - for byte in bytes { - if *byte == b'-' { - if previous_dash { - return false; - } - previous_dash = true; - } else if byte.is_ascii_lowercase() || byte.is_ascii_digit() { - previous_dash = false; - } else { - return false; - } - } - true -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::token::TokenKind as T; - - fn lex_all(src: &str) -> Vec { - let mut c = Cursor::new(FileId(0), src); - let mut out = Vec::new(); - loop { - let t = c.dsl_token(); - let eof = t.kind == T::Eof; - out.push(t.kind); - if eof { - break; - } - } - out.pop(); // drop Eof - out - } - - #[test] - fn kebab_vs_minus() { - assert_eq!( - lex_all("like-pending"), - vec![T::Ident("like-pending".into())] - ); - assert_eq!(lex_all("0 - 1"), vec![T::Int(0), T::Minus, T::Int(1)]); - // `a -b` is subtraction: `-` starts a fresh token after whitespace. - assert_eq!( - lex_all("a -b"), - vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] - ); - // `a- b`: the dash is not followed by an ident char, so it detaches. - assert_eq!( - lex_all("a- b"), - vec![T::Ident("a".into()), T::Minus, T::Ident("b".into())] - ); - } - - #[test] - fn operators() { - assert_eq!( - lex_all("a ?? b != c ++ \"x\" && !d"), - vec![ - T::Ident("a".into()), - T::Coalesce, - T::Ident("b".into()), - T::NotEq, - T::Ident("c".into()), - T::PlusPlus, - T::Str("x".into()), - T::AndAnd, - T::Bang, - T::Ident("d".into()), - ] - ); - } - - #[test] - fn string_escapes() { - assert_eq!( - lex_all(r#""a\n\"b\" \u{e9}""#), - vec![T::Str("a\n\"b\" é".into())] - ); - } - - #[test] - fn comments_are_leading_trivia() { - let mut c = Cursor::new(FileId(0), "// hi\n// there\nset"); - let t = c.dsl_token(); - assert_eq!(t.kind, T::Ident("set".into())); - assert_eq!(t.leading.len(), 2); - assert_eq!(t.leading[0].text, " hi"); - } - - #[test] - fn unterminated_string_diagnoses() { - let mut c = Cursor::new(FileId(0), "\"abc\nx"); - let t = c.dsl_token(); - assert!(matches!(t.kind, T::Str(_))); - assert_eq!(c.diagnostics.len(), 1); - assert_eq!(c.diagnostics[0].code, "UH0002"); - } -} diff --git a/crates/uhura-syntax/src/format.rs b/crates/uhura-syntax/src/format.rs index 49f3f3e..a2f2bd5 100644 --- a/crates/uhura-syntax/src/format.rs +++ b/crates/uhura-syntax/src/format.rs @@ -1,834 +1,30 @@ -//! The one canonical formatter — zero options (design §4). Layout is fully -//! deterministic and width-independent: attributes, guards, and expressions -//! render on one line; children/bodies indent by two spaces. Comments attach -//! before their item. CSS declarations pass through verbatim (§4.5). +//! Conservative deterministic Uhura formatter. +//! +//! Uhura 0.3 deliberately treats comments and UI text as non-semantic but +//! author-visible source. Until the candidate selects a comment attachment +//! model, the source layer preserves their placement and canonicalises only +//! line endings, trailing horizontal whitespace, and the final newline. The +//! result is deterministic and idempotent without fabricating declaration or +//! transaction structure. -use crate::ast::*; -use crate::token::CommentKind; +use super::ast::Module; -const INDENT: &str = " "; - -pub fn format_module(f: &File) -> String { - let mut out = String::new(); - - // ── header ────────────────────────────────────────────────────────── - fmt_comments(&f.preamble, 0, &mut out); - match &f.kind { - DefKind::Component { name, .. } => out.push_str(&format!("component {name}\n")), - DefKind::Page { .. } => out.push_str("page\n"), - DefKind::Surface { name, modality, .. } => match modality { - Some(m) => out.push_str(&format!("surface {name} modality {m}\n")), - None => out.push_str(&format!("surface {name}\n")), - }, - DefKind::Error { .. } => {} - } - - if !f.uses.is_empty() { - out.push('\n'); - for u in &f.uses { - fmt_use(u, &mut out); - } - } - - if f.props_present { - out.push('\n'); - fmt_comments(&f.props_leading, 0, &mut out); - out.push_str("props {\n"); - for p in &f.props { - fmt_comments(&p.leading, 1, &mut out); - out.push_str(&format!("{INDENT}{}: {}\n", p.name, type_str(&p.ty))); - } - fmt_comments(&f.props_trailing, 1, &mut out); - out.push_str("}\n"); - } - - if f.emits_present { - out.push('\n'); - fmt_comments(&f.emits_leading, 0, &mut out); - out.push_str("emits {\n"); - for e in &f.emits { - fmt_comments(&e.leading, 1, &mut out); - if params_are_multiline( - e.params.iter().map(|param| ¶m.leading), - &e.params_trailing, - ) { - out.push_str(&format!("{INDENT}{}(\n", e.name)); - for (index, param) in e.params.iter().enumerate() { - fmt_comments(¶m.leading, 2, &mut out); - let comma = if index + 1 < e.params.len() { "," } else { "" }; - out.push_str(&format!( - "{INDENT}{INDENT}{}: {}{comma}\n", - param.name, - type_str(¶m.ty) - )); - } - fmt_comments(&e.params_trailing, 2, &mut out); - out.push_str(&format!("{INDENT})\n")); - } else { - let params = e - .params - .iter() - .map(|param| format!("{}: {}", param.name, type_str(¶m.ty))) - .collect::>() - .join(", "); - out.push_str(&format!("{INDENT}{}({params})\n", e.name)); - } - } - fmt_comments(&f.emits_trailing, 1, &mut out); - out.push_str("}\n"); - } - - for p in &f.params { - out.push('\n'); - fmt_comments(&p.leading, 0, &mut out); - out.push_str(&format!("param {}: {}\n", p.name, type_str(&p.ty))); - } - - if let Some(store) = &f.store { - out.push('\n'); - fmt_comments(&store.leading, 0, &mut out); - out.push_str("store {\n"); - if store.state_present { - fmt_comments(&store.state_leading, 1, &mut out); - out.push_str(&format!("{INDENT}state {{\n")); - for sf in &store.state { - fmt_comments(&sf.leading, 2, &mut out); - out.push_str(&format!( - "{INDENT}{INDENT}{}: {} = {}\n", - sf.name, - type_str(&sf.ty), - literal_str(&sf.init) - )); - } - fmt_comments(&store.state_trailing, 2, &mut out); - out.push_str(&format!("{INDENT}}}\n")); - } - for h in &store.handlers { - out.push('\n'); - fmt_handler(h, &mut out); - } - fmt_comments(&store.trailing, 1, &mut out); - out.push_str("}\n"); - } - - if f.trailing_dsl.has_formattable_content() - || !f.markup.is_empty() - || !f.markup.comments.is_empty() - || f.style.is_some() - { - out.push('\n'); - fmt_comments(&f.trailing_dsl, 0, &mut out); - fmt_markup_list(&f.markup, 0, &mut out); - } - - if let Some(style) = &f.style { - if !f.markup.is_empty() { - out.push('\n'); - } - out.push_str("\n"); - } - - out -} - -pub fn format_examples(f: &ExamplesFile) -> String { - let mut out = String::new(); - for u in &f.uses { - fmt_use(u, &mut out); - } - for e in &f.examples { - out.push('\n'); - fmt_comments(&e.leading, 0, &mut out); - let default = if e.is_default { " default" } else { "" }; - out.push_str(&format!("example {}{default} {{\n", e.name)); - for (index, c) in e.clauses.iter().enumerate() { - if let Some(trivia) = e.clause_leading.get(index) { - fmt_comments(trivia, 1, &mut out); - } - fmt_example_clause(c, &mut out); - } - fmt_comments(&e.trailing, 1, &mut out); - out.push_str("}\n"); - } - fmt_comments(&f.trailing, 0, &mut out); - out -} - -// ── pieces ────────────────────────────────────────────────────────────────── - -fn fmt_comments(trivia: &DslTrivia, depth: usize, out: &mut String) { - let mut rendered_docs: Vec> = vec![None; trivia.pieces.len()]; - let mut cursor = 0; - while cursor < trivia.pieces.len() { - let form = match trivia.pieces[cursor].kind { - CommentKind::Ordinary => { - cursor += 1; - continue; - } - CommentKind::OuterDoc => CommentKind::OuterDoc, - CommentKind::InnerDoc => CommentKind::InnerDoc, - }; - let mut end = cursor; - let mut doc_indices = Vec::new(); - let mut lines = Vec::new(); - while end < trivia.pieces.len() { - let kind = trivia.pieces[end].kind; - if kind != CommentKind::Ordinary && kind != form { - break; - } - if kind == form { - doc_indices.push(end); - lines.push(trivia.pieces[end].normalized_doc_line()); - } - end += 1; - } - while lines.last().is_some_and(String::is_empty) { - lines.pop(); - doc_indices.pop(); - } - for (index, line) in doc_indices.into_iter().zip(lines) { - rendered_docs[index] = Some(line); - } - cursor = end; - } - - for (index, c) in trivia.pieces.iter().enumerate() { - let line = match c.kind { - CommentKind::Ordinary => Some(format!("//{}", c.text.trim_end_matches([' ', '\t']))), - CommentKind::OuterDoc => rendered_docs[index] - .as_ref() - .map(|text| format!("///{}", doc_body(text))), - CommentKind::InnerDoc => rendered_docs[index] - .as_ref() - .map(|text| format!("//!{}", doc_body(text))), - }; - let Some(line) = line else { continue }; - out.push_str(&INDENT.repeat(depth)); - out.push_str(&line); - out.push('\n'); - } -} - -fn doc_body(text: &str) -> String { - if text.is_empty() { - String::new() - } else { - format!(" {text}") - } -} - -fn params_are_multiline<'a>( - mut leading: impl Iterator, - trailing: &DslTrivia, -) -> bool { - trailing.has_formattable_content() || leading.any(DslTrivia::has_formattable_content) -} - -fn fmt_use(u: &Use, out: &mut String) { - match u { - Use::Component { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use component {name}\n")); - } - Use::Surface { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use surface {name}\n")); - } - Use::Fixture { name, leading, .. } => { - fmt_comments(leading, 0, out); - out.push_str(&format!("use fixture {name}\n")); - } - Use::Port { - name, - items, - leading, - .. - } => { - fmt_comments(leading, 0, out); - // ≤ 3 items inline; otherwise one per line (deterministic by - // count, not width). - let rendered: Vec = items - .iter() - .map(|i| { - let kind = match i.kind { - PortItemKind::Projection => "projection", - PortItemKind::Command => "command", - PortItemKind::Type => "type", - }; - format!("{kind} {}", i.name) - }) - .collect(); - if rendered.len() <= 3 { - out.push_str(&format!("use port {name} {{ {} }}\n", rendered.join(", "))); - } else { - out.push_str(&format!("use port {name} {{\n")); - for r in rendered { - out.push_str(&format!("{INDENT}{r}\n")); - } - out.push_str("}\n"); - } - } - } -} - -fn fmt_handler(h: &Handler, out: &mut String) { - fmt_comments(&h.leading, 1, out); - let event = match &h.event { - EventRef::Semantic { name, .. } => name.clone(), - EventRef::Outcome { command, which, .. } => format!( - "{command}.{}", - if *which == OutcomeKind::Ok { - "ok" - } else { - "err" - } - ), - }; - let guard = match &h.guard { - Some(g) => format!(" when {}", expr_str(g)), - None => String::new(), - }; - if params_are_multiline( - h.params.iter().map(|param| ¶m.leading), - &h.params_trailing, - ) { - out.push_str(&format!("{INDENT}on {event}(\n")); - for (index, param) in h.params.iter().enumerate() { - fmt_comments(¶m.leading, 2, out); - let rendered = match ¶m.ty { - Some(ty) => format!("{}: {}", param.name, type_str(ty)), - None => param.name.clone(), - }; - let comma = if index + 1 < h.params.len() { "," } else { "" }; - out.push_str(&format!("{INDENT}{INDENT}{rendered}{comma}\n")); - } - fmt_comments(&h.params_trailing, 2, out); - out.push_str(&format!("{INDENT}){guard} {{\n")); - } else { - let params = h - .params - .iter() - .map(|p| match &p.ty { - Some(t) => format!("{}: {}", p.name, type_str(t)), - None => p.name.clone(), - }) - .collect::>() - .join(", "); - out.push_str(&format!("{INDENT}on {event}({params}){guard} {{\n")); - } - for st in &h.body { - fmt_stmt(st, out); - } - fmt_comments(&h.body_trailing, 2, out); - out.push_str(&format!("{INDENT}}}\n")); -} - -fn fmt_stmt(st: &Stmt, out: &mut String) { - let pad = INDENT.repeat(2); - match st { - Stmt::Set { - path, - value, - leading, - .. - } => { - fmt_comments(leading, 2, out); - let key = match &path.key { - Some(k) => format!("[{}]", expr_str(k)), - None => String::new(), - }; - out.push_str(&format!( - "{pad}set {}{key} = {}\n", - path.field, - expr_str(value) - )); - } - Stmt::Send { - command, - args, - bind, - leading, - .. - } => { - fmt_comments(leading, 2, out); - let bind = match bind { - Some(b) => format!(" as {b}"), - None => String::new(), - }; - out.push_str(&format!("{pad}send {command}({}){bind}\n", args_str(args))); - } - Stmt::OpenSurface { - name, - args, - leading, - .. - } => { - fmt_comments(leading, 2, out); - out.push_str(&format!("{pad}open-surface {name}({})\n", args_str(args))); - } - Stmt::Dismiss { leading, .. } => { - fmt_comments(leading, 2, out); - out.push_str(&format!("{pad}dismiss\n")); - } - Stmt::Navigate { - target, leading, .. - } => { - fmt_comments(leading, 2, out); - match target { - NavTarget::Back => out.push_str(&format!("{pad}navigate back\n")), - NavTarget::Route { name, args } => { - if args.is_empty() { - out.push_str(&format!("{pad}navigate {name}()\n")); - } else { - out.push_str(&format!("{pad}navigate {name}({})\n", args_str(args))); - } - } - NavTarget::Replace { name, args } => { - if args.is_empty() { - out.push_str(&format!("{pad}navigate replace {name}()\n")); - } else { - out.push_str(&format!( - "{pad}navigate replace {name}({})\n", - args_str(args) - )); - } - } - } - } - Stmt::Error { .. } => {} - } -} - -fn fmt_node(n: &Node, depth: usize, out: &mut String) { - let pad = INDENT.repeat(depth); - match n { - Node::Element(e) => { - let mut head = format!("<{}", e.name); - for a in &e.attrs { - match &a.value { - AttrValue::Bare => head.push_str(&format!(" {}", a.name)), - AttrValue::Literal(v) => head.push_str(&format!(" {}=\"{v}\"", a.name)), - AttrValue::Expr(x) => { - head.push_str(&format!(" {}={{{}}}", a.name, expr_str(x))) - } - } - } - for ev in &e.events { - match &ev.binding { - EventBinding::Forward => head.push_str(&format!(" on:{}", ev.event)), - EventBinding::Emit { name, args } => head.push_str(&format!( - " on:{}={{emit {name}({})}}", - ev.event, - args_str(args) - )), - } - } - if e.self_closing || (e.children.is_empty() && e.children.comments.is_empty()) { - out.push_str(&format!("{pad}{head} />\n")); - } else if is_inline_text_only(e) { - // `{expr} literal` stays on one line. - let mut line = format!("{pad}{head}>"); - if let Node::Text { runs, .. } = &e.children[0] { - line.push_str(&text_runs_str(runs)); - } - line.push_str(&format!("\n", e.name)); - out.push_str(&line); - } else { - out.push_str(&format!("{pad}{head}>\n")); - fmt_markup_list(&e.children, depth + 1, out); - out.push_str(&format!("{pad}\n", e.name)); - } - } - Node::Text { runs, .. } => { - out.push_str(&format!("{pad}{}\n", text_runs_str(runs))); - } - Node::If { - cond, then, els, .. - } => { - out.push_str(&format!("{pad}{{#if {}}}\n", expr_str(cond))); - fmt_markup_list(then, depth + 1, out); - if let Some(els) = els { - out.push_str(&format!("{pad}{{:else}}\n")); - fmt_markup_list(els, depth + 1, out); - } - out.push_str(&format!("{pad}{{/if}}\n")); - } - Node::Each { - item, - seq, - key, - body, - .. - } => { - out.push_str(&format!( - "{pad}{{#each {} as {item} ({})}}\n", - expr_str(seq), - expr_str(key) - )); - fmt_markup_list(body, depth + 1, out); - out.push_str(&format!("{pad}{{/each}}\n")); - } - Node::Match { - scrutinee, - before_arms, - arms, - .. - } => { - out.push_str(&format!("{pad}{{#match {}}}\n", expr_str(scrutinee))); - fmt_markup_list(before_arms, depth + 1, out); - for a in arms { - match &a.pattern { - MatchPattern::Variant(v) => match &a.binding { - Some(b) => out.push_str(&format!("{pad}{INDENT}{{:when {v} {b}}}\n")), - None => out.push_str(&format!("{pad}{INDENT}{{:when {v}}}\n")), - }, - MatchPattern::Else => out.push_str(&format!("{pad}{INDENT}{{:else}}\n")), - } - fmt_markup_list(&a.body, depth + 2, out); - } - out.push_str(&format!("{pad}{{/match}}\n")); - } - Node::Error { .. } => {} - } -} - -fn is_inline_text_only(e: &Element) -> bool { - e.children.comments.is_empty() - && e.children.len() == 1 - && matches!(&e.children[0], Node::Text { .. }) -} - -fn fmt_markup_list(list: &MarkupList, depth: usize, out: &mut String) { - let mut comments = list.comments.iter().peekable(); - for index in 0..=list.nodes.len() { - while comments.peek().is_some_and(|placed| placed.before == index) { - let placed = comments.next().expect("peeked comment"); - fmt_markup_comment(&placed.comment, depth, out); - } - if let Some(node) = list.nodes.get(index) { - fmt_node(node, depth, out); - } - } +pub fn format(module: &Module) -> String { + format_source(&module.source) } -fn fmt_markup_comment(comment: &MarkupComment, depth: usize, out: &mut String) { - let pad = INDENT.repeat(depth); - match &comment.kind { - MarkupCommentKind::Malformed { terminated } => { - // Error formatting must preserve the lexical failure. In - // particular, adding canonical padding around a trailing `-`, or - // inventing a missing close, can turn recovery text into valid - // metadata on the next parse. - out.push_str(&pad); - out.push_str(""); - } - out.push('\n'); - return; - } - MarkupCommentKind::RejectedAnnotation { kind } => { - // `:` is outside annotation-kind, yielding a stable UH0016 - // carrier while keeping the author's visible kind and prose. - out.push_str(&format!("{pad}\n"); - return; - } - MarkupCommentKind::Ordinary | MarkupCommentKind::Annotation { .. } => {} +pub fn format_source(source: &str) -> String { + let normalized = source.replace("\r\n", "\n").replace('\r', "\n"); + let mut output = String::with_capacity(normalized.len().saturating_add(1)); + for line in normalized.lines() { + output.push_str(line.trim_end_matches([' ', '\t'])); + output.push('\n'); } - let marker = match &comment.kind { - MarkupCommentKind::Ordinary => None, - MarkupCommentKind::Annotation { kind } => Some(kind.as_str()), - MarkupCommentKind::Malformed { .. } | MarkupCommentKind::RejectedAnnotation { .. } => { - unreachable!("recovery comments return above") - } - }; - if !comment.text.contains('\n') { - match marker { - Some(kind) => out.push_str(&format!("{pad}\n", comment.text)), - None if comment.text.is_empty() => out.push_str(&format!("{pad}\n")), - None => out.push_str(&format!("{pad}\n", comment.text)), - } - return; - } - - match marker { - Some(kind) => out.push_str(&format!("{pad}\n")); -} - -fn text_runs_str(runs: &[TextRun]) -> String { - let mut out = String::new(); - for r in runs { - match r { - TextRun::Literal(t) => out.push_str(&normalize_text(t)), - TextRun::Interp(x) => out.push_str(&format!("{{{}}}", expr_str(x))), - } + if normalized.is_empty() { + return String::new(); } - out -} - -/// Collapses internal whitespace runs; preserves single spaces. -fn normalize_text(t: &str) -> String { - let has_lead = t.starts_with(char::is_whitespace); - let has_trail = t.ends_with(char::is_whitespace); - let core = t.split_whitespace().collect::>().join(" "); - format!( - "{}{core}{}", - if has_lead && !core.is_empty() { - " " - } else { - "" - }, - if has_trail && !core.is_empty() { - " " - } else { - "" - } - ) -} - -fn fmt_example_clause(c: &ExampleClause, out: &mut String) { - match c { - ExampleClause::From { name, .. } => out.push_str(&format!("{INDENT}from {name}\n")), - ExampleClause::Note { text, .. } => { - out.push_str(&format!("{INDENT}note {}\n", quote(text))); - } - ExampleClause::Params { entries, .. } => fmt_assign_block("params", entries, out), - ExampleClause::Props { entries, .. } => fmt_assign_block("props", entries, out), - ExampleClause::State { entries, .. } => fmt_assign_block("state", entries, out), - ExampleClause::Projection(p) => { - out.push_str(&format!("{INDENT}{}\n", projection_pin_str(p))); - } - ExampleClause::Events { entries, .. } => { - if entries.len() == 1 { - out.push_str(&format!( - "{INDENT}events [ {} ]\n", - example_event_str(&entries[0]) - )); - } else { - out.push_str(&format!("{INDENT}events [\n")); - for e in entries { - out.push_str(&format!("{INDENT}{INDENT}{}\n", example_event_str(e))); - } - out.push_str(&format!("{INDENT}]\n")); - } - } - ExampleClause::Error { .. } => {} - } -} - -fn fmt_assign_block(kw: &str, entries: &[(String, Expr)], out: &mut String) { - if entries.len() == 1 { - out.push_str(&format!( - "{INDENT}{kw} {{ {} = {} }}\n", - entries[0].0, - expr_str(&entries[0].1) - )); - return; - } - out.push_str(&format!("{INDENT}{kw} {{\n")); - for (n, v) in entries { - out.push_str(&format!("{INDENT}{INDENT}{n} = {}\n", expr_str(v))); - } - out.push_str(&format!("{INDENT}}}\n")); -} - -fn projection_pin_str(p: &ProjectionPin) -> String { - let key = match &p.key { - Some(k) => format!("({})", expr_str(k)), - None => String::new(), - }; - format!( - "projection {}.{}{key} = {}", - p.port, - p.projection, - expr_str(&p.value) - ) -} - -fn example_event_str(e: &ExampleEvent) -> String { - match e { - ExampleEvent::Semantic { name, args, .. } => format!("{name}({})", args_str(args)), - ExampleEvent::Outcome { - command, - which, - args, - .. - } => format!( - "outcome {command}.{}({})", - if *which == OutcomeKind::Ok { - "ok" - } else { - "err" - }, - args_str(args) - ), - ExampleEvent::Projection(p) => projection_pin_str(p), - } -} - -// ── leaf renderers ────────────────────────────────────────────────────────── - -pub fn type_str(t: &TypeExpr) -> String { - match &t.kind { - TypeKind::Name(n) => n.clone(), - TypeKind::List(inner) => format!("list[{}]", type_str(inner)), - TypeKind::Map(k, v) => format!("map[{k}]{}", type_str(v)), - TypeKind::Option(inner) => format!("{}?", type_str(inner)), - TypeKind::Error => "".to_string(), - } -} - -fn literal_str(l: &Literal) -> String { - match l { - Literal::Int(i) => i.to_string(), - Literal::Str(s) => quote(s), - Literal::Bool(b) => b.to_string(), - Literal::None => "none".to_string(), - Literal::EmptyMap => "{}".to_string(), - Literal::Error => "".to_string(), - } -} - -fn quote(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for c in s.chars() { - match c { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\t' => out.push_str("\\t"), - _ => out.push(c), - } - } - out.push('"'); - out -} - -fn args_str(args: &[Arg]) -> String { - args.iter() - .map(|a| format!("{}: {}", a.name, expr_str(&a.value))) - .collect::>() - .join(", ") -} - -/// Renders an expression with minimal parentheses (by precedence). -pub fn expr_str(e: &Expr) -> String { - render_expr(e, 0) -} - -/// Precedence levels, loosest → tightest (must mirror the parser). -fn level(e: &Expr) -> u8 { - match &e.kind { - ExprKind::If { .. } => 0, - ExprKind::Binary { op, .. } => match op { - BinaryOp::Or => 1, - BinaryOp::And => 2, - BinaryOp::Eq - | BinaryOp::NotEq - | BinaryOp::Lt - | BinaryOp::Le - | BinaryOp::Gt - | BinaryOp::Ge => 3, - BinaryOp::Coalesce => 4, - BinaryOp::Add | BinaryOp::Sub | BinaryOp::Concat => 5, - }, - ExprKind::Unary { .. } => 6, - _ => 7, - } -} - -fn render_expr(e: &Expr, min_level: u8) -> String { - let mine = level(e); - let body = match &e.kind { - ExprKind::Ident(n) => n.clone(), - ExprKind::Int(i) => i.to_string(), - ExprKind::Str(s) => quote(s), - ExprKind::Bool(b) => b.to_string(), - ExprKind::None => "none".to_string(), - ExprKind::Field { base, name } => format!("{}.{name}", render_expr(base, 7)), - ExprKind::Index { base, key } => { - format!("{}[{}]", render_expr(base, 7), render_expr(key, 0)) - } - ExprKind::Call { name, args } => format!( - "{name}({})", - args.iter() - .map(|a| render_expr(a, 0)) - .collect::>() - .join(", ") - ), - ExprKind::Unary { op, expr } => { - let sym = match op { - UnaryOp::Not => "!", - UnaryOp::Neg => "-", - }; - format!("{sym}{}", render_expr(expr, 6)) - } - ExprKind::Binary { op, lhs, rhs } => { - let sym = match op { - BinaryOp::Add => "+", - BinaryOp::Sub => "-", - BinaryOp::Concat => "++", - BinaryOp::Eq => "==", - BinaryOp::NotEq => "!=", - BinaryOp::Lt => "<", - BinaryOp::Le => "<=", - BinaryOp::Gt => ">", - BinaryOp::Ge => ">=", - BinaryOp::And => "&&", - BinaryOp::Or => "||", - BinaryOp::Coalesce => "??", - }; - // Left-associative: rhs needs one level tighter. - format!( - "{} {sym} {}", - render_expr(lhs, mine), - render_expr(rhs, mine + 1) - ) - } - ExprKind::If { cond, then, els } => format!( - "if {} then {} else {}", - render_expr(cond, 1), - render_expr(then, 0), - render_expr(els, 0) - ), - ExprKind::Record(fields) => format!( - "{{ {} }}", - fields - .iter() - .map(|(n, v)| format!("{n}: {}", render_expr(v, 0))) - .collect::>() - .join(", ") - ), - ExprKind::Error => "".to_string(), - }; - if mine < min_level { - format!("({body})") - } else { - body + while output.ends_with("\n\n") && normalized.ends_with('\n') && !normalized.ends_with("\n\n") { + output.pop(); } + output } diff --git a/crates/uhura-syntax/src/lexer.rs b/crates/uhura-syntax/src/lexer.rs new file mode 100644 index 0000000..2ce7494 --- /dev/null +++ b/crates/uhura-syntax/src/lexer.rs @@ -0,0 +1,446 @@ +//! UTF-8 lexer for Uhura's expression and declaration surfaces. +//! +//! UI bodies are parsed with a dedicated character cursor because text nodes +//! are intentionally not tokenised as language-generation identifiers. The +//! general lexer +//! still emits punctuation for the complete file so the declaration parser +//! can locate the balanced UI body without a second source scan. + +use serde::{Deserialize, Serialize}; +use unicode_ident::{is_xid_continue, is_xid_start}; + +use super::ast::{SourceId, SourceSpan}; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TriviaKind { + Whitespace, + Comment, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Trivia { + pub kind: TriviaKind, + pub text: String, + pub span: SourceSpan, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub enum TokenKind { + Ident(String), + Integer(String), + Decimal(String), + Text(String), + LBrace, + RBrace, + LParen, + RParen, + LBracket, + RBracket, + Less, + LessEqual, + Greater, + GreaterEqual, + Comma, + Colon, + ColonColon, + Dot, + Ellipsis, + At, + Eq, + EqEq, + Bang, + NotEqual, + Plus, + Minus, + Star, + Pipe, + Arrow, + FatArrow, + Slash, + Hash, + Other(char), + Eof, +} + +impl TokenKind { + pub fn describe(&self) -> String { + match self { + Self::Ident(value) => format!("identifier `{value}`"), + Self::Integer(value) | Self::Decimal(value) => format!("number `{value}`"), + Self::Text(_) => "text literal".into(), + Self::LBrace => "`{`".into(), + Self::RBrace => "`}`".into(), + Self::LParen => "`(`".into(), + Self::RParen => "`)`".into(), + Self::LBracket => "`[`".into(), + Self::RBracket => "`]`".into(), + Self::Less => "`<`".into(), + Self::LessEqual => "`<=`".into(), + Self::Greater => "`>`".into(), + Self::GreaterEqual => "`>=`".into(), + Self::Comma => "`,`".into(), + Self::Colon => "`:`".into(), + Self::ColonColon => "`::`".into(), + Self::Dot => "`.`".into(), + Self::Ellipsis => "`...`".into(), + Self::At => "`@`".into(), + Self::Eq => "`=`".into(), + Self::EqEq => "`==`".into(), + Self::Bang => "`!`".into(), + Self::NotEqual => "`!=`".into(), + Self::Plus => "`+`".into(), + Self::Minus => "`-`".into(), + Self::Star => "`*`".into(), + Self::Pipe => "`|`".into(), + Self::Arrow => "`->`".into(), + Self::FatArrow => "`=>`".into(), + Self::Slash => "`/`".into(), + Self::Hash => "`#`".into(), + Self::Other(value) => format!("`{value}`"), + Self::Eof => "end of file".into(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Token { + pub kind: TokenKind, + pub span: SourceSpan, + pub leading: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +pub enum LexDiagnosticKind { + InitialBom, + ReservedComment, + UnterminatedText, + InvalidEscape, + InvalidUnicodeEscape, + SourceTooLarge, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct LexDiagnostic { + pub kind: LexDiagnosticKind, + pub message: String, + pub span: SourceSpan, +} + +pub struct LexOutput { + pub tokens: Vec, + pub diagnostics: Vec, +} + +pub fn lex(source_id: &SourceId, source: &str) -> LexOutput { + Lexer::new(source_id.file, source, 0).run() +} + +pub(crate) fn lex_fragment(file: u32, source: &str, base: u32) -> LexOutput { + Lexer::new(file, source, base).run() +} + +struct Lexer<'a> { + file: u32, + source: &'a str, + base: u32, + pos: usize, + diagnostics: Vec, +} + +impl<'a> Lexer<'a> { + fn new(file: u32, source: &'a str, base: u32) -> Self { + Self { + file, + source, + base, + pos: 0, + diagnostics: Vec::new(), + } + } + + fn run(mut self) -> LexOutput { + if self.source.starts_with('\u{feff}') { + let end = '\u{feff}'.len_utf8() as u32; + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::InitialBom, + message: "Uhura source must not begin with a UTF-8 BOM".into(), + span: SourceSpan::new(self.file, self.base, self.base + end), + }); + self.pos += '\u{feff}'.len_utf8(); + } + if self.source.len() > (u32::MAX - self.base) as usize { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::SourceTooLarge, + message: "Uhura source exceeds the supported 32-bit byte range".into(), + span: SourceSpan::empty(self.file, self.base), + }); + } + + let mut tokens = Vec::new(); + loop { + let leading = self.trivia(); + let start = self.pos; + let kind = self.token(); + let end = self.pos; + let eof = kind == TokenKind::Eof; + tokens.push(Token { + kind, + span: self.span(start, end), + leading, + }); + if eof { + break; + } + } + LexOutput { + tokens, + diagnostics: self.diagnostics, + } + } + + fn span(&self, start: usize, end: usize) -> SourceSpan { + SourceSpan::new( + self.file, + self.base.saturating_add(start as u32), + self.base.saturating_add(end as u32), + ) + } + + fn rest(&self) -> &'a str { + &self.source[self.pos..] + } + + fn peek(&self) -> Option { + self.rest().chars().next() + } + + fn peek_n(&self, n: usize) -> Option { + self.rest().chars().nth(n) + } + + fn bump(&mut self) -> Option { + let value = self.peek()?; + self.pos += value.len_utf8(); + Some(value) + } + + fn eat(&mut self, expected: char) -> bool { + if self.peek() == Some(expected) { + self.bump(); + true + } else { + false + } + } + + fn trivia(&mut self) -> Vec { + let mut values = Vec::new(); + loop { + let start = self.pos; + match self.peek() { + Some(value) if value.is_whitespace() => { + while self.peek().is_some_and(char::is_whitespace) { + self.bump(); + } + values.push(Trivia { + kind: TriviaKind::Whitespace, + text: self.source[start..self.pos].to_string(), + span: self.span(start, self.pos), + }); + } + Some('/') if self.peek_n(1) == Some('/') => { + self.bump(); + self.bump(); + let reserved = matches!(self.peek(), Some('/' | '!')); + if reserved { + self.bump(); + } + while !matches!(self.peek(), None | Some('\n' | '\r')) { + self.bump(); + } + self.eat('\r'); + self.eat('\n'); + let span = self.span(start, self.pos); + if reserved { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::ReservedComment, + message: "`///` and `//!` are reserved in Uhura 0.3".into(), + span, + }); + } + values.push(Trivia { + kind: TriviaKind::Comment, + text: self.source[start..self.pos].to_string(), + span, + }); + } + _ => break, + } + } + values + } + + fn token(&mut self) -> TokenKind { + let start = self.pos; + let Some(value) = self.bump() else { + return TokenKind::Eof; + }; + match value { + value if value == '_' || is_xid_start(value) => { + while self + .peek() + .is_some_and(|next| next == '_' || is_xid_continue(next)) + { + self.bump(); + } + TokenKind::Ident(self.source[start..self.pos].to_string()) + } + '0'..='9' => { + while self.peek().is_some_and(|next| next.is_ascii_digit()) { + self.bump(); + } + if self.peek() == Some('.') + && self.peek_n(1).is_some_and(|next| next.is_ascii_digit()) + { + self.bump(); + while self.peek().is_some_and(|next| next.is_ascii_digit()) { + self.bump(); + } + TokenKind::Decimal(self.source[start..self.pos].to_string()) + } else { + TokenKind::Integer(self.source[start..self.pos].to_string()) + } + } + '"' => self.text(start), + '{' => TokenKind::LBrace, + '}' => TokenKind::RBrace, + '(' => TokenKind::LParen, + ')' => TokenKind::RParen, + '[' => TokenKind::LBracket, + ']' => TokenKind::RBracket, + '<' => { + if self.eat('=') { + TokenKind::LessEqual + } else { + TokenKind::Less + } + } + '>' => { + if self.eat('=') { + TokenKind::GreaterEqual + } else { + TokenKind::Greater + } + } + ',' => TokenKind::Comma, + ':' => { + if self.eat(':') { + TokenKind::ColonColon + } else { + TokenKind::Colon + } + } + '.' => { + if self.eat('.') && self.eat('.') { + TokenKind::Ellipsis + } else { + TokenKind::Dot + } + } + '@' => TokenKind::At, + '=' => { + if self.eat('=') { + TokenKind::EqEq + } else if self.eat('>') { + TokenKind::FatArrow + } else { + TokenKind::Eq + } + } + '!' => { + if self.eat('=') { + TokenKind::NotEqual + } else { + TokenKind::Bang + } + } + '+' => TokenKind::Plus, + '-' => { + if self.eat('>') { + TokenKind::Arrow + } else { + TokenKind::Minus + } + } + '*' => TokenKind::Star, + '|' => TokenKind::Pipe, + '/' => TokenKind::Slash, + '#' => TokenKind::Hash, + other => TokenKind::Other(other), + } + } + + fn text(&mut self, start: usize) -> TokenKind { + let mut decoded = String::new(); + loop { + let Some(value) = self.bump() else { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::UnterminatedText, + message: "unterminated Uhura text literal".into(), + span: self.span(start, self.pos), + }); + return TokenKind::Text(decoded); + }; + match value { + '"' => return TokenKind::Text(decoded), + '\n' | '\r' => { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::UnterminatedText, + message: "Uhura text literals cannot contain raw line endings".into(), + span: self.span(start, self.pos), + }); + return TokenKind::Text(decoded); + } + '\\' => { + let escape_start = self.pos.saturating_sub(1); + match self.bump() { + Some('"') => decoded.push('"'), + Some('\\') => decoded.push('\\'), + Some('n') => decoded.push('\n'), + Some('r') => decoded.push('\r'), + Some('t') => decoded.push('\t'), + Some('u') if self.eat('{') => { + let digits_start = self.pos; + while self.peek().is_some_and(|next| next.is_ascii_hexdigit()) { + self.bump(); + } + let digits_end = self.pos; + let closed = self.eat('}'); + let parsed = + u32::from_str_radix(&self.source[digits_start..digits_end], 16) + .ok() + .and_then(char::from_u32); + if !closed || digits_start == digits_end || parsed.is_none() { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::InvalidUnicodeEscape, + message: "invalid Unicode scalar escape".into(), + span: self.span(escape_start, self.pos), + }); + } else if let Some(value) = parsed { + decoded.push(value); + } + } + Some(_) | None => { + self.diagnostics.push(LexDiagnostic { + kind: LexDiagnosticKind::InvalidEscape, + message: "unsupported Uhura text escape".into(), + span: self.span(escape_start, self.pos), + }); + } + } + } + other => decoded.push(other), + } + } + } +} diff --git a/crates/uhura-syntax/src/lib.rs b/crates/uhura-syntax/src/lib.rs index f843a50..523b2c9 100644 --- a/crates/uhura-syntax/src/lib.rs +++ b/crates/uhura-syntax/src/lib.rs @@ -1,15 +1,18 @@ -//! uhura-syntax: mode-switching lexer (Dsl / Markup / Expr / Style / -//! Examples), recursive-descent parsers with recovery, AST, and the one -//! canonical trivia-preserving formatter (design §4, §12.2). +//! The canonical Uhura source layer: a UTF-8 lexer, source-spanned AST, +//! recursive-descent parser, checked UI parser, and deterministic formatter. pub mod ast; -pub mod css; -mod cursor; mod format; +mod lexer; mod parser; -mod token; +mod ui; +pub mod v04; -pub use cursor::Cursor; -pub use format::{expr_str, format_examples, format_module, type_str}; -pub use parser::{ParseOutput, Parsed, SourceKind, parse}; -pub use token::{Comment, CommentKind, Token, TokenKind}; +pub use ast::SourceId; +pub use format::format; +pub use parser::{ + Parse, ParseDiagnostic, ParseDiagnosticKind, ProjectParse, SourceFile, parse, parse_project, +}; + +#[cfg(test)] +mod tests; diff --git a/crates/uhura-syntax/src/parser/dsl.rs b/crates/uhura-syntax/src/parser/dsl.rs deleted file mode 100644 index 66870c6..0000000 --- a/crates/uhura-syntax/src/parser/dsl.rs +++ /dev/null @@ -1,784 +0,0 @@ -//! Header and store parsing (design §4.1–§4.2). These surfaces are pure -//! DSL; the file-level driver (`mod.rs`) decides when markup begins. - -use uhura_base::{Span, codes}; - -use crate::ast::*; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr, parse_type}; -use super::stream::DslStream; - -/// Sync set for header/store recovery: skip until one of these idents (at -/// nesting depth 0) or EOF. -fn sync_to(s: &mut DslStream, targets: &[&str]) { - let mut depth = 0i32; - loop { - match s.peek() { - T::Eof => return, - T::LBrace => { - depth += 1; - s.bump(); - } - T::RBrace => { - if depth == 0 { - return; - } - depth -= 1; - s.bump(); - } - T::Ident(name) if depth == 0 && targets.iter().any(|t| t == name) => return, - _ => { - s.bump(); - } - } - } -} - -// ── header declarations ───────────────────────────────────────────────────── - -pub fn parse_use(s: &mut DslStream, file_preamble: bool) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - if !s.eat_ident("use") { - return None; - } - let Some((kind, _)) = s.expect_ident("after `use` (component | surface | port | fixture)") - else { - sync_to(s, &["use", "props", "emits", "param", "store", "example"]); - return None; - }; - let parsed = match kind.as_str() { - "component" => { - let (name, nspan) = s.expect_ident("as the component name")?; - Some(Use::Component { - name, - span: start.to(nspan), - leading, - }) - } - "surface" => { - let (name, nspan) = s.expect_ident("as the surface name")?; - Some(Use::Surface { - name, - span: start.to(nspan), - leading, - }) - } - "fixture" => { - let (name, nspan) = s.expect_ident("as the fixture name")?; - Some(Use::Fixture { - name, - span: start.to(nspan), - leading, - }) - } - "port" => { - let (name, _) = s.expect_ident("as the port name")?; - s.expect(&T::LBrace, "to open the port import list"); - let mut items = Vec::new(); - loop { - match s.peek().clone() { - T::RBrace => { - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - T::Ident(k) if matches!(k.as_str(), "projection" | "command" | "type") => { - let kspan = s.peek_span(); - s.bump(); - let kind = match k.as_str() { - "projection" => PortItemKind::Projection, - "command" => PortItemKind::Command, - _ => PortItemKind::Type, - }; - if let Some((iname, ispan)) = s.expect_ident("as the imported item name") { - items.push(PortItem { - kind, - name: iname, - span: kspan.to(ispan), - }); - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "expected `projection`, `command`, or `type` in the port \ - import list, found {desc}" - ), - span, - ); - s.bump(); - } - } - } - let end = s.peek_span(); - s.expect(&T::RBrace, "to close the port import list"); - Some(Use::Port { - name, - items, - span: start.to(end), - leading, - }) - } - other => { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("`use {other}` is not an import kind (component | surface | port)"), - span, - ); - sync_to(s, &["use", "props", "emits", "param", "store"]); - None - } - }; - if let Some(use_decl) = &parsed { - let (span, leading) = match use_decl { - Use::Component { span, leading, .. } - | Use::Surface { span, leading, .. } - | Use::Port { span, leading, .. } - | Use::Fixture { span, leading, .. } => (*span, leading), - }; - if file_preamble { - s.accept_file_docs_only(leading, span); - } else { - s.reject_docs(leading, span); - } - } - parsed -} - -/// `props { name: type, … }` — brace block of typed names. -pub fn parse_props_block(s: &mut DslStream) -> (Vec, DslTrivia) { - let (items, trailing) = parse_typed_block(s, "props"); - ( - items - .into_iter() - .map(|(name, ty, span, leading)| PropDecl { - name, - ty, - span, - leading, - }) - .collect(), - trailing, - ) -} - -fn parse_typed_block( - s: &mut DslStream, - what: &str, -) -> (Vec<(String, TypeExpr, Span, DslTrivia)>, DslTrivia) { - let mut out = Vec::new(); - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, &format!("to open the `{what}` block")); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident(&format!("as a {what} name")) else { - sync_to(s, &[]); - break; - }; - s.expect(&T::Colon, "before the type"); - let ty = parse_type(s); - let span = start.to(ty.span); - s.accept_outer_docs(&leading, span); - out.push((name, ty, span, leading)); - } - } - } - (out, trailing) -} - -/// `emits { name(field: type, …), … }` -pub fn parse_emits_block(s: &mut DslStream) -> (Vec, DslTrivia) { - let mut out = Vec::new(); - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, "to open the `emits` block"); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, mut end)) = s.expect_ident("as an emit name") else { - sync_to(s, &[]); - break; - }; - let mut params = Vec::new(); - let mut params_trailing = DslTrivia::default(); - if *s.peek() == T::LParen { - s.bump(); - if *s.peek() != T::RParen { - loop { - let param_leading = s.take_leading(); - if *s.peek() == T::RParen { - params_trailing = param_leading; - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - break; - } - let pstart = s.peek_span(); - let Some((pname, _)) = s.expect_ident("as a payload field name") else { - break; - }; - s.expect(&T::Colon, "before the field type"); - let ty = parse_type(s); - let pspan = pstart.to(ty.span); - s.accept_outer_docs(¶m_leading, pspan); - params.push(EmitParam { - name: pname, - ty, - span: pspan, - leading: param_leading, - }); - if !s.eat(&T::Comma) { - break; - } - } - } - if params_trailing.is_empty() { - params_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - } - end = s.peek_span(); - s.expect(&T::RParen, "to close the emit payload"); - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - out.push(EmitDecl { - name, - params, - params_trailing, - span, - leading, - }); - } - } - } - (out, trailing) -} - -/// `param user: id` -pub fn parse_param(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - if !s.eat_ident("param") { - return None; - } - let (name, _) = s.expect_ident("as the route parameter name")?; - s.expect(&T::Colon, "before the parameter type"); - let ty = parse_type(s); - let span = start.to(ty.span); - s.accept_outer_docs(&leading, span); - Some(ParamDecl { - name, - ty, - span, - leading, - }) -} - -// ── store ─────────────────────────────────────────────────────────────────── - -pub fn parse_store(s: &mut DslStream) -> Store { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("store"); - s.expect(&T::LBrace, "to open the store block"); - let mut state = Vec::new(); - let mut state_present = false; - let mut handlers = Vec::new(); - let mut state_leading = DslTrivia::default(); - let mut state_trailing = DslTrivia::default(); - let mut trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed `store { … }`", span); - break; - } - T::Ident(k) if k == "state" => { - state_present = true; - state_leading = s.take_leading(); - let target = s.peek_span(); - s.reject_docs(&state_leading, target); - s.bump(); - state_trailing = parse_state_block(s, &mut state); - } - T::Ident(k) if k == "on" => { - if let Some(h) = parse_handler(s) { - handlers.push(h); - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `state` or `on` in the store, found {desc}"), - span, - ); - sync_to(s, &["state", "on"]); - } - } - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - Store { - state_present, - state, - handlers, - state_leading, - state_trailing, - trailing, - span, - leading, - } -} - -fn parse_state_block(s: &mut DslStream, out: &mut Vec) -> DslTrivia { - let mut trailing = DslTrivia::default(); - s.expect(&T::LBrace, "to open the state block"); - loop { - match s.peek() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let leading = s.take_leading(); - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident("as a state field name") else { - sync_to(s, &["on"]); - break; - }; - s.expect(&T::Colon, "before the field type"); - let ty = parse_type(s); - s.expect( - &T::Eq, - "before the initial value (state initializers are literals)", - ); - let (init, end) = parse_literal(s); - let span = start.to(end); - s.accept_outer_docs(&leading, span); - out.push(StateField { - name, - ty, - init, - span, - leading, - }); - } - } - } - trailing -} - -fn parse_literal(s: &mut DslStream) -> (Literal, Span) { - let span = s.peek_span(); - let lit = match s.peek().clone() { - T::Int(i) => { - s.bump(); - Literal::Int(i) - } - T::Str(v) => { - s.bump(); - Literal::Str(v) - } - T::Ident(name) => match name.as_str() { - "true" => { - s.bump(); - Literal::Bool(true) - } - "false" => { - s.bump(); - Literal::Bool(false) - } - "none" => { - s.bump(); - Literal::None - } - _ => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("state initializers are literals only (§4.3), found `{name}`"), - span, - ); - s.bump(); - Literal::Error - } - }, - T::LBrace => { - s.bump(); - let end = s.peek_span(); - if s.expect( - &T::RBrace, - "— `{}` (the empty map) is the only brace literal here", - ) - .is_some() - { - return (Literal::EmptyMap, span.to(end)); - } - Literal::Error - } - T::Minus => { - // Negative integer literal. - s.bump(); - if let T::Int(i) = s.peek().clone() { - let end = s.peek_span(); - s.bump(); - return (Literal::Int(-i), span.to(end)); - } - s.cur.error( - codes::UNEXPECTED_TOKEN, - "expected an integer after `-`", - span, - ); - Literal::Error - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a literal initializer, found {desc}"), - span, - ); - s.bump(); - Literal::Error - } - }; - (lit, span) -} - -fn parse_handler(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("on"); - let (first, fspan) = s.expect_ident("as the event name")?; - - // `on .ok(…)` / `.err(…)` — outcome handlers. - let event = if *s.peek() == T::Dot { - s.bump(); - let (which, wspan) = s.expect_ident("(`ok` or `err`) after `.`")?; - let kind = match which.as_str() { - "ok" => OutcomeKind::Ok, - "err" => OutcomeKind::Err, - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("outcome handlers are `.ok` or `.err`, found `.{other}`"), - wspan, - ); - OutcomeKind::Err - } - }; - EventRef::Outcome { - command: first, - which: kind, - span: fspan.to(wspan), - } - } else { - EventRef::Semantic { - name: first, - span: fspan, - } - }; - - // Parameter list: UI events declare `name: type`; outcome handlers are - // name-only. - let mut params = Vec::new(); - let mut params_trailing = DslTrivia::default(); - if *s.peek() == T::LParen { - s.bump(); - if *s.peek() != T::RParen { - loop { - let param_leading = s.take_leading(); - if *s.peek() == T::RParen { - params_trailing = param_leading; - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - break; - } - let pstart = s.peek_span(); - let Some((pname, pspan)) = s.expect_ident("as a handler parameter") else { - break; - }; - let ty = if s.eat(&T::Colon) { - Some(parse_type(s)) - } else { - None - }; - let span = ty.as_ref().map_or(pspan, |t| pstart.to(t.span)); - s.accept_outer_docs(¶m_leading, span); - params.push(HandlerParam { - name: pname, - ty, - span, - leading: param_leading, - }); - if !s.eat(&T::Comma) { - break; - } - } - } - if params_trailing.is_empty() { - params_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(¶ms_trailing, boundary); - } - s.expect(&T::RParen, "to close the handler parameters"); - } - - let guard = if s.eat_ident("when") { - Some(parse_expr(s)) - } else { - None - }; - - s.expect(&T::LBrace, "to open the handler body"); - let mut body = Vec::new(); - let mut body_trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - body_trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&body_trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed handler body", span); - break; - } - _ => match parse_stmt(s) { - Some(st) => body.push(st), - None => { - sync_to( - s, - &["set", "send", "open-surface", "dismiss", "navigate", "on"], - ); - if s.peek().is_ident("on") { - // Missing `}` — let the store loop pick the next handler. - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "handler body not closed", span); - break; - } - } - }, - } - } - let span = start.to(end); - s.accept_outer_docs(&leading, span); - Some(Handler { - event, - params, - params_trailing, - guard, - body, - body_trailing, - span, - leading, - }) -} - -/// The five statements (design §4.2). -fn parse_stmt(s: &mut DslStream) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - let T::Ident(kw) = s.peek().clone() else { - let desc = s.peek().describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a statement (set | send | open-surface | dismiss | navigate), found {desc}"), - start, - ); - return None; - }; - let parsed = match kw.as_str() { - "set" => { - s.bump(); - let (field, fspan) = s.expect_ident("as the state field")?; - let key = if s.eat(&T::LBracket) { - let k = parse_expr(s); - s.expect(&T::RBracket, "to close the map key"); - Some(k) - } else { - None - }; - let path_span = fspan; - s.expect(&T::Eq, "in `set = `"); - let value = parse_expr(s); - let span = start.to(value.span); - Some(Stmt::Set { - path: SetPath { - field, - key, - span: path_span, - }, - value, - span, - leading, - }) - } - "send" => { - s.bump(); - let (command, _) = s.expect_ident("as the command name")?; - let args = parse_args(s); - let bind = if s.eat_ident("as") { - s.expect_ident("as the tag binding name").map(|(n, _)| n) - } else { - None - }; - let span = start.to(s.peek_span()); - Some(Stmt::Send { - command, - args, - bind, - span, - leading, - }) - } - "open-surface" => { - s.bump(); - let (name, _) = s.expect_ident("as the surface name")?; - let args = parse_args(s); - let span = start.to(s.peek_span()); - Some(Stmt::OpenSurface { - name, - args, - span, - leading, - }) - } - "dismiss" => { - s.bump(); - Some(Stmt::Dismiss { - span: start, - leading, - }) - } - "navigate" => { - s.bump(); - let (mut target_name, tspan) = - s.expect_ident("as a route name, `replace`, or `back`")?; - if target_name == "back" { - Some(Stmt::Navigate { - target: NavTarget::Back, - span: start.to(tspan), - leading, - }) - } else { - let replace = target_name == "replace"; - if replace { - (target_name, _) = s.expect_ident("as the route name after `replace`")?; - } - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - Some(Stmt::Navigate { - target: if replace { - NavTarget::Replace { - name: target_name, - args, - } - } else { - NavTarget::Route { - name: target_name, - args, - } - }, - span, - leading, - }) - } - } - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "`{other}` is not a statement — the closed set is \ - set | send | open-surface | dismiss | navigate (§4.2)" - ), - start, - ); - None - } - }; - if let Some(stmt) = &parsed { - let span = match stmt { - Stmt::Set { span, .. } - | Stmt::Send { span, .. } - | Stmt::OpenSurface { span, .. } - | Stmt::Dismiss { span, .. } - | Stmt::Navigate { span, .. } - | Stmt::Error { span } => *span, - }; - let leading = match stmt { - Stmt::Set { leading, .. } - | Stmt::Send { leading, .. } - | Stmt::OpenSurface { leading, .. } - | Stmt::Dismiss { leading, .. } - | Stmt::Navigate { leading, .. } => Some(leading), - Stmt::Error { .. } => None, - }; - if let Some(leading) = leading { - s.reject_docs(leading, span); - } - } - parsed -} diff --git a/crates/uhura-syntax/src/parser/examples.rs b/crates/uhura-syntax/src/parser/examples.rs deleted file mode 100644 index 0bab62e..0000000 --- a/crates/uhura-syntax/src/parser/examples.rs +++ /dev/null @@ -1,397 +0,0 @@ -//! `.examples.uhura` files (design §6.1): `use fixture …` imports plus -//! `example [default] { clauses }` declarations. Pure DSL surface. - -use uhura_base::codes; - -use crate::ast::*; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr}; -use super::stream::DslStream; - -pub fn parse_examples(s: &mut DslStream) -> ExamplesFile { - let mut preamble = DslTrivia::default(); - let mut uses = Vec::new(); - let mut examples = Vec::new(); - let trailing; - let mut first_item = true; - loop { - match s.peek().clone() { - T::Eof => { - trailing = s.take_leading(); - let eof = s.peek_span(); - if first_item { - s.accept_file_docs_at_eof(&trailing, eof); - preamble = trailing.clone(); - } else { - s.reject_boundary_docs(&trailing, eof); - } - break; - } - T::Ident(k) if k == "use" => { - if let Some(u) = super::dsl::parse_use(s, first_item) { - if first_item { - preamble = use_leading(&u).clone(); - } - uses.push(u); - } - first_item = false; - } - T::Ident(k) if k == "example" => { - if let Some(e) = parse_example(s, first_item) { - if first_item { - preamble = e.leading.clone(); - } - examples.push(e); - } - first_item = false; - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `use fixture …` or `example …`, found {desc}"), - span, - ); - s.bump(); - } - } - } - ExamplesFile { - preamble, - uses, - examples, - trailing, - } -} - -fn use_leading(use_decl: &Use) -> &DslTrivia { - match use_decl { - Use::Component { leading, .. } - | Use::Surface { leading, .. } - | Use::Port { leading, .. } - | Use::Fixture { leading, .. } => leading, - } -} - -fn parse_example(s: &mut DslStream, file_preamble: bool) -> Option { - let leading = s.take_leading(); - let start = s.peek_span(); - s.eat_ident("example"); - let (name, _) = s.expect_ident("as the example name")?; - let is_default = s.eat_ident("default"); - s.expect(&T::LBrace, "to open the example body"); - - let mut clauses = Vec::new(); - let mut clause_leading = Vec::new(); - let mut trailing = DslTrivia::default(); - let mut end = start; - loop { - match s.peek().clone() { - T::RBrace => { - trailing = s.take_leading(); - let boundary = s.peek_span(); - s.reject_boundary_docs(&trailing, boundary); - end = s.bump().span; - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed example body", span); - break; - } - T::Ident(k) => { - let clause_trivia = s.take_leading(); - let cstart = s.peek_span(); - match k.as_str() { - "from" => { - s.bump(); - if let Some((from, fspan)) = s.expect_ident("as the parent example") { - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::From { - name: from, - span: cstart.to(fspan), - }, - ); - } - } - "note" => { - s.bump(); - if let T::Str(text) = s.peek().clone() { - let tspan = s.peek_span(); - s.bump(); - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Note { - text, - span: cstart.to(tspan), - }, - ); - } else { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "`note` takes a string literal", - span, - ); - } - } - "params" | "props" | "state" => { - s.bump(); - let entries = parse_assign_block(s, &k); - let span = cstart.to(s.peek_span()); - let clause = match k.as_str() { - "params" => ExampleClause::Params { entries, span }, - "props" => ExampleClause::Props { entries, span }, - _ => ExampleClause::State { entries, span }, - }; - push_clause(s, &mut clauses, &mut clause_leading, clause_trivia, clause); - } - "projection" => { - s.bump(); - if let Some(pin) = parse_projection_pin(s) { - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Projection(pin), - ); - } - } - "events" => { - s.bump(); - let entries = parse_events_list(s); - let span = cstart.to(s.peek_span()); - push_clause( - s, - &mut clauses, - &mut clause_leading, - clause_trivia, - ExampleClause::Events { entries, span }, - ); - } - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!( - "unknown example clause `{other}` — clauses are from | note | \ - params | props | state | projection | events" - ), - cstart, - ); - s.reject_docs(&clause_trivia, cstart); - s.bump(); - } - } - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected an example clause, found {desc}"), - span, - ); - s.bump(); - } - } - } - let span = start.to(end); - if file_preamble { - s.accept_preamble_docs(&leading, span); - } else { - s.accept_outer_docs(&leading, span); - } - Some(ExampleDecl { - name, - is_default, - clauses, - clause_leading, - trailing, - span, - leading, - }) -} - -fn push_clause( - s: &mut DslStream, - clauses: &mut Vec, - leading: &mut Vec, - trivia: DslTrivia, - clause: ExampleClause, -) { - let span = clause_span(&clause); - s.reject_docs(&trivia, span); - leading.push(trivia); - clauses.push(clause); -} - -fn clause_span(clause: &ExampleClause) -> uhura_base::Span { - match clause { - ExampleClause::From { span, .. } - | ExampleClause::Note { span, .. } - | ExampleClause::Params { span, .. } - | ExampleClause::Props { span, .. } - | ExampleClause::State { span, .. } - | ExampleClause::Events { span, .. } - | ExampleClause::Error { span } => *span, - ExampleClause::Projection(pin) => pin.span, - } -} - -/// `{ name = expr, … }` for params / props / state clauses. -fn parse_assign_block(s: &mut DslStream, what: &str) -> Vec<(String, Expr)> { - let mut out = Vec::new(); - if s.expect(&T::LBrace, &format!("to open the `{what}` clause")) - .is_none() - { - return out; - } - loop { - match s.peek() { - T::RBrace => { - s.bump(); - break; - } - T::Eof => break, - T::Comma => { - s.bump(); - } - _ => { - let Some((name, _)) = s.expect_ident(&format!("as a {what} entry name")) else { - break; - }; - s.expect(&T::Eq, "before the value"); - out.push((name, parse_expr(s))); - } - } - } - out -} - -/// `feed.feed-page = expr` or `comments.for-post("post-1") = expr` -/// (the leading `projection` keyword is already consumed). -fn parse_projection_pin(s: &mut DslStream) -> Option { - let start = s.peek_span(); - let (port, _) = s.expect_ident("as the port name")?; - s.expect(&T::Dot, "between port and projection"); - let (projection, _) = s.expect_ident("as the projection name")?; - let key = if *s.peek() == T::LParen { - s.bump(); - let k = parse_expr(s); - s.expect(&T::RParen, "to close the projection key"); - Some(k) - } else { - None - }; - s.expect(&T::Eq, "before the pinned value"); - let value = parse_expr(s); - let span = start.to(value.span); - Some(ProjectionPin { - port, - projection, - key, - value, - span, - }) -} - -/// `[ entry … ]` — the derivation timeline (design §6.2). -fn parse_events_list(s: &mut DslStream) -> Vec { - let mut out = Vec::new(); - if s.expect(&T::LBracket, "to open the events timeline") - .is_none() - { - return out; - } - loop { - match s.peek().clone() { - T::RBracket => { - s.bump(); - break; - } - T::Eof => { - let span = s.peek_span(); - s.cur - .error(codes::UNCLOSED_BLOCK, "unclosed events timeline", span); - break; - } - T::Comma => { - s.bump(); - } - T::Ident(k) if k == "outcome" => { - let start = s.peek_span(); - s.bump(); - let Some((command, _)) = s.expect_ident("as the command name") else { - continue; - }; - s.expect(&T::Dot, "before `ok` or `err`"); - let which = match s.expect_ident("(`ok` or `err`)") { - Some((w, wspan)) => match w.as_str() { - "ok" => OutcomeKind::Ok, - "err" => OutcomeKind::Err, - other => { - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected `ok` or `err`, found `{other}`"), - wspan, - ); - OutcomeKind::Err - } - }, - None => OutcomeKind::Err, - }; - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - out.push(ExampleEvent::Outcome { - command, - which, - args, - span, - }); - } - T::Ident(k) if k == "projection" => { - s.bump(); - if let Some(pin) = parse_projection_pin(s) { - out.push(ExampleEvent::Projection(pin)); - } - } - T::Ident(_) => { - let start = s.peek_span(); - let (name, _) = s.expect_ident("as the event name").unwrap(); - let args = if *s.peek() == T::LParen { - parse_args(s) - } else { - Vec::new() - }; - let span = start.to(s.peek_span()); - out.push(ExampleEvent::Semantic { name, args, span }); - } - other => { - let desc = other.describe(); - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a timeline entry, found {desc}"), - span, - ); - s.bump(); - } - } - } - out -} diff --git a/crates/uhura-syntax/src/parser/expr.rs b/crates/uhura-syntax/src/parser/expr.rs deleted file mode 100644 index be64d01..0000000 --- a/crates/uhura-syntax/src/parser/expr.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! The total, tiny, closed expression language (design §4.3) and type -//! expressions. Precedence, loosest → tightest (micro-decision #4): -//! -//! `if-then-else` < `||` < `&&` < comparison (non-assoc) < `??` -//! < `+ - ++` < unary `! -` < postfix `.field` `[k]` `(call)` - -use uhura_base::codes; - -use crate::ast::{Arg, BinaryOp, Expr, ExprKind, TypeExpr, TypeKind, UnaryOp}; -use crate::token::TokenKind as T; - -use super::stream::DslStream; - -pub fn parse_expr(s: &mut DslStream) -> Expr { - parse_if_expr(s) -} - -fn parse_if_expr(s: &mut DslStream) -> Expr { - let start = s.peek_span(); - if s.peek().is_ident("if") { - s.bump(); - let cond = parse_or(s); - s.expect(&T::Ident("then".into()), "in `if … then … else …`"); - let then = parse_if_expr(s); - let els = if s.eat_ident("else") { - parse_if_expr(s) - } else { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "`if` expressions require an `else` branch (§4.3: both branches, same type)", - span, - ); - Expr { - kind: ExprKind::Error, - span, - } - }; - let span = start.to(els.span); - return Expr { - kind: ExprKind::If { - cond: Box::new(cond), - then: Box::new(then), - els: Box::new(els), - }, - span, - }; - } - parse_or(s) -} - -fn parse_or(s: &mut DslStream) -> Expr { - let mut lhs = parse_and(s); - while *s.peek() == T::OrOr { - s.bump(); - let rhs = parse_and(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::Or, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_and(s: &mut DslStream) -> Expr { - let mut lhs = parse_cmp(s); - while *s.peek() == T::AndAnd { - s.bump(); - let rhs = parse_cmp(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::And, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn cmp_op(t: &T) -> Option { - match t { - T::EqEq => Some(BinaryOp::Eq), - T::NotEq => Some(BinaryOp::NotEq), - T::Lt => Some(BinaryOp::Lt), - T::Le => Some(BinaryOp::Le), - T::Gt => Some(BinaryOp::Gt), - T::Ge => Some(BinaryOp::Ge), - _ => None, - } -} - -fn parse_cmp(s: &mut DslStream) -> Expr { - let lhs = parse_coalesce(s); - let Some(op) = cmp_op(s.peek()) else { - return lhs; - }; - s.bump(); - let rhs = parse_coalesce(s); - let span = lhs.span.to(rhs.span); - let out = Expr { - kind: ExprKind::Binary { - op, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - // Comparison is non-associative: a second comparison operator here is - // a hard parse error (design §4.3). - if cmp_op(s.peek()).is_some() { - let span = s.peek_span(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - "comparison operators do not chain — parenthesize explicitly", - span, - ); - s.bump(); - let _ = parse_coalesce(s); - } - out -} - -fn parse_coalesce(s: &mut DslStream) -> Expr { - let mut lhs = parse_additive(s); - while *s.peek() == T::Coalesce { - s.bump(); - let rhs = parse_additive(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op: BinaryOp::Coalesce, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_additive(s: &mut DslStream) -> Expr { - let mut lhs = parse_unary(s); - loop { - let op = match s.peek() { - T::Plus => BinaryOp::Add, - T::Minus => BinaryOp::Sub, - T::PlusPlus => BinaryOp::Concat, - _ => break, - }; - s.bump(); - let rhs = parse_unary(s); - let span = lhs.span.to(rhs.span); - lhs = Expr { - kind: ExprKind::Binary { - op, - lhs: Box::new(lhs), - rhs: Box::new(rhs), - }, - span, - }; - } - lhs -} - -fn parse_unary(s: &mut DslStream) -> Expr { - let start = s.peek_span(); - let op = match s.peek() { - T::Bang => Some(UnaryOp::Not), - T::Minus => Some(UnaryOp::Neg), - _ => None, - }; - if let Some(op) = op { - s.bump(); - let expr = parse_unary(s); - let span = start.to(expr.span); - return Expr { - kind: ExprKind::Unary { - op, - expr: Box::new(expr), - }, - span, - }; - } - parse_postfix(s) -} - -fn parse_postfix(s: &mut DslStream) -> Expr { - let mut expr = parse_primary(s); - loop { - match s.peek() { - T::Dot => { - s.bump(); - if let Some((name, nspan)) = s.expect_ident("after `.`") { - let span = expr.span.to(nspan); - expr = Expr { - kind: ExprKind::Field { - base: Box::new(expr), - name, - }, - span, - }; - } else { - break; - } - } - T::LBracket => { - s.bump(); - let key = parse_expr(s); - let end = s.peek_span(); - s.expect(&T::RBracket, "to close the index"); - let span = expr.span.to(end); - expr = Expr { - kind: ExprKind::Index { - base: Box::new(expr), - key: Box::new(key), - }, - span, - }; - } - _ => break, - } - } - expr -} - -fn parse_primary(s: &mut DslStream) -> Expr { - let t = s.peek_token(); - let span = t.span; - match s.peek().clone() { - T::Int(i) => { - s.bump(); - Expr { - kind: ExprKind::Int(i), - span, - } - } - T::Str(v) => { - s.bump(); - Expr { - kind: ExprKind::Str(v), - span, - } - } - T::Ident(name) => { - match name.as_str() { - "true" => { - s.bump(); - return Expr { - kind: ExprKind::Bool(true), - span, - }; - } - "false" => { - s.bump(); - return Expr { - kind: ExprKind::Bool(false), - span, - }; - } - "none" => { - s.bump(); - return Expr { - kind: ExprKind::None, - span, - }; - } - _ => {} - } - s.bump(); - // Call form: `name(expr, …)` — builtins and keyed projections. - if *s.peek() == T::LParen { - s.bump(); - let mut args = Vec::new(); - if *s.peek() != T::RParen { - loop { - args.push(parse_expr(s)); - if !s.eat(&T::Comma) { - break; - } - } - } - let end = s.peek_span(); - s.expect(&T::RParen, "to close the call"); - return Expr { - kind: ExprKind::Call { name, args }, - span: span.to(end), - }; - } - Expr { - kind: ExprKind::Ident(name), - span, - } - } - T::LParen => { - s.bump(); - let inner = parse_expr(s); - s.expect(&T::RParen, "to close the group"); - inner - } - T::LBrace => { - // Record literal `{ field: expr, … }` (set-rhs and example pins). - s.bump(); - let mut fields = Vec::new(); - if *s.peek() != T::RBrace { - loop { - let Some((name, _)) = s.expect_ident("as a record field name") else { - break; - }; - s.expect(&T::Colon, "after the field name"); - fields.push((name, parse_expr(s))); - if !s.eat(&T::Comma) { - break; - } - } - } - let end = s.peek_span(); - s.expect(&T::RBrace, "to close the record literal"); - Expr { - kind: ExprKind::Record(fields), - span: span.to(end), - } - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected an expression, found {desc}"), - span, - ); - s.bump(); - Expr { - kind: ExprKind::Error, - span, - } - } - } -} - -/// Named argument list: `(name: expr, …)` — the opening paren is expected -/// by the caller's context description. -pub fn parse_args(s: &mut DslStream) -> Vec { - let mut args = Vec::new(); - if s.expect(&T::LParen, "to open the argument list").is_none() { - return args; - } - if *s.peek() != T::RParen { - loop { - let start = s.peek_span(); - let Some((name, _)) = s.expect_ident("as an argument name") else { - break; - }; - s.expect( - &T::Colon, - "after the argument name (all arguments are named)", - ); - let value = parse_expr(s); - let span = start.to(value.span); - args.push(Arg { name, value, span }); - if !s.eat(&T::Comma) { - break; - } - } - } - s.expect(&T::RParen, "to close the argument list"); - args -} - -/// Type expressions: `name`, `list[T]`, `map[K]V`, suffix `?`. -pub fn parse_type(s: &mut DslStream) -> TypeExpr { - let start = s.peek_span(); - let base = match s.peek().clone() { - T::Ident(name) => { - s.bump(); - match name.as_str() { - "list" if *s.peek() == T::LBracket => { - s.bump(); - let inner = parse_type(s); - let end = s.peek_span(); - s.expect(&T::RBracket, "to close `list[…]`"); - TypeExpr { - kind: TypeKind::List(Box::new(inner)), - span: start.to(end), - } - } - "map" if *s.peek() == T::LBracket => { - s.bump(); - let key = match s.expect_ident("as the map key type") { - Some((k, _)) => k, - None => "id".to_string(), - }; - s.expect(&T::RBracket, "to close the map key"); - let value = parse_type(s); - let span = start.to(value.span); - TypeExpr { - kind: TypeKind::Map(key, Box::new(value)), - span, - } - } - _ => TypeExpr { - kind: TypeKind::Name(name), - span: start, - }, - } - } - other => { - let desc = other.describe(); - s.cur.error( - codes::UNEXPECTED_TOKEN, - format!("expected a type, found {desc}"), - start, - ); - TypeExpr { - kind: TypeKind::Error, - span: start, - } - } - }; - if *s.peek() == T::Question { - let end = s.peek_span(); - s.bump(); - let span = base.span.to(end); - return TypeExpr { - kind: TypeKind::Option(Box::new(base)), - span, - }; - } - base -} diff --git a/crates/uhura-syntax/src/parser/markup.rs b/crates/uhura-syntax/src/parser/markup.rs deleted file mode 100644 index a710755..0000000 --- a/crates/uhura-syntax/src/parser/markup.rs +++ /dev/null @@ -1,1155 +0,0 @@ -//! The markup surface (design §4.4): elements, `{#if}` / `{#each}` / -//! `{#match}` blocks, `{expr}` interpolation, `on:` event bindings. Parsed -//! char-wise off the shared cursor; every `{…}` expression region drops into -//! the DSL parser and resyncs on its closing brace. - -use uhura_base::{Diagnostic, Span, codes}; - -use crate::ast::*; -use crate::cursor::Cursor; -use crate::token::TokenKind as T; - -use super::expr::{parse_args, parse_expr}; -use super::stream::DslStream; - -/// Why `parse_nodes` stopped. -#[derive(Debug, PartialEq, Eq)] -pub enum Stop { - /// `` — left unconsumed for the caller to match. - CloseTag, - /// `{:…}` — an arm marker; left unconsumed. - ArmMarker, - /// `{/…}` — a block close; left unconsumed. - BlockClose, - /// ` other than whitespace is misplaced. - let tail_start = cur.pos(); - let tail = cur.rest().trim(); - if !tail.is_empty() { - cur.error( - codes::MISPLACED_SECTION, - "content after `` — the style block ends the file", - Span::new(cur.file, tail_start, tail_start + 1), + fn parse_expr(&mut self) -> Expr { + self.parse_lambda() + } + + fn parse_lambda(&mut self) -> Expr { + if self.lambda_ahead() { + let start = self.current().span.start; + let parameters = if self.eat(TokenKind::LParen) { + let open = self.tokens[self.cursor - 1].span; + let mut values = Vec::new(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + values.push(self.parse_pattern()); + if !self.eat(TokenKind::Comma) { + break; + } + } + let close = self.expect(TokenKind::RParen, "`)` after lambda parameters"); + if values.len() < 2 { + self.error( + ParseDiagnosticKind::InvalidPattern, + "parenthesized lambda parameters require at least two bindings", + open.to(close), + ); + } + values + } else { + vec![self.parse_atomic_pattern()] + }; + for parameter in ¶meters { + self.validate_lambda_parameter(parameter); + } + self.expect(TokenKind::FatArrow, "`=>` after lambda parameters"); + let body = self.parse_expr(); + let end = body.span.end; + return Spanned::new( + ExprKind::Lambda { + parameters, + body: Box::new(body), + }, + SourceSpan::new(self.source_id.file, start, end), ); } + self.parse_binary(1) } - File { - preamble, - kind, - uses, - props_present, - props_leading, - props, - props_trailing, - emits_present, - emits_leading, - emits, - emits_trailing, - params, - store, - trailing_dsl, - markup, - style, + fn lambda_ahead(&self) -> bool { + if matches!(self.current().kind, TokenKind::Ident(_)) { + return matches!( + self.tokens.get(self.cursor + 1).map(|token| &token.kind), + Some(TokenKind::FatArrow) + ); + } + if self.at(TokenKind::LParen) { + return self + .find_matching(self.cursor, TokenKind::LParen, TokenKind::RParen) + .is_some_and(|end| { + matches!( + self.tokens.get(end + 1).map(|token| &token.kind), + Some(TokenKind::FatArrow) + ) + }); + } + false } -} -fn def_kind_span(kind: &DefKind) -> Span { - match kind { - DefKind::Component { span, .. } - | DefKind::Page { span } - | DefKind::Surface { span, .. } - | DefKind::Error { span } => *span, + fn parse_binary(&mut self, minimum_precedence: u8) -> Expr { + let mut left = self.parse_unary(); + let mut saw_equality = false; + let mut saw_relational = false; + loop { + if self.at_word("is") && 3 >= minimum_precedence { + let op = self.bump().span; + if saw_equality { + self.error( + ParseDiagnosticKind::InvalidExpression, + "equality and `is` comparisons do not chain", + op, + ); + } + saw_equality = true; + let pattern = self.parse_pattern(); + let span = left.span.to(pattern.span).to(op); + left = Spanned::new( + ExprKind::Is { + value: Box::new(left), + pattern, + }, + span, + ); + continue; + } + + let Some((precedence, op)) = self.current_binary_op() else { + break; + }; + if precedence < minimum_precedence { + break; + } + let op_span = self.bump().span; + match precedence { + 3 => { + if saw_equality { + self.error( + ParseDiagnosticKind::InvalidExpression, + "equality and `is` comparisons do not chain", + op_span, + ); + } + saw_equality = true; + } + 4 => { + if saw_relational { + self.error( + ParseDiagnosticKind::InvalidExpression, + "relational comparisons do not chain", + op_span, + ); + } + saw_relational = true; + } + _ => {} + } + let right = self.parse_binary(precedence + 1); + let span = left.span.to(right.span); + left = Spanned::new( + ExprKind::Binary { + left: Box::new(left), + op: Spanned::new(op, op_span), + right: Box::new(right), + }, + span, + ); + } + left } -} -fn parse_def_kind(s: &mut DslStream) -> DefKind { - let start = s.peek_span(); - let T::Ident(kw) = s.peek().clone() else { - let span = s.peek_span(); - s.cur.error( - codes::MISPLACED_SECTION, - "a .uhura file starts with `component `, `page`, or `surface `", - span, - ); - return DefKind::Error { span: start }; - }; - match kw.as_str() { - "component" => { - s.bump(); - match s.expect_ident("as the component name") { - Some((name, nspan)) => DefKind::Component { - name, - span: start.to(nspan), + fn current_binary_op(&self) -> Option<(u8, BinaryOp)> { + match &self.current().kind { + TokenKind::Ident(value) if value == "or" => Some((1, BinaryOp::Or)), + TokenKind::Ident(value) if value == "and" => Some((2, BinaryOp::And)), + TokenKind::EqEq => Some((3, BinaryOp::Equal)), + TokenKind::NotEqual => Some((3, BinaryOp::NotEqual)), + TokenKind::Less => Some((4, BinaryOp::Less)), + TokenKind::LessEqual => Some((4, BinaryOp::LessEqual)), + TokenKind::Greater => Some((4, BinaryOp::Greater)), + TokenKind::GreaterEqual => Some((4, BinaryOp::GreaterEqual)), + TokenKind::Plus => Some((5, BinaryOp::Add)), + TokenKind::Minus => Some((5, BinaryOp::Subtract)), + TokenKind::Star => Some((6, BinaryOp::Multiply)), + _ => None, + } + } + + fn parse_unary(&mut self) -> Expr { + let start = self.current().span.start; + let operation = if self.at_word("not") { + Some((UnaryOp::Not, self.bump().span)) + } else if self.at(TokenKind::Minus) { + Some((UnaryOp::Negate, self.bump().span)) + } else { + None + }; + if let Some((operation, operation_span)) = operation { + let operand = self.parse_unary(); + let end = operand.span.end; + Spanned::new( + ExprKind::Unary { + op: Spanned::new(operation, operation_span), + operand: Box::new(operand), }, - None => DefKind::Error { span: start }, + SourceSpan::new(self.source_id.file, start, end), + ) + } else { + self.parse_postfix() + } + } + + fn parse_postfix(&mut self) -> Expr { + let mut value = self.parse_primary(); + let mut saw_update = false; + let mut diagnosed_post_update_suffix = false; + loop { + if self.eat(TokenKind::LParen) { + if saw_update && !diagnosed_post_update_suffix { + self.error( + ParseDiagnosticKind::InvalidExpression, + "a record update must be parenthesized before applying a postfix suffix", + self.tokens[self.cursor - 1].span, + ); + diagnosed_post_update_suffix = true; + } + // Match arms have no comma terminator. In + // `current == expected` followed by `(next, pattern) =>`, + // the next arm's tuple would otherwise be consumed as a + // call on `expected`. A parenthesized group immediately + // followed by `=>` is unambiguously the next arm pattern. + let open_index = self.cursor.saturating_sub(1); + if self + .find_matching(open_index, TokenKind::LParen, TokenKind::RParen) + .is_some_and(|end| { + matches!( + self.tokens.get(end + 1).map(|token| &token.kind), + Some(TokenKind::FatArrow) + ) + }) + { + self.cursor = open_index; + break; + } + let arguments = self.parse_expr_list(TokenKind::RParen); + let close = self.expect(TokenKind::RParen, "`)` after call arguments"); + let span = value.span.to(close); + value = Spanned::new( + ExprKind::Call { + callee: Box::new(value), + arguments, + }, + span, + ); + } else if self.eat(TokenKind::Dot) { + if saw_update && !diagnosed_post_update_suffix { + self.error( + ParseDiagnosticKind::InvalidExpression, + "a record update must be parenthesized before applying a postfix suffix", + self.tokens[self.cursor - 1].span, + ); + diagnosed_post_update_suffix = true; + } + let member = self.expect_name("member name"); + let span = value.span.to(member.span); + value = Spanned::new( + ExprKind::Member { + receiver: Box::new(value), + member, + }, + span, + ); + } else if self.eat(TokenKind::LBracket) { + if saw_update && !diagnosed_post_update_suffix { + self.error( + ParseDiagnosticKind::InvalidExpression, + "a record update must be parenthesized before applying a postfix suffix", + self.tokens[self.cursor - 1].span, + ); + diagnosed_post_update_suffix = true; + } + let index = self.parse_expr(); + let close = self.expect(TokenKind::RBracket, "`]` after index"); + let span = value.span.to(close); + value = Spanned::new( + ExprKind::Index { + receiver: Box::new(value), + index: Box::new(index), + }, + span, + ); + } else if self.at_word("with") { + let with_span = self.bump().span; + if saw_update { + self.error( + ParseDiagnosticKind::InvalidExpression, + "record updates permit only one `with` clause", + with_span, + ); + } + let fields = self.parse_record_entries(); + let span = + SourceSpan::new(self.source_id.file, value.span.start, self.previous_end()); + value = Spanned::new( + ExprKind::Update { + base: Box::new(value), + fields, + }, + span, + ); + saw_update = true; + } else { + break; } } - "page" => { - s.bump(); - DefKind::Page { span: start } + value + } + + fn parse_primary(&mut self) -> Expr { + let token = self.current().clone(); + match token.kind { + TokenKind::Integer(value) => { + self.bump(); + Spanned::new(ExprKind::Integer(value), token.span) + } + TokenKind::Decimal(value) => { + self.bump(); + Spanned::new(ExprKind::Decimal(value), token.span) + } + TokenKind::Text(value) => { + self.bump(); + Spanned::new(ExprKind::Text(value), token.span) + } + TokenKind::Ident(ref value) if value == "true" || value == "false" => { + let value = value == "true"; + self.bump(); + Spanned::new(ExprKind::Bool(value), token.span) + } + TokenKind::Ident(ref value) if value == "if" => self.parse_if(), + TokenKind::Ident(ref value) if value == "match" => self.parse_match(), + TokenKind::Ident(ref value) if value == "collect" => self.parse_collect(), + TokenKind::Ident(ref value) + if value == "Set" + && matches!( + self.tokens.get(self.cursor + 1).map(|token| &token.kind), + Some(TokenKind::LBrace) + ) => + { + self.parse_set_comprehension() + } + TokenKind::Ident(ref value) if value == "finish" => { + let start = self.bump().span.start; + let outcome = self.parse_expr(); + let end = outcome.span.end; + Spanned::new( + ExprKind::Finish(Box::new(outcome)), + SourceSpan::new(self.source_id.file, start, end), + ) + } + TokenKind::Ident(ref value) if value == "unreachable" => { + self.bump(); + Spanned::new(ExprKind::Unreachable, token.span) + } + TokenKind::Ident(_) => { + let name = self.expect_name("name"); + let span = name.span; + Spanned::new(ExprKind::Name(name), span) + } + TokenKind::LParen => self.parse_tuple_or_group(), + TokenKind::LBracket => { + let start = self.bump().span.start; + let values = self.parse_expr_list(TokenKind::RBracket); + let close = self.expect(TokenKind::RBracket, "`]` after sequence"); + Spanned::new( + ExprKind::Sequence(values), + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + TokenKind::LBrace if self.looks_like_record_literal() => { + let start = self.current().span.start; + let entries = self.parse_record_entries(); + Spanned::new( + ExprKind::Record(entries), + SourceSpan::new(self.source_id.file, start, self.previous_end()), + ) + } + TokenKind::LBrace => { + let block = self.parse_block(); + let span = block.span; + Spanned::new(ExprKind::Block(block), span) + } + _ => { + self.error( + ParseDiagnosticKind::InvalidExpression, + format!("expected an expression, found {}", token.kind.describe()), + token.span, + ); + self.bump(); + Spanned::new(ExprKind::Error, token.span) + } + } + } + + fn parse_tuple_or_group(&mut self) -> Expr { + let open = self.bump().span; + let start = open.start; + if self.eat(TokenKind::RParen) { + let close = self.tokens[self.cursor - 1].span; + self.error( + ParseDiagnosticKind::InvalidExpression, + "Uhura has no unit tuple expression; use the selected `Unit` value form", + open.to(close), + ); + return Spanned::new( + ExprKind::Tuple(Vec::new()), + SourceSpan::new(self.source_id.file, start, self.previous_end()), + ); + } + let first = self.parse_expr(); + if !self.at(TokenKind::Comma) { + let close = self.expect(TokenKind::RParen, "`)` after grouped expression"); + return Spanned::new( + first.value, + SourceSpan::new(self.source_id.file, start, close.end), + ); + } + let comma = self.bump().span; + let mut values = vec![first]; + if self.at(TokenKind::RParen) { + self.error( + ParseDiagnosticKind::InvalidExpression, + "Uhura tuple expressions require at least two elements", + comma.to(self.current().span), + ); + } + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + values.push(self.parse_expr()); + if !self.eat(TokenKind::Comma) { + break; + } + } + let close = self.expect(TokenKind::RParen, "`)` after tuple"); + Spanned::new( + ExprKind::Tuple(values), + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + + fn parse_if(&mut self) -> Expr { + let start = self.bump().span.start; + let condition = self.parse_expr(); + let then_branch = if self.eat_word("then") { + self.parse_expr() + } else { + let block = self.parse_block(); + let span = block.span; + Spanned::new(ExprKind::Block(block), span) + }; + let else_branch = if self.eat_word("else") { + Some(Box::new(if self.at_word("if") { + self.parse_if() + } else { + self.parse_expr() + })) + } else { + None + }; + let end = else_branch + .as_ref() + .map_or(then_branch.span.end, |value| value.span.end); + Spanned::new( + ExprKind::If { + condition: Box::new(condition), + then_branch: Box::new(then_branch), + else_branch, + }, + SourceSpan::new(self.source_id.file, start, end), + ) + } + + fn parse_match(&mut self) -> Expr { + let start = self.bump().span.start; + let subject = self.parse_expr(); + self.expect(TokenKind::LBrace, "`{` after match subject"); + let mut arms = Vec::new(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let arm_start = self.current().span.start; + let pattern = self.parse_pattern(); + self.expect(TokenKind::FatArrow, "`=>` after match pattern"); + let body = self.parse_expr(); + arms.push(MatchArm { + pattern, + body, + span: SourceSpan::new(self.source_id.file, arm_start, self.previous_end()), + }); + } + let close = self.expect(TokenKind::RBrace, "`}` after match arms"); + Spanned::new( + ExprKind::Match { + subject: Box::new(subject), + arms, + }, + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + + fn parse_collect(&mut self) -> Expr { + let start = self.bump().span.start; + self.expect(TokenKind::LBracket, "`[` after `collect`"); + let mut clauses = Vec::new(); + while !self.at(TokenKind::RBracket) && !self.at(TokenKind::Eof) { + let clause_start = self.current().span.start; + self.expect_word("when"); + let condition = self.parse_expr(); + self.expect(TokenKind::FatArrow, "`=>` in collect clause"); + let value = self.parse_expr(); + clauses.push(CollectClause { + condition, + value, + span: SourceSpan::new(self.source_id.file, clause_start, self.previous_end()), + }); + } + let close = self.expect(TokenKind::RBracket, "`]` after collect clauses"); + Spanned::new( + ExprKind::Collect(clauses), + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + + fn parse_set_comprehension(&mut self) -> Expr { + let start = self.bump().span.start; + self.expect(TokenKind::LBrace, "`{` after `Set`"); + self.expect_word("for"); + let binding = self.parse_pattern(); + self.expect_word("in"); + let source = self.parse_expr(); + let mut filters = Vec::new(); + while self.eat_word("when") { + filters.push(self.parse_expr()); } - "surface" => { - s.bump(); - let Some((name, mut end)) = s.expect_ident("as the surface name") else { - return DefKind::Error { span: start }; + self.expect_word("yield"); + let value = self.parse_expr(); + let close = self.expect(TokenKind::RBrace, "`}` after set comprehension"); + Spanned::new( + ExprKind::SetComprehension { + binding, + source: Box::new(source), + filters, + value: Box::new(value), + }, + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + + fn parse_block(&mut self) -> Block { + let open = self.expect(TokenKind::LBrace, "`{` before block"); + let mut statements = Vec::new(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let before = self.cursor; + statements.push(self.parse_statement()); + if before == self.cursor { + self.bump(); + } + } + let close = self.expect(TokenKind::RBrace, "`}` after block"); + Block { + statements, + span: open.to(close), + } + } + + fn parse_statement(&mut self) -> Statement { + let start = self.current().span.start; + let value = if self.eat_word("let") { + let name = self.expect_binding_name("local binding"); + let ty = if self.eat(TokenKind::Colon) { + Some(self.parse_type()) + } else { + None }; - let modality = if s.eat_ident("modality") { - match s.expect_ident("as the modality (`sheet`)") { - Some((m, mspan)) => { - end = mspan; - Some(m) + self.expect(TokenKind::Eq, "`=` in local binding"); + StatementKind::Let { + name, + ty, + value: self.parse_expr(), + } + } else if self.eat_word("set") { + let target = self.expect_name("state field"); + self.expect(TokenKind::Eq, "`=` in state update"); + StatementKind::Set { + target, + value: self.parse_expr(), + } + } else if self.eat_word("emit") { + StatementKind::Emit(self.parse_expr()) + } else if self.eat_word("while") { + let condition = self.parse_expr(); + self.expect_word("decreases"); + let decreases = self.parse_expr(); + let body = self.parse_block(); + StatementKind::While { + condition, + decreases, + body, + } + } else { + StatementKind::Expr(self.parse_expr()) + }; + Spanned::new( + value, + SourceSpan::new(self.source_id.file, start, self.previous_end()), + ) + } + + fn looks_like_record_literal(&self) -> bool { + if !self.at(TokenKind::LBrace) { + return false; + } + // Speculatively parse exactly the first braces item as an expression. + // A following top-level `:` makes it a record/map entry. This admits + // every grammar-valid compile-time key expression without mistaking a + // later `let name: Type` inside a value/reaction block for an entry. + let mut probe = Parser { + source_id: self.source_id.clone(), + source: self.source, + tokens: self.tokens.clone(), + cursor: self.cursor + 1, + diagnostics: Vec::new(), + }; + probe.parse_expr(); + probe.diagnostics.is_empty() && probe.at(TokenKind::Colon) + } + + fn parse_record_entries(&mut self) -> Vec { + self.expect(TokenKind::LBrace, "`{` before record"); + let mut entries = Vec::new(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let start = self.current().span.start; + let key = self.parse_expr(); + self.expect(TokenKind::Colon, "`:` in record entry"); + let value = self.parse_expr(); + entries.push(RecordEntry { + key, + value, + span: SourceSpan::new(self.source_id.file, start, self.previous_end()), + }); + if !self.eat(TokenKind::Comma) { + break; + } + } + self.expect(TokenKind::RBrace, "`}` after record"); + entries + } + + fn parse_expr_list(&mut self, close: TokenKind) -> Vec { + let mut values = Vec::new(); + while !self.at(close.clone()) && !self.at(TokenKind::Eof) { + values.push(self.parse_expr()); + if !self.eat(TokenKind::Comma) { + break; + } + } + values + } + + fn parse_pattern(&mut self) -> Pattern { + let first = self.parse_atomic_pattern(); + if !self.eat(TokenKind::Pipe) { + return first; + } + let start = first.span.start; + let mut alternatives = vec![first]; + loop { + alternatives.push(self.parse_atomic_pattern()); + if !self.eat(TokenKind::Pipe) { + break; + } + } + let end = alternatives.last().map_or(start, |value| value.span.end); + Spanned::new( + PatternKind::Alternative(alternatives), + SourceSpan::new(self.source_id.file, start, end), + ) + } + + fn parse_atomic_pattern(&mut self) -> Pattern { + let token = self.current().clone(); + match token.kind { + TokenKind::Ident(ref value) if value == "_" => { + self.bump(); + Spanned::new(PatternKind::Wildcard, token.span) + } + TokenKind::Ellipsis => { + self.error( + ParseDiagnosticKind::InvalidPattern, + "`...` is valid only as the final openness marker inside a record pattern", + token.span, + ); + self.bump(); + Spanned::new(PatternKind::Error, token.span) + } + TokenKind::Minus => { + let start = self.bump().span.start; + let numeric = self.bump().clone(); + let kind = match numeric.kind { + TokenKind::Integer(value) => PatternKind::Integer(format!("-{value}")), + TokenKind::Decimal(value) => PatternKind::Decimal(format!("-{value}")), + _ => { + self.error( + ParseDiagnosticKind::InvalidPattern, + "expected a number after `-` in pattern", + numeric.span, + ); + PatternKind::Error + } + }; + Spanned::new( + kind, + SourceSpan::new(self.source_id.file, start, numeric.span.end), + ) + } + TokenKind::Integer(value) => { + self.bump(); + Spanned::new(PatternKind::Integer(value), token.span) + } + TokenKind::Decimal(value) => { + self.bump(); + Spanned::new(PatternKind::Decimal(value), token.span) + } + TokenKind::Text(value) => { + self.bump(); + Spanned::new(PatternKind::Text(value), token.span) + } + TokenKind::Ident(ref value) if value == "true" || value == "false" => { + let value = value == "true"; + self.bump(); + Spanned::new(PatternKind::Bool(value), token.span) + } + TokenKind::Ident(ref value) if is_hard_reserved_word(value) => { + self.error( + ParseDiagnosticKind::InvalidPattern, + format!("`{value}` is a reserved Uhura word, not a pattern identifier"), + token.span, + ); + self.bump(); + Spanned::new(PatternKind::Error, token.span) + } + TokenKind::Ident(_) => { + let start = token.span.start; + let path = self.parse_dot_path(); + if self.eat(TokenKind::LParen) { + let mut arguments = Vec::new(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + arguments.push(self.parse_pattern()); + if !self.eat(TokenKind::Comma) { + break; + } + } + let close = self.expect(TokenKind::RParen, "`)` after constructor pattern"); + Spanned::new( + PatternKind::Constructor { path, arguments }, + SourceSpan::new(self.source_id.file, start, close.end), + ) + } else if path.len() > 1 { + let end = path.last().map_or(start, |value| value.span.end); + Spanned::new( + PatternKind::Constructor { + path, + arguments: Vec::new(), + }, + SourceSpan::new(self.source_id.file, start, end), + ) + } else { + let name = path + .into_iter() + .next() + .unwrap_or_else(|| Spanned::new("".into(), token.span)); + let span = name.span; + Spanned::new(PatternKind::Name(name), span) + } + } + TokenKind::LParen => { + let start = self.bump().span.start; + let mut values = Vec::new(); + while !self.at(TokenKind::RParen) && !self.at(TokenKind::Eof) { + values.push(self.parse_pattern()); + if !self.eat(TokenKind::Comma) { + break; } - None => None, } + let close = self.expect(TokenKind::RParen, "`)` after tuple pattern"); + Spanned::new( + PatternKind::Tuple(values), + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + TokenKind::LBrace => { + let start = self.bump().span.start; + let mut fields = Vec::new(); + let mut open = false; + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + if self.eat(TokenKind::Ellipsis) { + open = true; + self.eat(TokenKind::Comma); + break; + } + let field_start = self.current().span.start; + let name = self.expect_name("record pattern field"); + self.expect(TokenKind::Colon, "`:` after record pattern field"); + let pattern = self.parse_pattern(); + fields.push(RecordPatternField { + name, + pattern, + span: SourceSpan::new( + self.source_id.file, + field_start, + self.previous_end(), + ), + }); + if !self.eat(TokenKind::Comma) { + break; + } + } + let close = self.expect(TokenKind::RBrace, "`}` after record pattern"); + Spanned::new( + PatternKind::Record { fields, open }, + SourceSpan::new(self.source_id.file, start, close.end), + ) + } + _ => { + self.error( + ParseDiagnosticKind::InvalidPattern, + format!("expected a pattern, found {}", token.kind.describe()), + token.span, + ); + self.bump(); + Spanned::new(PatternKind::Error, token.span) + } + } + } + + fn parse_ui(&mut self) -> UiDecl { + self.expect_word("ui"); + let name = self.expect_binding_name("UI declaration name"); + self.expect_word("for"); + let machine = self.expect_name("UI machine name"); + self.expect(TokenKind::LParen, "`(` before UI observation binding"); + let binding = self.expect_binding_name("UI observation binding"); + self.expect(TokenKind::RParen, "`)` after UI observation binding"); + + let open_index = self.cursor; + let open = self.expect(TokenKind::LBrace, "`{` before UI body"); + let close_index = self.find_matching(open_index, TokenKind::LBrace, TokenKind::RBrace); + let nodes = if let Some(close_index) = close_index { + let close = self.tokens[close_index].span; + let body_start = open.end as usize; + let body_end = close.start as usize; + let body = self.source.get(body_start..body_end).unwrap_or_default(); + let output = parse_ui_body(self.source_id.file, body, open.end); + self.diagnostics.extend(output.diagnostics); + self.cursor = close_index + 1; + output.nodes + } else { + self.error( + ParseDiagnosticKind::InvalidUi, + "unterminated UI declaration", + open, + ); + Vec::new() + }; + UiDecl { + name, + machine, + binding, + nodes, + } + } + + fn parse_scenario(&mut self) -> ScenarioDecl { + self.expect_word("scenario"); + let name = self.expect_binding_name("scenario name"); + let origin = if self.eat_word("for") { + let machine = self.expect_name("scenario machine"); + let configuration = if self.eat(TokenKind::LParen) { + let value = self.parse_expr(); + self.expect(TokenKind::RParen, "`)` after scenario configuration"); + Some(value) } else { None }; - DefKind::Surface { - name, - modality, - span: start.to(end), + ScenarioOrigin::Machine { + machine, + configuration, + } + } else if self.eat_word("from") { + ScenarioOrigin::Snapshot(self.parse_evidence_ref()) + } else { + let span = self.current().span; + self.error( + ParseDiagnosticKind::InvalidEvidence, + "scenario requires `for Machine` or `from snapshot`", + span, + ); + ScenarioOrigin::Snapshot(EvidenceRef { + path: vec![Spanned::new("".into(), span)], + span, + }) + }; + self.expect(TokenKind::LBrace, "`{` before scenario steps"); + let mut steps = Vec::new(); + while !self.at(TokenKind::RBrace) && !self.at(TokenKind::Eof) { + let start = self.current().span.start; + let before = self.cursor; + if let Some(kind) = self.parse_evidence_step() { + steps.push(Spanned::new( + kind, + SourceSpan::new(self.source_id.file, start, self.previous_end()), + )); } + if before == self.cursor { + self.error_here( + ParseDiagnosticKind::InvalidEvidence, + "expected an evidence step", + ); + self.bump(); + } + } + self.expect(TokenKind::RBrace, "`}` after scenario"); + ScenarioDecl { + name, + origin, + steps, } - other => { - s.cur.error( - codes::MISPLACED_SECTION, + } + + fn parse_evidence_alias(&mut self, is_example: bool) -> EvidenceAliasDecl { + self.bump(); // `example` or `checkpoint` + let name = self.expect_binding_name("evidence declaration name"); + let mut presentation = None; + let mut kind = None; + let mut is_default = false; + let mut note = None; + if is_example && self.eat_word("for") { + presentation = Some(self.expect_name("example presentation")); + self.expect_word("as"); + kind = Some(if self.eat_word("page") { + EvidencePresentationKind::Page + } else if self.eat_word("component") { + EvidencePresentationKind::Component + } else if self.eat_word("surface") { + EvidencePresentationKind::Surface + } else { + self.error_here( + ParseDiagnosticKind::InvalidEvidence, + "expected example presentation kind `page`, `component`, or `surface`", + ); + if !self.at(TokenKind::Eof) { + self.bump(); + } + EvidencePresentationKind::Page + }); + loop { + if self.eat_word("default") { + if is_default { + self.error_here( + ParseDiagnosticKind::InvalidEvidence, + "`default` is repeated on this example", + ); + } + is_default = true; + } else if self.eat_word("note") { + let token = self.bump().clone(); + match token.kind { + TokenKind::Text(value) if note.is_none() => note = Some(value), + TokenKind::Text(_) => self.error( + ParseDiagnosticKind::InvalidEvidence, + "`note` is repeated on this example", + token.span, + ), + _ => self.error( + ParseDiagnosticKind::MissingToken, + "expected quoted text after `note`", + token.span, + ), + } + } else { + break; + } + } + } + self.expect(TokenKind::Eq, "`=` in evidence declaration"); + let target = self.parse_evidence_ref(); + EvidenceAliasDecl { + name, + presentation, + kind, + is_default, + note, + target, + } + } + + fn parse_evidence_ref(&mut self) -> EvidenceRef { + let start = self.current().span.start; + let mut path = vec![self.expect_name("evidence reference")]; + while self.eat(TokenKind::ColonColon) { + path.push(self.expect_name("evidence reference component")); + } + EvidenceRef { + span: SourceSpan::new(self.source_id.file, start, self.previous_end()), + path, + } + } + + fn parse_evidence_step(&mut self) -> Option { + Some(if self.eat_word("bind") { + let port = self.expect_name("bound port name"); + self.expect(TokenKind::Eq, "`=` in fixture binding"); + EvidenceStepKind::Bind { + port, + fixture: self.parse_expr(), + } + } else if self.eat_word("start") { + EvidenceStepKind::Start + } else if self.eat_word("send") { + EvidenceStepKind::Send(self.parse_expr()) + } else if self.eat_word("deliver") { + EvidenceStepKind::Deliver(self.parse_expr()) + } else if self.eat_word("pin") { + EvidenceStepKind::Pin(self.expect_binding_name("pin name")) + } else if self.eat_word("expect") { + self.parse_expect_step() + } else { + return None; + }) + } + + fn parse_expect_step(&mut self) -> EvidenceStepKind { + if self.eat_word("observation") { + if self.eat_word("where") { + EvidenceStepKind::ExpectObservationWhere(self.parse_expr()) + } else { + EvidenceStepKind::ExpectObservationPattern(self.parse_pattern()) + } + } else if self.eat_word("inspection") { + EvidenceStepKind::ExpectInspectionPattern(self.parse_pattern()) + } else if self.eat_word("restore") { + self.expect_word("commands"); + EvidenceStepKind::ExpectRestore { + commands: self.parse_command_expectation(), + } + } else if self.eat_word("snapshot") { + self.expect(TokenKind::EqEq, "`==` in snapshot expectation"); + EvidenceStepKind::ExpectSnapshot { + target: self.parse_evidence_ref(), + } + } else { + let outcome = self.parse_pattern(); + self.expect_word("commands"); + EvidenceStepKind::ExpectReaction { + outcome, + commands: self.parse_command_expectation(), + } + } + } + + fn parse_command_expectation(&mut self) -> Vec { + self.expect(TokenKind::LBracket, "`[` before expected commands"); + let commands = self.parse_expr_list(TokenKind::RBracket); + self.expect(TokenKind::RBracket, "`]` after expected commands"); + commands + } + + fn parse_dot_path(&mut self) -> Vec { + let mut values = vec![self.expect_name("name")]; + while self.eat(TokenKind::Dot) { + values.push(self.expect_name("name after `.`")); + } + values + } + + fn parse_name_list(&mut self, close: TokenKind) -> Vec { + let mut values = Vec::new(); + while !self.at(close.clone()) && !self.at(TokenKind::Eof) { + values.push(self.expect_binding_name("type parameter name")); + if !self.eat(TokenKind::Comma) { + break; + } + } + values + } + + fn expect_name(&mut self, context: &str) -> Name { + let token = self.bump().clone(); + match token.kind { + // Uhura's checked-in corpus requires contextual words: `start` + // is both a command constructor and an evidence step, `from` is + // both a total conversion member and an import/scenario word, + // and `machine` appears in a logical module identity. The + // declaration parser therefore consumes grammar words + // contextually instead of globally banning their spelling. + TokenKind::Ident(value) => Spanned::new(value, token.span), + _ => { + self.error( + ParseDiagnosticKind::MissingToken, + format!("expected {context}, found {}", token.kind.describe()), + token.span, + ); + Spanned::new("".into(), token.span) + } + } + } + + fn expect_binding_name(&mut self, context: &str) -> Name { + let name = self.expect_name(context); + self.validate_binding_name(&name, context); + name + } + + fn validate_binding_name(&mut self, name: &Name, context: &str) { + if is_hard_reserved_word(&name.value) { + self.error( + ParseDiagnosticKind::UnexpectedToken, + format!( + "`{}` is a reserved Uhura word and cannot be used as {context}", + name.value + ), + name.span, + ); + } else if is_binding_reserved_builtin(&name.value) { + self.error( + ParseDiagnosticKind::UnexpectedToken, format!( - "`{other}` is not a definition kind — a .uhura file starts with \ - `component `, `page`, or `surface `" + "`{}` is a binding-reserved Uhura builtin and cannot be used as {context}", + name.value ), - start, + name.span, ); - DefKind::Error { span: start } } } -} -fn parse_style_section(cur: &mut Cursor) -> Option { - let start = cur.pos(); - debug_assert!(markup::starts_style_section(cur.rest())); - if !cur.eat_str("`. - while matches!(cur.peek(), Some(c) if c.is_whitespace()) { - cur.bump(); - } - if !cur.eat('>') { - cur.error( - codes::INVALID_STYLE_BLOCK, - "`") { - Some(i) => (i, true), - None => (rest.len(), false), - }; - let raw = rest[..inner_len].to_string(); - cur.set_pos(inner_start + inner_len as u32); - if closed { - cur.eat_str(""); - } else { - cur.error( - codes::INVALID_STYLE_BLOCK, - "`\n"; - let (file, diagnostics) = module(source); - assert!( - diagnostics - .iter() - .all(|diagnostic| !matches!(diagnostic.code, "UH0016" | "UH0017" | "UH0019")), - "{diagnostics:#?}" - ); - assert!(file.markup.comments.is_empty()); -} - -#[test] -fn annotations_do_not_cross_text_or_scope_boundaries() { - let (_, incompatible) = - module("page\nliteral - - - - - {post.caption} - - - -"#; - -const FEED_STORE: &str = r#"page - -use component post-card -use surface comments-sheet -use port feed { - projection feed-page, projection viewer, - command like-post, command unlike-post, - command load-next-page, command reload -} - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - load-pending: bool = false - notice: text? = none - } - - // like / unlike: guard-ordered multi-handler dispatch - on like-toggled(post: id, now-liked: bool) - when now-liked && !(like-pending[post] ?? false) { - set like-overlay[post] = true - set like-pending[post] = true - send like-post(post: post) - } - on like-post.ok(tag, cmd) { - set like-pending[cmd.post] = none - set like-overlay[cmd.post] = none - } - on like-post.err(tag, cmd, refusal) { - set like-pending[cmd.post] = none - set notice = "Couldn't like this post. Try again." - } - on feed-near-end() - when !load-pending && feed-page.has-more && feed-page.cursor != none { - set load-pending = true - send load-next-page(cursor: feed-page.cursor) - } - on comments-requested(post: id) { open-surface comments-sheet(post: post) } - on author-tapped(user: id) { navigate profile(user: user) } - on submit-requested() when draft != "" { - send add-comment(post: post, body: draft) as t - set pending-appends[t] = draft - set draft = "" - } - on dismiss-requested() { dismiss } - on back-tapped() { navigate back } -} - - - {#if notice != none} - - {/if} - {#match feed-page} - {:when loading} - Loading your feed… - {:when ready f} - - - {#each f.posts as p (p.id)} - - {/each} - - {#if !f.has-more} - You're all caught up. - {/if} - - {/match} - - -"#; - -const FEED_EXAMPLES: &str = r#"use fixture standard - -example loading { - note "cold start — nothing delivered yet" -} - -example first-page default { - projection feed.viewer = fixture.users.mira - projection feed.feed-page = fixture.feed.page-1 -} - -example like-pending { - from first-page - events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] - note "optimistic heart + count while like-post is in flight" -} - -example comments-open { - from first-page - projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze - events [ comments-requested(post: "post-lena-glaze") ] -} - -example appended { - from first-page - events [ - feed-near-end() - projection feed.feed-page = fixture.feed.pages-1-2 - outcome load-next-page.ok() - ] -} -"#; diff --git a/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura b/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura new file mode 100644 index 0000000..5e92fcf --- /dev/null +++ b/crates/uhura-syntax/tests/fixtures/v04-feed-ui.uhura @@ -0,0 +1,28 @@ +use uhura::ui; +use crate::feed::{Feed, Post, PostCard}; + +pub ui FeedWeb for Feed(view) { +
+
+

{view.title}

+ {#if view.loading} + + {:else} + + {/if} +
+ + {#each view.posts as Post { id, .. } (id)} + + ToggleLike(id) + featured + /> + {/each} + +

안녕하세요 {view.viewer_name}

+
+} diff --git a/crates/uhura-syntax/tests/fmt_roundtrip.rs b/crates/uhura-syntax/tests/fmt_roundtrip.rs deleted file mode 100644 index 1aaf976..0000000 --- a/crates/uhura-syntax/tests/fmt_roundtrip.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Formatter contract (design §12.5): formatting is idempotent, and the -//! formatted corpus reparses without diagnostics. Fixpoint stability implies -//! reparse-equality for everything the formatter renders. - -use uhura_base::FileId; -use uhura_syntax::{Parsed, SourceKind, format_examples, format_module, parse}; - -fn fmt(src: &str, kind: SourceKind) -> String { - let out = parse(FileId(0), src, kind); - assert!( - out.diagnostics.is_empty(), - "input must parse clean: {:?}", - out.diagnostics - ); - match out.parsed { - Parsed::Module(f) => format_module(&f), - Parsed::Examples(e) => format_examples(&e), - } -} - -fn assert_fixpoint(src: &str, kind: SourceKind) { - let once = fmt(src, kind); - let twice = fmt(&once, kind); - assert_eq!(once, twice, "formatter is not idempotent"); -} - -// Reuse the normative sources by including the sibling test file's constants -// via a tiny duplication-free include. -include!("common/normative_sources.rs"); - -#[test] -fn post_card_roundtrips() { - assert_fixpoint(POST_CARD, SourceKind::Module); -} - -#[test] -fn feed_page_roundtrips() { - assert_fixpoint(FEED_STORE, SourceKind::Module); -} - -#[test] -fn examples_roundtrip() { - assert_fixpoint(FEED_EXAMPLES, SourceKind::Examples); -} - -#[test] -fn comment_attachment_survives() { - let src = "page\n\nstore {\n state {\n // the overlay\n x: bool = false\n }\n\n // fires on tap\n on tapped() {\n // write it\n set x = true\n }\n}\n\n\n"; - let once = fmt(src, SourceKind::Module); - assert!(once.contains("// the overlay"), "{once}"); - assert!(once.contains("// fires on tap"), "{once}"); - assert!(once.contains("// write it"), "{once}"); - assert_fixpoint(src, SourceKind::Module); -} diff --git a/crates/uhura-syntax/tests/navigation.rs b/crates/uhura-syntax/tests/navigation.rs deleted file mode 100644 index daabb8c..0000000 --- a/crates/uhura-syntax/tests/navigation.rs +++ /dev/null @@ -1,49 +0,0 @@ -use uhura_base::FileId; -use uhura_syntax::ast::{NavTarget, Stmt}; -use uhura_syntax::{Parsed, SourceKind, format_module, parse}; - -const SOURCE: &str = r#"page - -store { - on reset(target: id) { - navigate replace profile(user: target) - } -} - - -"#; - -#[test] -fn navigate_replace_parses_and_formats_as_a_distinct_target() { - let parsed = parse(FileId(0), SOURCE, SourceKind::Module); - assert!( - parsed.diagnostics.is_empty(), - "unexpected diagnostics: {:?}", - parsed.diagnostics - ); - let Parsed::Module(file) = parsed.parsed else { - panic!("expected module") - }; - let store = file.store.as_ref().expect("store"); - let Stmt::Navigate { target, .. } = &store.handlers[0].body[0] else { - panic!("expected navigate statement") - }; - let NavTarget::Replace { name, args } = target else { - panic!("expected replace target") - }; - assert_eq!(name, "profile"); - assert_eq!(args.len(), 1); - assert_eq!(args[0].name, "user"); - - let formatted = format_module(&file); - assert!( - formatted.contains("navigate replace profile(user: target)"), - "{formatted}" - ); - let reparsed = parse(FileId(1), &formatted, SourceKind::Module); - assert!( - reparsed.diagnostics.is_empty(), - "formatted source must reparse: {:?}", - reparsed.diagnostics - ); -} diff --git a/crates/uhura-syntax/tests/parse_normative.rs b/crates/uhura-syntax/tests/parse_normative.rs deleted file mode 100644 index 02e18cc..0000000 --- a/crates/uhura-syntax/tests/parse_normative.rs +++ /dev/null @@ -1,209 +0,0 @@ -//! The design doc's normative sources (§4.6, §4.7, §6.1) must parse with -//! zero diagnostics — the grammar is validated against the doc's own text -//! (plan risk #1 mitigation). - -use uhura_base::FileId; -use uhura_syntax::ast::*; -use uhura_syntax::{Parsed, SourceKind, parse}; - -include!("common/normative_sources.rs"); - -fn assert_clean(diags: &[uhura_base::Diagnostic]) { - assert!( - diags.is_empty(), - "expected zero diagnostics, got:\n{}", - diags - .iter() - .map(|d| format!( - " [{}] {} @{}..{}", - d.code, d.message, d.span.start, d.span.end - )) - .collect::>() - .join("\n") - ); -} - -#[test] -fn post_card_parses_clean() { - let out = parse(FileId(0), POST_CARD, SourceKind::Module); - assert_clean(&out.diagnostics); - let Parsed::Module(f) = out.parsed else { - panic!() - }; - - let DefKind::Component { name, .. } = &f.kind else { - panic!("expected component") - }; - assert_eq!(name, "post-card"); - assert_eq!(f.props.len(), 3); - assert_eq!(f.emits.len(), 3); - assert_eq!(f.uses.len(), 1); - assert!(f.store.is_none()); - assert_eq!(f.markup.len(), 1, "component has exactly one root"); - - let Node::Element(root) = &f.markup[0] else { - panic!() - }; - assert_eq!(root.name, "view"); - // match block with three arms sits among the children - let match_node = root - .children - .iter() - .find_map(|n| match n { - Node::Match { arms, .. } => Some(arms), - _ => None, - }) - .expect("media match"); - assert_eq!(match_node.len(), 3); - assert!(matches!(&match_node[0].pattern, MatchPattern::Variant(v) if v == "image")); - assert_eq!(match_node[1].binding.as_deref(), Some("c")); - - let style = f.style.expect("style block"); - assert_eq!(style.rules.len(), 2); - assert_eq!(style.rules[0].classes, vec!["post-card"]); -} - -#[test] -fn feed_page_parses_clean() { - let out = parse(FileId(0), FEED_STORE, SourceKind::Module); - assert_clean(&out.diagnostics); - let Parsed::Module(f) = out.parsed else { - panic!() - }; - - assert!(matches!(f.kind, DefKind::Page { .. })); - let store = f.store.expect("store"); - assert_eq!(store.state.len(), 4); - assert_eq!(store.handlers.len(), 9); - - // Multi-handler + guard + outcome signatures. - let h0 = &store.handlers[0]; - assert!(matches!(&h0.event, EventRef::Semantic { name, .. } if name == "like-toggled")); - assert!(h0.guard.is_some()); - assert_eq!(h0.body.len(), 3); - let h1 = &store.handlers[1]; - assert!(matches!( - &h1.event, - EventRef::Outcome { command, which: OutcomeKind::Ok, .. } if command == "like-post" - )); - // Outcome params are name-only. - assert!(h1.params.iter().all(|p| p.ty.is_none())); - - // `send … as t` binding. - let submit = &store.handlers[6]; - assert!(matches!( - &submit.body[0], - Stmt::Send { bind: Some(b), .. } if b == "t" - )); - // `navigate back`. - let back = &store.handlers[8]; - assert!(matches!( - &back.body[0], - Stmt::Navigate { - target: NavTarget::Back, - .. - } - )); - - // Markup: forwarding event attrs on the component call. - let Node::Element(root) = &f.markup[0] else { - panic!() - }; - fn find_element<'a>(nodes: &'a [Node], name: &str) -> Option<&'a Element> { - for n in nodes { - let kids: &[Node] = match n { - Node::Element(e) => { - if e.name == name { - return Some(e); - } - &e.children.nodes - } - Node::If { then, .. } => &then.nodes, - Node::Each { body, .. } => &body.nodes, - Node::Match { arms, .. } => { - for a in arms { - if let Some(e) = find_element(&a.body.nodes, name) { - return Some(e); - } - } - &[] - } - _ => &[], - }; - if let Some(e) = find_element(kids, name) { - return Some(e); - } - } - None - } - let card = find_element(&root.children.nodes, "post-card").expect("post-card call"); - assert_eq!(card.events.len(), 3); - assert!( - card.events - .iter() - .all(|e| matches!(e.binding, EventBinding::Forward)) - ); - assert!(card.self_closing); -} - -#[test] -fn examples_file_parses_clean() { - let out = parse(FileId(0), FEED_EXAMPLES, SourceKind::Examples); - assert_clean(&out.diagnostics); - let Parsed::Examples(ex) = out.parsed else { - panic!() - }; - - assert_eq!(ex.examples.len(), 5); - assert!(ex.examples[1].is_default); - - // Keyed projection pin. - let comments_open = &ex.examples[3]; - assert!(comments_open.clauses.iter().any(|c| matches!( - c, - ExampleClause::Projection(p) if p.projection == "for-post" && p.key.is_some() - ))); - - // Timeline with all three entry kinds. - let appended = &ex.examples[4]; - let events = appended - .clauses - .iter() - .find_map(|c| match c { - ExampleClause::Events { entries, .. } => Some(entries), - _ => None, - }) - .expect("events clause"); - assert_eq!(events.len(), 3); - assert!(matches!(&events[0], ExampleEvent::Semantic { name, .. } if name == "feed-near-end")); - assert!(matches!(&events[1], ExampleEvent::Projection(_))); - assert!(matches!( - &events[2], - ExampleEvent::Outcome { - which: OutcomeKind::Ok, - .. - } - )); -} - -#[test] -fn planted_errors_diagnose() { - // Unkeyed each is a parse error (§4.4). - let src = "component x\n{#each xs as x}{x}{/each}\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!( - out.diagnostics.iter().any(|d| d.code == "UH0003"), - "{:?}", - out.diagnostics - ); - - // Unknown statement keyword. - let src = "page\nstore { on x() { mutate y = 1 } }\n\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!(out.diagnostics.iter().any(|d| d.code == "UH0001")); - - // Mismatched close tag. - let src = "component x\nhello\n"; - let out = parse(FileId(0), src, SourceKind::Module); - assert!(out.diagnostics.iter().any(|d| d.code == "UH0004")); -} diff --git a/crates/uhura-syntax/tests/v04_format.rs b/crates/uhura-syntax/tests/v04_format.rs new file mode 100644 index 0000000..87ef0ba --- /dev/null +++ b/crates/uhura-syntax/tests/v04_format.rs @@ -0,0 +1,299 @@ +use uhura_syntax::v04::{ + FormatError, SourceIdentity, TriviaKind, UnsupportedComment, format, parse, +}; + +const PROGRAMS: &str = include_str!("../../../examples/programs/answers/uhura-0.4/programs.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(19, "examples.programs@1", "programs", path) +} + +fn parse_clean(path: &str, source: &str) -> uhura_syntax::v04::Parse { + let parsed = parse(identity(path), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics for {path}:\n{:#?}", + parsed.diagnostics + ); + parsed +} + +fn assert_round_trip(path: &str, source: &str) -> String { + let parsed = parse_clean(path, source); + let formatted = format(&parsed.module).expect("comment-free source must format"); + let reparsed = parse_clean(path, &formatted); + let reformatted = format(&reparsed.module).expect("formatted source must format again"); + + // The formatter is a complete structural projection of the AST: parsing + // its output and projecting again must retain every represented choice. + assert_eq!(reformatted, formatted, "formatter must be idempotent"); + assert_eq!( + reparsed.module.uses.len(), + parsed.module.uses.len(), + "imports must survive formatting" + ); + assert_eq!( + reparsed.module.declarations.len(), + parsed.module.declarations.len(), + "declarations must survive formatting" + ); + formatted +} + +#[test] +fn formats_the_complete_l0_l1_l2_fixture_and_reparses_it() { + let formatted = assert_round_trip("programs.uhura", PROGRAMS); + assert!(formatted.starts_with("pub machine BoundedCounter {\n config {\n")); + assert!(formatted.contains("\n before commit {\n")); + assert!(formatted.ends_with("\n")); + assert!(!formatted.ends_with("\n\n")); +} + +#[test] +fn formats_every_core_declaration_member_expression_and_pattern_form() { + let source = r#"use crate::shared::{Notice, Helper as LocalHelper}; +use crate::other::Thing as LocalThing; +pub use vendor::api::PublicType; + +pub struct Item { + value: Text, + pair: (Nat, Text), + nested: Outer::Inner, +} + +enum Choice { + Empty, + Value { value: Text }, +} + +pub key ItemId(Text); +pub const DEFAULT_VALUE: Text = "line\n\"quoted\""; + +pub fn expressions(value: Item, other: Item) -> Text { + let unit: () = (); + let sequence = [true, false, 0, 1, 1.5, "text"]; + let tuple: (Item, Item) = (value, other); + let grouped = (value); + let record = Item { + value: other.value, + pair: (1, "one"), + nested: other.nested, + ..value + }; + let empty = Choice::Empty {}; + let block = { + let inside = other; + inside + }; + let call = collect(value.member[0], |item| item + 1); + let operators = !false || -1 * 2 + 3 - 4 == 5 && 6 != 7; + let comparisons = 1 < 2 && 2 <= 3 && 3 > 2 && 3 >= 3; + let tested = value is Item { value: name, .. }; + let selected = if true { + value + } else if false { + other + } else { + record + }; + if false { + return; + } + let matched = match selected { + true => "bool", + false => "bool", + -1 => "integer", + -1.5 => "decimal", + "text" => "text", + () => "unit", + (left, right) => "tuple", + (single) => "group", + Some(inner) => "some", + None => "none", + Choice::Empty => "constructor", + Item { value, pair: renamed, .. } => "record", + Choice::Empty | Choice::Value { .. } => "alternative", + _ => "wildcard", + }; + if true {} else {} + match value { + _ => (), + } + value; + return matched +} + +pub part Worker(seed: Text) { + require seed != ""; + + requires outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + const LIMIT: Nat = 2; + + fn normalize(value: Text) -> Text { + value + } + + events { + Start(value: Text), + } + + commands { + Logged(value: Text), + } + + port clock = ClockPort { zone: seed }; + + state { + current: Option = None, + } + + pub computed visible: Bool = current is Some(_); + + invariant true; + + observe { + current, + ready: true, + } + + on Start(value) { + current = Some(value); + emit Logged(value); + emit clock.Logged(value); + Accepted + } + + on clock.Tick(now) { + let ignored = now; + Accepted + } + + pub update clear() -> Outcome { + current = None; + Accepted + } +} + +pub machine Application { + config { + label: Text, + } + + require label != ""; + + const ZERO: Nat = 0; + + fn identity(value: Text) -> Text { + return value; + } + + part worker = Worker(label); + + events { + Started, + } + + commands { + Ready, + } + + port router = Router {}; + + outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + state { + count: Nat = ZERO, + } + + computed doubled: Int = count * 2; + + computed unlabeled = count; + + invariant { + count >= 0, + doubled >= 0, + } + + observe { + count, + } + + on Started { + while count > 0 decreases(count) { + count = count - 1; + unreachable; + } + emit Ready; + Accepted + } + + update clear() { + count = 0; + } + + before commit { + worker.clear(); + } +} +"#; + + let formatted = assert_round_trip("all-core.uhura", source); + assert!(formatted.contains("pub part Worker(seed: Text) {")); + assert!(formatted.contains("Choice::Empty | Choice::Value {")); + assert!(formatted.contains("while count > 0 decreases(count) {")); + assert!(formatted.contains("port router = Router {};")); +} + +#[test] +fn inserts_only_the_parentheses_required_by_the_ast() { + let source = r#"const VALUE: Bool = (a || b) && c || d && e; + +fn arithmetic(a: Int, b: Int, c: Int) -> Int { + a - (b - c) + a * (b + c) +} +"#; + let formatted = assert_round_trip("precedence.uhura", source); + assert!(formatted.contains("const VALUE: Bool = (a || b) && c || d && e;")); + assert!(formatted.contains("a - (b - c) + a * (b + c)")); +} + +#[test] +fn refuses_to_silently_delete_comments_until_attachment_is_modeled() { + let source = r#"//! Module documentation. +// ordinary module note +/// Declaration documentation. +pub struct Item { + value: Text, +} +"#; + let parsed = parse_clean("comments.uhura", source); + let error = format(&parsed.module).expect_err("comments must be refused explicitly"); + let FormatError::UnsupportedComments { comments } = error; + assert_eq!( + comments, + vec![ + UnsupportedComment { + kind: TriviaKind::InnerDoc, + text: "//! Module documentation.".into(), + span: comments[0].span, + }, + UnsupportedComment { + kind: TriviaKind::OrdinaryComment, + text: "// ordinary module note".into(), + span: comments[1].span, + }, + UnsupportedComment { + kind: TriviaKind::OuterDoc, + text: "/// Declaration documentation.".into(), + span: comments[2].span, + }, + ] + ); +} diff --git a/crates/uhura-syntax/tests/v04_instagram.rs b/crates/uhura-syntax/tests/v04_instagram.rs new file mode 100644 index 0000000..0270104 --- /dev/null +++ b/crates/uhura-syntax/tests/v04_instagram.rs @@ -0,0 +1,34 @@ +use uhura_syntax::v04::{SourceIdentity, format, parse}; + +const MACHINE: &str = include_str!("../../../examples/instagram/client/machine.uhura"); + +#[test] +fn parses_and_formats_the_complete_instagram_machine_losslessly() { + let parsed = parse( + SourceIdentity::new(41, "app.instagram@1", "machine", "client/machine.uhura"), + MACHINE, + ); + + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.source_from_tokens(), MACHINE); + + let formatted = format(&parsed.module).expect("comment-free source must format"); + let reparsed = parse( + SourceIdentity::new(42, "app.instagram@1", "machine", "client/machine.uhura"), + &formatted, + ); + assert!( + reparsed.diagnostics.is_empty(), + "formatted source must reparse:\n{:#?}", + reparsed.diagnostics + ); + assert_eq!( + format(&reparsed.module).expect("formatted source must format again"), + formatted, + "formatter must be idempotent" + ); +} diff --git a/crates/uhura-syntax/tests/v04_lexer.rs b/crates/uhura-syntax/tests/v04_lexer.rs new file mode 100644 index 0000000..1cd6b8a --- /dev/null +++ b/crates/uhura-syntax/tests/v04_lexer.rs @@ -0,0 +1,72 @@ +use uhura_syntax::v04::{LexDiagnosticKind, SourceIdentity, TriviaKind, lex}; + +fn identity() -> SourceIdentity { + SourceIdentity::new(3, "test@1", "test", "test.uhura") +} + +#[test] +fn classifies_comments_and_decodes_json_text() { + let source = + "//! file\n/// outer\n//// ordinary\nconst TEXT: Text = \"A\\uD83D\\uDE80\\n\"; // tail\n"; + let output = lex(&identity(), source); + assert!(output.diagnostics.is_empty(), "{:#?}", output.diagnostics); + let kinds = output + .tokens + .iter() + .flat_map(|token| token.leading.iter().map(|trivia| trivia.kind)) + .collect::>(); + assert!(kinds.contains(&TriviaKind::InnerDoc)); + assert!(kinds.contains(&TriviaKind::OuterDoc)); + assert!(kinds.contains(&TriviaKind::OrdinaryComment)); + assert!(output.tokens.iter().any(|token| { + matches!(&token.kind, uhura_syntax::v04::TokenKind::Text(value) if value == "A🚀\n") + })); +} + +#[test] +fn rejects_non_core_lexical_spellings_deterministically() { + for (source, expected) in [ + ("\u{feff}const X: Int = 0;", LexDiagnosticKind::InitialBom), + ( + "const CAFÉ: Int = 0;", + LexDiagnosticKind::NonAsciiIdentifier, + ), + ("const X: Int = 01;", LexDiagnosticKind::InvalidNumber), + ("const X: Decimal = .5;", LexDiagnosticKind::InvalidNumber), + ("const X: Text = \"\\x\";", LexDiagnosticKind::InvalidEscape), + ( + "const X: Text = \"\\uD800\";", + LexDiagnosticKind::InvalidSurrogatePair, + ), + ( + "const X: Text = \"line\n\";", + LexDiagnosticKind::InvalidEscape, + ), + ( + "const X:\u{00a0}Int = 0;", + LexDiagnosticKind::InvalidWhitespace, + ), + ] { + let output = lex(&identity(), source); + assert!( + output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == expected), + "expected {expected:?} for {source:?}, got {:#?}", + output.diagnostics + ); + let reconstructed = output + .tokens + .iter() + .flat_map(|token| { + token + .leading + .iter() + .map(|trivia| trivia.text.as_str()) + .chain(std::iter::once(token.lexeme.as_str())) + }) + .collect::(); + assert_eq!(reconstructed, source); + } +} diff --git a/crates/uhura-syntax/tests/v04_parser.rs b/crates/uhura-syntax/tests/v04_parser.rs new file mode 100644 index 0000000..822970a --- /dev/null +++ b/crates/uhura-syntax/tests/v04_parser.rs @@ -0,0 +1,139 @@ +use uhura_syntax::v04::ast::{ + BinaryOperator, DeclarationKind, ExpressionKind, MachineMemberKind, StatementKind, +}; +use uhura_syntax::v04::{ParseDiagnosticKind, SourceIdentity, parse}; + +fn parse_source(source: &str) -> uhura_syntax::v04::Parse { + parse( + SourceIdentity::new(11, "test@1", "precedence", "precedence.uhura"), + source, + ) +} + +#[test] +fn applies_the_frozen_operator_precedence() { + let parsed = parse_source("const VALUE: Bool = a || b && c == d + e * f;"); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + let DeclarationKind::Const(value) = &parsed.module.declarations[0].kind else { + panic!("expected const"); + }; + let ExpressionKind::Binary { + operator: BinaryOperator::Or, + right, + .. + } = &value.value.kind + else { + panic!("expected top-level logical or: {:#?}", value.value); + }; + assert!(matches!( + right.kind, + ExpressionKind::Binary { + operator: BinaryOperator::And, + .. + } + )); +} + +#[test] +fn rejects_comparison_chains() { + let parsed = parse_source("const VALUE: Bool = a < b < c;"); + assert!( + parsed + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == ParseDiagnosticKind::ComparisonChain) + ); +} + +#[test] +fn preserves_block_tail_and_every_authored_semicolon() { + let source = r#"machine Example { + events { Run, } + outcomes { commit Accepted, } + state { count: Int = 0, } + on Run { + let next = count + 1; + count = next; + if next > 2 { + count = 2; + } + Accepted + } +} +"#; + let parsed = parse_source(source); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + let DeclarationKind::Machine(machine) = &parsed.module.declarations[0].kind else { + panic!("expected machine"); + }; + let handler = machine + .members + .iter() + .find_map(|member| match &member.kind { + MachineMemberKind::Handler(handler) => Some(handler), + _ => None, + }) + .expect("handler"); + assert_eq!(handler.body.statements.len(), 3); + assert!(handler.body.tail.is_some()); + assert!(matches!( + handler.body.statements[0].kind, + StatementKind::Let { .. } + )); + assert!(matches!( + handler.body.statements[1].kind, + StatementKind::Assign { .. } + )); + assert!(matches!( + handler.body.statements[2].kind, + StatementKind::BlockExpression(_) + )); +} + +#[test] +fn diagnoses_missing_semicolons_and_recovers_to_later_statements() { + let source = r#"machine Example { + events { Run, } + outcomes { commit Accepted, } + state { count: Int = 0, } + on Run { + let next = count + 1 + count = next + Accepted + } +} +"#; + let parsed = parse_source(source); + let missing = parsed + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.kind == ParseDiagnosticKind::MissingToken) + .count(); + assert!(missing >= 2, "{:#?}", parsed.diagnostics); +} + +#[test] +fn admits_keywords_only_as_contextual_member_and_explicit_record_labels() { + let source = r#"struct Entry { + value: Text, +} + +fn project(entry: Entry) -> Text { + let copied = Entry { + key: entry.key, + match: entry.match, + }; + match copied { + Entry { key: id, match: value } => value, + } +} +"#; + let parsed = parse_source(source); + assert!(parsed.diagnostics.is_empty(), "{:#?}", parsed.diagnostics); + + let shorthand = parse_source("const VALUE: Entry = Entry { key };"); + assert!(shorthand.diagnostics.iter().any(|diagnostic| { + diagnostic.kind == ParseDiagnosticKind::InvalidName + && diagnostic.message.contains("keyword shorthand") + })); +} diff --git a/crates/uhura-syntax/tests/v04_programs.rs b/crates/uhura-syntax/tests/v04_programs.rs new file mode 100644 index 0000000..a86c9fa --- /dev/null +++ b/crates/uhura-syntax/tests/v04_programs.rs @@ -0,0 +1,199 @@ +use uhura_syntax::v04::ast::{DeclarationKind, Module, SourceIdentity}; +use uhura_syntax::v04::parse; + +const PROGRAMS: &str = include_str!("../../../examples/programs/answers/uhura-0.4/programs.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(7, "examples.programs@1", "programs", path) +} + +#[test] +fn parses_complete_l0_l1_l2_programs_losslessly() { + let parsed = parse(identity("programs.uhura"), PROGRAMS); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.source_from_tokens(), PROGRAMS); + assert_eq!(parsed.module.source, PROGRAMS); + assert_eq!(parsed.module.identity.package, "examples.programs@1"); + assert_eq!(parsed.module.identity.module, "programs"); + assert_eq!( + parsed + .module + .declarations + .iter() + .filter(|declaration| matches!(declaration.kind, DeclarationKind::Machine(_))) + .count(), + 3 + ); + assert!(parsed.tokens.iter().any(|token| !token.leading.is_empty())); +} + +#[test] +fn parses_every_core_declaration_and_member_shape() { + let source = r#"//! Module documentation. +use crate::shared::{Notice, Helper as LocalHelper}; +pub use vendor::api::PublicType; + +pub struct Message { + text: Text, + priority: Nat, +} + +enum Delivery { + Pending, + Sent { at: Nat }, +} + +pub key MessageId(Text); +pub const DEFAULT_PRIORITY: Nat = 1; + +pub fn choose(left: Text, right: Text) -> Text { + if left == "" { right } else { left } +} + +pub part Notice(seed: Text) { + require seed != ""; + + requires outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + const LIMIT: Nat = 2; + + fn normalize(value: Text) -> Text { + value + } + + events { + Show(value: Text), + Hide, + } + + commands { + Logged(value: Text), + } + + port clock = ClockPort { zone: seed, }; + + state { + message: Option = None, + remaining: Nat = LIMIT, + } + + pub computed current: Option = message; + invariant remaining <= LIMIT; + + observe { + message, + visible: message is Some(_), + } + + on Show(value) { + message = Some(normalize(value)); + emit Logged(value); + Accepted + } + + on clock.Tick(now) { + let pair: (Nat, Text) = (now, seed); + if now == 0 { + return Refused("early"); + } + Accepted + } + + pub update dismiss() { + message = None; + } +} + +pub machine Application { + config { + label: Text, + } + + require label != ""; + const ZERO: Nat = 0; + + fn identity(value: Text) -> Text { + return value; + } + + part notice = Notice(label); + + events { + Started, + } + + commands { + Ready, + } + + port router = Router { initial: label, }; + + outcomes { + commit Accepted, + abort Refused(reason: Text), + } + + state { + count: Nat = ZERO, + } + + computed doubled: Int = count * 2; + + invariant { + count >= 0, + doubled >= 0, + } + + observe { + count, + } + + on Started { + notice.dismiss(); + emit Ready; + Accepted + } + + on router.Changed(next) { + let selected = match next { + Some(value) => value, + None => identity(label), + }; + count = selected.len(); + Accepted + } + + update clear() { + count = 0; + } + + before commit { + while count > 0 decreases(count) { + count = count - 1; + } + } +} +"#; + let parsed = parse(identity("all-core.uhura"), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics:\n{:#?}", + parsed.diagnostics + ); + assert_eq!(parsed.module.uses.len(), 2); + assert_eq!(parsed.module.declarations.len(), 7); + assert_eq!(parsed.source_from_tokens(), source); +} + +#[test] +fn v04_module_is_serde_ready() { + fn assert_wire serde::Deserialize<'de>>() {} + assert_wire::(); +} diff --git a/crates/uhura-syntax/tests/v04_ui.rs b/crates/uhura-syntax/tests/v04_ui.rs new file mode 100644 index 0000000..4499a1a --- /dev/null +++ b/crates/uhura-syntax/tests/v04_ui.rs @@ -0,0 +1,232 @@ +use uhura_syntax::v04::ast::{DeclarationKind, UiAttribute, UiNameKind, UiNodeKind}; +use uhura_syntax::v04::{ + FormatError, ParseDiagnosticKind, SourceIdentity, TokenKind, format, parse, +}; + +const FEED: &str = include_str!("fixtures/v04-feed-ui.uhura"); + +fn identity(path: &str) -> SourceIdentity { + SourceIdentity::new(31, "examples.feed@1", "feed", path) +} + +fn parse_clean(path: &str, source: &str) -> uhura_syntax::v04::Parse { + let parsed = parse(identity(path), source); + assert!( + parsed.diagnostics.is_empty(), + "unexpected diagnostics for {path}:\n{:#?}", + parsed.diagnostics + ); + parsed +} + +#[test] +fn parses_the_complete_ui_profile_losslessly_with_exact_spans() { + let parsed = parse_clean("feed.uhura", FEED); + assert_eq!(parsed.source_from_tokens(), FEED); + assert_eq!( + parsed + .tokens + .iter() + .filter(|token| token.kind == TokenKind::UiBody) + .count(), + 1 + ); + + let declaration = &parsed.module.declarations[0]; + let DeclarationKind::Ui(ui) = &declaration.kind else { + panic!("expected contextual UI declaration"); + }; + assert_eq!(ui.name.text, "FeedWeb"); + assert_eq!(ui.machine.segments[0].name.text, "Feed"); + assert_eq!(ui.observation.text, "view"); + let body_start = FEED.find("Feed(view) {").unwrap() + "Feed(view) {".len(); + assert_eq!( + &FEED[ui.body.span.start as usize..ui.body.span.end as usize], + &FEED[body_start..FEED.rfind('}').unwrap()] + ); + + let UiNodeKind::Element(main) = &ui.body.nodes[0].kind else { + panic!("expected root element"); + }; + assert_eq!(main.name.text, "main"); + assert_eq!(main.name.kind, UiNameKind::Native); + let root_source = + &FEED[ui.body.nodes[0].span.start as usize..ui.body.nodes[0].span.end as usize]; + assert!(root_source.starts_with("")); + assert!(matches!(main.attributes[0], UiAttribute::StaticText { .. })); + + let each = main + .children + .iter() + .find_map(|node| match &node.kind { + UiNodeKind::Each(value) => Some(value), + _ => None, + }) + .expect("keyed each"); + let component = each + .children + .iter() + .find_map(|node| match &node.kind { + UiNodeKind::Element(value) => Some(value), + _ => None, + }) + .expect("component element"); + assert_eq!(component.name.kind, UiNameKind::Component); + assert!(component + .attributes + .iter() + .any(|attribute| matches!(attribute, UiAttribute::Boolean { name, .. } if name.text == "featured"))); + assert!(component.attributes.iter().any( + |attribute| matches!(attribute, UiAttribute::Event { event, .. } if event.text == "like") + )); +} + +#[test] +fn canonical_ui_format_is_parseable_and_idempotent() { + let parsed = parse_clean("feed.uhura", FEED); + let formatted = format(&parsed.module).expect("comment-free core expressions format"); + assert!(formatted.contains("pub ui FeedWeb for Feed(view) {")); + assert!(formatted.contains("{#if view.loading}")); + assert!(formatted.contains("{:else}")); + assert!(formatted.contains("{#each view.posts as Post {")); + assert!(formatted.contains("on like -> ToggleLike(id)")); + assert!(formatted.contains("featured")); + assert!(formatted.contains("")); + assert!(formatted.contains(">Refresh")); + assert!(formatted.contains("

안녕하세요 {view.viewer_name}

")); + + let reparsed = parse_clean("feed.formatted.uhura", &formatted); + let reformatted = format(&reparsed.module).expect("formatted UI must format again"); + assert_eq!(reformatted, formatted); +} + +#[test] +fn ui_and_for_remain_contextual_outside_the_declaration_shape() { + let source = r#"fn ui(value: Bool) -> Bool { + value +} + +fn for_value(value: Bool) -> Bool { + value +} +"#; + let parsed = parse_clean("contextual.uhura", source); + assert_eq!(parsed.module.declarations.len(), 2); + assert!( + !parsed + .tokens + .iter() + .any(|token| token.kind == TokenKind::UiBody) + ); +} + +#[test] +fn ui_lexical_mode_owns_markup_braces_and_resumes_at_the_next_declaration() { + let source = r#"use uhura::ui; + +ui AppWeb for App(view) { +

{view.label}

+} + +const AFTER: Bool = true; +"#; + let parsed = parse_clean("mode-boundary.uhura", source); + assert_eq!(parsed.source_from_tokens(), source); + assert_eq!(parsed.module.declarations.len(), 2); + assert_eq!( + parsed + .tokens + .iter() + .filter(|token| token.kind == TokenKind::UiBody) + .count(), + 1 + ); +} + +#[test] +fn global_nul_refusal_survives_ui_body_isolation() { + let source = "use uhura::ui;\nui AppWeb for App(view) {

\0

}\n"; + let parsed = parse(identity("nul.uhura"), source); + assert!( + parsed + .diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == ParseDiagnosticKind::Lexical + && diagnostic.message.contains("U+0000")) + ); + assert_eq!(parsed.source_from_tokens(), source); +} + +#[test] +fn reports_invalid_or_non_selected_ui_forms_without_losing_the_module() { + let cases = [ + ( + "unkeyed", + "use uhura::ui;\nui AppWeb for App(view) {{#each view.items as item}

{item}

{/each}}\n", + "parenthesized key", + ), + ( + "match-block", + "use uhura::ui;\nui AppWeb for App(view) {{#match view.state}{/match}}\n", + "has no `match` block", + ), + ( + "mismatched-tag", + "use uhura::ui;\nui AppWeb for App(view) {
}\n", + "does not match", + ), + ( + "missing-arrow", + "use uhura::ui;\nui AppWeb for App(view) { - - {viewer.avatar.alt} - - - - {#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} - - Nothing new yet - Posts from people you follow will appear here. - - {: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/client/app/post/[id]/page.examples.uhura b/examples/instagram/client/app/post/[id]/page.examples.uhura deleted file mode 100644 index 7b823f1..0000000 --- a/examples/instagram/client/app/post/[id]/page.examples.uhura +++ /dev/null @@ -1,35 +0,0 @@ -use fixture standard - -example loading { - params { id = "post-lena-glaze" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { id = "post-lena-glaze" } - projection feed.viewer = fixture.users.mira - projection feed.post-by-id("post-lena-glaze") = fixture.posts.lena-glaze -} - -example profile-history { - params { id = "post-lena-bowls" } - projection feed.viewer = fixture.users.mira - projection feed.post-by-id("post-lena-bowls") = fixture.posts.lena-bowls - note "a profile-grid tile opens a genuine post, not a decorative thumbnail" -} - -example like-pending { - from lena - events [ like-toggled(post: "post-lena-glaze", now-liked: true) ] -} - -example save-pending { - from lena - events [ save-toggled(post: "post-lena-glaze", now-saved: true) ] -} - -example comments-open { - from lena - projection comments.for-post("post-lena-glaze") = fixture.comments.lena-glaze - events [ comments-requested(post: "post-lena-glaze") ] -} diff --git a/examples/instagram/client/app/post/[id]/page.uhura b/examples/instagram/client/app/post/[id]/page.uhura deleted file mode 100644 index a536227..0000000 --- a/examples/instagram/client/app/post/[id]/page.uhura +++ /dev/null @@ -1,170 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component post-card -use surface comments-sheet -use port feed { - projection post-by-id - projection viewer - command like-post - command unlike-post - command save-post - command unsave-post -} - -param id: id - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - save-overlay: map[id]bool = {} - save-pending: map[id]bool = {} - notice: text? = none - } - - 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 update this like. 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 update this like. Try again." - } - - on save-toggled(post: id, now-saved: bool) when now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = true - set save-pending[post] = true - send save-post(post: post) - } - - on save-toggled(post: id, now-saved: bool) when !now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = false - set save-pending[post] = true - send unsave-post(post: post) - } - - on save-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on save-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't save this post." - } - - on unsave-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on unsave-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't remove this post from saved." - } - - on comments-requested(post: id) { - open-surface comments-sheet(post: post) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Post - - {#if notice != none} - - {/if} - {#match post-by-id(id)} - {:when loading} - - Loading post… - - {:when failed reason} - - This post isn't available. - - {:when ready p} - - - - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura b/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura deleted file mode 100644 index 849cc97..0000000 --- a/examples/instagram/client/app/profile/[user]/followers/page.examples.uhura +++ /dev/null @@ -1,23 +0,0 @@ -use fixture standard - -example loading { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira - projection profile.followers("user-lena") = fixture.people.lena-followers -} - -example follow-pending { - from lena - events [ follow-toggled(target: "user-nils", now-following: true) ] -} - -example mira { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.followers("user-mira") = fixture.people.mira-followers -} diff --git a/examples/instagram/client/app/profile/[user]/followers/page.uhura b/examples/instagram/client/app/profile/[user]/followers/page.uhura deleted file mode 100644 index 24169e5..0000000 --- a/examples/instagram/client/app/profile/[user]/followers/page.uhura +++ /dev/null @@ -1,122 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { projection followers, command follow-user, command unfollow-user } - -param user: id - -store { - state { - follow-pending: map[id]bool = {} - notice: text? = none - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) when target == viewer.id { - navigate replace profile(user: target) - } - - on profile-tapped(target: id) when target != viewer.id { - navigate profile(user: target) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Followers - - {#if notice != none} - - {/if} - {#match followers(user)} - {:when loading} - - Loading followers… - - {:when failed reason} - - Followers aren't available. - - {:when ready list} - {#if count(list.people) == 0} - - No followers yet. - - {:else} - - - {#each list.people as person (person.user.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/following/page.examples.uhura b/examples/instagram/client/app/profile/[user]/following/page.examples.uhura deleted file mode 100644 index 77a201c..0000000 --- a/examples/instagram/client/app/profile/[user]/following/page.examples.uhura +++ /dev/null @@ -1,18 +0,0 @@ -use fixture standard - -example loading { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira -} - -example lena default { - params { user = "user-lena" } - projection feed.viewer = fixture.users.mira - projection profile.following("user-lena") = fixture.people.lena-following -} - -example mira { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.following("user-mira") = fixture.people.mira-following -} diff --git a/examples/instagram/client/app/profile/[user]/following/page.uhura b/examples/instagram/client/app/profile/[user]/following/page.uhura deleted file mode 100644 index f3edb87..0000000 --- a/examples/instagram/client/app/profile/[user]/following/page.uhura +++ /dev/null @@ -1,122 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { projection following, command follow-user, command unfollow-user } - -param user: id - -store { - state { - follow-pending: map[id]bool = {} - notice: text? = none - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) when target == viewer.id { - navigate replace profile(user: target) - } - - on profile-tapped(target: id) when target != viewer.id { - navigate profile(user: target) - } - - on back-tapped() { - navigate back - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - - Following - - {#if notice != none} - - {/if} - {#match following(user)} - {:when loading} - - Loading following… - - {:when failed reason} - - Following isn't available. - - {:when ready list} - {#if count(list.people) == 0} - - Not following anyone yet. - - {:else} - - - {#each list.people as person (person.user.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/profile/[user]/page.examples.uhura b/examples/instagram/client/app/profile/[user]/page.examples.uhura deleted file mode 100644 index 23ab039..0000000 --- a/examples/instagram/client/app/profile/[user]/page.examples.uhura +++ /dev/null @@ -1,58 +0,0 @@ -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 lena-tagged { - from lena-posts - events [ profile-tab-selected(tab: "tagged") ] - note "the tile is Priya's real sourdough post from the seeded tag edge" -} - -example self { - params { user = "user-mira" } - projection feed.viewer = fixture.users.mira - projection profile.profile("user-mira") = fixture.profiles.mira -} - -example self-tagged { - from self - events [ profile-tab-selected(tab: "tagged") ] - note "Mira's tagged grid carries Marco's real Baja post id and opens shared post detail" -} - -example self-reels { - from self - events [ profile-tab-selected(tab: "reels") ] - note "Reels is a filtered view of Mira's genuine video posts" -} - -example self-saved { - from self - events [ profile-tab-selected(tab: "saved") ] - note "Saved is private to Mira and mirrors her two seeded save edges" -} - -example nils-posts { - params { user = "user-nils" } - projection feed.viewer = fixture.users.mira - projection profile.profile("user-nils") = fixture.profiles.nils -} - -example nils-reels { - from nils-posts - events [ profile-tab-selected(tab: "reels") ] -} - -example nils-tagged-empty { - from nils-posts - events [ profile-tab-selected(tab: "tagged") ] -} diff --git a/examples/instagram/client/app/profile/[user]/page.uhura b/examples/instagram/client/app/profile/[user]/page.uhura deleted file mode 100644 index 831d338..0000000 --- a/examples/instagram/client/app/profile/[user]/page.uhura +++ /dev/null @@ -1,255 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component profile-header -use port feed { projection viewer } -use port profile { projection profile, command follow-user, command unfollow-user } - -param user: id - -store { - state { - active-tab: text = "posts" - relationship-pending: bool = false - notice: text? = none - } - - on profile-tab-selected(tab: text) { - set active-tab = tab - } - - on posts-tapped() { - set active-tab = "posts" - } - - on followers-tapped(target: id) { - navigate profile-followers(user: target) - } - - on following-tapped(target: id) { - navigate profile-following(user: target) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on follow-toggled(target: id, now-following: bool) when now-following && !relationship-pending { - set relationship-pending = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !relationship-pending { - set relationship-pending = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set relationship-pending = false - } - - on follow-user.err(tag, cmd, refusal) { - set relationship-pending = false - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set relationship-pending = false - } - - on unfollow-user.err(tag, cmd, refusal) { - set relationship-pending = false - set notice = "Couldn't unfollow this person." - } - - on create-tapped() { - navigate create() - } - - on notice-dismissed() { - set notice = none - } - - on back-tapped() { - navigate back - } - - on feed-tapped() { - navigate replace feed() - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" && user == viewer.id { - set active-tab = "posts" - } - - on tab-selected(section: text) when section == "profile" && user != viewer.id { - navigate replace profile(user: viewer.id) - } -} - - - {#if notice != none} - - {/if} - {#match profile(user)} - {:when loading} - - {#if user != viewer.id} - - {/if} - Profile - - - Loading profile… - - {:when failed reason} - - {#if user != viewer.id} - - {/if} - Profile - - - This profile didn't load. - - - {:when ready pr} - - {#if !pr.is-self} - - {/if} - {pr.user.username} - - - - - - - {#if pr.is-self} - - {/if} - - - {#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 active-tab == "reels"} - {#if count(pr.reels) == 0} - - - No reels yet. - - {:else} - - {#each pr.reels as th (th.id)} - - - {th.alt} - - - {/each} - - {/if} - {:else} - {#if active-tab == "saved"} - {#if count(pr.saved) == 0} - - - Posts you save will appear here. - - {:else} - - {#each pr.saved 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} - {/if} - {/if} - - {/match} - - - - diff --git a/examples/instagram/client/app/reels/page.examples.uhura b/examples/instagram/client/app/reels/page.examples.uhura deleted file mode 100644 index ca80e29..0000000 --- a/examples/instagram/client/app/reels/page.examples.uhura +++ /dev/null @@ -1,26 +0,0 @@ -use fixture standard - -example loading { - projection feed.viewer = fixture.users.mira -} - -example videos default { - projection feed.viewer = fixture.users.mira - projection feed.reels = fixture.reels.page - note "three real video posts backed by local fixture MP4s" -} - -example like-pending { - from videos - events [ like-toggled(post: "post-nils-aurora", now-liked: true) ] -} - -example save-pending { - from videos - events [ save-toggled(post: "post-theo-court", now-saved: true) ] -} - -example unsave-pending { - from videos - events [ save-toggled(post: "post-nils-aurora", now-saved: false) ] -} diff --git a/examples/instagram/client/app/reels/page.uhura b/examples/instagram/client/app/reels/page.uhura deleted file mode 100644 index 3ab7a6d..0000000 --- a/examples/instagram/client/app/reels/page.uhura +++ /dev/null @@ -1,174 +0,0 @@ -page - -use component bottom-nav -use component notice-bar -use component reel-card -use surface comments-sheet -use port feed { - projection reels - projection viewer - command like-post - command unlike-post - command save-post - command unsave-post -} - -store { - state { - like-overlay: map[id]bool = {} - like-pending: map[id]bool = {} - save-overlay: map[id]bool = {} - save-pending: map[id]bool = {} - notice: text? = none - } - - 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 update this like." - } - - 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 update this like." - } - - on save-toggled(post: id, now-saved: bool) when now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = true - set save-pending[post] = true - send save-post(post: post) - } - - on save-toggled(post: id, now-saved: bool) when !now-saved && !(save-pending[post] ?? false) { - set save-overlay[post] = false - set save-pending[post] = true - send unsave-post(post: post) - } - - on save-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on save-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't save this reel." - } - - on unsave-post.ok(tag, cmd) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - } - - on unsave-post.err(tag, cmd, refusal) { - set save-pending[cmd.post] = none - set save-overlay[cmd.post] = none - set notice = "Couldn't remove this reel from saved." - } - - on comments-requested(post: id) { - open-surface comments-sheet(post: post) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "search" { - navigate replace search() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on notice-dismissed() { - set notice = none - } -} - - - - Reels - - {#if notice != none} - - {/if} - {#match reels} - {:when loading} - - Loading reels… - - {:when failed reason} - - Reels aren't available. - - {:when ready r} - {#if count(r.posts) == 0} - - - No reels yet. - - {:else} - - - {#each r.posts as p (p.id)} - - {/each} - - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/search/page.examples.uhura b/examples/instagram/client/app/search/page.examples.uhura deleted file mode 100644 index b6b6661..0000000 --- a/examples/instagram/client/app/search/page.examples.uhura +++ /dev/null @@ -1,44 +0,0 @@ -use fixture standard - -example loading { - projection feed.viewer = fixture.users.mira -} - -example explore default { - projection feed.viewer = fixture.users.mira - projection profile.search-results = fixture.people.search-all - note "Explore combines real people and clickable post thumbnails" -} - -example searching { - from explore - events [ - query-changed(value: "nils") - search-submitted() - ] -} - -example nils-results { - from explore - events [ - query-changed(value: "nils") - search-submitted() - projection profile.search-results = fixture.people.search-nils - outcome search-people.ok() - ] -} - -example no-results { - from explore - events [ - query-changed(value: "no-such-person") - search-submitted() - projection profile.search-results = fixture.people.search-empty - outcome search-people.ok() - ] -} - -example empty-explore { - projection feed.viewer = fixture.users.mira - projection profile.search-results = fixture.people.search-empty -} diff --git a/examples/instagram/client/app/search/page.uhura b/examples/instagram/client/app/search/page.uhura deleted file mode 100644 index cc18bb5..0000000 --- a/examples/instagram/client/app/search/page.uhura +++ /dev/null @@ -1,178 +0,0 @@ -page - -use component bottom-nav -use component connection-row -use component notice-bar -use port feed { projection viewer } -use port profile { - projection search-results - command search-people - command follow-user - command unfollow-user -} - -store { - state { - query: text = "" - applied-query: text = "" - search-pending: bool = false - follow-pending: map[id]bool = {} - notice: text? = none - } - - on query-changed(value: text) { - set query = value - } - - on search-submitted() when !search-pending { - set search-pending = true - send search-people(query: query) - } - - on search-people.ok(tag, cmd) { - set search-pending = false - set applied-query = cmd.query - } - - on search-people.err(tag, cmd, refusal) { - set search-pending = false - set notice = "Search isn't available right now." - } - - on follow-toggled(target: id, now-following: bool) when now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send follow-user(user: target) - } - - on follow-toggled(target: id, now-following: bool) when !now-following && !(follow-pending[target] ?? false) { - set follow-pending[target] = true - send unfollow-user(user: target) - } - - on follow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on follow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't follow this person." - } - - on unfollow-user.ok(tag, cmd) { - set follow-pending[cmd.user] = none - } - - on unfollow-user.err(tag, cmd, refusal) { - set follow-pending[cmd.user] = none - set notice = "Couldn't unfollow this person." - } - - on profile-tapped(target: id) { - navigate profile(user: target) - } - - on post-tapped(post: id) { - navigate post(id: post) - } - - on tab-selected(section: text) when section == "feed" { - navigate replace feed() - } - - on tab-selected(section: text) when section == "create" { - navigate create() - } - - on tab-selected(section: text) when section == "reels" { - navigate replace reels() - } - - on tab-selected(section: text) when section == "profile" { - navigate replace profile(user: viewer.id) - } - - on tab-selected(section: text) when section == "search" && !search-pending { - set search-pending = true - send search-people(query: query) - } - - on notice-dismissed() { - set notice = none - } -} - - - - Explore - - - - - - {#if notice != none} - - {/if} - {#match search-results} - {:when loading} - - Finding people… - - {:when failed reason} - - Search isn't available. - - {:when ready results} - {#if count(results.people) == 0 && count(results.posts) == 0} - - No results found. - Try an account name or a word from a caption. - - {:else} - - {#if count(results.people) > 0} - - {if applied-query == "" then "Suggested accounts" else "Accounts"} - - {#each results.people as person (person.user.id)} - - {/each} - - - {/if} - {#if count(results.posts) > 0} - - {if applied-query == "" then "Explore posts" else "Posts"} - - {#each results.posts as post (post.id)} - - - {post.alt} - - - {/each} - - - {/if} - - {/if} - {/match} - - - - diff --git a/examples/instagram/client/app/story/[id]/page.examples.uhura b/examples/instagram/client/app/story/[id]/page.examples.uhura deleted file mode 100644 index 339c155..0000000 --- a/examples/instagram/client/app/story/[id]/page.examples.uhura +++ /dev/null @@ -1,46 +0,0 @@ -use fixture standard - -example loading { - params { id = "ring-lena" } - projection feed.viewer = fixture.users.mira -} - -example unseen default { - params { id = "ring-lena" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena") = fixture.story-details.lena - note "first of three: no previous target, next target present, all segments unseen" -} - -example lena-middle { - params { id = "ring-lena-glazes" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena-glazes") = fixture.story-details.lena-glazes - note "middle frame exposes both previous and next hit zones" -} - -example lena-last { - params { id = "ring-lena-studio" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-lena-studio") = fixture.story-details.lena-studio - note "last frame replaces Next with the close affordance" -} - -example self-middle-seen { - params { id = "ring-mira-tram" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-mira-tram") = fixture.story-details.mira-tram - note "Mira's own three-frame sequence is fully viewed" -} - -example seen { - params { id = "ring-priya" } - projection feed.viewer = fixture.users.mira - projection feed.story-by-id("ring-priya") = fixture.story-details.priya - note "a single-frame seen story has one progress segment and closes on advance" -} - -example marking-seen { - from unseen - events [ story-selected(story: "ring-lena") ] -} diff --git a/examples/instagram/client/app/story/[id]/page.uhura b/examples/instagram/client/app/story/[id]/page.uhura deleted file mode 100644 index 62b4472..0000000 --- a/examples/instagram/client/app/story/[id]/page.uhura +++ /dev/null @@ -1,128 +0,0 @@ -page - -use port feed { projection story-by-id, projection viewer, command mark-story-seen } - -param id: id - -store { - state { - mark-pending: bool = false - } - - on story-selected(story: id) when !mark-pending { - set mark-pending = true - send mark-story-seen(story: story) - } - - on mark-story-seen.ok(tag, cmd) { - set mark-pending = false - navigate replace story(id: cmd.story) - } - - on mark-story-seen.err(tag, cmd, refusal) { - set mark-pending = false - // Viewing is still useful when the read is available but its seen edge - // could not settle. The destination will honestly show failed if it was - // actually removed. - navigate replace story(id: cmd.story) - } - - on author-tapped(user: id) when user == viewer.id { - navigate replace profile(user: user) - } - - on author-tapped(user: id) when user != viewer.id { - navigate profile(user: user) - } - - on back-tapped() { - navigate back - } -} - - - {#match story-by-id(id)} - {:when loading} - - Loading story… - - {:when failed reason} - - This story is no longer available. - - - {:when ready s} - - {s.image.alt} - - - - - {#each s.progress as segment (segment.id)} - - {/each} - - - - - - - - - - {s.caption} - - - - {#if s.previous != none} - - {:else} - - {/if} - {#if s.next != none} - - {:else} - - {/if} - - - {/match} - - - diff --git a/examples/instagram/client/catalog/base.toml b/examples/instagram/client/catalog/base.toml deleted file mode 100644 index 820a2df..0000000 --- a/examples/instagram/client/catalog/base.toml +++ /dev/null @@ -1,184 +0,0 @@ -# The base semantic element catalog (design §10 — normative). Ten -# 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.3.0" - -[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.img] -class = "content" -children = "none" -# a11y completeness: exactly one of alt / decorative (§10). -exactly-one-of = [["alt", "decorative"]] - -[elements.img.props.src] -type = "asset" -required = true - -[elements.img.props.alt] -type = "text" - -[elements.img.props.decorative] -type = "bool" - -# First-class time-based media. The semantic props are deliberately small: -# source/poster are provider-resolved assets, label is the accessible name, -# and playback policy remains explicit instead of renderer magic. -[elements.video] -class = "content" -children = "none" - -[elements.video.props.src] -type = "asset" -required = true - -[elements.video.props.poster] -type = "asset" - -[elements.video.props.label] -type = "text" -required = true - -[elements.video.props.autoplay] -type = "bool" - -[elements.video.props.muted] -type = "bool" - -[elements.video.props.loop] -type = "bool" - -[elements.video.props.controls] -type = "bool" - -[elements.video.props.playsinline] -type = "bool" - -[elements.icon] -class = "content" -children = "none" - -[elements.icon.props.name] -type = "icon" -required = true - -[elements.icon.props.family] -type = "icon-family" - -[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.textfield] -class = "interactive" -children = "none" -# Binding `value` obligates handling `change` (controlled promotion, §10). -controlled = { prop = "value", event = "change" } - -[elements.textfield.props.value] -type = "text" - -[elements.textfield.props.placeholder] -type = "text" - -[elements.textfield.props.label] -type = "text" -required = true - -[elements.textfield.props.disabled] -type = "bool" - -[elements.textfield.events.change] -kind = "input" -carries = { value = "text" } - -[elements.textfield.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/client/components/bottom-nav.examples.uhura b/examples/instagram/client/components/bottom-nav.examples.uhura deleted file mode 100644 index ad03880..0000000 --- a/examples/instagram/client/components/bottom-nav.examples.uhura +++ /dev/null @@ -1,21 +0,0 @@ -use fixture standard - -example feed-active default { - props { current = "feed" } -} - -example profile-active { - props { current = "profile" } -} - -example create-active { - props { current = "create" } -} - -example search-active { - props { current = "search" } -} - -example reels-active { - props { current = "reels" } -} diff --git a/examples/instagram/client/components/bottom-nav.uhura b/examples/instagram/client/components/bottom-nav.uhura deleted file mode 100644 index 8d34ff7..0000000 --- a/examples/instagram/client/components/bottom-nav.uhura +++ /dev/null @@ -1,45 +0,0 @@ -component bottom-nav - -props { - current: text -} - -emits { - tab-selected(section: text) -} - - - Instagram - - - - - - - - - - diff --git a/examples/instagram/client/components/comment-row.examples.uhura b/examples/instagram/client/components/comment-row.examples.uhura deleted file mode 100644 index c29ba30..0000000 --- a/examples/instagram/client/components/comment-row.examples.uhura +++ /dev/null @@ -1,21 +0,0 @@ -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/client/components/comment-row.uhura b/examples/instagram/client/components/comment-row.uhura deleted file mode 100644 index 5294ea7..0000000 --- a/examples/instagram/client/components/comment-row.uhura +++ /dev/null @@ -1,28 +0,0 @@ -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/client/components/connection-row.examples.uhura b/examples/instagram/client/components/connection-row.examples.uhura deleted file mode 100644 index d6c6e27..0000000 --- a/examples/instagram/client/components/connection-row.examples.uhura +++ /dev/null @@ -1,25 +0,0 @@ -use fixture standard - -example following default { - props { - person = fixture.connections.lena - viewer = "user-mira" - pending = false - } -} - -example follow-action { - props { - person = fixture.connections.nils - viewer = "user-mira" - pending = false - } -} - -example pending { - props { - person = fixture.connections.nils - viewer = "user-mira" - pending = true - } -} diff --git a/examples/instagram/client/components/connection-row.uhura b/examples/instagram/client/components/connection-row.uhura deleted file mode 100644 index 2c01694..0000000 --- a/examples/instagram/client/components/connection-row.uhura +++ /dev/null @@ -1,39 +0,0 @@ -component connection-row - -use port profile { type connection } - -props { - person: connection - viewer: id - pending: bool -} - -emits { - profile-tapped(target: id) - follow-toggled(target: id, now-following: bool) -} - - - - - {person.user.avatar.alt} - - {person.user.username} - {person.user.display-name} - - - - {#if person.user.id != viewer} - - {/if} - - - diff --git a/examples/instagram/client/components/notice-bar.examples.uhura b/examples/instagram/client/components/notice-bar.examples.uhura deleted file mode 100644 index 682b0d1..0000000 --- a/examples/instagram/client/components/notice-bar.examples.uhura +++ /dev/null @@ -1,5 +0,0 @@ -use fixture standard - -example refusal default { - props { text = "Couldn't like this post. Try again." } -} diff --git a/examples/instagram/client/components/notice-bar.uhura b/examples/instagram/client/components/notice-bar.uhura deleted file mode 100644 index ca29deb..0000000 --- a/examples/instagram/client/components/notice-bar.uhura +++ /dev/null @@ -1,21 +0,0 @@ -component notice-bar - -props { - text: text -} - -emits { - dismissed() -} - - - {text} - - - - diff --git a/examples/instagram/client/components/post-card.examples.uhura b/examples/instagram/client/components/post-card.examples.uhura deleted file mode 100644 index 25e1043..0000000 --- a/examples/instagram/client/components/post-card.examples.uhura +++ /dev/null @@ -1,61 +0,0 @@ -//! Design examples for the post-card component. -use fixture standard - -/// The canonical image-post example. -example image-post default { - props { - post = fixture.posts.lena-glaze - liked = false - like-pending = false - saved = false - save-pending = false - show-open = true - } -} - -example carousel-liked { - props { - post = fixture.posts.marco-baja - liked = true - like-pending = false - saved = true - save-pending = false - show-open = true - } -} - -example video-post { - props { - post = fixture.posts.nils-aurora - liked = false - like-pending = false - saved = true - save-pending = false - show-open = true - } - note "native video with a fixture MP4, poster, accessible label, and controls" -} - -example like-pending { - props { - post = fixture.posts.lena-glaze - liked = true - like-pending = true - saved = false - save-pending = false - show-open = true - } - note "busy heart during the optimistic window" -} - -example save-pending { - props { - post = fixture.posts.lena-glaze - liked = false - like-pending = false - saved = true - save-pending = true - show-open = true - } - note "optimistic bookmark while the private saved-library edge settles" -} diff --git a/examples/instagram/client/components/post-card.uhura b/examples/instagram/client/components/post-card.uhura deleted file mode 100644 index eb8a1a0..0000000 --- a/examples/instagram/client/components/post-card.uhura +++ /dev/null @@ -1,110 +0,0 @@ -//! Shared post presentation for the Instagram example. -/// Presents one post and its primary interactions. -component post-card - -use port feed { type post-summary } - -props { - /// The post projection rendered by this card. - post: post-summary - liked: bool - like-pending: bool - saved: bool - save-pending: bool - show-open: bool -} - -emits { - like-toggled(post: id, now-liked: bool) - save-toggled(post: id, now-saved: bool) - comments-requested(post: id) - author-tapped(user: id) - post-tapped(post: 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} - - - diff --git a/examples/instagram/client/components/profile-header.examples.uhura b/examples/instagram/client/components/profile-header.examples.uhura deleted file mode 100644 index b31fc7e..0000000 --- a/examples/instagram/client/components/profile-header.examples.uhura +++ /dev/null @@ -1,41 +0,0 @@ -use fixture standard - -example lena default { - props { - user = fixture.users.lena - bio = "Ceramics and slow mornings. Small-batch studio work from Portland." - is-self = false - viewer-follows = true - relationship-pending = false - post-count = 10 - follower-count = 8 - following-count = 5 - } -} - -example self { - props { - user = fixture.users.mira - bio = "Food and travel photographer in Lisbon. Usually awake before the trams." - is-self = true - viewer-follows = false - relationship-pending = false - post-count = 6 - follower-count = 4 - following-count = 6 - } - note "the current actor gets a New post action, never a self-follow action" -} - -example follow-pending { - props { - user = fixture.users.nils - bio = "Night skies and northern water, filmed around Tromsø." - is-self = false - viewer-follows = true - relationship-pending = true - post-count = 1 - follower-count = 2 - following-count = 3 - } -} diff --git a/examples/instagram/client/components/profile-header.uhura b/examples/instagram/client/components/profile-header.uhura deleted file mode 100644 index 3498156..0000000 --- a/examples/instagram/client/components/profile-header.uhura +++ /dev/null @@ -1,72 +0,0 @@ -component profile-header - -use port profile { type user-ref } - -props { - user: user-ref - bio: text - is-self: bool - viewer-follows: bool - relationship-pending: bool - post-count: int - follower-count: int - following-count: int -} - -emits { - posts-tapped() - followers-tapped(target: id) - following-tapped(target: id) - follow-toggled(target: id, now-following: bool) - create-tapped() -} - - - - {user.avatar.alt} - - - - - - - {user.display-name} - {"@" ++ user.username} - {bio} - - - {#if is-self} - - {:else} - - {/if} - - - - diff --git a/examples/instagram/client/components/reel-card.examples.uhura b/examples/instagram/client/components/reel-card.examples.uhura deleted file mode 100644 index a6a3b3c..0000000 --- a/examples/instagram/client/components/reel-card.examples.uhura +++ /dev/null @@ -1,32 +0,0 @@ -use fixture standard - -example aurora default { - props { - post = fixture.posts.nils-aurora - liked = false - like-pending = false - saved = true - save-pending = false - } - note "a real vertical player surface backed by the seeded aurora MP4" -} - -example court { - props { - post = fixture.posts.theo-court - liked = false - like-pending = false - saved = false - save-pending = false - } -} - -example save-pending { - props { - post = fixture.posts.theo-court - liked = false - like-pending = false - saved = true - save-pending = true - } -} diff --git a/examples/instagram/client/components/reel-card.uhura b/examples/instagram/client/components/reel-card.uhura deleted file mode 100644 index fbd15f0..0000000 --- a/examples/instagram/client/components/reel-card.uhura +++ /dev/null @@ -1,84 +0,0 @@ -component reel-card - -use port feed { type post-summary } - -props { - post: post-summary - liked: bool - like-pending: bool - saved: bool - save-pending: bool -} - -emits { - like-toggled(post: id, now-liked: bool) - save-toggled(post: id, now-saved: bool) - comments-requested(post: id) - author-tapped(user: id) - post-tapped(post: id) -} - - - - {#match post.media} - {:when video v} - - - - - - - {post.author.avatar.alt} - {post.author.username} - - - {post.caption} - - - - - - - - - - - diff --git a/examples/instagram/client/components/stories-tray.examples.uhura b/examples/instagram/client/components/stories-tray.examples.uhura deleted file mode 100644 index efc188d..0000000 --- a/examples/instagram/client/components/stories-tray.examples.uhura +++ /dev/null @@ -1,5 +0,0 @@ -use fixture standard - -example tray default { - props { stories = fixture.feed.stories } -} diff --git a/examples/instagram/client/components/stories-tray.uhura b/examples/instagram/client/components/stories-tray.uhura deleted file mode 100644 index 2d2c4d7..0000000 --- a/examples/instagram/client/components/stories-tray.uhura +++ /dev/null @@ -1,37 +0,0 @@ -component stories-tray - -use port feed { type story-ring } - -props { - stories: list[story-ring] -} - -emits { - story-tapped(story: id) -} - - - - {#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/client/evidence.uhura b/examples/instagram/client/evidence.uhura new file mode 100644 index 0000000..91aca5a --- /dev/null +++ b/examples/instagram/client/evidence.uhura @@ -0,0 +1,1353 @@ +language uhura 0.3 +module app.instagram.evidence@1 + +use evidence + +import { + Authority, + BottomNav, + CommentRow, + CommentsSheet, + ConnectionRow, + CreatePage, + DEMO_APPENDED, + DEMO_EMPTY, + DEMO_EMPTY_EXPLORE, + DEMO_EXHAUSTED, + DEMO_STANDARD, + FeedPage, + FollowersPage, + FollowingPage, + INSTAGRAM_ROUTES, + Instagram, + Location, + Mutation, + NoticeBar, + POST_AYLA_FERRY, + POST_LENA_BOWLS, + POST_LENA_GLAZE, + POST_MARCO_BAJA, + POST_NILS_AURORA, + POST_THEO_COURT, + PostCard, + PostPage, + ProfileTab, + ProfileHeader, + ProfilePage, + ReelCard, + ReelsPage, + RequestId, + SearchPage, + Settlement, + STORY_LENA, + STORY_LENA_GLAZES, + STORY_LENA_STUDIO, + STORY_MIRA_TRAM, + STORY_PRIYA, + StoriesTray, + StoryPage, + USER_LENA, + USER_MIRA, + USER_NILS, +} from "app.instagram@1" +import { Observation } from "uhura.observation@1" +import { RequestPort } from "uhura.ports@1" +import { Router } from "uhura.web_router@1" + + +scenario bootstrap_loading for Instagram { + bind router = Router.fixture(INSTAGRAM_ROUTES) + bind authority = Observation.fixture() + bind mutations = RequestPort.fixture() + + start + deliver router.changed(Feed) + expect Accepted commands [] + pin frame +} + +checkpoint loading_base = bootstrap_loading::frame + +scenario bootstrap_ready from loading_base { + expect restore commands [] + deliver authority.observed(Ready(DEMO_STANDARD)) + expect Accepted commands [] + pin frame +} + +checkpoint ready_base = bootstrap_ready::frame + + +// Pages: Create + +scenario create_loading_scenario from loading_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario create_empty_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario create_choosing_scenario + from create_empty_scenario::frame +{ + send ChooseImage + expect Accepted commands [ + mutations.request(RequestId(1), ChooseImage), + ] + pin frame +} + +scenario create_uploaded_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + send ChooseImage + expect Accepted commands [ + mutations.request(RequestId(1), ChooseImage), + ] + deliver mutations.settled( + RequestId(1), + ImageReady( + "object-mira-draft", + "media-ayla-ferry", + "lisbon-ferry.webp", + ), + ) + expect Accepted commands [] + pin frame +} + +scenario create_composed_scenario + from create_uploaded_scenario::frame +{ + send CaptionChanged("Last light over the Tagus.") + expect Accepted commands [] + send AltChanged( + "Ferry wake glowing orange beneath the Lisbon skyline", + ) + expect Accepted commands [] + pin frame +} + +scenario create_publishing_scenario + from create_composed_scenario::frame +{ + send PublishImage + expect Accepted commands [ + mutations.request( + RequestId(2), + PublishImage( + "object-mira-draft", + "Last light over the Tagus.", + "Ferry wake glowing orange beneath the Lisbon skyline", + ), + ), + ] + pin frame +} + +scenario create_publish_refused_scenario + from create_publishing_scenario::frame +{ + deliver mutations.settled( + RequestId(2), + Refused("image-not-ready"), + ) + expect Accepted commands [] + pin frame +} + + +// Pages: Feed + +scenario feed_loading_scenario from loading_base { + pin frame +} + +scenario feed_first_page_scenario from ready_base { + pin frame +} + +scenario feed_like_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario feed_like_refused_scenario + from feed_like_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("network unavailable"), + ) + expect Accepted commands [] + pin frame +} + +scenario feed_save_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario feed_unsave_pending_scenario + from feed_first_page_scenario::frame +{ + send ToggleSave(POST_MARCO_BAJA, false) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_MARCO_BAJA, false), + ), + ] + pin frame +} + +scenario feed_comments_open_scenario + from feed_first_page_scenario::frame +{ + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + +scenario feed_load_pending_scenario + from feed_first_page_scenario::frame +{ + send FeedNearEnd + expect Accepted commands [ + mutations.request(RequestId(1), LoadMore), + ] + pin frame +} + +scenario feed_load_failed_scenario + from feed_load_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("unreachable"), + ) + expect Accepted commands [] + pin frame +} + +scenario feed_appended_scenario + from feed_first_page_scenario::frame +{ + send FeedNearEnd + expect Accepted commands [ + mutations.request(RequestId(1), LoadMore), + ] + deliver authority.observed(Ready(DEMO_APPENDED)) + expect Accepted commands [] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario feed_exhausted_scenario from ready_base { + deliver authority.observed(Ready(DEMO_EXHAUSTED)) + expect Accepted commands [] + pin frame +} + +scenario feed_empty_scenario from ready_base { + deliver authority.observed(Ready(DEMO_EMPTY)) + expect Accepted commands [] + pin frame +} + +scenario feed_failed_scenario from ready_base { + deliver authority.observed(Failed("unreachable")) + expect Accepted commands [] + pin frame +} + + +// Pages: Post + +scenario post_loading_scenario from loading_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_lena_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_profile_history_scenario from ready_base { + deliver router.changed(Post(POST_LENA_BOWLS)) + expect Accepted commands [] + pin frame +} + +scenario post_like_pending_scenario + from post_lena_scenario::frame +{ + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_save_pending_scenario + from post_lena_scenario::frame +{ + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_comments_open_scenario + from post_lena_scenario::frame +{ + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + + +// Pages: Followers + +scenario followers_loading_scenario from loading_base { + deliver router.changed(Followers(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario followers_lena_scenario from ready_base { + deliver router.changed(Followers(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario followers_follow_pending_scenario + from followers_lena_scenario::frame +{ + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + +scenario followers_mira_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + pin frame +} + + +// Pages: Following + +scenario following_loading_scenario from loading_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario following_lena_scenario from ready_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario following_mira_scenario from ready_base { + deliver router.changed(Following(USER_MIRA)) + expect Accepted commands [] + pin frame +} + + +// Pages: Profile + +scenario profile_loading_scenario from loading_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_lena_posts_scenario from ready_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_lena_tagged_scenario + from profile_lena_posts_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + +scenario profile_self_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario profile_self_tagged_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + +scenario profile_self_reels_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Reels) + expect Accepted commands [] + pin frame +} + +scenario profile_self_saved_scenario + from profile_self_scenario::frame +{ + send SelectProfileTab(Saved) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_posts_scenario from ready_base { + deliver router.changed(Profile(USER_NILS)) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_reels_scenario + from profile_nils_posts_scenario::frame +{ + send SelectProfileTab(Reels) + expect Accepted commands [] + pin frame +} + +scenario profile_nils_tagged_empty_scenario + from profile_nils_posts_scenario::frame +{ + send SelectProfileTab(Tagged) + expect Accepted commands [] + pin frame +} + + +// Pages: Reels + +scenario reels_loading_scenario from loading_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + +scenario reels_videos_scenario from ready_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + +scenario reels_like_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleLike(POST_NILS_AURORA, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_NILS_AURORA, true), + ), + ] + pin frame +} + +scenario reels_save_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleSave(POST_THEO_COURT, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_THEO_COURT, true), + ), + ] + pin frame +} + +scenario reels_unsave_pending_scenario + from reels_videos_scenario::frame +{ + send ToggleSave(POST_NILS_AURORA, false) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_NILS_AURORA, false), + ), + ] + pin frame +} + + +// Pages: Search + +scenario search_loading_scenario from loading_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario search_explore_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario search_searching_scenario + from search_explore_scenario::frame +{ + send SearchChanged("nils") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("nils"), + ), + ] + pin frame +} + +scenario search_nils_results_scenario + from search_explore_scenario::frame +{ + send SearchChanged("nils") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("nils"), + ), + ] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario search_no_results_scenario + from search_explore_scenario::frame +{ + send SearchChanged("no-such-person") + expect Accepted commands [] + send SubmitSearch + expect Accepted commands [ + mutations.request( + RequestId(1), + SearchPeople("no-such-person"), + ), + ] + deliver mutations.settled(RequestId(1), Accepted) + expect Accepted commands [] + pin frame +} + +scenario search_empty_explore_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + deliver authority.observed(Ready(DEMO_EMPTY_EXPLORE)) + expect Accepted commands [] + pin frame +} + + +// Pages: Story + +scenario story_loading_scenario from loading_base { + deliver router.changed(Story(STORY_LENA)) + expect Accepted commands [] + pin frame +} + +scenario story_unseen_scenario from ready_base { + deliver router.changed(Story(STORY_LENA)) + expect Accepted commands [] + pin frame +} + +scenario story_lena_middle_scenario from ready_base { + deliver router.changed(Story(STORY_LENA_GLAZES)) + expect Accepted commands [] + pin frame +} + +scenario story_lena_last_scenario from ready_base { + deliver router.changed(Story(STORY_LENA_STUDIO)) + expect Accepted commands [] + pin frame +} + +scenario story_self_middle_seen_scenario from ready_base { + deliver router.changed(Story(STORY_MIRA_TRAM)) + expect Accepted commands [] + pin frame +} + +scenario story_seen_scenario from ready_base { + deliver router.changed(Story(STORY_PRIYA)) + expect Accepted commands [] + pin frame +} + +scenario story_marking_seen_scenario + from story_unseen_scenario::frame +{ + send MarkStorySeen(STORY_LENA) + expect Accepted commands [ + mutations.request( + RequestId(1), + MarkStory(STORY_LENA), + ), + ] + pin frame +} + + +// Components: bottom navigation + +scenario bottom_nav_feed_scenario from ready_base { + pin frame +} + +scenario bottom_nav_profile_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_create_scenario from ready_base { + deliver router.changed(Create) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_search_scenario from ready_base { + deliver router.changed(Search) + expect Accepted commands [] + pin frame +} + +scenario bottom_nav_reels_scenario from ready_base { + deliver router.changed(Reels) + expect Accepted commands [] + pin frame +} + + +// Components: comment row + +scenario comment_row_settled_scenario from ready_base { + pin frame +} + +scenario comment_row_pending_scenario from ready_base { + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + send CommentChanged( + "Saving this palette for my kitchen reno — stunning work!", + ) + expect Accepted commands [] + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment( + POST_LENA_GLAZE, + "Saving this palette for my kitchen reno — stunning work!", + ), + ), + ] + pin frame +} + + +// Components: connection row + +scenario connection_following_scenario from ready_base { + deliver router.changed(Following(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario connection_follow_action_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario connection_pending_scenario from ready_base { + deliver router.changed(Followers(USER_MIRA)) + expect Accepted commands [] + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + + +// Components: notice bar + +scenario notice_refusal_scenario from ready_base { + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + deliver mutations.settled( + RequestId(1), + Refused("network unavailable"), + ) + expect Accepted commands [] + pin frame +} + + +// Components: Post card + +scenario post_card_image_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + pin frame +} + +scenario post_card_carousel_scenario from ready_base { + deliver router.changed(Post(POST_MARCO_BAJA)) + expect Accepted commands [] + pin frame +} + +scenario post_card_video_scenario from ready_base { + deliver router.changed(Post(POST_NILS_AURORA)) + expect Accepted commands [] + pin frame +} + +scenario post_card_like_pending_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + send ToggleLike(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetLike(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + +scenario post_card_save_pending_scenario from ready_base { + deliver router.changed(Post(POST_LENA_GLAZE)) + expect Accepted commands [] + send ToggleSave(POST_LENA_GLAZE, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_LENA_GLAZE, true), + ), + ] + pin frame +} + + +// Components: Profile header + +scenario profile_header_lena_scenario from ready_base { + deliver router.changed(Profile(USER_LENA)) + expect Accepted commands [] + pin frame +} + +scenario profile_header_self_scenario from ready_base { + deliver router.changed(Profile(USER_MIRA)) + expect Accepted commands [] + pin frame +} + +scenario profile_header_follow_pending_scenario from ready_base { + deliver router.changed(Profile(USER_NILS)) + expect Accepted commands [] + send ToggleFollow(USER_NILS, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetFollow(USER_NILS, true), + ), + ] + pin frame +} + + +// Components: reel card + +scenario reel_card_aurora_scenario from ready_base { + deliver router.changed(Post(POST_NILS_AURORA)) + expect Accepted commands [] + pin frame +} + +scenario reel_card_court_scenario from ready_base { + deliver router.changed(Post(POST_THEO_COURT)) + expect Accepted commands [] + pin frame +} + +scenario reel_card_save_pending_scenario from ready_base { + deliver router.changed(Post(POST_THEO_COURT)) + expect Accepted commands [] + send ToggleSave(POST_THEO_COURT, true) + expect Accepted commands [ + mutations.request( + RequestId(1), + SetSave(POST_THEO_COURT, true), + ), + ] + pin frame +} + + +// Components: stories tray + +scenario stories_tray_scenario from ready_base { + pin frame +} + + +// Surface: comments sheet + +scenario comments_populated_scenario from ready_base { + send OpenComments(POST_LENA_GLAZE) + expect Accepted commands [] + pin frame +} + +scenario comments_composing_scenario + from comments_populated_scenario::frame +{ + send CommentChanged( + "Saving this palette for my kitchen reno", + ) + expect Accepted commands [] + pin frame +} + +scenario comments_pending_append_scenario + from comments_composing_scenario::frame +{ + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment( + POST_LENA_GLAZE, + "Saving this palette for my kitchen reno", + ), + ), + ] + pin frame +} + +scenario comments_empty_scenario from ready_base { + send OpenComments(POST_AYLA_FERRY) + expect Accepted commands [] + pin frame +} + +scenario comments_empty_composing_scenario + from comments_empty_scenario::frame +{ + send CommentChanged("First comment") + expect Accepted commands [] + pin frame +} + +scenario comments_empty_pending_scenario + from comments_empty_composing_scenario::frame +{ + send SubmitComment + expect Accepted commands [ + mutations.request( + RequestId(1), + AddComment(POST_AYLA_FERRY, "First comment"), + ), + ] + pin frame +} + +scenario comments_rejected_scenario + from comments_empty_pending_scenario::frame +{ + deliver mutations.settled( + RequestId(1), + Refused("comment_body_invalid"), + ) + expect Accepted commands [] + pin frame +} + + +// The catalog is presentation-targeted. Every alias names one checked +// snapshot; `default` is unique within its presentation. + +example create_loading + for CreatePage as page + = create_loading_scenario::frame + +example create_empty + for CreatePage as page default + = create_empty_scenario::frame + +example create_choosing + for CreatePage as page + note "the platform picker/upload is in flight; no bytes enter Core" + = create_choosing_scenario::frame + +example create_uploaded + for CreatePage as page + = create_uploaded_scenario::frame + +example create_composed + for CreatePage as page + = create_composed_scenario::frame + +example create_publishing + for CreatePage as page + note "publish carries only the storage object id and authored text" + = create_publishing_scenario::frame + +example create_publish_refused + for CreatePage as page + = create_publish_refused_scenario::frame + + +example feed_loading + for FeedPage as page + note "cold start — nothing delivered yet" + = feed_loading_scenario::frame + +example feed_first_page + for FeedPage as page default + = feed_first_page_scenario::frame + +example feed_like_pending + for FeedPage as page + note "optimistic heart + count while the like request is in flight" + = feed_like_pending_scenario::frame + +example feed_like_refused + for FeedPage as page + note "transport unavailable — rollback, notice explains" + = feed_like_refused_scenario::frame + +example feed_save_pending + for FeedPage as page + note "optimistic bookmark while the save request is in flight" + = feed_save_pending_scenario::frame + +example feed_unsave_pending + for FeedPage as page + note "Marco's post starts saved because the seed contains Mira's save edge" + = feed_unsave_pending_scenario::frame + +example feed_comments_open + for FeedPage as page + note "the sheet mounts because the machine owns its semantic lifetime" + = feed_comments_open_scenario::frame + +example feed_load_pending + for FeedPage as page + note "footer spinner; the machine suppresses duplicate pagination" + = feed_load_pending_scenario::frame + +example feed_load_failed + for FeedPage as page + = feed_load_failed_scenario::frame + +example feed_appended + for FeedPage as page + note "all six followed-author posts are loaded, so has-more is false" + = feed_appended_scenario::frame + +example feed_exhausted + for FeedPage as page + note "has-more false — end cap; pinned state" + = feed_exhausted_scenario::frame + +example feed_empty + for FeedPage as page + = feed_empty_scenario::frame + +example feed_failed + for FeedPage as page + note "provider reported an authoritative source failure" + = feed_failed_scenario::frame + + +example post_loading + for PostPage as page + = post_loading_scenario::frame + +example post_lena + for PostPage as page default + = post_lena_scenario::frame + +example post_profile_history + for PostPage as page + note "a profile-grid tile opens a genuine post, not a decorative thumbnail" + = post_profile_history_scenario::frame + +example post_like_pending + for PostPage as page + = post_like_pending_scenario::frame + +example post_save_pending + for PostPage as page + = post_save_pending_scenario::frame + +example post_comments_open + for PostPage as page + = post_comments_open_scenario::frame + + +example followers_loading + for FollowersPage as page + = followers_loading_scenario::frame + +example followers_lena + for FollowersPage as page default + = followers_lena_scenario::frame + +example followers_follow_pending + for FollowersPage as page + = followers_follow_pending_scenario::frame + +example followers_mira + for FollowersPage as page + = followers_mira_scenario::frame + + +example following_loading + for FollowingPage as page + = following_loading_scenario::frame + +example following_lena + for FollowingPage as page default + = following_lena_scenario::frame + +example following_mira + for FollowingPage as page + = following_mira_scenario::frame + + +example profile_loading + for ProfilePage as page + = profile_loading_scenario::frame + +example profile_lena_posts + for ProfilePage as page default + = profile_lena_posts_scenario::frame + +example profile_lena_tagged + for ProfilePage as page + note "the tile is Priya's real sourdough post from the seeded tag edge" + = profile_lena_tagged_scenario::frame + +example profile_self + for ProfilePage as page + = profile_self_scenario::frame + +example profile_self_tagged + for ProfilePage as page + note "Mira's tagged grid carries Marco's real Baja post id" + = profile_self_tagged_scenario::frame + +example profile_self_reels + for ProfilePage as page + note "Reels is a filtered view of Mira's genuine video posts" + = profile_self_reels_scenario::frame + +example profile_self_saved + for ProfilePage as page + note "Saved is private to Mira and mirrors her two seeded save edges" + = profile_self_saved_scenario::frame + +example profile_nils_posts + for ProfilePage as page + = profile_nils_posts_scenario::frame + +example profile_nils_reels + for ProfilePage as page + = profile_nils_reels_scenario::frame + +example profile_nils_tagged_empty + for ProfilePage as page + = profile_nils_tagged_empty_scenario::frame + + +example reels_loading + for ReelsPage as page + = reels_loading_scenario::frame + +example reels_videos + for ReelsPage as page default + note "three real video posts backed by local fixture MP4s" + = reels_videos_scenario::frame + +example reels_like_pending + for ReelsPage as page + = reels_like_pending_scenario::frame + +example reels_save_pending + for ReelsPage as page + = reels_save_pending_scenario::frame + +example reels_unsave_pending + for ReelsPage as page + = reels_unsave_pending_scenario::frame + + +example search_loading + for SearchPage as page + = search_loading_scenario::frame + +example search_explore + for SearchPage as page default + note "Explore combines real people and clickable post thumbnails" + = search_explore_scenario::frame + +example search_searching + for SearchPage as page + = search_searching_scenario::frame + +example search_nils_results + for SearchPage as page + = search_nils_results_scenario::frame + +example search_no_results + for SearchPage as page + = search_no_results_scenario::frame + +example search_empty_explore + for SearchPage as page + = search_empty_explore_scenario::frame + + +example story_loading + for StoryPage as page + = story_loading_scenario::frame + +example story_unseen + for StoryPage as page default + note "first of three: no previous target, next target present, all segments unseen" + = story_unseen_scenario::frame + +example story_lena_middle + for StoryPage as page + note "middle frame exposes both previous and next hit zones" + = story_lena_middle_scenario::frame + +example story_lena_last + for StoryPage as page + note "last frame replaces Next with the close affordance" + = story_lena_last_scenario::frame + +example story_self_middle_seen + for StoryPage as page + note "Mira's own three-frame sequence is fully viewed" + = story_self_middle_seen_scenario::frame + +example story_seen + for StoryPage as page + note "a single-frame seen story has one progress segment and closes on advance" + = story_seen_scenario::frame + +example story_marking_seen + for StoryPage as page + = story_marking_seen_scenario::frame + + +example bottom_nav_feed_active + for BottomNav as component default + = bottom_nav_feed_scenario::frame + +example bottom_nav_profile_active + for BottomNav as component + = bottom_nav_profile_scenario::frame + +example bottom_nav_create_active + for BottomNav as component + = bottom_nav_create_scenario::frame + +example bottom_nav_search_active + for BottomNav as component + = bottom_nav_search_scenario::frame + +example bottom_nav_reels_active + for BottomNav as component + = bottom_nav_reels_scenario::frame + + +example comment_row_settled + for CommentRow as component default + = comment_row_settled_scenario::frame + +example comment_row_pending + for CommentRow as component + = comment_row_pending_scenario::frame + + +example connection_row_following + for ConnectionRow as component default + = connection_following_scenario::frame + +example connection_row_follow_action + for ConnectionRow as component + = connection_follow_action_scenario::frame + +example connection_row_pending + for ConnectionRow as component + = connection_pending_scenario::frame + + +example notice_bar_refusal + for NoticeBar as component default + = notice_refusal_scenario::frame + + +example post_card_image + for PostCard as component default + = post_card_image_scenario::frame + +example post_card_carousel_liked + for PostCard as component + = post_card_carousel_scenario::frame + +example post_card_video + for PostCard as component + note "native video with a fixture MP4, poster, accessible label, and controls" + = post_card_video_scenario::frame + +example post_card_like_pending + for PostCard as component + note "busy heart during the optimistic window" + = post_card_like_pending_scenario::frame + +example post_card_save_pending + for PostCard as component + note "optimistic bookmark while the private saved-library edge settles" + = post_card_save_pending_scenario::frame + + +example profile_header_lena + for ProfileHeader as component default + = profile_header_lena_scenario::frame + +example profile_header_self + for ProfileHeader as component + note "the current actor gets a New post action, never a self-follow action" + = profile_header_self_scenario::frame + +example profile_header_follow_pending + for ProfileHeader as component + = profile_header_follow_pending_scenario::frame + + +example reel_card_aurora + for ReelCard as component default + note "a real vertical player surface backed by the seeded aurora MP4" + = reel_card_aurora_scenario::frame + +example reel_card_court + for ReelCard as component + = reel_card_court_scenario::frame + +example reel_card_save_pending + for ReelCard as component + = reel_card_save_pending_scenario::frame + + +example stories_tray + for StoriesTray as component default + = stories_tray_scenario::frame + + +example comments_populated + for CommentsSheet as surface default + = comments_populated_scenario::frame + +example comments_composing + for CommentsSheet as surface + note "composer mid-draft; post enables once non-empty" + = comments_composing_scenario::frame + +example comments_pending_append + for CommentsSheet as surface + note "optimistic dimmed row until the outcome settles" + = comments_pending_append_scenario::frame + +example comments_empty + for CommentsSheet as surface + = comments_empty_scenario::frame + +example comments_empty_composing + for CommentsSheet as surface + = comments_empty_composing_scenario::frame + +example comments_empty_pending + for CommentsSheet as surface + note "the optimistic row replaces the empty state and serializes submission" + = comments_empty_pending_scenario::frame + +example comments_rejected + for CommentsSheet as surface + note "a refusal restores the submitted body for correction" + = comments_rejected_scenario::frame diff --git a/examples/instagram/client/fixtures/assets/manifest.toml b/examples/instagram/client/fixtures/assets/manifest.toml index 7ce38e5..ca05887 100644 --- a/examples/instagram/client/fixtures/assets/manifest.toml +++ b/examples/instagram/client/fixtures/assets/manifest.toml @@ -7,7 +7,7 @@ # https://grida.co/library/license # # `cargo run -p uhura-cli --bin gen-assets -- examples/instagram/client` -# validates and preserves sourced files; it only renders legacy motif entries. +# validates and preserves sourced files; it only renders declared motif entries. [assets.avatar-mira] file = "avatar-mira.webp" diff --git a/examples/instagram/client/fixtures/scripts/comment-ok.toml b/examples/instagram/client/fixtures/scripts/comment-ok.toml deleted file mode 100644 index 72eb3b6..0000000 --- a/examples/instagram/client/fixtures/scripts/comment-ok.toml +++ /dev/null @@ -1,46 +0,0 @@ -# §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/client/fixtures/scripts/demo.toml b/examples/instagram/client/fixtures/scripts/demo.toml deleted file mode 100644 index a5b47f7..0000000 --- a/examples/instagram/client/fixtures/scripts/demo.toml +++ /dev/null @@ -1,150 +0,0 @@ -# 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 = 1 -port = "create" -projection = "draft" -slice = "create.empty" - -[[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" - -# Hand-play create flow. File selection/upload remains a platform/provider -# concern in live play; this deterministic driver settles the same contract. -[[reply]] -on = { command = "choose-image" } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "create" -projection = "draft" -slice = "create.uploaded" - -[[reply]] -on = { command = "publish-image", where = { image = "object-mira-draft" } } -after-ticks = 1 -outcome = "ok" - -[[reply.updates]] -port = "create" -projection = "draft" -slice = "create.empty" - -[[reply.updates]] -port = "feed" -projection = "feed-page" -slice = "feed.page-1-created" - -[[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 is unavailable — rollback + notice bar. -[[reply]] -on = { command = "like-post", where = { post = "post-marco-baja" } } -after-ticks = 2 -outcome = "unavailable" -reason = "network unavailable" - -# 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/client/fixtures/scripts/feed-empty.toml b/examples/instagram/client/fixtures/scripts/feed-empty.toml deleted file mode 100644 index 021ab52..0000000 --- a/examples/instagram/client/fixtures/scripts/feed-empty.toml +++ /dev/null @@ -1,13 +0,0 @@ -# A followed-nobody feed: the empty state renders. The current scroll owns a -# near-end observer even when empty, so the scripted observation proves the -# projection-truth guard emits no pagination command. CI-goldened. - -[[deliver]] -after-ticks = 1 -port = "feed" -projection = "feed-page" -slice = "feed.empty" - -[[ui]] -at-tick = 2 -emit = "feed-near-end" diff --git a/examples/instagram/client/fixtures/scripts/feed-failed.toml b/examples/instagram/client/fixtures/scripts/feed-failed.toml deleted file mode 100644 index 9a8b842..0000000 --- a/examples/instagram/client/fixtures/scripts/feed-failed.toml +++ /dev/null @@ -1,22 +0,0 @@ -# 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/client/fixtures/scripts/like-ok.toml b/examples/instagram/client/fixtures/scripts/like-ok.toml deleted file mode 100644 index 417d03c..0000000 --- a/examples/instagram/client/fixtures/scripts/like-ok.toml +++ /dev/null @@ -1,23 +0,0 @@ -# §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/client/fixtures/scripts/like-refused.toml b/examples/instagram/client/fixtures/scripts/like-refused.toml deleted file mode 100644 index e0ae5ed..0000000 --- a/examples/instagram/client/fixtures/scripts/like-refused.toml +++ /dev/null @@ -1,24 +0,0 @@ -# §11.4 step 3: the authority is unavailable — 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 = "unavailable" -reason = "network unavailable" - -[[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/client/fixtures/scripts/paginate.toml b/examples/instagram/client/fixtures/scripts/paginate.toml deleted file mode 100644 index e62174f..0000000 --- a/examples/instagram/client/fixtures/scripts/paginate.toml +++ /dev/null @@ -1,27 +0,0 @@ -# §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); the two -# remaining followed-author posts append 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/client/fixtures/standard.toml b/examples/instagram/client/fixtures/standard.toml deleted file mode 100644 index 7aa140f..0000000 --- a/examples/instagram/client/fixtures/standard.toml +++ /dev/null @@ -1,1140 +0,0 @@ -# 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. Relative-time labels mirror the -# provider's clock formatting; every integer count matches the concrete -# post, like, comment, or follow rows in Spock's seed. - -# ── 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 - -# Story details share the ring ids. Mira, Lena, and Marco each have the same -# three-frame sequence as the Spock seed; previous/next stay within an -# author's sequence, and progress reflects Mira's concrete story-view rows. -[story-details.mira] -id = "ring-mira" -author = "@users.mira" -image = { src = "thumb-mira-1", alt = "Pastéis de nata cooling on a marble counter" } -caption = "Breakfast before the first tram" -posted-label = "20m" -viewer-has-viewed = true -next = "ring-mira-tram" -progress = [ - { id = "ring-mira", is-current = true, is-viewed = true }, - { id = "ring-mira-tram", is-current = false, is-viewed = true }, - { id = "ring-mira-market", is-current = false, is-viewed = true }, -] - -[story-details.mira-tram] -id = "ring-mira-tram" -author = "@users.mira" -image = { src = "thumb-mira-2", alt = "Tram rails catching the first light in Lisbon" } -caption = "Then the city wakes" -posted-label = "8m" -viewer-has-viewed = true -previous = "ring-mira" -next = "ring-mira-market" -progress = [ - { id = "ring-mira", is-current = false, is-viewed = true }, - { id = "ring-mira-tram", is-current = true, is-viewed = true }, - { id = "ring-mira-market", is-current = false, is-viewed = true }, -] - -[story-details.mira-market] -id = "ring-mira-market" -author = "@users.mira" -image = { src = "thumb-mira-3", alt = "Crates of citrus stacked at the morning market" } -caption = "Saturday palette" -posted-label = "now" -viewer-has-viewed = true -previous = "ring-mira-tram" -progress = [ - { id = "ring-mira", is-current = false, is-viewed = true }, - { id = "ring-mira-tram", is-current = false, is-viewed = true }, - { id = "ring-mira-market", is-current = true, is-viewed = true }, -] - -[story-details.lena] -id = "ring-lena" -author = "@users.lena" -image = { src = "thumb-lena-7", alt = "Lena throwing a tall clay cylinder" } -caption = "One pull, no edits" -posted-label = "35m" -viewer-has-viewed = false -next = "ring-lena-glazes" -progress = [ - { id = "ring-lena", is-current = true, is-viewed = false }, - { id = "ring-lena-glazes", is-current = false, is-viewed = false }, - { id = "ring-lena-studio", is-current = false, is-viewed = false }, -] - -[story-details.lena-glazes] -id = "ring-lena-glazes" -author = "@users.lena" -image = { src = "thumb-lena-8", alt = "Rows of glaze buckets labelled by firing cone" } -caption = "The unglamorous half of studio day" -posted-label = "22m" -viewer-has-viewed = false -previous = "ring-lena" -next = "ring-lena-studio" -progress = [ - { id = "ring-lena", is-current = false, is-viewed = false }, - { id = "ring-lena-glazes", is-current = true, is-viewed = false }, - { id = "ring-lena-studio", is-current = false, is-viewed = false }, -] - -[story-details.lena-studio] -id = "ring-lena-studio" -author = "@users.lena" -image = { src = "thumb-lena-9", alt = "Morning light crossing a clean ceramics workbench" } -caption = "Reset for tomorrow" -posted-label = "6m" -viewer-has-viewed = false -previous = "ring-lena-glazes" -progress = [ - { id = "ring-lena", is-current = false, is-viewed = false }, - { id = "ring-lena-glazes", is-current = false, is-viewed = false }, - { id = "ring-lena-studio", is-current = true, is-viewed = false }, -] - -[story-details.marco] -id = "ring-marco" -author = "@users.marco" -image = { src = "media-marco-baja-2", alt = "Campfire on a bluff above the break" } -caption = "Last night at camp" -posted-label = "1h" -viewer-has-viewed = false -next = "ring-marco-swell" -progress = [ - { id = "ring-marco", is-current = true, is-viewed = false }, - { id = "ring-marco-swell", is-current = false, is-viewed = false }, - { id = "ring-marco-dawn", is-current = false, is-viewed = false }, -] - -[story-details.marco-swell] -id = "ring-marco-swell" -author = "@users.marco" -image = { src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" } -caption = "It finally arrived" -posted-label = "48m" -viewer-has-viewed = false -previous = "ring-marco" -next = "ring-marco-dawn" -progress = [ - { id = "ring-marco", is-current = false, is-viewed = false }, - { id = "ring-marco-swell", is-current = true, is-viewed = false }, - { id = "ring-marco-dawn", is-current = false, is-viewed = false }, -] - -[story-details.marco-dawn] -id = "ring-marco-dawn" -author = "@users.marco" -image = { src = "media-marco-baja-3", alt = "Surfboard fins silhouetted against a Baja sunrise" } -caption = "Pack up before the wind" -posted-label = "36m" -viewer-has-viewed = false -previous = "ring-marco-swell" -progress = [ - { id = "ring-marco", is-current = false, is-viewed = false }, - { id = "ring-marco-swell", is-current = false, is-viewed = false }, - { id = "ring-marco-dawn", is-current = true, is-viewed = false }, -] - -[story-details.priya] -id = "ring-priya" -author = "@users.priya" -image = { src = "media-priya-starter", alt = "Freshly sliced sourdough loaf" } -caption = "Still warm" -posted-label = "2h" -viewer-has-viewed = true -progress = [{ id = "ring-priya", is-current = true, is-viewed = true }] - -[story-details.june] -id = "ring-june" -author = "@users.june" -image = { src = "media-june-lookbook", alt = "Linen garments arranged by shade" } -caption = "Fitting day" -posted-label = "2h" -viewer-has-viewed = false -progress = [{ id = "ring-june", is-current = true, is-viewed = false }] - -[story-details.kenji] -id = "ring-kenji" -author = "@users.kenji" -image = { src = "media-kenji-copper", alt = "Road bike at Copper Pass" } -caption = "Worth the climb" -posted-label = "3h" -viewer-has-viewed = true -progress = [{ id = "ring-kenji", is-current = true, is-viewed = true }] - -# ── 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 = 7 -comment-count = 4 -viewer-has-liked = false -viewer-has-saved = 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 8, -# 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 = 8 -comment-count = 4 -viewer-has-liked = true -viewer-has-saved = false -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 = 8 -comment-count = 5 -viewer-has-liked = true -viewer-has-saved = false -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 = 5 -comment-count = 2 -viewer-has-liked = false -viewer-has-saved = true -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." -like-count = 5 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = true -posted-label = "9h" - -[posts.nils-aurora.media.video] -src = "media-nils-aurora" - -[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 = 7 -comment-count = 2 -viewer-has-liked = false -viewer-has-saved = 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 = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = 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 = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = 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 = 4 -comment-count = 1 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "2d" - -[posts.theo-court.media.video] -src = "media-theo-court" - -[posts.theo-court.media.video.poster] -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 = 8 -comment-count = 1 -viewer-has-liked = true -viewer-has-saved = false -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" - -# Lena's and Mira's old decorative grid images are genuine post summaries. -# Their ids are the ids carried by profile tiles and post-detail projections. -[posts.lena-bowls] -id = "post-lena-bowls" -author = "@users.lena" -caption = "Copper glaze in close-up, before the wax cooled." -like-count = 2 -comment-count = 0 -viewer-has-liked = true -viewer-has-saved = false -posted-label = "4d" - -[posts.lena-bowls.media.image.image] -src = "thumb-lena-1" -alt = "Copper-red glaze tiles" - -[posts.lena-greenware] -id = "post-lena-greenware" -author = "@users.lena" -caption = "A quiet stack of bowls waiting for bisque firing." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "6d" - -[posts.lena-greenware.media.image.image] -src = "thumb-lena-2" -alt = "Stack of unglazed bowls" - -[posts.lena-kiln] -id = "post-lena-kiln" -author = "@users.lena" -caption = "Kiln Tetris, level thirty-seven." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "9d" - -[posts.lena-kiln.media.image.image] -src = "thumb-lena-3" -alt = "Kiln shelf mid-load" - -[posts.lena-celadon] -id = "post-lena-celadon" -author = "@users.lena" -caption = "Celadon tests after a slower cool-down." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "12d" - -[posts.lena-celadon.media.image.image] -src = "thumb-lena-4" -alt = "Celadon test cups" - -[posts.lena-clay] -id = "post-lena-clay" -author = "@users.lena" -caption = "Fresh reclaim, wedged and ready for tomorrow." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "16d" - -[posts.lena-clay.media.image.image] -src = "thumb-lena-5" -alt = "Wedging table with fresh clay" - -[posts.lena-plates] -id = "post-lena-plates" -author = "@users.lena" -caption = "Dinner plates with just enough iron speckle." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "20d" - -[posts.lena-plates.media.image.image] -src = "thumb-lena-6" -alt = "Iron-speckled dinner plates" - -[posts.lena-throwing] -id = "post-lena-throwing" -author = "@users.lena" -caption = "Pulling one tall cylinder before lunch." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "25d" - -[posts.lena-throwing.media.image.image] -src = "thumb-lena-7" -alt = "Throwing a tall cylinder" - -[posts.lena-buckets] -id = "post-lena-buckets" -author = "@users.lena" -caption = "Labelling day. Future me will be grateful." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "32d" - -[posts.lena-buckets.media.image.image] -src = "thumb-lena-8" -alt = "Glaze buckets labelled by cone" - -[posts.lena-morning] -id = "post-lena-morning" -author = "@users.lena" -caption = "Seven o'clock light across the clean bench." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "41d" - -[posts.lena-morning.media.image.image] -src = "thumb-lena-9" -alt = "Morning light across the studio bench" - -[posts.mira-pasteis] -id = "post-mira-pasteis" -author = "@users.mira" -caption = "The batch that vanished before I finished the coffee." -like-count = 2 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "5d" - -[posts.mira-pasteis.media.image.image] -src = "thumb-mira-1" -alt = "Pastéis de nata on a marble counter" - -[posts.mira-tram] -id = "post-mira-tram" -author = "@users.mira" -caption = "Rails holding the first light on Rua da Conceição." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "10d" - -[posts.mira-tram.media.image.image] -src = "thumb-mira-2" -alt = "Tram rails catching dawn light" - -[posts.mira-citrus] -id = "post-mira-citrus" -author = "@users.mira" -caption = "Saturday citrus, arranged better than any still life." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "15d" - -[posts.mira-citrus.media.image.image] -src = "thumb-mira-3" -alt = "Market citrus stacked in crates" - -[posts.mira-tiles] -id = "post-mira-tiles" -author = "@users.mira" -caption = "Blue after blue after blue." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "23d" - -[posts.mira-tiles.media.image.image] -src = "thumb-mira-4" -alt = "Tiled facade in alternating blues" - -[posts.mira-sardines] -id = "post-mira-sardines" -author = "@users.mira" -caption = "Sardines, smoke, lemon. Nothing else needed." -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "31d" - -[posts.mira-sardines.media.image.image] -src = "thumb-mira-5" -alt = "Grilled sardines over coals" - -[posts.mira-ferry] -id = "post-mira-ferry" -author = "@users.mira" -caption = "The last ferry left a gold line all the way home." -like-count = 1 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "43d" - -[posts.mira-ferry.media.video] -src = "media-mira-ferry" - -[posts.mira-ferry.media.video.poster] -src = "thumb-mira-6" -alt = "Ferry wake at golden hour" - -# Reels reuse the real video posts and their playable fixture media rather -# than maintaining a separate decorative dataset. -[reels.page] -posts = ["@posts.nils-aurora", "@posts.theo-court", "@posts.mira-ferry"] - -# ── 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.priya-starter", - "@posts.ayla-ferry", -] -cursor = "cursor-page-2" -has-more = true - -# Page 1 with Lena's post settled at 8 — the like-ok reply value. -[feed.page-1-liked] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze-liked", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", -] -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.priya-starter", - "@posts.ayla-ferry", -] -cursor = "cursor-page-2" -has-more = true - -[feed.page-2] -stories = "@feed.stories" -posts = [ - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.pages-1-2] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -# 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.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -# 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.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.final] -stories = "@feed.stories" -posts = [ - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", - "@posts.ayla-ferry", - "@posts.june-lookbook", - "@posts.kenji-copper", -] -has-more = false - -[feed.empty] -stories = ["@stories.ring-mira"] -posts = [] -has-more = false - -# ── create draft (create-draft) ───────────────────────────────────────── - -[create.empty.empty] - -[create.uploaded.uploaded] -object = "object-mira-draft" -preview = "thumb-mira-6" -name = "tagus-last-light.jpg" - -# The fixture authority's post after publish-image settles. The upload -# object id stays in the command; only authored caption/alt cross back into -# the projection through the scripted authority response. -[posts.mira-created] -id = "@fresh-id" -author = "@users.mira" -caption = "@payload.caption" -like-count = 0 -comment-count = 0 -viewer-has-liked = false -viewer-has-saved = false -posted-label = "now" - -[posts.mira-created.media.image.image] -src = "thumb-mira-6" -alt = "@payload.alt" - -[feed.page-1-created] -stories = "@feed.stories" -posts = [ - "@posts.mira-created", - "@posts.lena-glaze", - "@posts.marco-baja", - "@posts.priya-starter", -] -cursor = "cursor-page-2" -has-more = true - -# ── 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 and slow mornings. Small-batch studio work from Portland." -is-self = false -viewer-follows = true -post-count = 10 -follower-count = 8 -following-count = 5 -posts = [ - { id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles" }, - { id = "post-lena-bowls", src = "thumb-lena-1", alt = "Copper-red glaze tiles" }, - { id = "post-lena-greenware", src = "thumb-lena-2", alt = "Stack of unglazed bowls" }, - { id = "post-lena-kiln", src = "thumb-lena-3", alt = "Kiln shelf mid-load" }, - { id = "post-lena-celadon", src = "thumb-lena-4", alt = "Celadon test cups" }, - { id = "post-lena-clay", src = "thumb-lena-5", alt = "Wedging table with fresh clay" }, - { id = "post-lena-plates", src = "thumb-lena-6", alt = "Iron-speckled dinner plates" }, - { id = "post-lena-throwing", src = "thumb-lena-7", alt = "Throwing a tall cylinder" }, - { id = "post-lena-buckets", src = "thumb-lena-8", alt = "Glaze buckets labelled by cone" }, - { id = "post-lena-morning", src = "thumb-lena-9", alt = "Morning light across the studio bench" }, -] -reels = [] -saved = [] -tagged = [ - { id = "post-priya-starter", src = "media-priya-starter", alt = "Fresh sourdough loaf" }, -] - -[profiles.mira] -user = "@users.mira" -bio = "Food and travel photographer in Lisbon. Usually awake before the trams." -is-self = true -viewer-follows = false -post-count = 6 -follower-count = 4 -following-count = 6 -posts = [ - { id = "post-mira-pasteis", src = "thumb-mira-1", alt = "Pastéis de nata on a marble counter" }, - { id = "post-mira-tram", src = "thumb-mira-2", alt = "Tram rails catching dawn light" }, - { id = "post-mira-citrus", src = "thumb-mira-3", alt = "Market citrus stacked in crates" }, - { id = "post-mira-tiles", src = "thumb-mira-4", alt = "Tiled facade in alternating blues" }, - { id = "post-mira-sardines", src = "thumb-mira-5", alt = "Grilled sardines over coals" }, - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] -reels = [ - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] -saved = [ - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }, -] -tagged = [ - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, -] - -[profiles.marco] -user = "@users.marco" -bio = "Surf photographer, road-trip cook, and reluctant morning person." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 4 -posts = [{ id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long wave peeling along a desert point" }] -reels = [] -saved = [] -tagged = [{ id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike at Copper Pass" }] - -[profiles.nils] -user = "@users.nils" -bio = "Night skies and northern water, filmed around Tromsø." -is-self = false -viewer-follows = false -post-count = 1 -follower-count = 2 -following-count = 3 -posts = [{ id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }] -reels = [{ id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora over a fjord" }] -saved = [] -tagged = [] - -[profiles.priya] -user = "@users.priya" -bio = "Bread notebook, tiny kitchen, stubborn sourdough starter." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 4 -posts = [{ id = "post-priya-starter", src = "media-priya-starter", alt = "Fresh sourdough loaf" }] -reels = [] -saved = [] -tagged = [] - -[profiles.ayla] -user = "@users.ayla" -bio = "Istanbul by ferry. Architecture, tea, and ordinary light." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 4 -following-count = 3 -posts = [{ id = "post-ayla-ferry", src = "media-ayla-ferry", alt = "Morning ferry across the Bosphorus" }] -reels = [] -saved = [] -tagged = [{ id = "post-june-lookbook", src = "media-june-lookbook", alt = "Linen garments arranged by shade" }] - -[profiles.june] -user = "@users.june" -bio = "Natural-fiber clothes made in a very crowded studio." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 5 -following-count = 4 -posts = [{ id = "post-june-lookbook", src = "media-june-lookbook", alt = "Linen garments arranged by shade" }] -reels = [] -saved = [] -tagged = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] - -[profiles.theo] -user = "@users.theo" -bio = "Murals, community courts, and an unreliable jump shot." -is-self = false -viewer-follows = false -post-count = 1 -follower-count = 3 -following-count = 3 -posts = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] -reels = [{ id = "post-theo-court", src = "media-theo-court", alt = "Geometric basketball court mural" }] -saved = [] -tagged = [{ id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles" }] - -[profiles.kenji] -user = "@users.kenji" -bio = "Long climbs, quiet roads, and coffee at the turnaround." -is-self = false -viewer-follows = true -post-count = 1 -follower-count = 2 -following-count = 4 -posts = [{ id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike at Copper Pass" }] -reels = [] -saved = [] -tagged = [] - -# ── relationships (connection / connection-list) ──────────────────────── - -[connections.mira] -user = "@users.mira" -viewer-follows = false - -[connections.lena] -user = "@users.lena" -viewer-follows = true - -[connections.marco] -user = "@users.marco" -viewer-follows = true - -[connections.nils] -user = "@users.nils" -viewer-follows = false - -[connections.priya] -user = "@users.priya" -viewer-follows = true - -[connections.ayla] -user = "@users.ayla" -viewer-follows = true - -[connections.june] -user = "@users.june" -viewer-follows = true - -[connections.theo] -user = "@users.theo" -viewer-follows = false - -[connections.kenji] -user = "@users.kenji" -viewer-follows = true - -[people.search-all] -people = [ - "@connections.ayla", "@connections.june", "@connections.kenji", - "@connections.lena", "@connections.marco", "@connections.nils", - "@connections.priya", "@connections.theo", -] -posts = [ - { id = "post-lena-glaze", src = "media-lena-glaze", alt = "Grid of copper-red glaze test tiles on a maple bench" }, - { id = "post-marco-baja", src = "media-marco-baja-1", alt = "Long left-hand wave peeling along a desert point" }, - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora curtains over a dark fjord" }, - { id = "post-priya-starter", src = "media-priya-starter", alt = "Open crumb of a sourdough loaf, sliced on a flour-dusted board" }, - { id = "post-ayla-ferry", src = "media-ayla-ferry", alt = "Ferry deck railing over blue water, city skyline behind" }, - { id = "post-june-lookbook", src = "media-june-lookbook", alt = "Folded linen garments stacked by shade on a workbench" }, - { id = "post-theo-court", src = "media-theo-court", alt = "Basketball court painted with bold geometric shapes" }, - { id = "post-kenji-copper", src = "media-kenji-copper", alt = "Road bike leaning on a stone wall at a mountain pass" }, - { id = "post-lena-bowls", src = "thumb-lena-1", alt = "Copper-red glaze tiles" }, - { id = "post-mira-pasteis", src = "thumb-mira-1", alt = "Pastéis de nata on a marble counter" }, - { id = "post-lena-greenware", src = "thumb-lena-2", alt = "Stack of unglazed bowls" }, - { id = "post-lena-kiln", src = "thumb-lena-3", alt = "Kiln shelf mid-load" }, - { id = "post-mira-tram", src = "thumb-mira-2", alt = "Tram rails catching dawn light" }, - { id = "post-lena-celadon", src = "thumb-lena-4", alt = "Celadon test cups" }, - { id = "post-mira-citrus", src = "thumb-mira-3", alt = "Market citrus stacked in crates" }, - { id = "post-lena-clay", src = "thumb-lena-5", alt = "Wedging table with fresh clay" }, - { id = "post-lena-plates", src = "thumb-lena-6", alt = "Iron-speckled dinner plates" }, - { id = "post-mira-tiles", src = "thumb-mira-4", alt = "Tiled facade in alternating blues" }, - { id = "post-lena-throwing", src = "thumb-lena-7", alt = "Throwing a tall cylinder" }, - { id = "post-mira-sardines", src = "thumb-mira-5", alt = "Grilled sardines over coals" }, - { id = "post-lena-buckets", src = "thumb-lena-8", alt = "Glaze buckets labelled by cone" }, - { id = "post-lena-morning", src = "thumb-lena-9", alt = "Morning light across the studio bench" }, - { id = "post-mira-ferry", src = "thumb-mira-6", alt = "Ferry wake at golden hour" }, -] - -[people.search-empty] -people = [] -posts = [] - -[people.search-nils] -people = ["@connections.nils"] -posts = [ - { id = "post-nils-aurora", src = "media-nils-aurora-poster", alt = "Green aurora curtains over a dark fjord" }, -] - -[people.mira-followers] -people = ["@connections.lena", "@connections.priya", "@connections.june", "@connections.theo"] - -[people.mira-following] -people = [ - "@connections.lena", "@connections.marco", "@connections.priya", - "@connections.ayla", "@connections.june", "@connections.kenji", -] - -[people.lena-followers] -people = [ - "@connections.mira", "@connections.marco", "@connections.nils", - "@connections.priya", "@connections.ayla", "@connections.june", - "@connections.theo", "@connections.kenji", -] - -[people.lena-following] -people = [ - "@connections.mira", "@connections.marco", "@connections.priya", - "@connections.june", "@connections.theo", -] diff --git a/examples/instagram/client/host.toml b/examples/instagram/client/host.toml new file mode 100644 index 0000000..8082230 --- /dev/null +++ b/examples/instagram/client/host.toml @@ -0,0 +1,19 @@ +[entry.instagram] +machine = "crate::Instagram" +presentation = "crate::FeedPage" +lifetime = "application-session" +stylesheet = "styles/theme.css" + +[entry.instagram.ports] +router = "web.history" +authority = "app.provider" +mutations = "app.provider" + +[entry.instagram.provider] +module = "providers/dist/spock.js" + +[entry.instagram.provider.config] +graphql_url = "http://127.0.0.1:4000/graphql/v1" +rpc_url = "http://127.0.0.1:4000/rest/v1/rpc" +storage_url = "http://127.0.0.1:4000/storage/v1" +actor = "10000000-0000-4000-8000-000000000001" diff --git a/examples/instagram/client/machine.uhura b/examples/instagram/client/machine.uhura new file mode 100644 index 0000000..2c6dbcc --- /dev/null +++ b/examples/instagram/client/machine.uhura @@ -0,0 +1,1834 @@ +use uhura::observation::Observation; +use uhura::ports::RequestPort; +use uhura::web_router::{Router, Routes}; +use crate::parts::{Notice, NoticeControls}; + +pub key UserId(Text); + +pub key PostId(Text); + +pub key StoryId(Text); + +pub key RequestId(PositiveInt); + +pub const USER_MIRA: UserId = UserId("user-mira"); + +pub const USER_LENA: UserId = UserId("user-lena"); + +pub const USER_MARCO: UserId = UserId("user-marco"); + +pub const USER_NILS: UserId = UserId("user-nils"); + +pub const USER_PRIYA: UserId = UserId("user-priya"); + +pub const USER_AYLA: UserId = UserId("user-ayla"); + +pub const USER_JUNE: UserId = UserId("user-june"); + +pub const USER_THEO: UserId = UserId("user-theo"); + +pub const USER_KENJI: UserId = UserId("user-kenji"); + +pub const POST_LENA_GLAZE: PostId = PostId("post-lena-glaze"); + +pub const POST_LENA_BOWLS: PostId = PostId("post-lena-bowls"); + +pub const POST_MARCO_BAJA: PostId = PostId("post-marco-baja"); + +pub const POST_NILS_AURORA: PostId = PostId("post-nils-aurora"); + +pub const POST_PRIYA_STARTER: PostId = PostId("post-priya-starter"); + +pub const POST_AYLA_FERRY: PostId = PostId("post-ayla-ferry"); + +pub const POST_JUNE_LOOKBOOK: PostId = PostId("post-june-lookbook"); + +pub const POST_THEO_COURT: PostId = PostId("post-theo-court"); + +pub const POST_KENJI_COPPER: PostId = PostId("post-kenji-copper"); + +pub const POST_MIRA_FERRY: PostId = PostId("post-mira-ferry"); + +pub const STORY_MIRA: StoryId = StoryId("ring-mira"); + +pub const STORY_MIRA_TRAM: StoryId = StoryId("ring-mira-tram"); + +pub const STORY_LENA: StoryId = StoryId("ring-lena"); + +pub const STORY_LENA_GLAZES: StoryId = StoryId("ring-lena-glazes"); + +pub const STORY_LENA_STUDIO: StoryId = StoryId("ring-lena-studio"); + +pub const STORY_PRIYA: StoryId = StoryId("ring-priya"); + +pub enum Section { + Feed, + Search, + Create, + Reels, + Profile, +} + +pub enum ProfileTab { + Posts, + Reels, + Tagged, + Saved, +} + +pub enum FeedStatus { + Idle, + Loading, + Failed, + Exhausted, +} + +pub enum SearchStatus { + Explore, + Searching, + Results, + NoResults, +} + +pub enum Location { + Feed, + Search, + Create, + Reels, + Post { + id: PostId, + }, + Profile { + user: UserId, + }, + Followers { + user: UserId, + }, + Following { + user: UserId, + }, + Story { + id: StoryId, + }, +} + +pub const INSTAGRAM_ROUTES: Routes = Routes::from([("Feed", "/"), ("Search", "/search"), ("Create", "/create"), ("Reels", "/reels"), ("Post", "/p/{id}"), ("Profile", "/profile/{user}"), ("Followers", "/profile/{user}/followers"), ("Following", "/profile/{user}/following"), ("Story", "/stories/{id}")]); + +pub enum Page { + None, + Feed, + Search, + Create, + Reels, + Post { + id: PostId, + }, + Profile { + user: UserId, + }, + Followers { + user: UserId, + }, + Following { + user: UserId, + }, + Story { + id: StoryId, + }, +} + +pub struct ImageRef { + src: Text, + alt: Text, +} + +pub struct User { + id: UserId, + username: Text, + display_name: Text, + avatar: ImageRef, +} + +pub enum Media { + Image { + image: ImageRef, + }, + Carousel { + images: Seq, + }, + Video { + src: Text, + poster: ImageRef, + }, +} + +pub struct Post { + id: PostId, + author: User, + caption: Text, + media: Media, + like_count: Nat, + comment_count: Nat, + viewer_liked: Bool, + viewer_saved: Bool, + posted_label: Text, +} + +pub struct StoryRing { + id: StoryId, + user: User, + unseen: Bool, + is_self: Bool, +} + +pub struct StorySegment { + id: StoryId, + current: Bool, + viewed: Bool, +} + +pub struct StoryDetail { + id: StoryId, + author: User, + image: ImageRef, + caption: Text, + posted_label: Text, + viewed: Bool, + previous: Option, + next: Option, + progress: Seq, +} + +pub struct Tile { + post: PostId, + image: ImageRef, +} + +pub struct Profile { + user: User, + bio: Text, + post_count: Nat, + follower_count: Nat, + following_count: Nat, + viewer_follows: Bool, + posts: Seq, + reels: Seq, + tagged: Seq, + saved: Seq, +} + +pub struct Connection { + user: User, + follows_viewer: Bool, + viewer_follows: Bool, +} + +pub struct Comment { + id: Text, + author: User, + body: Text, + posted_label: Text, +} + +pub struct AppData { + viewer: User, + posts: Map, + feed_posts: Seq, + feed_has_more: Bool, + reels: Seq, + stories: Seq, + story_details: Map, + profiles: Map, + followers: Map>, + following: Map>, + comments: Map>, + search_people: Seq, + explore_tiles: Seq, +} + +pub enum Authority { + Loading, + Failed { + reason: Text, + }, + Ready { + data: AppData, + }, +} + +pub enum Upload { + Empty, + Choosing, + Uploaded { + object: Text, + preview: Text, + name: Text, + }, + Publishing { + object: Text, + preview: Text, + name: Text, + }, +} + +pub enum Mutation { + SetLike { + post: PostId, + liked: Bool, + }, + SetSave { + post: PostId, + saved: Bool, + }, + LoadMore, + ReloadFeed, + SetFollow { + user: UserId, + following: Bool, + }, + AddComment { + post: PostId, + body: Text, + }, + SearchPeople { + query: Text, + }, + ChooseImage, + PublishImage { + object: Text, + caption: Text, + alt: Text, + }, + MarkStory { + story: StoryId, + }, +} + +pub enum Settlement { + Accepted, + Refused { + reason: Text, + }, + ImageReady { + object: Text, + preview: Text, + name: Text, + }, +} + +pub const MIRA: User = User { + id: USER_MIRA, + username: "mira.santos", + display_name: "Mira Santos", + avatar: ImageRef { + src: "avatar-mira", + alt: "Mira Santos", + }, +}; + +pub const LENA: User = User { + id: USER_LENA, + username: "lena.holt", + display_name: "Lena Holt", + avatar: ImageRef { + src: "avatar-lena", + alt: "Lena Holt", + }, +}; + +pub const MARCO: User = User { + id: USER_MARCO, + username: "marco.reyes", + display_name: "Marco Reyes", + avatar: ImageRef { + src: "avatar-marco", + alt: "Marco Reyes", + }, +}; + +pub const NILS: User = User { + id: USER_NILS, + username: "nils.bergman", + display_name: "Nils Bergman", + avatar: ImageRef { + src: "avatar-nils", + alt: "Nils Bergman", + }, +}; + +pub const PRIYA: User = User { + id: USER_PRIYA, + username: "priya.raman", + display_name: "Priya Raman", + avatar: ImageRef { + src: "avatar-priya", + alt: "Priya Raman", + }, +}; + +pub const AYLA: User = User { + id: USER_AYLA, + username: "ayla.demir", + display_name: "Ayla Demir", + avatar: ImageRef { + src: "avatar-ayla", + alt: "Ayla Demir", + }, +}; + +pub const JUNE: User = User { + id: USER_JUNE, + username: "june.park", + display_name: "June Park", + avatar: ImageRef { + src: "avatar-june", + alt: "June Park", + }, +}; + +pub const THEO: User = User { + id: USER_THEO, + username: "theo.okafor", + display_name: "Theo Okafor", + avatar: ImageRef { + src: "avatar-theo", + alt: "Theo Okafor", + }, +}; + +pub const KENJI: User = User { + id: USER_KENJI, + username: "kenji.rides", + display_name: "Kenji Tanaka", + avatar: ImageRef { + src: "avatar-kenji", + alt: "Kenji Tanaka", + }, +}; + +pub const LENA_GLAZE: Post = Post { + id: POST_LENA_GLAZE, + author: LENA, + caption: "New copper-red test tiles out of the kiln. Cone 10, heavy reduction — the speckle finally behaved.", + media: Media::Image { + image: ImageRef { + src: "media-lena-glaze", + alt: "Decorative ceramic tile panels leaning in an artisan studio", + }, + }, + like_count: 7, + comment_count: 4, + viewer_liked: false, + viewer_saved: false, + posted_label: "2h", +}; + +pub const LENA_BOWLS: Post = Post { + id: POST_LENA_BOWLS, + author: LENA, + caption: "Copper glaze in close-up, before the wax cooled.", + media: Media::Image { + image: ImageRef { + src: "thumb-lena-2", + alt: "Celadon ceramic vessel with a sculpted wave rim", + }, + }, + like_count: 5, + comment_count: 1, + viewer_liked: false, + viewer_saved: false, + posted_label: "1d", +}; + +pub const MARCO_BAJA: Post = Post { + id: POST_MARCO_BAJA, + author: MARCO, + caption: "Three days down the Baja coast. Swell arrived on the last morning, as it always does.", + media: Media::Carousel { + images: [ImageRef { + src: "media-marco-baja-1", + alt: "Ocean wave exploding into white spray against deep blue water", + }, ImageRef { + src: "media-marco-baja-2", + alt: "Glowing campfire beside a tent under a desert night sky", + }, ImageRef { + src: "media-marco-baja-3", + alt: "Palm-lined ocean glowing orange and violet at sunset", + }], + }, + like_count: 12, + comment_count: 2, + viewer_liked: true, + viewer_saved: true, + posted_label: "4h", +}; + +pub const NILS_AURORA: Post = Post { + id: POST_NILS_AURORA, + author: NILS, + caption: "Aurora over the fjord last night — the whole sky was breathing.", + media: Media::Video { + src: "video-nils-aurora", + poster: ImageRef { + src: "media-nils-aurora-poster", + alt: "Soft bands of blue, violet, and green light across a dark sky", + }, + }, + like_count: 21, + comment_count: 3, + viewer_liked: false, + viewer_saved: true, + posted_label: "6h", +}; + +pub const PRIYA_STARTER: Post = Post { + id: POST_PRIYA_STARTER, + author: PRIYA, + caption: "Day 400 of the starter. She's earned a name: Clint Yeastwood.", + media: Media::Image { + image: ImageRef { + src: "media-priya-starter", + alt: "Black-and-white cross-section of a rustic bread loaf", + }, + }, + like_count: 18, + comment_count: 2, + viewer_liked: false, + viewer_saved: false, + posted_label: "8h", +}; + +pub const AYLA_FERRY: Post = Post { + id: POST_AYLA_FERRY, + author: AYLA, + caption: "Morning ferry across the Bosphorus. Tea, gulls, and nowhere to be until noon.", + media: Media::Image { + image: ImageRef { + src: "media-ayla-ferry", + alt: "Small boat crossing blue water toward the Jaffa skyline", + }, + }, + like_count: 9, + comment_count: 0, + viewer_liked: false, + viewer_saved: false, + posted_label: "10h", +}; + +pub const JUNE_LOOKBOOK: Post = Post { + id: POST_JUNE_LOOKBOOK, + author: JUNE, + caption: "Studio lookbook, page one. Linen in every weight we could mill.", + media: Media::Image { + image: ImageRef { + src: "media-june-lookbook", + alt: "Navy mosaic printed across natural linen fabric", + }, + }, + like_count: 14, + comment_count: 1, + viewer_liked: false, + viewer_saved: false, + posted_label: "12h", +}; + +pub const THEO_COURT: Post = Post { + id: POST_THEO_COURT, + author: THEO, + caption: "Finished the mural at the 9th street court. Paint holds up better than my jumper.", + media: Media::Video { + src: "video-theo-court", + poster: ImageRef { + src: "media-theo-court", + alt: "Colorful patterned staircase framed by saturated yellow walls", + }, + }, + like_count: 32, + comment_count: 5, + viewer_liked: false, + viewer_saved: false, + posted_label: "14h", +}; + +pub const KENJI_COPPER: Post = Post { + id: POST_KENJI_COPPER, + author: KENJI, + caption: "120km of switchbacks and one very smug goat. Copper Pass, you were worth it.", + media: Media::Image { + image: ImageRef { + src: "media-kenji-copper", + alt: "Cyclists riding through a crowded market square", + }, + }, + like_count: 11, + comment_count: 4, + viewer_liked: false, + viewer_saved: false, + posted_label: "1d", +}; + +pub const MIRA_FERRY: Post = Post { + id: POST_MIRA_FERRY, + author: MIRA, + caption: "The last ferry left a gold line all the way home.", + media: Media::Video { + src: "video-mira-ferry", + poster: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }, + like_count: 24, + comment_count: 6, + viewer_liked: true, + viewer_saved: false, + posted_label: "2d", +}; + +pub const ALL_POSTS: Map = Map::from([(POST_LENA_GLAZE, LENA_GLAZE), (POST_LENA_BOWLS, LENA_BOWLS), (POST_MARCO_BAJA, MARCO_BAJA), (POST_NILS_AURORA, NILS_AURORA), (POST_PRIYA_STARTER, PRIYA_STARTER), (POST_AYLA_FERRY, AYLA_FERRY), (POST_JUNE_LOOKBOOK, JUNE_LOOKBOOK), (POST_THEO_COURT, THEO_COURT), (POST_KENJI_COPPER, KENJI_COPPER), (POST_MIRA_FERRY, MIRA_FERRY)]); + +pub const FEED_PAGE_ONE: Seq = [LENA_GLAZE, MARCO_BAJA, NILS_AURORA]; + +pub const FEED_ALL: Seq = [LENA_GLAZE, MARCO_BAJA, NILS_AURORA, PRIYA_STARTER, AYLA_FERRY, JUNE_LOOKBOOK]; + +pub const ALL_REELS: Seq = [NILS_AURORA, THEO_COURT, MIRA_FERRY]; + +pub const ALL_STORIES: Seq = [StoryRing { + id: STORY_MIRA, + user: MIRA, + unseen: false, + is_self: true, +}, StoryRing { + id: STORY_LENA, + user: LENA, + unseen: true, + is_self: false, +}, StoryRing { + id: StoryId("ring-marco"), + user: MARCO, + unseen: true, + is_self: false, +}, StoryRing { + id: STORY_PRIYA, + user: PRIYA, + unseen: false, + is_self: false, +}, StoryRing { + id: StoryId("ring-june"), + user: JUNE, + unseen: true, + is_self: false, +}, StoryRing { + id: StoryId("ring-kenji"), + user: KENJI, + unseen: false, + is_self: false, +}]; + +pub const LENA_STORY: StoryDetail = StoryDetail { + id: STORY_LENA, + author: LENA, + image: ImageRef { + src: "thumb-lena-7", + alt: "Lena throwing a tall clay cylinder", + }, + caption: "One pull, no edits", + posted_label: "35m", + viewed: false, + previous: None, + next: Some(STORY_LENA_GLAZES), + progress: [StorySegment { + id: STORY_LENA, + current: true, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: false, + viewed: false, + }], +}; + +pub const LENA_GLAZES_STORY: StoryDetail = StoryDetail { + id: STORY_LENA_GLAZES, + author: LENA, + image: ImageRef { + src: "thumb-lena-8", + alt: "Rows of glaze buckets labelled by firing cone", + }, + caption: "The unglamorous half of studio day", + posted_label: "22m", + viewed: false, + previous: Some(STORY_LENA), + next: Some(STORY_LENA_STUDIO), + progress: [StorySegment { + id: STORY_LENA, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: true, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: false, + viewed: false, + }], +}; + +pub const LENA_STUDIO_STORY: StoryDetail = StoryDetail { + id: STORY_LENA_STUDIO, + author: LENA, + image: ImageRef { + src: "thumb-lena-9", + alt: "Morning light crossing a clean ceramics workbench", + }, + caption: "Reset for tomorrow", + posted_label: "6m", + viewed: false, + previous: Some(STORY_LENA_GLAZES), + next: None, + progress: [StorySegment { + id: STORY_LENA, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_GLAZES, + current: false, + viewed: false, + }, StorySegment { + id: STORY_LENA_STUDIO, + current: true, + viewed: false, + }], +}; + +pub const MIRA_TRAM_STORY: StoryDetail = StoryDetail { + id: STORY_MIRA_TRAM, + author: MIRA, + image: ImageRef { + src: "thumb-mira-2", + alt: "Tram rails catching the first light in Lisbon", + }, + caption: "Then the city wakes", + posted_label: "8m", + viewed: true, + previous: Some(STORY_MIRA), + next: Some(StoryId("ring-mira-market")), + progress: [StorySegment { + id: STORY_MIRA, + current: false, + viewed: true, + }, StorySegment { + id: STORY_MIRA_TRAM, + current: true, + viewed: true, + }, StorySegment { + id: StoryId("ring-mira-market"), + current: false, + viewed: true, + }], +}; + +pub const PRIYA_STORY: StoryDetail = StoryDetail { + id: STORY_PRIYA, + author: PRIYA, + image: ImageRef { + src: "media-priya-starter", + alt: "Freshly sliced sourdough loaf", + }, + caption: "Still warm", + posted_label: "45m", + viewed: true, + previous: None, + next: None, + progress: [StorySegment { + id: STORY_PRIYA, + current: true, + viewed: true, + }], +}; + +pub const STORY_DETAILS: Map = Map::from([(STORY_LENA, LENA_STORY), (STORY_LENA_GLAZES, LENA_GLAZES_STORY), (STORY_LENA_STUDIO, LENA_STUDIO_STORY), (STORY_MIRA_TRAM, MIRA_TRAM_STORY), (STORY_PRIYA, PRIYA_STORY)]); + +pub const LENA_PROFILE: Profile = Profile { + user: LENA, + bio: "Ceramics and slow mornings. Small-batch studio work from Portland.", + post_count: 10, + follower_count: 8, + following_count: 5, + viewer_follows: true, + posts: [Tile { + post: POST_LENA_GLAZE, + image: ImageRef { + src: "thumb-lena-1", + alt: "Decorative ceramic tile panels in warm glaze colors", + }, + }, Tile { + post: POST_LENA_BOWLS, + image: ImageRef { + src: "thumb-lena-2", + alt: "Celadon ceramic vessel with a sculpted wave rim", + }, + }, Tile { + post: PostId("post-lena-greenware"), + image: ImageRef { + src: "thumb-lena-3", + alt: "Editorial catalog of handmade stoneware ceramics", + }, + }, Tile { + post: PostId("post-lena-kiln"), + image: ImageRef { + src: "thumb-lena-4", + alt: "White ceramic vessel with a flowing sculptural form", + }, + }, Tile { + post: PostId("post-lena-clay"), + image: ImageRef { + src: "thumb-lena-5", + alt: "Miniature artist studio with shelves and a workbench", + }, + }, Tile { + post: PostId("post-lena-plates"), + image: ImageRef { + src: "thumb-lena-6", + alt: "Blush ceramic sculpture with a looping organic form", + }, + }], + reels: [], + tagged: [Tile { + post: POST_PRIYA_STARTER, + image: ImageRef { + src: "media-priya-starter", + alt: "Fresh sourdough loaf", + }, + }], + saved: [], +}; + +pub const MIRA_PROFILE: Profile = Profile { + user: MIRA, + bio: "Food and travel photographer in Lisbon. Usually awake before the trams.", + post_count: 6, + follower_count: 4, + following_count: 6, + viewer_follows: false, + posts: [Tile { + post: PostId("post-mira-pasteis"), + image: ImageRef { + src: "thumb-mira-1", + alt: "Translucent citrus and radish slices on white", + }, + }, Tile { + post: PostId("post-mira-tram"), + image: ImageRef { + src: "thumb-mira-2", + alt: "Colorful geometric Mediterranean hillside village", + }, + }, Tile { + post: PostId("post-mira-citrus"), + image: ImageRef { + src: "thumb-mira-3", + alt: "Grid of cross-sectioned fruits and vegetables", + }, + }, Tile { + post: PostId("post-mira-tiles"), + image: ImageRef { + src: "thumb-mira-4", + alt: "Colorful patterned staircase framed by yellow walls", + }, + }, Tile { + post: PostId("post-mira-sardines"), + image: ImageRef { + src: "thumb-mira-5", + alt: "Bold illustrated food flavors in a four-panel grid", + }, + }, Tile { + post: POST_MIRA_FERRY, + image: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }], + reels: [Tile { + post: POST_MIRA_FERRY, + image: ImageRef { + src: "thumb-mira-6", + alt: "Ocean spray breaking over rocks in golden light", + }, + }], + tagged: [Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, + }], + saved: [Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, + }, Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], +}; + +pub const NILS_PROFILE: Profile = Profile { + user: NILS, + bio: "Night skies and northern water, filmed around Tromsø.", + post_count: 1, + follower_count: 2, + following_count: 3, + viewer_follows: false, + posts: [Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], + reels: [Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora over a fjord", + }, + }], + tagged: [], + saved: [], +}; + +pub const PROFILES: Map = Map::from([(USER_LENA, LENA_PROFILE), (USER_MIRA, MIRA_PROFILE), (USER_NILS, NILS_PROFILE)]); + +pub const LENA_CONNECTIONS: Seq = [Connection { + user: MIRA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: false, + viewer_follows: false, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const MIRA_FOLLOWERS: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: true, + viewer_follows: false, +}]; + +pub const MIRA_FOLLOWING: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: MARCO, + follows_viewer: false, + viewer_follows: true, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const FOLLOWER_LISTS: Map> = Map::from([(USER_LENA, LENA_CONNECTIONS), (USER_MIRA, MIRA_FOLLOWERS), (USER_NILS, [])]); + +pub const FOLLOWING_LISTS: Map> = Map::from([(USER_LENA, LENA_CONNECTIONS), (USER_MIRA, MIRA_FOLLOWING), (USER_NILS, [])]); + +pub const LENA_COMMENTS: Seq = [Comment { + id: "comment-1", + author: KENJI, + body: "That copper red is unreal. What cone are you firing to?", + posted_label: "1h", +}, Comment { + id: "comment-2", + author: PRIYA, + body: "The speckle is perfect.", + posted_label: "48m", +}, Comment { + id: "comment-3", + author: MARCO, + body: "Saving this palette.", + posted_label: "22m", +}]; + +pub const COMMENT_LISTS: Map> = Map::from([(POST_LENA_GLAZE, LENA_COMMENTS), (POST_AYLA_FERRY, [])]); + +pub const SEARCH_CONNECTIONS: Seq = [Connection { + user: LENA, + follows_viewer: true, + viewer_follows: true, +}, Connection { + user: MARCO, + follows_viewer: false, + viewer_follows: true, +}, Connection { + user: NILS, + follows_viewer: false, + viewer_follows: false, +}, Connection { + user: PRIYA, + follows_viewer: true, + viewer_follows: true, +}]; + +pub const EXPLORE_TILES: Seq = [Tile { + post: POST_LENA_GLAZE, + image: ImageRef { + src: "media-lena-glaze", + alt: "Grid of copper-red glaze test tiles on a maple bench", + }, +}, Tile { + post: POST_MARCO_BAJA, + image: ImageRef { + src: "media-marco-baja-1", + alt: "Long left-hand wave peeling along a desert point", + }, +}, Tile { + post: POST_NILS_AURORA, + image: ImageRef { + src: "media-nils-aurora-poster", + alt: "Green aurora curtains over a dark fjord", + }, +}, Tile { + post: POST_PRIYA_STARTER, + image: ImageRef { + src: "media-priya-starter", + alt: "Open crumb of a sourdough loaf", + }, +}, Tile { + post: POST_AYLA_FERRY, + image: ImageRef { + src: "media-ayla-ferry", + alt: "Ferry deck railing over blue water", + }, +}, Tile { + post: POST_JUNE_LOOKBOOK, + image: ImageRef { + src: "media-june-lookbook", + alt: "Folded linen garments stacked by shade", + }, +}]; + +pub const DEMO_STANDARD: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: true, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_APPENDED: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_ALL, + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EXHAUSTED: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EMPTY: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: [], + feed_has_more: false, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: SEARCH_CONNECTIONS, + explore_tiles: EXPLORE_TILES, +}; + +pub const DEMO_EMPTY_EXPLORE: AppData = AppData { + viewer: MIRA, + posts: ALL_POSTS, + feed_posts: FEED_PAGE_ONE, + feed_has_more: true, + reels: ALL_REELS, + stories: ALL_STORIES, + story_details: STORY_DETAILS, + profiles: PROFILES, + followers: FOLLOWER_LISTS, + following: FOLLOWING_LISTS, + comments: COMMENT_LISTS, + search_people: [], + explore_tiles: [], +}; + +pub machine Instagram { + port router = Router { + routes: INSTAGRAM_ROUTES, + }; + + port authority = Observation {}; + + port mutations = RequestPort {}; + + events { + SelectTab(section: Section), + OpenPost(id: PostId), + OpenProfile(user: UserId), + OpenFollowers(user: UserId), + OpenFollowing(user: UserId), + OpenStory(id: StoryId), + MarkStorySeen(id: StoryId), + GoBack, + ToggleLike(post: PostId, liked: Bool), + ToggleSave(post: PostId, saved: Bool), + FeedNearEnd, + RetryFeed, + OpenComments(post: PostId), + DismissComments, + CommentChanged(value: Text), + SubmitComment, + ToggleFollow(user: UserId, following: Bool), + SelectProfileTab(tab: ProfileTab), + SearchChanged(value: Text), + SubmitSearch, + ChooseImage, + CaptionChanged(value: Text), + AltChanged(value: Text), + PublishImage, + } + + outcomes { + commit Accepted, + abort Blocked(reason: Text), + abort Duplicate, + abort Stale, + abort Invalid(reason: Text), + } + + state { + location: Option = None, + authority_state: Authority = Authority::Loading, + profile_tab: ProfileTab = ProfileTab::Posts, + feed_status: FeedStatus = FeedStatus::Idle, + search_query: Text = "", + search_status: SearchStatus = SearchStatus::Explore, + comments_post: Option = None, + comment_draft: Text = "", + pending_comment: Option<(RequestId, Text)> = None, + upload: Upload = Upload::Empty, + caption: Text = "", + alt: Text = "", + like_overlay: Map = Map::empty(), + save_overlay: Map = Map::empty(), + follow_overlay: Map = Map::empty(), + like_pending: Set = Set::empty(), + save_pending: Set = Set::empty(), + follow_pending: Set = Set::empty(), + story_pending: Set = Set::empty(), + pending: Map = Map::empty(), + settled: Set = Set::empty(), + next_request: Nat = 0, + } + + fn page_of(value: Option) -> Page { + match value { + None => Page::None, + Some(Location::Feed) => Page::Feed, + Some(Location::Search) => Page::Search, + Some(Location::Create) => Page::Create, + Some(Location::Reels) => Page::Reels, + Some(Location::Post { + id, + }) => Page::Post { + id, + }, + Some(Location::Profile { + user, + }) => Page::Profile { + user, + }, + Some(Location::Followers { + user, + }) => Page::Followers { + user, + }, + Some(Location::Following { + user, + }) => Page::Following { + user, + }, + Some(Location::Story { + id, + }) => Page::Story { + id, + }, + } + } + + fn section_location(section: Section) -> Location { + match section { + Section::Feed => Location::Feed, + Section::Search => Location::Search, + Section::Create => Location::Create, + Section::Reels => Location::Reels, + Section::Profile => Location::Profile { + user: USER_MIRA, + }, + } + } + + fn can_load(value: Authority) -> Bool { + match value { + Authority::Loading => false, + Authority::Failed { + .., + } => false, + Authority::Ready { + data, + } => data.feed_has_more, + } + } + + computed page: Page = page_of(location); + + invariant { + next_request >= pending.len(), + settled.len() <= next_request, + } + + part notice = Notice(); + + part notice_controls = NoticeControls(notice.reads, notice.updates); + + observe { + page, + location, + authority: authority_state, + profile_tab, + feed_status, + search_query, + search_status, + comments_post, + comment_draft, + pending_comment, + upload, + caption, + alt, + notice: notice.reads.current, + like_overlay, + save_overlay, + follow_overlay, + like_pending, + save_pending, + follow_pending, + story_pending, + } + + on router.Changed(next) { + location = Some(next); + Accepted + } + + on authority.Observed(next) { + authority_state = next; + Accepted + } + + on SelectTab(section) { + let target = section_location(section); + match location { + Some(current) => { + if current == target { + if section == Section::Feed { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::ReloadFeed); + emit mutations.Request(request, Mutation::ReloadFeed); + return Accepted; + } + return Duplicate; + } + }, + None => {}, + } + emit router.Replace(target); + Accepted + } + + on OpenPost(id) { + emit router.Push(Location::Post { + id, + }); + Accepted + } + + on OpenProfile(user) { + emit router.Push(Location::Profile { + user, + }); + Accepted + } + + on OpenFollowers(user) { + emit router.Push(Location::Followers { + user, + }); + Accepted + } + + on OpenFollowing(user) { + emit router.Push(Location::Following { + user, + }); + Accepted + } + + on OpenStory(id) { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + story_pending = story_pending.add(id); + pending = pending.put(request, Mutation::MarkStory { + story: id, + }); + emit mutations.Request(request, Mutation::MarkStory { + story: id, + }); + emit router.Push(Location::Story { + id, + }); + Accepted + } + + on MarkStorySeen(id) { + if story_pending.contains(id) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + story_pending = story_pending.add(id); + pending = pending.put(request, Mutation::MarkStory { + story: id, + }); + emit mutations.Request(request, Mutation::MarkStory { + story: id, + }); + Accepted + } + + on GoBack { + emit router.Replace(Location::Feed); + Accepted + } + + on ToggleLike(post, liked) { + if like_pending.contains(post) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + like_overlay = like_overlay.put(post, liked); + like_pending = like_pending.add(post); + pending = pending.put(request, Mutation::SetLike { + post, + liked, + }); + emit mutations.Request(request, Mutation::SetLike { + post, + liked, + }); + Accepted + } + + on ToggleSave(post, saved) { + if save_pending.contains(post) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + save_overlay = save_overlay.put(post, saved); + save_pending = save_pending.add(post); + pending = pending.put(request, Mutation::SetSave { + post, + saved, + }); + emit mutations.Request(request, Mutation::SetSave { + post, + saved, + }); + Accepted + } + + on FeedNearEnd { + if feed_status == FeedStatus::Loading { + return Duplicate; + } + if !can_load(authority_state) { + return Blocked("feed is exhausted"); + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::LoadMore); + emit mutations.Request(request, Mutation::LoadMore); + Accepted + } + + on RetryFeed { + if feed_status == FeedStatus::Loading { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + feed_status = FeedStatus::Loading; + pending = pending.put(request, Mutation::ReloadFeed); + emit mutations.Request(request, Mutation::ReloadFeed); + Accepted + } + + on OpenComments(post) { + if comments_post == Some(post) { + return Duplicate; + } + comments_post = Some(post); + comment_draft = ""; + pending_comment = None; + Accepted + } + + on DismissComments { + if comments_post == None { + return Duplicate; + } + comments_post = None; + comment_draft = ""; + pending_comment = None; + Accepted + } + + on CommentChanged(value) { + if comment_draft == value { + return Duplicate; + } + comment_draft = value; + Accepted + } + + on SubmitComment { + if comment_draft == "" { + return Invalid("comment body is empty"); + } + if pending_comment is Some(_) { + return Duplicate; + } + let post = match comments_post { + None => return Blocked("comments are closed"), + Some(post) => post, + }; + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + let body = comment_draft; + next_request = serial; + pending_comment = Some((request, body)); + comment_draft = ""; + pending = pending.put(request, Mutation::AddComment { + post, + body, + }); + emit mutations.Request(request, Mutation::AddComment { + post, + body, + }); + Accepted + } + + on ToggleFollow(user, following) { + if user == USER_MIRA { + return Invalid("the viewer cannot follow itself"); + } + if follow_pending.contains(user) { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + follow_overlay = follow_overlay.put(user, following); + follow_pending = follow_pending.add(user); + pending = pending.put(request, Mutation::SetFollow { + user, + following, + }); + emit mutations.Request(request, Mutation::SetFollow { + user, + following, + }); + Accepted + } + + on SelectProfileTab(tab) { + if profile_tab == tab { + return Duplicate; + } + profile_tab = tab; + Accepted + } + + on SearchChanged(value) { + if search_query == value { + return Duplicate; + } + search_query = value; + if value == "" { + search_status = SearchStatus::Explore; + } + Accepted + } + + on SubmitSearch { + if search_query == "" { + return Invalid("search query is empty"); + } + if search_status == SearchStatus::Searching { + return Duplicate; + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + search_status = SearchStatus::Searching; + pending = pending.put(request, Mutation::SearchPeople { + query: search_query, + }); + emit mutations.Request(request, Mutation::SearchPeople { + query: search_query, + }); + Accepted + } + + on ChooseImage { + match upload { + Upload::Choosing => return Duplicate, + Upload::Publishing { + .., + } => return Blocked("publish is in flight"), + _ => {}, + } + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + upload = Upload::Choosing; + pending = pending.put(request, Mutation::ChooseImage); + emit mutations.Request(request, Mutation::ChooseImage); + Accepted + } + + on CaptionChanged(value) { + if caption == value { + return Duplicate; + } + caption = value; + Accepted + } + + on AltChanged(value) { + if alt == value { + return Duplicate; + } + alt = value; + Accepted + } + + on PublishImage { + match upload { + Upload::Uploaded { + object, + preview, + name, + } => { + let serial: PositiveInt = next_request + 1; + let request = RequestId(serial); + next_request = serial; + upload = Upload::Publishing { + object, + preview, + name, + }; + pending = pending.put(request, Mutation::PublishImage { + object, + caption, + alt, + }); + emit mutations.Request(request, Mutation::PublishImage { + object, + caption, + alt, + }); + return Accepted; + }, + Upload::Publishing { + .., + } => return Duplicate, + _ => return Blocked("no uploaded image"), + } + } + + on mutations.Settled(request, result) { + if settled.contains(request) { + return Stale; + } + let mutation = match pending.get(request) { + None => return Stale, + Some(mutation) => mutation, + }; + pending = pending.remove(request); + settled = settled.add(request); + match mutation { + Mutation::SetLike { + post, + liked: _, + } => { + like_pending = like_pending.remove(post); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + like_overlay = like_overlay.remove(post); + notice.updates.show("Couldn't update this like. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SetSave { + post, + saved: _, + } => { + save_pending = save_pending.remove(post); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + save_overlay = save_overlay.remove(post); + notice.updates.show("Couldn't update this saved post."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::LoadMore => { + match result { + Settlement::Accepted => { + feed_status = FeedStatus::Idle; + }, + Settlement::Refused { + .., + } => { + feed_status = FeedStatus::Failed; + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::ReloadFeed => { + match result { + Settlement::Accepted => { + feed_status = FeedStatus::Idle; + }, + Settlement::Refused { + .., + } => { + feed_status = FeedStatus::Failed; + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SetFollow { + user, + following: _, + } => { + follow_pending = follow_pending.remove(user); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + follow_overlay = follow_overlay.remove(user); + notice.updates.show("Couldn't update this relationship."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::AddComment { + post: _, + body, + } => { + pending_comment = None; + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => { + comment_draft = body; + notice.updates.show("Couldn't post your comment. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::SearchPeople { + query, + } => { + match result { + Settlement::Accepted => { + if query == "nils" { + search_status = SearchStatus::Results; + } else { + search_status = SearchStatus::NoResults; + } + }, + Settlement::Refused { + .., + } => { + search_status = SearchStatus::NoResults; + notice.updates.show("Search isn't available."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::ChooseImage => { + match result { + Settlement::ImageReady { + object, + preview, + name, + } => { + upload = Upload::Uploaded { + object, + preview, + name, + }; + }, + Settlement::Refused { + .., + } => { + upload = Upload::Empty; + notice.updates.show("Choose a JPEG, PNG, or WebP image."); + }, + Settlement::Accepted => return Invalid("image selection omitted its object"), + } + }, + Mutation::PublishImage { + .., + } => { + match result { + Settlement::Accepted => { + upload = Upload::Empty; + caption = ""; + alt = ""; + emit router.Replace(Location::Feed); + }, + Settlement::Refused { + .., + } => { + match upload { + Upload::Publishing { + object, + preview, + name, + } => { + upload = Upload::Uploaded { + object, + preview, + name, + }; + }, + _ => {}, + } + notice.updates.show("Couldn't publish this post. Try again."); + }, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + Mutation::MarkStory { + story, + } => { + story_pending = story_pending.remove(story); + match result { + Settlement::Accepted => {}, + Settlement::Refused { + .., + } => {}, + Settlement::ImageReady { + .., + } => return Invalid("unexpected image settlement"), + } + }, + } + Accepted + } +} diff --git a/examples/instagram/client/parts.uhura b/examples/instagram/client/parts.uhura new file mode 100644 index 0000000..e7444e4 --- /dev/null +++ b/examples/instagram/client/parts.uhura @@ -0,0 +1,43 @@ +pub part Notice { + state { + message: Option = None, + } + + pub computed current: Option = message; + + observe {} + + pub update show(next: Text) { + message = Some(next); + } + + pub update dismiss() { + message = None; + } +} + +pub part NoticeControls( + notice: Notice::Reads, + notice_updates: Notice::Updates, +) { + requires outcomes { + commit Accepted, + abort Duplicate, + } + + events { + DismissNotice, + } + + state {} + + observe {} + + on DismissNotice { + if notice.current == None { + return Duplicate; + } + notice_updates.dismiss(); + Accepted + } +} diff --git a/examples/instagram/client/ports/comments.port.toml b/examples/instagram/client/ports/comments.port.toml deleted file mode 100644 index 53a624b..0000000 --- a/examples/instagram/client/ports/comments.port.toml +++ /dev/null @@ -1,52 +0,0 @@ -# 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.comment-body-invalid] -[refusals.not-found] - -[commands.add-comment] -payload = { post = "id", body = "text" } -refusals = ["not-authorized", "comment-body-invalid", "not-found"] diff --git a/examples/instagram/client/ports/create.port.toml b/examples/instagram/client/ports/create.port.toml deleted file mode 100644 index 5473154..0000000 --- a/examples/instagram/client/ports/create.port.toml +++ /dev/null @@ -1,32 +0,0 @@ -# The create-post seam. Picking and uploading a file are provider/platform -# concerns; Core sees only a storage-object id and serializable preview/name -# metadata, never the browser File or its bytes. - -[port] -name = "create" -version = "0.1.0" - -[types.create-draft] -kind = "union" - -[types.create-draft.variants.empty] - -[types.create-draft.variants.uploaded] -object = "id" -preview = "asset" -name = "text" - -[projections.draft] -type = "create-draft" - -[refusals.not-authorized] -[refusals.image-not-ready] -[refusals.unsupported-media-type] - -[commands.choose-image] -payload = {} -refusals = ["unsupported-media-type"] - -[commands.publish-image] -payload = { image = "id", caption = "text", alt = "text" } -refusals = ["not-authorized", "image-not-ready", "unsupported-media-type"] diff --git a/examples/instagram/client/ports/feed.port.toml b/examples/instagram/client/ports/feed.port.toml deleted file mode 100644 index 6cad524..0000000 --- a/examples/instagram/client/ports/feed.port.toml +++ /dev/null @@ -1,157 +0,0 @@ -# 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.story-detail] -kind = "record" - -[types.story-detail.fields] -id = "id" -author = "user-ref" -image = "image-ref" -caption = "text" -posted-label = "text" -viewer-has-viewed = "bool" -previous = "option" -next = "option" -progress = "list" - -[types.story-progress] -kind = "record" - -[types.story-progress.fields] -id = "id" -is-current = "bool" -is-viewed = "bool" - -[types.media] -kind = "union" - -[types.media.variants.image] -image = "image-ref" - -[types.media.variants.carousel] -slides = "list" - -[types.media.variants.video] -src = "asset" -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" -viewer-has-saved = "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" - -[types.reels-page] -kind = "record" - -[types.reels-page.fields] -posts = "list" - -# Delivered before Init; bare reads are legal (§9.2). -[projections.viewer] -type = "user-ref" -boot = true - -[projections.feed-page] -type = "feed-page" - -# Keyed detail carriers make profile-grid navigation and story viewing use -# the same authority-owned records as the feed. -[projections.post-by-id] -type = "post-summary" -key = "id" - -[projections.story-by-id] -type = "story-detail" -key = "id" - -[projections.reels] -type = "reels-page" - -[refusals.not-authorized] -[refusals.not-found] - -[commands.like-post] -payload = { post = "id" } -refusals = ["not-authorized", "not-found"] - -[commands.unlike-post] -payload = { post = "id" } -refusals = ["not-authorized"] - -[commands.save-post] -payload = { post = "id" } -refusals = ["not-authorized", "not-found"] - -[commands.unsave-post] -payload = { post = "id" } -refusals = ["not-authorized"] - -[commands.load-next-page] -payload = { cursor = "option" } - -[commands.reload] -payload = {} - -[commands.mark-story-seen] -payload = { story = "id" } -refusals = ["not-authorized", "not-found"] diff --git a/examples/instagram/client/ports/profile.port.toml b/examples/instagram/client/ports/profile.port.toml deleted file mode 100644 index ad0e3b7..0000000 --- a/examples/instagram/client/ports/profile.port.toml +++ /dev/null @@ -1,101 +0,0 @@ -# Profiles and their relationship lists. Counts are integers derived from -# authority rows; the client owns formatting, never pre-seeded labels. - -[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] -# The real post id, so a grid tile can navigate to feed.post(id). -id = "id" -src = "asset" -alt = "text" - -[types.connection] -kind = "record" - -[types.connection.fields] -user = "user-ref" -viewer-follows = "bool" - -[types.connection-list] -kind = "record" - -[types.connection-list.fields] -people = "list" - -[types.profile-view] -kind = "record" - -[types.profile-view.fields] -user = "user-ref" -bio = "text" -is-self = "bool" -viewer-follows = "bool" -post-count = "int" -follower-count = "int" -following-count = "int" -posts = "list" -reels = "list" -saved = "list" -tagged = "list" - -[types.search-view] -kind = "record" - -[types.search-view.fields] -people = "list" -posts = "list" - -# Keyed by user id: `profile(user)` (§9.2). -[projections.profile] -type = "profile-view" -key = "id" - -[projections.followers] -type = "connection-list" -key = "id" - -[projections.following] -type = "connection-list" -key = "id" - -# Initially all other people; search-people replaces this slice with the -# provider-filtered result. Search remains useful in Play and deterministic -# in read-only Editor previews without leaking a database query primitive into Core. -[projections.search-results] -type = "search-view" - -[refusals.not-authorized] -[refusals.not-found] -[refusals.cannot-follow-self] - -[commands.follow-user] -payload = { user = "id" } -refusals = ["not-authorized", "not-found", "cannot-follow-self"] - -[commands.unfollow-user] -payload = { user = "id" } -refusals = ["not-authorized"] - -[commands.search-people] -payload = { query = "text" } diff --git a/examples/instagram/client/providers/spock.test.ts b/examples/instagram/client/providers/spock.test.ts index c5a4f6b..5899689 100644 --- a/examples/instagram/client/providers/spock.test.ts +++ b/examples/instagram/client/providers/spock.test.ts @@ -1,1123 +1,537 @@ import assert from "node:assert/strict"; -import { test, vi } from "vitest"; +import { test } from "vitest"; -import { - createDriver, - type ProviderHost, - type SpockDriver, -} from "./spock.js"; +import { createUhuraAdapters } from "./spock.js"; -interface Decoded { - [key: string]: unknown; - kind?: string; - port?: string; - projection?: string; - key?: unknown; - value?: Decoded; - outcome?: unknown; - author: Decoded; - user: Decoded; - updates: Decoded[]; - posts: Decoded[]; - stories: Decoded[]; - people: Decoded[]; - saved: Decoded[]; - reels: Decoded[]; - progress: Decoded[]; +interface WireValue { + readonly $: string; + readonly [field: string]: unknown; } -interface RpcCall { - url: string; - init: RequestInit; -} - -interface PublishedPayload { - image: string; - caption: string; - alt: string; -} type TestFetch = ( input: RequestInfo | URL, init: RequestInit, ) => Promise; +const MODULE = "app.instagram@1"; +const MACHINE = `${MODULE}::Instagram`; +const POST_ID = `${MODULE}::PostId`; +const REQUEST_ID = `${MODULE}::RequestId`; +const MUTATION = `${MODULE}::Mutation`; +const MUTATIONS_SEND = `${MACHINE}::port.mutations.Send`; + const MIRA = "user-mira"; const LENA = "user-lena"; -const THEO = "user-theo"; - -const USERS = [ - { - id: LENA, - username: "lena.holt", - display_name: "Lena Holt", - avatar: { id: "avatar-lena" }, - avatar_alt: "Lena Holt", - bio: "Clay and slow mornings", - }, - { - id: MIRA, - username: "mira.santos", - display_name: "Mira Santos", - avatar: { id: "avatar-mira" }, - avatar_alt: "Mira Santos", - bio: "Designer", - }, - { - id: THEO, - username: "theo.okafor", - display_name: "Theo Okafor", - avatar: { id: "avatar-theo" }, - avatar_alt: "Theo Okafor", - bio: "Courts and murals", - }, -]; - -const BASE_SNAPSHOT = { - users: USERS, - stories: [ - { - id: "story-mira-1", - author: { id: MIRA }, - position: 1, - media_file: { id: "story-media-mira" }, - media_alt: "Breakfast on a marble counter", - caption: "Breakfast", - published_at: "2026-07-13T15:30:00Z", - }, - { - id: "story-lena-1", - author: { id: LENA }, - position: 1, - media_file: { id: "story-media-lena-1" }, - media_alt: "Clay on a wheel", - caption: "Centering", - published_at: "2026-07-13T15:00:00Z", - }, - { - id: "story-lena-2", - author: { id: LENA }, - position: 2, - media_file: { id: "story-media-lena-2" }, - media_alt: "A tall clay cylinder", - caption: "One pull", - published_at: "2026-07-13T15:10:00Z", - }, - { - id: "story-lena-3", - author: { id: LENA }, - position: 3, - media_file: { id: "story-media-lena-3" }, - media_alt: "A clean ceramics bench", - caption: "Reset", - published_at: "2026-07-13T15:20:00Z", - }, - { - id: "story-theo-1", - author: { id: THEO }, - position: 1, - media_file: { id: "story-media-theo" }, - media_alt: "A newly painted court", - caption: "Finished", - published_at: "2026-07-13T15:25:00Z", - }, - ], - storyViews: [ - { viewer: { id: MIRA }, story: { id: "story-lena-1" }, at: "2026-07-13T16:00:00Z" }, - ], - posts: [ - { - id: "post-theo-image", - author: { id: THEO }, - caption: "Court mural in cobalt and orange", - published_at: "2026-07-13T14:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: "media-theo" }, - video_file: null, - media_alt: "A geometric basketball court mural", - }, - { - id: "post-lena-video", - author: { id: LENA }, - caption: "Kiln notes from Lena", - published_at: "2026-07-13T13:00:00Z", - show_in_feed: true, - media_kind: "video", - media_file: { id: "poster-lena" }, - video_file: { id: "video-lena" }, - media_alt: "Copper glaze moving through kiln light", - }, - { - id: "post-mira-image", - author: { id: MIRA }, - caption: "First tram", - published_at: "2026-07-13T12:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: "media-mira" }, - video_file: null, - media_alt: "Tram rails at sunrise", - }, - { - id: "post-lena-archive", - author: { id: LENA }, - caption: "Shelf of celadon tests", - published_at: "2026-07-10T12:00:00Z", - show_in_feed: false, - media_kind: "image", - media_file: { id: "media-lena-archive" }, - video_file: null, - media_alt: "Celadon test cups on a shelf", - }, - { - id: "post-mira-video", - author: { id: MIRA }, - caption: "Last ferry home", - published_at: "2026-07-09T12:00:00Z", - show_in_feed: false, - media_kind: "video", - media_file: { id: "poster-mira" }, - video_file: { id: "video-mira" }, - media_alt: "Ferry wake at golden hour", - }, - ], - slides: [], - comments: [ - { - id: "comment-1", - post: { id: "post-lena-video" }, - author: { id: MIRA }, - body: "That light is perfect.", - created_at: "2026-07-13T13:30:00Z", - }, - ], - likes: [ - { user: { id: MIRA }, post: { id: "post-lena-video" }, at: "2026-07-13T13:20:00Z" }, - { user: { id: THEO }, post: { id: "post-lena-video" }, at: "2026-07-13T13:21:00Z" }, - ], - saves: [ - { user: { id: MIRA }, post: { id: "post-theo-image" }, at: "2026-07-13T14:10:00Z" }, - { user: { id: LENA }, post: { id: "post-mira-image" }, at: "2026-07-13T14:11:00Z" }, - ], - follows: [ - { follower: { id: MIRA }, followed: { id: LENA }, at: "2026-07-01T00:00:00Z" }, - { follower: { id: THEO }, followed: { id: MIRA }, at: "2026-07-02T00:00:00Z" }, - ], - postTags: [ - { post: { id: "post-theo-image" }, person: { id: LENA } }, - ], -}; - -function snapshot(): typeof BASE_SNAPSHOT { - return structuredClone(BASE_SNAPSHOT); -} -function driver( - actor = "mira.santos", - host: ProviderHost = { - signal: new AbortController().signal, - pickFile: async () => null, - }, -): SpockDriver { - return createDriver( - { - graphql_url: "http://spock.test/graphql/v1", - rpc_url: "http://spock.test/rest/v1/rpc", - storage_url: "http://spock.test/storage/v1", - actor, - }, - host, - ); -} - -function graphql(data: unknown): Response { - return new Response(JSON.stringify({ data })); +function snapshot() { + return { + users: [ + { + id: MIRA, + username: "mira.santos", + display_name: "Mira Santos", + avatar: { id: "avatar-mira" }, + avatar_alt: "Mira Santos", + bio: "Designer", + }, + { + id: LENA, + username: "lena.holt", + display_name: "Lena Holt", + avatar: { id: "avatar-lena" }, + avatar_alt: "Lena Holt", + bio: "Clay and slow mornings", + }, + ], + stories: [ + { + id: "story-lena-1", + author: { id: LENA }, + position: 1, + media_file: { id: "story-media-lena" }, + media_alt: "Clay on a wheel", + caption: "Centering", + published_at: "2026-07-13T15:00:00Z", + }, + ], + storyViews: [], + posts: [ + { + id: "post-lena-image", + author: { id: LENA }, + caption: "Kiln notes from Lena", + published_at: "2026-07-13T13:00:00Z", + show_in_feed: true, + media_kind: "image", + media_file: { id: "media-lena" }, + video_file: null, + media_alt: "Copper glaze moving through kiln light", + }, + ], + slides: [], + comments: [], + likes: [], + saves: [], + follows: [ + { + follower: { id: MIRA }, + followed: { id: LENA }, + at: "2026-07-01T00:00:00Z", + }, + ], + postTags: [], + }; } -function frameworkEnvironment( - authority: Record = { - graphql_path: "/framework/graphql", - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }, -): Record { +function frameworkEnvironment(): Record { return { protocol: "spock-host-environment/1", mode: "dev", project_generation_id: 7, backend_generation_id: 3, - authority, + authority: { + graphql_path: "/framework/graphql", + rpc_path: "/framework/rpc", + storage_path: "/framework/storage", + }, }; } function whoami(init: RequestInit): Response { - const headers = new Headers(init?.headers); - const actor = headers.get("x-spock-actor"); + const actor = new Headers(init.headers).get("x-spock-actor"); return new Response( JSON.stringify({ actor, known: true, anonymous: actor === null }), ); } -function requestBody(init: RequestInit): string { - if (typeof init.body !== "string") { - throw new Error("expected a JSON request body"); - } - return init.body; -} - async function withFetch( fetcher: TestFetch, run: () => Promise, ): Promise { - const originalFetch = globalThis.fetch; + const original = globalThis.fetch; globalThis.fetch = (input, init) => fetcher(input, init ?? {}); try { return await run(); } finally { - globalThis.fetch = originalFetch; + globalThis.fetch = original; } } -function bootMessages(remote: SpockDriver): Decoded[] { - return remote.tick().map((message) => JSON.parse(message) as Decoded); +function variant( + type: string, + caseName: string, + fields: ReadonlyArray = [], +): WireValue { + return { + $: "variant", + type, + case: caseName, + fields: fields.map(([name, value]) => ({ name, value })), + }; } -function projection( - messages: Decoded[], - port: string, - name: string, - key: unknown = null, -): Decoded { - const found = messages.find( - (message) => - message.kind === "projection" && - message.port === port && - message.projection === name && - message.key === key, - ); - assert.ok(found, `missing ${port}.${name}(${JSON.stringify(key)})`); - return found.value as Decoded; +function text(value: string): WireValue { + return { $: "Text", value }; } -function update( - outcome: Decoded, - port: string, - name: string, - key: unknown = null, -): Decoded { - const found = outcome.updates.find( - (candidate) => - candidate.port === port && - candidate.projection === name && - candidate.key === key, - ); - assert.ok(found, `missing update ${port}.${name}(${JSON.stringify(key)})`); - return found.value as Decoded; +function bool(value: boolean): WireValue { + return { $: "bool", value }; } -function command( - port: string, - name: string, - payload: Record, - correlation = `${port}-${name}`, -): string { - return JSON.stringify({ - kind: "command", - port, - command: name, - correlation, - payload, - }); +function key(type: string, value: WireValue): WireValue { + return { $: "key", type, value }; } -async function settle(remote: SpockDriver): Promise { - const messages: Decoded[] = []; - for (let attempt = 0; attempt < 100; attempt += 1) { - messages.push(...remote.tick().map((message) => JSON.parse(message))); - if (remote.idle()) return messages; - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("provider command did not settle"); +function textKeyMapKeys(value: WireValue): string[] { + assert.equal(value.$, "map"); + assert.ok(Array.isArray(value.entries)); + return (value.entries as WireValue[][]).map(([entryKey]) => { + assert.ok(entryKey); + assert.equal(entryKey.$, "key"); + assert.equal(typeof entryKey.value, "object"); + assert.notEqual(entryKey.value, null); + const underlying = entryKey.value as WireValue; + assert.equal(underlying.$, "Text"); + assert.equal(typeof underlying.value, "string"); + return underlying.value as string; + }); } -function onlyOutcome(messages: Decoded[]): Decoded { - const outcomes = messages.filter((message) => message.kind === "outcome"); - assert.equal(outcomes.length, 1); - const [outcome] = outcomes; - assert.ok(outcome); - assert.deepEqual(outcome.outcome, { ok: {} }); - return outcome; +function request( + id: number, + mutation: string, + fields: ReadonlyArray = [], +): WireValue { + return variant(MUTATIONS_SEND, "request", [ + [ + "id", + key(REQUEST_ID, { $: "PositiveInt", value: String(id) }), + ], + ["payload", variant(MUTATION, mutation, fields)], + ]); } -test("prefers one strictly typed framework environment before authority work", async () => { - const data = snapshot(); - const calls: string[] = []; - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") { - assert.equal(init.method, "GET"); - assert.equal(new Headers(init.headers).get("accept"), "application/json"); - return new Response(JSON.stringify(frameworkEnvironment())); - } - if (url === "/framework/graphql") return graphql(data); - if (url === "/~whoami") return whoami(init); - if (url === "/framework/storage/object/sign/media-theo") { - return new Response( - JSON.stringify({ - url: "/framework/storage/object/media-theo?exp=9999999999&sig=test", - }), - ); - } - if (url === "/framework/rpc/unlike_post") { - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - assert.equal( - await remote.resolveAsset("media-theo"), - "/framework/storage/object/media-theo?exp=9999999999&sig=test", - ); - remote.deliver( - command("feed", "unlike-post", { post: "post-lena-video" }), - ); - onlyOutcome(await settle(remote)); - remote.dispose(); - }); +function caseOf(value: unknown): string { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + assert.equal((value as Record).$, "variant"); + const caseName = (value as Record).case; + assert.equal(typeof caseName, "string"); + return caseName as string; +} - assert.equal(calls[0], "/~project/environment"); - assert.equal( - calls.filter((url) => url === "/~project/environment").length, - 1, +function namedField(value: unknown, name: string): WireValue { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + const fields = (value as Record).fields; + assert.ok(Array.isArray(fields)); + const found = fields.find( + (field) => + typeof field === "object" + && field !== null + && (field as Record).name === name, ); - assert.ok(calls.includes("/framework/graphql")); - assert.ok(calls.includes("/framework/rpc/unlike_post")); - assert.ok(calls.includes("/framework/storage/object/sign/media-theo")); - assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); -}); + assert.ok(found, `missing wire field ${name}`); + return (found as Record).value as WireValue; +} -test("falls back for unavailable or invalid framework metadata", async () => { - const cases: Array<{ name: string; response: () => Response }> = [ - { - name: "unavailable", - response: () => new Response(null, { status: 404 }), +function onlyField(value: unknown): WireValue { + assert.equal(typeof value, "object"); + assert.notEqual(value, null); + const fields = (value as Record).fields; + assert.ok(Array.isArray(fields)); + assert.equal(fields.length, 1); + return (fields[0] as Record).value as WireValue; +} + +function makeHarness( + pickFile: () => Promise = async () => null, +) { + const abort = new AbortController(); + const requirements = { + authority: { + port: "authority", + adapter: "app.provider", + contractHash: "authority-contract", + contractInstanceHash: "authority-instance", }, - { - name: "wrong protocol", - response: () => - new Response( - JSON.stringify({ - ...frameworkEnvironment(), - protocol: "spock-host-environment/0", - }), - ), + mutations: { + port: "mutations", + adapter: "app.provider", + contractHash: "mutations-contract", + contractInstanceHash: "mutations-instance", }, + } as const; + const provider = createUhuraAdapters( { - name: "extra top-level provider data", - response: () => - new Response( - JSON.stringify({ ...frameworkEnvironment(), provider: { actor: THEO } }), - ), + graphql_url: "http://standalone.test/graphql/v1", + rpc_url: "http://standalone.test/rest/v1/rpc", + storage_url: "http://standalone.test/storage/v1", + actor: "mira.santos", }, { - name: "absolute authority URL", - response: () => - new Response( - JSON.stringify( - frameworkEnvironment({ - graphql_path: "https://other.test/graphql", - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }), - ), - ), + signal: abort.signal, + pickFile: async () => pickFile(), + port(name: string) { + if (name !== "authority" && name !== "mutations") { + throw new Error(`unexpected port ${name}`); + } + return requirements[name]; + }, }, - { - name: "invalid generation", - response: () => - new Response( - JSON.stringify({ - ...frameworkEnvironment(), - backend_generation_id: 0, - }), - ), + ); + const authority = provider.adapters.find( + (adapter) => adapter.port === "authority", + ); + const mutations = provider.adapters.find( + (adapter) => adapter.port === "mutations", + ); + assert.ok(authority); + assert.ok(mutations); + assert.equal(authority.adapter, "app.provider"); + assert.equal(mutations.adapter, "app.provider"); + const authorityValues: WireValue[] = []; + const mutationValues: WireValue[] = []; + const authorityContext = { + signal: abort.signal, + deliver(value: WireValue): void { + authorityValues.push(value); }, - ]; - - for (const candidate of cases) { - const calls: string[] = []; - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") return candidate.response(); - if (url === "http://spock.test/graphql/v1") return graphql(data); - if (url === "http://spock.test/~whoami") return whoami(init); - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - remote.dispose(); - }); - assert.deepEqual( - calls.slice(0, 2), - ["/~project/environment", "http://spock.test/graphql/v1"], - candidate.name, - ); - } -}); + }; + const mutationsContext = { + signal: abort.signal, + deliver(value: WireValue): void { + mutationValues.push(value); + }, + }; + return { + abort, + provider, + authority, + mutations, + authorityContext, + mutationsContext, + authorityValues, + mutationValues, + }; +} -test("bounds framework discovery before using standalone fallback endpoints", async () => { - const calls: string[] = []; +test("boots through admitted authority and mutation port identities", async () => { const data = snapshot(); - vi.useFakeTimers(); - try { - await withFetch(async (input, init) => { - const url = String(input); - calls.push(url); - if (url === "/~project/environment") { - return await new Promise((_resolve, reject) => { - const signal = init.signal; - const abort = (): void => - reject( - new DOMException("environment discovery timed out", "AbortError"), - ); - if (signal?.aborted) abort(); - else signal?.addEventListener("abort", abort, { once: true }); - }); - } - if (url === "http://spock.test/graphql/v1") return graphql(data); - if (url === "http://spock.test/~whoami") return whoami(init); - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - const boot = remote.assembleBoot(); - await vi.advanceTimersByTimeAsync(2_001); - await boot; - remote.dispose(); - }); - } finally { - vi.useRealTimers(); - } - - assert.deepEqual(calls.slice(0, 2), [ - "/~project/environment", - "http://spock.test/graphql/v1", - ]); -}); - -test("treats nullable integrated GraphQL as capability absence, not fallback", async () => { + data.stories.push({ + id: "s", + author: { id: LENA }, + position: 2, + media_file: { id: "story-media-lena-2" }, + media_alt: "Glaze buckets beside the kiln", + caption: "Firing day", + published_at: "2026-07-13T15:30:00Z", + }); const calls: string[] = []; - await withFetch(async (input) => { + await withFetch(async (input, init) => { const url = String(input); calls.push(url); if (url === "/~project/environment") { - return new Response( - JSON.stringify( - frameworkEnvironment({ - graphql_path: null, - rpc_path: "/framework/rpc", - storage_path: "/framework/storage", - }), - ), - ); + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await assert.rejects( - remote.assembleBoot(), - /integrated Spock host does not advertise a GraphQL capability/, + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + + assert.equal(harness.authority.contractHash, "authority-contract"); + assert.equal(harness.authority.contractInstanceHash, "authority-instance"); + assert.equal(harness.mutations.contractHash, "mutations-contract"); + assert.equal(harness.mutations.contractInstanceHash, "mutations-instance"); + assert.equal(harness.authorityValues.length, 1); + const observed = harness.authorityValues[0]; + assert.equal(caseOf(observed), "authority.observed"); + const authority = namedField(observed, "value"); + assert.equal(caseOf(authority), "Ready"); + const authorityData = namedField(authority, "data"); + const storyDetails = namedField(authorityData, "story_details"); + assert.equal(storyDetails.$, "map"); + assert.ok(Array.isArray(storyDetails.entries)); + assert.equal(storyDetails.entries.length, 2); + assert.deepEqual(textKeyMapKeys(storyDetails), ["s", "story-lena-1"]); + const storyDetailValues = (storyDetails.entries as WireValue[][]).map( + (entry) => entry[1], ); - remote.dispose(); - }); - - assert.deepEqual(calls, ["/~project/environment"]); - assert.equal(calls.some((url) => url.startsWith("http://spock.test")), false); -}); - -test("disposing during environment discovery aborts without authority fallback", async () => { - const controller = new AbortController(); - let markStarted!: () => void; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - let authorityCalls = 0; - - await withFetch(async (input, init) => { - if (String(input) !== "/~project/environment") { - authorityCalls += 1; - throw new Error(`unexpected authority fetch ${String(input)}`); + const previousOptions = storyDetailValues.map((detail) => + namedField(detail, "previous") + ); + const nextOptions = storyDetailValues.map((detail) => + namedField(detail, "next") + ); + const presentPrevious = previousOptions.filter( + (option) => caseOf(option) === "some", + ); + const presentNext = nextOptions.filter( + (option) => caseOf(option) === "some", + ); + assert.equal(presentPrevious.length, 1); + assert.equal(presentNext.length, 1); + for (const option of [...presentPrevious, ...presentNext]) { + assert.equal(namedField(option, "value").$, "key"); } - markStarted(); - return await new Promise((_resolve, reject) => { - init.signal?.addEventListener( - "abort", - () => reject(new DOMException("disposed", "AbortError")), - { once: true }, - ); - }); - }, async () => { - const remote = driver("mira.santos", { - signal: controller.signal, - pickFile: async () => null, - }); - const boot = remote.assembleBoot(); - await started; - controller.abort(); - await assert.rejects( - boot, - (error: unknown) => - error instanceof DOMException && error.name === "AbortError", + assert.deepEqual( + textKeyMapKeys(namedField(authorityData, "profiles")), + [LENA, MIRA], ); - }); - - assert.equal(authorityCalls, 0); -}); - -test("normalizes a configured username and exposes authority-owned actors", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - assert.deepEqual(remote.systemInfo(), { + assert.deepEqual(harness.provider.systemInfo(), { actor: MIRA, actors: [ { id: LENA, username: "lena.holt", label: "Lena Holt" }, { id: MIRA, username: "mira.santos", label: "Mira Santos" }, - { id: THEO, username: "theo.okafor", label: "Theo Okafor" }, - ], - }); - }); -}); - -test("retains the actor catalog when the configured actor is invalid", async () => { - const data = snapshot(); - await withFetch(async () => graphql(data), async () => { - const remote = driver("typo"); - await assert.rejects(remote.assembleBoot(), /actor `typo` is not a seeded user/); - assert.deepEqual(remote.systemInfo(), { - actor: "typo", - actors: [ - { id: LENA, username: "lena.holt", label: "Lena Holt" }, - { id: MIRA, username: "mira.santos", label: "Mira Santos" }, - { id: THEO, username: "theo.okafor", label: "Theo Okafor" }, ], }); + harness.provider.dispose(); }); -}); - -test("boot projects actor-filtered home, playable video, sequences, profiles, and explore", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - const boot = JSON.parse(await remote.assembleBoot()); - assert.equal(boot.updates[0].value.id, MIRA); - const messages = bootMessages(remote); - - const home = projection(messages, "feed", "feed-page"); - assert.deepEqual(home.posts.map((post) => post.id), [ - "post-lena-video", - "post-mira-image", - ]); - assert.deepEqual(home.stories.map((ring) => ring.id), [ - "story-mira-1", - "story-lena-2", - ]); - const [selfStory] = home.stories; - const [firstPost] = home.posts; - assert.ok(selfStory); - assert.ok(firstPost); - assert.equal(selfStory["is-self"], true); - assert.equal(selfStory["has-unseen"], false); - assert.deepEqual(firstPost.media, { - video: { - src: "video-lena", - poster: { - src: "poster-lena", - alt: "Copper glaze moving through kiln light", - }, - }, - }); - assert.equal(firstPost["viewer-has-liked"], true); - assert.equal(firstPost["viewer-has-saved"], false); - - const middle = projection(messages, "feed", "story-by-id", "story-lena-2"); - assert.equal(middle.previous, "story-lena-1"); - assert.equal(middle.next, "story-lena-3"); - assert.deepEqual(middle.progress, [ - { id: "story-lena-1", "is-current": false, "is-viewed": true }, - { id: "story-lena-2", "is-current": true, "is-viewed": false }, - { id: "story-lena-3", "is-current": false, "is-viewed": false }, - ]); - const self = projection(messages, "profile", "profile", MIRA); - assert.equal(self["is-self"], true); - assert.equal(self["viewer-follows"], false); - assert.deepEqual(self.reels.map((post) => post.id), ["post-mira-video"]); - assert.deepEqual(self.saved.map((post) => post.id), ["post-theo-image"]); - - const lena = projection(messages, "profile", "profile", LENA); - assert.equal(lena["is-self"], false); - assert.equal(lena["viewer-follows"], true); - assert.deepEqual(lena.reels.map((post) => post.id), ["post-lena-video"]); - assert.deepEqual(lena.saved, []); - - const explore = projection(messages, "profile", "search-results"); - assert.deepEqual(explore.people.map((person) => person.user.id), [LENA, THEO]); - assert.deepEqual(explore.posts.map((post) => post.id), [ - "post-theo-image", - "post-lena-video", - "post-mira-image", - "post-lena-archive", - "post-mira-video", - ]); - }); + assert.deepEqual(calls.slice(0, 3), [ + "/~project/environment", + "/framework/graphql", + "/~whoami", + ]); }); -test("follow and unfollow refresh the relationship-filtered feed and story tray", async () => { +test("settles a machine mutation and publishes refreshed authority", async () => { const data = snapshot(); - const rpcCalls: RpcCall[] = []; + const rpcBodies: unknown[] = []; await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/follow_user")) { - rpcCalls.push({ url, init }); - data.follows.push({ - follower: { id: MIRA }, - followed: { id: THEO }, - at: "2026-07-13T17:00:00Z", - }); - return new Response(JSON.stringify({ follower: MIRA, followed: THEO })); - } - if (url.endsWith("/unfollow_user")) { - rpcCalls.push({ url, init }); - data.follows = data.follows.filter( - (edge) => !(edge.follower.id === MIRA && edge.followed.id === THEO), - ); - return new Response(JSON.stringify({ follower: MIRA, followed: THEO })); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); } - throw new Error(`unexpected fetch ${url}`); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver(command("profile", "follow-user", { user: THEO })); - const followed = onlyOutcome(await settle(remote)); - const followedFeed = update(followed, "feed", "feed-page"); - assert.deepEqual(followedFeed.posts.map((post) => post.id), [ - "post-theo-image", - "post-lena-video", - "post-mira-image", - ]); - assert.deepEqual(followedFeed.stories.map((ring) => ring.user.id), [ - MIRA, - THEO, - LENA, - ]); - assert.equal( - update(followed, "profile", "profile", THEO)["viewer-follows"], - true, - ); - assert.equal( - update(followed, "profile", "search-results").people.find( - (person) => person.user.id === THEO, - )!["viewer-follows"], - true, - ); - - remote.deliver(command("profile", "unfollow-user", { user: THEO })); - const unfollowed = onlyOutcome(await settle(remote)); - const unfollowedFeed = update(unfollowed, "feed", "feed-page"); - assert.equal( - unfollowedFeed.posts.some((post) => post.author.id === THEO), - false, - ); - assert.equal( - unfollowedFeed.stories.some((ring) => ring.user.id === THEO), - false, - ); - - assert.equal(rpcCalls.length, 2); - for (const call of rpcCalls) { - assert.equal(new Headers(call.init.headers).get("x-spock-actor"), MIRA); - assert.deepEqual(JSON.parse(requestBody(call.init)), { target: THEO }); + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } - }); -}); - -test("save and unsave settle every viewer-specific post surface and private grid", async () => { - const data = snapshot(); - const rpcCalls: RpcCall[] = []; - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/save_post")) { - rpcCalls.push({ url, init }); - data.saves.push({ + if (url === "/~whoami") return whoami(init); + if (url === "/framework/rpc/like_post") { + assert.equal(init.method, "POST"); + assert.equal(new Headers(init.headers).get("x-spock-actor"), MIRA); + assert.equal(typeof init.body, "string"); + rpcBodies.push(JSON.parse(init.body as string)); + (data.likes as Array<{ + user: { id: string }; + post: { id: string }; + at: string; + }>).push({ user: { id: MIRA }, - post: { id: "post-lena-video" }, - at: "2026-07-13T17:00:00Z", + post: { id: "post-lena-image" }, + at: "2026-07-13T16:00:00Z", }); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - if (url.endsWith("/unsave_post")) { - rpcCalls.push({ url, init }); - data.saves = data.saves.filter( - (save) => !(save.user.id === MIRA && save.post.id === "post-lena-video"), - ); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); + return new Response(JSON.stringify({ user: MIRA, post: "post-lena-image" })); } throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver( - command("feed", "save-post", { post: "post-lena-video" }), - ); - const saved = onlyOutcome(await settle(remote)); - assert.equal( - update(saved, "feed", "post-by-id", "post-lena-video")["viewer-has-saved"], - true, - ); - assert.equal( - update(saved, "feed", "feed-page").posts.find( - (post) => post.id === "post-lena-video", - )!["viewer-has-saved"], - true, - ); - assert.equal( - update(saved, "feed", "reels").posts.find( - (post) => post.id === "post-lena-video", - )!["viewer-has-saved"], - true, - ); - assert.deepEqual( - update(saved, "profile", "profile", MIRA).saved.map((post) => post.id), - ["post-theo-image", "post-lena-video"], + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(1, "SetLike", [ + ["post", key(POST_ID, text("post-lena-image"))], + ["liked", bool(true)], + ]), + harness.mutationsContext, ); - remote.deliver( - command("feed", "unsave-post", { post: "post-lena-video" }), - ); - const unsaved = onlyOutcome(await settle(remote)); - assert.equal( - update(unsaved, "feed", "post-by-id", "post-lena-video")["viewer-has-saved"], - false, - ); - assert.deepEqual( - update(unsaved, "profile", "profile", MIRA).saved.map((post) => post.id), - ["post-theo-image"], - ); - - assert.equal(rpcCalls.length, 2); - for (const call of rpcCalls) { - assert.equal(new Headers(call.init.headers).get("x-spock-actor"), MIRA); - assert.deepEqual(JSON.parse(requestBody(call.init)), { - post: "post-lena-video", - }); - } + assert.equal(harness.mutationValues.length, 1); + const settled = harness.mutationValues[0]; + assert.equal(caseOf(settled), "mutations.settled"); + const result = namedField(settled, "result"); + assert.equal(caseOf(result), "Accepted", JSON.stringify(result)); + assert.deepEqual(rpcBodies, [{ post: "post-lena-image" }]); + assert.equal(harness.authorityValues.length, 2); + harness.provider.dispose(); }); }); -test("viewing one frame advances the ring and refreshes sequence progress", async () => { +test("accepts the browser-unqualified request case for search", async () => { const data = snapshot(); await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/mark_story_viewed")) { - const { story } = JSON.parse(requestBody(init)) as { story: string }; - data.storyViews.push({ - viewer: { id: MIRA }, - story: { id: story }, - at: "2026-07-13T17:00:00Z", - }); - return new Response(JSON.stringify({ viewer: MIRA, story })); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver( - command("feed", "mark-story-seen", { story: "story-lena-2" }), - ); - const viewed = onlyOutcome(await settle(remote)); - assert.equal( - update(viewed, "feed", "feed-page").stories.find( - (ring) => ring.user.id === LENA, - )!.id, - "story-lena-3", + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(1, "SearchPeople", [["query", text("nils")]]), + harness.mutationsContext, ); - assert.deepEqual( - update(viewed, "feed", "story-by-id", "story-lena-3").progress, - [ - { id: "story-lena-1", "is-current": false, "is-viewed": true }, - { id: "story-lena-2", "is-current": false, "is-viewed": true }, - { id: "story-lena-3", "is-current": true, "is-viewed": false }, - ], - ); - }); -}); -test("search returns both matching people and authority post thumbnails", async () => { - const data = snapshot(); - await withFetch(async (input, init) => { - const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - return graphql(data); - }, async () => { - const remote = driver(); - await remote.assembleBoot(); - bootMessages(remote); - remote.deliver( - command("profile", "search-people", { query: "lena" }), - ); - const searched = onlyOutcome(await settle(remote)); - const results = update(searched, "profile", "search-results"); - assert.deepEqual(results.people.map((person) => person.user.id), [LENA]); - assert.deepEqual(results.posts.map((post) => post.id), [ - "post-lena-video", - "post-lena-archive", - ]); + assert.equal(harness.mutationValues.length, 1); + const settled = harness.mutationValues[0]; + assert.equal(caseOf(settled), "mutations.settled"); + assert.equal(caseOf(namedField(settled, "result")), "Accepted"); + assert.equal(harness.authorityValues.length, 2); + harness.provider.dispose(); }); }); -test("empty create metadata publishes and uses a provenance-only fallback alt", async () => { +test("returns ImageReady directly from the current mutation contract", async () => { const data = snapshot(); const selected = new File(["jpeg bytes"], "sunrise.jpg", { type: "image/jpeg", }); - let publishedPayload: PublishedPayload | null = null; await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) return graphql(data); - if (url.endsWith("/object/upload/sign")) { + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); + } + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); + } + if (url === "/~whoami") return whoami(init); + if (url === "/framework/storage/object/upload/sign") { return new Response( JSON.stringify({ id: "upload-1", - url: "/storage/v1/object/upload-1?exp=9999999999&sig=test", + url: "/framework/storage/object/upload-1?exp=9999999999&sig=test", }), ); } - if (url.includes("/object/upload-1?") && init.method === "PUT") { - assert.equal(new Headers(init.headers).get("content-type"), "image/jpeg"); + if ( + url === "/framework/storage/object/upload-1?exp=9999999999&sig=test" + && init.method === "PUT" + ) { assert.equal(init.body, selected); return new Response(null, { status: 204 }); } - if (url.endsWith("/create_image_post")) { - const payload = JSON.parse(requestBody(init)) as PublishedPayload; - publishedPayload = payload; - data.posts.push({ - id: "post-upload", - author: { id: MIRA }, - caption: payload.caption, - published_at: "2026-07-13T18:00:00Z", - show_in_feed: true, - media_kind: "image", - media_file: { id: payload.image }, - video_file: null, - media_alt: payload.alt, - }); - return new Response(JSON.stringify({ id: "post-upload" })); - } throw new Error(`unexpected fetch ${url}`); }, async () => { - const remote = driver("mira.santos", { - signal: new AbortController().signal, - pickFile: async () => selected, - }); - await remote.assembleBoot(); - bootMessages(remote); - - remote.deliver(command("create", "choose-image", {})); - const chosen = onlyOutcome(await settle(remote)); - assert.deepEqual(update(chosen, "create", "draft"), { - uploaded: { - object: "upload-1", - preview: "upload-1", - name: "sunrise.jpg", - }, - }); - - remote.deliver( - command("create", "publish-image", { - image: "upload-1", - caption: "", - alt: "", - }), + const harness = makeHarness(async () => selected); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(2, "ChooseImage"), + harness.mutationsContext, ); - const published = onlyOutcome(await settle(remote)); - assert.deepEqual(publishedPayload, { - image: "upload-1", - caption: "", - alt: "Uploaded image “sunrise.jpg” by Mira Santos", - }); - const post = update(published, "feed", "post-by-id", "post-upload"); - assert.equal(post.caption, ""); - assert.deepEqual(post.media, { - image: { - image: { - src: "upload-1", - alt: "Uploaded image “sunrise.jpg” by Mira Santos", - }, - }, - }); - assert.equal( - update(published, "profile", "profile", MIRA).posts[0]!.id, - "post-upload", - ); - assert.equal( - update(published, "profile", "search-results").posts[0]!.id, - "post-upload", + + assert.equal(harness.authorityValues.length, 1); + const settled = harness.mutationValues[0]; + const result = namedField(settled, "result"); + assert.equal(caseOf(result), "ImageReady"); + assert.deepEqual( + [ + namedField(result, "object").value, + namedField(result, "preview").value, + namedField(result, "name").value, + ], + ["upload-1", "upload-1", "sunrise.jpg"], ); - assert.deepEqual(update(published, "create", "draft"), { empty: {} }); + harness.provider.dispose(); }); }); -test("a remounted driver waits for an accepted retired mutation before boot", async () => { - const data = snapshot(); - let graphqlCalls = 0; - let abortedRetiredReads = 0; - let markMutationStarted!: () => void; - let finishMutation!: () => void; - const mutationStarted = new Promise((resolve) => { - markMutationStarted = resolve; - }); - const mutationFinished = new Promise((resolve) => { - finishMutation = resolve; - }); - - await withFetch(async (input, init) => { - const url = String(input); - if (init.signal?.aborted) { - abortedRetiredReads += 1; - throw new DOMException("retired", "AbortError"); - } - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) { - graphqlCalls += 1; - return graphql(data); - } - if (url.endsWith("/unlike_post")) { - markMutationStarted(); - await mutationFinished; - data.likes = data.likes.filter( - (like) => !(like.user.id === MIRA && like.post.id === "post-lena-video"), - ); - return new Response(JSON.stringify({ user: MIRA, post: "post-lena-video" })); - } - throw new Error(`unexpected fetch ${url}`); +test("resolves checked local assets without contacting Spock storage", async () => { + let fetched = false; + await withFetch(async () => { + fetched = true; + throw new Error("local fixture asset must not use fetch"); }, async () => { - const retired = driver(); - await retired.assembleBoot(); - bootMessages(retired); - retired.deliver( - command("feed", "unlike-post", { post: "post-lena-video" }), - ); - await mutationStarted; - retired.dispose(); - - const fresh = driver(); - const freshBoot = fresh.assembleBoot(); - await Promise.resolve(); - assert.equal(graphqlCalls, 1, "fresh boot must wait behind accepted authority work"); - - finishMutation(); - await freshBoot; - assert.equal(graphqlCalls, 2); - assert.equal(abortedRetiredReads, 1); - assert.deepEqual(retired.tick(), []); + const harness = makeHarness(); assert.equal( - projection( - bootMessages(fresh), - "feed", - "post-by-id", - "post-lena-video", - )["viewer-has-liked"], - false, + await harness.provider.resolveAsset("video-nils-aurora"), + "/api/play/assets/media-nils-aurora.mp4", ); - fresh.dispose(); + harness.provider.dispose(); }); + assert.equal(fetched, false); }); -test("a retired hung upload cannot block the replacement driver boot", async () => { +test("cancelling the picker settles as an explicit refusal", async () => { const data = snapshot(); - const controller = new AbortController(); - const selected = new File(["jpeg bytes"], "never-finishes.jpg", { - type: "image/jpeg", - }); - let markUploadStarted!: () => void; - const uploadStarted = new Promise((resolve) => { - markUploadStarted = resolve; - }); - let uploadSignal: AbortSignal | null = null; - let graphqlCalls = 0; - await withFetch(async (input, init) => { const url = String(input); - if (url.endsWith("/~whoami")) return whoami(init); - if (url.endsWith("/graphql/v1")) { - graphqlCalls += 1; - return graphql(data); + if (url === "/~project/environment") { + return new Response(JSON.stringify(frameworkEnvironment())); } - if (url.endsWith("/object/upload/sign")) { - uploadSignal = init.signal ?? null; - markUploadStarted(); - // Model a transport that fails to settle even after cancellation. A - // draft upload is not domain authority work, so it must not gate boot. - return await new Promise(() => {}); + if (url === "/framework/graphql") { + return new Response(JSON.stringify({ data })); } + if (url === "/~whoami") return whoami(init); throw new Error(`unexpected fetch ${url}`); }, async () => { - const retired = driver("mira.santos", { - signal: controller.signal, - pickFile: async () => selected, - }); - await retired.assembleBoot(); - bootMessages(retired); - retired.deliver(command("create", "choose-image", {})); - await uploadStarted; - retired.dispose(); - assert.equal(uploadSignal?.aborted, true); - - const fresh = driver(); - let timeout: ReturnType | undefined; - try { - await Promise.race([ - fresh.assembleBoot(), - new Promise((_resolve, reject) => { - timeout = setTimeout( - () => reject(new Error("replacement provider boot stayed blocked")), - 250, - ); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } - assert.equal(graphqlCalls, 2); - fresh.dispose(); + const harness = makeHarness(); + await harness.authority.start?.(harness.authorityContext); + await harness.mutations.accept( + request(3, "ChooseImage"), + harness.mutationsContext, + ); + const result = namedField(harness.mutationValues[0], "result"); + assert.equal(caseOf(result), "Refused"); + assert.equal(onlyField(result).value, "selection-cancelled"); + harness.provider.dispose(); }); }); diff --git a/examples/instagram/client/providers/spock.ts b/examples/instagram/client/providers/spock.ts index 09bf02f..55d3c35 100644 --- a/examples/instagram/client/providers/spock.ts +++ b/examples/instagram/client/providers/spock.ts @@ -1,7 +1,6 @@ -// Instagram's app-local Spock provider. It speaks the same -// `uhura-provider/0` envelopes as FixtureDriver, but reads one coherent -// authority snapshot through Spock GraphQL and sends commands through the -// deliberate REST RPC surface. +// Instagram's app-local adapter provider. Uhura owns the deterministic +// machine; this module observes Spock authority and performs requested +// mutations at the two explicitly admitted application ports. const PAGE_SIZE = 4; const SUPPORTED_IMAGE_TYPES = new Set([ @@ -10,7 +9,49 @@ const SUPPORTED_IMAGE_TYPES = new Set([ "image/webp", ]); -// Spock v0 caps one collection read at 200 rows. The current demo fits inside +// Evidence fixtures use stable logical names while Play serves exact captured +// files. Live Spock storage ids deliberately fall through to signed URLs. +const LOCAL_PLAY_ASSETS: Readonly> = { + "avatar-mira": "avatar-mira.webp", + "avatar-lena": "avatar-lena.webp", + "avatar-marco": "avatar-marco.webp", + "avatar-nils": "avatar-nils.webp", + "avatar-priya": "avatar-priya.webp", + "avatar-ayla": "avatar-ayla.webp", + "avatar-june": "avatar-june.webp", + "avatar-theo": "avatar-theo.webp", + "avatar-kenji": "avatar-kenji.webp", + "media-lena-glaze": "media-lena-glaze.webp", + "media-marco-baja-1": "media-marco-baja-1.webp", + "media-marco-baja-2": "media-marco-baja-2.webp", + "media-marco-baja-3": "media-marco-baja-3.webp", + "media-nils-aurora-poster": "media-nils-aurora-poster.webp", + "media-priya-starter": "media-priya-starter.webp", + "media-ayla-ferry": "media-ayla-ferry.webp", + "media-june-lookbook": "media-june-lookbook.webp", + "media-theo-court": "media-theo-court.webp", + "media-kenji-copper": "media-kenji-copper.webp", + "thumb-lena-1": "thumb-lena-1.webp", + "thumb-lena-2": "thumb-lena-2.webp", + "thumb-lena-3": "thumb-lena-3.webp", + "thumb-lena-4": "thumb-lena-4.webp", + "thumb-lena-5": "thumb-lena-5.webp", + "thumb-lena-6": "thumb-lena-6.webp", + "thumb-lena-7": "thumb-lena-7.webp", + "thumb-lena-8": "thumb-lena-8.webp", + "thumb-lena-9": "thumb-lena-9.webp", + "thumb-mira-1": "thumb-mira-1.webp", + "thumb-mira-2": "thumb-mira-2.webp", + "thumb-mira-3": "thumb-mira-3.webp", + "thumb-mira-4": "thumb-mira-4.webp", + "thumb-mira-5": "thumb-mira-5.webp", + "thumb-mira-6": "thumb-mira-6.webp", + "video-nils-aurora": "media-nils-aurora.mp4", + "video-theo-court": "media-theo-court.mp4", + "video-mira-ferry": "media-mira-ferry.mp4", +}; + +// The current Spock authority caps one collection read at 200 rows. This demo fits inside // that ceiling per table; snapshot-consistent pagination is deferred dogfood // rather than pretending a clamped response is complete. const SNAPSHOT_QUERY = ` @@ -104,7 +145,7 @@ const COMMAND_REFUSALS: Readonly> = { ], }; -export interface SpockDriverConfig { +export interface SpockProviderConfig { /** Standalone fallback for the full Spock `/graphql/v1` endpoint. */ graphql_url: string; /** Standalone fallback for the Spock `/rest/v1/rpc` prefix. */ @@ -125,42 +166,179 @@ export interface ProviderHost { pickFile(options: { accept: string }): Promise; } +interface PortRequirement { + readonly port: string; + readonly adapter: "app.provider"; + readonly contractHash: string; + readonly contractInstanceHash: string; +} + +interface PortAdapterContext { + readonly signal: AbortSignal; + deliver(value: WireValue): void; +} + +interface AdapterProviderHost extends ProviderHost { + port(name: string): PortRequirement; +} + +interface PortAdapter extends PortRequirement { + start?(context: PortAdapterContext): void | Promise; + accept(command: WireValue, context: PortAdapterContext): void | Promise; + dispose?(): void; +} + +interface WireValue { + readonly $: string; + readonly [field: string]: unknown; +} + export interface RemoteSystemInfo { actor: string | null; actors: Array<{ id: string; username: string; label: string }>; } -export interface SpockDriver { +interface SpockBackend { dispose(): void; - assembleBoot(): Promise; - deliver(commandJson: string): void; - tick(): string[]; - idle(): boolean; + load(): Promise; + execute(operation: BackendOperation): Promise; + authorityValue(): WireValue; resolveAsset(asset: string): Promise; systemInfo(): RemoteSystemInfo; } +const INSTAGRAM_MODULE = "app.instagram@1"; +const INSTAGRAM_MACHINE = `${INSTAGRAM_MODULE}::Instagram`; +const USER_ID_TYPE = `${INSTAGRAM_MODULE}::UserId`; +const POST_ID_TYPE = `${INSTAGRAM_MODULE}::PostId`; +const STORY_ID_TYPE = `${INSTAGRAM_MODULE}::StoryId`; +const REQUEST_ID_TYPE = `${INSTAGRAM_MODULE}::RequestId`; +const AUTHORITY_TYPE = `${INSTAGRAM_MODULE}::Authority`; +const MEDIA_TYPE = `${INSTAGRAM_MODULE}::Media`; +const MUTATION_TYPE = `${INSTAGRAM_MODULE}::Mutation`; +const SETTLEMENT_TYPE = `${INSTAGRAM_MODULE}::Settlement`; +const AUTHORITY_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.authority.Receive`; +const MUTATIONS_SEND_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Send`; +const MUTATIONS_RECEIVE_TYPE = + `${INSTAGRAM_MACHINE}::port.mutations.Receive`; + +const wireText = (value: string): WireValue => ({ $: "Text", value }); +const wireBool = (value: boolean): WireValue => ({ $: "bool", value }); +const wireNat = (value: number): WireValue => ({ + $: "Nat", + value: String(value), +}); +const wireKey = ( + type: string, + value: WireValue, +): WireValue => ({ $: "key", type, value }); +const wireRecord = ( + fields: ReadonlyArray, +): WireValue => ({ + $: "record", + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireVariant = ( + type: string, + caseName: string, + fields: ReadonlyArray = [], +): WireValue => ({ + $: "variant", + type, + case: caseName, + fields: fields.map(([name, value]) => ({ name, value })), +}); +const wireSeq = (items: readonly WireValue[]): WireValue => ({ + $: "seq", + items, +}); +const wireMap = ( + entries: ReadonlyArray, +): WireValue => ({ $: "map", entries }); +const textEncoder = new TextEncoder(); +const lengthPrefix = (value: number): number[] => { + const bytes: number[] = []; + do { + let byte = value % 128; + value = Math.floor(value / 128); + if (value !== 0) byte += 128; + bytes.push(byte); + } while (value !== 0); + return bytes; +}; +const canonicalTextKeyBytes = (value: string): Uint8Array => { + const body = textEncoder.encode(value); + return Uint8Array.from([...lengthPrefix(body.length), ...body]); +}; +const compareBytes = (left: Uint8Array, right: Uint8Array): number => { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const order = (left[index] ?? 0) - (right[index] ?? 0); + if (order !== 0) return order; + } + return left.length - right.length; +}; +/** + * Every map currently emitted by this adapter has a nominal Text-backed key. + * Uhura orders a map by the complete canonical key bytes. The nominal type is + * constant within one map, so its shared prefix cancels and the order reduces + * exactly to the length-framed UTF-8 Text body below. + */ +const wireTextKeyMap = ( + type: string, + entries: ReadonlyArray, +): WireValue => { + const ordered = entries + .map(([key, value]) => ({ + key, + value, + canonical: canonicalTextKeyBytes(key), + })) + .sort((left, right) => compareBytes(left.canonical, right.canonical)); + for (let index = 1; index < ordered.length; index += 1) { + if ( + compareBytes( + ordered[index - 1]?.canonical ?? new Uint8Array(), + ordered[index]?.canonical ?? new Uint8Array(), + ) === 0 + ) { + throw new Error(`duplicate canonical map key \`${ordered[index]?.key}\``); + } + } + return wireMap(ordered.map(({ key, value }) => [ + wireKey(type, wireText(key)), + value, + ])); +}; +const wireOption = ( + type: string, + value: WireValue | null, +): WireValue => wireVariant( + `Option<${type}>`, + value === null ? "none" : "some", + value === null ? [] : [["value", value]], +); + /** * A picker result is observed immediately so a rejection cannot become * unhandled while an earlier provider command finishes. */ type PickedFile = Promise<{ file: File | null } | { error: unknown }>; -// A retired driver can have a mutation already accepted by Spock. New driver -// boot waits for that work before reading its authority snapshot, so a route +// A retired backend instance can have a mutation already accepted by Spock. +// A replacement waits for that work before reading its authority snapshot, so a route // remount cannot strand a just-accepted mutation behind stale boot data. let authorityTail: Promise = Promise.resolve(); -const AUTHORITY_COMMANDS = new Set([ - "feed/like-post", - "feed/unlike-post", - "feed/save-post", - "feed/unsave-post", - "comments/add-comment", - "feed/mark-story-seen", - "profile/follow-user", - "profile/unfollow-user", - "create/publish-image", +const AUTHORITY_OPERATIONS = new Set([ + "set_like", + "set_save", + "add_comment", + "mark_story", + "set_follow", + "publish_image_request", ]); const AUTHORITY_REQUEST_TIMEOUT_MS = 15_000; @@ -272,9 +450,12 @@ function resolveFromEndpoint(reference: string, endpoint: string): string { } } -function enqueueAuthorityWork(work: () => Promise): Promise { +function enqueueAuthorityWork(work: () => Promise): Promise { const queued = authorityTail.then(work, work); - authorityTail = queued.catch(() => {}); + authorityTail = queued.then( + () => {}, + () => {}, + ); return queued; } @@ -433,26 +614,32 @@ type RpcReply = | { ok: true; result: unknown } | { ok: false; error: SpockError }; -interface ProviderCommand { - kind: "command"; - port: string; - command: string; - correlation: string; - payload: Record; -} - -interface ProjectionUpdate { - port: string; - projection: string; - key: unknown; - revision: number; - value: unknown; -} - -type CommandOutcome = - | { ok: Record } - | { refused: { refusal: string } } - | { unavailable: { reason: string } }; +type BackendOperation = + | { kind: "set_like"; post: string; liked: boolean } + | { kind: "set_save"; post: string; saved: boolean } + | { kind: "load_more" } + | { kind: "reload_feed" } + | { kind: "set_follow"; user: string; following: boolean } + | { kind: "add_comment"; post: string; body: string } + | { kind: "search_people"; query: string } + | { kind: "choose_image_request" } + | { + kind: "publish_image_request"; + object: string; + caption: string; + alt: string; + } + | { kind: "mark_story"; story: string }; + +type BackendSettlement = + | { kind: "accepted" } + | { kind: "refused"; reason: string } + | { + kind: "image_ready"; + object: string; + preview: string; + name: string; + }; interface Database { users: Map; @@ -568,21 +755,14 @@ function toRefusalName(code: string): string { } /** - * Create the Instagram demo's live Spock-backed provider. - * - * Delivery is eager: boot queues every keyed post, comment thread, story, - * profile, and relationship list plus the feed, reels, people search, and - * create draft. Commands settle by re-reading one authority snapshot and - * carrying whole-slice updates in their outcome envelope. - * - * @param {SpockDriverConfig} config - * @param {ProviderHost} host - * @returns {SpockDriver} + * Create the app-local Spock authority bridge used by the admitted Uhura + * ports. It exposes domain operations and typed authority values directly; + * there is no second provider protocol or projection/outcome envelope. */ -export function createDriver( - { graphql_url, rpc_url, storage_url, actor }: SpockDriverConfig, +function createSpockBackend( + { graphql_url, rpc_url, storage_url, actor }: SpockProviderConfig, host: ProviderHost, -): SpockDriver { +): SpockBackend { const graphqlUrl = graphql_url.replace(/\/+$/, ""); const rpcUrl = rpc_url.replace(/\/+$/, ""); const storageUrl = storage_url.replace(/\/+$/, ""); @@ -600,13 +780,10 @@ export function createDriver( whoamiUrl: new URL("/~whoami", graphqlUrl).toString(), }; - const outbox: string[] = []; - let inflight = 0; - let commandTail: Promise = Promise.resolve(); - const signedAssets = new Map(); const signingAssets = new Map>(); const uploadedFileNames = new Map(); + let operationTail: Promise = Promise.resolve(); const cancellable = new AbortController(); let disposed = host.signal.aborted; let authorityResolution: Promise | undefined; @@ -616,7 +793,6 @@ export function createDriver( disposed = true; host.signal.removeEventListener("abort", dispose); cancellable.abort(); - outbox.length = 0; signedAssets.clear(); signingAssets.clear(); uploadedFileNames.clear(); @@ -666,70 +842,6 @@ export function createDriver( return authorityResolution; } - const revisions = new Map(); - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @returns {number} - */ - function mintRevision( - port: string, - projection: string, - key: unknown, - ): number { - const slot = `${port}|${projection}|${encode(key ?? null)}`; - const next = (revisions.get(slot) ?? 1) + 1; - revisions.set(slot, next); - return next; - } - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @param {unknown} value - * @returns {string} - */ - function projectionMsg( - port: string, - projection: string, - key: unknown, - value: unknown, - ): string { - return encode({ - kind: "projection", - port, - projection, - key: key ?? null, - revision: mintRevision(port, projection, key), - value, - }); - } - - /** - * @param {string} port - * @param {string} projection - * @param {unknown} key - * @param {unknown} value - * @returns {ProjectionUpdate} - */ - function projectionUpdate( - port: string, - projection: string, - key: unknown, - value: unknown, - ): ProjectionUpdate { - return { - port, - projection, - key: key ?? null, - revision: mintRevision(port, projection, key), - value, - }; - } - /** * @returns {Promise} */ @@ -816,7 +928,7 @@ export function createDriver( try { // Once sent, a domain mutation may already be accepted by Spock. Do not // abort it merely because its route retired; the module-level authority - // barrier makes the next driver wait for settlement. The finite timeout + // barrier makes the replacement backend wait for settlement. The finite timeout // prevents a broken connection from blocking every future boot forever. response = await fetch(`${rpcUrl}/${fn}`, { method: "POST", @@ -853,6 +965,11 @@ export function createDriver( * @returns {Promise} */ async function resolveAsset(asset: string): Promise { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(asset)) return asset; + const local = LOCAL_PLAY_ASSETS[asset]; + if (local) { + return `/api/play/assets/${encodeURIComponent(local)}`; + } assertLive(); const cached = signedAssets.get(asset); if (cached && Date.now() < cached.refreshAt) return cached.url; @@ -1064,8 +1181,8 @@ export function createDriver( const resolved = data.users.find( (user) => user.id === actor || user.username === actor, ); - // Keep the authority-owned user catalog available even when a stale - // tab-local actor selection cannot resolve. assembleBoot still refuses + // Keep the authority-owned user directory available even when a stale + // tab-local actor selection cannot resolve. `load` still refuses // that identity, but the system chrome can offer a valid actor and recover // by replacing the stored selection. viewerRow = resolved ?? null; @@ -1091,87 +1208,6 @@ export function createDriver( return user; } - /** - * @param {UserRow} user - * @returns {Record} - */ - function userRef(user: UserRow) { - return { - id: user.id, - username: user.username, - "display-name": user.display_name, - avatar: { src: user.avatar.id, alt: user.avatar_alt }, - }; - } - - /** - * @param {PostRow} post - * @returns {Record} - */ - function media(post: PostRow) { - if (post.media_kind === "carousel") { - const slides = db.slidesByPost.get(post.id) ?? []; - return { - carousel: { - slides: slides.map((slide) => ({ - id: slide.id, - src: slide.file, - alt: slide.alt, - })), - }, - }; - } - if (post.media_file === null || post.media_alt === null) { - throw new Error(`post \`${post.id}\` has incomplete ${post.media_kind} media`); - } - const ref = { src: post.media_file, alt: post.media_alt }; - if (post.media_kind === "video") { - if (post.video_file === null) { - throw new Error(`video post \`${post.id}\` has no playable video_file`); - } - return { video: { src: post.video_file, poster: ref } }; - } - return { image: { image: ref } }; - } - - /** - * @param {PostRow} post - * @returns {Record} - */ - function postSummary(post: PostRow) { - return { - id: post.id, - author: userRef(requireUser(post.author)), - media: media(post), - caption: post.caption, - "like-count": db.likeCounts.get(post.id) ?? 0, - "comment-count": (db.commentsByPost.get(post.id) ?? []).length, - "viewer-has-liked": db.liked.has(post.id), - "viewer-has-saved": db.saved.has(post.id), - "posted-label": ageLabel(post.published_at), - }; - } - - /** - * @param {string} id - * @returns {PostRow} - */ - function requirePost(id: string): PostRow { - const post = db.posts.find((candidate) => candidate.id === id); - if (!post) throw new Error(`Spock snapshot has no post \`${id}\``); - return post; - } - - /** - * @param {string} id - * @returns {StoryRow} - */ - function requireStory(id: string): StoryRow { - const story = db.stories.find((candidate) => candidate.id === id); - if (!story) throw new Error(`Spock snapshot has no story \`${id}\``); - return story; - } - /** * @param {PostRow} post * @returns {{ id: string, src: string, alt: string }} @@ -1204,301 +1240,323 @@ export function createDriver( return db.posts.filter((post) => post.show_in_feed && isHomeAuthor(post.author)); } - /** - * One tray entry represents one author's current story sequence. Its id is - * the next unseen frame (or the first frame after the sequence is exhausted), - * so opening a ring always addresses a real keyed story projection. - * @returns {Record[]} - */ - function storyRingsValue() { + function userWire(id: string): WireValue { + const user = requireUser(id); + return wireRecord([ + ["id", wireKey(USER_ID_TYPE, wireText(user.id))], + ["username", wireText(user.username)], + ["display_name", wireText(user.display_name)], + [ + "avatar", + wireRecord([ + ["src", wireText(user.avatar.id)], + ["alt", wireText(user.avatar_alt)], + ]), + ], + ]); + } + + function imageWire(src: string, alt: string): WireValue { + return wireRecord([ + ["src", wireText(src)], + ["alt", wireText(alt)], + ]); + } + + function mediaWire(post: PostRow): WireValue { + if (post.media_kind === "carousel") { + const slides = db.slidesByPost.get(post.id) ?? []; + return wireVariant(MEDIA_TYPE, "Carousel", [[ + "images", + wireSeq(slides.map((slide) => imageWire(slide.file, slide.alt))), + ]]); + } + if (post.media_file === null || post.media_alt === null) { + throw new Error( + `post \`${post.id}\` has incomplete ${post.media_kind} media`, + ); + } + const poster = imageWire(post.media_file, post.media_alt); + if (post.media_kind === "video") { + if (post.video_file === null) { + throw new Error(`video post \`${post.id}\` has no playable video_file`); + } + return wireVariant(MEDIA_TYPE, "Video", [ + ["src", wireText(post.video_file)], + ["poster", poster], + ]); + } + return wireVariant(MEDIA_TYPE, "Image", [["image", poster]]); + } + + function postWire(post: PostRow): WireValue { + return wireRecord([ + ["id", wireKey(POST_ID_TYPE, wireText(post.id))], + ["author", userWire(post.author)], + ["caption", wireText(post.caption)], + ["media", mediaWire(post)], + ["like_count", wireNat(db.likeCounts.get(post.id) ?? 0)], + [ + "comment_count", + wireNat((db.commentsByPost.get(post.id) ?? []).length), + ], + ["viewer_liked", wireBool(db.liked.has(post.id))], + ["viewer_saved", wireBool(db.saved.has(post.id))], + ["posted_label", wireText(ageLabel(post.published_at))], + ]); + } + + function tileWire(post: PostRow): WireValue { + const thumb = postThumb(post); + return wireRecord([ + ["post", wireKey(POST_ID_TYPE, wireText(post.id))], + ["image", imageWire(thumb.src, thumb.alt)], + ]); + } + + function connectionWire(id: string): WireValue { + return wireRecord([ + ["user", userWire(id)], + ["follows_viewer", wireBool(db.follows.has(edgeKey(id, viewerId())))], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ]); + } + + function connectionSequence(ids: readonly string[]): WireValue { + const unique = [...new Set(ids)]; + unique.sort((left, right) => + requireUser(left).username.localeCompare(requireUser(right).username) + ); + return wireSeq(unique.map(connectionWire)); + } + + function commentWire(comment: CommentRow): WireValue { + return wireRecord([ + ["id", wireText(comment.id)], + ["author", userWire(comment.author)], + ["body", wireText(comment.body)], + ["posted_label", wireText(ageLabel(comment.created_at))], + ]); + } + + function storyDetailWire(story: StoryRow): WireValue { + const sequence = db.stories + .filter((candidate) => candidate.author === story.author) + .sort( + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), + ); + const index = sequence.findIndex((candidate) => candidate.id === story.id); + if (index < 0) { + throw new Error(`story sequence lost frame \`${story.id}\``); + } + const previous = index > 0 ? sequence[index - 1]?.id ?? null : null; + const next = index + 1 < sequence.length + ? sequence[index + 1]?.id ?? null + : null; + return wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(story.id))], + ["author", userWire(story.author)], + ["image", imageWire(story.media_file, story.media_alt)], + ["caption", wireText(story.caption ?? "")], + ["posted_label", wireText(ageLabel(story.published_at))], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), story.id))), + ], + [ + "previous", + wireOption( + STORY_ID_TYPE, + previous === null + ? null + : wireKey(STORY_ID_TYPE, wireText(previous)), + ), + ], + [ + "next", + wireOption( + STORY_ID_TYPE, + next === null ? null : wireKey(STORY_ID_TYPE, wireText(next)), + ), + ], + [ + "progress", + wireSeq(sequence.map((frame) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(frame.id))], + ["current", wireBool(frame.id === story.id)], + [ + "viewed", + wireBool(db.storyViews.has(edgeKey(viewerId(), frame.id))), + ], + ]) + )), + ], + ]); + } + + function storyRingWires(): WireValue[] { const grouped = groupBy( db.stories.filter((story) => isHomeAuthor(story.author)), (story) => story.author, ); const rings = [...grouped.entries()].map(([author, stories]) => { stories.sort( - (left, right) => left.position - right.position || left.id.localeCompare(right.id), + (left, right) => + left.position - right.position || left.id.localeCompare(right.id), ); const unseen = stories.filter( (story) => !db.storyViews.has(edgeKey(viewerId(), story.id)), ); - const isSelf = author === viewerId(); - const selected = isSelf ? stories[0] : unseen[0] ?? stories[0]; + const self = author === viewerId(); + const selected = self ? stories[0] : unseen[0] ?? stories[0]; if (!selected) throw new Error(`story author \`${author}\` has no frames`); const newest = stories.reduce((latest, story) => - newestFirst(latest.published_at, story.published_at) <= 0 ? latest : story, + newestFirst(latest.published_at, story.published_at) <= 0 + ? latest + : story ); return { author, selected, newest, - hasUnseen: !isSelf && unseen.length > 0, + unseen: !self && unseen.length > 0, + self, }; }); - rings.sort((left, right) => { - const leftSelf = left.author === viewerId() ? 1 : 0; - const rightSelf = right.author === viewerId() ? 1 : 0; - return ( - rightSelf - leftSelf || - newestFirst(left.newest.published_at, right.newest.published_at) || - requireUser(left.author).username.localeCompare(requireUser(right.author).username) - ); - }); - return rings.map((ring) => ({ - id: ring.selected.id, - user: userRef(requireUser(ring.author)), - "has-unseen": ring.hasUnseen, - "is-self": ring.author === viewerId(), - })); - } - - /** @returns {Record} */ - function feedPageValue() { - const posts = feedPosts(); - const shown = posts.slice(0, feedCount); - const hasMore = feedCount < posts.length; - return { - stories: storyRingsValue(), - posts: shown.map((post) => postSummary(post)), - cursor: hasMore ? `offset:${feedCount}` : null, - "has-more": hasMore, - }; - } - - /** - * @param {string} postId - * @returns {Record} - */ - function threadValue(postId: string) { - const rows = db.commentsByPost.get(postId) ?? []; - return { - comments: rows.map((comment) => ({ - id: comment.id, - author: userRef(requireUser(comment.author)), - body: comment.body, - "posted-label": ageLabel(comment.created_at), - })), - }; - } - - /** - * @param {string} storyId - * @returns {Record} - */ - function storyValue(storyId: string) { - const story = requireStory(storyId); - const sequence = db.stories - .filter((candidate) => candidate.author === story.author) - .sort( - (left, right) => - left.position - right.position || left.id.localeCompare(right.id), - ); - const index = sequence.findIndex((candidate) => candidate.id === story.id); - if (index < 0) throw new Error(`story sequence lost frame \`${story.id}\``); - return { - id: story.id, - author: userRef(requireUser(story.author)), - image: { src: story.media_file, alt: story.media_alt }, - caption: story.caption ?? "", - "posted-label": ageLabel(story.published_at), - "viewer-has-viewed": db.storyViews.has(edgeKey(viewerId(), story.id)), - previous: index > 0 ? sequence[index - 1]?.id ?? null : null, - next: - index + 1 < sequence.length ? sequence[index + 1]?.id ?? null : null, - progress: sequence.map((frame) => ({ - id: frame.id, - "is-current": frame.id === story.id, - "is-viewed": db.storyViews.has(edgeKey(viewerId(), frame.id)), - })), - }; - } - - /** @returns {Record} */ - function reelsValue() { - return { - posts: db.posts - .filter((post) => post.media_kind === "video") - .map((post) => postSummary(post)), - }; - } - - /** - * @param {string} userId - * @returns {Record} - */ - function profileValue(userId: string) { - const user = requireUser(userId); - const posts = db.posts.filter((post) => post.author === userId); - const reels = posts.filter((post) => post.media_kind === "video"); - const saved = - userId === viewerId() - ? db.posts.filter((post) => db.saved.has(post.id)) - : []; - const taggedIds = new Set(db.taggedPostsByUser.get(userId) ?? []); - const tagged = db.posts.filter((post) => taggedIds.has(post.id)); - return { - user: userRef(user), - bio: user.bio ?? "", - "is-self": userId === viewerId(), - "viewer-follows": db.follows.has(edgeKey(viewerId(), userId)), - "post-count": posts.length, - "follower-count": (db.followersByUser.get(userId) ?? []).length, - "following-count": (db.followingByUser.get(userId) ?? []).length, - posts: posts.map((post) => postThumb(post)), - reels: reels.map((post) => postThumb(post)), - saved: saved.map((post) => postThumb(post)), - tagged: tagged.map((post) => postThumb(post)), - }; - } - - /** - * @param {string[]} userIds - * @returns {Record} - */ - function connectionsValue(userIds: string[]) { - return { - people: userIds - .map((id) => requireUser(id)) - .sort((left, right) => left.username.localeCompare(right.username)) - .map((user) => ({ - user: userRef(user), - "viewer-follows": db.follows.has(edgeKey(viewerId(), user.id)), - })), - }; - } - - /** - * @param {string} userId - * @returns {Record} - */ - function followersValue(userId: string) { - requireUser(userId); - return connectionsValue(db.followersByUser.get(userId) ?? []); + rings.sort((left, right) => + Number(right.self) - Number(left.self) + || newestFirst(left.newest.published_at, right.newest.published_at) + || requireUser(left.author).username.localeCompare( + requireUser(right.author).username, + ) + ); + return rings.map((ring) => + wireRecord([ + ["id", wireKey(STORY_ID_TYPE, wireText(ring.selected.id))], + ["user", userWire(ring.author)], + ["unseen", wireBool(ring.unseen)], + ["is_self", wireBool(ring.self)], + ]) + ); } - /** - * @param {string} userId - * @returns {Record} - */ - function followingValue(userId: string) { - requireUser(userId); - return connectionsValue(db.followingByUser.get(userId) ?? []); + function profileWire(id: string): WireValue { + const user = requireUser(id); + const posts = db.posts.filter((post) => post.author === id); + const tagged = new Set(db.taggedPostsByUser.get(id) ?? []); + return wireRecord([ + ["user", userWire(id)], + ["bio", wireText(user.bio ?? "")], + ["post_count", wireNat(posts.length)], + [ + "follower_count", + wireNat((db.followersByUser.get(id) ?? []).length), + ], + [ + "following_count", + wireNat((db.followingByUser.get(id) ?? []).length), + ], + [ + "viewer_follows", + wireBool(db.follows.has(edgeKey(viewerId(), id))), + ], + ["posts", wireSeq(posts.map(tileWire))], + [ + "reels", + wireSeq(posts.filter((post) => post.media_kind === "video").map(tileWire)), + ], + [ + "tagged", + wireSeq(db.posts.filter((post) => tagged.has(post.id)).map(tileWire)), + ], + [ + "saved", + wireSeq( + id === viewerId() + ? db.posts.filter((post) => db.saved.has(post.id)).map(tileWire) + : [], + ), + ], + ]); } - /** - * @param {string} query - * @returns {Record} - */ - function searchValue(query: string) { - const needle = query.trim().toLocaleLowerCase(); - const people = [...db.users.values()] + function authorityValue(): WireValue { + const home = feedPosts(); + const visible = home.slice(0, feedCount); + const needle = searchQuery.trim().toLocaleLowerCase(); + const searchPeople = [...db.users.values()] .filter((user) => user.id !== viewerId()) - .filter( - (user) => - needle.length === 0 || - user.username.toLocaleLowerCase().includes(needle) || - user.display_name.toLocaleLowerCase().includes(needle), + .filter((user) => + needle.length === 0 + || user.username.toLocaleLowerCase().includes(needle) + || user.display_name.toLocaleLowerCase().includes(needle) ) .map((user) => user.id); - const posts = db.posts.filter((post) => { - if (needle.length === 0) return true; - const author = requireUser(post.author); - return ( - post.caption.toLocaleLowerCase().includes(needle) || - author.username.toLocaleLowerCase().includes(needle) || - author.display_name.toLocaleLowerCase().includes(needle) - ); - }); - return { - people: connectionsValue(people).people, - posts: posts.map((post) => postThumb(post)), - }; - } - - /** - * @param {string} postId - * @param {boolean} includeThread - * @returns {ProjectionUpdate[]} - */ - function postSettlementUpdates( - postId: string, - includeThread: boolean, - ): ProjectionUpdate[] { - const updates = [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - projectionUpdate( - "feed", - "post-by-id", - postId, - postSummary(requirePost(postId)), - ), - projectionUpdate("feed", "reels", null, reelsValue()), - ]; - if (includeThread) { - updates.push( - projectionUpdate("comments", "for-post", postId, threadValue(postId)), - ); - } - return updates; - } - - /** - * Saving changes every viewer-specific rendering of a post plus the private - * Saved grid on the actor's own profile. - * @param {string} postId - * @returns {ProjectionUpdate[]} - */ - function saveSettlementUpdates(postId: string): ProjectionUpdate[] { - return [ - ...postSettlementUpdates(postId, false), - projectionUpdate( - "profile", - "profile", - viewerId(), - profileValue(viewerId()), - ), - ]; - } - - /** - * A viewed edge changes the ring and every frame's progress strip in that - * author's sequence, so settle them as one authority snapshot. - * @param {string} storyId - * @returns {ProjectionUpdate[]} - */ - function storySettlementUpdates(storyId: string): ProjectionUpdate[] { - const author = requireStory(storyId).author; - return [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ...db.stories - .filter((story) => story.author === author) - .map((story) => - projectionUpdate( - "feed", - "story-by-id", - story.id, - storyValue(story.id), + const users = [...db.users.keys()]; + return wireVariant(AUTHORITY_TYPE, "Ready", [[ + "data", + wireRecord([ + ["viewer", userWire(viewerId())], + [ + "posts", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + postWire(post), + ])), + ], + ["feed_posts", wireSeq(visible.map(postWire))], + ["feed_has_more", wireBool(feedCount < home.length)], + [ + "reels", + wireSeq( + db.posts.filter((post) => post.media_kind === "video").map(postWire), ), - ), - ]; - } - - /** @returns {ProjectionUpdate[]} */ - function allSocialUpdates(): ProjectionUpdate[] { - const updates: ProjectionUpdate[] = [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]; - for (const userId of db.users.keys()) { - updates.push( - projectionUpdate("profile", "profile", userId, profileValue(userId)), - projectionUpdate("profile", "followers", userId, followersValue(userId)), - projectionUpdate("profile", "following", userId, followingValue(userId)), - ); - } - updates.push( - projectionUpdate("profile", "search-results", null, searchValue(searchQuery)), - ); - return updates; + ], + ["stories", wireSeq(storyRingWires())], + [ + "story_details", + wireTextKeyMap(STORY_ID_TYPE, db.stories.map((story) => [ + story.id, + storyDetailWire(story), + ])), + ], + [ + "profiles", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + profileWire(id), + ])), + ], + [ + "followers", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followersByUser.get(id) ?? []), + ])), + ], + [ + "following", + wireTextKeyMap(USER_ID_TYPE, users.map((id) => [ + id, + connectionSequence(db.followingByUser.get(id) ?? []), + ])), + ], + [ + "comments", + wireTextKeyMap(POST_ID_TYPE, db.posts.map((post) => [ + post.id, + wireSeq((db.commentsByPost.get(post.id) ?? []).map(commentWire)), + ])), + ], + ["search_people", connectionSequence(searchPeople)], + ["explore_tiles", wireSeq(db.posts.map(tileWire))], + ]), + ]]); } /** @@ -1515,202 +1573,139 @@ export function createDriver( : `Image uploaded by ${author.display_name}`; } - /** - * @param {ProviderCommand} command - * @param {string} field - * @returns {string} - */ - function payloadString(command: ProviderCommand, field: string): string { - const value = command.payload[field]; - if (typeof value !== "string") { - throw new Error(`command \`${command.port}/${command.command}\` needs string \`${field}\``); - } - return value; - } - - /** - * @param {string} route - * @param {SpockError} error - * @returns {CommandOutcome} - */ - function refuseOrUnavailable( + function refusal( route: string, error: SpockError, - ): CommandOutcome { - const refusal = toRefusalName(error.code ?? ""); - if ((COMMAND_REFUSALS[route] ?? []).includes(refusal)) { - return { refused: { refusal } }; + ): BackendSettlement { + const reason = toRefusalName(error.code ?? ""); + if ((COMMAND_REFUSALS[route] ?? []).includes(reason)) { + return { kind: "refused", reason }; } return { - unavailable: { reason: error.message ?? error.code ?? "provider error" }, + kind: "refused", + reason: error.message ?? error.code ?? "provider-error", }; } - /** - * @param {ProviderCommand} command - * @param {CommandOutcome} result - * @param {ProjectionUpdate[]} [updates] - * @returns {void} - */ - function outcome( - command: ProviderCommand, - result: CommandOutcome, - updates: ProjectionUpdate[] = [], - ): void { - if (disposed) return; - outbox.push( - encode({ - kind: "outcome", - correlation: command.correlation, - outcome: result, - updates, - }), - ); - } - - /** - * @param {ProviderCommand} command - * @param {PickedFile | undefined} pickedFile - * @returns {Promise} - */ async function handle( - command: ProviderCommand, + operation: BackendOperation, pickedFile: PickedFile | undefined, - ): Promise { - const route = `${command.port}/${command.command}`; + ): Promise { try { - switch (route) { - case "feed/like-post": - case "feed/unlike-post": { - const post = payloadString(command, "post"); - const fn = command.command === "like-post" ? "like_post" : "unlike_post"; - const reply = await rpc(fn, { post }); + switch (operation.kind) { + case "set_like": { + const route = operation.liked + ? "feed/like-post" + : "feed/unlike-post"; + const reply = await rpc( + operation.liked ? "like_post" : "unlike_post", + { post: operation.post }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, postSettlementUpdates(post, false)); - return; + return { kind: "accepted" }; } - case "feed/save-post": - case "feed/unsave-post": { - const post = payloadString(command, "post"); - const fn = command.command === "save-post" ? "save_post" : "unsave_post"; - const reply = await rpc(fn, { post }); + case "set_save": { + const route = operation.saved + ? "feed/save-post" + : "feed/unsave-post"; + const reply = await rpc( + operation.saved ? "save_post" : "unsave_post", + { post: operation.post }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, saveSettlementUpdates(post)); - return; + return { kind: "accepted" }; } - case "comments/add-comment": { - const post = payloadString(command, "post"); - const body = payloadString(command, "body"); - const reply = await rpc("add_comment", { post, body }); + case "add_comment": { + const route = "comments/add-comment"; + const reply = await rpc("add_comment", { + post: operation.post, + body: operation.body, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, postSettlementUpdates(post, true)); - return; + return { kind: "accepted" }; } - case "feed/load-next-page": { + case "load_more": { await loadAll(); feedCount = Math.min(feedCount + PAGE_SIZE, feedPosts().length); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]); - return; + return { kind: "accepted" }; } - case "feed/reload": { + case "reload_feed": { feedCount = PAGE_SIZE; await loadAll(); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - ]); - return; + return { kind: "accepted" }; } - case "feed/mark-story-seen": { - const story = payloadString(command, "story"); - const reply = await rpc("mark_story_viewed", { story }); + case "mark_story": { + const route = "feed/mark-story-seen"; + const reply = await rpc("mark_story_viewed", { + story: operation.story, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, storySettlementUpdates(story)); - return; + return { kind: "accepted" }; } - case "profile/follow-user": - case "profile/unfollow-user": { - const user = payloadString(command, "user"); - const fn = command.command === "follow-user" ? "follow_user" : "unfollow_user"; - const reply = await rpc(fn, { target: user }); + case "set_follow": { + const route = operation.following + ? "profile/follow-user" + : "profile/unfollow-user"; + const reply = await rpc( + operation.following ? "follow_user" : "unfollow_user", + { target: operation.user }, + ); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } await loadAll(); - outcome(command, { ok: {} }, allSocialUpdates()); - return; + return { kind: "accepted" }; } - case "profile/search-people": { - searchQuery = payloadString(command, "query"); + case "search_people": { + searchQuery = operation.query; await loadAll(); - outcome(command, { ok: {} }, [ - projectionUpdate( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - ]); - return; + return { kind: "accepted" }; } - case "create/choose-image": { + case "choose_image_request": { if (!pickedFile) { throw new Error("this play host cannot choose local files"); } const picked = await pickedFile; if ("error" in picked) throw picked.error; if (picked.file === null) { - outcome(command, { ok: {} }); - return; + return { kind: "refused", reason: "selection-cancelled" }; } if (!SUPPORTED_IMAGE_TYPES.has(picked.file.type.trim().toLowerCase())) { - outcome(command, { - refused: { refusal: "unsupported-media-type" }, - }); - return; + return { kind: "refused", reason: "unsupported-media-type" }; } const object = await uploadFile(picked.file); uploadedFileNames.set(object, picked.file.name); - outcome(command, { ok: {} }, [ - projectionUpdate("create", "draft", null, { - uploaded: { - object, - preview: object, - name: picked.file.name, - }, - }), - ]); - return; + return { + kind: "image_ready", + object, + preview: object, + name: picked.file.name, + }; } - case "create/publish-image": { - const image = payloadString(command, "image"); - const caption = payloadString(command, "caption"); - const requestedAlt = payloadString(command, "alt"); - const alt = requestedAlt.trim().length > 0 - ? requestedAlt - : fallbackUploadAlt(image); - const reply = await rpc("create_image_post", { image, caption, alt }); + case "publish_image_request": { + const route = "create/publish-image"; + const alt = operation.alt.trim().length > 0 + ? operation.alt + : fallbackUploadAlt(operation.object); + const reply = await rpc("create_image_post", { + image: operation.object, + caption: operation.caption, + alt, + }); if (reply.ok === false) { - outcome(command, refuseOrUnavailable(route, reply.error)); - return; + return refusal(route, reply.error); } if ( typeof reply.result !== "object" || @@ -1720,36 +1715,13 @@ export function createDriver( ) { throw new Error("create_image_post returned no post id"); } - const post = reply.result.id; await loadAll(); - uploadedFileNames.delete(image); - outcome(command, { ok: {} }, [ - projectionUpdate("feed", "feed-page", null, feedPageValue()), - projectionUpdate( - "feed", - "post-by-id", - post, - postSummary(requirePost(post)), - ), - projectionUpdate("comments", "for-post", post, threadValue(post)), - projectionUpdate("profile", "profile", viewerId(), profileValue(viewerId())), - projectionUpdate( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - projectionUpdate("create", "draft", null, { empty: {} }), - ]); - return; + uploadedFileNames.delete(operation.object); + return { kind: "accepted" }; } - default: - outcome(command, { - unavailable: { reason: `no binding for command \`${route}\`` }, - }); } } catch (error) { - outcome(command, { unavailable: { reason: errorMessage(error) } }); + return { kind: "refused", reason: errorMessage(error) }; } } @@ -1769,7 +1741,7 @@ export function createDriver( }; }, - async assembleBoot() { + async load() { await authorityTail; assertLive(); await loadAll(); @@ -1777,63 +1749,15 @@ export function createDriver( const viewer = viewerRow; if (!viewer) throw new Error(`actor \`${actor}\` is not a seeded user`); await verifyViewer(); - - outbox.push(projectionMsg("feed", "feed-page", null, feedPageValue())); - for (const post of db.posts) { - outbox.push( - projectionMsg("comments", "for-post", post.id, threadValue(post.id)), - projectionMsg("feed", "post-by-id", post.id, postSummary(post)), - ); - } - for (const story of db.stories) { - outbox.push( - projectionMsg( - "feed", - "story-by-id", - story.id, - storyValue(story.id), - ), - ); - } - outbox.push(projectionMsg("feed", "reels", null, reelsValue())); - for (const userId of db.users.keys()) { - outbox.push( - projectionMsg("profile", "profile", userId, profileValue(userId)), - projectionMsg("profile", "followers", userId, followersValue(userId)), - projectionMsg("profile", "following", userId, followingValue(userId)), - ); - } - outbox.push( - projectionMsg( - "profile", - "search-results", - null, - searchValue(searchQuery), - ), - ); - outbox.push(projectionMsg("create", "draft", null, { empty: {} })); - - return encode({ - updates: [ - { - port: "feed", - projection: "viewer", - key: null, - revision: 1, - value: userRef(viewer), - }, - ], - }); }, - deliver(commandJson: string) { - if (disposed) return; - const command = JSON.parse(commandJson) as ProviderCommand; + execute(operation: BackendOperation): Promise { + assertLive(); let pickedFile: PickedFile | undefined; - if (`${command.port}/${command.command}` === "create/choose-image") { + if (operation.kind === "choose_image_request") { try { // This must happen in the click's synchronous call stack. Deferring - // it behind commandTail would lose browser user activation. + // it behind the operation queue would lose browser user activation. pickedFile = host.pickFile({ accept: "image/jpeg,image/png,image/webp" }) .then( (file) => ({ file }), @@ -1843,34 +1767,323 @@ export function createDriver( pickedFile = Promise.resolve({ error }); } } - inflight += 1; - // Preserve delivery order inside this driver. Only domain mutations - // enter the cross-driver authority barrier: a picker, upload draft, or - // ordinary read must never strand a later Play boot. - const route = `${command.port}/${command.command}`; - const predecessor = commandTail; - commandTail = predecessor - .then(() => { - if (disposed) return; - const work = () => handle(command, pickedFile); - return AUTHORITY_COMMANDS.has(route) - ? enqueueAuthorityWork(work) - : work(); - }) - .finally(() => { - inflight -= 1; - }); + const work = operationTail.then(() => { + assertLive(); + const run = () => handle(operation, pickedFile); + return AUTHORITY_OPERATIONS.has(operation.kind) + ? enqueueAuthorityWork(run) + : run(); + }); + operationTail = work.then( + () => {}, + () => {}, + ); + return work; }, - tick() { - if (disposed) return []; - return outbox.splice(0, outbox.length); + authorityValue, + resolveAsset, + }; +} + +function wireObject(value: unknown, context: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${context} must be an object`); + } + return value as Record; +} + +function variantFields( + value: WireValue, + type: string, + caseName?: string, +): Map { + if (value.$ !== "variant" || value.type !== type) { + throw new TypeError(`expected Uhura variant ${type}`); + } + if (caseName !== undefined && value.case !== caseName) { + throw new TypeError(`expected Uhura variant ${type}.${caseName}`); + } + if (!Array.isArray(value.fields)) { + throw new TypeError(`Uhura variant ${type} has no fields`); + } + const fields = new Map(); + for (const raw of value.fields) { + const field = wireObject(raw, `${type} field`); + const name = field.name; + if (name !== null && typeof name !== "string") { + throw new TypeError(`${type} field name must be text or null`); + } + const child = wireObject(field.value, `${type} field value`) as WireValue; + if (fields.has(name)) throw new TypeError(`${type} repeats field ${String(name)}`); + fields.set(name, child); + } + return fields; +} + +function requiredField( + fields: ReadonlyMap, + name: string, +): WireValue { + const value = fields.get(name); + if (!value) throw new TypeError(`Uhura value has no field \`${name}\``); + return value; +} + +function keyText(value: WireValue, type: string): string { + if (value.$ !== "key" || value.type !== type) { + throw new TypeError(`expected Uhura key ${type}`); + } + const body = wireObject(value.value, `${type} body`); + if (body.$ !== "Text" || typeof body.value !== "string") { + throw new TypeError(`${type} must wrap Text`); + } + return body.value; +} + +function requestText(value: WireValue): string { + if (value.$ !== "key" || value.type !== REQUEST_ID_TYPE) { + throw new TypeError(`expected Uhura key ${REQUEST_ID_TYPE}`); + } + const body = wireObject(value.value, `${REQUEST_ID_TYPE} body`); + if ( + body.$ !== "PositiveInt" + || typeof body.value !== "string" + || !/^[1-9]\d*$/u.test(body.value) + ) { + throw new TypeError(`${REQUEST_ID_TYPE} must wrap PositiveInt`); + } + return body.value; +} + +function textValue(value: WireValue): string { + if (value.$ !== "Text" || typeof value.value !== "string") { + throw new TypeError("expected Uhura Text"); + } + return value.value; +} + +function boolValue(value: WireValue): boolean { + if (value.$ !== "bool" || typeof value.value !== "boolean") { + throw new TypeError("expected Uhura Bool"); + } + return value.value; +} + +interface AdaptedRequest { + readonly request: WireValue; + readonly operation: BackendOperation; +} + +function adaptRequest(command: WireValue): AdaptedRequest { + const requestFields = variantFields( + command, + MUTATIONS_SEND_TYPE, + "request", + ); + const request = requiredField(requestFields, "id"); + requestText(request); + const payload = requiredField(requestFields, "payload"); + const fields = variantFields(payload, MUTATION_TYPE); + const mutation = String(payload.case); + + switch (mutation) { + case "SetLike": + return { + request, + operation: { + kind: "set_like", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + liked: boolValue(requiredField(fields, "liked")), + }, + }; + case "SetSave": + return { + request, + operation: { + kind: "set_save", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + saved: boolValue(requiredField(fields, "saved")), + }, + }; + case "LoadMore": + return { request, operation: { kind: "load_more" } }; + case "ReloadFeed": + return { request, operation: { kind: "reload_feed" } }; + case "SetFollow": + return { + request, + operation: { + kind: "set_follow", + user: keyText(requiredField(fields, "user"), USER_ID_TYPE), + following: boolValue(requiredField(fields, "following")), + }, + }; + case "AddComment": + return { + request, + operation: { + kind: "add_comment", + post: keyText(requiredField(fields, "post"), POST_ID_TYPE), + body: textValue(requiredField(fields, "body")), + }, + }; + case "SearchPeople": + return { + request, + operation: { + kind: "search_people", + query: textValue(requiredField(fields, "query")), + }, + }; + case "ChooseImage": + return { request, operation: { kind: "choose_image_request" } }; + case "PublishImage": + return { + request, + operation: { + kind: "publish_image_request", + object: textValue(requiredField(fields, "object")), + caption: textValue(requiredField(fields, "caption")), + alt: textValue(requiredField(fields, "alt")), + }, + }; + case "MarkStory": + return { + request, + operation: { + kind: "mark_story", + story: keyText(requiredField(fields, "story"), STORY_ID_TYPE), + }, + }; + default: + throw new TypeError(`unsupported Instagram mutation \`${mutation}\``); + } +} + +function observed(value: WireValue): WireValue { + return wireVariant( + AUTHORITY_RECEIVE_TYPE, + "authority.observed", + [["value", value]], + ); +} + +function refused(reason: string): WireValue { + return wireVariant(SETTLEMENT_TYPE, "Refused", [[ + "reason", + wireText(reason), + ]]); +} + +function settlementValue(result: BackendSettlement): WireValue { + switch (result.kind) { + case "accepted": + return wireVariant(SETTLEMENT_TYPE, "Accepted"); + case "refused": + return refused(result.reason); + case "image_ready": + return wireVariant(SETTLEMENT_TYPE, "ImageReady", [ + ["object", wireText(result.object)], + ["preview", wireText(result.preview)], + ["name", wireText(result.name)], + ]); + } +} + +function settled(request: WireValue, result: WireValue): WireValue { + return wireVariant( + MUTATIONS_RECEIVE_TYPE, + "mutations.settled", + [ + ["id", request], + ["result", result], + ], + ); +} + +function providerConfig( + config: Readonly>, +): SpockProviderConfig { + const value = (name: keyof SpockProviderConfig): string => { + const entry = config[name]; + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new TypeError(`Instagram provider needs nonempty \`${name}\``); + } + return entry; + }; + return { + graphql_url: value("graphql_url"), + rpc_url: value("rpc_url"), + storage_url: value("storage_url"), + actor: value("actor"), + }; +} + +/** + * Current Uhura adapter entry point. Contract identities come from the + * admitted Play deployment; the app provider never calculates or hardcodes + * compiler-owned hashes. + */ +export function createUhuraAdapters( + config: Readonly>, + host: AdapterProviderHost, +): { + readonly adapters: readonly PortAdapter[]; + resolveAsset(asset: string): Promise; + systemInfo(): RemoteSystemInfo; + dispose(): void; +} { + const backend = createSpockBackend(providerConfig(config), host); + const authorityRequirement = host.port("authority"); + const mutationsRequirement = host.port("mutations"); + let authorityContext: PortAdapterContext | null = null; + + const authority: PortAdapter = { + ...authorityRequirement, + async start(context): Promise { + authorityContext = context; + try { + await backend.load(); + context.deliver(observed(backend.authorityValue())); + } catch (error) { + context.deliver( + observed( + wireVariant(AUTHORITY_TYPE, "Failed", [[ + "reason", + wireText(errorMessage(error)), + ]]), + ), + ); + } + }, + accept(): never { + throw new Error("Observation does not accept commands"); }, + }; - idle() { - return inflight === 0 && outbox.length === 0; + const mutations: PortAdapter = { + ...mutationsRequirement, + accept(command, context): Promise { + const adapted = adaptRequest(command); + const work = backend.execute(adapted.operation).then((settlement) => { + const result = settlementValue(settlement); + if ( + result.case === "Accepted" + && adapted.operation.kind !== "choose_image_request" + ) { + authorityContext?.deliver(observed(backend.authorityValue())); + } + context.deliver(settled(adapted.request, result)); + }); + return work; }, + }; - resolveAsset, + return { + adapters: [authority, mutations], + resolveAsset: (asset) => backend.resolveAsset(asset), + systemInfo: () => backend.systemInfo(), + dispose: () => backend.dispose(), }; } diff --git a/examples/instagram/client/styles/theme.css b/examples/instagram/client/styles/theme.css index 23510d7..9af1e05 100644 --- a/examples/instagram/client/styles/theme.css +++ b/examples/instagram/client/styles/theme.css @@ -214,3 +214,588 @@ body { #uh-frame[data-frame="desktop"] .reel-card .reel-overlay { padding-block-end: 58px; } + +/* Instagram application projections. These rules used to be distributed + across v0 page/component files; the current project has one checked UI + module and one deployment-owned stylesheet. */ +.feed-page, +.create-page, +.profile-page, +.search-page, +.post-page, +.connections-page, +.reels-page, +.story-page { + display: flex; + flex-direction: column; + block-size: 100%; +} + +.feed-head, +.create-head, +.detail-bar, +.search-head { + flex: none; + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: 52px; + padding: var(--space-2) var(--space-4); + border-block-end: 1px solid var(--color-line); + background: var(--color-surface); +} + +.feed-head { + justify-content: space-between; +} + +.feed-head .wordmark { + font-family: "Snell Roundhand", "Segoe Script", cursive; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.04em; +} + +.feed-head-actions, +.action-row, +.action-primary, +.create-actions, +.profile-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.feed-head-actions .uh-button, +.icon-action { + min-inline-size: 40px; + min-block-size: 40px; + justify-content: center; +} + +.viewer-avatar, +.avatar { + inline-size: 32px; + block-size: 32px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.feed-scroll, +.post-scroll, +.profile-scroll, +.search-scroll, +.connection-list, +.reels-scroll, +.comment-list, +.create-form { + flex: 1; + min-block-size: 0; +} + +.feed-empty { + min-block-size: 360px; + text-align: center; +} + +.post-list { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.notice, +.notice-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: var(--color-surface-sunken); + border-block-end: 1px solid var(--color-line); +} + +.stories-tray { + flex: none; + border-block-end: 1px solid var(--color-line); +} + +.ring-row { + display: flex; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); +} + +.ring-item { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} + +.ring { + inline-size: 56px; + block-size: 56px; + padding: 2px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.ring.unseen { border: 2px solid var(--color-accent); } +.ring.seen { border: 2px solid var(--color-line); } + +.ring-name { + max-inline-size: 60px; + overflow: hidden; + color: var(--color-ink-subtle); + font-size: var(--type-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.post-card { + display: flex; + flex-direction: column; + gap: var(--space-2); + background: var(--color-surface); +} + +.post-card .author-row { + display: flex; + align-items: center; + gap: var(--space-2); + min-block-size: 48px; + padding: var(--space-2) var(--space-4); +} + +.post-card .username, +.comment-row .username, +.connection-row .username, +.reel-card .username { + font-weight: 600; +} + +.post-card .media, +.post-card .slide { + inline-size: 100%; + aspect-ratio: 1; + object-fit: cover; +} + +.post-card .action-row { + justify-content: space-between; + padding-inline: var(--space-2); +} + +.liked { color: var(--color-accent); } +.save-action { margin-inline-start: auto; } + +.post-card .likes { + padding-inline: var(--space-4); + font-weight: 600; +} + +.caption-row { + display: block; + padding-inline: var(--space-4); +} + +.caption-author { + display: inline; + margin-inline-end: var(--space-2); + font-weight: 600; + white-space: nowrap; +} + +.caption { display: inline; } + +.comments-link, +.post-meta-link { + display: inline-flex; + align-items: center; + inline-size: fit-content; + min-block-size: 32px; + margin-inline: var(--space-4); +} + +.comment-link { + color: var(--color-ink-subtle); + font-size: var(--type-sm); +} + +.posted-label { + color: var(--color-ink-subtle); + font-size: var(--type-xs); + text-transform: uppercase; +} + +.bottom-nav { + z-index: 6; + flex: none; + display: flex; + align-items: center; + min-block-size: 54px; + padding: var(--space-1) var(--space-2); + border-block-start: 1px solid var(--color-line); + background: var(--color-surface); +} + +.bottom-nav > .uh-button { + flex: 1; + justify-content: center; + min-inline-size: 44px; + min-block-size: 44px; + gap: var(--space-1); + padding: var(--space-1); +} + +.bottom-nav > .uh-button > .uh-text { + flex: none; + overflow-wrap: normal; + white-space: nowrap; + font-size: var(--type-xs); +} + +.bottom-nav > .uh-button[aria-current="true"] { + font-weight: 700; +} + +.create-page .upload-mark { + display: grid; + place-items: center; + inline-size: 72px; + block-size: 72px; + border: 1px dashed var(--color-ink-faint); + border-radius: var(--radius-full); +} + +.create-page .upload-mark .uh-icon { font-size: 30px; } +.create-empty-title { font-size: var(--type-lg); font-weight: 600; } + +.create-preview { + inline-size: 100%; + aspect-ratio: 1; + object-fit: cover; + background: var(--color-surface-sunken); +} + +.create-form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.create-form > :not(.create-preview) { + margin-inline: var(--space-4); +} + +.create-actions { + justify-content: space-between; +} + +.create-actions .uh-button { + flex: 1; + justify-content: center; +} + +.technical-id { + overflow-wrap: anywhere; + color: var(--color-ink-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--type-xs); +} + +.profile-header { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4); +} + +.profile-header .head-row { + display: flex; + align-items: center; + gap: var(--space-6); +} + +.profile-avatar { + inline-size: 80px; + block-size: 80px; + border-radius: var(--radius-full); + object-fit: cover; +} + +.profile-header .stats { + flex: 1; + display: flex; + justify-content: space-around; +} + +.profile-header .stat { + display: flex; + flex-direction: column; + align-items: center; +} + +.stat-num, +.display-name { font-weight: 600; } +.stat-name, +.profile-handle { color: var(--color-ink-subtle); font-size: var(--type-xs); } +.bio { font-size: var(--type-sm); } + +.profile-action { + flex: 1; + justify-content: center; + min-block-size: 38px; +} + +.profile-tabs { + display: flex; + justify-content: space-around; + border-block: 1px solid var(--color-line); +} + +.profile-tabs .uh-button { + flex: 1; + justify-content: center; + min-block-size: 44px; + border-radius: 0; +} + +.profile-tabs .uh-button[aria-current="true"] { + box-shadow: inset 0 -2px 0 var(--color-ink); +} + +.profile-grid, +.explore-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 2px; +} + +.profile-grid .grid-tile, +.explore-grid .grid-tile { + inline-size: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; +} + +.search-head { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); +} + +.connection-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-block-end: 1px solid var(--color-line); +} + +.connection-person, +.connection-copy { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.connection-copy { + flex: 1; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); +} + +.connection-row .avatar { + inline-size: 48px; + block-size: 48px; +} + +.reels-page { + position: relative; + color: var(--color-on-media); + background: #050505; +} + +.reels-page .muted { color: rgb(255 255 255 / 80%); } + +.reels-scroll { + scroll-behavior: smooth; + scroll-snap-type: y mandatory; + overscroll-behavior-y: contain; + scrollbar-width: none; +} + +.reel-card { + position: relative; + display: grid; + min-block-size: 100%; + overflow: hidden; + color: var(--color-on-media); + background: #050505; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.reel-card > * { grid-area: 1 / 1; } + +.reel-media { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.reel-overlay { + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-6) var(--space-3) 118px var(--space-4); + background: linear-gradient(to bottom, transparent 38%, rgb(0 0 0 / 72%)); +} + +.reel-copy, +.reel-actions { + pointer-events: auto; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.reel-copy { + max-inline-size: calc(100% - 76px); + text-shadow: 0 1px 2px rgb(0 0 0 / 65%); +} + +.reel-author { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.reel-actions { align-items: center; } + +.story-page { + color: var(--color-on-media); + background: var(--color-ink); +} + +.story-page .muted { color: rgb(255 255 255 / 80%); } + +.story-stage { + flex: 1; + display: grid; + min-block-size: 0; +} + +.story-stage > * { grid-area: 1 / 1; } + +.story-image { + inline-size: 100%; + block-size: 100%; + object-fit: cover; +} + +.story-progress { + z-index: 2; + display: flex; + gap: 3px; + block-size: fit-content; + padding: var(--space-3); +} + +.story-segment { + flex: 1; + block-size: 2px; + border-radius: var(--radius-full); + background: rgb(255 255 255 / 36%); +} + +.story-segment.current, +.story-segment.viewed { background: #fff; } + +.story-head { + z-index: 2; + display: flex; + align-items: center; + gap: var(--space-2); + block-size: fit-content; + margin-block-start: var(--space-6); + padding: var(--space-3); + text-shadow: 0 1px 2px rgb(0 0 0 / 70%); +} + +.story-head .uh-button { margin-inline-start: auto; color: #fff; } + +.story-caption { + z-index: 2; + align-self: end; + max-inline-size: 80%; + margin-block-end: var(--space-8); + padding: var(--space-3); + text-shadow: 0 1px 2px rgb(0 0 0 / 80%); +} + +.story-hit-zones { + z-index: 1; + display: grid; + grid-template-columns: 1fr 1fr; +} + +.story-hit-zones .uh-button { + min-block-size: 100%; + color: transparent; + border-radius: 0; +} + +.comments-sheet { + display: flex; + flex-direction: column; + block-size: 100%; + background: var(--color-surface); +} + +.comments-sheet .sheet-head, +.comments-sheet .composer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); +} + +.comments-sheet .sheet-head { + justify-content: space-between; + border-block-end: 1px solid var(--color-line); +} + +.comments-sheet .composer { + border-block-start: 1px solid var(--color-line); +} + +.comments-sheet .composer .uh-textfield { flex: 1; } +.sheet-title, +.empty-title { font-weight: 600; } + +.comment-row { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); +} + +.comment-row .avatar, +.composer .avatar { + inline-size: 28px; + block-size: 28px; +} + +.comment-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pending { opacity: 0.55; } diff --git a/examples/instagram/client/surfaces/comments-sheet.examples.uhura b/examples/instagram/client/surfaces/comments-sheet.examples.uhura deleted file mode 100644 index b5e1c6e..0000000 --- a/examples/instagram/client/surfaces/comments-sheet.examples.uhura +++ /dev/null @@ -1,42 +0,0 @@ -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 -} - -example empty-composing { - from empty - events [ composer-changed(value: "First comment") ] -} - -example empty-pending { - from empty-composing - events [ submit-requested() ] - note "the optimistic row replaces the empty state and serializes submission" -} - -example rejected { - from empty-pending - events [ outcome add-comment.err(reason: "comment_body_invalid") ] - note "a refusal restores the submitted body for correction" -} diff --git a/examples/instagram/client/surfaces/comments-sheet.uhura b/examples/instagram/client/surfaces/comments-sheet.uhura deleted file mode 100644 index 5aead7a..0000000 --- a/examples/instagram/client/surfaces/comments-sheet.uhura +++ /dev/null @@ -1,115 +0,0 @@ -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 = {} - comment-pending: bool = false - notice: text? = none - } - - on composer-changed(value: text) { - set draft = value - } - - on submit-requested() when draft != "" && !comment-pending { - send add-comment(post: post, body: draft) as t - set pending-appends[t] = draft - set comment-pending = true - set draft = "" - } - - on add-comment.ok(tag, cmd) { - set pending-appends[tag] = none - set comment-pending = false - } - - on add-comment.err(tag, cmd, refusal) { - set pending-appends[tag] = none - set comment-pending = false - 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 && count(pending-appends) == 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/client/uhura.lock b/examples/instagram/client/uhura.lock deleted file mode 100644 index b43a527..0000000 --- a/examples/instagram/client/uhura.lock +++ /dev/null @@ -1,9 +0,0 @@ -# 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.3.0 sha256:5a8957419d5b25051a93834888385117bb1f1a03424f9f5115875e28ceaac8ec -icon-glyphs lucide sha256:4b8c4c4d25a12009c031d2d3db86e978a8f0624f1c92fe672650daee9aac3643 -icon-font lucide sha256:ac8e910a948c000ad075c8ebc7c429f066f68b87a4fbf6bce2d911588102c403 -port comments 0.1.0 sha256:3ab0bd261953917213e4932eb3ca62731f25452183b713a7776f61ccc2ffadc8 -port create 0.1.0 sha256:ff8666517391bcd8d87efcbfb2d1862af25ec9757f2fb280918fd852e5eb0731 -port feed 0.1.0 sha256:8a34a68363cc0492734a233c04bd0bbfd6d73cf4589ede56658a1743361e600d -port profile 0.1.0 sha256:73e13ebb6b0cbb11b5331523430ca2f635f3d335456cfb1baa6f3307c12ae814 diff --git a/examples/instagram/client/uhura.toml b/examples/instagram/client/uhura.toml index 74808e3..bf412e9 100644 --- a/examples/instagram/client/uhura.toml +++ b/examples/instagram/client/uhura.toml @@ -1,41 +1,20 @@ -# App manifest (design §3): entry route, catalog pin, port bindings, -# fixtures, and play profiles. Paths are corpus-relative. +[project] +name = "app.instagram" +version = 1 +language = "0.4" -[app] -name = "instagram" -entry = "feed" +[modules] +instagram = "machine.uhura" +parts = "parts.uhura" +ui = "ui.uhura" -[catalog] -path = "catalog/base.toml" - -[ports] -feed = "ports/feed.port.toml" -comments = "ports/comments.port.toml" -profile = "ports/profile.port.toml" -create = "ports/create.port.toml" - -[fixtures] -standard = "fixtures/standard.toml" +[evidence] +sources = ["evidence.uhura"] +# Live instance identity, presentation, lifetime, and port bindings belong to +# `host.toml`. [assets] manifest = "fixtures/assets/manifest.toml" -# `uhura play`/`uhura trace` profiles: which fixture data + script to drive. -[play.default] -fixture = "standard" -script = "demo" -# This strict script is a deterministic preview/trace walkthrough, not a -# complete interactive backend. Browser Play is therefore Spock-only. -allow_fixture = false - -# The browser play shell uses only the live provider. The fixture and -# script above remain the deterministic source for checks, canvas examples, -# and `uhura trace`. -[play.default.provider] -module = "providers/dist/spock.js" - -[play.default.provider.config] -graphql_url = "http://127.0.0.1:4000/graphql/v1" -rpc_url = "http://127.0.0.1:4000/rest/v1/rpc" -storage_url = "http://127.0.0.1:4000/storage/v1" -actor = "10000000-0000-4000-8000-000000000001" +[icons] +default = "lucide" diff --git a/examples/instagram/client/ui.uhura b/examples/instagram/client/ui.uhura new file mode 100644 index 0000000..bc79ae2 --- /dev/null +++ b/examples/instagram/client/ui.uhura @@ -0,0 +1,2511 @@ +use uhura::ui; +use crate::instagram::{AppData, Authority, Comment, Connection, Instagram, Media, Page, Post, PostId, Profile, ProfileTab, Section, SearchStatus, FeedStatus, StoryDetail, StoryId, Tile, Upload, UserId, KENJI, LENA, LENA_GLAZE, LENA_PROFILE, LENA_STORY, MIRA, NILS, POST_LENA_GLAZE, USER_MIRA, USER_NILS}; +use uhura::ui_surface::Surface; + +pub fn post_for(data: AppData, id: PostId) -> Post { + match data.posts.get(id) { + Some(post) => post, + None => LENA_GLAZE, + } +} + +pub fn profile_for(data: AppData, user: UserId) -> Profile { + match data.profiles.get(user) { + Some(profile) => profile, + None => LENA_PROFILE, + } +} + +pub fn comments_for(data: AppData, post: PostId) -> Seq { + match data.comments.get(post) { + Some(comments) => comments, + None => [], + } +} + +pub fn followers_for(data: AppData, user: UserId) -> Seq { + match data.followers.get(user) { + Some(connections) => connections, + None => [], + } +} + +pub fn following_for(data: AppData, user: UserId) -> Seq { + match data.following.get(user) { + Some(connections) => connections, + None => [], + } +} + +pub fn story_for(data: AppData, story: StoryId) -> StoryDetail { + match data.story_details.get(story) { + Some(detail) => detail, + None => LENA_STORY, + } +} + +pub fn effective_post_flag(source: Bool, overlay: Map, post: PostId) -> Bool { + match overlay.get(post) { + Some(value) => value, + None => source, + } +} + +pub fn effective_follow(source: Bool, overlay: Map, user: UserId) -> Bool { + match overlay.get(user) { + Some(value) => value, + None => source, + } +} + +pub fn post_for_page(data: AppData, page: Page) -> Post { + match page { + Page::Post { + id, + } => post_for(data, id), + _ => LENA_GLAZE, + } +} + +pub fn profile_for_page(data: AppData, page: Page) -> Profile { + match page { + Page::Profile { + user, + } => profile_for(data, user), + Page::Followers { + user, + } => profile_for(data, user), + Page::Following { + user, + } => profile_for(data, user), + _ => LENA_PROFILE, + } +} + +pub fn story_for_page(data: AppData, page: Page) -> StoryDetail { + match page { + Page::Story { + id, + } => story_for(data, id), + _ => LENA_STORY, + } +} + +pub fn tiles_for(profile: Profile, tab: ProfileTab) -> Seq { + match tab { + ProfileTab::Posts => profile.posts, + ProfileTab::Reels => profile.reels, + ProfileTab::Tagged => profile.tagged, + ProfileTab::Saved => profile.saved, + } +} + +pub fn show_navigation(page: Page) -> Bool { + match page { + Page::None => false, + Page::Story { + .., + } => false, + _ => true, + } +} + +pub fn feed_current(page: Page) -> Bool { + match page { + Page::Feed => true, + _ => false, + } +} + +pub fn search_current(page: Page) -> Bool { + match page { + Page::Search => true, + _ => false, + } +} + +pub fn create_current(page: Page) -> Bool { + match page { + Page::Create => true, + _ => false, + } +} + +pub fn reels_current(page: Page) -> Bool { + match page { + Page::Reels => true, + _ => false, + } +} + +pub fn profile_current(page: Page) -> Bool { + match page { + Page::Profile { + .., + } | Page::Followers { + .., + } | Page::Following { + .., + } => true, + _ => false, + } +} + +pub fn like_label(liked: Bool) -> Text { + if liked { + "Unlike" + } else { + "Like" + } +} + +pub fn save_label(saved: Bool) -> Text { + if saved { + "Remove from saved" + } else { + "Save post" + } +} + +pub fn follow_label(following: Bool) -> Text { + if following { + "Unfollow" + } else { + "Follow" + } +} + +pub fn profile_tabs_label(tab: ProfileTab) -> Text { + match tab { + ProfileTab::Posts => "Posts", + ProfileTab::Reels => "Reels", + ProfileTab::Tagged => "Tagged", + ProfileTab::Saved => "Saved", + } +} + +pub ui FeedPage for Instagram(view) { + + {#if view.page is Page::None} + + Opening Instagram… + + {:else} + {#if view.page is Page::Feed} + + + Instagram + + + {#if view.authority is Authority::Ready { + data: data, + }} + OpenProfile(data.viewer.id) + > + {data.viewer.avatar.alt} + + {:else} + + {/if} + + + {#if view.notice is Some(message)} + + {message} + + + {:else} + {#if view.notice is None} + + {/if} + {/if} + {#if view.authority is Authority::Loading} + + Loading your feed… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Your feed didn't load. + + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + FeedNearEnd + > + + + {#each data.stories as story (story.id)} + OpenStory(story.id) + > + + {story.user.avatar.alt} + {story.user.username} + + + {/each} + + + {#if data.feed_posts.size == 0} + + Nothing new yet + Posts from people you follow will appear here. + + {/if} + + {#each data.feed_posts as post (post.id)} + + OpenProfile(post.author.id) + > + + {post.author.avatar.alt} + {post.author.username} + + + {#if post.media is Media::Image { + image: media, + }} + ToggleLike(post.id, true) + > + {media.alt} + + {:else} + {#if post.media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {#if view.feed_status is FeedStatus::Loading} + Loading more… + {:else} + {#if view.feed_status is FeedStatus::Failed} + + Couldn't load more. + + + {:else} + + {/if} + {/if} + {#if !data.feed_has_more} + You're all caught up. + {/if} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Search} + + + SearchChanged(event.text) + /> + + + {#if view.authority is Authority::Loading} + + Loading Explore… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Search isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if view.search_status is SearchStatus::Explore} + + {#each data.explore_tiles as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + {:else} + {#if view.search_status is SearchStatus::Searching} + + Searching… + + {:else} + {#if view.search_status is SearchStatus::Results} + + {#each data.search_people as person (person.user.id)} + OpenProfile(person.user.id) + > + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {/each} + + {:else} + {#if view.search_status is SearchStatus::NoResults} + + No results + Try another username. + + {/if} + {/if} + {/if} + {/if} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Create} + + + + New post + + {#if view.upload is Upload::Empty} + + + + + Share a photo + Choose a JPEG, PNG, or WebP image from this device. + + + {:else} + {#if view.upload is Upload::Choosing} + + Choosing a photo… + + {:else} + {#if view.upload is Upload::Uploaded { + object: object, + preview: preview, + name: name, + }} + + {name} + CaptionChanged(event.text) + /> + AltChanged(event.text) + /> + + {object} + + {:else} + {#if view.upload is Upload::Publishing { + preview: preview, + name: name, + .., + }} + + {name} + Publishing… + + {/if} + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Reels} + + {#if view.authority is Authority::Loading} + + Loading Reels… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Reels aren't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each data.reels as post (post.id)} + + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Post { + id: id, + }} + + + + Post + + {#if view.authority is Authority::Loading} + + Loading post… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This post isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + {post_for(data, + {post_for(data, id).author.username} + {#if post_for(data, id).media is Media::Image { + image: media, + }} + {media.alt} + {:else} + {#if post_for(data, id).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for(data, id).media is Media::Video { + src: src, + poster: poster, + }} + + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Profile { + user: user, + }} + + {#if view.authority is Authority::Loading} + + Loading profile… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This profile isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {profile_for(data, + + {profile_for(data, user).post_count} Posts + + + + + {profile_for(data, user).user.display_name} + {profile_for(data, user).user.username} + {profile_for(data, user).bio} + {#if user != data.viewer.id} + + {/if} + {#if user == data.viewer.id} + + {/if} + + + + + + {#if user == data.viewer.id} + + {/if} + + + {#each tiles_for(profile_for(data, user), view.profile_tab) as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + + {/if} + {/if} + {/if} + + {:else} + {#if view.page is Page::Followers { + user: user, + }} + + + + Followers + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each followers_for(data, user) as person (person.user.id)} + + OpenProfile(person.user.id) + > + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {#if person.user.id != data.viewer.id} + + {/if} + + {/each} + + {:else} + + Loading followers… + + {/if} + + {:else} + {#if view.page is Page::Following { + user: user, + }} + + + + Following + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each following_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + {#if person.user.id != data.viewer.id} + + {/if} + + {/each} + + {:else} + + Loading following… + + {/if} + + {:else} + {#if view.page is Page::Story { + id: id, + }} + + {#if view.authority is Authority::Ready { + data: data, + }} + + {story_for(data, + + {#each story_for(data, id).progress as segment (segment.id)} + + {/each} + + + {story_for(data, + {story_for(data, id).author.username} + {story_for(data, id).posted_label} + + + {story_for(data, id).caption} + + {#if story_for(data, id).previous is Some(previous)} + + {:else} + {#if story_for(data, id).previous is None} + + {/if} + {/if} + {#if story_for(data, id).next is Some(next)} + + {:else} + {#if story_for(data, id).next is None} + + {/if} + {/if} + + + {:else} + + Loading story… + + {/if} + + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {#if show_navigation(view.page)} + + + + + + + + {/if} + {#if view.comments_post is Some(post)} + + + + Comments + + + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each comments_for(data, post) as comment (comment.id)} + + {comment.author.avatar.alt} + + {comment.author.username} + {comment.body} + {comment.posted_label} + + + {/each} + {#if view.pending_comment is Some((_, body))} + + {data.viewer.avatar.alt} + + {data.viewer.username} + {body} + Posting… + + + {:else} + {#if view.pending_comment is None} + + {/if} + {/if} + + + {data.viewer.avatar.alt} + CommentChanged(event.text) + /> + + + {:else} + + Loading comments… + + {/if} + + + {:else} + {#if view.comments_post is None} + + {/if} + {/if} + +} + +pub ui CreatePage for Instagram(view) { + + + + New post + + {#if view.authority is Authority::Loading} + + Preparing the uploader… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This uploader isn't available. + + {:else} + {#if view.authority is Authority::Ready { + .., + }} + {#if view.upload is Upload::Empty} + + + + + Share a photo + Choose a JPEG, PNG, or WebP image from this device. + + + {:else} + {#if view.upload is Upload::Choosing} + + Choosing a photo… + + {:else} + {#if view.upload is Upload::Uploaded { + object: object, + preview: preview, + name: name, + }} + + {name} + {name} + CaptionChanged(event.text) + /> + AltChanged(event.text) + /> + + + + + {object} + + {:else} + {#if view.upload is Upload::Publishing { + preview: preview, + name: name, + .., + }} + + {name} + Publishing… + + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + {/if} + +} + +pub ui PostPage for Instagram(view) { + + + + Post + + {#if view.authority is Authority::Loading} + + Loading post… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This post isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {post_for_page(data, + {post_for_page(data, view.page).author.username} + + {#if post_for_page(data, view.page).media is Media::Image { + image: media, + }} + {media.alt} + {:else} + {#if post_for_page(data, view.page).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + + {/if} + {/if} + {/if} + +} + +pub ui ProfilePage for Instagram(view) { + + {#if view.authority is Authority::Loading} + + Loading profile… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This profile isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + + + {profile_for_page(data, + + {profile_for_page(data, view.page).post_count} Posts + {profile_for_page(data, view.page).follower_count} Followers + {profile_for_page(data, view.page).following_count} Following + + + {profile_for_page(data, view.page).user.display_name} + {profile_for_page(data, view.page).user.username} + {profile_for_page(data, view.page).bio} + + + + + + + + + {#each tiles_for(profile_for_page(data, view.page), view.profile_tab) as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + + {/if} + {/if} + {/if} + +} + +pub ui FollowersPage for Instagram(view) { + + + + Followers + + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.page is Page::Followers { + user: user, + }} + + {#each followers_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {/each} + + {:else} + + Loading followers… + + {/if} + {:else} + + Loading followers… + + {/if} + +} + +pub ui FollowingPage for Instagram(view) { + + + + Following + + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.page is Page::Following { + user: user, + }} + + {#each following_for(data, user) as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + + {/each} + + {:else} + + Loading following… + + {/if} + {:else} + + Loading following… + + {/if} + +} + +pub ui ReelsPage for Instagram(view) { + + {#if view.authority is Authority::Loading} + + Loading Reels… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Reels aren't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#each data.reels as post (post.id)} + + {#if post.media is Media::Video { + src: src, + poster: poster, + }} + + {/each} + + {/if} + {/if} + {/if} + +} + +pub ui SearchPage for Instagram(view) { + + + SearchChanged(event.text) + /> + + + {#if view.authority is Authority::Loading} + + Loading Explore… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + Search isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if view.search_status is SearchStatus::Explore} + {#if data.explore_tiles.size == 0} + + Explore is empty. + + {/if} + + {#each data.explore_tiles as tile (tile.post)} + OpenPost(tile.post) + > + {tile.image.alt} + + {/each} + + {:else} + {#if view.search_status is SearchStatus::Searching} + + Searching… + + {:else} + {#if view.search_status is SearchStatus::Results} + + {#each data.search_people as person (person.user.id)} + + {person.user.avatar.alt} + + {person.user.username} + {person.user.display_name} + + + {/each} + + {:else} + {#if view.search_status is SearchStatus::NoResults} + + No results + Try another username. + + {/if} + {/if} + {/if} + {/if} + + {/if} + {/if} + {/if} + +} + +pub ui StoryPage for Instagram(view) { + + {#if view.authority is Authority::Loading} + + Loading story… + + {:else} + {#if view.authority is Authority::Failed { + .., + }} + + This story isn't available. + + {:else} + {#if view.authority is Authority::Ready { + data: data, + }} + + {story_for_page(data, + + {#each story_for_page(data, view.page).progress as segment (segment.id)} + + {/each} + + + {story_for_page(data, + {story_for_page(data, view.page).author.username} + {story_for_page(data, view.page).posted_label} + + + {story_for_page(data, view.page).caption} + + {#if story_for_page(data, view.page).previous is Some(previous)} + + {:else} + {#if story_for_page(data, view.page).previous is None} + + {/if} + {/if} + {#if story_for_page(data, view.page).next is Some(next)} + + {:else} + {#if story_for_page(data, view.page).next is None} + + {/if} + {/if} + + + {/if} + {/if} + {/if} + +} + +pub ui BottomNav for Instagram(view) { + + + + + + + +} + +pub ui CommentRow for Instagram(view) { + + {#if view.pending_comment is Some((_, body))} + {MIRA.avatar.alt} + + {MIRA.username} + {body} + Posting… + + {:else} + {#if view.pending_comment is None} + {KENJI.avatar.alt} + + {KENJI.username} + That copper red is unreal. What cone are you firing to? + 1h + + {/if} + {/if} + +} + +pub ui ConnectionRow for Instagram(view) { + + {#if view.page is Page::Followers { + user: user, + }} + {#if user == USER_MIRA} + {NILS.avatar.alt} + + {NILS.username} + {NILS.display_name} + + + {/if} + {#if user != USER_MIRA} + {LENA.avatar.alt} + + {LENA.username} + {LENA.display_name} + + + {/if} + {:else} + {LENA.avatar.alt} + + {LENA.username} + {LENA.display_name} + + + {/if} + +} + +pub ui NoticeBar for Instagram(view) { + + {#if view.notice is Some(message)} + {message} + {:else} + {#if view.notice is None} + Couldn't like this post. Try again. + {/if} + {/if} + + +} + +pub ui PostCard for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {post_for_page(data, + {post_for_page(data, view.page).author.username} + + {#if post_for_page(data, view.page).media is Media::Image { + image: media, + }} + ToggleLike(post_for_page(data, view.page).id, true) + > + {media.alt} + + {:else} + {#if post_for_page(data, view.page).media is Media::Carousel { + images: slides, + }} + + {#each slides as slide (slide.src)} + {slide.alt} + {/each} + + {:else} + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + {:else} + + Loading post… + + {/if} +} + +pub ui ProfileHeader for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {profile_for_page(data, + + + {profile_for_page(data, view.page).post_count} + Posts + + + {profile_for_page(data, view.page).follower_count} + Followers + + + {profile_for_page(data, view.page).following_count} + Following + + + + {profile_for_page(data, view.page).user.display_name} + {profile_for_page(data, view.page).user.username} + {profile_for_page(data, view.page).bio} + + {#if profile_for_page(data, view.page).user.id == data.viewer.id} + + {/if} + {#if profile_for_page(data, view.page).user.id != data.viewer.id} + + {/if} + + + {:else} + + Loading profile… + + {/if} +} + +pub ui ReelCard for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + {#if post_for_page(data, view.page).media is Media::Video { + src: src, + poster: poster, + }} + + {:else} + + Loading reel… + + {/if} +} + +pub ui StoriesTray for Instagram(view) { + {#if view.authority is Authority::Ready { + data: data, + }} + + + {#each data.stories as story (story.id)} + OpenStory(story.id) + > + + {story.user.avatar.alt} + {story.user.username} + + + {/each} + + + {:else} + + Loading stories… + + {/if} +} + +pub ui CommentsSheet for Instagram(view) { + + + + Comments + + + {#if view.notice is Some(message)} + + {message} + + {:else} + {#if view.notice is None} + + {/if} + {/if} + {#if view.authority is Authority::Ready { + data: data, + }} + {#if view.comments_post is Some(post)} + + {#if comments_for(data, post).size == 0 && view.pending_comment == None} + + No comments yet + Start the conversation. + + {/if} + {#each comments_for(data, post) as comment (comment.id)} + + {comment.author.avatar.alt} + + {comment.author.username} + {comment.body} + {comment.posted_label} + + + {/each} + {#if view.pending_comment is Some((_, body))} + + {data.viewer.avatar.alt} + + {data.viewer.username} + {body} + Posting… + + + {:else} + {#if view.pending_comment is None} + + {/if} + {/if} + + + {data.viewer.avatar.alt} + CommentChanged(event.text) + /> + + + {:else} + {#if view.comments_post is None} + + Comments are closed. + + {/if} + {/if} + {:else} + + Loading comments… + + {/if} + + +} diff --git a/examples/programs/answers/uhura-0.4/README.md b/examples/programs/answers/uhura-0.4/README.md new file mode 100644 index 0000000..3ecb394 --- /dev/null +++ b/examples/programs/answers/uhura-0.4/README.md @@ -0,0 +1,23 @@ +# Uhura 0.4 answer to L0–L2 + +- **Status:** Executable incubation-candidate answer +- **Language:** Uhura 0.4 incubation candidate +- **Problem authority:** [L0–L2 program harnesses](../../) +- **Specification:** [Uhura 0.4](../../../../docs/spec/drafts/0.4/) + +[programs.uhura](programs.uhura) is the complete executable source fixture +against which the 0.4 grammar, formatter, checker, lowering, and differential +runtime behavior are tested. It answers: + +- L0 Bounded Counter; +- L1 River Crossing; and +- L2 Keyed Task Supervisor. + +The 0.4 frontend parses, formats, checks, lowers, executes, checkpoints, and +replays this file. Differential tests compare its committed state, +observations, outcomes, commands, faults, and replay behavior with the retained +0.3 answer. It remains subordinate to the language-neutral problems. + +The answer deliberately contains no UI, framework feature, host adapter, or +widget. It tests the standalone machine core. Its project identity and +single-file logical-module map are fixed by [uhura.toml](uhura.toml). diff --git a/examples/programs/answers/uhura-0.4/programs.uhura b/examples/programs/answers/uhura-0.4/programs.uhura new file mode 100644 index 0000000..e51a79c --- /dev/null +++ b/examples/programs/answers/uhura-0.4/programs.uhura @@ -0,0 +1,492 @@ +pub machine BoundedCounter { + config { + minimum: Int, + maximum: Int, + initial: Int, + } + + require minimum <= initial && initial <= maximum; + + events { + Increment, + Decrement, + Reset, + } + + outcomes { + commit Accepted, + } + + state { + count: Int = initial, + } + + invariant minimum <= count && count <= maximum; + + observe { + count, + at_minimum: count == minimum, + at_maximum: count == maximum, + } + + on Increment { + count = min(count + 1, maximum); + Accepted + } + + on Decrement { + count = max(count - 1, minimum); + Accepted + } + + on Reset { + count = initial; + Accepted + } +} + +enum Side { + Left, + Right, +} + +enum Entity { + Farmer, + Wolf, + Goat, + Cabbage, +} + +enum Cargo { + Wolf, + Goat, + Cabbage, +} + +enum Violation { + WolfWithGoat, + GoatWithCabbage, +} + +enum RiverStatus { + InProgress, + Solved, +} + +struct Crossing { + passenger: Option, + departure: Side, + arrival: Side, +} + +enum Refusal { + PassengerNotWithFarmer { + passenger: Cargo, + }, + Unsafe { + violations: NonEmpty, + }, +} + +const INITIAL_POSITIONS: Table = Table::from([ + (Entity::Farmer, Side::Left), + (Entity::Wolf, Side::Left), + (Entity::Goat, Side::Left), + (Entity::Cabbage, Side::Left), +]); + +fn entity(cargo: Cargo) -> Entity { + match cargo { + Cargo::Wolf => Entity::Wolf, + Cargo::Goat => Entity::Goat, + Cargo::Cabbage => Entity::Cabbage, + } +} + +fn opposite(side: Side) -> Side { + match side { + Side::Left => Side::Right, + Side::Right => Side::Left, + } +} + +fn violations(at: Table) -> Seq { + Seq::from_options([ + if at[Entity::Wolf] == at[Entity::Goat] + && at[Entity::Farmer] != at[Entity::Wolf] + { + Some(Violation::WolfWithGoat) + } else { + None + }, + if at[Entity::Goat] == at[Entity::Cabbage] + && at[Entity::Farmer] != at[Entity::Goat] + { + Some(Violation::GoatWithCabbage) + } else { + None + }, + ]) +} + +pub machine RiverCrossing { + events { + Cross(passenger: Option), + } + + outcomes { + commit Accepted(crossing: Crossing), + abort Refused(reason: Refusal), + } + + state { + positions: Table = INITIAL_POSITIONS, + } + + invariant violations(positions).is_empty(); + + observe { + positions, + status: if positions.values().all(|side| side == Side::Right) { + RiverStatus::Solved + } else { + RiverStatus::InProgress + }, + } + + on Cross(passenger) { + let departure = positions[Entity::Farmer]; + + if passenger is Some(cargo) + && positions[entity(cargo)] != departure + { + return Refused(Refusal::PassengerNotWithFarmer { + passenger: cargo, + }); + } + + let arrival = opposite(departure); + let farmer_moved = positions.set(Entity::Farmer, arrival); + let candidate = match passenger { + None => farmer_moved, + Some(cargo) => farmer_moved.set(entity(cargo), arrival), + }; + + match NonEmpty::checked_from(violations(candidate)) { + Some(harms) => Refused(Refusal::Unsafe { + violations: harms, + }), + None => { + positions = candidate; + Accepted(Crossing { + passenger, + departure, + arrival, + }) + }, + } + } +} + +pub key TaskId(Text); + +enum Terminal { + Success, + Failure, +} + +enum Phase { + Queued, + Running { + attempt: PositiveInt, + progress: Ratio, + }, + Succeeded, + Failed, + Cancelled, +} + +struct Task { + phase: Phase, + started: Nat, +} + +struct Running { + task: TaskId, + attempt: PositiveInt, + progress: Ratio, +} + +pub machine KeyedTaskSupervisor { + const LIMIT: Nat = 2; + + events { + Submit(task: TaskId), + Cancel(task: TaskId), + Retry(task: TaskId), + Progress(task: TaskId, attempt: Int, value: BoundaryNumber), + Succeed(task: TaskId, attempt: Int), + Fail(task: TaskId, attempt: Int), + } + + commands { + Start(task: TaskId, attempt: PositiveInt), + Cancel(task: TaskId, attempt: PositiveInt), + } + + outcomes { + commit Accepted, + abort Duplicate, + abort Stale, + abort Invalid, + } + + state { + tasks: Map = Map::empty(), + queue: Seq = [], + } + + computed running_count: Nat = + tasks.values().count(|task| task.phase is Phase::Running { .. }); + + computed running: Set = + Set::filter_map(tasks.entries(), |entry| + match entry.value.phase { + Phase::Running { attempt, progress } => Some(Running { + task: entry.key, + attempt, + progress, + }), + _ => None, + } + ); + + computed available_capacity: Nat = LIMIT - running_count; + + invariant { + running_count <= LIMIT, + queue.is_unique(), + queue.all(|id| tasks.get(id) is Some(Task { + phase: Phase::Queued, + .. + })), + tasks.entries().all(|entry| + (entry.value.phase is Phase::Queued) == queue.contains(entry.key) + ), + queue.is_empty() || running_count == LIMIT, + tasks.values().all(|task| + match task.phase { + Phase::Running { attempt, .. } => task.started == attempt, + _ => true, + } + ), + } + + observe { + tasks, + queue, + running, + available_capacity, + } + + update resolve_terminal( + id: TaskId, + attempt: Int, + terminal: Terminal, + ) -> Outcome { + if attempt <= 0 { + return Invalid; + } + + let task = match tasks.get(id) { + None => return Invalid, + Some(task) => task, + }; + + if attempt > task.started { + return Invalid; + } + + if attempt < task.started { + return Stale; + } + + match task.phase { + Phase::Running { + attempt: current_attempt, + .. + } => { + if current_attempt != attempt { + unreachable; + } + + let phase = match terminal { + Terminal::Success => Phase::Succeeded, + Terminal::Failure => Phase::Failed, + }; + + tasks = tasks.put(id, Task { + phase, + ..task + }); + Accepted + }, + Phase::Succeeded => { + if terminal == Terminal::Success { + return Duplicate; + } + Stale + }, + Phase::Failed => { + if terminal == Terminal::Failure { + return Duplicate; + } + Stale + }, + Phase::Queued | Phase::Cancelled => Stale, + } + } + + on Submit(id) { + match tasks.get(id) { + Some(_) => Invalid, + None => { + tasks = tasks.put(id, Task { + phase: Phase::Queued, + started: 0, + }); + queue = queue.append(id); + Accepted + }, + } + } + + on Cancel(id) { + match tasks.get(id) { + None => Invalid, + Some(task) => match task.phase { + Phase::Queued => { + queue = queue.without(id); + tasks = tasks.put(id, Task { + phase: Phase::Cancelled, + ..task + }); + Accepted + }, + Phase::Running { attempt, .. } => { + tasks = tasks.put(id, Task { + phase: Phase::Cancelled, + ..task + }); + emit Cancel(id, attempt); + Accepted + }, + Phase::Cancelled => Duplicate, + Phase::Succeeded | Phase::Failed => Invalid, + }, + } + } + + on Retry(id) { + match tasks.get(id) { + None => Invalid, + Some(task) => match task.phase { + Phase::Failed | Phase::Cancelled => { + tasks = tasks.put(id, Task { + phase: Phase::Queued, + ..task + }); + queue = queue.append(id); + Accepted + }, + Phase::Queued | Phase::Running { .. } | Phase::Succeeded => Invalid, + }, + } + } + + on Progress(id, attempt, value) { + if attempt <= 0 { + return Invalid; + } + + let next = match Ratio::checked_from(value) { + None => return Invalid, + Some(progress) => progress, + }; + + let task = match tasks.get(id) { + None => return Invalid, + Some(task) => task, + }; + + if attempt > task.started { + return Invalid; + } + + if attempt < task.started { + return Stale; + } + + match task.phase { + Phase::Running { + attempt: current_attempt, + progress: current, + } => { + if current_attempt != attempt { + unreachable; + } + if next < current { + return Stale; + } + if next == current { + return Duplicate; + } + + tasks = tasks.put(id, Task { + phase: Phase::Running { + attempt, + progress: next, + }, + ..task + }); + Accepted + }, + Phase::Queued + | Phase::Succeeded + | Phase::Failed + | Phase::Cancelled => Stale, + } + } + + on Succeed(id, attempt) { + resolve_terminal(id, attempt, Terminal::Success) + } + + on Fail(id, attempt) { + resolve_terminal(id, attempt, Terminal::Failure) + } + + before commit { + while running_count < LIMIT + && queue.uncons() is Some(Uncons { head: id, tail: rest }) + decreases(queue.len()) { + let task = match tasks.get(id) { + None => { + unreachable; + }, + Some(task) => task, + }; + let attempt: PositiveInt = task.started + 1; + + queue = rest; + tasks = tasks.put(id, Task { + phase: Phase::Running { + attempt, + progress: 0.0, + }, + started: attempt, + }); + emit Start(id, attempt); + } + } +} diff --git a/examples/programs/answers/uhura-0.4/uhura.toml b/examples/programs/answers/uhura-0.4/uhura.toml new file mode 100644 index 0000000..9f97951 --- /dev/null +++ b/examples/programs/answers/uhura-0.4/uhura.toml @@ -0,0 +1,7 @@ +[project] +name = "examples.programs" +version = 1 +language = "0.4" + +[modules] +programs = "programs.uhura" diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 74dc9dc..e9bfb2f 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Builds the uhura-wasm bundle for both targets (design §12.3): +# Builds the canonical uhura-wasm runtime for both browser and Node.js hosts: # crates/uhura-wasm/pkg/web/ — ES module for the play shell -# crates/uhura-wasm/pkg/node/ — CommonJS for scripts/parity.mjs +# crates/uhura-wasm/pkg/node/ — CommonJS for conformance and host tooling # # wasm-bindgen-cli MUST match the workspace's wasm-bindgen pin exactly # (Cargo.lock) — the CLI and the crate write two halves of one ABI. diff --git a/scripts/parity.mjs b/scripts/parity.mjs deleted file mode 100644 index 5ff4575..0000000 --- a/scripts/parity.mjs +++ /dev/null @@ -1,223 +0,0 @@ -// Native ↔ wasm parity (design §12.5, §13): replays a script through the -// REAL wasm32 binary (pkg/node, built by scripts/build-wasm.sh) with the -// same JSON-only pump the play shell and the native ABI-contract test -// use, and diffs the per-step trace lines byte-for-byte against the -// native harness's output. -// -// Inputs (a directory of prepared artifacts): -// ir.json — canonical uhura-ir/0 (uhura check --emit-ir) -// fixture.json — resolved slices (what `uhura play` serves) -// script.json — the script as JSON -// boot.json — {"updates": […]} boot deliveries -// native.jsonl — `uhura trace --script=` output -// -// Usage: node scripts/parity.mjs -// M6 automates artifact preparation per canonical script; until then the -// quickest source is a running `uhura play` (curl `/api/play/ir.json`, -// `/api/play/fixture.json`, `/api/play/script.json`, and `/api/play/boot.json`) -// plus `uhura trace` for native.jsonl. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; - -const here = fileURLToPath(new URL(".", import.meta.url)); -const require = createRequire(import.meta.url); -const { Session, FixtureDriver, protocols } = require( - join(here, "../crates/uhura-wasm/pkg/node/uhura_wasm.js"), -); - -const dir = process.argv[2]; -if (!dir) { - console.error("usage: node scripts/parity.mjs "); - process.exit(2); -} -const read = (name) => readFileSync(join(dir, name), "utf8"); - -const spoken = JSON.parse(protocols()); -if ( - spoken.inspect !== "uhura-inspect/0" || - spoken.ir !== "uhura-ir/0" || - spoken.view !== "uhura-view/0" || - spoken.provider !== "uhura-provider/0" -) { - console.error(`protocol mismatch: ${JSON.stringify(spoken)}`); - process.exit(1); -} - -const irText = read("ir.json"); -const script = JSON.parse(read("script.json")); -const native = read("native.jsonl").trim().split("\n"); - -// ── the same pump as web/src/play/main.ts and abi_contract.rs ────────── -const session = new Session(irText); -session.boot(read("boot.json")); -const driver = new FixtureDriver(read("fixture.json"), JSON.stringify(script)); - -const stimuli = (script.ui ?? []).map((entry) => ({ - atTick: entry["at-tick"], - emit: entry.emit, - where: entry.where ?? {}, - data: entry.data ?? {}, -})); - -const lines = []; -function dispatch(event) { - const raw = session.dispatch(JSON.stringify(event)); - // The compared artifacts are extracted from the RAW canonical envelope - // bytes — never round-tripped through JS numbers, so the byte-parity - // verdict is faithful for the whole i64 domain. - lines.push(extractTop(raw, "t")); - for (const c of arrayElements(extractTop(raw, "c"))) driver.deliver(c); - return JSON.parse(raw).v; -} - -/** - * The raw value substring of a top-level key in one canonical JSON - * object (canonical ⇒ object/array/string/number/bool/null, no floats). - */ -function extractTop(raw, key) { - const needle = `"${key}":`; - let depth = 0; - let inStr = false; - let esc = false; - for (let i = 0; i < raw.length; i += 1) { - const ch = raw[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') { - if (depth === 1 && raw.startsWith(needle, i)) { - return sliceValue(raw, i + needle.length); - } - inStr = true; - } else if (ch === "{" || ch === "[") depth += 1; - else if (ch === "}" || ch === "]") depth -= 1; - } - throw new Error(`no top-level "${key}" in the step result`); -} - -function sliceValue(raw, start) { - let depth = 0; - let inStr = false; - let esc = false; - for (let i = start; i < raw.length; i += 1) { - const ch = raw[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - } else if (ch === '"') inStr = true; - else if (ch === "{" || ch === "[") depth += 1; - else if (ch === "}" || ch === "]") { - depth -= 1; - if (depth === 0) return raw.slice(start, i + 1); - } else if (depth === 0 && (ch === "," || ch === "}")) { - return raw.slice(start, i); // bare scalar value - } - } - throw new Error("unbalanced JSON value"); -} - -/** Splits a raw canonical JSON array into raw element substrings. */ -function arrayElements(rawArray) { - const out = []; - let depth = 0; - let inStr = false; - let esc = false; - let start = -1; - for (let i = 0; i < rawArray.length; i += 1) { - const ch = rawArray[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') inStr = true; - else if (ch === "{" || ch === "[") { - depth += 1; - if (depth === 2) start = i; - } else if (ch === "}" || ch === "]") { - if (depth === 2 && start >= 0) { - out.push(rawArray.slice(start, i + 1)); - start = -1; - } - depth -= 1; - } - } - return out; -} - -const matches = (d, stim) => - d.emit === stim.emit && - Object.entries(stim.where).every(([k, v]) => JSON.stringify(d.payload?.[k]) === JSON.stringify(v)); - -function findDescriptor(view, stim) { - const found = []; - const walk = (node) => { - for (const d of node.on ?? []) if (matches(d, stim)) found.push(d); - for (const child of node.children ?? []) walk(child); - }; - walk(view.page.root); - for (const surface of view.surfaces) { - walk(surface.root); - if (matches(surface.dismiss, stim)) found.push(surface.dismiss); - } - const distinct = found.filter( - (d, i) => - found.findIndex( - (o) => o.emit === d.emit && o.scope === d.scope && canonical(o.payload) === canonical(d.payload), - ) === i, - ); - if (distinct.length !== 1) { - throw new Error(`stimulus \`${stim.emit}\` matched ${distinct.length} descriptors`); - } - return distinct[0]; -} - -let view = dispatch({ kind: "init", route: JSON.parse(irText).entry, params: {} }); -let tick = 0; -let next = 0; -while (!(driver.idle() && next >= stimuli.length)) { - tick += 1; - if (tick > 10_000) throw new Error("the script did not quiesce"); - for (const msgJson of driver.tick()) { - const msg = JSON.parse(msgJson); - view = dispatch(msg.kind === "projection" ? { kind: "projection", updates: [msg] } : msg); - } - while (next < stimuli.length && stimuli[next].atTick === tick) { - const stim = stimuli[next++]; - const event = { kind: "ui", descriptor: findDescriptor(view, stim), "view-rev": view.revision }; - if (Object.keys(stim.data).length > 0) event.data = stim.data; - view = dispatch(event); - } -} - -// ── canonical JSON (stimulus matching only — trace lines never pass -// through here) ───────────────────────────────────────────────────────── -function canonical(value) { - if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; - if (value !== null && typeof value === "object") { - const keys = Object.keys(value).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`; - } - return JSON.stringify(value); -} - -// ── diff ───────────────────────────────────────────────────────────────── -if (lines.length !== native.length) { - console.error(`step count diverged: wasm ${lines.length}, native ${native.length}`); - process.exit(1); -} -for (let i = 0; i < lines.length; i += 1) { - if (lines[i] !== native[i]) { - console.error(`step ${i} diverged:\n wasm: ${lines[i]}\n native: ${native[i]}`); - process.exit(1); - } -} -console.log(`parity: ${lines.length} steps byte-identical (native ↔ wasm32)`); diff --git a/web/src/app/location.ts b/web/src/app/location.ts new file mode 100644 index 0000000..e712bdb --- /dev/null +++ b/web/src/app/location.ts @@ -0,0 +1,28 @@ +import type { LocationChange } from "../app/router.js"; + +export type LocationConsumer = (change: LocationChange) => void; + +const consumers = new Set(); +let latest: LocationChange | null = null; + +/** Publishes the browser router's committed location to the mounted Play runtime. */ +export const publishLocation = (change: LocationChange): void => { + latest = change; + for (const consumer of [...consumers]) consumer(change); +}; + +/** Subscribes one app-owned route adapter until its provider is disposed. */ +export const installLocationConsumer = ( + next: LocationConsumer, +): (() => void) => { + consumers.add(next); + try { + if (latest !== null) next(latest); + } catch (error) { + consumers.delete(next); + throw error; + } + return () => { + consumers.delete(next); + }; +}; diff --git a/web/src/app/main.ts b/web/src/app/main.ts index 9637b03..416607d 100644 --- a/web/src/app/main.ts +++ b/web/src/app/main.ts @@ -1,5 +1,6 @@ import type { SurfaceLoader } from "./router.js"; import { createRouter } from "./router.js"; +import { publishLocation } from "./location.js"; const root = document.getElementById("uhura-root"); if (!root) throw new Error("Uhura application entry lost #uhura-root"); @@ -14,4 +15,9 @@ const loadPlay: SurfaceLoader = async () => { return mountPlay; }; -createRouter({ root, loadEditor, loadPlay }).start(); +createRouter({ + root, + loadEditor, + loadPlay, + locationChanged: publishLocation, +}).start(); diff --git a/web/src/app/router.test.ts b/web/src/app/router.test.ts index 8270dab..639fb71 100644 --- a/web/src/app/router.test.ts +++ b/web/src/app/router.test.ts @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import { test } from "vitest"; import type { SurfaceLoader, SurfaceMount } from "./router.js"; -import { createRouteRenderer } from "./router.js"; +import { + createRouteRenderer, + EDITOR_PATH, + routeFor, +} from "./router.js"; const deferred = (): { load: SurfaceLoader; @@ -49,3 +53,51 @@ test("only a committed route owns disposal", async () => { await renderer.render("/play"); assert.equal(editorDisposals, 1); }); + +test("only reserved editor entry points select Editor", () => { + assert.equal(routeFor("/").surface, "editor"); + assert.equal(routeFor(EDITOR_PATH).surface, "editor"); + assert.equal(routeFor(`${EDITOR_PATH}/`).surface, "editor"); + + assert.equal(routeFor("/play").surface, "play"); + assert.equal(routeFor("/play/").surface, "play"); + assert.equal(routeFor("/returns/return-100").surface, "play"); + assert.equal(routeFor("/_uhura/editor/preferences").surface, "play"); +}); + +test("real application locations keep one running Play surface", async () => { + let playLoads = 0; + let playMounts = 0; + let playDisposals = 0; + const commits: string[] = []; + const renderer = createRouteRenderer({ + root: { replaceChildren() {} } as unknown as HTMLElement, + loadEditor: async () => () => undefined, + loadPlay: async () => { + playLoads += 1; + return () => { + playMounts += 1; + return () => { playDisposals += 1; }; + }; + }, + committed(route) { + commits.push(route.pathname); + }, + }); + + await renderer.render("/play"); + await renderer.render("/returns"); + await renderer.render("/returns/return-100"); + + assert.equal(playLoads, 1); + assert.equal(playMounts, 1); + assert.equal(playDisposals, 0); + assert.deepEqual(commits, [ + "/play", + "/returns", + "/returns/return-100", + ]); + + await renderer.render(EDITOR_PATH); + assert.equal(playDisposals, 1); +}); diff --git a/web/src/app/router.ts b/web/src/app/router.ts index 4bd12d4..01a2f43 100644 --- a/web/src/app/router.ts +++ b/web/src/app/router.ts @@ -4,55 +4,116 @@ export type SurfaceMount = ( ) => void | SurfaceDispose; export type SurfaceLoader = () => Promise; -interface RouterOptions { +export type AppSurface = "editor" | "play"; +export type NavigationCause = "start" | "push" | "replace" | "pop"; + +export interface AppRoute { + pathname: string; + surface: AppSurface; +} + +export interface BrowserLocation { + pathname: string; + search: string; + hash: string; +} + +export interface LocationChange { + cause: NavigationCause; + location: BrowserLocation; + route: AppRoute; +} + +export interface RouterOptions { root: HTMLElement; loadEditor: SurfaceLoader; loadPlay: SurfaceLoader; + /** + * Runs only after the matching surface owns the route. Play can use this + * seam to deliver a real pathname/query change to its router port without + * remounting the running machine. + */ + locationChanged?(change: LocationChange): void; } export interface AppRouter { start(): void; - navigate(path: "/" | "/play", replace?: boolean): Promise; + navigate(destination: string | URL, replace?: boolean): Promise; } interface RouteRendererOptions extends RouterOptions { - committed?(path: "/" | "/play"): void; + committed?(route: AppRoute): void; } export interface RouteRenderer { - render(path: "/" | "/play"): Promise; + /** + * Returns false when a newer route superseded this asynchronous render. + * A route within the already-mounted surface commits without remounting it. + */ + render(pathname: string): Promise; } -const routeFor = (pathname: string): "/" | "/play" => - pathname === "/play" || pathname === "/play/" ? "/play" : "/"; +export const EDITOR_PATH = "/_uhura/editor"; + +const editorPath = (pathname: string): boolean => + pathname === "/" + || pathname === EDITOR_PATH + || pathname === `${EDITOR_PATH}/`; + +/** + * `/` remains the friendly Editor entry. The explicit reserved route makes + * Editor addressable after an Uhura application owns ordinary web paths. + * `/play` is the compatibility Play entry; every other pathname is an actual + * application location and therefore also belongs to Play. + */ +export const routeFor = (pathname: string): AppRoute => ({ + pathname, + surface: editorPath(pathname) ? "editor" : "play", +}); -const routedAnchor = (target: EventTarget | null): HTMLAnchorElement | null => { +interface RoutedAnchor { + url: URL; +} + +const routedAnchor = (target: EventTarget | null): RoutedAnchor | null => { if (!(target instanceof Element)) return null; const anchor = target.closest("a[href]"); if (!(anchor instanceof HTMLAnchorElement)) return null; if (anchor.target && anchor.target !== "_self") return null; + if (anchor.hasAttribute("download")) return null; const url = new URL(anchor.href, location.href); - if (url.origin !== location.origin || (url.pathname !== "/" && url.pathname !== "/play")) { - return null; - } - return anchor; + if (url.origin !== location.origin) return null; + return { url }; }; /** Loads first and mounts only after ownership is rechecked. */ export function createRouteRenderer(options: RouteRendererOptions): RouteRenderer { let dispose: SurfaceDispose | undefined; let transition = 0; + let activeSurface: AppSurface | null = null; - const render = async (path: "/" | "/play"): Promise => { + const render = async (pathname: string): Promise => { const token = ++transition; - const mount = await (path === "/play" ? options.loadPlay() : options.loadEditor()); - if (token !== transition) return; + const route = routeFor(pathname); + if (route.surface === activeSurface) { + options.committed?.(route); + return true; + } + + const mount = await ( + route.surface === "play" + ? options.loadPlay() + : options.loadEditor() + ); + if (token !== transition) return false; dispose?.(); dispose = undefined; options.root.replaceChildren(); - options.committed?.(path); const mounted = mount(options.root); + activeSurface = route.surface; + options.committed?.(route); if (typeof mounted === "function") dispose = mounted; + return true; }; return { render }; @@ -61,23 +122,56 @@ export function createRouteRenderer(options: RouteRendererOptions): RouteRendere export function createRouter(options: RouterOptions): AppRouter { const renderer = createRouteRenderer({ ...options, - committed(path) { - document.documentElement.dataset["uhuraRoute"] = path === "/play" ? "play" : "editor"; - document.title = path === "/play" ? "Uhura Play" : "Uhura Editor"; + committed(route) { + document.documentElement.dataset["uhuraRoute"] = route.surface; + document.title = route.surface === "play" ? "Uhura Play" : "Uhura Editor"; }, }); - const navigate = async (path: "/" | "/play", replace = false): Promise => { - const normalized = routeFor(path); - if (replace) history.replaceState(null, "", normalized); - else if (routeFor(location.pathname) !== normalized) history.pushState(null, "", normalized); - await renderer.render(normalized); + let locationSequence = 0; + + const browserLocation = (url: URL): BrowserLocation => ({ + pathname: url.pathname, + search: url.search, + hash: url.hash, + }); + + const renderLocation = async ( + url: URL, + cause: NavigationCause, + ): Promise => { + const sequence = ++locationSequence; + const committed = await renderer.render(url.pathname); + if (!committed || sequence !== locationSequence) return; + options.locationChanged?.({ + cause, + location: browserLocation(url), + route: routeFor(url.pathname), + }); + }; + + const navigate = async ( + destination: string | URL, + replace = false, + ): Promise => { + const url = new URL(destination, location.href); + if (url.origin !== location.origin) { + throw new Error(`cannot route a different origin: ${url.origin}`); + } + const href = `${url.pathname}${url.search}${url.hash}`; + const current = `${location.pathname}${location.search}${location.hash}`; + if (replace) { + history.replaceState(null, "", href); + } else if (current !== href) { + history.pushState(null, "", href); + } + await renderLocation(url, replace ? "replace" : "push"); }; return { start(): void { window.addEventListener("popstate", () => { - void renderer.render(routeFor(location.pathname)); + void renderLocation(new URL(location.href), "pop"); }); document.addEventListener("click", (event) => { if ( @@ -90,13 +184,12 @@ export function createRouter(options: RouterOptions): AppRouter { ) { return; } - const anchor = routedAnchor(event.target); - if (!anchor) return; + const routed = routedAnchor(event.target); + if (!routed) return; event.preventDefault(); - const path = new URL(anchor.href, location.href).pathname as "/" | "/play"; - void navigate(path); + void navigate(routed.url); }); - void renderer.render(routeFor(location.pathname)); + void renderLocation(new URL(location.href), "start"); }, navigate, }; diff --git a/web/src/editor/annotation-overlay.ts b/web/src/editor/annotation-overlay.ts index 4ac6115..5ea8203 100644 --- a/web/src/editor/annotation-overlay.ts +++ b/web/src/editor/annotation-overlay.ts @@ -193,8 +193,8 @@ const sourceTargetAction = ( const button = element(document, "button", "source-target-select", "Show"); button.type = "button"; button.setAttribute("data-source-target-id", target.id); - button.setAttribute("aria-label", `Show ${target.label} annotation on canvas`); - button.title = "Show annotation on canvas"; + button.setAttribute("aria-label", `Show ${target.label} on canvas`); + button.title = "Show rendered source on canvas"; button.disabled = !selectTarget || !occurrences.some((item) => item.occurrence.anchors.length > 0); button.addEventListener("click", () => selectTarget?.(target.id)); @@ -277,7 +277,7 @@ export const renderSourcePanel = ( const heading = element(document, "div", "source-entry-heading"); const actions = element(document, "div", "source-entry-actions"); const annotations = entries.filter((entry) => entry.class === "annotation"); - if (annotations.length > 0 && selectTarget) { + if (occurrences.length > 0 && selectTarget) { actions.append(sourceTargetAction(document, target, occurrences, selectTarget)); } actions.append(sourceAction(document, target, stale)); @@ -302,7 +302,12 @@ export const renderSourcePanel = ( sections.push(groupSection); } if (sections.length === 0) { - sections.push(element(document, "p", "inspector-muted", "No authored documentation or annotations.")); + sections.push(element( + document, + "p", + "inspector-muted", + "No authored documentation, annotations, or rendered source targets.", + )); } container.replaceChildren(...sections); container.classList.toggle("is-stale", stale); @@ -498,7 +503,29 @@ export class AnnotationOverlay { /** Selects a Source target and reveals its selected-preview or first realization. */ selectSourceTarget(targetId: string): boolean { const record = this.#records.find((candidate) => candidate.annotation.target.id === targetId); - if (!record || record.markers.length === 0) return false; + if (!record || record.markers.length === 0) { + const occurrences = this.#install.authoring.occurrencesByTarget.get(targetId) ?? []; + const rendered = occurrences.filter((occurrence) => + occurrence.occurrence.anchors.length > 0 + ); + const occurrence = rendered.find((candidate) => + candidate.previewId === this.#activePreviewId + ) ?? rendered[0]; + if (!occurrence) return false; + this.#activeMarkerId = null; + this.#revealedTargetId = null; + this.#pendingFocusTargetId = null; + for (const candidate of this.#records) { + candidate.card.hidden = true; + candidate.card.classList.toggle("is-revealed", false); + for (const marker of candidate.markers) marker.line.style.display = "none"; + } + this.#syncStateClasses(); + this.#focusSourceTarget?.(targetId); + this.#focusPreviewOccurrence(occurrence); + this.invalidate(); + return true; + } this.setCanvasVisible(true); const selected = record.markers.filter((marker) => marker.occurrence.previewId === this.#activePreviewId @@ -803,12 +830,16 @@ export class AnnotationOverlay { } #focusOccurrence(record: OverlayMarkerRecord): void { - const resources = this.#install.resourcesByPreviewId.get(record.occurrence.previewId); - const anchors = record.occurrence.occurrence.anchors.flatMap((anchor) => { + this.#focusPreviewOccurrence(record.occurrence); + } + + #focusPreviewOccurrence(occurrence: PreviewOccurrence): void { + const resources = this.#install.resourcesByPreviewId.get(occurrence.previewId); + const anchors = occurrence.occurrence.anchors.flatMap((anchor) => { const realized = resources?.resolve(anchor); return realized ? [realized] : []; }); - this.#focusPreview(record.occurrence.previewId, anchors); + this.#focusPreview(occurrence.previewId, anchors); } #pinToViewport(): void { diff --git a/web/src/editor/display-labels.ts b/web/src/editor/display-labels.ts new file mode 100644 index 0000000..95d10ba --- /dev/null +++ b/web/src/editor/display-labels.ts @@ -0,0 +1,72 @@ +import type { PreviewIdentity } from "./editor-state.js"; + +export interface PreviewDisplayLabels { + readonly subject: string; + readonly example: string; + readonly combined: string; +} + +type SubjectIdentity = Pick; + +const qualifiedTail = (value: string): string => { + const separator = value.lastIndexOf("::"); + return separator < 0 ? value : value.slice(separator + 2); +}; + +/** + * Converts one authored/public identifier into the Editor's compact label + * vocabulary. This is presentation only: callers retain the original identity + * for every semantic join and protocol operation. + */ +export const editorIdentifierLabel = (value: string): string => { + const tail = qualifiedTail(value) + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/[^A-Za-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase(); + return tail || value; +}; + +/** A page's `Page` suffix describes its UI kind, so the Editor need not repeat it. */ +export const editorSubjectLabel = (identity: SubjectIdentity): string => { + const label = editorIdentifierLabel(identity.subject); + if (identity.kind !== "page") return label; + const withoutPage = label.replace(/-?page$/, ""); + return withoutPage || label; +}; + +const fullExampleLabel = (identity: PreviewIdentity): string => + editorIdentifierLabel(identity.example); + +const shortenedExampleLabel = (identity: PreviewIdentity): string => { + const full = fullExampleLabel(identity); + const subject = editorSubjectLabel(identity); + const prefix = `${subject}-`; + return subject && full.startsWith(prefix) && full.length > prefix.length + ? full.slice(prefix.length) + : full; +}; + +const sameSubject = (left: PreviewIdentity, right: PreviewIdentity): boolean => + left.kind === right.kind && left.subject === right.subject; + +/** + * Produces friendly labels for one preview. A presentation-derived subject + * prefix is removed from its example only when the resulting label is unique + * among the subject's peer examples. + */ +export const editorPreviewLabels = ( + identity: PreviewIdentity, + peers: readonly PreviewIdentity[] = [identity], +): PreviewDisplayLabels => { + const subject = editorSubjectLabel(identity); + const fullExample = fullExampleLabel(identity); + const candidate = shortenedExampleLabel(identity); + const collisions = peers.filter((peer) => + sameSubject(identity, peer) && shortenedExampleLabel(peer) === candidate); + const example = candidate !== fullExample && collisions.length === 1 + ? candidate + : fullExample; + return { subject, example, combined: `${subject} / ${example}` }; +}; diff --git a/web/src/editor/editor-authoring.ts b/web/src/editor/editor-authoring.ts index bf42db4..9337db8 100644 --- a/web/src/editor/editor-authoring.ts +++ b/web/src/editor/editor-authoring.ts @@ -149,6 +149,7 @@ export const presentedSourceTargets = ( const targetIds = new Set([ ...authoring.documentedTargets.map((target) => target.id), ...authoring.annotationTargets.map((annotation) => annotation.target.id), + ...authoring.occurrencesByTarget.keys(), ]); return [...targetIds] .flatMap((targetId) => { diff --git a/web/src/editor/editor-board.ts b/web/src/editor/editor-board.ts index a66b8ce..569b7c9 100644 --- a/web/src/editor/editor-board.ts +++ b/web/src/editor/editor-board.ts @@ -1,6 +1,6 @@ -import { createEditorRenderer } from "../renderer/editor.js"; +import { createEditorAssets } from "../renderer/assets.js"; import type { IconFontRegistry } from "../renderer/icons.js"; -import type { Snapshot, VNode } from "../protocol/types.js"; +import { createProjectionRenderer } from "../renderer/projection.js"; import { type EditorPreview, type EditorRender, @@ -32,6 +32,12 @@ import { structureConnectorDescription, } from "./structure-connectors.js"; import { structureConnectorLabelSegments } from "./structure-presentation.js"; +import { + editorIdentifierLabel, + editorPreviewLabels, + editorSubjectLabel, + type PreviewDisplayLabels, +} from "./display-labels.js"; export interface PreparedWorkflowConnector extends WorkflowConnector { element: SVGGElement; @@ -93,9 +99,9 @@ const prepareWorkflowConnector = ( group.dataset.lane = String(connector.lane); group.dataset.sourcePort = `${connector.sourcePort.slot + 1}/${connector.sourcePort.count}`; group.dataset.targetPort = `${connector.targetPort.slot + 1}/${connector.targetPort.count}`; - if (connector.openedSurfaces.length > 0) { + if (connector.introducedSurfaces.length > 0) { group.classList.add("opens-surface"); - group.dataset.openedSurfaces = connector.openedSurfaces + group.dataset.introducedSurfaces = connector.introducedSurfaces .map((surface) => surface.definition) .join(" "); } @@ -107,7 +113,7 @@ const prepareWorkflowConnector = ( const origin = svgElement(document, "circle", "workflow-connector-origin"); origin.setAttribute("r", "3"); const label = svgElement(document, "text", "workflow-connector-label"); - label.textContent = workflowConnectorLabel(connector.steps, connector.openedSurfaces); + label.textContent = workflowConnectorLabel(connector.steps, connector.introducedSurfaces); group.append(title, path, arrow, origin, label); return { ...connector, element: group }; }; @@ -174,9 +180,6 @@ const prepareStructureConnector = ( return { ...connector, element: group }; }; -const isSnapshot = (content: Snapshot | VNode): content is Snapshot => - "protocol" in content && content.protocol === "uhura-view/0"; - const provenance = (preview: EditorPreview): string => { if (preview.pinned) return "Pinned example"; if (preview.derived) return "Replay-derived"; @@ -190,60 +193,26 @@ const realizePreview = ( stylesheet: CSSStyleSheet, host: HTMLElement, resources: RealizationResources, - icons: IconFontRegistry, + icons: IconFontRegistry | undefined, ): void => { const shadow = host.attachShadow({ mode: "open" }); shadow.adoptedStyleSheets = [stylesheet]; const application = element(document, "div", "preview-application"); application.id = "uh-app"; - const wrapper = element(document, "div", isSnapshot(preview.content) + const wrapper = element(document, "div", preview.identity.kind === "page" ? "screen-root" - : "fragment-root"); + : "preview-root"); wrapper.inert = true; - const renderer = createEditorRenderer({ - document, - assets: render.assets, + const renderer = createProjectionRenderer({ + root: wrapper, + dispatch: () => undefined, + mode: "editor", + assets: createEditorAssets(render.assets), icons, + modalSurfaces: false, + observeElement: (key, realized) => resources.registerKey(key, realized), }); - if (isSnapshot(preview.content)) { - renderer.realizeRoot(wrapper, preview.content.page.root, { - root: { kind: "page" }, - scope: `${preview.id}:page`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - for (const [index, surface] of preview.content.surfaces.entries()) { - const overlay = element(document, "div", "uh-surface-overlay"); - overlay.dataset.surfaceDefinition = surface.definition; - overlay.dataset.surfaceModality = surface.modality; - overlay.dataset.surfaceStackIndex = String(index); - overlay.style.zIndex = String(index + 1); - const scrim = element(document, "div", "uh-scrim"); - const surfaceHost = element( - document, - "div", - `uh-surface uh-modality-${surface.modality}`, - ); - surfaceHost.setAttribute("role", "dialog"); - surfaceHost.setAttribute("aria-modal", "true"); - surfaceHost.dataset.surfaceDefinition = surface.definition; - renderer.realizeRoot(surfaceHost, surface.root, { - root: { kind: "surface", key: surface.key }, - scope: `${preview.id}:surface:${surface.key}`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - overlay.append(scrim, surfaceHost); - wrapper.append(overlay); - } - } else { - renderer.realizeRoot(wrapper, preview.content, { - root: { kind: "fragment" }, - scope: `${preview.id}:fragment`, - parentIsList: false, - observe: (realization) => resources.register(realization), - }); - } + renderer.render(preview.content.value.document); application.append(wrapper); shadow.append(application); }; @@ -262,10 +231,11 @@ interface PreparedFrame { const frame = ( document: Document, preview: EditorPreview, + labels: PreviewDisplayLabels, render: EditorRender, stylesheet: CSSStyleSheet, resources: RealizationResources, - icons: IconFontRegistry, + icons: IconFontRegistry | undefined, realize: boolean, ): PreparedFrame => { const figure = element(document, "figure", "editor-frame"); @@ -295,7 +265,7 @@ const frame = ( document, "span", "caption-title", - `${preview.identity.subject} / ${preview.identity.example}`, + labels.combined, )); if (preview.default) caption.append(badge(document, "badge-default", "default")); if (preview.pinned) caption.append(badge(document, "badge-pinned", "pinned")); @@ -309,13 +279,13 @@ const frame = ( const surfaceBadge = badge( document, "badge-surface", - `${surface.modality} ${surface.definition}`, + `${surface.modality} ${editorIdentifierLabel(surface.definition)}`, ); surfaceBadge.dataset.relation = surface.relation; surfaceBadge.title = { - direct: "Child surface opened by this replay edge", - inherited: "Child surface inherited from replay ancestry", - mounted: "Child surface mounted in this snapshot", + introduced: "Present in this projection but absent from its evidence parent", + retained: "Present in this projection and its evidence parent", + present: "Present in this standalone projection", }[surface.relation]; caption.append(surfaceBadge); } @@ -331,22 +301,29 @@ const navigatorGroup = ( group: EditorRender["groups"][number], previews: EditorPreview[], ): HTMLElement => { + const peerIdentities = previews.map((preview) => preview.identity); + const subjectLabel = editorSubjectLabel(group); const section = element(document, "section", "navigator-group"); section.dataset.navigatorGroup = ""; - section.dataset.search = `${group.kind} ${group.subject}`.toLocaleLowerCase(); + section.dataset.search = [ + group.kind, + group.subject, + subjectLabel, + ].join(" ").toLocaleLowerCase(); const row = element(document, "button", "navigator-row"); row.type = "button"; row.dataset.groupId = group.id; row.append( element(document, "span", "navigator-kind"), - element(document, "span", "navigator-row-title", group.subject), + element(document, "span", "navigator-row-title", subjectLabel), element(document, "span", "navigator-count", String(previews.length)), ); (row.firstElementChild as HTMLElement).dataset.kind = group.kind; const list = element(document, "div", "navigator-frames"); for (const preview of previews) { + const labels = editorPreviewLabels(preview.identity, peerIdentities); const button = element(document, "button", "navigator-frame"); button.type = "button"; button.dataset.previewId = preview.id; @@ -354,11 +331,13 @@ const navigatorGroup = ( preview.identity.kind, preview.identity.subject, preview.identity.example, + labels.subject, + labels.example, ].join(" ").toLocaleLowerCase(); button.setAttribute("aria-pressed", "false"); button.append( element(document, "span", "navigator-frame-icon"), - element(document, "span", "navigator-frame-title", preview.identity.example), + element(document, "span", "navigator-frame-title", labels.example), ); if (preview.derived) { const marker = element(document, "span", "navigator-derived", "D"); @@ -437,12 +416,11 @@ export const prepareEditorModel = ( }; } - if (!icons) throw new Error("A renderable Editor model requires icon fonts"); - const stylesheet = previous?.render?.stylesheet === render.stylesheet ? previous.stylesheet ?? preparePreviewStylesheet(document, render.stylesheet) : preparePreviewStylesheet(document, render.stylesheet); - const resourcesMatch = previous?.iconFingerprint === icons.fingerprint; + const iconFingerprint = icons?.fingerprint ?? null; + const resourcesMatch = previous?.iconFingerprint === iconFingerprint; const reusableRealizationIds = new Set(resourcesMatch ? [...reusablePreviewIds(previous?.render ?? null, render)].filter((id) => previous?.frameById.has(id) ?? false) @@ -475,7 +453,7 @@ export const prepareEditorModel = ( document, "h2", "row-title", - `${group.kind} ${group.subject}`, + `${group.kind} ${editorSubjectLabel(group)}`, )); const frames = element(document, "div", "row-frames"); const laneCount = groupConnectors.reduce( @@ -485,6 +463,7 @@ export const prepareEditorModel = ( if (laneCount > 0) { frames.style.setProperty("--workflow-rail-height", `${workflowRailHeight(laneCount)}px`); } + const peerIdentities = typedPreviews.map((preview) => preview.identity); for (const preview of typedPreviews) { const resources = new RealizationResources(); resources.claim(resourceOwner); @@ -492,6 +471,7 @@ export const prepareEditorModel = ( const prepared = frame( document, preview, + editorPreviewLabels(preview.identity, peerIdentities), render, stylesheet, resources, @@ -533,7 +513,7 @@ export const prepareEditorModel = ( connectors, structureConnectors, render, - iconFingerprint: icons.fingerprint, + iconFingerprint, stylesheet, reusableRealizationIds, reusableFrameIds, diff --git a/web/src/editor/editor-realization.ts b/web/src/editor/editor-realization.ts index 307f0d7..9b9ce4d 100644 --- a/web/src/editor/editor-realization.ts +++ b/web/src/editor/editor-realization.ts @@ -1,19 +1,6 @@ -import type { - EditorNodeRealization, - EditorRenderNodeRef, - EditorRenderRoot, -} from "../renderer/editor.js"; - export type RealizationOwner = object; -const rootKey = (root: EditorRenderRoot): string => { - if (root.kind === "page") return "page"; - if (root.kind === "fragment") return "fragment"; - return `surface:${root.key}`; -}; - -export const realizationKey = (reference: EditorRenderNodeRef): string => - `${rootKey(reference.root)}|${reference.path.join(".")}`; +export const realizationKey = (key: string): string => `key|${key}`; /** * Direct semantic-node handles and their geometry subscriptions for one @@ -47,18 +34,18 @@ export class RealizationResources { this.#owner = to; } - register(realization: EditorNodeRealization): void { + registerKey(key: string, element: HTMLElement): void { if (this.#disposed) throw new Error("cannot register into disposed realization resources"); - const key = realizationKey(realization); - if (this.#elements.has(key)) { - throw new Error(`duplicate semantic realization ${key}`); + const realization = realizationKey(key); + if (this.#elements.has(realization)) { + throw new Error(`duplicate semantic realization ${realization}`); } - this.#elements.set(key, realization.element); + this.#elements.set(realization, element); } - resolve(reference: EditorRenderNodeRef): HTMLElement | null { + resolve(key: string): HTMLElement | null { if (this.#disposed) return null; - return this.#elements.get(realizationKey(reference)) ?? null; + return this.#elements.get(realizationKey(key)) ?? null; } realizedElements(): readonly HTMLElement[] { diff --git a/web/src/editor/editor-state.ts b/web/src/editor/editor-state.ts index 2c49ad3..85ff7e7 100644 --- a/web/src/editor/editor-state.ts +++ b/web/src/editor/editor-state.ts @@ -1,13 +1,18 @@ -import type { - Descriptor, - InteractionGraph, - Snapshot, - SurfaceView, - VNode, - VValue, -} from "../protocol/types.js"; - -export const EDITOR_STATE_PROTOCOL = "uhura-editor-state/2" as const; +import type { InteractionGraph } from "../protocol/types.js"; +import { decodeInteractionGraphArtifacts } from "../protocol/interaction-graph.js"; +import { + decodeSemanticProvenance, + type SemanticProvenance, +} from "../protocol/provenance.js"; +import { + decodeProjectionSources, + decodeRenderDocument, + type ProjectionSources, + type RenderDocument, + type RenderNode, +} from "../renderer/projection.js"; + +export const EDITOR_STATE_PROTOCOL = "uhura-editor-state/4" as const; export const EDITOR_EVENT_PROTOCOL = "uhura-editor-event/0" as const; export const INTERACTION_GRAPH_PROTOCOL = "uhura-interaction-graph/0" as const; @@ -96,7 +101,7 @@ export type SourceTargetClass = | "outcome-handler" | "handler-parameter" | "example-declaration" - | "catalog-element" + | "ui-element" | "component-invocation" | "if-block" | "each-block" @@ -145,26 +150,30 @@ export interface PreviewDocumentation { exampleDocId: string | null; } -export type RenderRoot = - | { kind: "page" } - | { kind: "fragment" } - | { kind: "surface"; key: string }; - -export interface RenderNodeRef { - root: RenderRoot; - path: number[]; -} - export interface TargetOccurrence { id: string; targetId: string; - anchors: RenderNodeRef[]; + /** Opaque semantic node keys from this preview's `uhura-view/1` document. */ + anchors: string[]; } export interface PreviewProvenance { occurrences: TargetOccurrence[]; } +export interface PreviewEvidence { + scenario: string; + pin: string; + sourceId: string; + sources: { + registration: JsonValue; + pin: JsonValue; + }; + observation: JsonValue; + snapshot: JsonValue; + scenarioReceiptLog: JsonValue; +} + export interface ReplayGuard { handler: number; result: "satisfied" | "unsatisfied" | "not-ready"; @@ -211,7 +220,16 @@ export interface EditorPreview { interactions: PreviewInteraction[]; documentation: PreviewDocumentation; provenance: PreviewProvenance; - content: Snapshot | VNode; + evidence: PreviewEvidence | null; + content: PreviewContent; +} + +export interface PreviewContent { + kind: "projection"; + value: { + document: RenderDocument; + sources: ProjectionSources; + }; } export interface EditorAsset { @@ -219,6 +237,18 @@ export interface EditorAsset { alt: string; } +export interface EditorMachine { + protocol: "uhura-machine-inspection/0"; + identityProtocol: string; + deployment: JsonValue; + sources: JsonValue; + provenance: SemanticProvenance | null; + interactionGraph: JsonValue; + graphSources: JsonValue; + checkpoints: JsonValue; + evidence: JsonValue; +} + export interface EditorRender { revision: number; freshness: PreviewFreshness; @@ -229,6 +259,7 @@ export interface EditorRender { stylesheet: string; assets: Record; interactionGraph: InteractionGraph; + machine: EditorMachine | null; } export interface EditorState { @@ -369,7 +400,7 @@ const sourceTargetClasses = [ "outcome-handler", "handler-parameter", "example-declaration", - "catalog-element", + "ui-element", "component-invocation", "if-block", "each-block", @@ -377,7 +408,7 @@ const sourceTargetClasses = [ ] as const satisfies readonly SourceTargetClass[]; const annotatableTargetClasses = new Set([ - "catalog-element", + "ui-element", "component-invocation", "if-block", "each-block", @@ -485,27 +516,6 @@ const previewDocumentation = (value: unknown, path: string): PreviewDocumentatio }; }; -const renderRoot = (value: unknown, path: string): RenderRoot => { - const object = record(value, path); - const kind = oneOf(object["kind"], `${path}.kind`, ["page", "fragment", "surface"]); - if (kind === "surface") { - exact(object, path, ["kind", "key"]); - return { kind, key: string(object["key"], `${path}.key`) }; - } - exact(object, path, ["kind"]); - return { kind }; -}; - -const renderNodeRef = (value: unknown, path: string): RenderNodeRef => { - const object = record(value, path); - exact(object, path, ["root", "path"]); - return { - root: renderRoot(object["root"], `${path}.root`), - path: array(object["path"], `${path}.path`).map((item, index) => - nonNegativeInteger(item, `${path}.path[${index}]`)), - }; -}; - const targetOccurrence = (value: unknown, path: string): TargetOccurrence => { const object = record(value, path); exact(object, path, ["id", "targetId", "anchors"]); @@ -513,7 +523,7 @@ const targetOccurrence = (value: unknown, path: string): TargetOccurrence => { id: string(object["id"], `${path}.id`), targetId: string(object["targetId"], `${path}.targetId`), anchors: array(object["anchors"], `${path}.anchors`).map((item, index) => - renderNodeRef(item, `${path}.anchors[${index}]`)), + string(item, `${path}.anchors[${index}]`)), }; }; @@ -526,114 +536,70 @@ const previewProvenance = (value: unknown, path: string): PreviewProvenance => { }; }; -const descriptor = (value: unknown, path: string): Descriptor => { - const object = record(value, path); - exact(object, path, ["kind", "event", "emit", "scope", "payload", "carries"]); - const carriesValue = object["carries"]; - let carries: Record | undefined; - if (carriesValue !== undefined) { - carries = Object.fromEntries(Object.entries(record(carriesValue, `${path}.carries`)).map( - ([key, item]) => [key, oneOf(item, `${path}.carries.${key}`, ["text", "bool", "int"])], - )); - } - return { - kind: oneOf(object["kind"], `${path}.kind`, ["input", "observe"]), - event: string(object["event"], `${path}.event`), - emit: string(object["emit"], `${path}.emit`), - scope: string(object["scope"], `${path}.scope`), - payload: jsonValue(object["payload"], `${path}.payload`), - ...(carries === undefined ? {} : { carries }), - }; -}; - -const vValue = (value: unknown, path: string): VValue => { - if (typeof value === "boolean" || typeof value === "string") return value; - if (typeof value === "number") return finiteNumber(value, path); +const content = (value: unknown, path: string): PreviewContent => { const object = record(value, path); - const tag = object["t"]; - if (tag === "plain") { - exact(object, path, ["t", "v"]); - return { t: "plain", v: string(object["v"], `${path}.v`, true) }; - } - if (tag === "image") { - exact(object, path, ["t", "asset"]); - return { t: "image", asset: string(object["asset"], `${path}.asset`) }; + const kind = oneOf(object["kind"], `${path}.kind`, ["projection"]); + exact(object, path, ["kind", "value"]); + const projection = record(object["value"], `${path}.value`); + exact(projection, `${path}.value`, ["document", "sources"]); + try { + const document = decodeRenderDocument( + projection["document"], + `${path}.value.document`, + ); + return { + kind, + value: { + document, + sources: decodeProjectionSources( + projection["sources"], + document, + `${path}.value.sources`, + ), + }, + }; + } catch (error) { + throw new EditorContractError( + `${path}.value`, + error instanceof Error ? error.message : "an Uhura machine render document", + ); } - throw new EditorContractError(path, "a valid Uhura property value"); -}; - -const vnode = (value: unknown, path: string): VNode => { - const object = record(value, path); - exact(object, path, ["key", "element", "class", "props", "children", "on"]); - const props = Object.fromEntries(Object.entries(record(object["props"], `${path}.props`)).map( - ([key, item]) => [key, vValue(item, `${path}.props.${key}`)], - )); - const childrenValue = object["children"]; - const onValue = object["on"]; - const children = childrenValue === undefined - ? undefined - : array(childrenValue, `${path}.children`).map((item, index) => - vnode(item, `${path}.children[${index}]`)); - const on = onValue === undefined - ? undefined - : array(onValue, `${path}.on`).map((item, index) => - descriptor(item, `${path}.on[${index}]`)); - return { - key: string(object["key"], `${path}.key`), - element: string(object["element"], `${path}.element`), - props, - ...(object["class"] === undefined - ? {} - : { class: string(object["class"], `${path}.class`, true) }), - ...(children === undefined ? {} : { children }), - ...(on === undefined ? {} : { on }), - }; }; -const surface = (value: unknown, path: string): SurfaceView => { +const previewEvidence = (value: unknown, path: string): PreviewEvidence | null => { + if (value === null) return null; const object = record(value, path); - exact(object, path, ["key", "definition", "modality", "restore-focus", "dismiss", "root"]); - return { - key: string(object["key"], `${path}.key`), - definition: string(object["definition"], `${path}.definition`), - modality: string(object["modality"], `${path}.modality`), - ...(object["restore-focus"] === undefined - ? {} - : { "restore-focus": string(object["restore-focus"], `${path}.restore-focus`) }), - dismiss: descriptor(object["dismiss"], `${path}.dismiss`), - root: vnode(object["root"], `${path}.root`), - }; -}; - -const snapshot = (value: UnknownRecord, path: string): Snapshot => { - exact(value, path, ["protocol", "revision", "page", "surfaces"]); - if (value["protocol"] !== "uhura-view/0") { - throw new EditorContractError(`${path}.protocol`, JSON.stringify("uhura-view/0")); - } - const page = record(value["page"], `${path}.page`); - exact(page, `${path}.page`, ["route", "root"]); + exact(object, path, [ + "scenario", + "pin", + "sourceId", + "sources", + "observation", + "snapshot", + "scenarioReceiptLog", + ]); + const sources = record(object["sources"], `${path}.sources`); + exact(sources, `${path}.sources`, ["registration", "pin"]); return { - protocol: "uhura-view/0", - revision: nonNegativeInteger(value["revision"], `${path}.revision`), - page: { - route: string(page["route"], `${path}.page.route`, true), - root: vnode(page["root"], `${path}.page.root`), + scenario: string(object["scenario"], `${path}.scenario`), + pin: string(object["pin"], `${path}.pin`), + sourceId: string(object["sourceId"], `${path}.sourceId`), + sources: { + registration: jsonValue( + sources["registration"], + `${path}.sources.registration`, + ), + pin: jsonValue(sources["pin"], `${path}.sources.pin`), }, - surfaces: array(value["surfaces"], `${path}.surfaces`).map((item, index) => - surface(item, `${path}.surfaces[${index}]`)), + observation: jsonValue(object["observation"], `${path}.observation`), + snapshot: jsonValue(object["snapshot"], `${path}.snapshot`), + scenarioReceiptLog: jsonValue( + object["scenarioReceiptLog"], + `${path}.scenarioReceiptLog`, + ), }; }; -const content = (value: unknown, path: string): Snapshot | VNode => { - const object = record(value, path); - return object["protocol"] === "uhura-view/0" - ? snapshot(object, path) - : vnode(object, path); -}; - -const isSnapshotContent = (value: Snapshot | VNode): value is Snapshot => - "protocol" in value && value.protocol === "uhura-view/0"; - const dataSource = (value: unknown, path: string): PreviewDataSource | null => { if (value === null) return null; const object = record(value, path); @@ -768,16 +734,11 @@ const preview = (value: unknown, path: string): EditorPreview => { const object = record(value, path); exact(object, path, [ "id", "identity", "sourceFile", "default", "pinned", "derived", "inFlight", "from", "note", - "replaySteps", "replay", "data", "interactions", "documentation", "provenance", "content", + "replaySteps", "replay", "data", "interactions", "documentation", "provenance", "evidence", + "content", ]); const previewIdentity = identity(object["identity"], `${path}.identity`); const previewContent = content(object["content"], `${path}.content`); - if ((previewIdentity.kind === "page") !== isSnapshotContent(previewContent)) { - throw new EditorContractError( - `${path}.content`, - previewIdentity.kind === "page" ? "an uhura-view/0 snapshot" : "a fragment VNode", - ); - } const replaySteps = array(object["replaySteps"], `${path}.replaySteps`).map((item, index) => string(item, `${path}.replaySteps[${index}]`)); const replay = array(object["replay"], `${path}.replay`).map((item, index) => @@ -804,6 +765,7 @@ const preview = (value: unknown, path: string): EditorPreview => { interaction(item, `${path}.interactions[${index}]`)), documentation: previewDocumentation(object["documentation"], `${path}.documentation`), provenance: previewProvenance(object["provenance"], `${path}.provenance`), + evidence: previewEvidence(object["evidence"], `${path}.evidence`), content: previewContent, }; }; @@ -846,7 +808,11 @@ const validateAuthoring = ( const targets = new Map(authoring.targets.map((target) => [target.id, target])); const entries = new Map(authoring.entries.map((entry) => [entry.id, entry])); const orders = new Map(); - const annotationTargets = new Set(); + const projectionSourceTargets = new Set( + previews.flatMap((preview) => + preview.provenance.occurrences.map((occurrence) => occurrence.targetId) + ), + ); for (const [index, entry] of authoring.entries.entries()) { const entryPath = `$.render.authoring.entries[${index}]`; @@ -867,7 +833,6 @@ const validateAuthoring = ( ) { throw new EditorContractError(entryPath, "annotation metadata on an annotatable target"); } - annotationTargets.add(entry.targetId); } const targetOrders = orders.get(entry.targetId) ?? []; targetOrders.push(entry.order); @@ -884,7 +849,9 @@ const validateAuthoring = ( } }); } - const unusedTarget = authoring.targets.find((target) => !orders.has(target.id)); + const unusedTarget = authoring.targets.find((target) => + !orders.has(target.id) && !projectionSourceTargets.has(target.id) + ); if (unusedTarget) { throw new EditorContractError( "$.render.authoring.targets", @@ -944,22 +911,18 @@ const validateAuthoring = ( if ( !target || !annotatableTargetClasses.has(target.class) - || !annotationTargets.has(occurrence.targetId) ) { throw new EditorContractError( `${occurrencePath}.targetId`, - "an annotation-bearing annotatable source target id", + "an annotatable source target id", ); } - unique( - occurrence.anchors.map((anchor) => JSON.stringify(anchor)), - `${occurrencePath}.anchors`, - ); + unique(occurrence.anchors, `${occurrencePath}.anchors`); for (const [anchorIndex, anchor] of occurrence.anchors.entries()) { if (!anchorResolves(preview.content, anchor)) { throw new EditorContractError( `${occurrencePath}.anchors[${anchorIndex}]`, - "a semantic node path in this preview", + "a semantic node key in this preview", ); } } @@ -967,25 +930,16 @@ const validateAuthoring = ( } }; -const anchorResolves = (contentValue: Snapshot | VNode, anchor: RenderNodeRef): boolean => { - let node: VNode | undefined; - if (isSnapshotContent(contentValue)) { - if (anchor.root.kind === "page") node = contentValue.page.root; - else if (anchor.root.kind === "surface") { - const key = anchor.root.key; - const matching = contentValue.surfaces.filter((surfaceValue) => surfaceValue.key === key); - if (matching.length === 1) node = matching[0]?.root; - } - } else if (anchor.root.kind === "fragment") { - node = contentValue; - } - if (!node) return false; - for (const index of anchor.path) { - node = node.children?.[index]; - if (!node) return false; - } - return true; -}; +const projectionNodeHasKey = ( + nodes: readonly RenderNode[], + key: string, +): boolean => nodes.some((node) => + node.key === key + || (node.kind === "element" && projectionNodeHasKey(node.children, key)) +); + +const anchorResolves = (contentValue: PreviewContent, anchor: string): boolean => + projectionNodeHasKey(contentValue.value.document.nodes, anchor); const validateReferences = (groups: PreviewGroup[], previews: EditorPreview[]): void => { unique(groups.map((item) => item.id), "$.render.groups[].id"); @@ -1088,12 +1042,82 @@ const interactionGraph = (value: unknown, path: string): InteractionGraph => { }; }; +const editorMachine = (value: unknown, path: string): EditorMachine | null => { + if (value === null) return null; + const object = record(value, path); + exact(object, path, [ + "protocol", + "identityProtocol", + "deployment", + "sources", + "provenance", + "interactionGraph", + "graphSources", + "checkpoints", + "evidence", + ]); + if (object["protocol"] !== "uhura-machine-inspection/0") { + throw new EditorContractError( + `${path}.protocol`, + JSON.stringify("uhura-machine-inspection/0"), + ); + } + const sources = jsonValue(object["sources"], `${path}.sources`); + decodeInteractionGraphArtifacts( + object["interactionGraph"], + object["graphSources"], + ); + const provenance = decodeSemanticProvenance( + object["provenance"], + `${path}.provenance`, + ); + if (provenance !== null) { + const inventory = new Map( + array(object["sources"], `${path}.sources`).map((value, index) => { + const sourcePath = `${path}.sources[${index}]`; + const source = record(value, sourcePath); + return [ + string(source["path"], `${sourcePath}.path`), + { + sha256: string(source["sha256"], `${sourcePath}.sha256`), + bytes: nonNegativeInteger(source["bytes"], `${sourcePath}.bytes`), + }, + ] as const; + }), + ); + for (const semanticSource of provenance.sources) { + const physical = inventory.get(semanticSource.path); + if ( + physical === undefined + || physical.sha256 !== semanticSource.sha256 + || physical.bytes !== semanticSource.bytes + ) { + throw new EditorContractError( + `${path}.provenance.sources`, + "entries matching the accepted source inventory", + ); + } + } + } + return { + protocol: "uhura-machine-inspection/0", + identityProtocol: string(object["identityProtocol"], `${path}.identityProtocol`), + deployment: jsonValue(object["deployment"], `${path}.deployment`), + sources, + provenance, + interactionGraph: jsonValue(object["interactionGraph"], `${path}.interactionGraph`), + graphSources: jsonValue(object["graphSources"], `${path}.graphSources`), + checkpoints: jsonValue(object["checkpoints"], `${path}.checkpoints`), + evidence: jsonValue(object["evidence"], `${path}.evidence`), + }; +}; + const render = (value: unknown, path: string, sourceRevision: number): EditorRender | null => { if (value === null) return null; const object = record(value, path); exact(object, path, [ "revision", "freshness", "application", "authoring", "groups", "previews", "stylesheet", - "assets", "interactionGraph", + "assets", "interactionGraph", "machine", ]); const revision = positiveRevision(object["revision"], `${path}.revision`); const freshness = oneOf(object["freshness"], `${path}.freshness`, ["current", "stale"]); @@ -1125,6 +1149,7 @@ const render = (value: unknown, path: string, sourceRevision: number): EditorRen stylesheet: string(object["stylesheet"], `${path}.stylesheet`, true), assets, interactionGraph: interactionGraph(object["interactionGraph"], `${path}.interactionGraph`), + machine: editorMachine(object["machine"], `${path}.machine`), }; }; diff --git a/web/src/editor/editor-styles.ts b/web/src/editor/editor-styles.ts index 15cc786..d72e897 100644 --- a/web/src/editor/editor-styles.ts +++ b/web/src/editor/editor-styles.ts @@ -474,6 +474,22 @@ export const EDITOR_STYLES = ` .inspector-grid > div { padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: #fafbfc; } .inspector-grid dt { color: var(--faint); font-size: 9px; text-transform: uppercase; letter-spacing: .06em; } .inspector-grid dd { margin: 2px 0 0; font-size: 16px; font-weight: 680; } + .inspector-block.overview-machine-block { margin: 0 0 16px; padding: 11px; border: 1px solid #dbe5ec; border-radius: 8px; background: #f8fbfd; } + .machine-block-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-block-end: 8px; } + .overview-machine-block .machine-block-heading h3 { margin: 0; } + .machine-evidence-status { flex: none; padding: 2px 6px; border-radius: 999px; color: #596a79; background: #e9eff4; font-size: 8px; font-weight: 700; } + .machine-evidence-status[data-tone="passed"] { color: #24623c; background: #dff4e7; } + .machine-evidence-status[data-tone="failed"] { color: #7b4651; background: #fbe5e9; } + .machine-property-list { margin: 0; } + .machine-property-list > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 8px; padding-block: 6px; border-block-end: 1px solid #e4eaee; } + .machine-property-list > div:first-child { border-block-start: 1px solid #e4eaee; } + .machine-property-list dt { color: var(--faint); font-size: 9px; } + .machine-property-list dd { min-inline-size: 0; margin: 0; color: #34404c; font: 9px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } + .machine-metric-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 9px 0 0; } + .machine-metric-grid > div { padding: 7px 8px; border-radius: 6px; background: #edf3f7; } + .machine-metric-grid dt { color: #70808d; font-size: 8px; text-transform: uppercase; letter-spacing: .04em; } + .machine-metric-grid dd { margin: 1px 0 0; color: #2f3c48; font-size: 13px; font-weight: 680; } + .machine-topology-heading { margin: 11px 0 5px; color: #5f6e7a; font-size: 9px; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } .inspector-callout { padding: 12px; border-radius: 8px; color: #425466; background: #f3f7fa; } .inspector-callout strong { font-size: 11px; } .inspector-callout p { margin: 4px 0 0; color: #657181; font-size: 11px; } @@ -622,10 +638,10 @@ export const EDITOR_STYLES = ` export const PREVIEW_BASE_STYLES = ` :host, #uh-app { display: block; inline-size: 100%; block-size: 100%; color: #16181c; } *, *::before, *::after { box-sizing: border-box; } - .screen-root, .fragment-root { position: relative; inline-size: 100%; block-size: 100%; overflow: hidden; } + .screen-root, .preview-root { position: relative; inline-size: 100%; block-size: 100%; overflow: hidden; } .screen-root { isolation: isolate; } .screen-root > * { block-size: 100%; } - .fragment-root > * { min-inline-size: 0; } + .preview-root > * { min-inline-size: 0; } .uh-view { display: block; min-inline-size: 0; } .uh-scroll { overflow-y: auto; overflow-x: hidden; min-block-size: 0; } .uh-scroll[data-direction="horizontal"] { overflow-x: auto; overflow-y: hidden; } diff --git a/web/src/editor/editor.ts b/web/src/editor/editor.ts index 9619889..24f21d8 100644 --- a/web/src/editor/editor.ts +++ b/web/src/editor/editor.ts @@ -46,6 +46,7 @@ import { import { incomingLeftLabelShift, layoutStructureConnectors, + logicalRoutePreviewNode, routeStructureConnector, structureDefinitionNode, visibleStructureConnectors, @@ -67,6 +68,17 @@ import { loadIconFontRegistry, type IconFontRegistry, } from "../renderer/icons.js"; +import type { RenderNode } from "../renderer/projection.js"; +import { + inspectMachine, + machineMetricRows, + previewEvidenceRows, + renderInspectionRows, +} from "./machine-inspection.js"; +import { + editorIdentifierLabel, + editorPreviewLabels, +} from "./display-labels.js"; const EDITOR_STATE_PATH = "/api/editor/state"; const EDITOR_ICON_FONTS_PATH = "/api/editor/icon-fonts.json"; @@ -97,6 +109,13 @@ interface Rect extends Point { height: number; } +export const projectionNeedsIconFonts = ( + nodes: readonly RenderNode[], +): boolean => nodes.some((node) => + node.kind === "element" + && (node.element === "icon" || projectionNeedsIconFonts(node.children)) +); + interface PanState { pointerId: number; pointerX: number; @@ -149,6 +168,13 @@ interface EditorShell { overviewApplication: HTMLElement; overviewFreshness: HTMLElement; overviewStats: HTMLElement; + overviewMachineBlock: HTMLElement; + overviewMachineIdentity: HTMLElement; + overviewMachineStatus: HTMLElement; + overviewMachineMetrics: HTMLElement; + overviewMachineOwnership: HTMLElement; + overviewMachineOutcomes: HTMLElement; + overviewMachineDependencies: HTMLElement; overviewCallout: HTMLElement; clearSelectionButton: HTMLButtonElement; selectionKind: HTMLElement; @@ -168,6 +194,8 @@ interface EditorShell { selectionWorkflowBlock: HTMLElement; selectionWorkflow: HTMLOListElement; selectionStatus: HTMLElement; + selectionEvidenceBlock: HTMLElement; + selectionEvidence: HTMLElement; selectionData: HTMLElement; selectionNoData: HTMLElement; selectionNoteBlock: HTMLElement; @@ -234,6 +262,17 @@ const SHELL_HTML = `
UhuraLoading preview model
+
Read-only projection

Save a .uhura file to rebuild these previews automatically.

@@ -325,6 +369,13 @@ const buildShell = (root: HTMLElement): EditorShell => { overviewApplication: required(shell, "[data-overview-application]"), overviewFreshness: required(shell, "[data-overview-freshness]"), overviewStats: required(shell, "[data-overview-stats]"), + overviewMachineBlock: required(shell, ".overview-machine-block"), + overviewMachineIdentity: required(shell, ".overview-machine-identity"), + overviewMachineStatus: required(shell, ".machine-evidence-status"), + overviewMachineMetrics: required(shell, ".overview-machine-metrics"), + overviewMachineOwnership: required(shell, ".overview-machine-ownership"), + overviewMachineOutcomes: required(shell, ".overview-machine-outcomes"), + overviewMachineDependencies: required(shell, ".overview-machine-dependencies"), overviewCallout: required(shell, "[data-overview-callout]"), clearSelectionButton: required(shell, ".clear-selection"), selectionKind: required(shell, ".selection-kind"), @@ -344,6 +395,8 @@ const buildShell = (root: HTMLElement): EditorShell => { selectionWorkflowBlock: required(shell, ".selection-workflow-block"), selectionWorkflow: required(shell, ".selection-workflow"), selectionStatus: required(shell, ".selection-status"), + selectionEvidenceBlock: required(shell, ".selection-evidence-block"), + selectionEvidence: required(shell, ".selection-evidence"), selectionData: required(shell, ".selection-data"), selectionNoData: required(shell, ".selection-no-data"), selectionNoteBlock: required(shell, ".selection-note-block"), @@ -970,9 +1023,13 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { observeFocusedFrame(null); return; } + const labels = editorPreviewLabels( + focusedPreview.identity, + model.render?.previews.map((preview) => preview.identity), + ); shell.focusBreadcrumbKind.textContent = focusedPreview.identity.kind; - shell.focusBreadcrumbSubject.textContent = focusedPreview.identity.subject; - shell.focusBreadcrumbExample.textContent = focusedPreview.identity.example; + shell.focusBreadcrumbSubject.textContent = labels.subject; + shell.focusBreadcrumbExample.textContent = labels.example; frame.classList.add("is-focus-target"); frame.closest(".preview-row")?.classList.add("is-focus-row"); const navigatorButton = Array.from( @@ -1186,12 +1243,13 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { child.dataset.surfaceKey = node.surface.key; if (node.opener !== null) child.dataset.opener = node.opener; const label = document.createElement("strong"); - label.textContent = `${node.surface.modality} ${node.surface.definition}`; + label.textContent = + `${node.surface.modality} ${editorIdentifierLabel(node.surface.definition)}`; const relation = document.createElement("span"); relation.textContent = { - direct: "opened by this replay", - inherited: "inherited from replay ancestry", - mounted: "mounted in this snapshot", + introduced: "introduced since its evidence parent", + retained: "retained from its evidence parent", + present: "present in this projection", }[node.surface.relation]; child.append(label, relation); if (node.children.length > 0) { @@ -1203,7 +1261,7 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { }; const root = document.createElement("li"); root.className = "surface-hierarchy-root"; - root.textContent = `page ${hierarchy.page}`; + root.textContent = `presentation ${editorIdentifierLabel(hierarchy.presentation)}`; const children = document.createElement("ul"); children.append(...hierarchy.roots.map(renderNode)); root.append(children); @@ -1213,6 +1271,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { const renderInspector = (preview: EditorPreview): void => { const focused = focusedPreviewId() === preview.id; + const peerIdentities = model.render?.previews.map((candidate) => candidate.identity); + const labels = editorPreviewLabels(preview.identity, peerIdentities); shell.inspectorOverview.hidden = true; shell.inspectorSelection.hidden = false; shell.focusSelectionButton.disabled = false; @@ -1220,9 +1280,9 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { shell.selectionKind.textContent = focused ? `Focused ${preview.identity.kind}` : preview.identity.kind; - shell.selectionName.textContent = `${preview.identity.subject} / ${preview.identity.example}`; - shell.selectionSubject.textContent = preview.identity.subject; - shell.selectionExample.textContent = preview.identity.example; + shell.selectionName.textContent = labels.combined; + shell.selectionSubject.textContent = labels.subject; + shell.selectionExample.textContent = labels.example; shell.selectionSize.textContent = shellSize(preview); shell.selectionOrigin.textContent = origin(preview); shell.selectionSourceRow.hidden = preview.identity.kind !== "page"; @@ -1234,7 +1294,12 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { ? "Copy is disabled while the preview is stale" : "Copy page source path"; shell.selectionFromRow.hidden = preview.from === null || preview.from === ""; - shell.selectionFrom.textContent = preview.from ?? ""; + shell.selectionFrom.textContent = preview.from + ? editorPreviewLabels( + { ...preview.identity, example: preview.from }, + peerIdentities, + ).example + : ""; shell.selectionReplayRow.hidden = preview.replaySteps.length === 0; shell.selectionReplay.textContent = preview.replaySteps.join(" → "); renderSurfaceHierarchy(preview); @@ -1242,6 +1307,17 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { const status = preview.default ? ["Default"] : []; status.push(preview.inFlight > 0 ? `${preview.inFlight} in flight` : "Settled"); shell.selectionStatus.textContent = status.join(" · "); + if (preview.evidence) { + renderInspectionRows( + document, + shell.selectionEvidence, + previewEvidenceRows(preview.evidence), + ); + shell.selectionEvidenceBlock.hidden = false; + } else { + shell.selectionEvidence.replaceChildren(); + shell.selectionEvidenceBlock.hidden = true; + } renderData(preview); shell.selectionNoteBlock.hidden = !preview.note; shell.selectionNote.textContent = preview.note ?? ""; @@ -1266,8 +1342,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { })); shell.selectionNoInteractions.hidden = preview.interactions.length > 0; shell.selectionAnnouncement.textContent = focused - ? `${preview.identity.subject} / ${preview.identity.example} focused; details updated.` - : `${preview.identity.subject} / ${preview.identity.example} selected; details updated.`; + ? `${labels.combined} focused; details updated.` + : `${labels.combined} selected; details updated.`; }; const clearSelection = (): void => { @@ -1295,6 +1371,8 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { shell.selectionHierarchyBlock.hidden = true; shell.selectionWorkflow.replaceChildren(); shell.selectionWorkflowBlock.hidden = true; + shell.selectionEvidence.replaceChildren(); + shell.selectionEvidenceBlock.hidden = true; shell.selectionAnnouncement.textContent = ""; }; @@ -1323,8 +1401,15 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { // right edge, incoming arrives muted at the left edge, and presents // leave the bottom edge (or arrive at a selected surface's top edge). activeStructureConnectors = layoutStructureConnectors( - visibleStructureConnectors(model.structureConnectors, preview.identity), - { node: structureDefinitionNode(preview.identity), previewId }, + visibleStructureConnectors(model.structureConnectors, { + ...preview.identity, + previewId, + }), + { + node: logicalRoutePreviewNode(previewId), + aliases: [structureDefinitionNode(preview.identity)], + previewId, + }, ); // Active structural arrows lift the whole connector layer above the // preview rows so edge label pills and arrowheads never clip behind a @@ -1452,6 +1537,59 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { stat(document, "Flows", previews.filter((preview) => preview.from !== null).length), stat(document, "Assets", Object.keys(render?.assets ?? {}).length), ); + if (render?.machine) { + const inspection = inspectMachine(render.machine); + renderInspectionRows(document, shell.overviewMachineIdentity, inspection.identity); + shell.overviewMachineIdentity.hidden = inspection.identity.length === 0; + renderInspectionRows( + document, + shell.overviewMachineMetrics, + machineMetricRows(inspection), + ); + renderInspectionRows( + document, + shell.overviewMachineOwnership, + inspection.ownership, + ); + shell.overviewMachineOwnership.hidden = inspection.ownership.length === 0; + const ownershipHeading = shell.overviewMachineOwnership + .previousElementSibling as HTMLElement | null; + if (ownershipHeading) ownershipHeading.hidden = inspection.ownership.length === 0; + renderInspectionRows( + document, + shell.overviewMachineOutcomes, + inspection.outcomes, + ); + shell.overviewMachineOutcomes.hidden = inspection.outcomes.length === 0; + const outcomeHeading = shell.overviewMachineOutcomes + .previousElementSibling as HTMLElement | null; + if (outcomeHeading) outcomeHeading.hidden = inspection.outcomes.length === 0; + renderInspectionRows( + document, + shell.overviewMachineDependencies, + inspection.dependencies, + ); + shell.overviewMachineDependencies.hidden = inspection.dependencies.length === 0; + const dependencyHeading = shell.overviewMachineDependencies + .previousElementSibling as HTMLElement | null; + if (dependencyHeading) dependencyHeading.hidden = inspection.dependencies.length === 0; + shell.overviewMachineStatus.dataset.tone = inspection.status; + shell.overviewMachineStatus.textContent = { + passed: "Evidence passed", + failed: "Evidence failed", + unknown: "Evidence unavailable", + }[inspection.status]; + shell.overviewMachineBlock.hidden = false; + } else { + shell.overviewMachineIdentity.replaceChildren(); + shell.overviewMachineMetrics.replaceChildren(); + shell.overviewMachineOwnership.replaceChildren(); + shell.overviewMachineOutcomes.replaceChildren(); + shell.overviewMachineDependencies.replaceChildren(); + shell.overviewMachineStatus.textContent = ""; + delete shell.overviewMachineStatus.dataset.tone; + shell.overviewMachineBlock.hidden = true; + } const callout = shell.overviewCallout.querySelector("p"); if (callout) { callout.textContent = render?.freshness === "stale" @@ -1626,7 +1764,10 @@ export const mountEditor = (root: HTMLElement): EditorDispose => { return; } let icons: IconFontRegistry | undefined; - if (decision.state.render) { + const needsIcons = decision.state.render?.previews.some((preview) => + projectionNeedsIconFonts(preview.content.value.document.nodes) + ) ?? false; + if (decision.state.render && needsIcons) { const iconResponse = await window.fetch(EDITOR_ICON_FONTS_PATH, { headers: { Accept: "application/json" }, cache: "no-store", diff --git a/web/src/editor/machine-inspection.ts b/web/src/editor/machine-inspection.ts new file mode 100644 index 0000000..1a5609a --- /dev/null +++ b/web/src/editor/machine-inspection.ts @@ -0,0 +1,275 @@ +import type { + EditorMachine, + JsonValue, + PreviewEvidence, +} from "./editor-state.js"; + +export interface InspectionRow { + label: string; + value: string; +} + +export interface MachineInspection { + identity: InspectionRow[]; + status: "passed" | "failed" | "unknown"; + passes: number; + failures: number; + checkpoints: number; + sources: number; + ownership: InspectionRow[]; + outcomes: InspectionRow[]; + dependencies: InspectionRow[]; +} + +type JsonRecord = Record; + +interface InspectionGraphNode { + id: string; + kind: string; + machine: string; + label: string; +} + +interface InspectionGraphEdge { + from: string; + to: string; + kind: string; +} + +const jsonRecord = (value: JsonValue | undefined): JsonRecord | null => + typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : null; + +const nonEmptyString = (value: JsonValue | undefined): string | null => + typeof value === "string" && value.length > 0 ? value : null; + +const collectionSize = (value: JsonValue): number => { + if (Array.isArray(value)) return value.length; + const object = jsonRecord(value); + return object ? Object.keys(object).length : 0; +}; + +const graphNodes = (value: JsonValue): InspectionGraphNode[] => { + const nodes = jsonRecord(value)?.["nodes"]; + if (!Array.isArray(nodes)) return []; + return nodes.flatMap((value) => { + const node = jsonRecord(value); + const id = nonEmptyString(node?.["id"]); + const kind = nonEmptyString(node?.["kind"]); + const machine = nonEmptyString(node?.["machine"]); + const label = nonEmptyString(node?.["label"]); + return id && kind && machine && label + ? [{ id, kind, machine, label }] + : []; + }); +}; + +const graphEdges = (value: JsonValue): InspectionGraphEdge[] => { + const edges = jsonRecord(value)?.["edges"]; + if (!Array.isArray(edges)) return []; + return edges.flatMap((value) => { + const edge = jsonRecord(value); + const from = nonEmptyString(edge?.["from"]); + const to = nonEmptyString(edge?.["to"]); + const kind = nonEmptyString(edge?.["kind"]); + return from && to && kind ? [{ from, to, kind }] : []; + }); +}; + +const memberSummary = ( + ids: ReadonlySet, + nodes: ReadonlyMap, +): string => { + const counts = new Map(); + for (const id of ids) { + const kind = nodes.get(id)?.kind; + if ( + kind === "state" + || kind === "computed" + || kind === "invariant" + || kind === "update" + || kind === "observation" + ) { + counts.set(kind, (counts.get(kind) ?? 0) + 1); + } + } + return ["state", "computed", "invariant", "update", "observation"] + .flatMap((kind) => { + const count = counts.get(kind) ?? 0; + return count > 0 ? [`${count} ${kind}${count === 1 ? "" : "s"}`] : []; + }) + .join(" · "); +}; + +const ownershipRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const allNodes = graphNodes(graph); + const nodes = allNodes.filter((node) => machine === null || node.machine === machine); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const edges = graphEdges(graph).filter((edge) => + nodeById.has(edge.from) && nodeById.has(edge.to) + ); + const modules = nodes + .filter((node) => node.kind === "module") + .map((node) => node.label) + .filter((label, index, labels) => labels.indexOf(label) === index) + .sort(); + const parts = nodes.filter((node) => node.kind === "part") + .sort((left, right) => left.label.localeCompare(right.label)); + const partOwned = new Set( + edges + .filter((edge) => + edge.kind === "owns" && nodeById.get(edge.from)?.kind === "part" + ) + .map((edge) => edge.to), + ); + const machineOwned = new Set( + nodes + .filter((node) => + ["state", "computed", "invariant", "update", "observation"].includes(node.kind) + && !partOwned.has(node.id) + ) + .map((node) => node.id), + ); + + return [ + ...modules.map((value) => ({ label: "Module", value })), + ...(machineOwned.size > 0 + ? [{ label: "Machine-owned", value: memberSummary(machineOwned, nodeById) }] + : []), + ...parts.map((part) => { + const owned = new Set( + edges + .filter((edge) => edge.kind === "owns" && edge.from === part.id) + .map((edge) => edge.to), + ); + return { + label: `Part ${part.label}`, + value: memberSummary(owned, nodeById) || "No stateful members", + }; + }), + ]; +}; + +const outcomeRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const policies = jsonRecord(jsonRecord(graph)?.["outcome_policies"]); + if (policies === null) return []; + return graphNodes(graph) + .filter((node) => + node.kind === "outcome" + && (machine === null || node.machine === machine) + ) + .flatMap((node) => { + const policy = nonEmptyString(policies[node.id]); + return policy === "commit" || policy === "abort" + ? [{ label: `Outcome ${node.label}`, value: policy }] + : []; + }) + .sort((left, right) => left.label.localeCompare(right.label)); +}; + +const dependencyRows = ( + graph: JsonValue, + machine: string | null, +): InspectionRow[] => { + const nodes = graphNodes(graph) + .filter((node) => machine === null || node.machine === machine); + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const edges = graphEdges(graph) + .filter((edge) => + ["reads", "calls", "observes"].includes(edge.kind) + && nodeById.has(edge.from) + && nodeById.has(edge.to) + ); + return [ + { kind: "reads", label: "Reads" }, + { kind: "calls", label: "Calls" }, + { kind: "observes", label: "Observes" }, + ].flatMap(({ kind, label }) => { + const matches = edges.filter((edge) => edge.kind === kind); + if (matches.length === 0) return []; + const examples = matches.slice(0, 2).map((edge) => + `${nodeById.get(edge.from)!.label} → ${nodeById.get(edge.to)!.label}` + ); + return [{ + label, + value: `${matches.length} · ${examples.join(", ")}${ + matches.length > examples.length ? ", …" : "" + }`, + }]; + }); +}; + +export const inspectMachine = (machine: EditorMachine): MachineInspection => { + const deployment = jsonRecord(machine.deployment); + const entry = nonEmptyString(deployment?.["entry"]); + const machineName = nonEmptyString(deployment?.["machine"]); + const presentation = nonEmptyString(deployment?.["presentation"]); + const evidence = jsonRecord(machine.evidence); + const scenarios = Array.isArray(evidence?.["scenarios"]) + ? evidence["scenarios"] + : []; + const passedScenarios = scenarios.filter((scenario) => + jsonRecord(scenario)?.["status"] === "passed").length; + const failedScenarios = scenarios.filter((scenario) => + jsonRecord(scenario)?.["status"] === "failed").length; + const reportedFailures = Array.isArray(evidence?.["failures"]) + ? evidence["failures"].length + : failedScenarios; + const passed = evidence?.["passed"]; + + return { + identity: [ + entry ? { label: "Deployment", value: entry } : null, + machineName ? { label: "Machine", value: machineName } : null, + presentation ? { label: "Presentation", value: presentation } : null, + ].filter((row): row is InspectionRow => row !== null), + status: passed === true ? "passed" : passed === false ? "failed" : "unknown", + passes: passedScenarios, + failures: reportedFailures, + checkpoints: collectionSize(machine.checkpoints), + sources: collectionSize(machine.sources), + ownership: ownershipRows(machine.interactionGraph, machineName), + outcomes: outcomeRows(machine.interactionGraph, machineName), + dependencies: dependencyRows(machine.interactionGraph, machineName), + }; +}; + +export const machineMetricRows = ( + inspection: MachineInspection, +): InspectionRow[] => [ + { label: "Passes", value: String(inspection.passes) }, + { label: "Failures", value: String(inspection.failures) }, + { label: "Checkpoints", value: String(inspection.checkpoints) }, + { label: "Sources", value: String(inspection.sources) }, +]; + +export const previewEvidenceRows = ( + evidence: PreviewEvidence, +): InspectionRow[] => [ + { label: "Scenario", value: evidence.scenario }, + { label: "Pin", value: evidence.pin }, + { label: "Source", value: evidence.sourceId }, +]; + +export const renderInspectionRows = ( + document: Document, + root: HTMLElement, + rows: readonly InspectionRow[], +): void => { + root.replaceChildren(...rows.map((row) => { + const group = document.createElement("div"); + const term = document.createElement("dt"); + term.textContent = row.label; + const description = document.createElement("dd"); + description.textContent = row.value; + group.append(term, description); + return group; + })); +}; diff --git a/web/src/editor/structure-connectors.ts b/web/src/editor/structure-connectors.ts index 1bff3ed..43d51db 100644 --- a/web/src/editor/structure-connectors.ts +++ b/web/src/editor/structure-connectors.ts @@ -12,12 +12,14 @@ export type StructureConnectorKind = "navigate" | "present"; export interface StructureDefinition { kind: string; subject: string; + /** Exact preview selected when the graph models a logical route state. */ + previewId?: string; } /** One deduplicated structural edge between two board frames. */ export interface StructureConnector { kind: StructureConnectorKind; - /** The `page:`/`surface:` graph node behind each endpoint. */ + /** Definition or `preview:` graph identity behind each endpoint. */ sourceNode: string; targetNode: string; sourceId: string; @@ -29,9 +31,9 @@ export interface StructureConnector { } /** - * Maps `page:`/`surface:` graph nodes to the first board frame - * that previews the same definition. Command and dynamic nodes, and - * definitions without previews, have no frame and draw nothing. + * Maps definition nodes to their first board frame and preview-backed logical + * route nodes to their exact frame. Command and dynamic nodes, and graph + * identities without previews, have no frame and draw nothing. */ const frameIdByGraphNode = ( previews: readonly EditorPreview[], @@ -42,10 +44,15 @@ const frameIdByGraphNode = ( if (kind !== "page" && kind !== "surface") continue; const nodeId = `${kind}:${preview.identity.subject}`; if (!frames.has(nodeId)) frames.set(nodeId, preview.id); + frames.set(logicalRoutePreviewNode(preview.id), preview.id); } return frames; }; +/** Graph identity for one preview-backed logical route state. */ +export const logicalRoutePreviewNode = (previewId: string): string => + `preview:${previewId}`; + const compareStrings = (left: readonly string[], right: readonly string[]): number => { for (let index = 0; index < left.length; index += 1) { if (left[index]! < right[index]!) return -1; @@ -107,19 +114,25 @@ export const buildStructureConnectors = ( export const structureDefinitionNode = (definition: StructureDefinition): string => `${definition.kind}:${definition.subject}`; +const structureSelectionNodes = (definition: StructureDefinition): Set => + new Set([ + structureDefinitionNode(definition), + ...(definition.previewId ? [logicalRoutePreviewNode(definition.previewId)] : []), + ]); + /** * Figma-style selection scoping: with no selection nothing structural draws; - * with a selected preview only the connectors entering or leaving that - * preview's definition (kind + subject) remain. + * with a selected preview only the connectors entering or leaving either its + * definition (kind + subject) or its exact preview-backed route state remain. */ export const visibleStructureConnectors = ( connectors: readonly T[], selected: StructureDefinition | null, ): T[] => { if (!selected) return []; - const node = structureDefinitionNode(selected); + const nodes = structureSelectionNodes(selected); return connectors.filter((connector) => - connector.sourceNode === node || connector.targetNode === node); + nodes.has(connector.sourceNode) || nodes.has(connector.targetNode)); }; export type StructureConnectorDirection = "outgoing" | "incoming"; @@ -127,9 +140,11 @@ export type StructureConnectorDirection = "outgoing" | "incoming"; /** The selected frame's edge a connector fans out on. */ export type StructureEdgeSide = "right" | "left" | "bottom" | "top"; -/** The board frame the user actually clicked, plus its definition node. */ +/** The board frame the user actually clicked, plus its graph identities. */ export interface StructureSelection { node: string; + /** Equivalent definition/logical-state nodes owned by this selection. */ + aliases?: readonly string[]; previewId: string; } @@ -171,10 +186,11 @@ export const layoutStructureConnectors = ( connectors: readonly T[], selected: StructureSelection, ): PlacedStructureConnector[] => { + const selectedNodes = new Set([selected.node, ...(selected.aliases ?? [])]); const classified = connectors .map((connector) => { const direction: StructureConnectorDirection = - connector.sourceNode === selected.node ? "outgoing" : "incoming"; + selectedNodes.has(connector.sourceNode) ? "outgoing" : "incoming"; const farId = direction === "outgoing" ? connector.targetId : connector.sourceId; const farNode = direction === "outgoing" ? connector.targetNode : connector.sourceNode; return { diff --git a/web/src/editor/surface-hierarchy.ts b/web/src/editor/surface-hierarchy.ts index e4b1cbd..10440ab 100644 --- a/web/src/editor/surface-hierarchy.ts +++ b/web/src/editor/surface-hierarchy.ts @@ -1,145 +1,128 @@ -import type { SurfaceView } from "../protocol/types.js"; -import type { EditorPreview, JsonValue } from "./editor-state.js"; +import type { RenderNode } from "../renderer/projection.js"; +import type { EditorPreview } from "./editor-state.js"; export interface MountedSurface { + /** Stable semantic identity from the canonical projection. */ key: string; + /** Best available authored label, falling back to the semantic key. */ definition: string; + /** Honest rendered modality; canonical Surface currently projects to dialog. */ modality: string; + /** Deterministic pre-order among every mounted surface in this projection. */ stackIndex: number; - relation: "direct" | "inherited" | "mounted"; + /** Comparison with the direct evidence parent, when one exists. */ + relation: "introduced" | "retained" | "present"; } export interface SurfaceHierarchyNode { surface: MountedSurface; + /** Nearest containing surface key, not an inferred runtime opener. */ opener: string | null; children: SurfaceHierarchyNode[]; } export interface SurfaceHierarchy { - page: string; - /** Bottom-to-top mounted surface order. */ + presentation: string; + /** Deterministic pre-order over canonical `surface: true` nodes. */ surfaces: MountedSurface[]; - /** Opener-derived parent/child forest rooted at the current page. */ + /** Render-tree containment forest. */ roots: SurfaceHierarchyNode[]; } -const stringField = (value: JsonValue, field: string): string | null => { - if (typeof value !== "object" || value === null || Array.isArray(value)) return null; - const candidate = value[field]; - return typeof candidate === "string" ? candidate : null; +const textAttribute = ( + node: Extract, + names: readonly string[], +): string | null => { + for (const name of names) { + const value = node.attributes.find((attribute) => attribute.name === name)?.value; + if (typeof value === "string" && value.length > 0) return value; + } + return null; }; -interface SurfaceOpen { - surface: string; - opener: string | null; -} - -const openedSurfaces = (preview: EditorPreview): SurfaceOpen[] => - preview.replay.flatMap((step) => step.effects.structural.flatMap((effect) => { - if (stringField(effect, "op") !== "open-surface") return []; - const surface = stringField(effect, "surface"); - const opener = stringField(effect, "opener"); - return surface === null ? [] : [{ surface, opener }]; - })); - -const previewLineage = ( - preview: EditorPreview, - relatedPreviews: readonly EditorPreview[], -): EditorPreview[] => { - const byExample = new Map( - relatedPreviews - .filter((candidate) => - candidate.identity.kind === preview.identity.kind - && candidate.identity.subject === preview.identity.subject) - .map((candidate) => [candidate.identity.example, candidate] as const), - ); - byExample.set(preview.identity.example, preview); - - const lineage: EditorPreview[] = []; - const visited = new Set(); - let current: EditorPreview | undefined = preview; - while (current && !visited.has(current.id)) { - lineage.unshift(current); - visited.add(current.id); - current = current.from === null ? undefined : byExample.get(current.from); - } - return lineage; +const surfaceKeys = (nodes: readonly RenderNode[]): Set => { + const keys = new Set(); + const visit = (node: RenderNode): void => { + if (node.kind !== "element") return; + if (node.surface) keys.add(node.key); + node.children.forEach(visit); + }; + nodes.forEach(visit); + return keys; }; -const inheritedOpeners = ( +const parentPreview = ( preview: EditorPreview, relatedPreviews: readonly EditorPreview[], -): Map => { - const openerBySurface = new Map(); - for (const ancestor of previewLineage(preview, relatedPreviews)) { - for (const step of ancestor.replay) { - for (const effect of step.effects.structural) { - const op = stringField(effect, "op"); - const surface = stringField(effect, "surface"); - if (op === "open-surface") { - const opener = stringField(effect, "opener"); - if (surface !== null && opener !== null) openerBySurface.set(surface, opener); - } else if ((op === "dismiss" || op === "force-close") && surface !== null) { - openerBySurface.delete(surface); - } - } - } - } - return openerBySurface; +): EditorPreview | null => { + if (preview.from === null) return null; + return relatedPreviews.find((candidate) => + candidate.identity.kind === preview.identity.kind + && candidate.identity.subject === preview.identity.subject + && candidate.identity.example === preview.from + ) ?? null; }; -const mountedSurface = ( - surface: SurfaceView, - stackIndex: number, - directlyOpened: ReadonlySet, - hasReplayParent: boolean, -): MountedSurface => ({ - key: surface.key, - definition: surface.definition, - modality: surface.modality, - stackIndex, - relation: directlyOpened.has(surface.key) - ? "direct" - : hasReplayParent - ? "inherited" - : "mounted", -}); - export const surfaceHierarchy = ( preview: EditorPreview, relatedPreviews: readonly EditorPreview[] = [preview], ): SurfaceHierarchy | null => { - if (!("protocol" in preview.content) || preview.content.protocol !== "uhura-view/0") return null; - const directlyOpened = new Set(openedSurfaces(preview).map(({ surface }) => surface)); - const surfaces = preview.content.surfaces.map((surface, index) => - mountedSurface(surface, index, directlyOpened, preview.from !== null)); - const nodeByKey = new Map(); - const nodeByScope = new Map(); - for (const [index, surface] of preview.content.surfaces.entries()) { - const node: SurfaceHierarchyNode = { - surface: surfaces[index]!, - opener: null, - children: [], - }; - nodeByKey.set(surface.key, node); - nodeByScope.set(surface.dismiss.scope, node); - } - - const openerBySurface = inheritedOpeners(preview, relatedPreviews); + const document = preview.content.value.document; + const parent = parentPreview(preview, relatedPreviews); + const retainedKeys = parent === null + ? null + : surfaceKeys(parent.content.value.document.nodes); + const surfaces: MountedSurface[] = []; const roots: SurfaceHierarchyNode[] = []; - for (const surface of surfaces) { - const node = nodeByKey.get(surface.key)!; - node.opener = openerBySurface.get(surface.key) ?? null; - const parent = node.opener === null ? undefined : nodeByScope.get(node.opener); - if (parent && parent.surface.stackIndex < surface.stackIndex) parent.children.push(node); - else roots.push(node); - } + + const visit = ( + nodes: readonly RenderNode[], + parent: SurfaceHierarchyNode | null, + ): void => { + for (const node of nodes) { + if (node.kind !== "element") continue; + let childParent = parent; + if (node.surface) { + const surface: MountedSurface = { + key: node.key, + definition: textAttribute(node, ["aria-label", "title", "name", "id"]) + ?? `Surface ${surfaces.length + 1}`, + modality: textAttribute(node, ["data-modality", "role"]) ?? node.element, + stackIndex: surfaces.length, + relation: retainedKeys === null + ? "present" + : retainedKeys.has(node.key) + ? "retained" + : "introduced", + }; + const hierarchyNode: SurfaceHierarchyNode = { + surface, + opener: parent?.surface.key ?? null, + children: [], + }; + surfaces.push(surface); + if (parent === null) roots.push(hierarchyNode); + else parent.children.push(hierarchyNode); + childParent = hierarchyNode; + } + visit(node.children, childParent); + } + }; + + visit(document.nodes, null); + if (surfaces.length === 0) return null; return { - page: preview.content.page.route, + presentation: document.presentation, surfaces, roots, }; }; -export const directlyOpenedSurfaces = (preview: EditorPreview): MountedSurface[] => - surfaceHierarchy(preview)?.surfaces.filter((surface) => surface.relation === "direct") ?? []; +/** Surfaces present in a derived projection but absent from its direct parent. */ +export const introducedSurfaces = ( + preview: EditorPreview, + relatedPreviews: readonly EditorPreview[] = [preview], +): MountedSurface[] => + surfaceHierarchy(preview, relatedPreviews)?.surfaces.filter( + (surface) => surface.relation === "introduced", + ) ?? []; diff --git a/web/src/editor/tests/annotation-overlay.test.ts b/web/src/editor/tests/annotation-overlay.test.ts index 6fd8930..380de1e 100644 --- a/web/src/editor/tests/annotation-overlay.test.ts +++ b/web/src/editor/tests/annotation-overlay.test.ts @@ -5,18 +5,18 @@ import type { PreparedAuthoring, PreviewOccurrence } from "../editor-authoring.j import { AnnotationOverlay, composedParent, + renderSourcePanel, validateAnnotationRealizations, } from "../annotation-overlay.js"; import { RealizationResources } from "../editor-realization.js"; import type { - RenderNodeRef, SourceMetadataEntry, SourceTarget, } from "../editor-state.js"; const sourceTarget: SourceTarget = { id: "target", - class: "catalog-element", + class: "ui-element", file: "example.uhura", span: { offset: 0, @@ -28,7 +28,7 @@ const sourceTarget: SourceTarget = { owner: { kind: "component", name: "card" }, }; -const anchor: RenderNodeRef = { root: { kind: "fragment" }, path: [0, 2] }; +const anchor = "primary-action"; const occurrence: PreviewOccurrence = { previewId: "preview", occurrence: { id: "occurrence", targetId: sourceTarget.id, anchors: [anchor] }, @@ -142,6 +142,14 @@ class OverlayTestElement { this.#listeners.set(type, current); } + click(): void { + const event = { type: "click", stopPropagation: () => {} } as Event; + for (const listener of this.#listeners.get("click") ?? []) { + if (typeof listener === "function") listener.call(this, event); + else listener.handleEvent(event); + } + } + removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { const current = this.#listeners.get(type); if (!current) return; @@ -214,7 +222,7 @@ const overlayElements = ( test("validates every protocol anchor against its direct realization registry", () => { const resources = new RealizationResources(); resources.claim({}); - resources.register({ root: anchor.root, path: anchor.path, element: {} as HTMLElement }); + resources.registerKey(anchor, {} as HTMLElement); assert.doesNotThrow(() => validateAnnotationRealizations({ render: null, authoring, @@ -229,7 +237,7 @@ test("validates every protocol anchor against its direct realization registry", authoring, resourcesByPreviewId: new Map([[occurrence.previewId, incomplete]]), }), - /internal error.*target.*occurrence.*preview.*fragment\|0\.2.*did not register/s, + /internal error.*target.*occurrence.*preview.*key\|primary-action.*did not register/s, ); assert.throws( () => validateAnnotationRealizations({ @@ -241,6 +249,76 @@ test("validates every protocol anchor against its direct realization registry", ); }); +test("presents entryless projection provenance as navigation without annotation UI", () => { + const document = new OverlayTestDocument(); + const viewport = document.createElement("div"); + const overlayRoot = document.createElement("div"); + const panel = document.createElement("div"); + const realized = document.createElement("h1"); + const projectionAnchor = "heading"; + const projectionOccurrence: PreviewOccurrence = { + previewId: "page/ready", + occurrence: { + id: "occurrence/heading", + targetId: sourceTarget.id, + anchors: [projectionAnchor], + }, + }; + const projectionAuthoring: PreparedAuthoring = { + targetsById: new Map([[sourceTarget.id, sourceTarget]]), + entriesById: new Map(), + entriesByTarget: new Map(), + occurrencesByTarget: new Map([[sourceTarget.id, [projectionOccurrence]]]), + annotationTargets: [], + documentedTargets: [], + }; + const resources = new RealizationResources(); + resources.claim({}); + resources.registerKey(projectionAnchor, realized as unknown as HTMLElement); + let focusedPreview: string | null = null; + let focusedAnchors: readonly HTMLElement[] | undefined; + const focusedSources: string[] = []; + const overlay = new AnnotationOverlay({ + viewport: viewport as unknown as HTMLElement, + root: overlayRoot as unknown as HTMLElement, + focusPreview: (previewId, anchors) => { + focusedPreview = previewId; + focusedAnchors = anchors; + }, + focusSourceTarget: (targetId) => focusedSources.push(targetId), + }); + overlay.install({ + render: null, + authoring: projectionAuthoring, + resourcesByPreviewId: new Map([[projectionOccurrence.previewId, resources]]), + }); + + renderSourcePanel( + panel as unknown as HTMLElement, + projectionAuthoring, + false, + (targetId) => { + assert.equal(overlay.selectSourceTarget(targetId), true); + }, + ); + const entries = overlayElements(panel, "source-entry"); + const actions = overlayElements(panel, "source-target-select"); + assert.equal(entries.length, 1, "occurrence-backed targets appear in Source without metadata"); + assert.equal(actions.length, 1); + assert.equal(actions[0]?.textContent, "Show"); + assert.equal(actions[0]?.getAttribute("aria-label"), `Show ${sourceTarget.label} on canvas`); + assert.equal(overlayElements(panel, "annotation-entry").length, 0); + assert.equal(overlayElements(overlayRoot, "annotation-marker").length, 0); + assert.equal(overlayElements(overlayRoot, "annotation-card").length, 0); + + actions[0]?.click(); + assert.equal(focusedPreview, projectionOccurrence.previewId); + assert.deepEqual(focusedAnchors, [realized]); + assert.deepEqual(focusedSources, [sourceTarget.id]); + + overlay.dispose(); +}); + test("composed parent traversal reaches a ShadowRoot host", () => { const host = { nodeType: 1, parentNode: null } as unknown as Node; const shadow = { nodeType: 11, parentNode: null, host } as unknown as Node; @@ -367,18 +445,10 @@ test("focused preview filters presentation without becoming annotation selection }; const firstResources = new RealizationResources(); firstResources.claim({}); - firstResources.register({ - root: anchor.root, - path: anchor.path, - element: firstTarget as unknown as HTMLElement, - }); + firstResources.registerKey(anchor, firstTarget as unknown as HTMLElement); const secondResources = new RealizationResources(); secondResources.claim({}); - secondResources.register({ - root: anchor.root, - path: anchor.path, - element: secondTarget as unknown as HTMLElement, - }); + secondResources.registerKey(anchor, secondTarget as unknown as HTMLElement); const overlay = new AnnotationOverlay({ viewport: viewport as unknown as HTMLElement, root: root as unknown as HTMLElement, @@ -490,7 +560,7 @@ test("focused preview expands one collision-aware card per annotated target", () id: "target/secondary", label: "secondary button", }; - const secondAnchor: RenderNodeRef = { root: { kind: "fragment" }, path: [0, 4] }; + const secondAnchor = "secondary-action"; const entries: SourceMetadataEntry[] = [ { id: "annotation/first", @@ -554,16 +624,8 @@ test("focused preview expands one collision-aware card per annotated target", () }; const resources = new RealizationResources(); resources.claim({}); - resources.register({ - root: anchor.root, - path: anchor.path, - element: firstElement as unknown as HTMLElement, - }); - resources.register({ - root: secondAnchor.root, - path: secondAnchor.path, - element: secondElement as unknown as HTMLElement, - }); + resources.registerKey(anchor, firstElement as unknown as HTMLElement); + resources.registerKey(secondAnchor, secondElement as unknown as HTMLElement); const overlay = new AnnotationOverlay({ viewport: viewport as unknown as HTMLElement, root: root as unknown as HTMLElement, diff --git a/web/src/editor/tests/display-labels.test.ts b/web/src/editor/tests/display-labels.test.ts new file mode 100644 index 0000000..d7d9940 --- /dev/null +++ b/web/src/editor/tests/display-labels.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + editorIdentifierLabel, + editorPreviewLabels, + editorSubjectLabel, +} from "../display-labels.js"; +import type { PreviewIdentity } from "../editor-state.js"; + +const identity = ( + kind: PreviewIdentity["kind"], + subject: string, + example: string, +): PreviewIdentity => ({ kind, subject, example }); + +test("qualified 0.4 page and evidence identities become compact Editor labels", () => { + const preview = identity( + "page", + "app.instagram@1::FeedPage", + "app.instagram.evidence@1::feed_first_page", + ); + + assert.deepEqual(editorPreviewLabels(preview), { + subject: "feed", + example: "first-page", + combined: "feed / first-page", + }); + assert.deepEqual(preview, { + kind: "page", + subject: "app.instagram@1::FeedPage", + example: "app.instagram.evidence@1::feed_first_page", + }, "display derivation never mutates semantic identity"); +}); + +test("identifier labels humanize PascalCase, acronyms, and snake case", () => { + assert.equal(editorIdentifierLabel("app.example@2::HTTPStatusCard"), "http-status-card"); + assert.equal(editorIdentifierLabel("app.example@2::save_in_flight"), "save-in-flight"); + assert.equal( + editorSubjectLabel({ kind: "surface", subject: "app.example@2::CommentsSheet" }), + "comments-sheet", + ); + assert.equal( + editorSubjectLabel({ kind: "component", subject: "app.example@2::LandingPage" }), + "landing-page", + "Page is semantic for non-page subjects", + ); +}); + +test("page suffix removal is total for a subject named only Page", () => { + assert.equal( + editorSubjectLabel({ kind: "page", subject: "app.example@2::Page" }), + "page", + ); +}); + +test("example prefixes are retained when shortening would collide", () => { + const prefixed = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::feed_first_page", + ); + const alreadyShort = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::first_page", + ); + const peers = [prefixed, alreadyShort]; + + assert.equal(editorPreviewLabels(prefixed, peers).example, "feed-first-page"); + assert.equal(editorPreviewLabels(alreadyShort, peers).example, "first-page"); +}); + +test("only an exact subject-token prefix is removed", () => { + const preview = identity( + "page", + "app.example@1::FeedPage", + "app.example.evidence@1::feedback_default", + ); + assert.equal(editorPreviewLabels(preview).example, "feedback-default"); +}); diff --git a/web/src/editor/tests/editor-authoring.test.ts b/web/src/editor/tests/editor-authoring.test.ts index 6c55499..2776c62 100644 --- a/web/src/editor/tests/editor-authoring.test.ts +++ b/web/src/editor/tests/editor-authoring.test.ts @@ -18,6 +18,7 @@ import { renderedOccurrences, sourceActionsEnabled, } from "../editor-authoring.js"; +import { projectionContent } from "./fixtures/projection.js"; const span = (offset: number): EditorSourceSpan => ({ offset, @@ -75,15 +76,16 @@ const preview = (entries: { id: string; targetId: string; anchored: boolean }[]) occurrences: entries.map((item) => ({ id: item.id, targetId: item.targetId, - anchors: item.anchored ? [{ root: { kind: "fragment" }, path: [] }] : [], + anchors: item.anchored ? ["root"] : [], })), }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const render = (): EditorRender => { const targets = [ - target("annotation", "z.uhura", 5, "catalog-element"), + target("annotation", "z.uhura", 5, "ui-element"), target( "declaration", "a.uhura", @@ -113,6 +115,7 @@ const render = (): EditorRender => { stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }; }; diff --git a/web/src/editor/tests/editor-board.test.ts b/web/src/editor/tests/editor-board.test.ts index 0341d77..5f95f0b 100644 --- a/web/src/editor/tests/editor-board.test.ts +++ b/web/src/editor/tests/editor-board.test.ts @@ -17,11 +17,11 @@ import { import type { EditorRender, PreviewFreshness, - RenderNodeRef, SourceMetadataEntry, SourceTarget, } from "../editor-state.js"; import type { IconFontRegistry } from "../../renderer/icons.js"; +import { elementNode, projectionContent, textNode } from "./fixtures/projection.js"; const TEST_ICONS: IconFontRegistry = { defaultFamily: "lucide", @@ -170,6 +170,16 @@ class FakeContainer extends FakeNode { } } +class FakeText extends FakeNode { + data: string; + __uhuraKey?: string; + + constructor(ownerDocument: FakeDocument, data: string) { + super(ownerDocument, 3); + this.data = data; + } +} + class FakeShadowRoot extends FakeContainer { adoptedStyleSheets: FakeStyleSheet[] = []; readonly host: FakeElement; @@ -190,6 +200,7 @@ class FakeElement extends FakeContainer { readonly dataset: Record = {}; readonly style = new FakeStyle(); readonly tagName: string; + readonly localName: string; readonly #listeners = new Map(); shadowRoot: FakeShadowRoot | null = null; className = ""; @@ -207,6 +218,7 @@ class FakeElement extends FakeContainer { constructor(ownerDocument: FakeDocument, tagName: string) { super(ownerDocument, 1); this.tagName = tagName.toUpperCase(); + this.localName = tagName.toLowerCase(); this.classList = new FakeClassList(this); } @@ -290,6 +302,10 @@ class FakeDocument { return this.createElement(tagName); } + createTextNode(data: string): FakeText { + return new FakeText(this, data); + } + createDocumentFragment(): FakeDocumentFragment { return new FakeDocumentFragment(this); } @@ -298,7 +314,7 @@ class FakeDocument { const asDocument = (document: FakeDocument): Document => document as unknown as Document; const asElement = (element: FakeElement): HTMLElement => element as unknown as HTMLElement; -const anchor: RenderNodeRef = { root: { kind: "fragment" }, path: [] }; +const anchor = "root"; const span = { offset: 24, @@ -309,7 +325,7 @@ const span = { const target: SourceTarget = { id: "target:button", - class: "catalog-element", + class: "ui-element", file: "components/card.uhura", span, label: "button", @@ -363,15 +379,15 @@ const render = ( anchors: [anchor], }], }, - content: { - key: "root", - element: "text", - props: { content: { t: "plain", v: "Stable semantic content" } }, - }, + evidence: null, + content: projectionContent([ + elementNode("root", [textNode("content", "Stable semantic content")]), + ]), }], stylesheet: ":root { --accent: blue; } body { color: black; }", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); const annotationText = (model: PreparedEditorModel): string | undefined => @@ -567,6 +583,64 @@ test("caption chrome can replace while its semantic ShadowRoot stays exact", () disposePreparedEditorModel(captionUpdate); }); +test("0.4 public identities keep semantic joins but render friendly board labels", () => { + const document = new FakeDocument(); + const qualified = render(1, "current", "Friendly labels"); + const group = qualified.groups[0]!; + const preview = qualified.previews[0]!; + group.id = "page:app.instagram@1::FeedPage"; + group.kind = "page"; + group.subject = "app.instagram@1::FeedPage"; + group.previews = ["feed/first-page"]; + preview.id = "feed/first-page"; + preview.identity = { + kind: "page", + subject: "app.instagram@1::FeedPage", + example: "app.instagram.evidence@1::feed_first_page", + }; + preview.sourceFile = "ui.uhura"; + + const model = prepareEditorModel(asDocument(document), qualified, null, TEST_ICONS); + const board = model.board as unknown as FakeElement; + const navigator = model.navigator as unknown as FakeDocumentFragment; + + assert.deepEqual( + classElements(board, "row-title").map((node) => node.textContent), + ["page feed"], + ); + assert.deepEqual( + classElements(board, "caption-title").map((node) => node.textContent), + ["feed / first-page"], + ); + assert.deepEqual( + navigator.descendants() + .filter((node) => node.classList.contains("navigator-row-title")) + .map((node) => node.textContent), + ["feed"], + ); + assert.deepEqual( + navigator.descendants() + .filter((node) => node.classList.contains("navigator-frame-title")) + .map((node) => node.textContent), + ["first-page"], + ); + const search = navigator.descendants() + .find((node) => node.classList.contains("navigator-frame")) + ?.dataset.search ?? ""; + assert.match(search, /app\.instagram@1::feedpage/); + assert.match(search, /app\.instagram\.evidence@1::feed_first_page/); + assert.equal( + model.previewIdByIdentity.get(JSON.stringify([ + "page", + "app.instagram@1::FeedPage", + "app.instagram.evidence@1::feed_first_page", + ])), + "feed/first-page", + ); + + disposePreparedEditorModel(model); +}); + test("all rendered occurrences keep one badge while preview selection only decorates them", () => { const document = new FakeDocument(); const root = document.createElement("main"); diff --git a/web/src/editor/tests/editor-focus.test.ts b/web/src/editor/tests/editor-focus.test.ts index 118f9d5..a3c8aa1 100644 --- a/web/src/editor/tests/editor-focus.test.ts +++ b/web/src/editor/tests/editor-focus.test.ts @@ -14,6 +14,7 @@ import type { EditorState, PreviewIdentity, } from "../editor-state.js"; +import { projectionContent } from "./fixtures/projection.js"; const identity = ( subject: string, @@ -36,7 +37,8 @@ const preview = (id: string, previewIdentity: PreviewIdentity): EditorPreview => interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const render = (previews: EditorPreview[]): EditorRender => ({ @@ -49,10 +51,11 @@ const render = (previews: EditorPreview[]): EditorRender => ({ stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); const state = (value: EditorRender | null): EditorState => ({ - protocol: "uhura-editor-state/2", + protocol: "uhura-editor-state/4", sourceRevision: 1, diagnostics: null, render: value, diff --git a/web/src/editor/tests/editor-icons.test.ts b/web/src/editor/tests/editor-icons.test.ts new file mode 100644 index 0000000..c28016a --- /dev/null +++ b/web/src/editor/tests/editor-icons.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { projectionNeedsIconFonts } from "../editor.js"; +import { + elementNode, + textNode, +} from "./fixtures/projection.js"; + +describe("Editor projection icon resources", () => { + it("loads icon fonts only when a canonical projection contains an icon", () => { + expect(projectionNeedsIconFonts([ + elementNode("root", [ + textNode("copy", "No icon"), + ]), + ])).toBe(false); + + expect(projectionNeedsIconFonts([ + elementNode("root", [ + elementNode("nested", [ + elementNode("heart", [], { element: "icon" }), + ]), + ]), + ])).toBe(true); + }); +}); diff --git a/web/src/editor/tests/editor-realization.test.ts b/web/src/editor/tests/editor-realization.test.ts index f5b86df..b472ba2 100644 --- a/web/src/editor/tests/editor-realization.test.ts +++ b/web/src/editor/tests/editor-realization.test.ts @@ -3,25 +3,33 @@ import { test } from "vitest"; import { RealizationResources } from "../editor-realization.js"; -test("registry resolves direct semantic references and transfers one live owner", () => { +test("registry resolves semantic keys and transfers one live owner", () => { const resources = new RealizationResources(); const firstOwner = {}; const nextOwner = {}; const element = {} as HTMLElement; resources.claim(firstOwner); - resources.register({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0], element }); + resources.registerKey("sheet:2/action", element); - assert.equal( - resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0] }), - element, - ); - assert.equal(resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [0] }), null); + assert.equal(resources.resolve("sheet:2/action"), element); + assert.equal(resources.resolve("sheet:2/missing"), null); resources.transfer(firstOwner, nextOwner); resources.release(firstOwner); assert.equal(resources.disposed, false, "the old model cannot dispose a transplanted registry"); resources.release(nextOwner); assert.equal(resources.disposed, true); - assert.equal(resources.resolve({ root: { kind: "surface", key: "sheet:2" }, path: [1, 0] }), null); + assert.equal(resources.resolve("sheet:2/action"), null); +}); + +test("registry resolves semantic keys without ShadowRoot queries", () => { + const resources = new RealizationResources(); + const owner = {}; + const element = {} as HTMLElement; + resources.claim(owner); + resources.registerKey("main/action", element); + + assert.equal(resources.resolve("main/action"), element); + assert.equal(resources.resolve("main/missing"), null); }); test("unused candidate resources dispose independently", () => { @@ -62,7 +70,7 @@ test("watchers move with ownership and release scroll/resize resources", () => { const nextOwner = {}; const resources = new RealizationResources(); resources.claim(firstOwner); - resources.register({ root: { kind: "fragment" }, path: [], element: realized }); + resources.registerKey("root", realized); let firstInvalidations = 0; resources.watch(firstOwner, frame, window, () => { firstInvalidations += 1; }); root.dispatchEvent(new Event("scroll")); diff --git a/web/src/editor/tests/editor-state.test.ts b/web/src/editor/tests/editor-state.test.ts index 732cf9e..df56944 100644 --- a/web/src/editor/tests/editor-state.test.ts +++ b/web/src/editor/tests/editor-state.test.ts @@ -1,21 +1,15 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; import { test } from "vitest"; import { decodeEditorRevisionEvent, decodeEditorState, + EDITOR_STATE_PROTOCOL, EditorContractError, type EditorState, } from "../editor-state.js"; -const node = { - key: "root", - element: "text", - props: { content: { t: "plain", v: "Hello" } }, -}; - const span = (offset: number, len: number, line: number, col: number) => ({ offset, len, @@ -35,8 +29,13 @@ const diagnostics = (message: string): Record => ({ }], }); -const stateFixture = (): unknown => ({ - protocol: "uhura-editor-state/2", +const stateFixture = (): { + protocol: string; + sourceRevision: number; + diagnostics: unknown; + render: Record | null; +} => ({ + protocol: "uhura-editor-state/4", sourceRevision: 3, diagnostics: null, render: { @@ -47,37 +46,37 @@ const stateFixture = (): unknown => ({ targets: [{ id: "target:feed", class: "page-declaration", - file: "pages/feed.uhura", + file: "web.uhura", span: span(20, 9, 2, 1), - label: "page feed", - owner: { kind: "page", name: "feed" }, + label: "Feed", + owner: { kind: "page", name: "example@1::Feed" }, }, { id: "target:primary-action", - class: "catalog-element", - file: "pages/feed.uhura", + class: "ui-element", + file: "web.uhura", span: span(80, 10, 6, 1), label: "button", - owner: { kind: "page", name: "feed" }, + owner: { kind: "page", name: "example@1::Feed" }, }, { id: "target:feed-example", class: "example-declaration", - file: "pages/feed.examples.uhura", + file: "evidence.uhura", span: span(20, 15, 2, 1), label: "default", - owner: { kind: "examples", name: "pages/feed.examples.uhura" }, + owner: { kind: "examples", name: "evidence.uhura" }, }], entries: [{ id: "doc:feed", class: "doc", kind: "doc", - text: "The feed page.", + text: "The feed presentation.", span: span(0, 18, 1, 1), targetId: "target:feed", order: 0, }, { id: "annotation:primary-action:0", class: "annotation", - kind: "doc", + kind: "review-note", text: "The primary action.", span: span(50, 28, 5, 1), targetId: "target:primary-action", @@ -86,7 +85,7 @@ const stateFixture = (): unknown => ({ id: "doc:feed-example", class: "doc", kind: "doc", - text: "The default feed example.", + text: "The default evidence pin.", span: span(0, 18, 1, 1), targetId: "target:feed-example", order: 0, @@ -95,15 +94,15 @@ const stateFixture = (): unknown => ({ groups: [{ id: "page-feed", kind: "page", - subject: "feed", + subject: "example@1::Feed", previews: ["page-feed-default"], }], previews: [{ id: "page-feed-default", - identity: { kind: "page", subject: "feed", example: "default" }, - sourceFile: "app/feed/page.uhura", + identity: { kind: "page", subject: "example@1::Feed", example: "default" }, + sourceFile: "web.uhura", default: true, - pinned: false, + pinned: true, derived: false, inFlight: 0, from: null, @@ -113,8 +112,8 @@ const stateFixture = (): unknown => ({ kind: "semantic", payload: { id: "post-1" }, dispatch: { - scope: "page:1", - definition: "feed", + scope: "entry/example", + definition: "example@1::App", on: "opened", guards: [ { handler: 0, result: "unsatisfied" }, @@ -139,11 +138,9 @@ const stateFixture = (): unknown => ({ status: "ready", value: "Feed", source: { - kind: "fixture", - declaredIn: "pages/feed.uhura", + kind: "inline", + declaredIn: "web.uhura", timeline: false, - fixture: "feed-default", - path: ["viewer", "feed"], }, }], interactions: [{ @@ -152,7 +149,7 @@ const stateFixture = (): unknown => ({ kind: "input", event: "press", emit: "opened", - scope: "page:1", + scope: "entry/example", payload: { id: "post-1" }, carries: { query: "text" }, }], @@ -164,14 +161,49 @@ const stateFixture = (): unknown => ({ occurrences: [{ id: "occurrence:primary-action:0", targetId: "target:primary-action", - anchors: [{ root: { kind: "page" }, path: [] }], + anchors: ["root"], }], }, + evidence: { + scenario: "ready", + pin: "default", + sourceId: "evidence/default", + sources: { + registration: { path: "evidence.uhura", start: 0, end: 8 }, + pin: { path: "evidence.uhura", start: 9, end: 12 }, + }, + observation: { state: "ready" }, + snapshot: { sequence: "0" }, + scenarioReceiptLog: null, + }, content: { - protocol: "uhura-view/0", - revision: 0, - page: { route: "feed", root: node }, - surfaces: [], + kind: "projection", + value: { + document: { + protocol: "uhura-view/1", + presentation: "example@1::Feed", + machine: "example@1::App", + instance: "entry/example", + sequence: "0", + nodes: [{ + kind: "element", + key: "root", + element: "main", + attributes: [], + events: [], + children: [{ kind: "text", key: "label", text: "Ready" }], + surface: false, + }], + }, + sources: { + protocol: "uhura-projection-sources/0", + presentation: "example@1::Feed", + nodes: { + root: { id: "ui/root", path: "web.uhura", start: 0, end: 4 }, + label: { id: "ui/label", path: "web.uhura", start: 5, end: 10 }, + }, + }, + }, }, }], stylesheet: ":root { --accent: blue; }", @@ -184,386 +216,142 @@ const stateFixture = (): unknown => ({ entry: "page:feed", nodes: [ { id: "page:feed", kind: "page", label: "feed" }, - { id: "surface:comments-sheet", kind: "surface", label: "comments-sheet", modality: "sheet" }, + { id: "surface:comments", kind: "surface", label: "comments", modality: "dialog" }, ], edges: [{ - id: "pages.feed/handler/0/stmt/0", + id: "edge/0", kind: "present", from: "page:feed", - to: "surface:comments-sheet", + to: "surface:comments", event: "comments-requested", - guard: { t: "bool", v: true }, }], }, + machine: { + protocol: "uhura-machine-inspection/0", + identityProtocol: "uhura-semantic-ir-hash/0", + deployment: { machine: "example@1::App" }, + sources: [], + provenance: null, + interactionGraph: { + protocol: "uhura-interaction-graph/0", + identity_protocol: "uhura-semantic-ir-hash/0", + machine_program_hashes: {}, + presentation_hashes: {}, + outcome_policies: {}, + nodes: [], + edges: [], + }, + graphSources: { + protocol: "uhura-interaction-graph-provenance/0", + nodes: [], + edges: [], + }, + checkpoints: {}, + evidence: {}, + }, }, }); -test("decodes the complete fixed EditorState contract", () => { +test("decodes the canonical projection-only EditorState/4 contract", () => { const state = decodeEditorState(stateFixture()); - - assert.equal(state.protocol, "uhura-editor-state/2"); - assert.equal(state.sourceRevision, 3); - assert.equal(state.render?.previews[0]?.data[0]?.source?.kind, "fixture"); - assert.equal(state.render?.previews[0]?.sourceFile, "app/feed/page.uhura"); - assert.deepEqual(state.render?.previews[0]?.interactions[0]?.payload, { id: "post-1" }); - assert.equal(state.render?.previews[0]?.replay[0]?.dispatch?.selected, 1); - assert.deepEqual(state.render?.previews[0]?.replay[0]?.effects.writes[0], { - field: "selected", - value: "post-1", - }); - assert.equal(state.render?.authoring.entries[1]?.class, "annotation"); - assert.deepEqual(state.render?.previews[0]?.provenance.occurrences[0]?.anchors[0], { - root: { kind: "page" }, - path: [], - }); const preview = state.render?.previews[0]; - const declarationDoc = state.render?.authoring.entries.find((entry) => - entry.id === preview?.documentation.declarationDocId); - const declarationTarget = state.render?.authoring.targets.find((target) => - target.id === declarationDoc?.targetId); - assert.equal(declarationDoc?.class, "doc"); - assert.equal(declarationTarget?.class, "page-declaration"); - assert.equal(declarationTarget?.owner.name, preview?.identity.subject); - - const exampleDoc = state.render?.authoring.entries.find((entry) => - entry.id === preview?.documentation.exampleDocId); - const exampleTarget = state.render?.authoring.targets.find((target) => - target.id === exampleDoc?.targetId); - assert.equal(exampleDoc?.class, "doc"); - assert.equal(exampleTarget?.class, "example-declaration"); - assert.equal(exampleTarget?.label, preview?.identity.example); - assert.equal(state.render?.interactionGraph.protocol, "uhura-interaction-graph/0"); - assert.equal(state.render?.interactionGraph.nodes[1]?.kind, "surface"); + assert.equal(state.protocol, EDITOR_STATE_PROTOCOL); + assert.equal(preview?.content.kind, "projection"); + assert.equal(preview?.content.value.document.protocol, "uhura-view/1"); + assert.deepEqual(preview?.provenance.occurrences[0]?.anchors, ["root"]); + assert.equal(preview?.evidence?.scenario, "ready"); + assert.equal(state.render?.machine?.identityProtocol, "uhura-semantic-ir-hash/0"); assert.deepEqual(state.render?.interactionGraph.edges[0], { kind: "present", from: "page:feed", - to: "surface:comments-sheet", + to: "surface:comments", event: "comments-requested", - }, "the decoder keeps only the drawn fields of a graph edge"); -}); - -test("decodes the native model's canonical contract fixture", () => { - const fixture = JSON.parse(readFileSync(new URL( - "../../../../crates/uhura-editor-model/tests/fixtures/editor-state.json", - import.meta.url, - ), "utf8")) as unknown; - - const state = decodeEditorState(fixture); - const render = state.render; - assert.ok(render); - assert.equal(render.previews.length, 3); - assert.equal(render.previews[1]?.identity.kind, "surface"); - assert.equal(render.authoring.targets.length, 3); - assert.equal(render.authoring.entries.length, 3); - assert.equal(render.interactionGraph.protocol, "uhura-interaction-graph/0"); - assert.equal(render.interactionGraph.nodes.length, 4); - assert.equal(render.interactionGraph.edges.length, 0); - - const page = render.previews.find((preview) => preview.id === "page/home/default"); - assert.ok(page); - const declarationDoc = render.authoring.entries.find((entry) => - entry.id === page.documentation.declarationDocId); - const exampleDoc = render.authoring.entries.find((entry) => - entry.id === page.documentation.exampleDocId); - assert.equal(declarationDoc?.class, "doc"); - assert.equal(exampleDoc?.class, "doc"); - assert.equal( - render.authoring.targets.find((target) => target.id === declarationDoc?.targetId)?.class, - "page-declaration", - ); - assert.equal( - render.authoring.targets.find((target) => target.id === exampleDoc?.targetId)?.class, - "example-declaration", - ); - - const annotation = render.authoring.entries.find((entry) => entry.class === "annotation"); - const occurrence = page.provenance.occurrences[0]; - assert.ok(annotation); - assert.ok(occurrence); - assert.equal(occurrence.targetId, annotation.targetId); - assert.deepEqual(occurrence.anchors, [{ root: { kind: "page" }, path: [] }]); -}); - -test("accepts explicit cold-invalid and stale render states", () => { - const cold = stateFixture() as Record; - cold["sourceRevision"] = 4; - cold["diagnostics"] = diagnostics("broken source"); - cold["render"] = null; - assert.equal(decodeEditorState(cold).render, null); - - const stale = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; - }; - stale.sourceRevision = 4; - stale.render.revision = 3; - stale.render.freshness = "stale"; - const decodedStale = decodeEditorState(stale); - assert.equal(decodedStale.render?.freshness, "stale"); - assert.equal(decodedStale.render?.authoring.entries.length, 3); - assert.equal( - decodedStale.render?.previews[0]?.provenance.occurrences.length, - 1, - "stale metadata and provenance stay owned by the retained render", - ); -}); - -test("rejects malformed or internally inconsistent diagnostics envelopes", () => { - const missingVersion = stateFixture() as Record; - missingVersion["diagnostics"] = { diagnostics: [] }; - assert.throws(() => decodeEditorState(missingVersion), /no unknown property|format/); - - const wrongCounts = stateFixture() as Record; - wrongCounts["diagnostics"] = diagnostics("broken source"); - (wrongCounts["diagnostics"] as { summary: { errors: number } }).summary.errors = 0; - assert.throws(() => decodeEditorState(wrongCounts), /counts matching diagnostics/); -}); - -test("enforces current and stale revision invariants", () => { - const current = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; - }; - current.sourceRevision = 4; - assert.throws(() => decodeEditorState(current), EditorContractError); - - const stale = stateFixture() as { - sourceRevision: number; - render: { revision: number; freshness: string }; - }; - stale.render.freshness = "stale"; - assert.throws(() => decodeEditorState(stale), /less than sourceRevision/); + }); }); -test("rejects unknown properties, malformed data variants, and content-kind drift", () => { - const unknown = stateFixture() as { render: { previews: Array> } }; - unknown.render.previews[0]!["html"] = "

not semantic

"; - assert.throws(() => decodeEditorState(unknown), /no unknown property/); - - const legacyIcons = stateFixture() as { render: Record }; - legacyIcons.render["icons"] = { heart: { viewBox: [0, 0, 24, 24], commands: [] } }; +test("strictly decodes the same machine graph artifact consumed by Play", () => { + const invalid = stateFixture(); + invalid.render!.machine.interactionGraph = {}; assert.throws( - () => decodeEditorState(legacyIcons), - /no unknown property/, - "EditorState/2 rejects engine-delivered glyph geometry", + () => decodeEditorState(invalid), + /interaction graph has the wrong fields/u, ); - - const waitingWithValue = stateFixture() as { - render: { previews: Array<{ data: Array> }> }; - }; - waitingWithValue.render.previews[0]!.data[0]!["status"] = "waiting"; - assert.throws(() => decodeEditorState(waitingWithValue), /no value unless status is ready/); - - const fragmentPage = stateFixture() as { - render: { previews: Array> }; - }; - fragmentPage.render.previews[0]!["content"] = node; - assert.throws(() => decodeEditorState(fragmentPage), /uhura-view\/0 snapshot/); - - const invalidGuard = stateFixture() as { - render: { previews: Array<{ replay: Array<{ dispatch: { guards: Array<{ result: string }> } }> }> }; - }; - invalidGuard.render.previews[0]!.replay[0]!.dispatch.guards[0]!.result = "maybe"; - assert.throws(() => decodeEditorState(invalidGuard), /"satisfied" or "unsatisfied" or "not-ready"/); - - const mismatchedReplay = stateFixture() as { - render: { previews: Array<{ replaySteps: string[] }> }; - }; - mismatchedReplay.render.previews[0]!.replaySteps[0] = "other-event"; - assert.throws(() => decodeEditorState(mismatchedReplay), /details matching replaySteps in order/); }); -test("enforces group references, identity matching, and unique IDs", () => { - const missing = stateFixture() as { - render: { groups: Array<{ previews: string[] }> }; - }; - missing.render.groups[0]!.previews = ["unknown"]; - assert.throws(() => decodeEditorState(missing), /existing preview id/); +test("rejects every retired Editor view and structural anchor encoding", () => { + const oldProtocol = stateFixture(); + oldProtocol.protocol = "uhura-editor-state/3"; + assert.throws(() => decodeEditorState(oldProtocol), /uhura-editor-state\/4/); - const duplicate = stateFixture() as { - render: { previews: unknown[]; groups: Array<{ previews: string[] }> }; - }; - duplicate.render.previews.push(structuredClone(duplicate.render.previews[0])); - duplicate.render.groups[0]!.previews.push("page-feed-default"); - assert.throws(() => decodeEditorState(duplicate), /unique values/); + for (const kind of ["snapshot", "fragment"]) { + const oldContent = stateFixture(); + oldContent.render!.previews[0].content = { kind, value: {} }; + assert.throws(() => decodeEditorState(oldContent), /"projection"/); + } - const missingParent = stateFixture() as { - render: { previews: Array<{ from: string | null }> }; - }; - missingParent.render.previews[0]!.from = "missing"; - assert.throws(() => decodeEditorState(missingParent), /existing example in the same subject/); + const pathAnchor = stateFixture(); + pathAnchor.render!.previews[0].provenance.occurrences[0].anchors = [{ + kind: "path", + root: { kind: "page" }, + path: [], + }]; + assert.throws(() => decodeEditorState(pathAnchor), /non-empty string/); }); -test("strictly validates authoring classes, ranges, kinds, and references", () => { - const malformedKind = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - malformedKind.render.authoring.entries[1]!["kind"] = "Review_Note"; - assert.throws(() => decodeEditorState(malformedKind), /annotation metadata/); +test("validates projection source coverage and semantic anchor keys", () => { + const missingSource = stateFixture(); + delete missingSource.render!.previews[0].content.value.sources.nodes.label; + assert.throws(() => decodeEditorState(missingSource), /must address every rendered key exactly/); - const missingTarget = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - missingTarget.render.authoring.entries[0]!["targetId"] = "missing"; - assert.throws(() => decodeEditorState(missingTarget), /existing source target id/); + const unknownAnchor = stateFixture(); + unknownAnchor.render!.previews[0].provenance.occurrences[0].anchors = ["missing"]; + assert.throws(() => decodeEditorState(unknownAnchor), /semantic node key/); - const invalidRange = stateFixture() as { - render: { authoring: { targets: Array<{ span: { start: { line: number } } }> } }; - }; - invalidRange.render.authoring.targets[0]!.span.start.line = 0; - assert.throws(() => decodeEditorState(invalidRange), /positive integer/); - - const annotationOnDocTarget = stateFixture() as { - render: { authoring: { entries: Array> } }; - }; - annotationOnDocTarget.render.authoring.entries[1]!["targetId"] = "target:feed"; - assert.throws(() => decodeEditorState(annotationOnDocTarget), /annotation metadata/); - - const unusedTarget = stateFixture() as { - render: { authoring: { targets: Array> } }; - }; - const extra = structuredClone(unusedTarget.render.authoring.targets[0]!); - extra["id"] = "target:unused"; - unusedTarget.render.authoring.targets.push(extra); - assert.throws(() => decodeEditorState(unusedTarget), /only metadata-referenced targets/); + const duplicateAnchor = stateFixture(); + duplicateAnchor.render!.previews[0].provenance.occurrences[0].anchors = ["root", "root"]; + assert.throws(() => decodeEditorState(duplicateAnchor), /unique values/); }); -test("annotation kinds use the full ASCII lower-kebab grammar", () => { - for (const kind of ["a", "a0", "a-0", "review-note", "a".repeat(64)]) { - const fixture = stateFixture() as { - render: { authoring: { entries: Array<{ kind: string }> } }; - }; - fixture.render.authoring.entries[1]!.kind = kind; - assert.equal(decodeEditorState(fixture).render?.authoring.entries[1]?.kind, kind); - } - for (const kind of [ - "", - "0note", - "Review", - "review_note", - "-note", - "note-", - "note--later", - "nöté", - "a".repeat(65), - ]) { - const fixture = stateFixture() as { - render: { authoring: { entries: Array<{ kind: string }> } }; - }; - fixture.render.authoring.entries[1]!.kind = kind; - assert.throws( - () => decodeEditorState(fixture), - /annotation metadata|non-empty string/, - kind, - ); - } -}); - -test("validates documentation and semantic provenance while allowing zero anchors", () => { - const wrongSourceFile = stateFixture() as { - render: { previews: Array<{ sourceFile: string }> }; - }; - wrongSourceFile.render.previews[0]!.sourceFile = "../feed.uhura"; - assert.throws(() => decodeEditorState(wrongSourceFile), /canonical project-relative source path/); - - const zeroAnchors = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - zeroAnchors.render.previews[0]!.provenance.occurrences[0]!.anchors = []; - assert.equal( - decodeEditorState(zeroAnchors).render?.previews[0]?.provenance.occurrences[0]?.anchors.length, - 0, - ); - - const wrongDoc = stateFixture() as { - render: { previews: Array<{ documentation: { declarationDocId: string } }> }; - }; - wrongDoc.render.previews[0]!.documentation.declarationDocId = "annotation:primary-action:0"; - assert.throws(() => decodeEditorState(wrongDoc), /doc entry for page-declaration/); - - const wrongDeclarationOwner = stateFixture() as { - render: { authoring: { targets: Array<{ owner: { name: string } }> } }; - }; - wrongDeclarationOwner.render.authoring.targets[0]!.owner.name = "another-page"; - assert.throws(() => decodeEditorState(wrongDeclarationOwner), /doc entry for page-declaration/); +test("accepts cold-invalid and stale render states with strict revisions", () => { + const cold = stateFixture(); + cold.sourceRevision = 4; + cold.diagnostics = diagnostics("broken source"); + cold.render = null; + assert.equal(decodeEditorState(cold).render, null); - const wrongExample = stateFixture() as { - render: { authoring: { targets: Array<{ label: string }> } }; - }; - wrongExample.render.authoring.targets[2]!.label = "another-example"; - assert.throws(() => decodeEditorState(wrongExample), /doc entry for example-declaration/); + const stale = stateFixture(); + stale.sourceRevision = 4; + stale.render!.revision = 3; + stale.render!.freshness = "stale"; + assert.equal(decodeEditorState(stale).render?.freshness, "stale"); - const wrongRoot = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - wrongRoot.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "fragment" }, - path: [], - }]; - assert.throws(() => decodeEditorState(wrongRoot), /semantic node path/); + const invalidCurrent = stateFixture(); + invalidCurrent.sourceRevision = 4; + assert.throws(() => decodeEditorState(invalidCurrent), /sourceRevision 4/); - const wrongPath = stateFixture() as { - render: { previews: Array<{ provenance: { occurrences: Array<{ anchors: unknown[] }> } }> }; - }; - wrongPath.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "page" }, - path: [9], - }]; - assert.throws(() => decodeEditorState(wrongPath), /semantic node path/); + const invalidStale = stateFixture(); + invalidStale.render!.freshness = "stale"; + assert.throws(() => decodeEditorState(invalidStale), /less than sourceRevision/); }); -test("resolves surface roots by semantic key and rejects malformed root variants", () => { - const withSurface = stateFixture() as { - render: { - previews: Array<{ - content: { surfaces: unknown[] }; - provenance: { occurrences: Array<{ anchors: unknown[] }> }; - }>; - }; - }; - withSurface.render.previews[0]!.content.surfaces.push({ - key: "sheet:1", - definition: "sheet", - modality: "sheet", - dismiss: { - kind: "input", - event: "dismiss", - emit: "dismissed", - scope: "surface:1", - payload: {}, - }, - root: { key: "surface-root", element: "view", props: {} }, - }); - withSurface.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "surface", key: "sheet:1" }, - path: [], - }]; - assert.equal( - decodeEditorState(withSurface).render?.previews[0] - ?.provenance.occurrences[0]?.anchors[0]?.root.kind, - "surface", - ); +test("strictly validates diagnostics, authoring, replay, and group references", () => { + const wrongCounts = stateFixture(); + wrongCounts.diagnostics = diagnostics("broken source"); + (wrongCounts.diagnostics as { summary: { errors: number } }).summary.errors = 0; + assert.throws(() => decodeEditorState(wrongCounts), /counts matching diagnostics/); - const missingSurface = structuredClone(withSurface); - missingSurface.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "surface", key: "missing" }, - path: [], - }]; - assert.throws(() => decodeEditorState(missingSurface), /semantic node path/); + const unknownTarget = stateFixture(); + unknownTarget.render!.previews[0].provenance.occurrences[0].targetId = "missing"; + assert.throws(() => decodeEditorState(unknownTarget), /annotatable source target/); - const duplicateSurface = structuredClone(withSurface); - duplicateSurface.render.previews[0]!.content.surfaces.push(structuredClone( - duplicateSurface.render.previews[0]!.content.surfaces[0], - )); - assert.throws(() => decodeEditorState(duplicateSurface), /semantic node path/); + const mismatchedReplay = stateFixture(); + mismatchedReplay.render!.previews[0].replaySteps[0] = "other"; + assert.throws(() => decodeEditorState(mismatchedReplay), /matching replaySteps/); - const pageWithKey = structuredClone(withSurface); - pageWithKey.render.previews[0]!.provenance.occurrences[0]!.anchors = [{ - root: { kind: "page", key: "illegal" }, - path: [], - }]; - assert.throws(() => decodeEditorState(pageWithKey), /no unknown property/); + const missingPreview = stateFixture(); + missingPreview.render!.groups[0].previews = ["missing"]; + assert.throws(() => decodeEditorState(missingPreview), /existing preview id/); }); test("decodes only the versioned revision event", () => { diff --git a/web/src/editor/tests/editor-updates.test.ts b/web/src/editor/tests/editor-updates.test.ts index d3f45be..30b1ec6 100644 --- a/web/src/editor/tests/editor-updates.test.ts +++ b/web/src/editor/tests/editor-updates.test.ts @@ -14,9 +14,10 @@ import { reusablePreviewFrameIds, reusablePreviewIds, } from "../editor-updates.js"; +import { elementNode, projectionContent, textNode } from "./fixtures/projection.js"; const state = (sourceRevision: number): EditorState => ({ - protocol: "uhura-editor-state/2", + protocol: "uhura-editor-state/4", sourceRevision, diagnostics: null, render: null, @@ -43,11 +44,10 @@ const preview = (id: string, content = id): EditorPreview => ({ interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - key: "root", - element: "text", - props: { content: { t: "plain", v: content } }, - }, + evidence: null, + content: projectionContent([ + elementNode("root", [textNode("content", content)]), + ]), }); const render = ( @@ -70,6 +70,7 @@ const render = ( photo: { dataUri: "data:image/png;base64,AA==", alt: "Photo" }, }, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }); test("every connection open fetches, including equal counters after a restart", () => { @@ -170,11 +171,13 @@ test("semantic selection survives replacement and disappears with its preview", interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }], stylesheet: "", assets: {}, interactionGraph: { protocol: "uhura-interaction-graph/0", nodes: [], edges: [] }, + machine: null, }, }; @@ -204,7 +207,7 @@ test("authoring-only changes reuse semantic DOM", () => { const next = structuredClone(render(4)); next.authoring.targets.push({ id: "target", - class: "catalog-element", + class: "ui-element", file: "card.uhura", span: { offset: 10, @@ -218,7 +221,7 @@ test("authoring-only changes reuse semantic DOM", () => { next.previews[0]!.provenance.occurrences.push({ id: "occurrence", targetId: "target", - anchors: [{ root: { kind: "fragment" }, path: [] }], + anchors: ["root"], }); assert.deepEqual([...reusablePreviewIds(previous, next)], ["alpha", "beta"]); diff --git a/web/src/editor/tests/fixtures/projection.ts b/web/src/editor/tests/fixtures/projection.ts new file mode 100644 index 0000000..30ee20e --- /dev/null +++ b/web/src/editor/tests/fixtures/projection.ts @@ -0,0 +1,58 @@ +import { natural } from "../../../protocol/machine.js"; +import type { RenderNode } from "../../../renderer/projection.js"; +import type { PreviewContent } from "../../editor-state.js"; + +export const elementNode = ( + key: string, + children: readonly RenderNode[] = [], + options: { + element?: string; + surface?: boolean; + attributes?: readonly { name: string; value: boolean | string }[]; + } = {}, +): RenderNode => ({ + kind: "element", + key, + element: options.element ?? "div", + attributes: options.attributes ?? [], + events: [], + children, + surface: options.surface ?? false, +}); + +export const textNode = (key: string, text: string): RenderNode => ({ + kind: "text", + key, + text, +}); + +const keys = (nodes: readonly RenderNode[]): string[] => + nodes.flatMap((node) => [ + node.key, + ...(node.kind === "element" ? keys(node.children) : []), + ]); + +export const projectionContent = ( + nodes: readonly RenderNode[] = [elementNode("root")], + presentation = "test@1::Web", +): PreviewContent => ({ + kind: "projection", + value: { + document: { + protocol: "uhura-view/1", + presentation, + machine: "test@1::Machine", + instance: "editor/test", + sequence: natural("0"), + nodes, + }, + sources: { + protocol: "uhura-projection-sources/0", + presentation, + nodes: Object.fromEntries(keys(nodes).map((key) => [ + key, + { id: `ui/${key}`, path: "web.uhura", start: 0, end: 1 }, + ])), + }, + }, +}); diff --git a/web/src/editor/tests/machine-inspection.test.ts b/web/src/editor/tests/machine-inspection.test.ts new file mode 100644 index 0000000..9c2a9e2 --- /dev/null +++ b/web/src/editor/tests/machine-inspection.test.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; + +import { test } from "vitest"; + +import type { + EditorMachine, + PreviewEvidence, +} from "../editor-state.js"; +import { + inspectMachine, + machineMetricRows, + previewEvidenceRows, + renderInspectionRows, +} from "../machine-inspection.js"; + +const machine = (overrides: Partial = {}): EditorMachine => ({ + protocol: "uhura-machine-inspection/0", + identityProtocol: "uhura-machine-identity/0", + deployment: { + entry: "return-desk", + machine: "app.return_desk.machine@1::ReturnDesk", + presentation: "app.return_desk.web@1::ReturnDeskWeb", + deploymentHash: "sha256:deployment", + }, + sources: [{ path: "machine.uhura" }, { path: "web.uhura" }], + provenance: null, + interactionGraph: {}, + graphSources: {}, + checkpoints: { + empty: { protocol: "uhura-checkpoint/0" }, + reviewed: { protocol: "uhura-checkpoint/0" }, + }, + evidence: { + passed: false, + scenarios: [ + { scenario: "ready", status: "passed" }, + { scenario: "rejected", status: "failed" }, + ], + failures: [{ code: "expectation-failed" }], + }, + ...overrides, +}); + +test("summarizes deployment identity and bounded machine evidence counts", () => { + const summary = inspectMachine(machine()); + + assert.deepEqual(summary.identity, [ + { label: "Deployment", value: "return-desk" }, + { label: "Machine", value: "app.return_desk.machine@1::ReturnDesk" }, + { label: "Presentation", value: "app.return_desk.web@1::ReturnDeskWeb" }, + ]); + assert.equal(summary.status, "failed"); + assert.deepEqual(machineMetricRows(summary), [ + { label: "Passes", value: "1" }, + { label: "Failures", value: "1" }, + { label: "Checkpoints", value: "2" }, + { label: "Sources", value: "2" }, + ]); +}); + +test("keeps absent deployment and unstable evidence payloads honest", () => { + const summary = inspectMachine(machine({ + deployment: null, + sources: [], + checkpoints: {}, + evidence: {}, + })); + + assert.deepEqual(summary.identity, []); + assert.equal(summary.status, "unknown"); + assert.equal(summary.passes, 0); + assert.equal(summary.failures, 0); + assert.equal(summary.checkpoints, 0); + assert.equal(summary.sources, 0); + assert.deepEqual(summary.ownership, []); + assert.deepEqual(summary.outcomes, []); + assert.deepEqual(summary.dependencies, []); +}); + +test("projects authored module, part ownership, and dependencies without replacing evidence UX", () => { + const machineId = "app.return_desk.machine@1::ReturnDesk"; + const summary = inspectMachine(machine({ + interactionGraph: { + protocol: "uhura-interaction-graph/0", + outcome_policies: { + accepted: "commit", + refused: "abort", + }, + nodes: [ + { id: "module:app", kind: "module", machine: machineId, label: "app" }, + { id: "module:parts", kind: "module", machine: machineId, label: "parts" }, + { id: "machine", kind: "machine", machine: machineId, label: machineId }, + { id: "producer", kind: "part", machine: machineId, label: "producer" }, + { id: "consumer", kind: "part", machine: machineId, label: "consumer" }, + { id: "value", kind: "state", machine: machineId, label: "producer.value" }, + { id: "current", kind: "computed", machine: machineId, label: "producer.current" }, + { id: "set", kind: "update", machine: machineId, label: "producer.set" }, + { id: "producer-invariant", kind: "invariant", machine: machineId, label: "producer.invariant 1" }, + { id: "input", kind: "input", machine: machineId, label: "consumer.Apply" }, + { id: "observed", kind: "observation", machine: machineId, label: "consumer.current" }, + { id: "root-invariant", kind: "invariant", machine: machineId, label: "invariant 1" }, + { id: "accepted", kind: "outcome", machine: machineId, label: "Accepted" }, + { id: "refused", kind: "outcome", machine: machineId, label: "Refused" }, + ], + edges: [ + { from: "module:app", to: "machine", kind: "owns" }, + { from: "module:parts", to: "producer", kind: "owns" }, + { from: "module:parts", to: "consumer", kind: "owns" }, + { from: "machine", to: "producer", kind: "composes" }, + { from: "machine", to: "consumer", kind: "composes" }, + { from: "producer", to: "value", kind: "owns" }, + { from: "producer", to: "current", kind: "owns" }, + { from: "producer", to: "set", kind: "owns" }, + { from: "producer", to: "producer-invariant", kind: "owns" }, + { from: "consumer", to: "input", kind: "owns" }, + { from: "consumer", to: "observed", kind: "owns" }, + { from: "machine", to: "root-invariant", kind: "owns" }, + { from: "machine", to: "accepted", kind: "owns" }, + { from: "machine", to: "refused", kind: "owns" }, + { from: "current", to: "value", kind: "reads" }, + { from: "input", to: "set", kind: "calls" }, + { from: "observed", to: "current", kind: "observes" }, + ], + }, + })); + + assert.deepEqual(summary.ownership, [ + { label: "Module", value: "app" }, + { label: "Module", value: "parts" }, + { + label: "Machine-owned", + value: "1 invariant", + }, + { + label: "Part consumer", + value: "1 observation", + }, + { + label: "Part producer", + value: "1 state · 1 computed · 1 invariant · 1 update", + }, + ]); + assert.deepEqual(summary.outcomes, [ + { label: "Outcome Accepted", value: "commit" }, + { label: "Outcome Refused", value: "abort" }, + ]); + assert.deepEqual(summary.dependencies, [ + { label: "Reads", value: "1 · producer.current → producer.value" }, + { label: "Calls", value: "1 · consumer.Apply → producer.set" }, + { label: "Observes", value: "1 · consumer.current → producer.current" }, + ]); +}); + +test("exposes only the selected preview evidence identity", () => { + const evidence: PreviewEvidence = { + scenario: "return-approved", + pin: "completed", + sourceId: "conformance.uhura:44:3", + sources: { + registration: { path: "conformance.uhura" }, + pin: { path: "conformance.uhura" }, + }, + observation: { result: "accepted" }, + snapshot: { state: "complete" }, + scenarioReceiptLog: { receipts: [{ sequence: "1" }] }, + }; + + assert.deepEqual(previewEvidenceRows(evidence), [ + { label: "Scenario", value: "return-approved" }, + { label: "Pin", value: "completed" }, + { label: "Source", value: "conformance.uhura:44:3" }, + ]); +}); + +class TestElement { + readonly children: TestElement[] = []; + readonly tagName: string; + textContent = ""; + + constructor(tagName: string) { + this.tagName = tagName; + } + + append(...children: TestElement[]): void { + this.children.push(...children); + } + + replaceChildren(...children: TestElement[]): void { + this.children.splice(0, this.children.length, ...children); + } +} + +test("renders semantic definition-list rows without serializing raw evidence", () => { + const document = { + createElement: (tagName: string) => new TestElement(tagName), + } as unknown as Document; + const root = new TestElement("dl"); + + renderInspectionRows( + document, + root as unknown as HTMLElement, + previewEvidenceRows({ + scenario: "ready", + pin: "loaded", + sourceId: "evidence/ready/loaded", + sources: { registration: {}, pin: {} }, + observation: { private: "large observation" }, + snapshot: { private: "large snapshot" }, + scenarioReceiptLog: { private: "large receipt log" }, + }), + ); + + assert.deepEqual( + root.children.map((group) => ({ + tags: group.children.map((child) => child.tagName), + text: group.children.map((child) => child.textContent), + })), + [ + { tags: ["dt", "dd"], text: ["Scenario", "ready"] }, + { tags: ["dt", "dd"], text: ["Pin", "loaded"] }, + { tags: ["dt", "dd"], text: ["Source", "evidence/ready/loaded"] }, + ], + ); +}); diff --git a/web/src/editor/tests/structure-connectors.test.ts b/web/src/editor/tests/structure-connectors.test.ts index ed6c711..9fdca59 100644 --- a/web/src/editor/tests/structure-connectors.test.ts +++ b/web/src/editor/tests/structure-connectors.test.ts @@ -8,6 +8,7 @@ import { buildStructureConnectors, incomingLeftLabelShift, layoutStructureConnectors, + logicalRoutePreviewNode, routeStructureConnector, structureConnectorDescription, structureConnectorLabel, @@ -15,6 +16,7 @@ import { type StructureConnectorPlacement, type StructureRect, } from "../structure-connectors.js"; +import { projectionContent } from "./fixtures/projection.js"; const preview = ( kind: PreviewKind, @@ -36,17 +38,8 @@ const preview = ( interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: kind === "page" - ? { - protocol: "uhura-view/0", - revision: 0, - page: { - route: subject, - root: { key: "root", element: "view", props: {} }, - }, - surfaces: [], - } - : { key: "root", element: "view", props: {} }, + evidence: null, + content: projectionContent(), }); const graph = (edges: InteractionGraphEdge[]): InteractionGraph => ({ @@ -227,6 +220,54 @@ test("selection scoping matches kind and subject, never subject alone", () => { ); }); +test("preview-backed route nodes map and select the exact application state", () => { + const feedBase = "page/feed/base"; + const feedExtra = "page/feed/extra"; + const profile = "page/profile/default"; + const connectors = buildStructureConnectors(graph([ + { + kind: "navigate", + from: logicalRoutePreviewNode(feedBase), + to: logicalRoutePreviewNode(profile), + event: "base-profile", + }, + { + kind: "navigate", + from: logicalRoutePreviewNode(feedExtra), + to: logicalRoutePreviewNode(profile), + event: "extra-profile", + }, + ]), boardPreviews); + + assert.deepEqual( + connectors.map(({ sourceId, targetId, event }) => [sourceId, targetId, event]), + [ + [feedBase, profile, "base-profile"], + [feedExtra, profile, "extra-profile"], + ], + ); + + const visible = visibleStructureConnectors(connectors, { + kind: "page", + subject: "feed", + previewId: feedExtra, + }); + assert.deepEqual( + visible.map(({ sourceId, event }) => [sourceId, event]), + [[feedExtra, "extra-profile"]], + "selecting one logical state must not adopt a sibling preview's route", + ); + + const laid = layoutStructureConnectors(visible, { + node: logicalRoutePreviewNode(feedExtra), + aliases: ["page:feed"], + previewId: feedExtra, + }); + assert.equal(laid[0]?.placement.direction, "outgoing"); + assert.equal(laid[0]?.placement.selectedId, feedExtra); + assert.equal(laid[0]?.placement.farId, profile); +}); + test("empty or unrelated selection hides every structural connector", () => { const all = buildStructureConnectors(fullGraph, boardPreviews); assert.deepEqual(visibleStructureConnectors(all, null), []); diff --git a/web/src/editor/tests/surface-hierarchy.test.ts b/web/src/editor/tests/surface-hierarchy.test.ts index fc78465..828822f 100644 --- a/web/src/editor/tests/surface-hierarchy.test.ts +++ b/web/src/editor/tests/surface-hierarchy.test.ts @@ -2,139 +2,110 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import type { EditorPreview, ReplayStep } from "../editor-state.js"; -import { directlyOpenedSurfaces, surfaceHierarchy } from "../surface-hierarchy.js"; +import type { RenderNode } from "../../renderer/projection.js"; +import type { EditorPreview } from "../editor-state.js"; +import { introducedSurfaces, surfaceHierarchy } from "../surface-hierarchy.js"; +import { elementNode, projectionContent } from "./fixtures/projection.js"; -const replay = (structural: ReplayStep["effects"]["structural"]): ReplayStep => ({ - label: "comments-requested", - kind: "semantic", - payload: { post: "post-1" }, - dispatch: null, - effects: { - writes: [], - commands: [], - intents: [], - structural, - projections: [], - }, -}); - -const page = (steps: ReplayStep[], from: string | null = "first-page"): EditorPreview => ({ - id: "page/feed/comments-open", - identity: { kind: "page", subject: "feed", example: "comments-open" }, - sourceFile: "pages/feed.uhura", - default: false, +const preview = ( + example: string, + nodes: readonly RenderNode[], + from: string | null = null, +): EditorPreview => ({ + id: `page/feed/${example}`, + identity: { kind: "page", subject: "feed", example }, + sourceFile: "web.uhura", + default: from === null, pinned: false, - derived: true, + derived: from !== null, inFlight: 0, from, - replaySteps: steps.map((step) => step.label), - replay: steps, + replaySteps: [], + replay: [], note: null, data: [], interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - protocol: "uhura-view/0", - revision: 2, - page: { route: "feed", root: { key: "root", element: "view", props: {} } }, - surfaces: [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", - dismiss: { - kind: "input", - event: "dismiss", - emit: "dismiss", - scope: "surface:1", - payload: {}, - }, - root: { key: "surface", element: "view", props: {} }, - }], - }, + evidence: null, + content: projectionContent(nodes, "instagram@1::Feed"), }); -test("matches a direct open-surface effect to the mounted child by instance key", () => { - const preview = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:1", - }])]); +const comments = (children: readonly RenderNode[] = []): RenderNode => + elementNode("comments-sheet", children, { + element: "dialog", + surface: true, + attributes: [{ name: "aria-label", value: "Comments" }], + }); + +test("derives mounted surfaces and readable labels from canonical projection nodes", () => { + const current = preview("comments", [ + elementNode("root", [comments()]), + ]); - assert.deepEqual(surfaceHierarchy(preview), { - page: "feed", + assert.deepEqual(surfaceHierarchy(current), { + presentation: "instagram@1::Feed", surfaces: [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", + key: "comments-sheet", + definition: "Comments", + modality: "dialog", stackIndex: 0, - relation: "direct", + relation: "present", }], roots: [{ surface: { - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", + key: "comments-sheet", + definition: "Comments", + modality: "dialog", stackIndex: 0, - relation: "direct", + relation: "present", }, - opener: "page:1", + opener: null, children: [], }], }); - assert.equal(directlyOpenedSurfaces(preview)[0]?.definition, "comments-sheet"); }); -test("does not infer direct parentage from a matching definition alone", () => { - const preview = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:2", - }])]); - assert.equal(surfaceHierarchy(preview)?.surfaces[0]?.relation, "inherited"); - assert.deepEqual(directlyOpenedSurfaces(preview), []); -}); +test("keeps nested hierarchy and compares exact keys with the evidence parent", () => { + const parent = preview("comments", [elementNode("root", [comments()])]); + const report = elementNode("report-dialog", [], { + element: "dialog", + surface: true, + attributes: [{ name: "data-modality", value: "alert dialog" }], + }); + const child = preview( + "report", + [elementNode("root", [comments([report])])], + "comments", + ); -test("keeps parentless snapshot surfaces distinct from inherited replay children", () => { - const preview = page([], null); - assert.equal(surfaceHierarchy(preview)?.surfaces[0]?.relation, "mounted"); - assert.deepEqual(directlyOpenedSurfaces(preview), []); + const hierarchy = surfaceHierarchy(child, [parent, child]); + assert.deepEqual( + hierarchy?.surfaces.map(({ key, definition, modality, relation }) => ({ + key, + definition, + modality, + relation, + })), + [{ + key: "comments-sheet", + definition: "Comments", + modality: "dialog", + relation: "retained", + }, { + key: "report-dialog", + definition: "Surface 2", + modality: "alert dialog", + relation: "introduced", + }], + ); + assert.equal(hierarchy?.roots[0]?.children[0]?.opener, "comments-sheet"); + assert.deepEqual( + introducedSurfaces(child, [parent, child]).map((surface) => surface.key), + ["report-dialog"], + ); }); -test("reconstructs nested surface ancestry from direct replay instance keys", () => { - const sheet = page([replay([{ - op: "open-surface", - opener: "page:1", - surface: "comments-sheet:1", - }])]); - const dialog = structuredClone(sheet); - dialog.id = "page/feed/report-open"; - dialog.identity.example = "report-open"; - dialog.from = "comments-open"; - dialog.replaySteps = ["report-requested"]; - dialog.replay = [replay([{ - op: "open-surface", - opener: "surface:1", - surface: "report-dialog:2", - }])]; - if (!("protocol" in dialog.content)) throw new Error("page fixture"); - dialog.content.surfaces.push({ - key: "report-dialog:2", - definition: "report-dialog", - modality: "dialog", - dismiss: { - kind: "input", event: "dismiss", emit: "dismiss", scope: "surface:2", payload: {}, - }, - root: { key: "dialog", element: "view", props: {} }, - }); - - const hierarchy = surfaceHierarchy(dialog, [sheet, dialog]); - assert.equal(hierarchy?.roots.length, 1); - assert.equal(hierarchy?.roots[0]?.surface.definition, "comments-sheet"); - assert.equal(hierarchy?.roots[0]?.surface.relation, "inherited"); - assert.equal(hierarchy?.roots[0]?.opener, "page:1"); - assert.equal(hierarchy?.roots[0]?.children[0]?.surface.definition, "report-dialog"); - assert.equal(hierarchy?.roots[0]?.children[0]?.surface.relation, "direct"); - assert.equal(hierarchy?.roots[0]?.children[0]?.opener, "surface:1"); +test("returns no hierarchy when a projection has no semantic surfaces", () => { + assert.equal(surfaceHierarchy(preview("plain", [elementNode("root")])), null); }); diff --git a/web/src/editor/tests/workflow-connectors.test.ts b/web/src/editor/tests/workflow-connectors.test.ts index ca5c044..5f3fda1 100644 --- a/web/src/editor/tests/workflow-connectors.test.ts +++ b/web/src/editor/tests/workflow-connectors.test.ts @@ -10,6 +10,7 @@ import { workflowConnectorDescription, workflowConnectorLabel, } from "../workflow-connectors.js"; +import { elementNode, projectionContent } from "./fixtures/projection.js"; const preview = ( example: string, @@ -31,15 +32,8 @@ const preview = ( interactions: [], documentation: { declarationDocId: null, exampleDocId: null }, provenance: { occurrences: [] }, - content: { - protocol: "uhura-view/0", - revision: 0, - page: { - route: "feed", - root: { key: "root", element: "view", props: {} }, - }, - surfaces: [], - }, + evidence: null, + content: projectionContent(), }); test("builds direct checked provenance without repeating ancestor steps", () => { @@ -54,7 +48,7 @@ test("builds direct checked provenance without repeating ancestor steps", () => sourceId: "page/feed/base", targetId: "page/feed/pending", steps: ["like-toggled"], - openedSurfaces: [], + introducedSurfaces: [], lane: 0, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -63,7 +57,7 @@ test("builds direct checked provenance without repeating ancestor steps", () => sourceId: "page/feed/pending", targetId: "page/feed/refused", steps: ["like-post.err"], - openedSurfaces: [], + introducedSurfaces: [], lane: 1, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -138,13 +132,13 @@ test("skips unresolved parents and summarizes labels without hiding full order", assert.equal( workflowConnectorDescription({ steps: ["near-end", "projection feed.page", "load.ok"], - openedSurfaces: [], + introducedSurfaces: [], }), "near-end → projection feed.page → load.ok", ); }); -test("classifies a checked edge that opens a mounted child surface", () => { +test("classifies a checked edge whose projection introduces a surface", () => { const child = preview("comments-open", "base", ["comments-requested"]); child.replay = [{ label: "comments-requested", @@ -156,27 +150,26 @@ test("classifies a checked edge that opens a mounted child surface", () => { structural: [{ op: "open-surface", surface: "comments-sheet:1" }], }, }]; - if (!("protocol" in child.content)) throw new Error("page fixture"); - child.content.surfaces = [{ - key: "comments-sheet:1", - definition: "comments-sheet", - modality: "sheet", - dismiss: { - kind: "input", event: "dismiss", emit: "dismiss", scope: "surface:1", payload: {}, - }, - root: { key: "surface", element: "view", props: {} }, - }]; + child.content = projectionContent([ + elementNode("root", [ + elementNode("comments-sheet:1", [], { + element: "dialog", + surface: true, + attributes: [{ name: "aria-label", value: "comments-sheet" }], + }), + ]), + ]); const connector = buildWorkflowConnectors("page/feed", [preview("base"), child])[0]!; - assert.deepEqual(connector.openedSurfaces.map(({ definition, modality }) => ({ + assert.deepEqual(connector.introducedSurfaces.map(({ definition, modality }) => ({ definition, modality, - })), [{ definition: "comments-sheet", modality: "sheet" }]); + })), [{ definition: "comments-sheet", modality: "dialog" }]); assert.equal( - workflowConnectorLabel(connector.steps, connector.openedSurfaces), - "comments-requested · opens comments-sheet", + workflowConnectorLabel(connector.steps, connector.introducedSurfaces), + "comments-requested · introduces comments-sheet", ); assert.equal( workflowConnectorDescription(connector), - "comments-requested; opens child sheet comments-sheet", + "comments-requested; projection introduces dialog comments-sheet", ); }); diff --git a/web/src/editor/workflow-connectors.ts b/web/src/editor/workflow-connectors.ts index 1faaa39..0e12760 100644 --- a/web/src/editor/workflow-connectors.ts +++ b/web/src/editor/workflow-connectors.ts @@ -1,12 +1,12 @@ import type { EditorPreview } from "./editor-state.js"; -import { directlyOpenedSurfaces, type MountedSurface } from "./surface-hierarchy.js"; +import { introducedSurfaces, type MountedSurface } from "./surface-hierarchy.js"; export interface WorkflowConnector { groupId: string; sourceId: string; targetId: string; steps: string[]; - openedSurfaces: MountedSurface[]; + introducedSurfaces: MountedSurface[]; lane: number; sourcePort: ConnectorPort; targetPort: ConnectorPort; @@ -145,7 +145,7 @@ export const buildWorkflowConnectors = ( sourceId, targetId: preview.id, steps: [...preview.replaySteps], - openedSurfaces: directlyOpenedSurfaces(preview), + introducedSurfaces: introducedSurfaces(preview, previews), lane: 0, sourcePort: { slot: 0, count: 1 }, targetPort: { slot: 0, count: 1 }, @@ -210,26 +210,26 @@ export const routeWorkflowConnector = ( export const workflowConnectorLabel = ( steps: readonly string[], - openedSurfaces: readonly Pick[] = [], + surfaces: readonly Pick[] = [], ): string => { const replay = steps.length === 0 ? "derived" : steps.length === 1 ? steps[0]! : `${steps[0]} +${steps.length - 1}`; - if (openedSurfaces.length === 0) return replay; - return `${replay} · opens ${openedSurfaces.map((surface) => surface.definition).join(", ")}`; + if (surfaces.length === 0) return replay; + return `${replay} · introduces ${surfaces.map((surface) => surface.definition).join(", ")}`; }; export const workflowConnectorDescription = ( - connector: Pick, + connector: Pick, ): string => { const replay = connector.steps.length === 0 ? "derived example" : connector.steps.join(" → "); - if (connector.openedSurfaces.length === 0) return replay; - const children = connector.openedSurfaces + if (connector.introducedSurfaces.length === 0) return replay; + const children = connector.introducedSurfaces .map((surface) => `${surface.modality} ${surface.definition}`) .join(", "); - return `${replay}; opens child ${children}`; + return `${replay}; projection introduces ${children}`; }; diff --git a/web/src/play/TODO.md b/web/src/play/TODO.md index 58cf301..20c826a 100644 --- a/web/src/play/TODO.md +++ b/web/src/play/TODO.md @@ -1,11 +1,11 @@ # Play debugger TODO -This tracker starts at the current Play debugger spike: the inspection protocol, -bounded step history, focused behavior graph, live highlighting, definition -pinning, a resizable responsive shell, graph zoom/pan, route-scoped page-scale -and history-swipe locking, and keyboard navigation already exist. Items below -describe what is still required to turn that demonstration into a useful -debugger. +This tracker starts at the current canonical Play debugger: an admitted host +inspection artifact, bounded correlated receipt/inspection history, a focused +machine graph, conservative live highlighting, machine pinning, responsive +debug chrome, graph zoom/pan, route-scoped page-scale and history-swipe +locking, and keyboard navigation already exist. Items below describe what is +still required to turn that foundation into a useful debugger. Ownership stays local. Engine and CLI prerequisites are tracked beside their implementations rather than being hidden in this browser backlog: @@ -25,10 +25,10 @@ implementations rather than being hidden in this browser backlog: - **Difficulty L** — substantial UI architecture, performance, or cross-host integration. - **Difficulty XL** — runtime-semantics or protocol-lifecycle project. -- **Engine work: No** — the current inspection artifact and step records are - sufficient. -- **Engine work: Conditional** — the browser-owned version is possible now, - but a stronger form requires the engine tracker. +- **Engine work: No** — admitted host inspection plus correlated machine + receipts and inspections are sufficient. +- **Engine work: Conditional** — a conservative browser-owned version is + possible now, but a stronger claim requires the core tracker. - **Engine work: Yes** — blocked on an item in the core inspection tracker. ## P0 — make recorded execution useful @@ -37,85 +37,95 @@ implementations rather than being hidden in this browser backlog: inspection mode.** - **Owner:** `inspection-store.ts`, `debug-controller.ts`, and `debug-surface.ts`. - - Present the already-retained bounded history; do not add another history - buffer in the visualization. + - Present the store's already-retained receipt/inspection pairs; do not add a + second history buffer in the visualization. - Provide Live/Pause, previous/next step, direct step selection, and a clear indication when the graph is showing history rather than the running tip. - - Keep historical inspection observational. Selecting an old record must - never mutate, pause, or restore the runtime. + - Keep historical inspection observational. Selecting an old publication + must never mutate, pause, or restore the Wasm `Session`. - Returning to Live must resume from the newest publication without losing - steps received while the viewer was paused. - -- [ ] **[P0][M][Engine work: No] Isolate and narrate the causal path for one - recorded step.** - - **Owner:** `debug-model.ts`, `debug-layout.ts`, and `debug-surface.ts`. - - Derive an explicit sequence from facts already present in the trace: - event, consulted guards, selected handler, writes, sends, structural - effects, outcomes, and projection application. - - Add a “taken path only” view that dims or hides unrelated topology without - changing the underlying graph identity. - - Show before/after values from adjacent retained snapshots for state that - changed in the selected step. - - Never invent expression values that the trace does not record. Richer - evaluated facts belong in the core tracker. + receipts received while the viewer was paused. + +- [ ] **[P0][M][Engine work: Conditional] Narrate the defensible causal path + for one receipt.** + - **Owner:** `session.ts`, `adapter-host.ts`, `debug-model.ts`, + `debug-layout.ts`, and `debug-surface.ts`. + - Start from facts the canonical boundary actually publishes: resolved local + or port input, reaction disposition or fault, ordered commands, state + differences between adjacent inspections, and the post-observation. + - Correlate those facts only with nodes and edges in the admitted interaction + graph. A “taken path” view may dim unrelated topology, but must label + conservative context separately from proven activity. + - Preserve exact port identity for adapter-delivered inputs and port-bound + commands; the browser must not reconstruct provider or router semantics. + - Evaluated guard values, internal transition paths, and expression + provenance require an explicit core inspection addition. Never infer them + from the rendered UI. ## P1 — product-quality browser debugger -- [ ] **[P1][L][Engine work: No] Make large definitions navigable.** +- [ ] **[P1][L][Engine work: No] Make large machine graphs navigable.** - **Owner:** `debug-model.ts`, `debug-layout.ts`, `debug-surface.ts`, and `shell.css`. - Build on the existing zoom/pan camera with search, semantic filters, fit-to-selection, and a compact overview or minimap. - Allow unrelated branches to collapse while preserving a stable route back - to the full definition. - - Keep lane labels and current-step context visible while the canvas scrolls. + to the full admitted machine graph. + - Keep lane labels and current-receipt context visible while the canvas + scrolls. - Preserve the existing roving-tab-stop and arrow-navigation contract. - - Validate against the current 77-node/115-edge feed definition and at least - one larger synthetic fixture. + - Validate against the Instagram machine graph and at least one larger + generated graph fixture; record node/edge counts in the test rather than in + this backlog. - [ ] **[P1][L][Engine work: No] Replace whole-graph rerenders with incremental - runtime decoration.** + receipt decoration.** - **Owner:** `debug-model.ts`, `debug-layout.ts`, and `debug-surface.ts`. - - Cache static topology and geometry by `(programHash, focusDefinitionId)`. - - Rebuild the graph only when the checked program or focused definition - changes; otherwise patch node classes, status text, edge activity, summary, - and selection details by stable ID. - - Avoid layout reads after graph writes on ordinary runtime steps. + - Cache static topology and geometry by + `(machineProgramHash, focusDefinitionId)`. + - Rebuild only when the admitted deployment or focused machine changes; + otherwise patch node classes, status text, edge activity, summary, and + selection details by stable ID. + - Avoid layout reads after graph writes on ordinary receipt publications. - Add a repeatable performance fixture and define budgets for update time, allocations, and dropped frames before optimizing further. - Keep the accessible SVG-edge/HTML-node renderer unless profiling isolates edge paint as the bottleneck. A Canvas edge layer is a measured fallback, - not an out-of-the-box renderer switch. + not an automatic renderer switch. -- [ ] **[P1][S][Engine work: No] Split ambiguous Follow-live behavior into an - explicit policy.** +- [ ] **[P1][S][Engine work: No] Make live-machine following explicit.** - **Owner:** `debug-model.ts` and `debug-surface.ts`. - - Decide whether the default follows the latest dispatch origin, the topmost - mounted definition, or exposes both as separate modes. - - Preserve the transition source long enough to explain navigation without - leaving the debugger apparently stuck on the prior page. - - Cover navigation, replace, back, surface open/dismiss, and provider outcome - delivery in model tests. + - The runtime machine from the admitted deployment is the sole live target. + Imported machines may be pinned for static inspection, but must never be + decorated as if they were the running instance. + - Rename UI copy if “Follow live” can be mistaken for route, page, component, + or DOM focus. + - Cover switching between the running machine and a pinned imported machine, + then returning to the newest receipt without changing the session. - [ ] **[P1][M][Engine work: No] Add checked-in real-browser and accessibility regressions.** - **Owner:** `tests/` plus the repository's browser-test harness. - - Exercise the actual Play route: open/close, lazy subscription, live - transition highlights, definition pinning, Follow live, historical mode, + - Exercise the actual Play route: open/close, lazy subscription, genesis and + reaction highlights, machine pinning, Follow live, historical mode, keyboard graph navigation, and disposal. + - Include one adapter-delivered port input and one emitted port command so the + test crosses the real `adapter-host.ts` boundary. - Cover wide right-dock, narrow bottom-dock, and compact takeover layouts, including bounds and overflow assertions. - - Add an automated accessibility audit and a short manual assistive- - technology checklist; unit DOM contracts are not a substitute for either. + - Add an automated accessibility audit and a short manual + assistive-technology checklist; unit DOM contracts are not a substitute for + either. - Add targeted visual snapshots only for layout states whose geometry is part of the contract. -- [ ] **[P1][M][Engine work: No] Turn source spans into source navigation.** +- [ ] **[P1][M][Engine work: No] Turn admitted source spans into source + navigation.** - **Owner:** `debug-surface.ts` for the interaction; the safe source contract is owned by the [CLI Play-host TODO](../../../crates/uhura-cli/src/cmd/TODO.md). - Show a small source excerpt and provide an Open-in-Editor action. - - Bind every excerpt to the source revision/hash that produced the inspected - program; never display current bytes against a stale span. + - Bind every excerpt to the machine program/deployment identity that produced + the inspected graph; never display current bytes against a stale span. - Treat UTF-8 byte offsets as bytes throughout the host boundary. ## P2 — measured hardening and optional capabilities @@ -123,35 +133,35 @@ implementations rather than being hidden in this browser backlog: - [ ] **[P2][M][Engine work: No] Replace count-only browser retention with a measured byte budget.** - **Owner:** `inspection-store.ts` and the timeline UI. - - Keep the existing hard step-count ceiling as a safety backstop, but evict - by measured payload size so large state snapshots cannot dominate memory. - - Make truncation visible in the timeline and preserve the newest coherent - step boundary. + - Keep the existing hard publication-count ceiling as a safety backstop, but + evict by measured payload size so large inspections cannot dominate memory. + - Make truncation visible in the timeline and preserve complete correlated + receipt/inspection pairs. - Measure representative projects before choosing defaults. - [ ] **[P2][M][Engine work: Conditional] Export and reopen observational inspection sessions.** - **Owner:** `inspection-store.ts`, protocol types, and a small Play UI seam. - - Export versioned program metadata plus retained records, with explicit - truncation and redaction metadata. - - Reopening an export is an offline viewer, not deterministic runtime replay. - - Exact replay or runtime restoration is blocked on the core runtime-control - item; do not imply that an observational export can reproduce provider - effects. - -- [ ] **[P2][M][Engine work: Yes] Represent component runtime instances without - misleading static values.** - - **Owner:** browser presentation after the core inspection contract decides - whether components have inspectable runtime identity. - - Until that contract exists, label component graphs as static topology when - instance-specific state is unavailable. - - See the component-instance item in the - [Core inspection TODO](../../../crates/uhura-core/src/TODO.md). + - Export versioned host deployment identity plus retained correlated + publications, with explicit truncation and redaction metadata. + - Reopening an export is an offline viewer, not deterministic session replay. + - Exact replay or runtime restoration needs an explicit session/checkpoint + design; do not imply that observational records reproduce foreign effects. + +- [ ] **[P2][L][Engine work: Yes] Visualize richer transition internals only + after the machine protocol can prove them.** + - **Owner:** browser presentation after the core inspection contract defines + any evaluated guard, transition, or expression-provenance facts. + - Until then, keep receipt decoration conservative and explain its limits in + the details pane. + - Do not revive a browser-side evaluator or a second trace schema. ## Non-goals for the browser backlog - Reimplementing the Uhura evaluator in TypeScript. -- Rewinding the running session by assigning old browser snapshots. -- Inferring guard values or provider effects that the engine did not record. +- Rewinding the running `Session` by assigning old browser inspections. +- Inferring guards, internal transition paths, or foreign effects that receipts + do not record. +- Letting an adapter provider bypass `adapter-host.ts` contract admission. - Making inspection data safe for an untrusted or public Play deployment solely by hiding fields in the DOM. diff --git a/web/src/play/adapter-host.test.ts b/web/src/play/adapter-host.test.ts new file mode 100644 index 0000000..cfab90c --- /dev/null +++ b/web/src/play/adapter-host.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import type { + ResolvedInput, + Value, +} from "../protocol/machine.js"; +import type { DeliveryQueue } from "./adapter-host.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + createAdapterHost, + createDeliveryQueue, + WEB_HISTORY_ADAPTER, +} from "./adapter-host.js"; +import { hash } from "../protocol/machine.js"; + +const text = (value: string): Value => ({ $: "Text", value }); + +const textOf = (input: ResolvedInput): string => { + assert.equal(input.value.$, "Text"); + return input.value.value; +}; + +test("adapter deliveries are deferred, FIFO, and drained from snapshots", () => { + const tasks: (() => void)[] = []; + const delivered: string[] = []; + let queue!: DeliveryQueue; + queue = createDeliveryQueue( + (input) => { + const value = textOf(input); + delivered.push(value); + if (value === "first") { + queue.enqueue({ source: "port", port: "router", value: text("later") }); + } + }, + (task) => { tasks.push(task); }, + ); + + queue.enqueue({ source: "port", port: "router", value: text("first") }); + queue.enqueue({ source: "port", port: "router", value: text("second") }); + assert.deepEqual(delivered, []); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered, ["first", "second"]); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered, ["first", "second", "later"]); +}); + +test("an admitted adapter receives commands in order and reports later inputs", () => { + const contractHash = hash("0".repeat(64)); + const contractInstanceHash = hash("1".repeat(64)); + const tasks: (() => void)[] = []; + const accepted: string[] = []; + const delivered: ResolvedInput[] = []; + const host = createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash, + contractInstanceHash, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash, + contractInstanceHash, + accept(command, context): void { + assert.equal(command.$, "Text"); + accepted.push(command.value); + context.deliver(text(`changed:${command.value}`)); + }, + }], + deliver(input) { + delivered.push(input); + }, + schedule(task) { + tasks.push(task); + }, + }); + + host.publish([ + { target: "port", port: "router", value: text("/orders") }, + { target: "port", port: "router", value: text("/returns") }, + ]); + + assert.deepEqual(accepted, ["/orders", "/returns"]); + assert.deepEqual(delivered, []); + assert.equal(tasks.length, 1); + + tasks.shift()?.(); + assert.deepEqual(delivered.map(textOf), [ + "changed:/orders", + "changed:/returns", + ]); + host.dispose(); +}); + +test("adapter admission is complete and contract checked", () => { + const expected = hash("1".repeat(64)); + const incompatible = hash("2".repeat(64)); + const instance = hash("3".repeat(64)); + const incompatibleInstance = hash("4".repeat(64)); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [], + deliver() {}, + }), + /missing Uhura adapter/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: incompatible, + contractInstanceHash: instance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: incompatibleInstance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [{ + port: "router", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + accept() {}, + }], + deliver() {}, + }), + /incompatible admitted identity/u, + ); + + const compatible = { + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + accept() {}, + } as const; + assert.throws( + () => createAdapterHost({ + requirements: [], + adapters: [compatible], + deliver() {}, + }), + /undeclared Uhura adapter/u, + ); + assert.throws( + () => createAdapterHost({ + requirements: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: expected, + contractInstanceHash: instance, + }], + adapters: [compatible, compatible], + deliver() {}, + }), + /duplicate Uhura adapter/u, + ); +}); diff --git a/web/src/play/adapter-host.ts b/web/src/play/adapter-host.ts new file mode 100644 index 0000000..255c5a0 --- /dev/null +++ b/web/src/play/adapter-host.ts @@ -0,0 +1,325 @@ +import type { + Hash, + Inspection, + ReactionStep, + ResolvedCommand, + ResolvedInput, + Value, +} from "../protocol/machine.js"; + +export interface RuntimeSession { + /** + * Runs exactly one admitted reaction. The native/Wasm implementation owns + * FIFO admission, transactional publication, and receipt construction. + */ + submit(input: ResolvedInput): ReactionStep; + inspect(): Inspection; +} + +export const WEB_HISTORY_ADAPTER = "web.history" as const; +export const APPLICATION_PROVIDER_ADAPTER = "app.provider" as const; +export const WEB_ROUTER_CONTRACT = "uhura.web_router@1::Router" as const; + +export type AdapterIdentity = + | typeof WEB_HISTORY_ADAPTER + | typeof APPLICATION_PROVIDER_ADAPTER; + +export interface PortRequirement { + readonly port: string; + readonly adapter: AdapterIdentity; + readonly contractHash: Hash; + readonly contractInstanceHash: Hash; +} + +export interface AdmittedPortRequirement extends PortRequirement { + readonly contract: string; +} + +/** The browser-visible mirror of the host's sealed adapter table. */ +export const assertSupportedAdapterBinding = ( + requirement: AdmittedPortRequirement, +): void => { + const adapter: string = requirement.adapter; + switch (adapter) { + case WEB_HISTORY_ADAPTER: + if (requirement.contract !== WEB_ROUTER_CONTRACT) { + throw new TypeError( + `Uhura adapter ${JSON.stringify(WEB_HISTORY_ADAPTER)} cannot implement ${JSON.stringify(requirement.contract)}`, + ); + } + return; + case APPLICATION_PROVIDER_ADAPTER: + return; + default: + throw new TypeError( + `unknown sealed Uhura adapter ${JSON.stringify(adapter)}`, + ); + } +}; + +export interface AdapterRequirementPartition { + readonly browser: readonly AdmittedPortRequirement[]; + readonly provider: readonly AdmittedPortRequirement[]; +} + +/** + * Partitions admitted ownership without guessing from a contract family. + * `web.history` is deliberately singular in the current sealed table. + */ +export const partitionAdapterRequirements = ( + requirements: readonly AdmittedPortRequirement[], +): AdapterRequirementPartition => { + const browser: AdmittedPortRequirement[] = []; + const provider: AdmittedPortRequirement[] = []; + for (const requirement of requirements) { + assertSupportedAdapterBinding(requirement); + if (requirement.adapter === WEB_HISTORY_ADAPTER) browser.push(requirement); + else provider.push(requirement); + } + if (browser.length > 1) { + throw new TypeError( + `Uhura adapter ${JSON.stringify(WEB_HISTORY_ADAPTER)} may own at most one port`, + ); + } + return { browser, provider }; +}; + +export interface PortAdapterContext { + readonly signal: AbortSignal; + /** + * Reports one later port input. The bridge always schedules delivery; this + * callback can never synchronously reenter a machine reaction. + */ + deliver(value: Value): void; +} + +export interface PortAdapter { + readonly port: string; + readonly adapter: AdapterIdentity; + readonly contractHash: Hash; + readonly contractInstanceHash: Hash; + /** + * Starts an observation or browser-capability adapter after the complete + * admitted set exists. Deliveries are always deferred by the host queue. + */ + start?(context: PortAdapterContext): void | Promise; + accept( + command: Value, + context: PortAdapterContext, + ): void | Promise; + dispose?(): void; +} + +export interface DeliveryQueue { + enqueue(input: ResolvedInput): void; + close(): void; +} + +export type Schedule = (task: () => void) => void; + +const defaultSchedule: Schedule = (task) => { + queueMicrotask(task); +}; + +/** + * A small host-boundary queue. Each drain uses a snapshot, so inputs reported + * while a reaction publishes new commands are deferred to a later turn. + */ +export function createDeliveryQueue( + deliver: (input: ResolvedInput) => void, + schedule: Schedule = defaultSchedule, +): DeliveryQueue { + let pending: ResolvedInput[] = []; + let scheduled = false; + let closed = false; + + const requestDrain = (): void => { + if (scheduled || closed) return; + scheduled = true; + schedule(() => { + scheduled = false; + if (closed) return; + const batch = pending; + pending = []; + for (const input of batch) deliver(input); + if (pending.length > 0) requestDrain(); + }); + }; + + return { + enqueue(input): void { + if (closed) { + throw new Error("cannot deliver to a disposed Uhura adapter host"); + } + pending.push(input); + requestDrain(); + }, + close(): void { + closed = true; + pending = []; + }, + }; +} + +export interface AdapterHostOptions { + readonly requirements: readonly PortRequirement[]; + readonly adapters: readonly PortAdapter[]; + readonly deliver: (input: ResolvedInput) => void; + readonly localCommand?: (command: Value) => void; + readonly adapterError?: ( + error: unknown, + port: string, + command?: ResolvedCommand, + ) => void; + readonly schedule?: Schedule; +} + +export interface AdapterHost { + /** Starts every admitted adapter exactly once. */ + start(): void; + /** + * Offers committed commands in semantic order. Adapters may complete in any + * order; promises are observed only for operational error reporting. + */ + publish(commands: readonly ResolvedCommand[]): void; + dispose(): void; +} + +const portTable = ( + adapters: readonly PortAdapter[], +): ReadonlyMap => { + const table = new Map(); + for (const adapter of adapters) { + if (table.has(adapter.port)) { + throw new Error(`duplicate Uhura adapter for port \`${adapter.port}\``); + } + table.set(adapter.port, adapter); + } + return table; +}; + +const admitAdapters = ( + requirements: readonly PortRequirement[], + adapters: ReadonlyMap, +): void => { + const required = new Set(); + for (const requirement of requirements) { + if (required.has(requirement.port)) { + throw new Error(`duplicate Uhura port requirement \`${requirement.port}\``); + } + required.add(requirement.port); + const adapter = adapters.get(requirement.port); + if (!adapter) { + throw new Error(`missing Uhura adapter for port \`${requirement.port}\``); + } + if ( + adapter.adapter !== requirement.adapter + || adapter.contractHash !== requirement.contractHash + || adapter.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new Error( + `Uhura adapter for \`${requirement.port}\` has an incompatible admitted identity`, + ); + } + } + for (const port of adapters.keys()) { + if (!required.has(port)) { + throw new Error(`undeclared Uhura adapter for port \`${port}\``); + } + } +}; + +/** + * Admits a complete adapter set and creates the only bridge from committed + * commands to foreign work. This object owns no machine semantics. + */ +export function createAdapterHost( + options: AdapterHostOptions, +): AdapterHost { + const adapters = portTable(options.adapters); + admitAdapters(options.requirements, adapters); + const abort = new AbortController(); + const deliveries = createDeliveryQueue( + options.deliver, + options.schedule, + ); + let disposed = false; + let started = false; + + const reportError = ( + error: unknown, + port: string, + command?: ResolvedCommand, + ): void => { + options.adapterError?.(error, port, command); + }; + + const contextFor = (port: string): PortAdapterContext => ({ + signal: abort.signal, + deliver(value): void { + deliveries.enqueue({ + source: "port", + port, + value, + }); + }, + }); + + return { + start(): void { + if (disposed) { + throw new Error("cannot start a disposed Uhura adapter host"); + } + if (started) return; + started = true; + for (const adapter of adapters.values()) { + if (!adapter.start) continue; + try { + const result = adapter.start(contextFor(adapter.port)); + if (result) { + void Promise.resolve(result).catch((error: unknown) => { + reportError(error, adapter.port); + }); + } + } catch (error) { + reportError(error, adapter.port); + } + } + }, + publish(commands): void { + if (disposed) { + throw new Error("cannot publish through a disposed Uhura adapter host"); + } + for (const command of commands) { + if (command.target === "local") { + options.localCommand?.(command.value); + continue; + } + const adapter = adapters.get(command.port); + if (!adapter) { + throw new Error( + `admitted Uhura adapter for \`${command.port}\` disappeared`, + ); + } + const context = contextFor(command.port); + try { + const accepted = adapter.accept(command.value, context); + if (accepted) { + void Promise.resolve(accepted).catch((error: unknown) => { + reportError(error, command.port, command); + }); + } + } catch (error) { + reportError(error, command.port, command); + } + } + }, + dispose(): void { + if (disposed) return; + disposed = true; + abort.abort(); + deliveries.close(); + for (const adapter of adapters.values()) adapter.dispose?.(); + }, + }; +} diff --git a/web/src/play/application-location.test.ts b/web/src/play/application-location.test.ts new file mode 100644 index 0000000..5d71bd0 --- /dev/null +++ b/web/src/play/application-location.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { + applicationPathForBrowser, + browserUrlForApplication, +} from "./application-location.js"; + +describe("Play compatibility location", () => { + it("presents /play as the application's root route", () => { + expect(applicationPathForBrowser({ + pathname: "/play", + search: "?tab=home", + hash: "#top", + })).toBe("/?tab=home#top"); + expect(applicationPathForBrowser({ + pathname: "/search", + search: "?q=uhura", + hash: "", + })).toBe("/search?q=uhura"); + }); + + it("keeps the application's root inside the mounted Play surface", () => { + expect( + browserUrlForApplication("/", "http://localhost/search").pathname, + ).toBe("/play"); + expect( + browserUrlForApplication( + "/profile/mira?tab=posts", + "http://localhost/play", + ).pathname, + ).toBe("/profile/mira"); + }); +}); diff --git a/web/src/play/application-location.ts b/web/src/play/application-location.ts new file mode 100644 index 0000000..f8fa347 --- /dev/null +++ b/web/src/play/application-location.ts @@ -0,0 +1,32 @@ +import type { BrowserLocation } from "../app/router.js"; + +export const PLAY_COMPATIBILITY_PATH = "/play" as const; + +/** + * The host keeps `/` as the friendly Editor entry, while an application's + * checked route table is still allowed to own `/`. `/play` is therefore a + * browser-shell alias for the application's root location, never a second + * route in the machine. + */ +export const applicationPathForBrowser = ( + location: BrowserLocation, +): string => { + const pathname = + location.pathname === PLAY_COMPATIBILITY_PATH + || location.pathname === `${PLAY_COMPATIBILITY_PATH}/` + ? "/" + : location.pathname; + return `${pathname}${location.search}${location.hash}`; +}; + +/** Maps a checked application URL back into the host-owned browser topology. */ +export const browserUrlForApplication = ( + applicationUrl: string, + baseUrl: string, +): URL => { + const destination = new URL(applicationUrl, baseUrl); + if (destination.pathname === "/") { + destination.pathname = PLAY_COMPATIBILITY_PATH; + } + return destination; +}; diff --git a/web/src/play/browser-adapters.test.ts b/web/src/play/browser-adapters.test.ts new file mode 100644 index 0000000..289f180 --- /dev/null +++ b/web/src/play/browser-adapters.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; + +import { hash, type Value } from "../protocol/machine.js"; +import type { PortAdapterContext } from "./adapter-host.js"; +import { + type AdmittedPortRequirement, + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, +} from "./adapter-host.js"; +import { + createBrowserPortAdapters, + createWebHistoryAdapter, + WEB_ROUTER_CONTRACT, +} from "./browser-adapters.js"; +import type { UhuraProviderHost } from "./provider.js"; + +const requirement: AdmittedPortRequirement = { + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contract: WEB_ROUTER_CONTRACT, + contractHash: hash("1".repeat(64)), + contractInstanceHash: hash("2".repeat(64)), +}; + +const location: Value = { + $: "variant", + type: "example.app@1::Location", + case: "orders", + fields: [], +}; + +const changed: Value = { + $: "variant", + type: "uhura.web_router@1::RouterReceive", + case: "changed", + fields: [{ name: "location", value: location }], +}; + +const command = (kind: "push" | "replace"): Value => ({ + $: "variant", + type: "uhura.web_router@1::RouterSend", + case: kind, + fields: [{ name: "location", value: location }], +}); + +const setup = () => { + let locationListener: ((url: string) => void) | null = null; + const host = { + signal: new AbortController().signal, + pickFile: vi.fn(), + port: vi.fn(() => requirement), + decodeRoute: vi.fn(() => ({ + source: "port" as const, + port: "router", + value: changed, + })), + encodeRoute: vi.fn(() => "/orders"), + onLocation: vi.fn((listener) => { + locationListener = listener; + return vi.fn<() => void>(); + }), + navigate: vi.fn(), + back: vi.fn(), + } satisfies UhuraProviderHost; + const deliver = vi.fn(); + const context = { + signal: new AbortController().signal, + deliver, + } satisfies PortAdapterContext; + return { + host, + context, + emitLocation: (url: string) => { + if (locationListener === null) throw new Error("adapter was not started"); + locationListener(url); + }, + }; +}; + +describe("built-in browser adapters", () => { + it("constructs web history only for a Router assigned to web.history", () => { + const { host } = setup(); + expect(createBrowserPortAdapters([requirement], host)).toHaveLength(1); + expect(createBrowserPortAdapters([{ + ...requirement, + adapter: APPLICATION_PROVIDER_ADAPTER, + }], host)).toEqual([]); + }); + + it("rejects unsupported adapter and contract pairs", () => { + const { host } = setup(); + expect(() => createBrowserPortAdapters([{ + ...requirement, + contract: "uhura.ports@1::RequestPort", + }], host)).toThrow(/cannot implement/u); + expect(() => createBrowserPortAdapters([{ + ...requirement, + adapter: "unknown.adapter", + } as never], host)).toThrow(/unknown sealed Uhura adapter/u); + expect(() => createBrowserPortAdapters([ + requirement, + { ...requirement, port: "router_backup" }, + ], host)).toThrow(/at most one port/u); + }); + + it("decodes committed browser locations through Wasm", () => { + const { host, context, emitLocation } = setup(); + const adapter = createWebHistoryAdapter(requirement, host); + adapter.start?.(context); + emitLocation("/orders?state=open"); + expect(host.decodeRoute).toHaveBeenCalledWith( + "router", + "/orders?state=open", + ); + expect(context.deliver).toHaveBeenCalledWith(changed); + }); + + it("encodes push/replace and delegates back without inventing values", () => { + const { host, context } = setup(); + const adapter = createWebHistoryAdapter(requirement, host); + adapter.accept(command("push"), context); + adapter.accept(command("replace"), context); + adapter.accept({ + $: "variant", + type: "uhura.web_router@1::RouterSend", + case: "back", + fields: [], + }, context); + expect(host.encodeRoute).toHaveBeenCalledTimes(2); + expect(host.navigate).toHaveBeenNthCalledWith(1, "push", "/orders"); + expect(host.navigate).toHaveBeenNthCalledWith(2, "replace", "/orders"); + expect(host.back).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/play/browser-adapters.ts b/web/src/play/browser-adapters.ts new file mode 100644 index 0000000..6a62c9c --- /dev/null +++ b/web/src/play/browser-adapters.ts @@ -0,0 +1,100 @@ +import type { Value } from "../protocol/machine.js"; +import type { + AdmittedPortRequirement, + PortAdapter, + PortAdapterContext, +} from "./adapter-host.js"; +import { + partitionAdapterRequirements, + WEB_HISTORY_ADAPTER, + WEB_ROUTER_CONTRACT, +} from "./adapter-host.js"; +import type { UhuraProviderHost } from "./provider.js"; +export { WEB_ROUTER_CONTRACT } from "./adapter-host.js"; + +const locationField = (command: Value): Value => { + if (command.$ !== "variant") { + throw new TypeError("Uhura web-history command must be a variant"); + } + if (command.fields.length !== 1 || command.fields[0]?.name !== "location") { + throw new TypeError( + `Uhura web-history \`${command.case}\` command must contain exactly one named \`location\` field`, + ); + } + return command.fields[0].value; +}; + +/** + * Implements the sealed browser-history capability for one checked Router + * port. Route encoding and decoding stay in Wasm, so this adapter owns only + * browser effects and never reconstructs an Uhura value or route table. + */ +export const createWebHistoryAdapter = ( + requirement: AdmittedPortRequirement, + host: UhuraProviderHost, +): PortAdapter => { + if (requirement.adapter !== WEB_HISTORY_ADAPTER) { + throw new TypeError( + `Uhura web history cannot take ownership of ${JSON.stringify(requirement.adapter)}`, + ); + } + if (requirement.contract !== WEB_ROUTER_CONTRACT) { + throw new TypeError( + `Uhura web history cannot implement ${JSON.stringify(requirement.contract)}`, + ); + } + let stop: (() => void) | null = null; + return { + port: requirement.port, + adapter: requirement.adapter, + contractHash: requirement.contractHash, + contractInstanceHash: requirement.contractInstanceHash, + start(context: PortAdapterContext): void { + stop = host.onLocation((url) => { + context.deliver(host.decodeRoute(requirement.port, url).value); + }); + }, + accept(command): void { + if (command.$ !== "variant") { + throw new TypeError("Uhura web-history command must be a variant"); + } + switch (command.case) { + case "push": + case "replace": { + const url = host.encodeRoute( + requirement.port, + locationField(command), + ); + host.navigate(command.case, url); + return; + } + case "back": + if (command.fields.length !== 0) { + throw new TypeError( + "Uhura web-history `back` command cannot contain fields", + ); + } + host.back(); + return; + default: + throw new TypeError( + `unknown Uhura web-history command \`${command.case}\``, + ); + } + }, + dispose(): void { + stop?.(); + stop = null; + }, + }; +}; + +/** Creates every host-owned browser adapter required by the checked machine. */ +export const createBrowserPortAdapters = ( + requirements: readonly AdmittedPortRequirement[], + host: UhuraProviderHost, +): PortAdapter[] => { + const { browser } = partitionAdapterRequirements(requirements); + return browser + .map((requirement) => createWebHistoryAdapter(requirement, host)); +}; diff --git a/web/src/play/chrome.ts b/web/src/play/chrome.ts index 54f6f57..2f42bb1 100644 --- a/web/src/play/chrome.ts +++ b/web/src/play/chrome.ts @@ -1,4 +1,4 @@ -// Route-owned Play controls. Frame size, provider, actor, and restart remain +// Route-owned Play controls. Frame size, application actor, and restart remain // host state rather than Uhura application state. import type { SystemState } from "../protocol/types.js"; @@ -160,28 +160,16 @@ export function mountPlayChrome( function renderSystem(system: SystemState): void { if (disposed) return; - renderStatus(system.status, system.error); + const boundary = system.hasProvider + ? "Application adapters admitted" + : "Built-in adapters only"; + renderStatus(system.status, system.error ?? boundary); shell.restart.disabled = system.status === "starting"; - shell.providerControl.hidden = system.providers.length < 2; - - const priorProvider = shell.providerSelect.value; - clearOptions(shell.providerSelect); - for (const provider of system.providers) { - const option = shell.document.createElement("option"); - option.value = provider; - option.textContent = provider === "remote" ? "Remote" : "Fixture"; - shell.providerSelect.append(option); - } - if (system.provider) shell.providerSelect.value = system.provider; - else if (priorProvider) shell.providerSelect.value = priorProvider; - shell.providerSelect.disabled = - system.status === "starting" || system.providers.length < 2; clearOptions(shell.actorSelect); if (system.actors.length === 0) { const option = shell.document.createElement("option"); - option.textContent = - system.provider === "fixture" ? "Fixture identity" : "Unavailable"; + option.textContent = system.hasProvider ? "Not exposed" : "Local session"; shell.actorSelect.append(option); } else { const hasCurrent = system.actors.some((actor) => actor.id === system.actor); @@ -221,12 +209,6 @@ export function mountPlayChrome( renderSystem(detail as SystemState); } }; - const onProviderChange = (): void => { - const provider = shell.providerSelect.value; - if (provider === "remote" || provider === "fixture") { - view.__uhura?.setProvider(provider); - } - }; const onActorChange = (): void => { view.__uhura?.setActor(shell.actorSelect.value); }; @@ -258,7 +240,6 @@ export function mountPlayChrome( }; view.addEventListener("uhura:system-state", onSystemState); - shell.providerSelect.addEventListener("change", onProviderChange); shell.actorSelect.addEventListener("change", onActorChange); shell.fullscreen.addEventListener("click", onFullscreen); shell.document.addEventListener("fullscreenchange", renderFullscreen); @@ -294,7 +275,6 @@ export function mountPlayChrome( autoHideTimer = undefined; observer.disconnect(); view.removeEventListener("uhura:system-state", onSystemState); - shell.providerSelect.removeEventListener("change", onProviderChange); shell.actorSelect.removeEventListener("change", onActorChange); shell.fullscreen.removeEventListener("click", onFullscreen); shell.document.removeEventListener("fullscreenchange", renderFullscreen); diff --git a/web/src/play/debug-controller.ts b/web/src/play/debug-controller.ts index b7d6af8..623e7c8 100644 --- a/web/src/play/debug-controller.ts +++ b/web/src/play/debug-controller.ts @@ -3,14 +3,14 @@ // owns the inspection subscription and coalesces its publications to frames. import type { - InspectionHandle, - InspectionState, + RuntimeInspectionHandle, + RuntimeInspectionState, } from "../protocol/types.js"; export type DebugControllerUpdate = | { readonly kind: "inspection"; - readonly state: InspectionState; + readonly publication: RuntimeInspectionState; } | { readonly kind: "unavailable"; @@ -18,7 +18,7 @@ export type DebugControllerUpdate = export interface DebugControllerOptions { /** Resolved on each closed -> open transition, never while closed. */ - resolveInspection(): InspectionHandle | null | undefined; + resolveInspection(): RuntimeInspectionHandle | null | undefined; requestFrame(callback: () => void): number; cancelFrame(handle: number): void; render(update: DebugControllerUpdate): void; @@ -37,7 +37,7 @@ export interface DebugController { interface SubscriptionSlot { readonly generation: number; - handle: InspectionHandle | null; + handle: RuntimeInspectionHandle | null; stop: (() => void) | null; terminal: boolean; } @@ -141,7 +141,7 @@ export function createDebugController( open = true; const owner = ++generation; - let handle: InspectionHandle | null | undefined; + let handle: RuntimeInspectionHandle | null | undefined; try { handle = options.resolveInspection(); } catch { @@ -163,7 +163,7 @@ export function createDebugController( let stop: () => void; try { - stop = handle.subscribe((state) => { + stop = handle.subscribe((publication) => { if ( disposed || !open @@ -172,8 +172,11 @@ export function createDebugController( ) { return; } - queue(Object.freeze({ kind: "inspection", state }), slot.generation); - if (state.disposed) { + queue( + Object.freeze({ kind: "inspection", publication }), + slot.generation, + ); + if (publication.disposed) { slot.terminal = true; if (slot.stop !== null) release(slot); } @@ -185,7 +188,7 @@ export function createDebugController( } slot.stop = stop; - // InspectionHandle.subscribe replays synchronously. The replay, or a + // RuntimeInspectionHandle.subscribe replays synchronously. The replay, or a // custom handle around it, may close/dispose this controller before the // unsubscribe function is returned. Never retain that late function. if ( diff --git a/web/src/play/debug-layout.ts b/web/src/play/debug-layout.ts index c4642df..fb25431 100644 --- a/web/src/play/debug-layout.ts +++ b/web/src/play/debug-layout.ts @@ -81,12 +81,22 @@ const LANE_LABELS: Readonly> = { }; const KIND_ORDER: Readonly> = { - event: 0, - projection: 1, - state: 2, - handler: 3, - command: 4, - definition: 5, + module: -2, + part: -1, + port: 0, + "ui-event": 1, + input: 2, + transition: 3, + "commit-hook": 4, + computed: 4.5, + invariant: 4.55, + observation: 4.6, + update: 4.7, + state: 5, + command: 6, + outcome: 7, + presentation: 8, + machine: 9, }; function compareText(left: string, right: string): number { diff --git a/web/src/play/debug-model.ts b/web/src/play/debug-model.ts index aa61c50..28202c8 100644 --- a/web/src/play/debug-model.ts +++ b/web/src/play/debug-model.ts @@ -1,22 +1,39 @@ -// Pure projection from the versioned inspection protocol into the small, -// focused behavior graph consumed by Play's developer UI. Runtime values only -// decorate static nodes: they never decide which nodes exist, so a focused -// graph keeps the same geometry while the machine advances. +// Pure projection from admitted machine topology and immutable runtime +// inspection into the focused behavior graph consumed by Play's developer UI. +// Receipts decorate the checked graph; they never invent topology or claim +// execution details that the machine boundary does not expose. import type { - DeepReadonly, - InspectProgramEdge, - InspectProgramNode, - InspectSourceSpan, - InspectionState, - StepTrace, - TraceGuardNote, + RuntimeInspectionState, } from "../protocol/types.js"; +import type { + GraphEdgeKind, + GraphNodeKind, + GraphSourceRef, + OutcomePolicy, +} from "../protocol/interaction-graph.js"; +import type { + ReactionReceipt, + ResolvedCommand, + ResolvedInput, + Value, +} from "../protocol/machine.js"; export type DebugLane = "input" | "handler" | "effect"; -export type DebugDefinitionKind = "page" | "surface" | "component"; -export type DebugProjectionApply = "applied" | "dropped-stale" | "failed"; +export type DebugDefinitionKind = "machine"; export type DebugEdgeActivity = "idle" | "context" | "taken"; +export type DebugGraphNodeKind = GraphNodeKind; +export type DebugGraphEdgeKind = GraphEdgeKind; + +export interface DebugSourceSpan { + /** Stable source inventory identity from the admitted inspection artifact. */ + readonly id: string | null; + readonly file: string; + /** Inclusive UTF-8 byte offset; this is not a JavaScript string index. */ + readonly start: number; + /** Exclusive UTF-8 byte offset; this is not a JavaScript string index. */ + readonly end: number; +} export interface DebugDefinitionOption { readonly id: string; @@ -24,59 +41,53 @@ export interface DebugDefinitionOption { readonly label: string; readonly entry: boolean; readonly active: boolean; - readonly top: boolean; readonly runtime: boolean; - readonly transitionTarget: boolean; } export interface DebugNodeRuntime { - /** The owning definition is mounted, or this definition target is mounted. */ + /** The node belongs to the admitted machine instance. */ readonly active: boolean; - /** The node participated in the latest retained step. */ + /** The node participated in the latest retained receipt. */ readonly current: boolean; readonly selected: boolean; - readonly consulted: TraceGuardNote["guard"] | null; readonly written: boolean; readonly sent: boolean; - readonly pending: number; - readonly projectionApply: DebugProjectionApply | null; - readonly projectionReady: number; - readonly projectionFailures: number; - readonly structural: boolean; } export interface DebugGraphNode { readonly id: string; - readonly kind: InspectProgramNode["kind"]; + readonly kind: DebugGraphNodeKind; readonly lane: DebugLane; - readonly definitionId: string | null; + readonly definitionId: string; readonly label: string; readonly detail: string | null; - /** Source-order hint. Handler nodes use their absolute handler index. */ + /** Stable source-order hint within a lane. */ readonly order: number; - readonly span: InspectSourceSpan | null; + readonly span: Omit | null; + readonly sourceSpans: readonly DebugSourceSpan[]; readonly runtime: DebugNodeRuntime; } export interface DebugGraphEdge { readonly id: string; - readonly kind: InspectProgramEdge["kind"]; + readonly kind: DebugGraphEdgeKind; readonly from: string; readonly to: string; readonly label: string; - readonly order: number | null; - readonly mode: "push" | "replace" | null; + readonly order: number; readonly activity: DebugEdgeActivity; + readonly sourceSpans: readonly DebugSourceSpan[]; } -export type DebugEmptyReason = "loading" | "disposed" | "no-definitions"; +export type DebugEmptyReason = "loading" | "disposed" | "no-machines"; export interface DebugGraphModel { readonly disposed: boolean; readonly emptyReason: DebugEmptyReason | null; readonly generation: number | null; readonly programHash: string | null; - readonly revision: number | null; + /** Exact machine sequence text. Never projected through a JavaScript number. */ + readonly exactSequence: string | null; readonly focusDefinitionId: string | null; readonly runtimeDefinitionId: string | null; readonly definitions: readonly DebugDefinitionOption[]; @@ -85,26 +96,27 @@ export interface DebugGraphModel { } export interface DeriveDebugGraphOptions { - /** A valid definition pins focus; absent/invalid focus follows the runtime. */ + /** A valid machine ID pins focus; absent/invalid focus follows the runtime. */ readonly focusDefinitionId?: string | null; } -type ProgramNode = DeepReadonly; -type ProgramEdge = DeepReadonly; - -const DEFINITION_KIND_ORDER: Readonly> = { - page: 0, - surface: 1, - component: 2, -}; - -const NODE_KIND_ORDER: Readonly> = { - event: 0, - projection: 1, - state: 2, - handler: 3, - command: 4, - definition: 5, +const NODE_KIND_ORDER: Readonly> = { + module: -2, + part: -1, + port: 0, + "ui-event": 1, + input: 2, + transition: 3, + "commit-hook": 4, + computed: 4.5, + invariant: 4.55, + observation: 4.6, + update: 4.7, + state: 5, + command: 6, + outcome: 7, + presentation: 8, + machine: 9, }; const LANE_ORDER: Readonly> = { @@ -117,39 +129,11 @@ function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function definitionIdForNode(node: ProgramNode): string | null { - switch (node.kind) { - case "definition": - return node.id; - case "event": - case "handler": - case "state": - return node.definition; - case "command": - case "projection": - return null; - } -} - -/** Maps a canonical dispatch record to the same definition namespace as IR. */ -export function runtimeDefinitionIdForTrace( - trace: DeepReadonly | null, -): string | null { - const dispatch = trace?.dispatch; - if (!dispatch) return null; - if (dispatch.scope.startsWith("page:")) return `pages.${dispatch.definition}`; - if (dispatch.scope.startsWith("surface:")) { - return `surfaces.${dispatch.definition}`; - } - return null; -} - function stableJson(value: unknown): string | undefined { if (value === undefined) return undefined; if (value === null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) { - const items = value.map((item) => stableJson(item) ?? "null"); - return `[${items.join(",")}]`; + return `[${value.map((item) => stableJson(item) ?? "null").join(",")}]`; } const record = value as Record; const fields = Object.keys(record) @@ -161,403 +145,225 @@ function stableJson(value: unknown): string | undefined { return `{${fields.join(",")}}`; } -/** Compact deterministic value text; layout never measures this string. */ -export function formatDebugValue(value: unknown, maxLength = 88): string { - const encoded = stableJson(value) ?? "unset"; - if (encoded.length <= maxLength) return encoded; - if (maxLength <= 1) return "…".slice(0, maxLength); - return `${encoded.slice(0, maxLength - 1)}…`; +function renderValue(value: Value): string { + switch (value.$) { + case "unit": + return "unit"; + case "bool": + return String(value.value); + case "Int": + case "Nat": + case "PositiveInt": + case "Decimal": + case "Ratio": + return value.value; + case "BoundaryNumber": + return value.case === "finite" ? value.value : value.case; + case "Text": + return JSON.stringify(value.value); + case "key": + return `${value.type}(${renderValue(value.value)})`; + case "tuple": + return `(${value.items.map(renderValue).join(", ")})`; + case "record": + return `{ ${ + value.fields + .map((field) => `${field.name}: ${renderValue(field.value)}`) + .join(", ") + } }`; + case "variant": { + const fields = value.fields.map((field) => { + const rendered = renderValue(field.value); + return field.name === null ? rendered : `${field.name}: ${rendered}`; + }); + return fields.length === 0 + ? value.case + : `${value.case}(${fields.join(", ")})`; + } + case "seq": + return `[${value.items.map(renderValue).join(", ")}]`; + case "nonempty": + return `NonEmpty[${value.items.map(renderValue).join(", ")}]`; + case "set": + return `Set{${value.items.map(renderValue).join(", ")}}`; + case "map": + return `Map{ ${ + value.entries + .map(([key, entry]) => + `${renderValue(key)}: ${renderValue(entry)}`) + .join(", ") + } }`; + case "table": + return `${value.keyType}{ ${ + value.entries + .map(([key, entry]) => + `${JSON.stringify(key)}: ${renderValue(entry)}`) + .join(", ") + } }`; + } } -function activeDefinitions( - state: InspectionState, -): { ids: Set; top: string | null } { - const snapshot = state.latest?.inspection; - if (!snapshot) return { ids: new Set(), top: null }; - const ids = new Set(); - for (const entry of snapshot.u.nav) ids.add(`pages.${entry.route}`); - for (const surface of snapshot.u.surfaces) { - ids.add(`surfaces.${surface.definition}`); +/** + * Human-facing Uhura value text. Exact numerics remain canonical text and are + * never translated through JavaScript's lossy number domain. + */ +export function formatDebugValue( + value: Value, + maxLength = 120, +): string { + const rendered = renderValue(value); + if ( + value.$ === "Int" + || value.$ === "Nat" + || value.$ === "PositiveInt" + || value.$ === "Decimal" + || value.$ === "Ratio" + || (value.$ === "BoundaryNumber" && value.case === "finite") + ) { + return rendered; } - const topSurface = snapshot.u.surfaces.at(-1); - if (topSurface) return { ids, top: `surfaces.${topSurface.definition}` }; - const topPage = snapshot.u.nav.at(-1); - return { ids, top: topPage ? `pages.${topPage.route}` : null }; + if (rendered.length <= maxLength) return rendered; + if (maxLength <= 1) return "…".slice(0, maxLength); + return `${rendered.slice(0, maxLength - 1)}…`; } -function structuralTargets(trace: DeepReadonly | null): Set { - const targets = new Set(); - const surfaceDefinition = (instance: string): string => - instance.replace(/:\d+$/, ""); - for (const operation of trace?.structural ?? []) { - switch (operation.op) { - case "init": - case "navigate": - case "replace": - targets.add(`pages.${operation.route}`); - break; - case "back": - if (operation.to !== null) targets.add(`pages.${operation.to}`); - break; - case "open-surface": - case "already-open": - case "force-close": - case "dismiss": - targets.add(`surfaces.${surfaceDefinition(operation.surface)}`); - break; - case "nav-underflow": - break; - } - } - return targets; +function constructor(value: Value): string | null { + return value.$ === "variant" ? value.case : null; } -interface DefinitionInstance { - readonly definitionId: string; - readonly scope: string; - readonly state: Readonly>; +function inputLabel(input: ResolvedInput): string | null { + const name = constructor(input.value); + if (name === null) return null; + return input.source === "port" ? `${input.port}.${name}` : name; } -function definitionInstance( - state: InspectionState, - definitionId: string, - exactScope?: string, -): DefinitionInstance | null { - const snapshot = state.latest?.inspection; - if (!snapshot) return null; - if (definitionId.startsWith("pages.")) { - const route = definitionId.slice("pages.".length); - const candidates = snapshot.u.nav.filter((item) => item.route === route); - const entry = exactScope === undefined - ? candidates.at(-1) - : candidates.find((item) => `page:${item.serial}` === exactScope); - return entry - ? { definitionId, scope: `page:${entry.serial}`, state: entry.state } - : null; - } - if (definitionId.startsWith("surfaces.")) { - const definition = definitionId.slice("surfaces.".length); - const candidates = snapshot.u.surfaces.filter( - (item) => item.definition === definition, - ); - const surface = exactScope === undefined - ? candidates.at(-1) - : candidates.find((item) => `surface:${item.serial}` === exactScope); - return surface - ? { - definitionId, - scope: `surface:${surface.serial}`, - state: surface.state, - } - : null; - } - return null; +function commandLabel(command: ResolvedCommand): string | null { + const name = constructor(command.value); + if (name === null) return null; + return command.target === "port" ? `${command.port}.${name}` : name; } -function staticEdgeOrder(edge: ProgramEdge): number | null { - switch (edge.kind) { - case "writes": - case "sends": - case "opens": - case "navigates": - return edge.order; - case "handles": - case "guard-reads": - case "body-reads": - case "settles": - return null; - } +function labelMatches(graphLabel: string, runtimeLabel: string | null): boolean { + return runtimeLabel !== null && graphLabel === runtimeLabel; } -function staticEdgeMode(edge: ProgramEdge): "push" | "replace" | null { - return edge.kind === "navigates" ? edge.mode : null; +function recordFields( + value: Value | null, +): ReadonlyMap { + if (value?.$ !== "record") return new Map(); + return new Map(value.fields.map((field) => [field.name, field.value])); } -function edgeSignature(edge: ProgramEdge): string { - return [ - edge.kind, - edge.from, - edge.to, - String(staticEdgeOrder(edge) ?? -1), - staticEdgeMode(edge) ?? "", - ].join("|"); +function valueEqual( + left: Value | undefined, + right: Value | undefined, +): boolean { + if (left === undefined || right === undefined) return left === right; + return stableJson(left) === stableJson(right); } -function edgeLabel(edge: ProgramEdge): string { - switch (edge.kind) { - case "handles": - return "handles"; - case "guard-reads": - return "guard"; - case "body-reads": - return "reads"; - case "writes": - return "writes"; - case "sends": - return "sends"; - case "opens": - return "opens"; - case "navigates": - return edge.mode; - case "settles": - return "settles"; - } +function sourceSpan(source: GraphSourceRef): DebugSourceSpan { + return { + id: source.id, + file: source.path, + start: source.start, + end: source.end, + }; } -function laneForNode( - node: ProgramNode, - writtenStateIds: ReadonlySet, -): DebugLane { - switch (node.kind) { - case "handler": - return "handler"; - case "event": - case "projection": +function lane(kind: GraphNodeKind): DebugLane { + switch (kind) { + case "module": + case "part": + case "computed": + case "invariant": + case "observation": + case "port": + case "presentation": + case "ui-event": return "input"; + case "input": + case "transition": + case "commit-hook": + case "update": + return "handler"; + case "machine": case "state": - return writtenStateIds.has(node.id) ? "effect" : "input"; case "command": - case "definition": + case "outcome": return "effect"; } } -function definitionDetail(node: Extract): string { - const kind = node["definition-kind"]; - const label = `${kind[0]?.toUpperCase() ?? ""}${kind.slice(1)}`; - return node.entry ? `${label} · entry` : label; +interface RuntimeFacts { + readonly reaction: ReactionReceipt | null; + readonly inputLabel: string | null; + readonly inputPort: string | null; + readonly commandLabels: readonly string[]; + readonly commandPorts: ReadonlySet; + readonly outcomeLabel: string | null; + readonly commit: boolean; + readonly stateFields: ReadonlyMap; + readonly writtenFields: ReadonlySet; } -function projectionDetail( - node: Extract, - state: InspectionState, -): string { - const snapshot = state.latest?.inspection; - if (!snapshot) return `Projection · ${node.port}`; - const ready = snapshot.x.snapshots.filter( - (item) => item.projection === node.name, - ); - const failed = snapshot.x.failed.filter( - (item) => item.projection === node.name, - ); - if (ready.length === 0 && failed.length === 0) return "Waiting"; - if (ready.length === 1 && failed.length === 0) { - return `Ready · ${formatDebugValue(ready[0]?.value)}`; - } - const parts: string[] = []; - if (ready.length > 0) parts.push(`${ready.length} ready`); - if (failed.length > 0) parts.push(`${failed.length} failed`); - return parts.join(" · "); -} - -function presentation( - node: ProgramNode, - state: InspectionState, - instance: DefinitionInstance | null, - pendingByCommand: ReadonlyMap, -): { label: string; detail: string | null; order: number } { - switch (node.kind) { - case "definition": - return { label: node.name, detail: definitionDetail(node), order: 0 }; - case "event": { - const detail = node["event-kind"] === "outcome" - ? `${node.command ?? "command"}.${node.outcome ?? "outcome"}` - : "Semantic event"; - return { label: node.name, detail, order: 0 }; - } - case "handler": - return { - label: `Handler ${node.index + 1}`, - detail: `on ${node.on}${node.guarded ? " · guarded" : ""}`, - order: node.index, - }; - case "state": { - const values = instance?.definitionId === node.definition - ? instance.state - : null; - const value = values && Object.hasOwn(values, node.name) - ? values[node.name] - : node.initial; - const prefix = values ? "" : "Initial · "; - return { - label: node.name, - detail: `${prefix}${formatDebugValue(value)}`, - order: 0, - }; +function runtimeFacts(state: RuntimeInspectionState): RuntimeFacts { + const latest = state.latest; + const receipt = latest?.receipt; + const reaction = receipt?.kind === "reaction" ? receipt : null; + const stateFields = recordFields(latest?.inspection.state ?? null); + const prior = state.history.length > 1 + ? state.history.at(-2)?.inspection.state ?? null + : null; + const priorFields = recordFields(prior); + const writtenFields = new Set(); + if (prior !== null) { + for (const [field, value] of stateFields) { + if (!valueEqual(priorFields.get(field), value)) { + writtenFields.add(field); + } } - case "projection": - return { label: node.name, detail: projectionDetail(node, state), order: 0 }; - case "command": { - const pending = pendingByCommand.get(node.name) ?? 0; - const detail = pending > 0 - ? `${pending} pending` - : node.port - ? `Command · ${node.port}` - : "Command"; - return { label: node.name, detail, order: 0 }; + for (const field of priorFields.keys()) { + if (!stateFields.has(field)) writtenFields.add(field); } } -} - -function programSpan( - spans: DeepReadonly>, - id: string, -): InspectSourceSpan | null { - const span = spans[id]; - return span ? { file: span.file, start: span.start, end: span.end } : null; -} - -function projectionRuntime( - node: ProgramNode, - state: InspectionState, - applies: ReadonlyMap, -): { - apply: DebugProjectionApply | null; - ready: number; - failures: number; -} { - if (node.kind !== "projection") return { apply: null, ready: 0, failures: 0 }; - const snapshot = state.latest?.inspection; - return { - apply: applies.get(node.name) ?? null, - ready: snapshot?.x.snapshots.filter( - (item) => item.projection === node.name, - ).length ?? 0, - failures: snapshot?.x.failed.filter( - (item) => item.projection === node.name, - ).length ?? 0, - }; -} - -interface EdgeActivityContext { - readonly currentEventId: string | null; - readonly selectedHandlerId: string | null; - readonly consultedHandlers: ReadonlyMap; - readonly runtimeWrittenIds: ReadonlySet; - readonly sentCommandIds: ReadonlySet; - readonly transitionTargets: ReadonlySet; -} - -interface DebugNodeContext extends EdgeActivityContext { - readonly state: InspectionState; - readonly spans: DeepReadonly>; - readonly writtenStateIds: ReadonlySet; - readonly activeIds: ReadonlySet; - readonly instance: DefinitionInstance | null; - readonly pendingByCommand: ReadonlyMap; - readonly applies: ReadonlyMap; -} - -function debugNode(node: ProgramNode, context: DebugNodeContext): DebugGraphNode { - const { - state, - spans, - writtenStateIds, - activeIds, - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - instance, - pendingByCommand, - applies, - transitionTargets, - } = context; - const definitionId = definitionIdForNode(node); - const view = presentation(node, state, instance, pendingByCommand); - const consulted = consultedHandlers.get(node.id) ?? null; - const written = runtimeWrittenIds.has(node.id); - const sent = sentCommandIds.has(node.id); - const projection = projectionRuntime(node, state, applies); - const structural = transitionTargets.has(node.id); - const selected = node.id === selectedHandlerId; - const active = node.kind === "definition" - ? activeIds.has(node.id) - : definitionId !== null && activeIds.has(definitionId); - const current = node.id === currentEventId - || consulted !== null - || selected - || written - || sent - || projection.apply !== null - || structural; + const commandLabels = reaction?.orderedCommands + .map(commandLabel) + .filter((label): label is string => label !== null) ?? []; + const commandPorts = new Set( + reaction?.orderedCommands.flatMap((command) => + command.target === "port" ? [command.port] : []) ?? [], + ); + const completed = reaction?.resolution.kind === "completed" + ? reaction.resolution + : null; return { - id: node.id, - kind: node.kind, - lane: laneForNode(node, writtenStateIds), - definitionId, - label: view.label, - detail: view.detail, - order: view.order, - span: programSpan(spans, node.id), - runtime: { - active, - current, - selected, - consulted, - written, - sent, - pending: node.kind === "command" - ? pendingByCommand.get(node.name) ?? 0 - : 0, - projectionApply: projection.apply, - projectionReady: projection.ready, - projectionFailures: projection.failures, - structural, - }, + reaction, + inputLabel: reaction ? inputLabel(reaction.input) : null, + inputPort: reaction?.input.source === "port" + ? reaction.input.port + : null, + commandLabels, + commandPorts, + outcomeLabel: completed === null + ? null + : constructor(completed.outcome), + commit: completed?.disposition === "commit", + stateFields, + writtenFields, }; } -function edgeActivity( - edge: ProgramEdge, - context: EdgeActivityContext, -): DebugEdgeActivity { - const { - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - transitionTargets, - } = context; - switch (edge.kind) { - case "handles": - if (edge.from === currentEventId && edge.to === selectedHandlerId) return "taken"; - if (edge.from === currentEventId && consultedHandlers.has(edge.to)) return "context"; - return "idle"; - case "guard-reads": - return consultedHandlers.has(edge.to) ? "context" : "idle"; - case "body-reads": - return edge.to === selectedHandlerId ? "context" : "idle"; - case "writes": - return edge.from === selectedHandlerId && runtimeWrittenIds.has(edge.to) - ? "taken" - : "idle"; - case "sends": - return edge.from === selectedHandlerId && sentCommandIds.has(edge.to) - ? "taken" - : "idle"; - case "opens": - case "navigates": - return edge.from === selectedHandlerId && transitionTargets.has(edge.to) - ? "taken" - : "idle"; - case "settles": - return edge.to === currentEventId ? "taken" : "idle"; - } -} - function emptyModel( - state: InspectionState, + state: RuntimeInspectionState, reason: DebugEmptyReason, ): DebugGraphModel { return { disposed: state.disposed, emptyReason: reason, - generation: null, - programHash: null, - revision: null, + generation: state.artifacts?.generation ?? null, + programHash: state.artifacts?.deployment.machineProgramHash ?? null, + exactSequence: state.latest?.inspection.nextSequence ?? null, focusDefinitionId: null, runtimeDefinitionId: null, definitions: [], @@ -566,226 +372,296 @@ function emptyModel( }; } +function nodeDetail( + kind: GraphNodeKind, + label: string, + state: RuntimeInspectionState, + facts: RuntimeFacts, + runtimeMachine: boolean, + policy: OutcomePolicy | null, +): string | null { + const inspection = state.latest?.inspection; + switch (kind) { + case "module": + return "Source module"; + case "machine": + return runtimeMachine && inspection + ? `Machine · ${inspection.lifecycle}` + : "Machine"; + case "part": + return "Composed part"; + case "port": + return facts.inputPort === label + ? "Inbound port" + : facts.commandPorts.has(label) + ? "Outbound port" + : "Port"; + case "input": + return facts.reaction && labelMatches(label, facts.inputLabel) + ? `Input · ${formatDebugValue(facts.reaction.input.value)}` + : "Input handler"; + case "transition": + return "Named transition"; + case "commit-hook": + return "Atomic commit hook"; + case "state": { + const value = facts.stateFields.get(label); + return value === undefined ? "State" : formatDebugValue(value); + } + case "computed": + return "Computed read"; + case "invariant": + return "Invariant"; + case "update": + return "Callable update"; + case "observation": + return "Committed observation"; + case "command": { + const commands = facts.reaction?.orderedCommands.filter((command) => + labelMatches(label, commandLabel(command))) ?? []; + if (commands.length === 0) return "Command"; + return commands + .map((command) => formatDebugValue(command.value)) + .join(" · "); + } + case "outcome": { + const resolution = facts.reaction?.resolution; + return resolution?.kind === "completed" + && labelMatches(label, facts.outcomeLabel) + ? `${resolution.disposition} · ${ + formatDebugValue(resolution.outcome) + }` + : policy === null + ? "Outcome" + : `Outcome · ${policy}`; + } + case "presentation": + return runtimeMachine && inspection + ? `Observation · ${formatDebugValue(inspection.observation)}` + : "Presentation"; + case "ui-event": + return "Checked UI event binding"; + } +} + +function edgeKey( + edge: { readonly kind: GraphEdgeKind; readonly from: string; readonly to: string }, +): string { + return `${edge.kind}\u0000${edge.from}\u0000${edge.to}`; +} + /** - * Produces one definition-sized behavior graph. The returned node and edge set - * depends only on `(program, focusDefinitionId)`; live state changes labels and - * runtime marks without moving or adding graph structure. + * Projects one inspection publication into a stable, machine-sized graph. + * Runtime receipts decorate admitted nodes and edges conservatively. */ export function deriveDebugGraph( - state: InspectionState, + state: RuntimeInspectionState, options: DeriveDebugGraphOptions = {}, ): DebugGraphModel { const artifacts = state.artifacts; - if (!artifacts) return emptyModel(state, state.disposed ? "disposed" : "loading"); - - const program = artifacts.program; - const nodesById = new Map(program.nodes.map((node) => [node.id, node])); - const definitionNodes = program.nodes.filter( - (node): node is Extract => - node.kind === "definition", - ); - if (definitionNodes.length === 0) { - return { - ...emptyModel(state, "no-definitions"), - generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, - }; + if (artifacts === null) { + return emptyModel(state, state.disposed ? "disposed" : "loading"); } - - const trace = state.latest?.trace ?? null; - const runtimeDefinitionId = runtimeDefinitionIdForTrace(trace); - const active = activeDefinitions(state); - const transitionTargets = structuralTargets(trace); - const validDefinitionIds = new Set(definitionNodes.map((node) => node.id)); + const deployment = artifacts.deployment; + const graph = deployment.interactionGraph; + const machineNodes = graph.nodes.filter((node) => node.kind === "machine"); + if (machineNodes.length === 0) { + return emptyModel(state, "no-machines"); + } + const deployedMachineNode = machineNodes.find( + (node) => node.machine === deployment.machine, + ) ?? null; + const validDefinitionIds = new Set(machineNodes.map((node) => node.id)); const requested = options.focusDefinitionId; - const entryId = `pages.${program.ir.entry}`; const focusDefinitionId = requested && validDefinitionIds.has(requested) ? requested - : runtimeDefinitionId && validDefinitionIds.has(runtimeDefinitionId) - ? runtimeDefinitionId - : active.top && validDefinitionIds.has(active.top) - ? active.top - : validDefinitionIds.has(entryId) - ? entryId - : definitionNodes - .map((node) => node.id) - .sort(compareText)[0] ?? null; - - const definitions = definitionNodes + : deployedMachineNode?.id + ?? [...validDefinitionIds].sort(compareText)[0] + ?? null; + const runtimeDefinitionId = state.latest === null + ? null + : deployedMachineNode?.id ?? null; + const definitions = machineNodes .map((node): DebugDefinitionOption => ({ id: node.id, - kind: node["definition-kind"], - label: node.name, - entry: node.entry === true, - active: active.ids.has(node.id), - top: active.top === node.id, - runtime: runtimeDefinitionId === node.id, - transitionTarget: transitionTargets.has(node.id), + kind: "machine", + label: node.label, + entry: node.machine === deployment.machine, + active: node.machine === deployment.machine + && state.latest?.inspection.lifecycle !== "stopped", + runtime: node.id === runtimeDefinitionId, })) .sort((left, right) => - DEFINITION_KIND_ORDER[left.kind] - DEFINITION_KIND_ORDER[right.kind] - || compareText(left.label, right.label) - || compareText(left.id, right.id)); - + compareText(left.label, right.label) || compareText(left.id, right.id)); if (focusDefinitionId === null) { return { - disposed: false, - emptyReason: "no-definitions", - generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, - focusDefinitionId: null, - runtimeDefinitionId, + ...emptyModel(state, "no-machines"), definitions, - nodes: [], - edges: [], }; } - - const localNodeIds = new Set( - program.nodes - .filter((node) => - node.kind !== "definition" - && definitionIdForNode(node) === focusDefinitionId) - .map((node) => node.id), - ); - const localHandlerIds = new Set( - program.nodes - .filter( - (node) => node.kind === "handler" && node.definition === focusDefinitionId, - ) - .map((node) => node.id), - ); - - const focusedEdges = program.edges.filter((edge) => - localHandlerIds.has(edge.from) || localHandlerIds.has(edge.to)); - const includedNodeIds = new Set(localNodeIds); - for (const edge of focusedEdges) { - includedNodeIds.add(edge.from); - includedNodeIds.add(edge.to); - } - // Commands sent by this definition can settle into its outcome events. - const settleEdges = program.edges.filter((edge) => - edge.kind === "settles" - && includedNodeIds.has(edge.from) - && localNodeIds.has(edge.to)); - const includedEdges = [...focusedEdges, ...settleEdges] - .filter((edge, index, all) => all.indexOf(edge) === index); - - const writtenStateIds = new Set( - focusedEdges.filter((edge) => edge.kind === "writes").map((edge) => edge.to), - ); - const dispatch = trace?.dispatch; - const traceMatchesFocus = runtimeDefinitionId === focusDefinitionId; - // A dispatch identifies one concrete mounted instance. When there is no - // dispatch (for example a projection delivery or a user-pinned definition), - // the topmost mounted instance of that definition is the observable one. - // If the dispatched instance was structurally removed by this step, do not - // fall through to a different duplicate instance with the same definition. - const exactScope = traceMatchesFocus ? dispatch?.scope : undefined; - const instance = definitionInstance( - state, - focusDefinitionId, - exactScope, - ); - const focusScope = exactScope ?? instance?.scope ?? null; - const currentEventId = traceMatchesFocus && dispatch - ? `${focusDefinitionId}/event/${dispatch.on}` - : null; - const selectedHandlerId = traceMatchesFocus && dispatch?.selected !== null - && dispatch?.selected !== undefined - ? `${focusDefinitionId}/handler/${dispatch.selected}` - : null; - const consultedHandlers = new Map(); - if (traceMatchesFocus && dispatch) { - for (const guard of dispatch.guards) { - consultedHandlers.set( - `${focusDefinitionId}/handler/${guard.handler}`, - guard.guard, - ); - } - } - const runtimeWrittenIds = new Set(); - if (traceMatchesFocus && dispatch) { - for (const write of dispatch.writes ?? []) { - runtimeWrittenIds.add(`${focusDefinitionId}/state/${write.field}`); - } - } - const sentCommandIds = new Set(); - if (traceMatchesFocus) { - for (const message of trace?.c ?? []) { - if (message.kind === "command" && message.command) { - sentCommandIds.add(`commands.${message.command}`); - } - } - } - const pendingByCommand = new Map(); - for (const pending of Object.values(state.latest?.inspection.u.pending ?? {})) { - if (focusScope === null || pending.origin !== focusScope) continue; - pendingByCommand.set( - pending.command, - (pendingByCommand.get(pending.command) ?? 0) + 1, - ); + const focusedMachine = machineNodes.find( + (node) => node.id === focusDefinitionId, + )?.machine; + if (focusedMachine === undefined) { + return { + ...emptyModel(state, "no-machines"), + definitions, + }; } - const applies = new Map(); - for (const apply of trace?.applies ?? []) applies.set(apply.projection, apply.apply); - const focusedTransitionTargets: ReadonlySet = traceMatchesFocus - ? transitionTargets - : new Set(); - const debugContext: DebugNodeContext = { - state, - spans: program.spans, - writtenStateIds, - activeIds: active.ids, - currentEventId, - selectedHandlerId, - consultedHandlers, - runtimeWrittenIds, - sentCommandIds, - instance, - pendingByCommand, - applies, - transitionTargets: focusedTransitionTargets, - }; - const nodes = [...includedNodeIds] - .map((id) => nodesById.get(id)) - .filter((node): node is ProgramNode => node !== undefined) - .map((node) => debugNode(node, debugContext)) + const runtimeMachine = focusedMachine === deployment.machine; + const facts: RuntimeFacts = runtimeMachine + ? runtimeFacts(state) + : { + reaction: null, + inputLabel: null, + inputPort: null, + commandLabels: [], + commandPorts: new Set(), + outcomeLabel: null, + commit: false, + stateFields: new Map(), + writtenFields: new Set(), + }; + const nodeSources = new Map( + deployment.graphSources.nodes.map((entry) => [entry.node, entry.sources]), + ); + const included = graph.nodes.filter((node) => node.machine === focusedMachine); + const includedIds = new Set(included.map((node) => node.id)); + const activeMachine = focusedMachine === deployment.machine + && state.latest?.inspection.lifecycle !== "stopped"; + const nodes = included + .map((node, order): DebugGraphNode => { + const inputCurrent = node.kind === "input" + && labelMatches(node.label, facts.inputLabel); + const commandCurrent = node.kind === "command" + && facts.commandLabels.some((label) => + labelMatches(node.label, label)); + const outcomeCurrent = node.kind === "outcome" + && labelMatches(node.label, facts.outcomeLabel); + const hookCurrent = node.kind === "commit-hook" && facts.commit; + const stateWritten = node.kind === "state" + && facts.writtenFields.has(node.label); + const portCurrent = node.kind === "port" + && (facts.inputPort === node.label || facts.commandPorts.has(node.label)); + const machineCurrent = runtimeMachine + && node.kind === "machine" + && state.latest !== null; + const presentationCurrent = node.kind === "presentation" + && runtimeMachine + && state.latest !== null + && node.label === deployment.presentation; + const current = inputCurrent + || commandCurrent + || outcomeCurrent + || hookCurrent + || stateWritten + || portCurrent + || machineCurrent + || presentationCurrent; + const sources = (nodeSources.get(node.id) ?? []).map(sourceSpan); + const first = sources[0]; + return { + id: node.id, + kind: node.kind, + lane: lane(node.kind), + definitionId: focusDefinitionId, + label: node.label, + detail: nodeDetail( + node.kind, + node.label, + state, + facts, + runtimeMachine, + graph.outcomePolicies[node.id] ?? null, + ), + order, + span: first + ? { file: first.file, start: first.start, end: first.end } + : null, + sourceSpans: sources, + runtime: { + active: activeMachine, + current, + selected: inputCurrent || hookCurrent, + written: stateWritten, + sent: commandCurrent, + }, + }; + }) .sort((left, right) => LANE_ORDER[left.lane] - LANE_ORDER[right.lane] || NODE_KIND_ORDER[left.kind] - NODE_KIND_ORDER[right.kind] || left.order - right.order || compareText(left.id, right.id)); - - const sortedProgramEdges = includedEdges - .map((edge, sourceIndex) => ({ edge, sourceIndex, signature: edgeSignature(edge) })) - .sort((left, right) => - compareText(left.signature, right.signature) - || left.sourceIndex - right.sourceIndex); - const duplicateCounts = new Map(); - const edges = sortedProgramEdges.map(({ edge, signature }): DebugGraphEdge => { - const duplicate = duplicateCounts.get(signature) ?? 0; - duplicateCounts.set(signature, duplicate + 1); - return { - id: `edge/${signature}/${duplicate}`, - kind: edge.kind, - from: edge.from, - to: edge.to, - label: edgeLabel(edge), - order: staticEdgeOrder(edge), - mode: staticEdgeMode(edge), - activity: edgeActivity(edge, debugContext), - }; - }); + const runtimeById = new Map(nodes.map((node) => [node.id, node.runtime])); + const edgeSources = new Map( + deployment.graphSources.edges.map((entry) => [ + edgeKey(entry.edge), + entry.sources, + ]), + ); + const edges = graph.edges + .filter((edge) => includedIds.has(edge.from) && includedIds.has(edge.to)) + .map((edge, order): DebugGraphEdge => { + const from = runtimeById.get(edge.from); + const to = runtimeById.get(edge.to); + let activity: DebugEdgeActivity = "idle"; + switch (edge.kind) { + case "delivers": + if (from?.current && to?.selected) activity = "taken"; + break; + case "writes": + if (from?.current && to?.written) activity = "taken"; + break; + case "emits": + if (from?.current && to?.sent) activity = "taken"; + break; + case "finishes": + case "triggers": + if (from?.current && to?.current) activity = "taken"; + break; + case "sends-via": + if (from?.sent && to?.current) activity = "taken"; + break; + case "dispatches": + if (to?.selected) activity = "context"; + break; + case "projects": + case "exposes": + if (from?.current || to?.current) activity = "context"; + break; + case "owns": + case "composes": + case "reads": + case "calls": + case "observes": + if (to?.current) activity = "context"; + break; + case "delegates": + // Receipts do not expose internal transition paths. + break; + } + const sources = (edgeSources.get(edgeKey(edge)) ?? []).map(sourceSpan); + return { + id: `edge/${edge.kind}/${edge.from}/${edge.to}`, + kind: edge.kind, + from: edge.from, + to: edge.to, + label: edge.kind, + order, + activity, + sourceSpans: sources, + }; + }); return { disposed: false, emptyReason: null, generation: artifacts.generation, - programHash: program.ir.hash, - revision: state.latest?.inspection.revision ?? null, + programHash: graph.machineProgramHashes[focusedMachine] + ?? deployment.machineProgramHash, + exactSequence: state.latest?.inspection.nextSequence ?? null, focusDefinitionId, runtimeDefinitionId, definitions, diff --git a/web/src/play/debug-surface.ts b/web/src/play/debug-surface.ts index db7ac53..faa117e 100644 --- a/web/src/play/debug-surface.ts +++ b/web/src/play/debug-surface.ts @@ -3,8 +3,8 @@ // focused definition at a time as a deterministic behavior graph. import type { - InspectionHandle, - InspectionState, + RuntimeInspectionHandle, + RuntimeInspectionState, } from "../protocol/types.js"; import { createDebugController, @@ -12,6 +12,7 @@ import { } from "./debug-controller.js"; import { deriveDebugGraph, + formatDebugValue, type DebugDefinitionOption, type DebugGraphModel, type DebugGraphNode, @@ -57,46 +58,42 @@ function capitalized(value: string): string { function definitionText(definition: DebugDefinitionOption): string { const markers: string[] = []; - if (definition.top) markers.push("top"); - else if (definition.active) markers.push("mounted"); + if (definition.active) markers.push("mounted"); if (definition.runtime) markers.push("running"); - if (definition.transitionTarget) markers.push("transition"); if (definition.entry) markers.push("entry"); const suffix = markers.length === 0 ? "" : " · " + markers.join(", "); return capitalized(definition.kind) + " · " + definition.label + suffix; } -function traceEventLabel(state: InspectionState): string { - const trace = state.latest?.trace; - if (!trace) return "waiting for first step"; - if (trace.dispatch) return trace.dispatch.on; - const kind = trace.event["kind"]; - return typeof kind === "string" ? kind : "runtime event"; +function traceEventLabel( + publication: RuntimeInspectionState, +): string { + const receipt = publication.latest?.receipt; + if (!receipt) return "waiting for first step"; + if (receipt.kind === "genesis") return "genesis"; + const value = formatDebugValue(receipt.input.value); + return receipt.input.source === "port" + ? `${receipt.input.port} · ${value}` + : value; } -function traceDisposition(state: InspectionState): string { - const trace = state.latest?.trace; - if (!trace) return "idle"; - if (trace.dispatch?.aborted) { - return "aborted · " + trace.dispatch.aborted; - } - if (trace.drop) return "dropped · " + trace.drop; - if (trace.dispatch?.selected !== null && trace.dispatch?.selected !== undefined) { - return "handler " + String(trace.dispatch.selected + 1); - } - if (trace.reserved) return "reserved · " + trace.reserved.event; - return "state updated"; +function traceDisposition( + publication: RuntimeInspectionState, +): string { + const receipt = publication.latest?.receipt; + if (!receipt) return "idle"; + if (receipt.kind === "genesis") return "admitted"; + const resolution = receipt.resolution; + return resolution.kind === "fault" + ? `fault · ${resolution.fault.code}` + : `${resolution.disposition} · ${formatDebugValue(resolution.outcome)}`; } function nodeStatus(node: DebugGraphNode): string { const runtime = node.runtime; if (runtime.selected) return "selected"; - if (runtime.consulted) return runtime.consulted; if (runtime.written) return "written"; if (runtime.sent) return "sent"; - if (runtime.structural) return "transition"; - if (runtime.projectionApply) return runtime.projectionApply; - if (runtime.pending > 0) return String(runtime.pending) + " pending"; if (runtime.active) return "mounted"; return node.kind; } @@ -112,12 +109,6 @@ function nodeClasses(node: DebugGraphNode, selected: boolean): string { if (runtime.selected) classes.push("is-runtime-selected"); if (runtime.written) classes.push("is-written"); if (runtime.sent) classes.push("is-sent"); - if (runtime.structural) classes.push("is-structural"); - if (runtime.pending > 0) classes.push("is-pending"); - if (runtime.projectionFailures > 0 || runtime.projectionApply === "failed") { - classes.push("has-failure"); - } - if (runtime.consulted) classes.push("is-consulted-" + runtime.consulted); if (selected) classes.push("is-selected"); return classes.join(" "); } @@ -126,21 +117,9 @@ function nodeRuntimeText(node: DebugGraphNode): string { const runtime = node.runtime; const states: string[] = []; if (runtime.active) states.push("mounted"); - if (runtime.selected) states.push("selected handler"); - else if (runtime.consulted) states.push("guard " + runtime.consulted); + if (runtime.selected) states.push("selected this step"); if (runtime.written) states.push("written this step"); if (runtime.sent) states.push("sent this step"); - if (runtime.structural) states.push("structural target"); - if (runtime.pending > 0) states.push(String(runtime.pending) + " pending"); - if (runtime.projectionApply) { - states.push("projection " + runtime.projectionApply); - } - if (runtime.projectionReady > 0) { - states.push(String(runtime.projectionReady) + " projection snapshot"); - } - if (runtime.projectionFailures > 0) { - states.push(String(runtime.projectionFailures) + " projection failure"); - } return states.length === 0 ? "No activity in the latest step" : states.join(" · "); } @@ -148,8 +127,8 @@ function emptyMessage(reason: DebugGraphModel["emptyReason"]): string { switch (reason) { case "disposed": return "Runtime inspection is unavailable for this Play session."; - case "no-definitions": - return "The checked program contains no visualizable definitions."; + case "no-machines": + return "The checked program contains no visualizable machines."; case "loading": return "Waiting for the checked program and first runtime step."; case null: @@ -159,7 +138,7 @@ function emptyMessage(reason: DebugGraphModel["emptyReason"]): string { export function mountPlayDebugSurface( shell: PlayShell, - inspection: InspectionHandle, + inspection: RuntimeInspectionHandle, options: PlayDebugSurfaceOptions = {}, ): PlayDebugSurface { const view = options.window ?? shell.document.defaultView ?? window; @@ -185,12 +164,14 @@ export function mountPlayDebugSurface( let pinnedDefinitionId: string | null = null; let selectedNodeId: string | null = null; let userSelectedNode = false; - let lastState: InspectionState | null = null; + let lastPublication: RuntimeInspectionState | null = null; let currentModel: DebugGraphModel | null = null; let definitionSignature = ""; let lastRuntimeNodeId: string | null = null; + let disclosed = false; function setDisclosure(open: boolean): void { + disclosed = open; shell.debugPanel.hidden = !open; shell.debugToggle.setAttribute("aria-expanded", String(open)); const label = open ? "Close runtime debugger" : "Open runtime debugger"; @@ -237,17 +218,20 @@ export function mountPlayDebugSurface( ); } children.push(activity); - if (node.span) { + const sourceSpans = node.sourceSpans + ?? (node.span ? [{ id: null, ...node.span }] : []); + for (const span of sourceSpans) { children.push( element( shell.document, "p", "uh-debug-source", - node.span.file + span.file + ":" - + String(node.span.start) + + String(span.start) + "-" - + String(node.span.end) + + String(span.end) + + (span.id === null ? "" : ` · ${span.id}`) + " · UTF-8 bytes", ), ); @@ -434,7 +418,7 @@ export function mountPlayDebugSurface( selectedNodeId = runtimeNode.id; } else if (selectedNodeId === null) { selectedNodeId = runtimeNode?.id - ?? model.nodes.find((node) => node.kind === "handler")?.id + ?? model.nodes.find((node) => node.lane === "handler")?.id ?? model.nodes[0]?.id ?? null; } @@ -523,10 +507,12 @@ export function mountPlayDebugSurface( lastRuntimeNodeId = runtimeNodeId; } - function renderInspection(state: InspectionState): void { - lastState = state; + function renderInspection( + publication: RuntimeInspectionState, + ): void { + lastPublication = publication; const previousFocus = currentModel?.focusDefinitionId ?? null; - const model = deriveDebugGraph(state, { + const model = deriveDebugGraph(publication, { focusDefinitionId: followLive ? null : pinnedDefinitionId, }); if (followLive) pinnedDefinitionId = model.focusDefinitionId; @@ -540,26 +526,26 @@ export function mountPlayDebugSurface( if (model.disposed) { shell.debugSummary.textContent = "Debugger unavailable · inspection retired"; - } else if (model.revision === null) { - shell.debugSummary.textContent = model.generation === null - ? "Waiting for checked program…" - : "Program ready · waiting for first runtime step"; - } else { + } else if (model.exactSequence !== null) { shell.debugSummary.textContent = - (model.focusDefinitionId ?? "program") - + " · revision " - + String(model.revision) + (model.focusDefinitionId ?? "machine") + + " · next sequence " + + model.exactSequence + " · " - + traceEventLabel(state) + + traceEventLabel(publication) + " · " - + traceDisposition(state); + + traceDisposition(publication); + } else { + shell.debugSummary.textContent = model.generation === null + ? "Waiting for checked program…" + : "Program ready · waiting for first runtime step"; } renderGraph(model); } function renderUpdate(update: DebugControllerUpdate): void { if (update.kind === "unavailable") { - lastState = null; + lastPublication = null; currentModel = null; definitionSignature = ""; shell.debugDefinition.disabled = true; @@ -577,11 +563,11 @@ export function mountPlayDebugSurface( renderDetails(null); return; } - renderInspection(update.state); + renderInspection(update.publication); } function clearTransientView(): void { - lastState = null; + lastPublication = null; currentModel = null; definitionSignature = ""; lastRuntimeNodeId = null; @@ -612,45 +598,45 @@ export function mountPlayDebugSurface( }); function open(): void { - if (disposed || controller.isOpen) return; + if (disposed || disclosed) return; setDisclosure(true); controller.open(); shell.debugClose.focus(); } function close(restoreFocus: boolean): void { - if (disposed || !controller.isOpen) return; - controller.close(); + if (disposed || !disclosed) return; + if (controller.isOpen) controller.close(); clearTransientView(); setDisclosure(false); if (restoreFocus) shell.debugToggle.focus(); } const onToggle = (): void => { - if (controller.isOpen) close(false); + if (disclosed) close(false); else open(); }; const onClose = (): void => close(true); const onDefinition = (): void => { - if (!lastState || shell.debugDefinition.value.length === 0) return; + if (!lastPublication || shell.debugDefinition.value.length === 0) return; followLive = false; pinnedDefinitionId = shell.debugDefinition.value; selectedNodeId = null; userSelectedNode = false; shell.debugFollowLive.setAttribute("aria-pressed", "false"); - renderInspection(lastState); + renderInspection(lastPublication); }; const onFollowLive = (): void => { - if (!lastState) return; + if (!lastPublication) return; followLive = true; pinnedDefinitionId = null; selectedNodeId = null; userSelectedNode = false; shell.debugFollowLive.setAttribute("aria-pressed", "true"); - renderInspection(lastState); + renderInspection(lastPublication); }; const onPanelKeydown = (event: KeyboardEvent): void => { - if (event.key !== "Escape" || !controller.isOpen) return; + if (event.key !== "Escape" || !disclosed) return; event.preventDefault(); event.stopPropagation(); close(true); @@ -709,12 +695,13 @@ export function mountPlayDebugSurface( return Object.freeze({ get isOpen() { - return controller.isOpen; + return disclosed; }, dispose(): void { if (disposed) return; - const wasOpen = controller.isOpen; + const wasOpen = disclosed; disposed = true; + disclosed = false; controller.dispose(); resizeController.dispose(); viewportController.dispose(); @@ -731,7 +718,7 @@ export function mountPlayDebugSurface( delete shell.container.dataset["debugOpen"]; if (wasOpen) options.onOpenChange?.(false); shell.debugGraphContent.replaceChildren(); - lastState = null; + lastPublication = null; currentModel = null; selectedNodeId = null; }, diff --git a/web/src/play/focus.ts b/web/src/play/focus.ts deleted file mode 100644 index 88a1ed6..0000000 --- a/web/src/play/focus.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Focus mechanics (§8.4), scoped to one mounted Play route. The machine owns -// WHAT gets focus back; this controller owns HOW and cancels queued work when -// the route is disposed. - -import type { Intent } from "../protocol/types.js"; - -const FOCUSABLE = 'button, input, [tabindex="0"]'; - -export interface FocusController { - handleIntents(intents: Intent[]): void; - enterSurface(surfaceEl: HTMLElement): void; - dispose(): void; -} - -export function createFocusController(root: HTMLElement): FocusController { - let active = true; - - function restoreFocus(keyPath: string): void { - if (!active) return; - const escaped = CSS.escape(keyPath); - const element = root.querySelector(`[data-path="${escaped}"]`); - if (!(element instanceof HTMLElement)) return; - const target = element.matches(FOCUSABLE) - ? element - : element.querySelector(FOCUSABLE); - if (target instanceof HTMLElement) target.focus(); - } - - function handleIntents(intents: Intent[]): void { - for (const intent of intents) { - if (intent.intent !== "focus-restore") continue; - const path = intent["key-path"]; - queueMicrotask(() => restoreFocus(path)); - } - } - - function enterSurface(surfaceEl: HTMLElement): void { - if (!active) return; - const target = surfaceEl.querySelector(FOCUSABLE); - if (target instanceof HTMLElement) target.focus(); - else surfaceEl.focus(); - } - - function dispose(): void { - active = false; - } - - return { handleIntents, enterSurface, dispose }; -} diff --git a/web/src/play/inspection-store.ts b/web/src/play/inspection-store.ts index ab52066..790e0a3 100644 --- a/web/src/play/inspection-store.ts +++ b/web/src/play/inspection-store.ts @@ -1,19 +1,18 @@ -// Framework-neutral, read-only publication of Uhura's inspection protocol. -// The store retains a bounded trace/state window and deliberately omits full -// view snapshots: Play already owns the current V, while Session.inspect() -// supplies the machine/projection state a behavior visualizer needs. - import type { - InspectSnapshot, - InspectedStep, - InspectionArtifacts, - InspectionHandle, - InspectionListener, - InspectionState, - StepResult, + RuntimeInspectedStep, + RuntimeInspectionArtifacts, + RuntimeInspectionHandle, + RuntimeInspectionListener, + RuntimeInspectionState, } from "../protocol/types.js"; +import type { + Inspection, + Receipt, +} from "../protocol/machine.js"; -export const DEFAULT_INSPECTION_HISTORY_LIMIT = 128; +export const UHURA_INSPECTION_STATE_PROTOCOL = + "uhura-runtime-inspection-state/0" as const; +export const DEFAULT_UHURA_INSPECTION_HISTORY_LIMIT = 128; export interface InspectionStoreOptions { historyLimit?: number; @@ -21,12 +20,9 @@ export interface InspectionStoreOptions { } export interface InspectionStore { - readonly handle: InspectionHandle; - /** Installs the one generation-coherent program artifact for this mount. */ - installArtifacts(artifacts: InspectionArtifacts): boolean; - /** Correlates and publishes one successful dispatch with committed U/X. */ - record(result: StepResult, inspection: InspectSnapshot): boolean; - /** Idempotently clears retained developer data and retires subscriptions. */ + readonly handle: RuntimeInspectionHandle; + installArtifacts(artifacts: RuntimeInspectionArtifacts): boolean; + record(inspection: Inspection, receipt: Receipt): boolean; dispose(): void; } @@ -41,87 +37,96 @@ function deepFreeze(value: T, seen = new WeakSet()): T { } function frozenState( - state: Omit & { - history: readonly InspectedStep[]; + state: Omit & { + history: readonly RuntimeInspectedStep[]; }, -): InspectionState { - const history = Object.freeze([...state.history]); - return Object.freeze({ ...state, history }); +): RuntimeInspectionState { + return Object.freeze({ + protocol: UHURA_INSPECTION_STATE_PROTOCOL, + ...state, + history: Object.freeze([...state.history]), + }); } -function assertGeneration(generation: number): void { - if (!Number.isSafeInteger(generation) || generation < 0) { - throw new Error("inspection artifact generation must be a non-negative safe integer"); - } +function sameWire(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); } -function assertProgram(artifacts: InspectionArtifacts): void { - assertGeneration(artifacts.generation); - const { program } = artifacts; - if (program.protocol !== "uhura-inspect/0" || program.kind !== "program") { - throw new Error("inspection artifact must be an uhura-inspect/0 program"); - } - if (program["span-offset-encoding"] !== "utf-8-bytes") { - throw new Error("inspection artifact spans must use UTF-8 byte offsets"); +function assertArtifacts(artifacts: RuntimeInspectionArtifacts): void { + if (!Number.isSafeInteger(artifacts.generation) || artifacts.generation < 0) { + throw new TypeError( + "Uhura machine inspection artifact generation must be a non-negative safe integer", + ); } - if (program.ir.protocol !== "uhura-ir/0" || program.ir.hash.length === 0) { - throw new Error("inspection artifact must identify a hashed uhura-ir/0 program"); + if (artifacts.deployment.protocol !== "uhura-inspection/0") { + throw new TypeError( + "Uhura machine inspection artifacts must contain uhura-inspection/0 deployment metadata", + ); } } function assertCorrelated( - artifacts: InspectionArtifacts, - previous: InspectedStep | null, - result: StepResult, - inspection: InspectSnapshot, + artifacts: RuntimeInspectionArtifacts, + previous: RuntimeInspectedStep | null, + inspection: Inspection, + receipt: Receipt, ): void { - if (inspection.protocol !== "uhura-inspect/0" || inspection.kind !== "snapshot") { - throw new Error("inspection step must be an uhura-inspect/0 snapshot"); - } - if (inspection["ir-hash"] !== artifacts.program.ir.hash) { - throw new Error("inspection snapshot IR hash does not match the program artifact"); - } - if (!Number.isSafeInteger(inspection.revision) || inspection.revision < 1) { - throw new Error("inspection snapshot revision must be a positive safe integer"); - } + const deployment = artifacts.deployment; if ( - previous !== null - && inspection.revision <= previous.inspection.revision + inspection.instance.length === 0 + || inspection.machineProgramHash !== deployment.machineProgramHash + || inspection.presentation !== deployment.presentation + || inspection.presentationHash !== deployment.presentationHash ) { - throw new Error("inspection snapshot revisions must increase monotonically"); - } - if (inspection.u.rev !== inspection.revision) { - throw new Error("inspection U revision does not match its snapshot revision"); - } - if (inspection["u-hash"] !== result.t["u-hash"]) { - throw new Error("inspection U hash does not match the step trace"); + throw new TypeError( + "Uhura machine inspection does not match the admitted deployment identity", + ); } - if (result.v.revision !== inspection.revision) { - throw new Error("inspection revision does not match the step view revision"); + if ( + receipt.instance !== inspection.instance + || receipt.machineProgramHash !== inspection.machineProgramHash + || receipt.configurationHash !== inspection.configurationHash + ) { + throw new TypeError( + "Uhura machine receipt does not match its runtime inspection identity", + ); } - if (inspection.view === null) { - throw new Error("a successful Play step inspection must include view metadata"); + const retained = inspection.receipts.at(-1); + if (!retained || !sameWire(retained, receipt)) { + throw new TypeError( + "Uhura machine inspection must retain the exact receipt being published", + ); } - if (inspection.view.revision !== result.v.revision) { - throw new Error("inspection view metadata revision does not match the step view"); + if (BigInt(inspection.nextSequence) !== BigInt(receipt.sequence) + 1n) { + throw new TypeError( + "Uhura machine inspection nextSequence must immediately follow its receipt", + ); } - if (inspection.view["v-hash"] !== result.t["v-hash"]) { - throw new Error("inspection view hash does not match the step trace"); + if ( + previous !== null + && BigInt(receipt.sequence) !== BigInt(previous.receipt.sequence) + 1n + ) { + throw new TypeError( + "Uhura machine inspection receipt sequences must increase contiguously", + ); } } export function createInspectionStore( options: InspectionStoreOptions = {}, ): InspectionStore { - const historyLimit = options.historyLimit ?? DEFAULT_INSPECTION_HISTORY_LIMIT; + const historyLimit = + options.historyLimit ?? DEFAULT_UHURA_INSPECTION_HISTORY_LIMIT; if (!Number.isSafeInteger(historyLimit) || historyLimit < 1) { - throw new RangeError("inspection history limit must be a positive safe integer"); + throw new RangeError( + "Uhura machine inspection history limit must be a positive safe integer", + ); } - const onListenerError = options.onListenerError - ?? ((error: unknown) => console.error("uhura inspection listener failed", error)); - const listeners = new Set(); + ?? ((error: unknown) => + console.error("Uhura machine inspection listener failed", error)); + const listeners = new Set(); let state = frozenState({ disposed: false, historyLimit, @@ -131,92 +136,93 @@ export function createInspectionStore( evictedSteps: 0, }); - function notifyOne(listener: InspectionListener, published: InspectionState): void { + function notify( + listener: RuntimeInspectionListener, + publication: RuntimeInspectionState, + ): void { try { - listener(published); + listener(publication); } catch (error) { try { onListenerError(error); } catch { - // Debug listeners and their reporters are observational: neither may - // interrupt Play or prevent the remaining subscribers from running. + // Inspection is observational; neither listeners nor reporters may + // interrupt the machine or prevent the remaining listeners. } } } - function publish(next: InspectionState): void { + function publish(next: RuntimeInspectionState): void { state = next; - for (const listener of [...listeners]) notifyOne(listener, next); + for (const listener of [...listeners]) notify(listener, next); } - function subscribe(listener: InspectionListener): () => void { - if (state.disposed) { - notifyOne(listener, state); - return () => {}; - } - listeners.add(listener); - notifyOne(listener, state); - let subscribed = true; - return () => { - if (!subscribed) return; - subscribed = false; - listeners.delete(listener); - }; - } - - const handle: InspectionHandle = Object.freeze({ + const handle: RuntimeInspectionHandle = Object.freeze({ get state() { return state; }, - subscribe, + subscribe(listener: RuntimeInspectionListener) { + if (state.disposed) { + notify(listener, state); + return () => {}; + } + listeners.add(listener); + notify(listener, state); + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + listeners.delete(listener); + }; + }, }); - function installArtifacts(artifacts: InspectionArtifacts): boolean { + function installArtifacts(artifacts: RuntimeInspectionArtifacts): boolean { if (state.disposed) return false; if (state.artifacts !== null) { - throw new Error("inspection artifacts are already installed for this mount"); + throw new Error( + "Uhura machine inspection artifacts are already installed for this mount", + ); } - assertProgram(artifacts); - const installed = deepFreeze(artifacts); - publish(frozenState({ ...state, artifacts: installed })); + assertArtifacts(artifacts); + publish(frozenState({ ...state, artifacts: deepFreeze(artifacts) })); return true; } - function record(result: StepResult, inspection: InspectSnapshot): boolean { + function record( + inspection: Inspection, + receipt: Receipt, + ): boolean { if (state.disposed) return false; - const { artifacts } = state; - if (artifacts === null) { - throw new Error("inspection artifacts must be installed before recording steps"); + if (state.artifacts === null) { + throw new Error( + "Uhura machine inspection artifacts must be installed before runtime records", + ); } - assertCorrelated(artifacts, state.latest, result, inspection); - - const step = deepFreeze({ trace: result.t, inspection }); + assertCorrelated(state.artifacts, state.latest, inspection, receipt); + const step = deepFreeze({ inspection, receipt }); const appended = [...state.history, step]; const evicted = Math.max(0, appended.length - historyLimit); const history = evicted === 0 ? appended : appended.slice(evicted); - publish( - frozenState({ - ...state, - latest: step, - history, - evictedSteps: state.evictedSteps + evicted, - }), - ); + publish(frozenState({ + ...state, + latest: step, + history, + evictedSteps: state.evictedSteps + evicted, + })); return true; } function dispose(): void { if (state.disposed) return; - publish( - frozenState({ - disposed: true, - historyLimit, - artifacts: null, - latest: null, - history: [], - evictedSteps: 0, - }), - ); + publish(frozenState({ + disposed: true, + historyLimit, + artifacts: null, + latest: null, + history: [], + evictedSteps: 0, + })); listeners.clear(); } diff --git a/web/src/play/main.test.ts b/web/src/play/main.test.ts new file mode 100644 index 0000000..73ef0f2 --- /dev/null +++ b/web/src/play/main.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { assertWasmProtocols } from "./main.js"; + +const protocols = { + browser: "uhura-browser/2", + checkpoint: "uhura-checkpoint/0", + genesisReceipt: "uhura-genesis-receipt/0", + ingressRecord: "uhura-ingress-record/0", + ir: "uhura-ir/1", + reactionReceipt: "uhura-reaction-receipt/0", + view: "uhura-view/1", +} as const; + +describe("Uhura Wasm protocol admission", () => { + it("accepts the one complete protocol set", () => { + expect(() => assertWasmProtocols(protocols)).not.toThrow(); + }); + + it("rejects missing, extra, and drifted protocol declarations", () => { + const missing: Record = { ...protocols }; + delete missing["view"]; + expect(() => assertWasmProtocols(missing)).toThrow(/protocol set mismatch/u); + expect(() => + assertWasmProtocols({ ...protocols, experimental: "example/0" }) + ).toThrow(/protocol set mismatch/u); + expect(() => + assertWasmProtocols({ ...protocols, browser: "uhura-browser/1" }) + ).toThrow(/protocol mismatch/u); + }); +}); diff --git a/web/src/play/main.ts b/web/src/play/main.ts index 8e9a804..8699049 100644 --- a/web/src/play/main.ts +++ b/web/src/play/main.ts @@ -1,69 +1,117 @@ -// Mount-owned Uhura Play runtime. Boot remains asynchronous, but every timer, -// stream, global handle, focus task, surface listener, and browser capability -// belongs to one route lifetime and is retired by dispose(). +// Mount-owned Uhura Play runtime. The deterministic machine lives in Wasm; +// this browser layer owns artifacts, rendering, foreign adapters, developer +// inspection, and every effectful capability for one route lifetime. import type { - Descriptor, DevEvent, - Driver, - InspectProgram, - InspectSnapshot, - InspectionHandle, - PlayConfig, - ProviderMode, - ProviderModule, - RemoteDriver, - RemoteSystemInfo, RuntimeHandle, - Snapshot, - StepResult, + RuntimeInspectionHandle, + SystemInfo, } from "../protocol/types.js"; -import type { ResolveAsset } from "../renderer/play.js"; -import type { AssetAppliers } from "../renderer/play.js"; +import { + UHURA_BROWSER_PROTOCOL, + decodeResolvedInput, + type ResolvedInput, + type Value, +} from "../protocol/machine.js"; +import { + decodeHostInspection, + type HostInspection, +} from "../protocol/host-inspection.js"; import { createPlayAssets, - createPlayRenderer, - findScope, -} from "../renderer/play.js"; + type AssetAppliers, +} from "../renderer/assets.js"; import { decodeIconFontManifest, loadIconFontRegistry, + type IconFontRegistry, } from "../renderer/icons.js"; -import { createFocusController } from "./focus.js"; +import { + installLocationConsumer, + publishLocation, +} from "../app/location.js"; +import { routeFor } from "../app/router.js"; import { PlayGenerationGate } from "./generation.js"; import type { GenerationAction } from "./generation.js"; import { createInspectionStore } from "./inspection-store.js"; import { createOverlay } from "./overlay.js"; -import { selectPlayProvider } from "./play-provider-selection.js"; -import { createPump, providerMsgToEvent } from "./pump.js"; +import { + loadUhuraAdapterProvider, + type UhuraAdapterProvider, + type UhuraProviderHost, +} from "./provider.js"; +import { + partitionAdapterRequirements, + type PortRequirement, +} from "./adapter-host.js"; +import { + admitConfiguredPorts, + decodePortRequirements, + decodePlayConfig, + startPlay, + type PlayConfig, + type PlayController, +} from "./session.js"; +import { createBrowserPortAdapters } from "./browser-adapters.js"; +import { + applicationPathForBrowser, + browserUrlForApplication, +} from "./application-location.js"; import { createProviderHost } from "./provider-host.js"; import type { DisposableProviderHost } from "./provider-host.js"; -import { createScrolls } from "./scroll.js"; import type { PlayShell } from "./shell.js"; -import { createSurfaces } from "./surfaces.js"; -import type { SurfaceController } from "./surfaces.js"; import { SYSTEM_ACTOR_STORAGE_KEY, - SYSTEM_PROVIDER_STORAGE_KEY, createSystemControls, } from "./system-controls.js"; -import { createTextFields } from "./textfield.js"; -import { createTicks, DEFAULT_TICK_MS } from "./ticks.js"; const WASM_MODULE_URL = "/api/play/wasm/uhura_wasm.js"; type WasmModule = typeof import("/api/play/wasm/uhura_wasm.js"); +type WasmSession = InstanceType; export const PLAY_ARTIFACT_URLS = [ "/api/play/ir.json", "/api/play/inspect.json", - "/api/play/boot.json", - "/api/play/fixture.json", - "/api/play/script.json", "/api/play/config.json", "/api/play/icon-fonts.json", "/api/play/stylesheet.css", ] as const; +const EXPECTED_PROTOCOLS: Readonly> = { + browser: UHURA_BROWSER_PROTOCOL, + checkpoint: "uhura-checkpoint/0", + genesisReceipt: "uhura-genesis-receipt/0", + ingressRecord: "uhura-ingress-record/0", + ir: "uhura-ir/1", + reactionReceipt: "uhura-reaction-receipt/0", + view: "uhura-view/1", +}; + +export function assertWasmProtocols(value: unknown): void { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("Uhura Wasm protocols must be an object"); + } + const spoken = value as Readonly>; + const expectedKeys = Object.keys(EXPECTED_PROTOCOLS).sort(); + const spokenKeys = Object.keys(spoken).sort(); + if ( + expectedKeys.length !== spokenKeys.length + || expectedKeys.some((key, index) => key !== spokenKeys[index]) + ) { + throw new Error( + `protocol set mismatch: this shell requires exactly [${expectedKeys.join(", ")}], the wasm build declares [${spokenKeys.join(", ")}] — rebuild with scripts/build-wasm.sh`, + ); + } + for (const [name, version] of Object.entries(EXPECTED_PROTOCOLS)) { + if (spoken[name] !== version) { + throw new Error( + `protocol mismatch: this shell speaks ${name} ${version}, the wasm build speaks ${String(spoken[name])} — rebuild with scripts/build-wasm.sh`, + ); + } + } +} + let wasmReady: Promise | undefined; async function loadWasm(): Promise { @@ -82,12 +130,32 @@ async function loadWasm(): Promise { } export interface PlayRuntime { - readonly inspection: InspectionHandle; + readonly inspection: RuntimeInspectionHandle; dispose(): void; } class PlayArtifactsUnavailableError extends Error {} +function assertDeploymentMatchesPlay( + deployment: HostInspection, + play: PlayConfig, +): void { + if ( + deployment.identityProtocol !== play.identityProtocol + || deployment.entry !== play.entry + || deployment.machine !== play.machine + || deployment.presentation !== play.presentation + || deployment.machineProgramHash !== play.machineProgramHash + || deployment.presentationHash !== play.presentationHash + || deployment.evidenceHash !== play.evidenceHash + || deployment.deploymentHash !== play.deploymentHash + ) { + throw new TypeError( + "Uhura host inspection identity differs from the admitted Play deployment", + ); + } +} + function isAbort(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } @@ -103,6 +171,47 @@ function release(value: unknown): void { } } +const parseJson = (source: string, context: string): unknown => { + try { + return JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${context} is not JSON: ${String(error)}`); + } +}; + +const providerSystemInfo = ( + provider: UhuraAdapterProvider | null, + hasProvider: boolean, +): SystemInfo => ({ + ...(provider?.systemInfo?.() ?? {}), + hasProvider, +}); + +async function loadOptionalIcons( + shell: PlayShell, + source: string, + generation: number, +): Promise { + const value = parseJson(source, "Uhura icon-font manifest"); + if ( + typeof value !== "object" + || value === null + || (value as Record)["protocol"] === undefined + ) { + return undefined; + } + const manifest = decodeIconFontManifest(value, "play"); + if (manifest.generation !== generation) { + throw new Error( + `Play icon fonts generation ${String(manifest.generation)} does not match artifact generation ${generation}`, + ); + } + return loadIconFontRegistry({ + document: shell.document, + manifest, + }); +} + /** Starts Play without waiting for its network/provider boot to finish. */ export function startPlayRuntime( shell: PlayShell, @@ -115,12 +224,12 @@ export function startPlayRuntime( const abort = new AbortController(); let disposed = false; let eventSource: EventSource | null = null; - let ticks: ReturnType | null = null; - let surfaces: SurfaceController | null = null; - let focus: ReturnType | null = null; - let scrolls: ReturnType | null = null; let providerHost: DisposableProviderHost | null = null; + let provider: UhuraAdapterProvider | null = null; + let hasProvider = false; let playAssets: AssetAppliers | null = null; + let play: PlayController | null = null; + let pendingSession: WasmSession | null = null; const systemControls = createSystemControls({ target: view, @@ -129,48 +238,19 @@ export function startPlayRuntime( }); const runtime: RuntimeHandle = { session: null, - driver: null, + provider: null, inspection: inspection.handle, get steps() { - return inspection.handle.state.history.map((step) => step.trace); + return inspection.handle.state.history.map((step) => step.receipt); }, - ticks: null, get system() { return systemControls.state; }, restart: () => systemControls.restart(), setActor: (actor: string) => systemControls.setActor(actor), - setProvider: (provider: ProviderMode) => systemControls.setProvider(provider), }; view.__uhura = runtime; - function currentRemoteSystemInfo(): RemoteSystemInfo | undefined { - const driver = runtime.driver as RemoteDriver | null; - if (!driver || typeof driver.systemInfo !== "function") return undefined; - try { - return driver.systemInfo(); - } catch (error) { - console.error("uhura provider system metadata failed", error); - return undefined; - } - } - - async function importProvider(module: string): Promise { - try { - const loaded = (await import(/* @vite-ignore */ module)) as ProviderModule; - view.sessionStorage.removeItem("uh-provider-retry"); - return loaded; - } catch (error) { - if (disposed) throw error; - if (view.sessionStorage.getItem("uh-provider-retry") === null) { - view.sessionStorage.setItem("uh-provider-retry", "1"); - view.location.reload(); - await new Promise(() => {}); - } - throw error; - } - } - async function fetchArtifacts( urls: T, ): Promise<{ texts: { [K in keyof T]: string }; generation: number }> { @@ -182,13 +262,15 @@ export function startPlayRuntime( if (!response.ok) { const message = `${urls[index] ?? "artifact"}: ${response.status}\n${texts[index] ?? ""}`; - if (response.status === 503) throw new PlayArtifactsUnavailableError(message); + if (response.status === 503) { + throw new PlayArtifactsUnavailableError(message); + } throw new Error(message); } }); const artifactGenerations = responses.map((response, index) => { const header = response.headers.get("x-uhura-generation"); - if (header === null || !/^\d+$/.test(header)) { + if (header === null || !/^\d+$/u.test(header)) { throw new Error( `${urls[index] ?? "artifact"}: missing or invalid x-uhura-generation`, ); @@ -199,8 +281,7 @@ export function startPlayRuntime( } return generation; }); - const distinctGenerations = new Set(artifactGenerations); - if (distinctGenerations.size > 1) { + if (new Set(artifactGenerations).size > 1) { if (view.sessionStorage.getItem("uh-gen-retry") === null) { view.sessionStorage.setItem("uh-gen-retry", "1"); view.location.reload(); @@ -210,8 +291,13 @@ export function startPlayRuntime( } view.sessionStorage.removeItem("uh-gen-retry"); const generation = artifactGenerations[0]; - if (generation === undefined) throw new Error("Play has no authoritative artifacts"); - return { texts: texts as { [K in keyof T]: string }, generation }; + if (generation === undefined) { + throw new Error("Play has no authoritative artifacts"); + } + return { + texts: texts as { [K in keyof T]: string }, + generation, + }; } function applyGenerationAction(action: GenerationAction): void { @@ -242,11 +328,120 @@ export function startPlayRuntime( }; events.onmessage = (message: MessageEvent) => { if (disposed) return; - const dev = JSON.parse(message.data) as DevEvent; - applyGenerationAction(generations.event(dev)); + applyGenerationAction(generations.event(JSON.parse(message.data) as DevEvent)); }; } + const providerConfig = ( + config: Readonly>, + ): Readonly> => { + const actor = + view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null; + return actor === null ? config : { ...config, actor }; + }; + + const adapterBoundary = ( + session: WasmSession, + host: DisposableProviderHost, + requirements: readonly PortRequirement[], + ): UhuraProviderHost => { + const ports = new Map( + requirements.map((requirement) => [requirement.port, requirement]), + ); + return { + signal: host.signal, + pickFile: (options) => host.pickFile(options), + port(name): PortRequirement { + const requirement = ports.get(name); + if (!requirement) { + throw new Error(`Uhura deployment has no admitted port \`${name}\``); + } + return requirement; + }, + decodeRoute(port, url): ResolvedInput { + if (!ports.has(port)) { + throw new Error( + `Uhura adapter boundary has no admitted port \`${port}\``, + ); + } + const input = decodeResolvedInput( + parseJson(session.decode_route(port, url), "Uhura route input"), + "Uhura route input", + ); + if (input.source !== "port" || input.port !== port) { + throw new TypeError( + `Uhura route decoder did not produce an input for \`${port}\``, + ); + } + return input; + }, + encodeRoute(port: string, location: Value): string { + if (!ports.has(port)) { + throw new Error( + `Uhura adapter boundary has no admitted port \`${port}\``, + ); + } + return session.encode_route(port, JSON.stringify(location)); + }, + onLocation(listener): () => void { + if (host.signal.aborted) return () => undefined; + const stop = installLocationConsumer((change) => { + if (change.route.surface !== "play") return; + listener(applicationPathForBrowser(change.location)); + }); + let active = true; + const dispose = (): void => { + if (!active) return; + active = false; + host.signal.removeEventListener("abort", dispose); + stop(); + }; + host.signal.addEventListener("abort", dispose, { once: true }); + return dispose; + }, + navigate(mode, url): void { + if (host.signal.aborted) { + throw new Error( + "cannot navigate through a disposed Uhura provider host", + ); + } + const destination = browserUrlForApplication(url, view.location.href); + if (destination.origin !== view.location.origin) { + throw new Error( + `Uhura web history cannot navigate a different origin: ${destination.origin}`, + ); + } + const route = routeFor(destination.pathname); + if (route.surface !== "play") { + throw new Error( + `Uhura application route ${JSON.stringify(destination.pathname)} is reserved by the host`, + ); + } + const href = + `${destination.pathname}${destination.search}${destination.hash}`; + if (mode === "replace") view.history.replaceState(null, "", href); + else view.history.pushState(null, "", href); + publishLocation({ + cause: mode, + location: { + pathname: destination.pathname, + search: destination.search, + hash: destination.hash, + }, + route, + }); + }, + back(): void { + if (host.signal.aborted) { + throw new Error( + "cannot navigate through a disposed Uhura provider host", + ); + } + view.history.back(); + }, + }; + }; + async function boot(): Promise { const artifacts = await fetchArtifacts(PLAY_ARTIFACT_URLS); const generationAction = generations.artifacts(artifacts.generation); @@ -255,229 +450,126 @@ export function startPlayRuntime( const [ irText, inspectText, - bootText, - fixtureText, - scriptText, playText, iconFontsText, styleText, ] = artifacts.texts; if (disposed) return; - const iconManifest = decodeIconFontManifest(JSON.parse(iconFontsText), "play"); - if (iconManifest.generation !== artifacts.generation) { - throw new Error( - `Play icon fonts generation ${String(iconManifest.generation)} does not match artifact generation ${artifacts.generation}`, - ); - } - const icons = await loadIconFontRegistry({ - document: shell.document, - manifest: iconManifest, - }); - if (disposed) return; - inspection.installArtifacts({ - generation: artifacts.generation, - program: JSON.parse(inspectText) as InspectProgram, - }); - applicationStyle.textContent = styleText; + + const config = decodePlayConfig(parseJson(playText, "Uhura Play config")); + const deployment = decodeHostInspection( + parseJson(inspectText, "Uhura host inspection"), + ); + assertDeploymentMatchesPlay(deployment, config); const wasm = await loadWasm(); if (disposed) return; - const { FixtureDriver, Session, protocols } = wasm; + const spoken = parseJson(wasm.protocols(), "Uhura Wasm protocols"); + assertWasmProtocols(spoken); - const spoken = JSON.parse(protocols()) as Record; - const expected: Record = { - inspect: "uhura-inspect/0", - ir: "uhura-ir/0", - view: "uhura-view/0", - provider: "uhura-provider/0", - }; - for (const [name, version] of Object.entries(expected)) { - if (spoken[name] !== version) { - throw new Error( - `protocol mismatch: this shell speaks ${name} ${version}, the wasm build speaks ${spoken[name]} — rebuild with scripts/build-wasm.sh`, - ); - } - } - - const play = JSON.parse(playText) as PlayConfig; - const storedProvider = view.sessionStorage.getItem(SYSTEM_PROVIDER_STORAGE_KEY); - const selection = selectPlayProvider(play, storedProvider); - if (selection.clearStoredProvider) { - view.sessionStorage.removeItem(SYSTEM_PROVIDER_STORAGE_KEY); - } - const inferredProvider = selection.provider; - const configuredActor = - play.provider.kind === "module" ? play.provider.config.actor ?? null : null; - const storedActor = - view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null; - const selectedActor = storedActor ?? configuredActor; + hasProvider = config.provider !== null; systemControls.starting({ - provider: inferredProvider, - providers: selection.providers, - actor: inferredProvider === "remote" ? selectedActor : null, + hasProvider, + actor: config.provider === null + ? null + : (view.sessionStorage.getItem(SYSTEM_ACTOR_STORAGE_KEY)?.trim() || null), actors: [], }); - const session = new Session(irText); + const session = new wasm.Session( + irText, + config.machine, + JSON.stringify(config.configuration), + config.instance, + config.presentation ?? undefined, + JSON.stringify({ + identityProtocol: config.identityProtocol, + machineProgramHash: config.machineProgramHash, + presentationHash: config.presentationHash, + }), + ); + pendingSession = session; runtime.session = session; - let driver: Driver; - let resolveAsset: ResolveAsset | undefined; - if (inferredProvider === "remote") { - if (play.provider.kind !== "module") { - throw new Error("remote play was selected without a provider module"); - } - const providerModule = await importProvider(play.provider.module); - if (disposed) return; - if (typeof providerModule.createDriver !== "function") { - throw new Error(`${play.provider.module} must export createDriver(config, host)`); - } - const config = { ...play.provider.config }; - if (selectedActor !== null) config.actor = selectedActor; - providerHost = createProviderHost(abort.signal); - const remote = providerModule.createDriver(config, providerHost); - runtime.driver = remote; - const remoteBoot = await remote.assembleBoot(); - if (disposed) return; - session.boot(remoteBoot); - driver = remote; - if (typeof remote.resolveAsset === "function") { - resolveAsset = remote.resolveAsset.bind(remote); - } - } else { - session.boot(bootText); - driver = new FixtureDriver(fixtureText, scriptText); - runtime.driver = driver; - } - - let currentRevision = 0; - let currentNavKey: string | null = null; - let pageElement: HTMLElement | null = null; - let nextNavToken = 1; - const navFrames: { params: string; token: number }[] = [ - { params: "{}", token: 0 }, - ]; - let pump: ReturnType; - - function emit( - descriptor: Descriptor, - data?: Record, - onApplied?: () => void, - ): void { - if (disposed) return; - const event: Record = { - kind: "ui", - descriptor, - "view-rev": currentRevision, - }; - if (data) event["data"] = data; - pump.enqueue(JSON.stringify(event), onApplied); - } - - const textFields = createTextFields({ emit }); - scrolls = createScrolls({ emit }); - const assets = createPlayAssets(resolveAsset); - playAssets = assets; - const renderer = createPlayRenderer({ - document: shell.document, - emit, - assets, - icons, - textFields, - scrolls, - }); - focus = createFocusController(shell.container); - surfaces = createSurfaces({ - host: shell.surfaceHost, - pageHost: shell.pageHost, - emit, - reconcileChildren: renderer.reconcileChildren, - disposeSubtree: renderer.disposeSubtree, - enterSurface: focus.enterSurface, - }); - - function renderPage(snapshot: Snapshot): void { - const scope = findScope(snapshot.page.root) ?? "page"; - const topFrame = navFrames.at(-1) ?? { params: "{}", token: -1 }; - const navKey = - `${snapshot.page.route}|${navFrames.length}|${topFrame.params}|${topFrame.token}`; - if (!pageElement || currentNavKey !== navKey) { - if (pageElement && currentNavKey !== null) { - scrolls?.savePositions(currentNavKey, pageElement); - } - if (pageElement) renderer.disposeSubtree(pageElement); - shell.pageHost.replaceChildren(); - pageElement = shell.document.createElement("div"); - pageElement.className = "uh-page-root"; - shell.pageHost.append(pageElement); - renderer.reconcileChildren(pageElement, [snapshot.page.root], scope, false); - scrolls?.restorePositions(navKey, pageElement); - currentNavKey = navKey; - } else { - renderer.reconcileChildren(pageElement, [snapshot.page.root], scope, false); - } - } - - function onStep(result: StepResult): void { - if (disposed) return; - currentRevision = result.v.revision; - for (const intent of result.i) { - if (intent.intent === "history-push") { - navFrames.push({ - params: JSON.stringify(intent.params ?? {}), - token: nextNavToken++, - }); - } else if (intent.intent === "history-replace") { - navFrames[navFrames.length - 1] = { - params: JSON.stringify(intent.params ?? {}), - token: nextNavToken++, - }; - } else if (intent.intent === "history-back" && navFrames.length > 1) { - navFrames.pop(); - } - } - renderPage(result.v); - surfaces?.render(result.v); - focus?.handleIntents(result.i); - for (const guard of result.g) { - console.warn(`uhura ${guard.code} ${guard.rule}: ${guard.message}`); - } + providerHost = createProviderHost(abort.signal); + const portRequirements = decodePortRequirements( + session.port_requirements(), + ); + const admittedRequirements = admitConfiguredPorts(portRequirements, config); + const requirements = partitionAdapterRequirements(admittedRequirements); + const browserBoundary = adapterBoundary( + session, + providerHost, + requirements.browser, + ); + const browserAdapters = createBrowserPortAdapters( + requirements.browser, + browserBoundary, + ); + if (config.provider !== null) { + const boundary = adapterBoundary( + session, + providerHost, + requirements.provider, + ); try { - const snapshot = JSON.parse(session.inspect()) as InspectSnapshot; - inspection.record(result, snapshot); + provider = await loadUhuraAdapterProvider( + config.provider.module, + providerConfig(config.provider.config), + boundary, + requirements.provider, + ); + view.sessionStorage.removeItem("uh-provider-retry"); } catch (error) { - // Inspection is observational. A tooling failure must not interrupt - // the already-committed machine step or the renderer/provider pump. - console.error("uhura inspection failed", error); - inspection.dispose(); + if ( + !disposed + && view.sessionStorage.getItem("uh-provider-retry") === null + ) { + view.sessionStorage.setItem("uh-provider-retry", "1"); + view.location.reload(); + await new Promise(() => {}); + } + throw error; } - console.debug("uhura-step", JSON.stringify(result.t)); } + if (disposed) return; - pump = createPump({ - dispatch: (eventJson) => session.dispatch(eventJson), - deliver: (commandJson) => driver.deliver(commandJson), - onStep, - onError: (error, eventJson) => { + const icons = await loadOptionalIcons( + shell, + iconFontsText, + artifacts.generation, + ); + if (disposed) return; + applicationStyle.textContent = styleText; + const resolveAsset = provider?.resolveAsset?.bind(provider); + playAssets = createPlayAssets(resolveAsset); + inspection.installArtifacts({ + generation: artifacts.generation, + deployment, + }); + play = startPlay({ + shell, + session, + config, + adapters: [...browserAdapters, ...(provider?.adapters ?? [])], + assets: playAssets, + icons, + publishInspection(nextInspection, receipt): void { + inspection.record(nextInspection, receipt); + }, + onProjectionError(error): void { if (disposed) return; - console.error("uhura dispatch failed", error, eventJson); - overlay.showFatal(`dispatch failed: ${String(error)}\n\nevent: ${eventJson}`); + console.error("Uhura presentation projection failed; machine continues", error); + }, + onError(error): void { + if (disposed) return; + console.error("Uhura Play failed", error); + overlay.showFatal(String(error)); }, }); - - ticks = createTicks({ - tick: () => driver.tick(), - idle: () => driver.idle(), - enqueue: (eventJson) => pump.enqueue(eventJson), - toEvent: providerMsgToEvent, - intervalMs: DEFAULT_TICK_MS, - }); - - const entry = String((JSON.parse(irText) as { entry?: unknown }).entry); - pump.enqueue(JSON.stringify({ kind: "init", route: entry, params: {} })); + pendingSession = null; + runtime.provider = provider; if (disposed) return; - ticks.start(); - runtime.ticks = ticks; - systemControls.ready(currentRemoteSystemInfo()); + systemControls.ready(providerSystemInfo(provider, hasProvider)); } try { @@ -489,7 +581,10 @@ export function startPlayRuntime( void boot().catch((error: unknown) => { if (disposed || isAbort(error)) return; - systemControls.failed(error, currentRemoteSystemInfo()); + systemControls.failed( + error, + providerSystemInfo(provider, hasProvider), + ); if (error instanceof PlayArtifactsUnavailableError) { const action = generations.unavailable(); applyGenerationAction(action); @@ -510,24 +605,19 @@ export function startPlayRuntime( eventSource.close(); eventSource = null; } - ticks?.stop(); - ticks = null; - surfaces?.dispose(); - surfaces = null; - focus?.dispose(); - focus = null; - scrolls?.dispose(); - scrolls = null; + play?.dispose(); + play = null; + if (pendingSession) release(pendingSession); + pendingSession = null; playAssets?.dispose?.(); playAssets = null; - inspection.dispose(); - release(runtime.driver); + release(provider); + provider = null; providerHost?.dispose(); providerHost = null; - release(runtime.session); - runtime.driver = null; + inspection.dispose(); + runtime.provider = null; runtime.session = null; - runtime.ticks = null; applicationStyle.textContent = ""; overlay.hide(); if (view.__uhura === runtime) delete view.__uhura; diff --git a/web/src/play/play-provider-selection.ts b/web/src/play/play-provider-selection.ts deleted file mode 100644 index 04f437f..0000000 --- a/web/src/play/play-provider-selection.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Pure selection policy for the browser Play runtime. A live-provider profile -// may keep its fixture for Editor/check/trace without exposing that deliberately -// partial script as an interactive Play backend. - -import type { PlayConfig, ProviderMode } from "../protocol/types.js"; - -export function selectPlayProvider( - play: PlayConfig, - storedProvider: string | null, -): { - provider: ProviderMode; - providers: ProviderMode[]; - clearStoredProvider: boolean; -} { - const hasRemote = play.provider.kind === "module"; - const providers: ProviderMode[] = hasRemote - ? play.allow_fixture === false - ? ["remote"] - : ["remote", "fixture"] - : ["fixture"]; - const storedCandidate = - storedProvider === "remote" || storedProvider === "fixture" - ? storedProvider - : null; - const override = - storedCandidate !== null && providers.includes(storedCandidate) - ? storedCandidate - : null; - return { - provider: override ?? (hasRemote ? "remote" : "fixture"), - providers, - clearStoredProvider: - storedProvider !== null && - (storedCandidate === null || !providers.includes(storedCandidate)), - }; -} diff --git a/web/src/play/provider.test.ts b/web/src/play/provider.test.ts new file mode 100644 index 0000000..58b9499 --- /dev/null +++ b/web/src/play/provider.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { hash } from "../protocol/machine.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, + type PortAdapter, + type PortRequirement, +} from "./adapter-host.js"; +import { admitProviderAdapterSet } from "./provider.js"; + +const requirement: PortRequirement = { + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash("1".repeat(64)), + contractInstanceHash: hash("2".repeat(64)), +}; + +const adapter = ( + fields: Partial = {}, +): PortAdapter => ({ + ...requirement, + accept() {}, + ...fields, +}); + +describe("application adapter provider admission", () => { + it("admits exactly the configured app.provider set", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter()] }, + [requirement], + "provider.js", + )).not.toThrow(); + }); + + it("rejects ownership substitution, missing, extra, and duplicate adapters", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ adapter: WEB_HISTORY_ADAPTER })] }, + [requirement], + "provider.js", + )).toThrow(/only "app\.provider" adapters/u); + expect(() => admitProviderAdapterSet( + { adapters: [] }, + [requirement], + "provider.js", + )).toThrow(/omitted provider adapter/u); + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ port: "extra" })] }, + [requirement], + "provider.js", + )).toThrow(/undeclared provider adapter/u); + expect(() => admitProviderAdapterSet( + { adapters: [adapter(), adapter()] }, + [requirement], + "provider.js", + )).toThrow(/duplicate adapter/u); + }); + + it("rejects contract or instance substitution", () => { + expect(() => admitProviderAdapterSet( + { adapters: [adapter({ contractHash: hash("3".repeat(64)) })] }, + [requirement], + "provider.js", + )).toThrow(/incompatible provider adapter/u); + expect(() => admitProviderAdapterSet( + { + adapters: [adapter({ + contractInstanceHash: hash("4".repeat(64)), + })], + }, + [requirement], + "provider.js", + )).toThrow(/incompatible provider adapter/u); + }); +}); diff --git a/web/src/play/provider.ts b/web/src/play/provider.ts new file mode 100644 index 0000000..47d4e5b --- /dev/null +++ b/web/src/play/provider.ts @@ -0,0 +1,169 @@ +import type { + ProviderHost, + SystemInfo, +} from "../protocol/types.js"; +import type { + ResolvedInput, + Value, +} from "../protocol/machine.js"; +import type { ResolveAsset } from "../renderer/assets.js"; +import type { + PortAdapter, + PortRequirement, +} from "./adapter-host.js"; +import { APPLICATION_PROVIDER_ADAPTER } from "./adapter-host.js"; + +/** Browser module ABI implemented by application-owned adapter providers. */ +export const UHURA_ADAPTER_PROVIDER_PROTOCOL = + "uhura-adapter-provider/0" as const; + +export interface UhuraProviderHost extends ProviderHost { + /** Exact admitted identity for one machine port owned by this deployment. */ + port(name: string): PortRequirement; + /** + * Decodes one browser URL through the checked route contract attached to a + * machine port. The returned input retains the admitted port identity. + */ + decodeRoute(port: string, url: string): ResolvedInput; + /** Encodes a checked route-contract Location value for browser history. */ + encodeRoute(port: string, location: Value): string; + /** Subscribes to committed Play locations owned by the application router. */ + onLocation(listener: (url: string) => void): () => void; + /** Applies browser history without inventing a machine input. */ + navigate(mode: "push" | "replace", url: string): void; + /** Requests one browser-history back traversal. */ + back(): void; +} + +/** + * One app-owned foreign-capability boundary. The browser admits the complete + * adapter set against Wasm-issued contract hashes before any command leaves + * the deterministic machine. + */ +export interface UhuraAdapterProvider { + readonly adapters: readonly PortAdapter[]; + readonly resolveAsset?: ResolveAsset; + systemInfo?(): SystemInfo; + dispose?(): void; +} + +export interface UhuraAdapterProviderModule { + createUhuraAdapters( + config: Readonly>, + host: UhuraProviderHost, + ): UhuraAdapterProvider | Promise; +} + +const providerModule = ( + value: unknown, + module: string, +): UhuraAdapterProviderModule => { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${module} must export createUhuraAdapters(config, host)`); + } + const candidate = value as Partial; + if (typeof candidate.createUhuraAdapters !== "function") { + throw new TypeError(`${module} must export createUhuraAdapters(config, host)`); + } + return candidate as UhuraAdapterProviderModule; +}; + +const providerInstance = ( + value: unknown, + module: string, +): UhuraAdapterProvider => { + if (typeof value !== "object" || value === null) { + throw new TypeError(`${module} createUhuraAdapters() must return an object`); + } + const candidate = value as Partial; + if (!Array.isArray(candidate.adapters)) { + throw new TypeError( + `${module} createUhuraAdapters() must return an adapters array`, + ); + } + return candidate as UhuraAdapterProvider; +}; + +export const admitProviderAdapterSet = ( + provider: UhuraAdapterProvider, + requirements: readonly PortRequirement[], + module: string, +): void => { + const expected = new Map(); + for (const requirement of requirements) { + if (requirement.adapter !== APPLICATION_PROVIDER_ADAPTER) { + throw new TypeError( + `${module} was offered non-provider port \`${requirement.port}\``, + ); + } + if (expected.has(requirement.port)) { + throw new TypeError( + `${module} received duplicate port requirement \`${requirement.port}\``, + ); + } + expected.set(requirement.port, requirement); + } + + const supplied = new Set(); + for (const adapter of provider.adapters) { + if (typeof adapter !== "object" || adapter === null) { + throw new TypeError(`${module} returned a non-object Uhura adapter`); + } + if (adapter.adapter !== APPLICATION_PROVIDER_ADAPTER) { + throw new TypeError( + `${module} may return only ${JSON.stringify(APPLICATION_PROVIDER_ADAPTER)} adapters`, + ); + } + if (supplied.has(adapter.port)) { + throw new TypeError( + `${module} returned duplicate adapter for port \`${adapter.port}\``, + ); + } + supplied.add(adapter.port); + const requirement = expected.get(adapter.port); + if (!requirement) { + throw new TypeError( + `${module} returned undeclared provider adapter for port \`${adapter.port}\``, + ); + } + if ( + adapter.contractHash !== requirement.contractHash + || adapter.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new TypeError( + `${module} returned an incompatible provider adapter for port \`${adapter.port}\``, + ); + } + if (typeof adapter.accept !== "function") { + throw new TypeError( + `${module} adapter for \`${adapter.port}\` must implement accept()`, + ); + } + } + + for (const port of expected.keys()) { + if (!supplied.has(port)) { + throw new TypeError( + `${module} omitted provider adapter for port \`${port}\``, + ); + } + } +}; + +export async function loadUhuraAdapterProvider( + module: string, + config: Readonly>, + host: UhuraProviderHost, + requirements: readonly PortRequirement[], +): Promise { + const loaded = providerModule( + await import(/* @vite-ignore */ module) as unknown, + module, + ); + const provider = providerInstance( + await loaded.createUhuraAdapters(config, host), + module, + ); + admitProviderAdapterSet(provider, requirements, module); + return provider; +} diff --git a/web/src/play/pump.ts b/web/src/play/pump.ts deleted file mode 100644 index 01894a0..0000000 --- a/web/src/play/pump.ts +++ /dev/null @@ -1,103 +0,0 @@ -// The event pump (§8.4, normative): renderer emissions always ENQUEUE; -// a `pumping` flag makes nested pumps no-ops — the wasm Session is -// single-borrow, so re-entering `dispatch` from inside `onStep` would -// panic. Post-drain observation checks run in a microtask. - -import type { ProviderMsg, StepResult } from "../protocol/types.js"; - -interface PumpWiring { - dispatch: (eventJson: string) => string; - deliver: (cmdJson: string) => void; - onStep: (result: StepResult) => void; - onError: (error: unknown, eventJson: string) => void; - onDrained?: () => void; -} - -interface QueuedEvent { - eventJson: string; - onApplied?: () => void; -} - -export function createPump({ dispatch, deliver, onStep, onError, onDrained }: PumpWiring) { - const queue: QueuedEvent[] = []; - let pumping = false; - - /** - * The ONLY entry point — everything (user input, driver ticks, Init) - * goes through the queue, so step order is arrival order. - * @param {string} eventJson - * @param {() => void} [onApplied] runs right after this event's step - * lands (the textfield in-flight accounting hangs off this) - */ - function enqueue(eventJson: string, onApplied?: () => void): void { - queue.push(onApplied ? { eventJson, onApplied } : { eventJson }); - pump(); - } - - function pump() { - if (pumping) return; - pumping = true; - try { - while (queue.length > 0) { - const item = queue.shift(); - if (!item) break; - let resultJson: string; - try { - resultJson = dispatch(item.eventJson); - } catch (error) { - item.onApplied?.(); - onError(error, item.eventJson); - continue; - } - const result = JSON.parse(resultJson) as StepResult; - // Emitted commands go to the provider as they appear (§7.2). A - // provider refusal to ACCEPT one (unscripted command, §9.5) is - // reported but must not skip the render below: the machine - // stepped — the DOM tracks the session, never the provider. - for (const c of result.c) { - const cmdJson = JSON.stringify(c); - try { - deliver(cmdJson); - } catch (error) { - onError(error, cmdJson); - } - } - // onStep reconciles the DOM; anything it provokes (focus events, - // observation flips) re-enters via enqueue and drains here. - onStep(result); - item.onApplied?.(); - } - } finally { - pumping = false; - } - if (queue.length > 0) { - pump(); // an emission slipped in during the finally window - } else if (onDrained) { - queueMicrotask(() => { - if (!pumping && queue.length === 0) onDrained(); - }); - } - } - - return { enqueue }; -} - -/** - * Maps one provider wire message to its external event (§7.2): a - * standalone projection update wraps into an `updates` list; `outcome` - * and `projection-failed` are shape-identical pass-throughs. - * @param {string} msgJson - * @returns {string} event JSON for `Session.dispatch` - */ -export function providerMsgToEvent(msgJson: string): string { - const msg = JSON.parse(msgJson) as ProviderMsg; - switch (msg.kind) { - case "projection": - return JSON.stringify({ kind: "projection", updates: [msg] }); - case "outcome": - case "projection-failed": - return msgJson; - default: - throw new Error(`the driver emitted a \`${msg.kind}\` message`); - } -} diff --git a/web/src/play/scroll.ts b/web/src/play/scroll.ts deleted file mode 100644 index ada0388..0000000 --- a/web/src/play/scroll.ts +++ /dev/null @@ -1,179 +0,0 @@ -// scroll mechanics (§8.4): near-end observation via a sentinel + -// IntersectionObserver (rootMargin 100% — the catalog's stated -// threshold), with an EDGE LATCH: one emission per entry into the -// near-end zone; re-arms only after the sentinel leaves. Wiggle-scroll -// at the bottom emits nothing (the machine's guard is the backstop, the -// latch keeps the trace clean). Plus the per-route scroll cache -// (micro-decision #17). - -import type { Descriptor } from "../protocol/types.js"; -import type { - NearEndState, - ScrollController, - ScrollHolder, -} from "../renderer/contracts.js"; - -interface ScrollPosition { - top: number; - left: number; -} - -/** Bounds stale page-instance positions minted by long-running navigation. */ -export const SCROLL_POSITION_CACHE_LIMIT = 64; - -interface ScrollWiring { - emit(descriptor: Descriptor): void; -} - -export interface PlayScrollController extends ScrollController { - dispose(): void; -} - -export function createScrolls({ emit }: ScrollWiring): PlayScrollController { - const routeCache = new Map>(); - const observed = new Map(); - - function disposeObservation(el: HTMLElement, holder: ScrollHolder): void { - const nearEnd = holder.nearEnd; - if (nearEnd) { - nearEnd.io.disconnect(); - nearEnd.sentinel.remove(); - holder.nearEnd = undefined; - } - observed.delete(el); - } - - /** - * Keeps one scroll element's near-end observation in sync with its - * CURRENT descriptors. `holder.on` rotates per step; descriptor - * absence (exhausted feed) tears the sentinel down — descriptor - * presence IS the subscription (§8.1). - */ - function sync(el: HTMLElement, holder: ScrollHolder): void { - const descriptor = holder.on["near-end"]; - if (!descriptor) { - disposeObservation(el, holder); - return; - } - if (!holder.nearEnd) { - const sentinel = document.createElement("div"); - sentinel.setAttribute("data-uh-mechanic", "sentinel"); - sentinel.style.cssText = "block-size:1px;flex:none;"; - el.append(sentinel); - const nearEnd: NearEndState = { - sentinel, - armed: true, - lastHeight: -1, - io: new IntersectionObserver( - (entries) => { - // A delivery already queued before disconnect must not outlive - // the renderer subtree that owned this observation. - if (holder.nearEnd !== nearEnd) return; - for (const entry of entries) { - if (entry.isIntersecting && nearEnd.armed) { - nearEnd.armed = false; - const d = holder.on["near-end"]; - if (d) emit(d); - } else if (!entry.isIntersecting) { - nearEnd.armed = true; // left the zone — re-arm the latch - } - } - }, - { root: el, rootMargin: "100%" }, - ), - }; - nearEnd.io.observe(sentinel); - holder.nearEnd = nearEnd; - observed.set(el, holder); - } - const nearEnd = holder.nearEnd; - if (nearEnd.sentinel !== el.lastElementChild) { - el.append(nearEnd.sentinel); // keep it after appended rows - } - if (el.scrollHeight !== nearEnd.lastHeight) { - // Content changed. The catalog's near-end threshold is a STATE - // (remaining extent below one viewport — §10), not an edge: re-arm - // and take a fresh observation, so a feed still inside the zone - // after a short append keeps paginating instead of deadlocking. - // The machine's guard is the backstop against spam. - nearEnd.lastHeight = el.scrollHeight; - nearEnd.armed = true; - nearEnd.io.unobserve(nearEnd.sentinel); - nearEnd.io.observe(nearEnd.sentinel); - } - } - - /** - * Saves every scroll position under the outgoing page instance before - * the page subtree remounts. The key is main.ts's nav key - * (route + depth + params — register #17), so two `profile/[user]` - * instances never share positions. - */ - function savePositions(navKey: string, pageEl: HTMLElement): void { - const positions = new Map(); - for (const candidate of pageEl.querySelectorAll(".uh-scroll")) { - if (!(candidate instanceof HTMLElement)) continue; - // Keyed by data-key, NOT data-path: node keys are stable source - // ordinals, while paths embed the page serial, which is freshly - // minted on every remount. - const key = candidate.getAttribute("data-key"); - if (key) { - positions.set(key, { - top: candidate.scrollTop, - left: candidate.scrollLeft, - }); - } - } - // Map insertion order gives a small LRU: refreshing a key moves it to - // the back, and the least-recent page instance is evicted first. - routeCache.delete(navKey); - routeCache.set(navKey, positions); - while (routeCache.size > SCROLL_POSITION_CACHE_LIMIT) { - const oldest = routeCache.keys().next().value; - if (oldest === undefined) break; - routeCache.delete(oldest); - } - } - - /** - * Restores cached positions after a page remount (back → the feed - * exactly where it was). Unknown keys stay at 0 — a freshly pushed - * instance starts at the top. - */ - function restorePositions(navKey: string, pageEl: HTMLElement): void { - const positions = routeCache.get(navKey); - if (!positions) return; - routeCache.delete(navKey); - routeCache.set(navKey, positions); - for (const candidate of pageEl.querySelectorAll(".uh-scroll")) { - if (!(candidate instanceof HTMLElement)) continue; - const key = candidate.getAttribute("data-key"); - const saved = key ? positions.get(key) : undefined; - if (saved) { - candidate.scrollTop = saved.top; - candidate.scrollLeft = saved.left; - } - } - } - - /** Disconnects observations before a renderer-owned subtree is detached. */ - function disposeSubtree(root: HTMLElement): void { - for (const [el, holder] of observed) { - if (el === root || root.contains(el)) disposeObservation(el, holder); - } - } - - function dispose(): void { - for (const [el, holder] of observed) disposeObservation(el, holder); - observed.clear(); - routeCache.clear(); - } - - return { sync, disposeSubtree, savePositions, restorePositions, dispose }; -} - -export type { - NearEndState, - ScrollController, - ScrollHolder, -} from "../renderer/contracts.js"; diff --git a/web/src/play/session.test.ts b/web/src/play/session.test.ts new file mode 100644 index 0000000..625a849 --- /dev/null +++ b/web/src/play/session.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; + +import { + UHURA_MACHINE_PROGRAM_ID_PROTOCOL, + UHURA_SEMANTIC_IR_HASH_PROTOCOL, +} from "../protocol/machine.js"; +import { + admitConfiguredPorts, + decodePlayConfig, + decodePlayStep, +} from "./session.js"; +import { UHURA_ADAPTER_PROVIDER_PROTOCOL } from "./provider.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + WEB_HISTORY_ADAPTER, + WEB_ROUTER_CONTRACT, +} from "./adapter-host.js"; + +const hash = "11".repeat(32); +const configurationHash = "22".repeat(32); +const stateHash = "33".repeat(32); +const nextStateHash = "44".repeat(32); + +const config = { + protocol: "uhura-play-config/1", + identityProtocol: UHURA_SEMANTIC_IR_HASH_PROTOCOL, + entry: "app", + machine: "example.app@1::App", + presentation: "example.app@1::Web", + machineProgramHash: hash, + presentationHash: hash, + evidenceHash: null, + deploymentHash: hash, + lifetime: "application-session", + instance: "entry/app", + configuration: { $: "unit" }, + ports: [], +} as const; + +const observation = (count: string) => ({ + $: "record", + fields: [{ name: "count", value: { $: "Int", value: count } }], +}); + +const command = { + target: "local", + value: { + $: "variant", + type: "example.app@1::App.Command", + case: "reported", + fields: [], + }, +}; + +const genesis = { + protocol: "uhura-genesis-receipt/0", + kind: "genesis", + instance: config.instance, + machineProgramHash: hash, + configurationHash, + sequence: "0", + initialObservation: observation("0"), + initialStateHash: stateHash, +}; + +const reaction = { + protocol: "uhura-reaction-receipt/0", + kind: "reaction", + instance: config.instance, + machineProgramHash: hash, + configurationHash, + sequence: "1", + input: { + source: "local", + value: { + $: "variant", + type: "example.app@1::App.Input", + case: "increment", + fields: [], + }, + }, + resolution: { + kind: "completed", + outcome: { + $: "variant", + type: "example.app@1::App.Outcome", + case: "accepted", + fields: [], + }, + disposition: "commit", + }, + orderedCommands: [command], + postObservation: observation("1"), + preStateHash: stateHash, + postStateHash: nextStateHash, +}; + +const step = { + protocol: "uhura-browser/2", + receipt: reaction, + observation: observation("1"), + commands: [command], + presentation: { + kind: "view", + view: { + protocol: "uhura-view/1", + presentation: config.presentation, + machine: config.machine, + instance: config.instance, + sequence: "1", + projectionHash: "55".repeat(32), + nodes: [], + }, + }, +}; + +const inspection = { + protocol: "uhura-browser/2", + identityProtocol: UHURA_SEMANTIC_IR_HASH_PROTOCOL, + instance: config.instance, + machineProgramHash: hash, + presentation: config.presentation, + presentationHash: hash, + configurationHash, + configuration: config.configuration, + state: observation("1"), + observation: observation("1"), + inbox: [], + lifecycle: "running", + nextSequence: "2", + tracePrefixHash: "66".repeat(32), + receipts: [genesis, reaction], + ingressPrefixHash: "77".repeat(32), + nextIngressOrdinal: "1", + ingressRecords: [], +}; + +const clone = (value: T): T => structuredClone(value); + +describe("Uhura Play config", () => { + it("admits only the two language-owned identity protocols", () => { + expect(decodePlayConfig(config).identityProtocol) + .toBe(UHURA_SEMANTIC_IR_HASH_PROTOCOL); + expect( + decodePlayConfig({ + ...config, + identityProtocol: UHURA_MACHINE_PROGRAM_ID_PROTOCOL, + }).identityProtocol, + ).toBe(UHURA_MACHINE_PROGRAM_ID_PROTOCOL); + expect(() => + decodePlayConfig({ + ...config, + identityProtocol: "uhura-unrecognized-identity/9", + }) + ).toThrow(/identityProtocol must be/u); + }); + + it("admits generic provider metadata and exact port identities", () => { + const decoded = decodePlayConfig({ + ...config, + ports: [{ + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + provider: { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: "/api/play/provider.js", + config: { actor: "demo" }, + }, + }); + expect(decoded.ports[0]?.contractInstanceHash).toBe(hash); + expect(decoded.ports[0]?.adapter).toBe(APPLICATION_PROVIDER_ADAPTER); + expect(decoded.provider?.protocol).toBe(UHURA_ADAPTER_PROVIDER_PROTOCOL); + expect(decoded.provider?.module).toBe("/api/play/provider.js"); + expect(decoded.provider?.config).toEqual({ actor: "demo" }); + }); + + it("rejects a provider module with an unknown adapter ABI", () => { + expect(() => + decodePlayConfig({ + ...config, + provider: { + protocol: "uhura-adapter-provider/9", + module: "/api/play/provider.js", + config: {}, + }, + }) + ).toThrow(/provider\.protocol/u); + }); + + it("has no runtime discriminator and rejects unsealed adapter names", () => { + expect(() => + decodePlayConfig({ + ...config, + runtime: "other", + }) + ).toThrow(/wrong fields/u); + expect(() => + decodePlayConfig({ + ...config, + ports: [{ + port: "orders", + adapter: "return-desk.orders", + contractHash: hash, + contractInstanceHash: hash, + }], + }) + ).toThrow(/sealed Uhura adapter table/u); + }); + + it("requires provider metadata exactly when app.provider owns a port", () => { + expect(() => decodePlayConfig({ + ...config, + ports: [{ + port: "authority", + adapter: APPLICATION_PROVIDER_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + })).toThrow(/has no provider module/u); + expect(() => decodePlayConfig({ + ...config, + provider: { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: "/api/play/provider.js", + config: {}, + }, + })).toThrow(/binds no app\.provider ports/u); + }); + + it("merges core contracts with exact host adapter ownership", () => { + const play = decodePlayConfig({ + ...config, + ports: [{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contractHash: hash, + contractInstanceHash: hash, + }], + }); + const admitted = admitConfiguredPorts([{ + port: "router", + contract: WEB_ROUTER_CONTRACT, + contractHash: play.ports[0]!.contractHash, + contractInstanceHash: play.ports[0]!.contractInstanceHash, + }], play); + expect(admitted).toEqual([{ + port: "router", + adapter: WEB_HISTORY_ADAPTER, + contract: WEB_ROUTER_CONTRACT, + contractHash: hash, + contractInstanceHash: hash, + }]); + }); + + it("requires a presentation and its identity together", () => { + expect(() => + decodePlayConfig({ + ...config, + presentationHash: null, + }) + ).toThrow(/must either both be null or both be present/u); + }); +}); + +describe("Uhura browser-step admission", () => { + const play = decodePlayConfig(config); + + it("correlates receipt, inspection, observation, commands, and view", () => { + const decoded = decodePlayStep( + JSON.stringify(step), + JSON.stringify(inspection), + play, + ); + expect(decoded.receipt.sequence).toBe("1"); + expect(decoded.commands).toEqual(decoded.receipt.orderedCommands); + expect(decoded.observation).toEqual(decoded.inspection.observation); + expect(decoded.presentation.kind).toBe("view"); + if (decoded.presentation.kind !== "view") throw new Error("expected view"); + expect(decoded.presentation.view.sequence).toBe(decoded.receipt.sequence); + }); + + it("rejects a stale or unrelated view", () => { + const invalid = clone(step); + invalid.presentation.view.sequence = "0"; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + JSON.stringify(inspection), + play, + ) + ).toThrow(/view identity or sequence/u); + }); + + it("admits a correlated projection error without losing committed commands", () => { + const failed = clone(step) as Record; + failed["presentation"] = { + kind: "error", + error: { + code: "projection-failed", + message: "one projection contains duplicate Surface keys", + machine: config.machine, + presentation: config.presentation, + instance: config.instance, + sequence: reaction.sequence, + }, + }; + const decoded = decodePlayStep( + JSON.stringify(failed), + JSON.stringify(inspection), + play, + ); + expect(decoded.presentation.kind).toBe("error"); + expect(decoded.commands).toEqual(decoded.receipt.orderedCommands); + }); + + it("rejects an uncorrelated projection error", () => { + const failed = clone(step) as Record; + failed["presentation"] = { + kind: "error", + error: { + code: "projection-failed", + message: "one projection contains duplicate Surface keys", + machine: config.machine, + presentation: config.presentation, + instance: config.instance, + sequence: "0", + }, + }; + expect(() => + decodePlayStep( + JSON.stringify(failed), + JSON.stringify(inspection), + play, + ) + ).toThrow(/projection error identity or sequence/u); + }); + + it("rejects receipt protocol drift", () => { + const invalid = clone(step); + invalid.receipt.protocol = "uhura-reaction-receipt/9"; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + JSON.stringify(inspection), + play, + ) + ).toThrow(/reaction-receipt\/0/u); + }); + + it("rejects commands that differ from the committed receipt", () => { + const invalid = clone(step); + invalid.commands = []; + expect(() => + decodePlayStep( + JSON.stringify(invalid), + JSON.stringify(inspection), + play, + ) + ).toThrow(/commands differ/u); + }); +}); diff --git a/web/src/play/session.ts b/web/src/play/session.ts new file mode 100644 index 0000000..b301093 --- /dev/null +++ b/web/src/play/session.ts @@ -0,0 +1,777 @@ +import type { Session as WasmSession } from "/api/play/wasm/uhura_wasm.js"; + +import type { PlayShell } from "./shell.js"; +import { + APPLICATION_PROVIDER_ADAPTER, + createAdapterHost, + partitionAdapterRequirements, + WEB_HISTORY_ADAPTER, + type AdapterIdentity, + type AdmittedPortRequirement, + type AdapterHost, + type PortAdapter, + type PortRequirement, +} from "./adapter-host.js"; +import { + UHURA_BROWSER_PROTOCOL, + decodeIdentityProtocol, + decodeInspection, + decodeReactionReceipt, + decodeResolvedCommand, + decodeValue, + hash, + natural, + type Hash, + type Inspection, + type NaturalText, + type Observation, + type Receipt, + type ReactionReceipt, + type ResolvedCommand, + type ResolvedInput, + type UhuraIdentityProtocol, + type Value, +} from "../protocol/machine.js"; +import type { AssetAppliers } from "../renderer/assets.js"; +import type { IconFontRegistry } from "../renderer/icons.js"; +import { UHURA_ADAPTER_PROVIDER_PROTOCOL } from "./provider.js"; +import { + createProjectionRenderer, + decodeRenderDocument, + type ProjectionRenderer, + type RenderDocument, +} from "../renderer/projection.js"; + +export const UHURA_PLAY_CONFIG_PROTOCOL = "uhura-play-config/1" as const; + +export interface PlayPortConfig extends PortRequirement {} + +export interface PlayProviderConfig { + readonly protocol: typeof UHURA_ADAPTER_PROVIDER_PROTOCOL; + readonly module: string; + readonly config: Readonly>; +} + +export interface PlayConfig { + readonly protocol: typeof UHURA_PLAY_CONFIG_PROTOCOL; + readonly identityProtocol: UhuraIdentityProtocol; + readonly entry: string; + readonly machine: string; + readonly presentation: string | null; + readonly machineProgramHash: Hash; + readonly presentationHash: Hash | null; + readonly evidenceHash: Hash | null; + readonly deploymentHash: Hash; + readonly lifetime: "application-session"; + readonly instance: string; + readonly configuration: Value; + readonly ports: readonly PlayPortConfig[]; + readonly provider: PlayProviderConfig | null; +} + +export interface PlayController { + readonly session: WasmSession; + dispose(): void; +} + +export interface StartPlayOptions { + readonly shell: PlayShell; + readonly session: WasmSession; + readonly config: PlayConfig; + readonly adapters: readonly PortAdapter[]; + readonly assets?: AssetAppliers; + readonly icons?: IconFontRegistry; + /** + * Publishes only a fully correlated runtime step. Inspection is + * observational and never participates in machine execution. + */ + readonly publishInspection: ( + inspection: Inspection, + receipt: Receipt, + ) => void; + /** Reports a recoverable UI projection failure; the machine keeps running. */ + readonly onProjectionError?: (error: ProjectionFailure) => void; + readonly onError: (error: unknown) => void; +} + +export interface BrowserStep { + readonly receipt: ReactionReceipt; + readonly observation: Observation; + readonly commands: readonly ResolvedCommand[]; + readonly presentation: BrowserPresentation; + readonly inspection: Inspection; +} + +export interface ProjectionFailure { + readonly code: "projection-failed"; + readonly message: string; + readonly machine: string; + readonly presentation: string; + readonly instance: string; + readonly sequence: NaturalText; +} + +export type BrowserPresentation = + | { readonly kind: "none" } + | { readonly kind: "view"; readonly view: RenderDocument } + | { readonly kind: "error"; readonly error: ProjectionFailure }; + +const object = ( + value: unknown, + context: string, +): Readonly> => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${context} must be an object`); + } + return value as Readonly>; +}; + +const text = (value: unknown, context: string): string => { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${context} must be nonempty text`); + } + return value; +}; + +const adapterIdentity = ( + value: unknown, + context: string, +): AdapterIdentity => { + if ( + value !== WEB_HISTORY_ADAPTER + && value !== APPLICATION_PROVIDER_ADAPTER + ) { + throw new TypeError(`${context} is not in the sealed Uhura adapter table`); + } + return value; +}; + +const list = (value: unknown, context: string): readonly unknown[] => { + if (!Array.isArray(value)) { + throw new TypeError(`${context} must be a list`); + } + return value; +}; + +const exactKeys = ( + value: Readonly>, + required: readonly string[], + context: string, + optional: readonly string[] = [], +): void => { + const expected = new Set([...required, ...optional]); + const missing = required.filter((key) => !Object.hasOwn(value, key)); + const extra = Object.keys(value).filter((key) => !expected.has(key)); + if (missing.length > 0 || extra.length > 0) { + throw new TypeError( + `${context} has the wrong fields; missing [${missing.join(", ")}], extra [${extra.join(", ")}]`, + ); + } +}; + +const parseJson = (source: string, context: string): unknown => { + try { + return JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${context} is not JSON: ${String(error)}`); + } +}; + +const decodeProvider = (value: unknown): PlayProviderConfig | null => { + if (value === undefined || value === null) return null; + const provider = object(value, "Uhura Play config.provider"); + exactKeys( + provider, + ["protocol", "module", "config"], + "Uhura Play config.provider", + ); + if (provider["protocol"] !== UHURA_ADAPTER_PROVIDER_PROTOCOL) { + throw new TypeError( + `Uhura Play config.provider.protocol must be ${JSON.stringify(UHURA_ADAPTER_PROVIDER_PROTOCOL)}`, + ); + } + return { + protocol: UHURA_ADAPTER_PROVIDER_PROTOCOL, + module: text(provider["module"], "Uhura Play config.provider.module"), + config: object(provider["config"], "Uhura Play config.provider.config"), + }; +}; + +export const decodePlayConfig = (value: unknown): PlayConfig => { + const config = object(value, "Uhura Play config"); + exactKeys( + config, + [ + "protocol", + "identityProtocol", + "entry", + "machine", + "presentation", + "machineProgramHash", + "presentationHash", + "evidenceHash", + "deploymentHash", + "lifetime", + "instance", + "configuration", + "ports", + ], + "Uhura Play config", + ["provider"], + ); + if (config["protocol"] !== UHURA_PLAY_CONFIG_PROTOCOL) { + throw new TypeError( + `Uhura Play config.protocol must be ${JSON.stringify(UHURA_PLAY_CONFIG_PROTOCOL)}`, + ); + } + const identityProtocol = decodeIdentityProtocol( + config["identityProtocol"], + "Uhura Play config.identityProtocol", + ); + if (config["lifetime"] !== "application-session") { + throw new TypeError( + "Uhura Play config.lifetime must be `application-session`", + ); + } + const presentation = config["presentation"]; + if (presentation !== null && typeof presentation !== "string") { + throw new TypeError("Uhura Play config.presentation must be text or null"); + } + if (presentation === "") { + throw new TypeError( + "Uhura Play config.presentation must be nonempty when present", + ); + } + const presentationHash = config["presentationHash"] === null + ? null + : hash( + text(config["presentationHash"], "Uhura Play config.presentationHash"), + ); + if ((presentation === null) !== (presentationHash === null)) { + throw new TypeError( + "Uhura Play config.presentation and presentationHash must either both be null or both be present", + ); + } + const evidenceHash = config["evidenceHash"] === null + ? null + : hash(text(config["evidenceHash"], "Uhura Play config.evidenceHash")); + const ports = list(config["ports"], "Uhura Play config.ports").map( + (value, index): PlayPortConfig => { + const context = `Uhura Play config.ports[${index}]`; + const port = object(value, context); + exactKeys( + port, + ["port", "adapter", "contractHash", "contractInstanceHash"], + context, + ); + return { + port: text(port["port"], `${context}.port`), + adapter: adapterIdentity(port["adapter"], `${context}.adapter`), + contractHash: hash(text(port["contractHash"], `${context}.contractHash`)), + contractInstanceHash: hash( + text(port["contractInstanceHash"], `${context}.contractInstanceHash`), + ), + }; + }, + ); + const names = new Set(); + for (const port of ports) { + if (names.has(port.port)) { + throw new TypeError(`Uhura Play config repeats port \`${port.port}\``); + } + names.add(port.port); + } + const provider = decodeProvider(config["provider"]); + const needsProvider = ports.some( + (port) => port.adapter === APPLICATION_PROVIDER_ADAPTER, + ); + if (needsProvider !== (provider !== null)) { + throw new TypeError( + needsProvider + ? "Uhura Play config binds app.provider ports but has no provider module" + : "Uhura Play config has a provider module but binds no app.provider ports", + ); + } + return { + protocol: UHURA_PLAY_CONFIG_PROTOCOL, + identityProtocol, + entry: text(config["entry"], "Uhura Play config.entry"), + machine: text(config["machine"], "Uhura Play config.machine"), + presentation: typeof presentation === "string" ? presentation : null, + machineProgramHash: hash( + text(config["machineProgramHash"], "Uhura Play config.machineProgramHash"), + ), + presentationHash, + evidenceHash, + deploymentHash: hash( + text(config["deploymentHash"], "Uhura Play config.deploymentHash"), + ), + lifetime: "application-session", + instance: text(config["instance"], "Uhura Play config.instance"), + configuration: decodeValue( + config["configuration"], + "Uhura Play config.configuration", + ), + ports, + provider, + }; +}; + +export interface WasmPortRequirement extends Omit { + readonly contract: string; +} + +export const decodePortRequirements = ( + source: string, +): WasmPortRequirement[] => { + const requirements = list( + parseJson(source, "Uhura port requirements"), + "Uhura port requirements", + ).map((value, index): WasmPortRequirement => { + const context = `Uhura port requirements[${index}]`; + const requirement = object(value, context); + exactKeys( + requirement, + ["port", "contract", "contractHash", "contractInstanceHash"], + context, + ); + return { + port: text(requirement["port"], `${context}.port`), + contract: text(requirement["contract"], `${context}.contract`), + contractHash: hash( + text(requirement["contractHash"], `${context}.contractHash`), + ), + contractInstanceHash: hash( + text( + requirement["contractInstanceHash"], + `${context}.contractInstanceHash`, + ), + ), + }; + }); + const names = new Set(); + for (const requirement of requirements) { + if (names.has(requirement.port)) { + throw new TypeError( + `Uhura machine runtime repeats port requirement \`${requirement.port}\``, + ); + } + names.add(requirement.port); + } + return requirements; +}; + +const sameWireValue = (left: unknown, right: unknown): boolean => + JSON.stringify(left) === JSON.stringify(right); + +const requireSameWireValue = ( + left: unknown, + right: unknown, + message: string, +): void => { + if (!sameWireValue(left, right)) throw new TypeError(message); +}; + +const validateInspectionIdentity = ( + inspection: Inspection, + config: PlayConfig, +): void => { + if (inspection.identityProtocol !== config.identityProtocol) { + throw new TypeError( + "Uhura machine runtime identity protocol differs from Play config", + ); + } + if (inspection.instance !== config.instance) { + throw new TypeError( + "Uhura machine runtime instance differs from Play config", + ); + } + if (inspection.machineProgramHash !== config.machineProgramHash) { + throw new TypeError( + "Uhura machine runtime machine identity differs from Play config", + ); + } + if (inspection.presentation !== config.presentation) { + throw new TypeError( + "Uhura machine runtime presentation differs from Play config", + ); + } + if (inspection.presentationHash !== config.presentationHash) { + throw new TypeError( + "Uhura machine runtime presentation identity differs from Play config", + ); + } + requireSameWireValue( + inspection.configuration, + config.configuration, + "Uhura machine runtime configuration differs from Play config", + ); +}; + +const validateViewIdentity = ( + view: RenderDocument, + receipt: Receipt, + config: PlayConfig, +): void => { + if (config.presentation === null) { + throw new TypeError("headless Uhura Play received an undeclared view"); + } + if ( + view.instance !== config.instance + || view.machine !== config.machine + || view.presentation !== config.presentation + || view.sequence !== receipt.sequence + ) { + throw new TypeError( + "Uhura reaction view identity or sequence differs from its admitted receipt", + ); + } +}; + +const decodeProjectionFailure = ( + value: unknown, + receipt: Receipt, + config: PlayConfig, + context: string, +): ProjectionFailure => { + if (config.presentation === null) { + throw new TypeError("headless Uhura Play received a projection error"); + } + const failure = object(value, context); + exactKeys( + failure, + ["code", "message", "machine", "presentation", "instance", "sequence"], + context, + ); + if (failure["code"] !== "projection-failed") { + throw new TypeError(`${context}.code must be \`projection-failed\``); + } + const decoded: ProjectionFailure = { + code: "projection-failed", + message: text(failure["message"], `${context}.message`), + machine: text(failure["machine"], `${context}.machine`), + presentation: text( + failure["presentation"], + `${context}.presentation`, + ), + instance: text(failure["instance"], `${context}.instance`), + sequence: natural(text(failure["sequence"], `${context}.sequence`)), + }; + if ( + decoded.machine !== config.machine + || decoded.presentation !== config.presentation + || decoded.instance !== config.instance + || decoded.sequence !== receipt.sequence + ) { + throw new TypeError( + "Uhura projection error identity or sequence differs from its admitted receipt", + ); + } + return decoded; +}; + +const decodeBrowserPresentation = ( + value: unknown, + receipt: Receipt, + config: PlayConfig, + context: string, +): BrowserPresentation => { + const presentation = object(value, context); + const kind = text(presentation["kind"], `${context}.kind`); + switch (kind) { + case "none": + exactKeys(presentation, ["kind"], context); + if (config.presentation !== null) { + throw new TypeError( + "presented Uhura Play omitted both its view and projection error", + ); + } + return { kind: "none" }; + case "view": { + exactKeys(presentation, ["kind", "view"], context); + const view = decodeRenderDocument( + presentation["view"], + `${context}.view`, + ); + validateViewIdentity(view, receipt, config); + return { kind: "view", view }; + } + case "error": + exactKeys(presentation, ["kind", "error"], context); + return { + kind: "error", + error: decodeProjectionFailure( + presentation["error"], + receipt, + config, + `${context}.error`, + ), + }; + default: + throw new TypeError(`${context}.kind is not supported`); + } +}; + +/** + * Validates one complete Wasm reaction boundary before Play mutates the DOM or + * publishes a command to an adapter. + */ +export const decodePlayStep = ( + source: string, + inspectionSource: string, + config: PlayConfig, +): BrowserStep => { + const step = object( + parseJson(source, "Uhura reaction step"), + "Uhura reaction step", + ); + exactKeys( + step, + ["protocol", "receipt", "observation", "commands", "presentation"], + "Uhura reaction step", + ); + if (step["protocol"] !== UHURA_BROWSER_PROTOCOL) { + throw new TypeError("Uhura reaction step has an unsupported protocol"); + } + const receipt = decodeReactionReceipt( + step["receipt"], + "Uhura reaction step.receipt", + ); + const observation = decodeValue( + step["observation"], + "Uhura reaction step.observation", + ); + const commands = list(step["commands"], "Uhura reaction step.commands").map( + (command, index) => + decodeResolvedCommand( + command, + `Uhura reaction step.commands[${index}]`, + ), + ); + const presentation = decodeBrowserPresentation( + step["presentation"], + receipt, + config, + "Uhura reaction step.presentation", + ); + requireSameWireValue( + commands, + receipt.orderedCommands, + "Uhura reaction step commands differ from receipt.orderedCommands", + ); + requireSameWireValue( + observation, + receipt.postObservation, + "Uhura reaction step observation differs from receipt.postObservation", + ); + + const inspection = decodeInspection( + parseJson(inspectionSource, "Uhura reaction inspection"), + "Uhura reaction inspection", + ); + validateInspectionIdentity(inspection, config); + if ( + receipt.instance !== inspection.instance + || receipt.machineProgramHash !== inspection.machineProgramHash + || receipt.configurationHash !== inspection.configurationHash + ) { + throw new TypeError( + "Uhura reaction receipt identity differs from the inspected runtime", + ); + } + const latest = inspection.receipts.at(-1); + if (latest?.kind !== "reaction") { + throw new TypeError( + "Uhura reaction inspection does not end in a reaction receipt", + ); + } + requireSameWireValue( + receipt, + latest, + "Uhura reaction step receipt differs from the latest inspected receipt", + ); + return { receipt, observation, commands, presentation, inspection }; +}; + +export const admitConfiguredPorts = ( + requirements: readonly WasmPortRequirement[], + config: PlayConfig, +): AdmittedPortRequirement[] => { + const configured = new Map(config.ports.map((port) => [port.port, port])); + const admitted: AdmittedPortRequirement[] = []; + for (const requirement of requirements) { + const port = configured.get(requirement.port); + if (!port) { + throw new Error( + `Uhura Play has no configured adapter for \`${requirement.port}\``, + ); + } + if ( + port.contractHash !== requirement.contractHash + || port.contractInstanceHash !== requirement.contractInstanceHash + ) { + throw new Error( + `Uhura Play contract identity does not match port \`${port.port}\``, + ); + } + admitted.push({ ...requirement, adapter: port.adapter }); + } + for (const port of configured.keys()) { + if (!requirements.some((requirement) => requirement.port === port)) { + throw new Error(`Uhura Play config binds undeclared port \`${port}\``); + } + } + partitionAdapterRequirements(admitted); + return admitted; +}; + +const showProjectionFailure = ( + root: HTMLElement, + failure: ProjectionFailure, +): void => { + const notice = root.ownerDocument.createElement("section"); + notice.className = "uh-projection-error"; + notice.setAttribute("role", "alert"); + notice.setAttribute("aria-live", "polite"); + const heading = root.ownerDocument.createElement("strong"); + heading.textContent = "Presentation unavailable"; + const message = root.ownerDocument.createElement("p"); + message.textContent = failure.message; + const context = root.ownerDocument.createElement("code"); + context.textContent = `${failure.presentation} at reaction ${failure.sequence}`; + notice.append(heading, message, context); + root.replaceChildren(notice); +}; + +export function startPlay( + options: StartPlayOptions, +): PlayController { + const { shell, session, config } = options; + const identityInspection = decodeInspection( + parseJson(session.inspect(), "Uhura identity inspection"), + "Uhura identity inspection", + ); + validateInspectionIdentity(identityInspection, config); + + const requirements = admitConfiguredPorts( + decodePortRequirements(session.port_requirements()), + config, + ); + const initialReceipt = identityInspection.receipts.at(-1); + if (!initialReceipt) { + throw new TypeError("Uhura initial inspection has no admitted receipt"); + } + const initialPresentation = decodeBrowserPresentation( + parseJson(session.presentation(), "Uhura initial presentation"), + initialReceipt, + config, + "Uhura initial presentation", + ); + + let disposed = false; + let renderer: ProjectionRenderer | null = null; + let adapters: AdapterHost | null = null; + let currentView: RenderDocument | null = null; + + function createRenderer(): ProjectionRenderer { + return createProjectionRenderer({ + root: shell.pageHost, + mode: "play", + assets: options.assets, + icons: options.icons, + dispatch(binding, event): void { + if (disposed || currentView === null) return; + try { + applyStep( + session.dispatch_ui( + binding, + currentView.sequence, + JSON.stringify(event), + ), + ); + } catch (error) { + options.onError(error); + } + }, + }); + } + + function applyPresentation(presentation: BrowserPresentation): void { + switch (presentation.kind) { + case "none": + currentView = null; + renderer?.dispose(); + renderer = null; + return; + case "error": + currentView = null; + renderer?.dispose(); + renderer = null; + showProjectionFailure(shell.pageHost, presentation.error); + options.onProjectionError?.(presentation.error); + return; + case "view": + renderer ??= createRenderer(); + currentView = null; + renderer.render(presentation.view); + currentView = presentation.view; + return; + } + } + + function applyStep(source: string): void { + const step = decodePlayStep(source, session.inspect(), config); + options.publishInspection(step.inspection, step.receipt); + // Committed commands are never contingent on optional UI projection or + // DOM reconciliation. Adapter delivery therefore precedes presentation. + adapters?.publish(step.commands); + applyPresentation(step.presentation); + } + + const submit = (input: ResolvedInput): void => { + if (disposed) return; + try { + applyStep(session.submit(JSON.stringify(input))); + } catch (error) { + options.onError(error); + } + }; + + applyPresentation(initialPresentation); + + adapters = createAdapterHost({ + requirements, + adapters: options.adapters, + deliver: submit, + localCommand(command): void { + options.onError( + new Error( + `Uhura Play has no host target for local command ${JSON.stringify(command)}`, + ), + ); + }, + adapterError(error, port): void { + options.onError( + error instanceof Error + ? error + : new Error(`Uhura adapter ${port} failed: ${String(error)}`), + ); + }, + }); + + options.publishInspection(identityInspection, initialReceipt); + adapters.start(); + + return { + session, + dispose(): void { + if (disposed) return; + disposed = true; + adapters?.dispose(); + adapters = null; + renderer?.dispose(); + renderer = null; + session.free(); + }, + }; +} diff --git a/web/src/play/shell.css b/web/src/play/shell.css index cd50275..c9998d8 100644 --- a/web/src/play/shell.css +++ b/web/src/play/shell.css @@ -595,18 +595,8 @@ body.uh-play-shell { border-color: #7aa7ff; box-shadow: 0 0 0 2px rgba(122, 167, 255, 0.16), 0 5px 16px rgba(0, 0, 0, 0.3); } -#uh-debug-panel button.uh-debug-node.is-consulted-unsatisfied { - border-color: #b98343; - border-style: dashed; -} -#uh-debug-panel button.uh-debug-node.is-consulted-not-ready, -#uh-debug-panel button.uh-debug-node.has-failure { - border-color: #bd6262; - border-style: dashed; -} #uh-debug-panel button.uh-debug-node.is-written, -#uh-debug-panel button.uh-debug-node.is-sent, -#uh-debug-panel button.uh-debug-node.is-structural { +#uh-debug-panel button.uh-debug-node.is-sent { background: #1d2a27; border-color: #4f8877; } @@ -730,6 +720,19 @@ body.uh-play-shell { } #uh-page, .uh-page-root { block-size: 100%; min-block-size: 0; } .uh-page-root > * { block-size: 100%; } +.uh-projection-error { + block-size: 100%; + display: grid; + place-content: center; + gap: 8px; + padding: 32px; + color: #e4e4e7; + background: #111113; + text-align: center; +} +.uh-projection-error strong { font-size: 15px; } +.uh-projection-error p { max-inline-size: 54ch; color: #f0a6a6; } +.uh-projection-error code { color: #a1a1aa; font: 12px/1.5 ui-monospace, monospace; } /* semantic element bases used by the shared browser renderer */ .uh-view { display: block; min-inline-size: 0; } diff --git a/web/src/play/shell.ts b/web/src/play/shell.ts index aa7e59e..7f4d4a9 100644 --- a/web/src/play/shell.ts +++ b/web/src/play/shell.ts @@ -27,10 +27,6 @@ export const PLAY_SHELL_MARKUP = ` Starting -