From 8a0f6073f7d925066a02ddbdad35da8898d2b72a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:09:10 -0400 Subject: [PATCH 01/14] =?UTF-8?q?joule-ui:=20the=20line=20language=20(SPEC?= =?UTF-8?q?=20=C2=A711),=20a=20streaming=20ingest=20into=20validated=20wid?= =?UTF-8?q?get=20trees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joule-ui takes in OpenUI Lang 1.0 the way it takes in A2UI: a model writes a surface as a program, one widget a line, and joule-ui-lang builds a widget tree against the host's registry as the program streams. After every chunk a snapshot holds the tree as far as it can be built, each widget checked by joule-ui's own validate, and every error with its source line and reason code. State, queries, operators, conditionals, built-in functions and sequences of actions are parsed and refused not-declarative. A canonical writer turns a tree back into the language; it parses back to the same tree. - joule-ui-core: WidgetSchema::positional, the order a positional call passes props in (SPEC §3); absent from the wire form when undeclared. - joule-ui-interaction: begin_interaction and Pending::finish, the lifecycle's three phases in two steps for a host whose handler is asynchronous (SPEC §6); run_interaction is built on them and seals what it sealed before. - conformance/lang/v1: L1 statements, L2 ingest, L3 streaming, L4 the canonical form, L5 refusals, with an independent Python verifier written from §11. - docs/create.md: the design of Create. Co-Authored-By: Claude Opus 5.5 --- joule-ui/NOTICE | 31 + joule-ui/README.md | 12 + joule-ui/SPEC.md | 379 +++++- joule-ui/conformance/lang/README.md | 34 + joule-ui/conformance/lang/v1/MANIFEST.json | 11 + .../conformance/lang/v1/l1-statements.json | 257 ++++ joule-ui/conformance/lang/v1/l2-ingest.json | 985 ++++++++++++++ joule-ui/conformance/lang/v1/l3-stream.json | 1205 ++++++++++++++++ joule-ui/conformance/lang/v1/l4-write.json | 364 +++++ joule-ui/conformance/lang/v1/l5-refusals.json | 1161 ++++++++++++++++ joule-ui/conformance/lang/v1/library.json | 178 +++ joule-ui/conformance/lang_verify.py | 1207 +++++++++++++++++ joule-ui/docs/create.md | 125 ++ joule-ui/joule-ui-rs/Cargo.lock | 10 + joule-ui/joule-ui-rs/Cargo.toml | 1 + .../crates/joule-ui-core/src/lib.rs | 57 + .../crates/joule-ui-interaction/src/lib.rs | 195 ++- .../crates/joule-ui-lang/Cargo.toml | 21 + .../joule-ui-lang/src/bin/gen_lang_vectors.rs | 17 + .../crates/joule-ui-lang/src/ingest.rs | 643 +++++++++ .../crates/joule-ui-lang/src/lex.rs | 351 +++++ .../crates/joule-ui-lang/src/lib.rs | 329 +++++ .../crates/joule-ui-lang/src/parse.rs | 487 +++++++ .../crates/joule-ui-lang/src/prepare.rs | 208 +++ .../crates/joule-ui-lang/src/tests.rs | 363 +++++ .../crates/joule-ui-lang/src/vectors.rs | 345 +++++ .../crates/joule-ui-lang/src/write.rs | 179 +++ .../crates/joule-ui-lang/tests/pack.rs | 20 + 28 files changed, 9125 insertions(+), 50 deletions(-) create mode 100644 joule-ui/NOTICE create mode 100644 joule-ui/conformance/lang/README.md create mode 100644 joule-ui/conformance/lang/v1/MANIFEST.json create mode 100644 joule-ui/conformance/lang/v1/l1-statements.json create mode 100644 joule-ui/conformance/lang/v1/l2-ingest.json create mode 100644 joule-ui/conformance/lang/v1/l3-stream.json create mode 100644 joule-ui/conformance/lang/v1/l4-write.json create mode 100644 joule-ui/conformance/lang/v1/l5-refusals.json create mode 100644 joule-ui/conformance/lang/v1/library.json create mode 100644 joule-ui/conformance/lang_verify.py create mode 100644 joule-ui/docs/create.md create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/Cargo.toml create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/bin/gen_lang_vectors.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/ingest.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lex.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lib.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/parse.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/prepare.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/tests.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/vectors.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/src/write.rs create mode 100644 joule-ui/joule-ui-rs/crates/joule-ui-lang/tests/pack.rs diff --git a/joule-ui/NOTICE b/joule-ui/NOTICE new file mode 100644 index 000000000..38adc5bcf --- /dev/null +++ b/joule-ui/NOTICE @@ -0,0 +1,31 @@ +joule-ui +======== + +The line language of SPEC.md §11 (joule-ui-rs/crates/joule-ui-lang) takes in +OpenUI Lang 1.0, the language of the open-source OpenUI project +(https://github.com/thesysdev/openui), whose specification is published under the +MIT License. joule-ui implements the language's grammar and streaming rules from +that specification's text (spec/language.md, version 1.0); no source code of the +project is included here. Its copyright and licence notice: + + The MIT License (MIT) + + Copyright (c) 2011-2024 Thesys Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. diff --git a/joule-ui/README.md b/joule-ui/README.md index 2968a2d2a..1c421f5be 100644 --- a/joule-ui/README.md +++ b/joule-ui/README.md @@ -39,6 +39,18 @@ renderer is product surface, owned by the consumer. chain, and the lifecycle are joule-ui's. See [`SPEC.md`](SPEC.md) §5–§7 and `examples/metered_surface.rs` for the full loop. +- `joule-ui-rs/crates/joule-ui-lang/` (**v0.3**) — the line language: a + model writes a surface as a short program, one widget a line + (OpenUI Lang 1.0; see [`NOTICE`](NOTICE)), and joule-ui takes it in as it + streams. After every chunk a snapshot holds the tree as far as it can be + built, each widget validated against the registry, and every error with its + line and reason; a canonical writer turns a tree back into the language. + State, queries, expressions and sequences of actions are refused + `not-declarative`: joule-ui carries no behaviour on the wire. See + [`SPEC.md`](SPEC.md) §11, the pack in [`conformance/lang/`](conformance/lang/) + with its independent verifier, and [`docs/create.md`](docs/create.md), the + design of Create, the page that builds a WAI app as a model describes it. + The surface layer makes joule-ui a peer of the declarative-UI protocols (A2UI, MCP Apps). The interaction layer makes it the only one you can hand to an auditor: a UI whose every interaction is energy-accounted and diff --git a/joule-ui/SPEC.md b/joule-ui/SPEC.md index e5543a4e1..34e3fd224 100644 --- a/joule-ui/SPEC.md +++ b/joule-ui/SPEC.md @@ -4,7 +4,7 @@ |---|---| | **Standard** | joule-ui | | **Status** | Draft for Review | -| **Version** | 0.2.0 | +| **Version** | 0.3.0 | | **Licence** | Specification text CC-BY-4.0 ([`LICENSE-SPEC`](LICENSE-SPEC)); reference code Apache-2.0 ([`LICENSE`](LICENSE)) | | **Reference** | [`joule-ui-rs/`](joule-ui-rs/) | @@ -50,6 +50,13 @@ children it accepts (`None` / `Any` / a named allow-list), and whether unknown props are permitted. The registry is the host's contract; the renderer is the host's product surface and is out of scope for this standard. +A schema MAY declare its **positional order** (`positional`): the order in which +a language that passes arguments by position, such as the line language of §11, +maps a call's arguments to the widget's props, the name `children` standing for +the child list. A schema that declares none has, as its order, its props in key +order followed by `children` when it accepts children. The order is absent from +the wire form when none is declared. + ## §4 Validation `validate(widget, registry)` checks a tree against the registry and returns @@ -130,6 +137,14 @@ interaction: the reference adapters seal such a span as the meter's record of the debit MUST record it with that provenance, never an estimate as a reading. +A host whose handler cannot run inside one call (a browser's handler awaits the +network) runs the same three phases in two steps: `begin_interaction` performs +phase 1 and samples the meter, and gives a **Pending** only when the action is +allowed; `Pending::finish` takes the handler's output and its wall-clock time on +the host's own clock, and performs phases 2 and 3. A denied action gives no +Pending, so no handler can run under it, and the denial is the Interaction phase +1 describes. + The result is an **Interaction**: the surface's content address (what the user was looking at), the action id, the content addresses of the inputs and the outputs (the genesis sentinel when denied), the verdict, the energy, the budget @@ -182,6 +197,9 @@ edge, not required.) still named on the denial. - **C4 — chain integrity.** Seal a sequence; `verify_chain` accepts it and rejects any excision, reorder, or field mutation at the first broken link. +- **L1–L5 — the line language (§11).** Statements, ingest, streaming, the + canonical form and the refusals, from the pack in `conformance/lang/v1/` + (§11.9). ## §9 Recognized edges @@ -201,6 +219,361 @@ A renderer (the host's), a transport (the consumer's), a component library registry, never a renderer). joule-ui pins the protocol and the receipt; the fast-moving surfaces stay the consumer's. +## §11 The line language + +A host MAY take in user interfaces written in the line-per-component language of +the open-source OpenUI project, OpenUI Lang 1.0 (MIT licence; [`NOTICE`](NOTICE)): a +program of statements `name = Component(arg, ...)`, one a line, a name usable before +the line that defines it. This section is how such a program becomes a joule-ui +widget tree (§2) against a registry (§3), checked by the validator of §4, while the +program is still arriving. The reference implementation is +[`joule-ui-rs/crates/joule-ui-lang`](joule-ui-rs/crates/joule-ui-lang/). + +The language's grammar is parsed whole. What joule-ui does not carry (state, +queries and mutations, operators, conditionals, member access, built-in functions, +sequences of actions) is refused with the code `not-declarative` (§11.8): joule-ui +puts no expression and no behaviour on the wire (§2), and the smallest piece that +holds one is dropped. + +A **library** is a registry and, optionally, the name of its **root widget**. Every +widget name of a library starts with an uppercase ASCII letter and holds only ASCII +letters, digits and `_`; none is `Query`, `Mutation` or `Action`; every name of a +widget's positional order (§3) is one of its props, or `children` on a widget that +accepts children, and none appears twice; the root names one of its widgets. + +### §11.1 Source text + +The text is prepared in this order. Each line of the prepared text keeps the number +of the source line it came from (1-based, counted in the text as received), and every +line an error names (§11.8) is that source line. + +1. Every carriage return immediately followed by a line feed is removed. The text is + then a sequence of lines separated by line feeds. +2. **Host marker lines.** A line whose text starts with `]]>openui:` is a marker line. + When the text holds one, the program is only the lines that follow a marker line + whose text after `]]>openui:` starts with `content`, up to the next marker line; + marker lines themselves and every other line are removed. +3. **Fences.** A line whose first three characters are backticks (`` ``` ``) outside a + fence opens a fence. When the text holds one, the program is only the lines inside + fences, joined by line feeds; fence lines and all other lines are removed. Inside a + fence a line whose first three characters are backticks closes it, unless a + double-quoted string is open at its start: each line inside the fence is scanned + for double quotes, a `"` toggling the string open or closed, and inside an open + double-quoted string a backslash makes the next character not count (a line feed + ends that effect). Single quotes do not count here. A fence still open at the end + of the text runs to the end. +4. **Comments.** Outside a string, `//` or `#` starts a comment that runs to the end of + its line; the comment is removed and the line feed kept. A string opens at `"` or + `'` and closes at the next unescaped occurrence of the same quote character, across + line feeds; inside a string a backslash takes the next character with it. +5. **Trimming.** Space, tab, line feed and carriage return are removed from both ends + of the text. Lines removed from the front leave the line numbering too. + +### §11.2 Lexing + +The prepared text is read left to right into tokens. A space or a tab separates +tokens; a line feed is a **newline** token. A character no rule below matches is +skipped: lexing never fails. + +- **Identifiers**: `[A-Za-z_][A-Za-z0-9_]*`; `true`, `false` and `null` are keyword + literals instead. `$` followed by an identifier is a **state name** (the `$` is part + of it); `@` followed by an identifier is an **`@` name**. A `$` or `@` not followed by + an identifier start is skipped. +- **Numbers**: a digit, or a `-` followed by a digit when the previous token is not a + value (a string, a number, usable or not, an identifier, a state name, a keyword + literal, `)` or `]`; `}` is not one), starts a number: an optional `-`, digits, then a `.` followed by + digits only when a digit follows the `.`, then, if an `e` or `E` follows, an exponent: + the marker, an optional `+` or `-`, and digits. An exponent marker with no digit + after it makes the token an **unusable number**. The number's value is the decimal + value of its text, rounded to the nearest binary64. +- **Strings** open at `"` or `'` and run to the next occurrence of the same quote; a + backslash takes the next character with it (a raw line feed is part of the string). + A double-quoted string's content (between the quotes) is decoded as a JSON string + (RFC 8259 §7); if it is not a valid one (an unknown escape, a raw control character, a + `\u` escape that is short or names a lone surrogate) the content is taken exactly as + written, backslashes included. In a single-quoted string, `\n` is a line feed, `\t` a + tab, and a backslash before any other character is dropped and the character kept. + A string still open at the end of the text is closed there: if its content then ends + in an odd number of backslashes, one more backslash is added first; the token is + marked **open**. +- **Punctuation**, longest first: `==` `!=` `>=` `<=` `&&` `||`, then `=` `>` `<` `+` `-` + `*` `/` `%` `!` `?` `:` `.` `,` `(` `)` `[` `]` `{` `}`; a single `&` is `&&` and a + single `|` is `||`. + +### §11.3 Statements + +A statement starts at a token that is an identifier or a state name (its **name**) +followed by `=`. Newline tokens before it are skipped; a line that does not start this +way is skipped up to its next newline token, with no error. The statement's +expression is the tokens after the `=`, newline tokens inside it left out, up to the +newline token that ends it. A newline token ends it when all of these hold: + +1. the bracket depth is 0: each of `(` `[` `{` adds one, and each of `)` `]` `}` takes one + away when the depth is above 0; +2. no ternary is open: at depth 0 a `?` opens one; a `:` at depth 0 moves the innermost + open ternary to its second branch when it is still in its first (otherwise the `:` + changes nothing); a value token at depth + 0 (a string, a number or an unusable number, an identifier, a state name, a keyword + literal, or a closing bracket that brings the depth to 0) gives the innermost open + ternary's current branch an operand, and closes that ternary when the branch is its + second; and +3. the next token that is not a newline is not `?`. + +A statement that reaches the end of the tokens ends there. + +A statement is **complete** when the host has ended the stream, or when its ending +newline has arrived and a token that is not a newline follows it. Otherwise it is +**pending**: only the last statement can be. A statement that reached the end of the +tokens (rather than a newline) while its bracket depth was above 0 or its last token was +an open string is **open**, pending or not. + +A statement's **kind**: `Query(...)` or `Mutation(...)` as its whole expression makes it +a query or a mutation; otherwise a state name makes it a state declaration; otherwise it +is a value statement. + +### §11.4 Expressions + +An open statement is first closed: a closing bracket that does not match the innermost +open bracket is dropped, and the brackets still open are then closed, innermost first. +The statement's tokens are then parsed whole by this grammar; tokens left after the +expression are an error: + +``` +expression = ternary +ternary = or [ "?" ternary ":" ternary ] +or = and { "||" and } +and = equality { "&&" equality } +equality = comparison { ( "==" | "!=" ) comparison } +comparison = additive { ( ">" | "<" | ">=" | "<=" ) additive } +additive = multiplicative { ( "+" | "-" ) multiplicative } +multiplicative = unary { ( "*" | "/" | "%" ) unary } +unary = { "!" | "-" } postfix +postfix = primary { "." key | "[" expression "]" } +primary = string | number | "true" | "false" | "null" + | "[" items "]" | "{" entries "}" | "(" expression ")" + | ComponentName "(" items ")" | AtName "(" items ")" + | StateName "=" expression | StateName | identifier +items = { "," } [ expression { { "," } expression } { "," } ] +entries = { "," } [ key ":" expression { { "," } key ":" expression } { "," } ] +``` + +A `ComponentName` is an identifier with an uppercase first letter followed by `(`; any +other identifier is a **reference**. An unusable number is an error wherever it +stands. A **key** is the next token, whatever it is: an identifier or a string gives +its text, a number its text as written, a state name its text without `$`, a keyword +literal its word, an `@` name its text with `@`; any other token is the key `?`. +Commas between items and entries are optional, and any number of them is ignored. + +A complete statement that does not parse is refused `syntax` (§11.8) at the source +line of the token where parsing stopped (the last token when the tokens ran out, the +statement's own line when it has none), and binds nothing. A pending statement that does not parse is not produced, with no error. + +### §11.5 Into a joule-ui tree + +**Bindings.** Statements are taken in order; each produced statement binds its name, +replacing an earlier binding, except that an open pending statement does not replace a +complete one. + +**Statement refusals.** A complete state declaration, query or mutation is refused +`not-declarative` at its line. A reference to one evaluates to *refused* (below), with +no further error. + +**The entry** is the binding of `root`. With no `root`, it is the first value +statement (by position in the text, among the bindings) whose expression is a call of +the library's root widget. + +**Evaluation** gives each expression a value: data (a joule-ui `PropValue`), null, a +list or an object of values, a widget, an action, *unresolved*, *held*, or *refused*. +Errors go to the statement whose text holds the expression. + +- A string, number or keyword gives `Text`, `Number`, `Bool`, or null. +- A reference to a name with no binding, or to a statement being evaluated already (a + cycle), is *unresolved*, and the name is listed as unresolved. Otherwise it is the + value of the bound statement's expression, evaluated where it is used. +- A list or an object gives a list or an object of its items' values. +- A state name, an operator, a conditional, member access, indexing and an assignment + are refused `not-declarative` at the line where that expression starts (a binary + operation and a conditional start at their left operand, member access and indexing at + their base, a call at its name). +- `Query(...)` or `Mutation(...)` inside an expression is refused `inline-reserved`, at its + line, its arguments not evaluated. +- `@Name(...)` with a built-in function's name (`Count`, `First`, `Last`, `Sum`, `Avg`, + `Min`, `Max`, `Filter`, `Sort`, `Round`, `Abs`, `Floor`, `Ceil`, `Each`) or a state + action's (`Set`, `Reset`, `Run`) is refused `not-declarative`, its arguments not + evaluated. +- `Action([x])` or `@Action([x])` with a list of exactly one item is that item's value + when it is an action (*held* when it is unresolved or held, *refused* when refused, and + otherwise refused `type-mismatch` at the item's line); any other argument list is + refused `not-declarative` at the call's line, unevaluated. +- Any other `@Name(args)` is the action `Action { id: Name, payload }`. Every argument is + evaluated and placed as data (below, null kept); if one was refused the action is + *refused*, else if one is not yet written the action is *held*. The payload is absent + for no argument, the argument for one, and the list of them for two or more. +- A component call is built into a widget, below. + +**Placing as data** (a prop other than `children`, an item of a list or object placed as +data, an action's argument): data and actions are themselves; null is `Null` inside a +list, an object or an action's arguments; a list or an object becomes `List` or +`Object` of its items placed as data, an item that is not yet written or refused being +left out; a widget is refused `type-mismatch` (at the line where the call's or the +action's argument holding it starts); *unresolved* and *held* are **not yet written**; +*refused* stays refused. + +**Building a widget.** A call of a name the registry lacks is refused +`unknown-component` at its line, its arguments not evaluated. Otherwise its arguments +map, in order, to the names of the widget's positional order (§3); arguments past its end +are refused together, `excess-args`, at the first extra one's line, and dropped +unevaluated. Every other argument is evaluated, in order; an error about an argument is +at the line where that argument starts. + +- The argument mapped to `children` must give a list. Its widgets become the widget's + children in order; *unresolved*, *held*, null and *refused* items are left out; any + other item is refused `type-mismatch` and left out. A widget instead of a list, or + data, is refused `type-mismatch` and gives no children; null, *unresolved*, *held* and + *refused* give no children. +- Any other argument is placed as data. Null leaves the prop absent ("null"); a value not + yet written leaves it absent ("not yet"); a refused one, or a widget (refused + `type-mismatch`), leaves it absent ("refused"). + +The widget is then checked by `validate` (§4). While the stream is open, a widget that +`validate` finds missing a required prop that is absent "not yet" is *held*, with no +error. Otherwise every error at the widget itself is resolved as below and reported; +then, if one of them drops the widget, it is *refused*; else the removals are made and +the widget is checked again, until it passes: + +| `validate` error | Resolution | +|---|---| +| a required prop missing | absent "not yet" (the stream has ended): `missing-required`. Absent "null": `null-required`. Absent "refused": no further error. Otherwise `missing-required`. Each drops the widget. | +| a prop of the wrong type | `type-mismatch`; a required prop drops the widget, an optional one is removed | +| a prop the widget does not declare | `type-mismatch`; the prop is removed | +| a child the widget does not accept | `disallowed-child`, at the child's own line and statement; the child is removed | + +An error the table names is reported at the line of the widget's call, unless it says +otherwise. A widget that passes is the value, its children already built and checked. + +**The tree** is the entry's value when that is a widget, and there is none otherwise. +Each widget of the tree has a **key**: a widget that is a statement's value takes the +statement's name, `name#2` for the second use of that statement in the evaluation and so +on; any other widget takes its parent value's key followed by `/` and its position +(`root/0`; `{key}/{slot}` for a widget in a prop's slot). The **nodes** list each +widget of the tree in pre-order with its key, the source line of its call, and its +statement. + +**Orphans** are the value statements the evaluation of the entry did not reach (none +when there is no entry). The unresolved names and the orphans are reported sorted. + +**At the end of the stream:** with no entry, the error is `parse-failed` (line 0, no +statement) when no statement was produced and the prepared text is not empty, and +`no-root` (line 0, no statement) otherwise; with an entry +that gives no widget, `no-root` at the entry's line; with an entry found by the root +widget rather than `root`, `no-root` as a warning at its line, and the tree renders. + +**Errors** are reported sorted by line, then statement, then code (as text), then +widget, then prop (an absent widget or prop before any), then warning (not one before +one), and of the errors equal in all of these only the first is kept. While the stream +is open, errors of a statement whose binding is pending are withheld. + +A snapshot MUST NOT hold a widget its registry's `validate` refuses: the tree as a whole +passes `validate`. + +### §11.6 Streaming + +A host passes the text a chunk at a time; after each chunk, and when the host ends the +stream, the ingest gives a **snapshot**: the tree, its nodes, the errors, the unresolved +names, the orphans, and whether the text ended inside an open string or bracket +(**incomplete**: the last statement is open). A chunk after the end starts a new stream. + +1. Each snapshot is that of §11.1–§11.5 for all the text so far, statements completing + as §11.3 says. A complete statement's text no longer changes, so its value only + changes as the statements it refers to arrive. +2. The pending statement is closed for the moment (§11.2 strings, §11.4 brackets) and + evaluated; its errors are withheld until it completes. +3. An open pending statement never replaces a complete one of the same name (§11.5). +4. Text that changes what came before it (a fence line arriving after unfenced lines) + changes the preparation, and so every later snapshot. +5. **Streaming equals parsing.** The snapshot at the end of a stream is the snapshot of + the whole text taken at once, however it was cut. + +### §11.7 The canonical form + +A tree that passes `validate` is written one statement a widget, in pre-order, each on +its own line ending with a line feed: the root is `root`; every other widget is named +by its widget name with its first letter in lowercase, followed by a count of that +lowercase name from 1, in pre-order (`card1`, `card2`). A statement is +`name = Widget(args)`, the arguments joined by `, `: one for each name of the widget's +positional order, `children` written `[name1, name2]` when the widget has children; a +missing prop or empty `children` is `null`, and the trailing ones are left off. Values: + +- text as a JSON string: `"` and `\` escaped, `\b` `\f` `\n` `\r` `\t` for those, `\u00xx` + (lowercase) for the other characters below U+0020, everything else as itself; +- a number as the shortest decimal that reads back to the same binary64 value, in + positional notation, with no exponent and no fraction for an integer (`-0` for + negative zero); a number that is not finite cannot be written; +- `true`, `false`, `null`, and lists as `[a, b]`; +- an object as `{key: value}` in key order (`, ` between entries), a key written bare + when it is an identifier other than a keyword literal, and as a JSON string otherwise; +- an action as `@Id(...)`: nothing inside for no payload, the items of a list payload of + two or more, and the payload as the single argument otherwise. The id must be an + identifier other than `Action`, a built-in function's or a state action's name. + +A tree holding a widget whose name a call cannot take, a prop outside its widget's +positional order, a prop whose value is `null` (a null argument is an absent one), or an +action id the form cannot carry, is not written. The canonical form of a tree parses +back (§11.5) to the same tree, and writing that tree again gives the same text. + +### §11.8 Reason codes + +| Code | Raised when | What is dropped | +|---|---|---| +| `unknown-component` | a call names a widget the registry lacks | the widget | +| `missing-required` | a required prop is absent (once the stream has ended, for one not yet written) | the widget | +| `null-required` | a required prop is written `null` | the widget | +| `type-mismatch` | a value is not of its prop's type; a widget stands where data goes, or data among children; children not written as a list | an optional prop or an item; a required prop drops the widget | +| `excess-args` | more arguments than the widget's positional order | the extra arguments | +| `disallowed-child` | a child the parent's `AllowedChildren` does not accept | the child | +| `not-declarative` | state, a query, a mutation, an operator, a conditional, member access, indexing, an assignment, a built-in function, a state action, or a sequence of actions | the statement, or the value | +| `inline-reserved` | `Query` or `Mutation` inside an expression | the value | +| `syntax` | a complete statement that does not parse | the statement | +| `no-root` | at the end of the stream, no entry or an entry that is not a widget; a warning when the entry is found by the root widget | everything (not for the warning) | +| `parse-failed` | at the end of the stream, text with no statement | everything | + +An error names its source line (0 for the program as a whole), its statement (empty for +the whole program), its code, the widget and the prop it concerns where there is one, +and a message for a person or a model to act on. Messages are not normative. + +The widget an error names: for `unknown-component`, `missing-required`, +`null-required`, `excess-args`, and a `type-mismatch` that `validate` raised or that is +about a call's own argument (a widget in a prop's slot, the `children` argument or an +item of it), the called widget; for `disallowed-child`, the child; for a `type-mismatch` +about a widget placed as data inside a list, an object or an action's arguments, that +widget. No other error names a widget. The prop an error names: for `missing-required`, +`null-required`, and a `type-mismatch` that names the called widget, the prop +(`children` for the children argument); no other error names one. + +### §11.9 Conformance + +The pack in [`conformance/lang/v1/`](conformance/lang/v1/) holds the library it is +written against (`library.json`, each widget's positional order written out) and five +classes; a conforming ingest reproduces every case's expected value, errors compared +without their messages: + +- **L1, statements** (`l1-statements.json`): the statements of each text at the end of the + stream, in order (name, source line, kind, complete): each produced statement with its + kind (`value`, `state`, `query`, `mutation`), and each complete statement that did not + parse with the kind `syntax`; and whether it ended open. +- **L2, ingest** (`l2-ingest.json`): each text's snapshot at the end of the stream. +- **L3, streaming** (`l3-stream.json`): each case's snapshot after every chunk and at + the end, which equals L2's snapshot of the joined chunks. +- **L4, the canonical form** (`l4-write.json`): each tree's text, which parses back to + the tree. +- **L5, refusals** (`l5-refusals.json`): negative controls, each refused with its code at + its line, and its whole snapshot. + +`MANIFEST.json` names each file by its SHA-256. The independent verifier +[`conformance/lang_verify.py`](conformance/lang_verify.py) is written from this section +alone and shares no code with the reference implementation. + --- -*joule-ui v0.2.0 — the surface layer (v0.1) plus the metered, sealed -interaction layer that makes a UI accountable by construction.* +*joule-ui v0.3.0 — the surface layer (v0.1), the metered, sealed interaction layer +that makes a UI accountable by construction (v0.2), and the line language a model +writes a surface in, taken in as it streams (v0.3).* diff --git a/joule-ui/conformance/lang/README.md b/joule-ui/conformance/lang/README.md new file mode 100644 index 000000000..1f6bd8a66 --- /dev/null +++ b/joule-ui/conformance/lang/README.md @@ -0,0 +1,34 @@ +# joule-ui line language conformance — v1 + +The pack of SPEC §11.9: the line language (OpenUI Lang 1.0, taken in as joule-ui +widget trees) against the pack's own library. Regenerate with: + +``` +cargo run -p joule-ui-lang --bin gen_lang_vectors +``` + +and verify, from the specification alone, with the independent verifier: + +``` +python3 -I conformance/lang_verify.py # every case recomputed and compared +python3 -I conformance/lang_verify.py --mutations # changed vectors are refused +``` + +`cargo test -p joule-ui-lang --test pack` checks that the committed files are exactly +what the reference implementation gives. + +| File | Class | What a conforming ingest reproduces | +|---|---|---| +| `library.json` | — | the library: each widget's props, children rule and positional order (written out), and the root widget | +| `l1-statements.json` | **L1** | each text's statements (name, source line, kind, complete) and whether it ended open | +| `l2-ingest.json` | **L2** | each text's snapshot at the end of the stream: the tree, its nodes (key, line, statement), the errors (line, statement, code, widget, prop, warning), the unresolved names, the orphans, whether it ended open | +| `l3-stream.json` | **L3** | each case's snapshot after every chunk and at the end; the end equals the batch parse of the joined chunks | +| `l4-write.json` | **L4** | each tree's canonical text, which parses back to the tree | +| `l5-refusals.json` | **L5** | negative controls: each refused with its code at its line, and its whole snapshot | +| `MANIFEST.json` | — | each file's SHA-256 | + +The tree and its values are in joule-ui's wire form (§2): a widget is +`{"name", "props", "children"}` (`children` absent when empty); a prop value is +`{"type": "text" | "number" | "bool" | "list" | "object", "value": ...}`, `{"type": "null"}`, +or `{"type": "action", "value": {"id", "payload"}}` with `payload` null or a prop value. +Errors carry no message: messages are for a person or a model, and not normative. diff --git a/joule-ui/conformance/lang/v1/MANIFEST.json b/joule-ui/conformance/lang/v1/MANIFEST.json new file mode 100644 index 000000000..61ad49d73 --- /dev/null +++ b/joule-ui/conformance/lang/v1/MANIFEST.json @@ -0,0 +1,11 @@ +{ + "files": { + "l1-statements.json": "sha256:4f8ab770076dfccc30b3d83b36b7abf3100b96ed170435ebfd60c62b9370cd32", + "l2-ingest.json": "sha256:f308252782180452fc469548f62b60fe94ea0195bcbaf5512c33926a5ecc788d", + "l3-stream.json": "sha256:99ce046d1d91cefac04c0176b3d9f251b2065fbebf71e828b05115d62ad7555d", + "l4-write.json": "sha256:121d8aa3d6b3c5922d595b12ff16e2893ce4b151c7b2f4f32afbf73cfb96515f", + "l5-refusals.json": "sha256:b69fba8400e62438e057cccae1c7bbbbab9432260c226b1ae1057ebda8aca290", + "library.json": "sha256:deb301ce3984d8441a828c4e710d64d01c024e3b74f8a19c69bf2ce11ce76546" + }, + "pack": "joule-ui-lang/v1" +} diff --git a/joule-ui/conformance/lang/v1/l1-statements.json b/joule-ui/conformance/lang/v1/l1-statements.json new file mode 100644 index 000000000..eabe60cb7 --- /dev/null +++ b/joule-ui/conformance/lang/v1/l1-statements.json @@ -0,0 +1,257 @@ +{ + "cases": [ + { + "incomplete": false, + "input": "root = Page([a])\na = Text(\"hi\")\n", + "name": "one-a-line", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 2, + "name": "a" + } + ] + }, + { + "incomplete": false, + "input": "\n\nhello there\nroot = Page()\n= 3\n \nx = 1\n", + "name": "blank-lines-and-prose-lines", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 4, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 7, + "name": "x" + } + ] + }, + { + "incomplete": false, + "input": "root = Page([\n a,\n b\n])\na = Text(\"a\")\nb = Text(\"b\")", + "name": "brackets-carry-over-lines", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 5, + "name": "a" + }, + { + "complete": true, + "kind": "value", + "line": 6, + "name": "b" + } + ] + }, + { + "incomplete": false, + "input": "a = Text(\"line one\nline two\")\nroot = Page([a])", + "name": "string-carries-over-lines", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "a" + }, + { + "complete": true, + "kind": "value", + "line": 3, + "name": "root" + } + ] + }, + { + "incomplete": false, + "input": "x = c\n ? 1\n : 2\nroot = Page()", + "name": "ternary-continues-on-a-question-line", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "x" + }, + { + "complete": true, + "kind": "value", + "line": 4, + "name": "root" + } + ] + }, + { + "incomplete": false, + "input": "x = c ? 1 :\n 2\nroot = Page()", + "name": "ternary-empty-branch-continues", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "x" + }, + { + "complete": true, + "kind": "value", + "line": 3, + "name": "root" + } + ] + }, + { + "incomplete": false, + "input": "$s = 1\nq = Query(\"t\", {}, {})\nm = Mutation(\"u\", {})\n$w = Query(\"t\", {}, {})\nroot = Page()", + "name": "state-query-mutation-kinds", + "statements": [ + { + "complete": true, + "kind": "state", + "line": 1, + "name": "$s" + }, + { + "complete": true, + "kind": "query", + "line": 2, + "name": "q" + }, + { + "complete": true, + "kind": "mutation", + "line": 3, + "name": "m" + }, + { + "complete": true, + "kind": "query", + "line": 4, + "name": "$w" + }, + { + "complete": true, + "kind": "value", + "line": 5, + "name": "root" + } + ] + }, + { + "incomplete": false, + "input": "root = Page([a])\na = Text(\"x\" 1 +)\nb = 1 2\n", + "name": "syntax-error-is-a-statement-of-its-own", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "root" + }, + { + "complete": true, + "kind": "syntax", + "line": 2, + "name": "a" + }, + { + "complete": true, + "kind": "syntax", + "line": 3, + "name": "b" + } + ] + }, + { + "incomplete": false, + "input": "Here it is:\n```openui-lang\nroot = Page([a])\n```\nand a note\n```\na = Text(\"in a second fence\")\n```\n", + "name": "fenced-with-prose", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 3, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 7, + "name": "a" + } + ] + }, + { + "incomplete": false, + "input": "root = Page([a]) // the page\n# a heading comment\na = Text(\"#not a comment // nor this\")\n", + "name": "comments", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 1, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 3, + "name": "a" + } + ] + }, + { + "incomplete": false, + "input": "]]>openui:content?library=pack%401\nroot = Page([a])\na = Text(\"x\")\n]]>openui:context\n{\"formState\": {}}\n]]>openui:end\n", + "name": "markers", + "statements": [ + { + "complete": true, + "kind": "value", + "line": 2, + "name": "root" + }, + { + "complete": true, + "kind": "value", + "line": 3, + "name": "a" + } + ] + }, + { + "incomplete": true, + "input": "root = Page([a\na = Text(\"open", + "name": "unterminated-at-end", + "statements": [ + { + "complete": true, + "kind": "syntax", + "line": 1, + "name": "root" + } + ] + } + ], + "class": "L1" +} diff --git a/joule-ui/conformance/lang/v1/l2-ingest.json b/joule-ui/conformance/lang/v1/l2-ingest.json new file mode 100644 index 000000000..82024cca4 --- /dev/null +++ b/joule-ui/conformance/lang/v1/l2-ingest.json @@ -0,0 +1,985 @@ +{ + "cases": [ + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "card", + "line": 2, + "statement": "card" + }, + { + "key": "t1", + "line": 3, + "statement": "t1" + }, + { + "key": "t2", + "line": 4, + "statement": "t2" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "one" + } + } + }, + { + "name": "Text", + "props": { + "size": { + "type": "number", + "value": 14.0 + }, + "text": { + "type": "text", + "value": "two" + } + } + } + ], + "name": "Card", + "props": { + "subtitle": { + "type": "text", + "value": "sub" + }, + "title": { + "type": "text", + "value": "Hello" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + }, + "unresolved": [] + }, + "input": "root = Page([card], \"Home\")\ncard = Card(\"Hello\", [t1, t2], \"sub\")\nt1 = Text(\"one\")\nt2 = Text(\"two\", 14)\n", + "name": "hoisting" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "a" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [ + "missing" + ] + }, + "input": "root = Page([a, missing, b])\na = Text(\"a\")\nb = Text(\"b\")\n", + "name": "unresolved-left-out" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "old" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "a" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"a\")\nold = Text(\"old\")\n", + "name": "orphans" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "root/0", + "line": 1, + "statement": "root" + }, + { + "key": "root/0/0", + "line": 1, + "statement": "root" + }, + { + "key": "root/0/1", + "line": 1, + "statement": "root" + }, + { + "key": "root/1", + "line": 1, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "x" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "y" + } + } + } + ], + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "c" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "z" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([Card(\"c\", [Text(\"x\"), Text(\"y\")]), Text(\"z\")])\n", + "name": "inline-calls-and-keys" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "t", + "line": 2, + "statement": "t" + }, + { + "key": "t#2", + "line": 2, + "statement": "t" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "again" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "again" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([t, t])\nt = Text(\"again\")\n", + "name": "used-twice" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "subtitle": { + "type": "text", + "value": "sub" + }, + "title": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([c])\nc = Card(\"t\", null, \"sub\")\n", + "name": "null-skips-an-optional" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "ch", + "line": 2, + "statement": "ch" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Chart", + "props": { + "labels": { + "type": "list", + "value": [ + { + "type": "text", + "value": "a" + }, + { + "type": "text", + "value": "b" + } + ] + }, + "options": { + "type": "object", + "value": { + "colours": { + "type": "list", + "value": [ + { + "type": "text", + "value": "red" + }, + { + "type": "text", + "value": "blue" + } + ] + }, + "max value": { + "type": "null" + }, + "stacked": { + "type": "bool", + "value": true + } + } + }, + "values": { + "type": "list", + "value": [ + { + "type": "number", + "value": 1.0 + }, + { + "type": "number", + "value": -2.5 + }, + { + "type": "number", + "value": 300.0 + } + ] + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([ch])\nch = Chart([\"a\", \"b\"], [1, -2.5, 3e2], {stacked: true, \"max value\": null, colours: ['red', \"blue\"]})\n", + "name": "data-props" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "b1", + "line": 2, + "statement": "b1" + }, + { + "key": "b2", + "line": 3, + "statement": "b2" + }, + { + "key": "b3", + "line": 4, + "statement": "b3" + }, + { + "key": "b4", + "line": 5, + "statement": "b4" + }, + { + "key": "b5", + "line": 6, + "statement": "b5" + }, + { + "key": "b6", + "line": 7, + "statement": "b6" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Go", + "payload": null + } + }, + "label": { + "type": "text", + "value": "none" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Open", + "payload": { + "type": "text", + "value": "x" + } + } + }, + "label": { + "type": "text", + "value": "one" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Send", + "payload": { + "type": "list", + "value": [ + { + "type": "text", + "value": "a" + }, + { + "type": "number", + "value": 2.0 + } + ] + } + } + }, + "label": { + "type": "text", + "value": "two" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Pick", + "payload": { + "type": "list", + "value": [ + { + "type": "number", + "value": 1.0 + }, + { + "type": "number", + "value": 2.0 + } + ] + } + } + }, + "label": { + "type": "text", + "value": "list" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Go", + "payload": null + } + }, + "label": { + "type": "text", + "value": "wrapped" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "ToAssistant", + "payload": { + "type": "list", + "value": [ + { + "type": "text", + "value": "hi" + }, + { + "type": "object", + "value": { + "id": { + "type": "number", + "value": 1.0 + } + } + } + ] + } + } + }, + "label": { + "type": "text", + "value": "by name" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([b1, b2, b3, b4, b5, b6])\nb1 = Button(\"none\", @Go())\nb2 = Button(\"one\", @Open(\"x\"))\nb3 = Button(\"two\", @Send(\"a\", 2))\nb4 = Button(\"list\", @Pick([1, 2]))\nb5 = Button(\"wrapped\", Action([@Go()]))\nb6 = Button(\"by name\", go)\ngo = @ToAssistant(\"hi\", {id: 1})\n", + "name": "actions" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "tabs", + "line": 2, + "statement": "tabs" + }, + { + "key": "t1", + "line": 3, + "statement": "t1" + }, + { + "key": "t1/0", + "line": 3, + "statement": "t1" + }, + { + "key": "t2", + "line": 4, + "statement": "t2" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "1" + } + } + } + ], + "name": "Tab", + "props": { + "title": { + "type": "text", + "value": "One" + } + } + }, + { + "name": "Tab", + "props": { + "title": { + "type": "text", + "value": "Two" + } + } + } + ], + "name": "Tabs", + "props": {} + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([tabs])\ntabs = Tabs([t1, t2])\nt1 = Tab(\"One\", [Text(\"1\")])\nt2 = Tab(\"Two\")\n", + "name": "only-of-children" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "n", + "line": 2, + "statement": "n" + }, + { + "key": "n/0", + "line": 2, + "statement": "n" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "inside" + } + } + } + ], + "name": "Note", + "props": { + "author": { + "type": "text", + "value": "by" + }, + "body": { + "type": "text", + "value": "the body" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([n])\nn = Note(\"by\", \"the body\", [Text(\"inside\")])\n", + "name": "derived-order" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 3, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "second" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"first\")\na = Text(\"second\")\n", + "name": "duplicate-later-wins" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "a" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [ + "a" + ] + }, + "input": "root = Page([a])\na = Card(\"a\", [b])\nb = Card(\"b\", [a])\n", + "name": "cycle" + }, + { + "expected": { + "errors": [ + { + "code": "no-root", + "line": 2, + "statement": "page", + "warning": true + } + ], + "incomplete": false, + "nodes": [ + { + "key": "page", + "line": 2, + "statement": "page" + }, + { + "key": "t", + "line": 1, + "statement": "t" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "x" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "t = Text(\"x\")\npage = Page([t])\n", + "name": "fallback-entry" + }, + { + "expected": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + }, + { + "key": "c", + "line": 4, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "tab\there é" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "it's" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "bad \\q escape" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b, c])\na = Text(\"tab\\there \\u00e9\")\nb = Text('it\\'s')\nc = Text(\"bad \\q escape\")\n", + "name": "escapes" + }, + { + "expected": { + "errors": [ + { + "code": "no-root", + "line": 0, + "statement": "" + } + ], + "incomplete": false, + "nodes": [], + "orphans": [], + "tree": null, + "unresolved": [] + }, + "input": "", + "name": "empty" + } + ], + "class": "L2" +} diff --git a/joule-ui/conformance/lang/v1/l3-stream.json b/joule-ui/conformance/lang/v1/l3-stream.json new file mode 100644 index 000000000..a883dd5e8 --- /dev/null +++ b/joule-ui/conformance/lang/v1/l3-stream.json @@ -0,0 +1,1205 @@ +{ + "cases": [ + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Ho" + } + } + }, + "unresolved": [ + "card" + ] + }, + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "card", + "line": 2, + "statement": "card" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "Hel" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "card", + "line": 2, + "statement": "card" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "Hello" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + }, + "unresolved": [ + "Te", + "t2" + ] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "card", + "line": 2, + "statement": "card" + }, + { + "key": "t1", + "line": 3, + "statement": "t1" + }, + { + "key": "t2", + "line": 4, + "statement": "t2" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "one" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "two" + } + } + } + ], + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "Hello" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "card", + "line": 2, + "statement": "card" + }, + { + "key": "t1", + "line": 3, + "statement": "t1" + }, + { + "key": "t2", + "line": 4, + "statement": "t2" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "one" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "two" + } + } + } + ], + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "Hello" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([card], \"Ho", + "me\")\ncard = Card(\"Hel", + "lo\", [t1, t2])\nt1 = Te", + "xt(\"one\")\nt2 = Text(\"two\")\n" + ], + "name": "worked-example" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "A" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "A long" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "A long title" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "A long title" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([c])\nc = Card(\"A", + " long", + " title\")" + ], + "name": "pending-title-grows" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "c", + "line": 2, + "statement": "c" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([c])\nc = Card(", + "\"t\")\n" + ], + "name": "held-until-required-arrives" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "ok" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [ + { + "code": "type-mismatch", + "component": "Text", + "line": 3, + "prop": "text", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "c" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "ok" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [ + { + "code": "type-mismatch", + "component": "Text", + "line": 3, + "prop": "text", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "c" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "ok" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a, b])\na = Text(\"ok\")\nb = Text(4", + "2)\nc = 1" + ], + "name": "errors-wait-for-completion" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "x" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "done" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 4, + "statement": "a" + } + ], + "orphans": [ + "x" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "partial" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 4, + "statement": "a" + } + ], + "orphans": [ + "x" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "partial" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a])\na = Text(\"done\")\nx = 1\na = Text(\"partial", + "\")\n" + ], + "name": "pending-does-not-overwrite" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "x" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "x", + "y" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [ + "x", + "y" + ], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a])\na = Text(\"t\")\nx = c\n", + "? 1 : 2\ny = 3" + ], + "name": "ternary-across-chunks" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "x" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 4, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 4, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a])\na = Text(\"x\")\n", + "```\nroot = Page()\n```" + ], + "name": "fence-arrives-late" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": true, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "back\\" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "back\\slash" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "back\\slash" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a])\na = Text(\"back\\", + "slash\")" + ], + "name": "open-string-mid-escape" + }, + { + "after_each": [ + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "b", + "line": 2, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [ + "Te" + ] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "b", + "line": 2, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [ + "Tex" + ] + }, + { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 3, + "statement": "a" + }, + { + "key": "b", + "line": 2, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "a" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + } + ], + "at_end": { + "errors": [], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 3, + "statement": "a" + }, + { + "key": "b", + "line": 2, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "a" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "chunks": [ + "root = Page([a, b])\nb = Text(\"b\")\na = Te", + "x", + "t(\"a\")\n" + ], + "name": "unknown-then-known" + } + ], + "class": "L3" +} diff --git a/joule-ui/conformance/lang/v1/l4-write.json b/joule-ui/conformance/lang/v1/l4-write.json new file mode 100644 index 000000000..0473f403b --- /dev/null +++ b/joule-ui/conformance/lang/v1/l4-write.json @@ -0,0 +1,364 @@ +{ + "cases": [ + { + "name": "page-of-cards", + "text": "root = Page([card1, card2], \"Home\")\ncard1 = Card(\"One\", [text1])\ntext1 = Text(\"a\")\ncard2 = Card(\"Two\", null, \"sub\")\n", + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "a" + } + } + } + ], + "name": "Card", + "props": { + "title": { + "type": "text", + "value": "One" + } + } + }, + { + "name": "Card", + "props": { + "subtitle": { + "type": "text", + "value": "sub" + }, + "title": { + "type": "text", + "value": "Two" + } + } + } + ], + "name": "Page", + "props": { + "title": { + "type": "text", + "value": "Home" + } + } + } + }, + { + "name": "middle-absent-is-null", + "text": "root = Page([card1])\ncard1 = Card(\"t\", null, \"s\")\n", + "tree": { + "children": [ + { + "name": "Card", + "props": { + "subtitle": { + "type": "text", + "value": "s" + }, + "title": { + "type": "text", + "value": "t" + } + } + } + ], + "name": "Page", + "props": {} + } + }, + { + "name": "numbers-and-strings", + "text": "root = Page([text1, text2, text3])\ntext1 = Text(\"quote \\\" back \\\\ line\\nfeed é \\u0001\", 0.1)\ntext2 = Text(\"neg\", -0)\ntext3 = Text(\"small\", 0.0000001)\n", + "tree": { + "children": [ + { + "name": "Text", + "props": { + "size": { + "type": "number", + "value": 0.1 + }, + "text": { + "type": "text", + "value": "quote \" back \\ line\nfeed é \u0001" + } + } + }, + { + "name": "Text", + "props": { + "size": { + "type": "number", + "value": -0.0 + }, + "text": { + "type": "text", + "value": "neg" + } + } + }, + { + "name": "Text", + "props": { + "size": { + "type": "number", + "value": 1e-7 + }, + "text": { + "type": "text", + "value": "small" + } + } + } + ], + "name": "Page", + "props": {} + } + }, + { + "name": "data", + "text": "root = Page([chart1])\nchart1 = Chart([\"a\", null], [1, []], {\"max value\": 1000000000000000000000, \"null\": null, stacked: true})\n", + "tree": { + "children": [ + { + "name": "Chart", + "props": { + "labels": { + "type": "list", + "value": [ + { + "type": "text", + "value": "a" + }, + { + "type": "null" + } + ] + }, + "options": { + "type": "object", + "value": { + "max value": { + "type": "number", + "value": 1e+21 + }, + "null": { + "type": "null" + }, + "stacked": { + "type": "bool", + "value": true + } + } + }, + "values": { + "type": "list", + "value": [ + { + "type": "number", + "value": 1.0 + }, + { + "type": "list", + "value": [] + } + ] + } + } + } + ], + "name": "Page", + "props": {} + } + }, + { + "name": "actions", + "text": "root = Page([button1, button2, button3, button4, button5])\nbutton1 = Button(\"none\", @Go())\nbutton2 = Button(\"one\", @Open(\"x\"))\nbutton3 = Button(\"two\", @Send(\"a\", 2))\nbutton4 = Button(\"list of one\", @Pick([1]))\nbutton5 = Button(\"null\", @Clear(null), true)\n", + "tree": { + "children": [ + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Go", + "payload": null + } + }, + "label": { + "type": "text", + "value": "none" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Open", + "payload": { + "type": "text", + "value": "x" + } + } + }, + "label": { + "type": "text", + "value": "one" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Send", + "payload": { + "type": "list", + "value": [ + { + "type": "text", + "value": "a" + }, + { + "type": "number", + "value": 2.0 + } + ] + } + } + }, + "label": { + "type": "text", + "value": "two" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Pick", + "payload": { + "type": "list", + "value": [ + { + "type": "number", + "value": 1.0 + } + ] + } + } + }, + "label": { + "type": "text", + "value": "list of one" + } + } + }, + { + "name": "Button", + "props": { + "action": { + "type": "action", + "value": { + "id": "Clear", + "payload": { + "type": "null" + } + } + }, + "label": { + "type": "text", + "value": "null" + }, + "primary": { + "type": "bool", + "value": true + } + } + } + ], + "name": "Page", + "props": {} + } + }, + { + "name": "derived-order", + "text": "root = Page([note1])\nnote1 = Note(null, \"b\", [text1])\ntext1 = Text(\"in\")\n", + "tree": { + "children": [ + { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "in" + } + } + } + ], + "name": "Note", + "props": { + "body": { + "type": "text", + "value": "b" + } + } + } + ], + "name": "Page", + "props": {} + } + }, + { + "name": "tabs", + "text": "root = Page([tabs1])\ntabs1 = Tabs([tab1, tab2])\ntab1 = Tab(\"x\")\ntab2 = Tab(\"y\")\n", + "tree": { + "children": [ + { + "children": [ + { + "name": "Tab", + "props": { + "title": { + "type": "text", + "value": "x" + } + } + }, + { + "name": "Tab", + "props": { + "title": { + "type": "text", + "value": "y" + } + } + } + ], + "name": "Tabs", + "props": {} + } + ], + "name": "Page", + "props": {} + } + } + ], + "class": "L4" +} diff --git a/joule-ui/conformance/lang/v1/l5-refusals.json b/joule-ui/conformance/lang/v1/l5-refusals.json new file mode 100644 index 000000000..04716085d --- /dev/null +++ b/joule-ui/conformance/lang/v1/l5-refusals.json @@ -0,0 +1,1161 @@ +{ + "cases": [ + { + "code": "unknown-component", + "expected": { + "errors": [ + { + "code": "unknown-component", + "component": "Chartt", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Chartt([1], [2])\n", + "line": 3, + "name": "unknown-component" + }, + { + "code": "unknown-component", + "expected": { + "errors": [ + { + "code": "unknown-component", + "component": "Graph", + "line": 3, + "statement": "root" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "root/0", + "line": 2, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([\n Text(\"kept\"),\n Graph(1)\n])\n", + "line": 3, + "name": "unknown-component-inline" + }, + { + "code": "missing-required", + "expected": { + "errors": [ + { + "code": "missing-required", + "component": "Card", + "line": 3, + "prop": "title", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Card()\n", + "line": 3, + "name": "missing-required" + }, + { + "code": "missing-required", + "expected": { + "errors": [ + { + "code": "missing-required", + "component": "Text", + "line": 3, + "prop": "text", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [ + "words" + ] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(words)\n", + "line": 3, + "name": "missing-required-never-written" + }, + { + "code": "null-required", + "expected": { + "errors": [ + { + "code": "null-required", + "component": "Text", + "line": 3, + "prop": "text", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(null)\n", + "line": 3, + "name": "null-required" + }, + { + "code": "type-mismatch", + "expected": { + "errors": [ + { + "code": "type-mismatch", + "component": "Text", + "line": 3, + "prop": "text", + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(42)\n", + "line": 3, + "name": "type-mismatch-required" + }, + { + "code": "type-mismatch", + "expected": { + "errors": [ + { + "code": "type-mismatch", + "component": "Text", + "line": 2, + "prop": "size", + "statement": "a" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"kept\", \"big\")\n", + "line": 2, + "name": "type-mismatch-optional-left-out" + }, + { + "code": "type-mismatch", + "expected": { + "errors": [ + { + "code": "type-mismatch", + "component": "Card", + "line": 2, + "prop": "title", + "statement": "a" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Card(Text(\"no\"))\n", + "line": 2, + "name": "type-mismatch-widget-as-data" + }, + { + "code": "type-mismatch", + "expected": { + "errors": [ + { + "code": "type-mismatch", + "component": "Page", + "line": 1, + "prop": "children", + "statement": "root" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + } + ], + "orphans": [], + "tree": { + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page(a)\na = Text(\"no list\")\n", + "line": 1, + "name": "type-mismatch-bare-child" + }, + { + "code": "type-mismatch", + "expected": { + "errors": [ + { + "code": "type-mismatch", + "component": "Page", + "line": 1, + "prop": "children", + "statement": "root" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, \"text\"])\na = Text(\"kept\")\n", + "line": 1, + "name": "type-mismatch-data-child" + }, + { + "code": "excess-args", + "expected": { + "errors": [ + { + "code": "excess-args", + "component": "Text", + "line": 2, + "statement": "a" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "size": { + "type": "number", + "value": 12.0 + }, + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"kept\", 12, \"extra\")\n", + "line": 2, + "name": "excess-args" + }, + { + "code": "disallowed-child", + "expected": { + "errors": [ + { + "code": "disallowed-child", + "component": "Text", + "line": 4, + "statement": "x" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "tabs", + "line": 2, + "statement": "tabs" + }, + { + "key": "t1", + "line": 3, + "statement": "t1" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "children": [ + { + "name": "Tab", + "props": { + "title": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Tabs", + "props": {} + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([tabs])\ntabs = Tabs([t1, x])\nt1 = Tab(\"kept\")\nx = Text(\"not a tab\")\n", + "line": 4, + "name": "disallowed-child" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "$count" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"kept\")\n$count = 0\n", + "line": 3, + "name": "state-statement" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text($name)\n", + "line": 3, + "name": "state-in-a-prop" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "rows" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"kept\")\nrows = Query(\"list\", {}, {rows: []})\n", + "line": 3, + "name": "query-statement" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"a\" + \"b\")\n", + "line": 3, + "name": "operator" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(x\n ? \"y\"\n : \"z\")\n", + "line": 3, + "name": "conditional" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(data.title)\n", + "line": 3, + "name": "member-access" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "n" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"n\", @Count([1, 2]))\n", + "line": 3, + "name": "builtin-function" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + }, + { + "name": "Button", + "props": { + "label": { + "type": "text", + "value": "x" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Button(\"x\", @Set($s, 1))\n", + "line": 3, + "name": "state-action" + }, + { + "code": "not-declarative", + "expected": { + "errors": [ + { + "code": "not-declarative", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + }, + { + "name": "Button", + "props": { + "label": { + "type": "text", + "value": "x" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Button(\"x\", Action([@A(), @B()]))\n", + "line": 3, + "name": "action-sequence" + }, + { + "code": "inline-reserved", + "expected": { + "errors": [ + { + "code": "inline-reserved", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + }, + { + "key": "b", + "line": 3, + "statement": "b" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + }, + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "x" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"x\", Query(\"t\", {}, 1))\n", + "line": 3, + "name": "inline-reserved" + }, + { + "code": "syntax", + "expected": { + "errors": [ + { + "code": "syntax", + "line": 3, + "statement": "b" + } + ], + "incomplete": false, + "nodes": [ + { + "key": "root", + "line": 1, + "statement": "root" + }, + { + "key": "a", + "line": 2, + "statement": "a" + } + ], + "orphans": [], + "tree": { + "children": [ + { + "name": "Text", + "props": { + "text": { + "type": "text", + "value": "kept" + } + } + } + ], + "name": "Page", + "props": {} + }, + "unresolved": [] + }, + "input": "root = Page([a])\na = Text(\"kept\")\nb = Text(\"x\" \"y\" +)\n", + "line": 3, + "name": "syntax" + }, + { + "code": "no-root", + "expected": { + "errors": [ + { + "code": "no-root", + "line": 0, + "statement": "" + } + ], + "incomplete": false, + "nodes": [], + "orphans": [], + "tree": null, + "unresolved": [] + }, + "input": "a = Text(\"nothing calls the root\")\n", + "line": 0, + "name": "no-root" + }, + { + "code": "no-root", + "expected": { + "errors": [ + { + "code": "no-root", + "line": 1, + "statement": "root" + } + ], + "incomplete": false, + "nodes": [], + "orphans": [], + "tree": null, + "unresolved": [] + }, + "input": "root = 42\n", + "line": 1, + "name": "root-not-a-widget" + }, + { + "code": "parse-failed", + "expected": { + "errors": [ + { + "code": "parse-failed", + "line": 0, + "statement": "" + } + ], + "incomplete": false, + "nodes": [], + "orphans": [], + "tree": null, + "unresolved": [] + }, + "input": "just some prose a model wrote\n", + "line": 0, + "name": "parse-failed" + } + ], + "class": "L5" +} diff --git a/joule-ui/conformance/lang/v1/library.json b/joule-ui/conformance/lang/v1/library.json new file mode 100644 index 000000000..927d05cf8 --- /dev/null +++ b/joule-ui/conformance/lang/v1/library.json @@ -0,0 +1,178 @@ +{ + "root": "Page", + "widgets": { + "Button": { + "allow_extra_props": false, + "children": { + "kind": "none" + }, + "description": "a button", + "name": "Button", + "positional": [ + "label", + "action", + "primary" + ], + "props": { + "action": { + "required": false, + "ty": "action" + }, + "label": { + "required": true, + "ty": "text" + }, + "primary": { + "required": false, + "ty": "bool" + } + } + }, + "Card": { + "allow_extra_props": false, + "children": { + "kind": "any" + }, + "description": "a titled container", + "name": "Card", + "positional": [ + "title", + "children", + "subtitle" + ], + "props": { + "subtitle": { + "required": false, + "ty": "text" + }, + "title": { + "required": true, + "ty": "text" + } + } + }, + "Chart": { + "allow_extra_props": false, + "children": { + "kind": "none" + }, + "description": "a chart of values", + "name": "Chart", + "positional": [ + "labels", + "values", + "options" + ], + "props": { + "labels": { + "required": true, + "ty": "list" + }, + "options": { + "required": false, + "ty": "object" + }, + "values": { + "required": true, + "ty": "list" + } + } + }, + "Note": { + "allow_extra_props": false, + "children": { + "kind": "any" + }, + "description": "a note", + "name": "Note", + "positional": [ + "author", + "body", + "children" + ], + "props": { + "author": { + "required": false, + "ty": "text" + }, + "body": { + "required": true, + "ty": "text" + } + } + }, + "Page": { + "allow_extra_props": false, + "children": { + "kind": "any" + }, + "description": "the page", + "name": "Page", + "positional": [ + "children", + "title" + ], + "props": { + "title": { + "required": false, + "ty": "text" + } + } + }, + "Tab": { + "allow_extra_props": false, + "children": { + "kind": "any" + }, + "description": "one tab", + "name": "Tab", + "positional": [ + "title", + "children" + ], + "props": { + "title": { + "required": true, + "ty": "text" + } + } + }, + "Tabs": { + "allow_extra_props": false, + "children": { + "kind": "only_of", + "value": [ + "Tab" + ] + }, + "description": "a strip of tabs", + "name": "Tabs", + "positional": [ + "children" + ], + "props": {} + }, + "Text": { + "allow_extra_props": false, + "children": { + "kind": "none" + }, + "description": "a run of text", + "name": "Text", + "positional": [ + "text", + "size" + ], + "props": { + "size": { + "required": false, + "ty": "number" + }, + "text": { + "required": true, + "ty": "text" + } + } + } + } +} diff --git a/joule-ui/conformance/lang_verify.py b/joule-ui/conformance/lang_verify.py new file mode 100644 index 000000000..caeadf443 --- /dev/null +++ b/joule-ui/conformance/lang_verify.py @@ -0,0 +1,1207 @@ +#!/usr/bin/env python3 +"""lang_verify.py: the independent verifier of joule-ui's line language (SPEC §11). + +Written from SPEC.md §4 and §11 alone, sharing no code with the reference +implementation (joule-ui-rs/crates/joule-ui-lang). It recomputes every case of the +pack in conformance/lang/v1/ (L1 statements, L2 ingest, L3 streaming, L4 the +canonical form, L5 refusals) from the case's input and the pack's library, and +compares what it gets with what the pack states. Errors are compared without +their messages, which are not normative. + + python3 -I lang_verify.py [] verify the pack + python3 -I lang_verify.py --mutations [] show that changed vectors are refused + +Exit status: 0 when every case agrees (or, with --mutations, when every mutation is +caught); 1 otherwise. Standard library only. +""" +from decimal import Decimal +import hashlib +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_PACK = os.path.join(HERE, "lang", "v1") + +# --------------------------------------------------------------------------- +# §4 validation + +def ptype(v): + return v["type"] + + +def validate(w, reg, path="$"): + """SPEC §4: every error of the tree, pre-order.""" + errs = [] + s = reg.get(w["name"]) + if s is None: + errs.append({"kind": "unknown_widget", "name": w["name"], "path": path}) + return errs + props = w.get("props", {}) + for p in sorted(s["props"]): + if s["props"][p].get("required") and p not in props: + errs.append({"kind": "missing_prop", "prop": p, "path": path}) + for p in sorted(props): + if p in s["props"]: + want = s["props"][p]["ty"] + if want != "any" and want != ptype(props[p]): + errs.append({"kind": "wrong_prop_type", "prop": p, "path": path}) + elif not s.get("allow_extra_props"): + errs.append({"kind": "extra_prop", "prop": p, "path": path}) + ch = s["children"] + for i, c in enumerate(w.get("children", [])): + cp = "%s.children[%d]" % (path, i) + kind = ch["kind"] + ok = kind == "any" or (kind == "only_of" and c["name"] in ch["value"]) + if not ok: + errs.append({"kind": "disallowed_child", "child": c["name"], "path": cp}) + errs.extend(validate(c, reg, cp)) + return errs + + +# --------------------------------------------------------------------------- +# §11.1 source text + +MARKER = "]]>openui:" +FENCE = "```" + + +def strip_comments(t): + out = [] + quote = None + i = 0 + n = len(t) + while i < n: + c = t[i] + if quote is not None: + out.append(c) + if c == "\\": + if i + 1 < n: + out.append(t[i + 1]) + i += 2 + continue + elif c == quote: + quote = None + i += 1 + continue + if c in "\"'": + quote = c + out.append(c) + i += 1 + continue + if c == "#" or (c == "/" and i + 1 < n and t[i + 1] == "/"): + while i < n and t[i] != "\n": + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +def prepare(source): + text = source.replace("\r\n", "\n") + lines = [(i + 1, l) for i, l in enumerate(text.split("\n"))] + if any(l.startswith(MARKER) for _, l in lines): + kept = [] + content = False + for n, l in lines: + if l.startswith(MARKER): + content = l[len(MARKER):].startswith("content") + continue + if content: + kept.append((n, l)) + lines = kept + if any(l.startswith(FENCE) for _, l in lines): + kept = [] + inside = False + dq = False + skip = False + for n, l in lines: + if not inside: + if l.startswith(FENCE): + inside, dq, skip = True, False, False + continue + if not dq and l.startswith(FENCE): + inside = False + continue + kept.append((n, l)) + for c in l: + if skip: + skip = False + elif dq and c == "\\": + skip = True + elif c == '"': + dq = not dq + skip = False + lines = kept + numbers = [n for n, _ in lines] + joined = strip_comments("\n".join(l for _, l in lines)) + ws = " \t\n\r" + lead = len(joined) - len(joined.lstrip(ws)) + numbers = numbers[joined[:lead].count("\n"):] + body = joined.strip(ws) + numbers = numbers[: len(body.split("\n"))] + return body, numbers + + +# --------------------------------------------------------------------------- +# §11.2 lexing + +def is_start(c): + return ("a" <= c <= "z") or ("A" <= c <= "Z") or c == "_" + + +def is_part(c): + return is_start(c) or ("0" <= c <= "9") + + +def is_digit(c): + return "0" <= c <= "9" + + +VALUE_KINDS = {"ident", "state", "str", "num", "badnum", "true", "false", "null"} + + +def ends_value(tok): + return tok["k"] in VALUE_KINDS or (tok["k"] == "p" and tok["v"] in (")", "]")) + + +def decode_dq(raw): + try: + v = json.loads('"' + raw + '"') + except ValueError: + return raw + if any(0xD800 <= ord(ch) <= 0xDFFF for ch in v): + return raw + return v + + +def decode_sq(raw): + out = [] + i = 0 + while i < len(raw): + c = raw[i] + if c != "\\": + out.append(c) + i += 1 + continue + if i + 1 < len(raw): + e = raw[i + 1] + out.append("\n" if e == "n" else "\t" if e == "t" else e) + i += 2 + return "".join(out) + + +TWO = ("==", "!=", ">=", "<=", "&&", "||") +ONE = {"=": "=", ">": ">", "<": "<", "+": "+", "-": "-", "*": "*", "/": "/", "%": "%", "&": "&&", "|": "||", + "!": "!", "?": "?", ":": ":", ".": ".", ",": ",", "(": "(", ")": ")", "[": "[", "]": "]", "{": "{", "}": "}"} + + +def lex(t): + toks = [] + i, n, line = 0, len(t), 0 + + def add(k, v=None, start=None, open_=False): + toks.append({"k": k, "v": v, "line": line if start is None else start, "open": open_}) + + while i < n: + c = t[i] + if c in " \t": + i += 1 + elif c == "\n": + add("nl") + line += 1 + i += 1 + elif c in "\"'": + start = line + j = i + 1 + closed = False + while j < n: + d = t[j] + if d == "\\": + if j + 1 < n and t[j + 1] == "\n": + line += 1 + j += 2 + continue + if d == "\n": + line += 1 + if d == c: + closed = True + break + j += 1 + raw = t[i + 1: min(j, n)] + if not closed: + trailing = len(raw) - len(raw.rstrip("\\")) + if trailing % 2 == 1: + raw += "\\" + add("str", decode_dq(raw) if c == '"' else decode_sq(raw), start, not closed) + i = j + 1 if closed else n + elif c in "$@" and i + 1 < n and is_start(t[i + 1]): + j = i + 1 + while j < n and is_part(t[j]): + j += 1 + if c == "$": + add("state", t[i:j]) + else: + add("at", t[i + 1:j]) + i = j + elif is_start(c): + j = i + while j < n and is_part(t[j]): + j += 1 + w = t[i:j] + if w in ("true", "false", "null"): + add(w, w) + else: + add("ident", w) + i = j + elif is_digit(c) or (c == "-" and i + 1 < n and is_digit(t[i + 1]) and not (toks and ends_value(toks[-1]))): + j = i + 1 if c == "-" else i + while j < n and is_digit(t[j]): + j += 1 + if j + 1 < n and t[j] == "." and is_digit(t[j + 1]): + j += 1 + while j < n and is_digit(t[j]): + j += 1 + bad = False + if j < n and t[j] in "eE": + j += 1 + if j < n and t[j] in "+-": + j += 1 + d0 = j + while j < n and is_digit(t[j]): + j += 1 + bad = j == d0 + text = t[i:j] + if bad: + add("badnum", text) + else: + toks.append({"k": "num", "v": float(text), "text": text, "line": line, "open": False}) + i = j + else: + if t[i:i + 2] in TWO: + add("p", t[i:i + 2]) + i += 2 + continue + if c in ONE: + add("p", ONE[c]) + i += 1 + return toks + + +# --------------------------------------------------------------------------- +# §11.3 statements + +def is_p(tok, v): + return tok["k"] == "p" and tok["v"] == v + + +def split(toks, finished): + out = [] + pos, n = 0, len(toks) + while pos < n: + while pos < n and toks[pos]["k"] == "nl": + pos += 1 + if pos >= n: + break + t0 = toks[pos] + if t0["k"] not in ("ident", "state") or pos + 1 >= n or not is_p(toks[pos + 1], "="): + while pos < n and toks[pos]["k"] != "nl": + pos += 1 + continue + name, state, line = t0["v"], t0["k"] == "state", t0["line"] + pos += 2 + expr = [] + depth = 0 + tern = [] # [in second branch, has operand] + complete = finished + boundary = False + while pos < n: + t = toks[pos] + if t["k"] == "nl": + if depth == 0 and not tern: + peek = pos + 1 + while peek < n and toks[peek]["k"] == "nl": + peek += 1 + if peek < n and is_p(toks[peek], "?"): + pos += 1 + continue + boundary = True + if peek < n: + complete = True + break + pos += 1 + continue + operand = False + if t["k"] == "p" and t["v"] in "([{": + depth += 1 + elif t["k"] == "p" and t["v"] in ")]}": + if depth > 0: + depth -= 1 + operand = depth == 0 + elif is_p(t, "?") and depth == 0: + tern.append([False, False]) + elif is_p(t, ":") and depth == 0: + if tern and not tern[-1][0]: + tern[-1] = [True, False] + elif t["k"] in VALUE_KINDS: + operand = depth == 0 + if operand and tern: + tern[-1][1] = True + if tern[-1][0]: + tern.pop() + expr.append(t) + pos += 1 + opened = (not boundary) and (depth > 0 or (bool(expr) and expr[-1]["open"])) + out.append({"name": name, "state": state, "line": line, "toks": expr, "complete": complete, "open": opened}) + return out + + +# --------------------------------------------------------------------------- +# §11.4 expressions + +def close(toks): + out, stack = [], [] + pairs = {"(": ")", "[": "]", "{": "}"} + for t in toks: + if t["k"] == "p" and t["v"] in pairs: + stack.append(pairs[t["v"]]) + elif t["k"] == "p" and t["v"] in ")]}": + if stack and stack[-1] == t["v"]: + stack.pop() + else: + continue + out.append(t) + line = toks[-1]["line"] if toks else 0 + while stack: + out.append({"k": "p", "v": stack.pop(), "line": line, "open": False}) + return out + + +class Stop(Exception): + def __init__(self, line): + self.line = line + + +class P: + def __init__(self, toks): + self.t, self.i = toks, 0 + + def peek(self): + return self.t[self.i] if self.i < len(self.t) else None + + def at(self, v): + x = self.peek() + return x is not None and is_p(x, v) + + def line(self): + x = self.peek() + if x is None: + x = self.t[-1] if self.t else None + return x["line"] if x else 0 + + def stop(self): + raise Stop(self.line()) + + def expect(self, v): + if not self.at(v): + self.stop() + self.i += 1 + + def ternary(self): + c = self.binary(0) + if self.at("?"): + self.i += 1 + a = self.ternary() + self.expect(":") + b = self.ternary() + return {"e": "ternary", "line": c["line"]} + return c + + LEVELS = [("||",), ("&&",), ("==", "!="), (">", "<", ">=", "<="), ("+", "-"), ("*", "/", "%")] + + def binary(self, lvl): + if lvl == len(self.LEVELS): + return self.unary() + left = self.binary(lvl + 1) + while True: + x = self.peek() + if x is None or x["k"] != "p" or x["v"] not in self.LEVELS[lvl]: + return left + op = x["v"] + self.i += 1 + self.binary(lvl + 1) + left = {"e": "binary", "op": op, "line": left["line"]} + + def unary(self): + if self.at("!") or self.at("-"): + line = self.line() + self.i += 1 + self.unary() + return {"e": "unary", "line": line} + return self.postfix() + + def postfix(self): + e = self.primary() + while True: + if self.at("."): + self.i += 1 + self.key() + e = {"e": "member", "line": e["line"]} + elif self.at("["): + self.i += 1 + self.ternary() + self.expect("]") + e = {"e": "index", "line": e["line"]} + else: + return e + + def key(self): + x = self.peek() + if x is None: + self.stop() + self.i += 1 + k = x["k"] + if k in ("ident", "str"): + return x["v"] + if k == "state": + return x["v"][1:] + if k == "num": + return x["text"] + if k == "badnum": + return x["v"] + if k in ("true", "false", "null"): + return k + if k == "at": + return "@" + x["v"] + return "?" + + def items(self, closer): + v = [] + while True: + while self.at(","): + self.i += 1 + if self.at(closer): + self.i += 1 + return v + if self.peek() is None: + self.stop() + v.append(self.ternary()) + + def primary(self): + x = self.peek() + if x is None: + self.stop() + line = x["line"] + k = x["k"] + self.i += 1 + if k == "str": + return {"e": "str", "v": x["v"], "line": line} + if k == "num": + return {"e": "num", "v": x["v"], "line": line} + if k == "badnum": + self.i -= 1 + self.stop() + if k in ("true", "false"): + return {"e": "bool", "v": k == "true", "line": line} + if k == "null": + return {"e": "null", "line": line} + if k == "p" and x["v"] == "[": + return {"e": "list", "items": self.items("]"), "line": line} + if k == "p" and x["v"] == "{": + entries = [] + while True: + while self.at(","): + self.i += 1 + if self.at("}"): + self.i += 1 + break + if self.peek() is None: + self.stop() + kk = self.key() + self.expect(":") + entries.append((kk, self.ternary())) + return {"e": "object", "entries": entries, "line": line} + if k == "p" and x["v"] == "(": + e = self.ternary() + self.expect(")") + return e + if k == "ident": + if self.at("(") and "A" <= x["v"][0] <= "Z": + self.i += 1 + return {"e": "call", "at": False, "name": x["v"], "args": self.items(")"), "line": line} + return {"e": "ref", "name": x["v"], "line": line} + if k == "at": + self.expect("(") + return {"e": "call", "at": True, "name": x["v"], "args": self.items(")"), "line": line} + if k == "state": + if self.at("="): + self.i += 1 + self.ternary() + return {"e": "assign", "line": line} + return {"e": "state", "name": x["v"], "line": line} + self.i -= 1 + self.stop() + + +def parse_tokens(toks): + if not toks: + raise Stop(None) + p = P(toks) + e = p.ternary() + if p.i < len(toks): + p.stop() + return e + + +# --------------------------------------------------------------------------- +# §11.5 into a joule-ui tree + +BUILTINS = {"Count", "First", "Last", "Sum", "Avg", "Min", "Max", "Filter", "Sort", "Round", "Abs", "Floor", "Ceil", "Each"} +STATE_ACTIONS = {"Set", "Reset", "Run"} + + +def T(v): + return {"type": "text", "value": v} + + +class Ingest: + def __init__(self, lib, finished, lines): + self.reg = lib["widgets"] + self.lib = lib + self.finished = finished + self.lines = lines + self.errors = [] + self.unresolved = set() + self.visited = set() + self.resolving = [] + self.occ = {} + self.binds = {} + + def src(self, line): + return self.lines[line] if 0 <= line < len(self.lines) else 0 + + def err(self, stmt, line, code, component=None, prop=None): + self.errors.append({"line": self.src(line), "statement": stmt, "code": code, "component": component, "prop": prop, "warning": False}) + + def reference(self, name): + b = self.binds.get(name) + if b is None or name in self.resolving: + self.unresolved.add(name) + return ("unresolved",) + if b["kind"] != "value": + return ("refused",) + self.visited.add(name) + self.occ[name] = self.occ.get(name, 0) + 1 + key = name if self.occ[name] == 1 else "%s#%d" % (name, self.occ[name]) + self.resolving.append(name) + v = self.eval(b["expr"], name, key) + self.resolving.pop() + return v + + def eval(self, e, stmt, key): + k = e["e"] + if k == "str": + return ("data", T(e["v"])) + if k == "num": + return ("data", {"type": "number", "value": e["v"]}) + if k == "bool": + return ("data", {"type": "bool", "value": e["v"]}) + if k == "null": + return ("null",) + if k == "ref": + return self.reference(e["name"]) + if k in ("state", "unary", "binary", "ternary", "member", "index", "assign"): + self.err(stmt, e["line"], "not-declarative") + return ("refused",) + if k == "list": + return ("list", [self.eval(it, stmt, "%s/%d" % (key, i)) for i, it in enumerate(e["items"])]) + if k == "object": + return ("object", [(kk, self.eval(v, stmt, "%s/%s" % (key, kk))) for kk, v in e["entries"]]) + # a call + name, args = e["name"], e["args"] + if not e["at"]: + if name in ("Query", "Mutation"): + self.err(stmt, e["line"], "inline-reserved") + return ("refused",) + if name == "Action": + return self.action_list(args, stmt, e["line"], key) + return self.build(name, args, e["line"], stmt, key) + if name in BUILTINS or name in STATE_ACTIONS: + self.err(stmt, e["line"], "not-declarative") + return ("refused",) + if name == "Action": + return self.action_list(args, stmt, e["line"], key) + items, not_yet, refused = [], False, False + for i, a in enumerate(args): + r = self.data(self.eval(a, stmt, "%s/%d" % (key, i)), stmt, a["line"], True) + if r == "not-yet": + not_yet = True + elif r == "refused": + refused = True + else: + items.append(r) + if refused: + return ("refused",) + if not_yet: + return ("held",) + payload = None if not items else items[0] if len(items) == 1 else {"type": "list", "value": items} + return ("action", {"type": "action", "value": {"id": name, "payload": payload}}) + + def action_list(self, args, stmt, line, key): + if len(args) == 1 and args[0]["e"] == "list" and len(args[0]["items"]) == 1: + one = args[0]["items"][0] + v = self.eval(one, stmt, key + "/0") + if v[0] == "action": + return v + if v[0] in ("unresolved", "held"): + return ("held",) + if v[0] == "refused": + return ("refused",) + self.err(stmt, one["line"], "type-mismatch") + return ("refused",) + self.err(stmt, line, "not-declarative") + return ("refused",) + + def data(self, v, stmt, line, keep_null): + """A value placed as data: a PropValue, "not-yet" or "refused".""" + k = v[0] + if k in ("data", "action"): + return v[1] + if k == "null": + return {"type": "null"} if keep_null else "not-yet" + if k == "list": + out = [] + for it in v[1]: + r = self.data(it, stmt, line, True) + if isinstance(r, dict): + out.append(r) + return {"type": "list", "value": out} + if k == "object": + out = {} + for kk, it in v[1]: + r = self.data(it, stmt, line, True) + if isinstance(r, dict): + out[kk] = r + return {"type": "object", "value": out} + if k == "widget": + self.err(stmt, line, "type-mismatch", v[1]["name"]) + return "refused" + if k in ("unresolved", "held"): + return "not-yet" + return "refused" + + def build(self, name, args, line, stmt, key): + s = self.reg.get(name) + if s is None: + self.err(stmt, line, "unknown-component", name) + return ("refused",) + order = s["positional"] + if len(args) > len(order): + self.err(stmt, args[len(order)]["line"], "excess-args", name) + props, absent, children = {}, {}, [] + for slot, arg in zip(order, args): + if slot == "children": + v = self.eval(arg, stmt, key) + if v[0] == "list": + for it in v[1]: + if it[0] == "widget": + children.append((it[1], it[2])) + elif it[0] in ("unresolved", "held", "null", "refused"): + pass + else: + self.err(stmt, arg["line"], "type-mismatch", name, "children") + elif v[0] in ("null", "unresolved", "held", "refused"): + pass + else: + self.err(stmt, arg["line"], "type-mismatch", name, "children") + continue + v = self.eval(arg, stmt, "%s/%s" % (key, slot)) + if v[0] == "null": + absent[slot] = "null" + elif v[0] in ("unresolved", "held"): + absent[slot] = "not-yet" + elif v[0] == "refused": + absent[slot] = "refused" + elif v[0] == "widget": + self.err(stmt, arg["line"], "type-mismatch", name, slot) + absent[slot] = "refused" + else: + r = self.data(v, stmt, arg["line"], False) + if isinstance(r, dict): + props[slot] = r + else: + absent[slot] = r + w = {"name": name, "props": props} + if children: + w["children"] = [c[0] for c in children] + child_nodes = [c[1] for c in children] + while True: + errs = validate(w, self.reg) + if not errs: + break + if not self.finished and any(x["kind"] == "missing_prop" and x["path"] == "$" and absent.get(x["prop"]) == "not-yet" for x in errs): + return ("held",) + drop = False + drop_children = [] + before = len(w["props"]) + for x in errs: + kind, path = x["kind"], x["path"] + if kind == "missing_prop" and path == "$": + why = absent.get(x["prop"]) + if why == "null": + self.err(stmt, line, "null-required", name, x["prop"]) + elif why != "refused": + self.err(stmt, line, "missing-required", name, x["prop"]) + drop = True + elif kind == "wrong_prop_type" and path == "$": + self.err(stmt, line, "type-mismatch", name, x["prop"]) + if s["props"][x["prop"]].get("required"): + drop = True + else: + del w["props"][x["prop"]] + elif kind == "extra_prop" and path == "$": + self.err(stmt, line, "type-mismatch", name, x["prop"]) + del w["props"][x["prop"]] + elif kind == "disallowed_child": + idx = child_index(path) + if idx is None: + drop = True + continue + first = child_nodes[idx][0] if idx < len(child_nodes) and child_nodes[idx] else None + self.errors.append({"line": first["line"] if first and first["line"] > 0 else self.src(line), + "statement": first["statement"] if first else stmt, + "code": "disallowed-child", "component": x["child"], "prop": None, "warning": False}) + drop_children.append(idx) + else: + drop = True + if drop: + return ("refused",) + drop_children = sorted(set(drop_children)) + changed = bool(drop_children) or len(w["props"]) != before + for i in reversed(drop_children): + del w["children"][i] + del child_nodes[i] + if "children" in w and not w["children"]: + del w["children"] + if not changed: + return ("refused",) + nodes = [{"key": key, "line": self.src(line), "statement": stmt}] + for cn in child_nodes: + nodes.extend(cn) + return ("widget", w, nodes) + + +def child_index(path): + pre = "$.children[" + if not path.startswith(pre) or not path.endswith("]"): + return None + mid = path[len(pre):-1] + return int(mid) if mid.isdigit() else None + + +def snapshot(lib, text, finished): + body, lines = prepare(text) + toks = lex(body) + raws = split(toks, finished) + incomplete = bool(raws) and raws[-1]["open"] + ing = Ingest(lib, finished, lines) + src = ing.src + errors, statements, binds = [], [], {} + for order, r in enumerate(raws): + tk = close(r["toks"]) if r["open"] else r["toks"] + line = src(r["line"]) + try: + e = parse_tokens(tk) + except Stop as stop: + if r["complete"]: + at = line if stop.line is None or not tk else src(stop.line) + errors.append({"line": at, "statement": r["name"], "code": "syntax", "component": None, "prop": None, "warning": False}) + statements.append({"name": r["name"], "line": line, "kind": "syntax", "complete": True}) + continue + if e["e"] == "call" and not e["at"] and e["name"] == "Query": + kind = "query" + elif e["e"] == "call" and not e["at"] and e["name"] == "Mutation": + kind = "mutation" + elif r["state"]: + kind = "state" + else: + kind = "value" + statements.append({"name": r["name"], "line": line, "kind": kind, "complete": r["complete"]}) + if r["complete"] and kind != "value": + errors.append({"line": line, "statement": r["name"], "code": "not-declarative", "component": None, "prop": None, "warning": False}) + prev = binds.get(r["name"]) + if prev is not None and prev["complete"] and not r["complete"] and r["open"]: + continue + binds[r["name"]] = {"line": line, "complete": r["complete"], "kind": kind, "expr": e, "order": order} + ing.binds = binds + entry = None + if "root" in binds: + entry = ("root", False) + elif lib.get("root"): + vals = sorted((b["order"], n) for n, b in binds.items() if b["kind"] == "value") + for _, n in vals: + ex = binds[n]["expr"] + if ex["e"] == "call" and not ex["at"] and ex["name"] == lib["root"]: + entry = (n, True) + break + tree, nodes = None, [] + if entry: + v = ing.reference(entry[0]) + if v[0] == "widget": + tree, nodes = v[1], v[2] + errors.extend(ing.errors) + if finished: + if entry is None: + code = "parse-failed" if not statements and body != "" else "no-root" + errors.append({"line": 0, "statement": "", "code": code, "component": None, "prop": None, "warning": False}) + else: + line = binds[entry[0]]["line"] + if tree is None: + errors.append({"line": line, "statement": entry[0], "code": "no-root", "component": None, "prop": None, "warning": False}) + elif entry[1]: + errors.append({"line": line, "statement": entry[0], "code": "no-root", "component": None, "prop": None, "warning": True}) + if not finished: + errors = [x for x in errors if x["statement"] not in binds or binds[x["statement"]]["complete"]] + + def opt(v): + return (0, "") if v is None else (1, v) + + def ekey(x): + return (x["line"], x["statement"], x["code"], opt(x["component"]), opt(x["prop"]), x["warning"]) + + errors.sort(key=ekey) + kept = [] + for x in errors: + if not kept or ekey(kept[-1]) != ekey(x): + kept.append(x) + orphans = sorted(n for n, b in binds.items() if b["kind"] == "value" and n not in ing.visited) if entry else [] + if tree is not None and validate(tree, ing.reg): + tree, nodes = None, [] + return {"tree": tree, "nodes": nodes, "errors": kept, "unresolved": sorted(ing.unresolved), "orphans": orphans, + "incomplete": incomplete, "statements": statements} + + +def stated(s): + """A snapshot as the pack states it (§11.9).""" + errs = [] + for x in s["errors"]: + e = {"line": x["line"], "statement": x["statement"], "code": x["code"]} + if x["component"] is not None: + e["component"] = x["component"] + if x["prop"] is not None: + e["prop"] = x["prop"] + if x["warning"]: + e["warning"] = True + errs.append(e) + return {"tree": s["tree"], "nodes": s["nodes"], "errors": errs, "unresolved": s["unresolved"], + "orphans": s["orphans"], "incomplete": s["incomplete"]} + + +class Stream: + def __init__(self, lib): + self.lib, self.text = lib, "" + + def push(self, chunk): + self.text += chunk + return stated(snapshot(self.lib, self.text, False)) + + def finish(self): + return stated(snapshot(self.lib, self.text, True)) + + +# --------------------------------------------------------------------------- +# §11.7 the canonical form + +def is_name(s): + return bool(s) and is_start(s[0]) and all(is_part(c) for c in s) + + +def jstr(s): + out = ['"'] + for ch in s: + o = ord(ch) + if ch == '"': + out.append('\\"') + elif ch == "\\": + out.append("\\\\") + elif ch == "\b": + out.append("\\b") + elif ch == "\f": + out.append("\\f") + elif ch == "\n": + out.append("\\n") + elif ch == "\r": + out.append("\\r") + elif ch == "\t": + out.append("\\t") + elif o < 0x20: + out.append("\\u%04x" % o) + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def num_text(x): + """The shortest decimal that reads back to x, positional, no exponent, no `.0`.""" + if x != x or x in (float("inf"), float("-inf")): + raise ValueError("not finite") + t = format(Decimal(repr(x)), "f") + if "." in t: + t = t.rstrip("0").rstrip(".") + return t + + +RESERVED_IDS = {"Action"} | BUILTINS | STATE_ACTIONS + + +def val(v): + t = v["type"] + if t == "null": + return "null" + if t == "bool": + return "true" if v["value"] else "false" + if t == "number": + return num_text(v["value"]) + if t == "text": + return jstr(v["value"]) + if t == "list": + return "[" + ", ".join(val(i) for i in v["value"]) + "]" + if t == "object": + parts = [] + for k in sorted(v["value"]): + kk = k if is_name(k) and k not in ("true", "false", "null") else jstr(k) + parts.append("%s: %s" % (kk, val(v["value"][k]))) + return "{" + ", ".join(parts) + "}" + if t == "action": + aid, payload = v["value"]["id"], v["value"]["payload"] + if not is_name(aid) or aid in RESERVED_IDS: + raise ValueError("action id") + if payload is None: + inner = "" + elif payload["type"] == "list" and len(payload["value"]) >= 2: + inner = ", ".join(val(i) for i in payload["value"]) + else: + inner = val(payload) + return "@%s(%s)" % (aid, inner) + raise ValueError(t) + + +def write(tree, lib): + reg = lib["widgets"] + if validate(tree, reg): + raise ValueError("invalid") + flat = [] + + def walk(w): + flat.append(w) + for c in w.get("children", []): + walk(c) + + walk(tree) + names, counts = [], {} + for i, w in enumerate(flat): + if i == 0: + names.append("root") + continue + base = w["name"][0].lower() + w["name"][1:] + counts[base] = counts.get(base, 0) + 1 + names.append("%s%d" % (base, counts[base])) + out = [] + idx = [0] + + def size(w): + return 1 + sum(size(c) for c in w.get("children", [])) + + def emit(w): + me = idx[0] + idx[0] += 1 + if not ("A" <= w["name"][0] <= "Z") or w["name"] in ("Query", "Mutation", "Action") or not is_name(w["name"]): + raise ValueError("not callable") + s = reg[w["name"]] + order = s["positional"] + for p in w.get("props", {}): + if p not in order: + raise ValueError("not positional") + child_names, at = [], me + 1 + for c in w.get("children", []): + child_names.append(names[at]) + at += size(c) + args = [] + for slot in order: + if slot == "children": + args.append("[" + ", ".join(child_names) + "]" if child_names else None) + continue + pv = w.get("props", {}).get(slot) + if pv is None: + args.append(None) + elif pv["type"] == "null": + raise ValueError("null prop") + else: + args.append(val(pv)) + while args and args[-1] is None: + args.pop() + out.append("%s = %s(%s)\n" % (names[me], w["name"], ", ".join("null" if a is None else a for a in args))) + for c in w.get("children", []): + emit(c) + + emit(tree) + return "".join(out) + + +# --------------------------------------------------------------------------- +# the pack + +def load(d): + files = {} + for n in sorted(os.listdir(d)): + with open(os.path.join(d, n), "rb") as f: + files[n] = f.read() + return files + + +def norm_tree(t): + """A tree for comparison: JSON numbers as floats.""" + return json.loads(json.dumps(t)) if t is not None else None + + +def check(files): + """Every disagreement, as a list of lines; empty when the pack agrees.""" + bad = [] + man = json.loads(files["MANIFEST.json"]) + for n, digest in man["files"].items(): + if n not in files: + bad.append("MANIFEST names %s, which the pack lacks" % n) + elif "sha256:" + hashlib.sha256(files[n]).hexdigest() != digest: + bad.append("%s: its SHA-256 is not the manifest's" % n) + for n in files: + if n != "MANIFEST.json" and n not in man["files"]: + bad.append("%s is not in the manifest" % n) + if bad: + # A file the manifest does not name as it is: nothing in it is relied on. + return bad + lib = json.loads(files["library.json"]) + + def same(a, b): + return json.loads(json.dumps(a, sort_keys=True)) == json.loads(json.dumps(b, sort_keys=True)) + + for c in json.loads(files["l1-statements.json"])["cases"]: + s = snapshot(lib, c["input"], True) + if not same(s["statements"], c["statements"]) or s["incomplete"] != c["incomplete"]: + bad.append("L1 %s: got %s" % (c["name"], json.dumps(s["statements"]))) + l2 = {} + for c in json.loads(files["l2-ingest.json"])["cases"]: + s = stated(snapshot(lib, c["input"], True)) + l2[c["name"]] = s + if not same(s, c["expected"]): + bad.append("L2 %s: got %s" % (c["name"], json.dumps(s)[:600])) + for c in json.loads(files["l3-stream.json"])["cases"]: + st = Stream(lib) + for i, ch in enumerate(c["chunks"]): + s = st.push(ch) + if not same(s, c["after_each"][i]): + bad.append("L3 %s after chunk %d: got %s" % (c["name"], i, json.dumps(s)[:600])) + end = st.finish() + if not same(end, c["at_end"]): + bad.append("L3 %s at the end: got %s" % (c["name"], json.dumps(end)[:600])) + if not same(end, stated(snapshot(lib, "".join(c["chunks"]), True))): + bad.append("L3 %s: the end of the stream is not the batch parse" % c["name"]) + for c in json.loads(files["l4-write.json"])["cases"]: + try: + text = write(c["tree"], lib) + except ValueError as e: + bad.append("L4 %s: not writable (%s)" % (c["name"], e)) + continue + if text != c["text"]: + bad.append("L4 %s: wrote %r" % (c["name"], text)) + back = snapshot(lib, c["text"], True) + if not same(back["tree"], c["tree"]) or back["errors"]: + bad.append("L4 %s: the text does not parse back to the tree" % c["name"]) + for c in json.loads(files["l5-refusals.json"])["cases"]: + s = stated(snapshot(lib, c["input"], True)) + if not any(e["code"] == c["code"] and e["line"] == c["line"] for e in s["errors"]): + bad.append("L5 %s: not refused %s at line %d" % (c["name"], c["code"], c["line"])) + if not same(s, c["expected"]): + bad.append("L5 %s: got %s" % (c["name"], json.dumps(s)[:600])) + return bad + + +def mutations(files): + """Changed vectors, each of which the verifier must refuse.""" + def edit(name, f): + doc = json.loads(files[name]) + f(doc) + out = dict(files) + out[name] = (json.dumps(doc, indent=2, sort_keys=True) + "\n").encode() + # keep the manifest true, so that the content check is what refuses it + man = json.loads(out["MANIFEST.json"]) + man["files"][name] = "sha256:" + hashlib.sha256(out[name]).hexdigest() + out["MANIFEST.json"] = (json.dumps(man, indent=2, sort_keys=True) + "\n").encode() + return out + + def case(doc, n): + return next(c for c in doc["cases"] if c["name"] == n) + + def first_text(t): + if t and "props" in t: + for k, v in t["props"].items(): + if v.get("type") == "text": + return t["props"], k + for c in (t or {}).get("children", []): + r = first_text(c) + if r: + return r + return None + + def change_text(doc, n, key="expected"): + props, k = first_text(case(doc, n)[key]["tree"]) + props[k]["value"] += "!" + + flipped = bytearray(files["l2-ingest.json"]) + flipped[100] ^= 0x01 + yield "a flipped byte under the manifest", dict(files, **{"l2-ingest.json": bytes(flipped)}) + yield "L1: a statement's line moved", edit("l1-statements.json", lambda d: d["cases"][0]["statements"][1].__setitem__("line", 9)) + yield "L1: complete flipped", edit("l1-statements.json", lambda d: d["cases"][0]["statements"][0].__setitem__("complete", False)) + yield "L2: a text changed", edit("l2-ingest.json", lambda d: change_text(d, "hoisting")) + yield "L2: an unresolved name dropped", edit("l2-ingest.json", lambda d: case(d, "unresolved-left-out")["expected"]["unresolved"].clear()) + yield "L2: an orphan dropped", edit("l2-ingest.json", lambda d: case(d, "orphans")["expected"]["orphans"].clear()) + yield "L2: a key renamed", edit("l2-ingest.json", lambda d: case(d, "used-twice")["expected"]["nodes"][2].__setitem__("key", "t")) + yield "L3: a pending title already whole", edit("l3-stream.json", lambda d: change_text(d, "pending-title-grows", "at_end")) + yield "L3: an error not withheld", edit("l3-stream.json", lambda d: case(d, "errors-wait-for-completion")["after_each"][0]["errors"].append({"line": 3, "statement": "b", "code": "type-mismatch", "component": "Text", "prop": "text"})) + yield "L4: the canonical text changed", edit("l4-write.json", lambda d: d["cases"][0].__setitem__("text", d["cases"][0]["text"].replace("card1", "c1", 1))) + yield "L5: a refusal's line moved", edit("l5-refusals.json", lambda d: case(d, "unknown-component").__setitem__("line", 2)) + yield "L5: a refused widget kept", edit("l5-refusals.json", lambda d: case(d, "unknown-component")["expected"]["tree"]["children"].append({"name": "Chartt", "props": {}})) + yield "L5: a code changed", edit("l5-refusals.json", lambda d: case(d, "disallowed-child")["expected"]["errors"][0].__setitem__("code", "type-mismatch")) + + +def main(argv): + mut = "--mutations" in argv + args = [a for a in argv if a != "--mutations"] + d = args[0] if args else DEFAULT_PACK + files = load(d) + if mut: + missed = 0 + n = 0 + for what, changed in mutations(files): + n += 1 + if check(changed): + print("refused: %s" % what) + else: + print("NOT REFUSED: %s" % what) + missed += 1 + if check(files): + print("the unchanged pack does not verify") + return 1 + print("%d mutation(s), %d not refused" % (n, missed)) + return 1 if missed else 0 + bad = check(files) + for b in bad: + print(b) + counts = {n: len(json.loads(files[n])["cases"]) for n in files if n[:1] == "l" and n[1:2].isdigit()} + print("joule-ui-lang/v1: %s; %d disagreement(s)" % (", ".join("%s %d" % (k.split(".")[0], v) for k, v in sorted(counts.items())), len(bad))) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/joule-ui/docs/create.md b/joule-ui/docs/create.md new file mode 100644 index 000000000..db60533fe --- /dev/null +++ b/joule-ui/docs/create.md @@ -0,0 +1,125 @@ +# Create: describing an app, and the line language joule-ui takes in + +A design note. The normative text is [`SPEC.md`](../SPEC.md) §11 (the line language) and +the conformance pack in [`conformance/lang/`](../conformance/lang/). The WAI side is +[`wai/wai-create/`](../../wai/wai-create/). + +**Create** is a page where a person describes a WAI app ("a show for my channel, with +chat and a live poll") and watches it build itself from WAI components while a model +streams its answer. The room it builds is a real room on a room service. The person then +exports it as a starter project. + +## 1. The line language onto joule-ui's tree + +A model writes OpenUI Lang (the open-source project's line-per-component language, MIT +licensed; see [`NOTICE`](../NOTICE)): one statement per line, `name = Component(arg, ...)`, +names usable before they are defined. joule-ui takes it in the way it takes in A2UI +(`joule-ui-a2ui`): the language is a way to write a joule-ui **widget tree** against a +host's **registry**, and what comes out is an ordinary `Widget`, checked by joule-ui's +own `validate`. + +| The language | joule-ui | +|---|---| +| the library | the `Registry`, plus the name of its root widget | +| `Component(a, b, c)` | a `Widget`; the arguments map to props in the order the widget's schema lists (`WidgetSchema::positional`, new in core) | +| the prop named `children` in that order | `Widget::children`; a list of components | +| strings, numbers, booleans, `null`, lists, objects | `Text`, `Number`, `Bool`, (absent), `List`, `Object` | +| a reference to another statement | that statement's value (hoisting) | +| `@Name(args)` in a prop | `PropValue::Action { id: "Name", payload }` | +| `$state`, `Query`, `Mutation`, operators, `? :`, member access, built-in functions, a sequence of actions | not carried: joule-ui puts no expression and no behaviour on the wire. Each is parsed (the grammar is parsed whole) and refused `not-declarative`, the smallest piece dropped | + +The crate is `joule-ui-lang`: a streaming parser, an ingest into widget trees validated +against a registry, and a canonical writer back to the language. Every error names the +line and the reason (`unknown-component`, `missing-required`, `type-mismatch`, +`disallowed-child`, `not-declarative`, ...; the table is SPEC §11.8). + +## 2. Streaming + +The text arrives in chunks. After each chunk the parser gives a **snapshot**: the tree as +far as it can be built, the errors so far, and the names still unresolved. + +- **Complete and pending.** A statement is complete once its newline has arrived and the + next non-blank character has too and is not `?` (a line starting `?` would continue a + ternary). Complete statements never change. The last statement is pending: it is + closed for the moment (an open string closed, open brackets closed in reverse order) + and built, so a title shows letter by letter as it streams. +- **What renders before the tree is whole.** The tree starts at `root`. A reference to a + statement not yet written is unresolved: in a list it is left out, in a prop it is + absent. A widget whose required prop is absent only because something has not arrived + is held back, silently, until it can render. So the first line, `root = App([room], + "Friday show")`, renders an empty app at once, and each later line fills it in. +- **How validation works on a stream.** Every widget is validated against the registry + as it is built, by joule-ui's `validate`; a widget that fails is dropped, and its error + is reported once its statement is complete (a pending statement's problems may be gone + by its next chunk). The snapshot's whole tree passes `validate` before it is handed to + a renderer: no snapshot ever holds a widget the registry refuses. At the end of the + stream the held-back cases become errors (`missing-required`, `no-root`), and the + result equals parsing the whole text at once. + +## 3. The WAI registry and the renderer + +`wai-create` publishes a joule-ui registry of WAI widgets, each backed by the room client +of `joule-moq-web` (the page that joins rooms, verifies every object against its receipt, +watches together and keeps co-performance): + +| Widget | What it is | +|---|---| +| `App(children, title)` | the root | +| `Room(children, title)` | a live room: join with camera and microphone, or watch | +| `WatchTogether(source, children, title)` | a watch room: everyone watches one file in sync | +| `Show(children, title, audience)` | a room with co-performance: hosts on camera, an audience that votes | +| `Player(title)` | the room's video, laid out from its signed record | +| `Chat(title)` | the room's chat events, each verified; a participant sends | +| `Poll(question, options, seconds)` | a vote: the room's clock opens the window and signs the selection | +| `Participants()` | who is present, from the room's lifecycle | +| `Receipts()` | this page's sealed interaction log, verified, and the room's verified objects | +| `EnergyMeter()` | what each interaction cost, with how the figure was obtained | +| `Text(text)` | a paragraph | + +Each room kind accepts only the widgets its service keeps (a watch room carries co-watch +statements alone, so it accepts no `Chat` and no `Poll`); joule-ui's `AllowedChildren` +says so, and a line that puts a `Poll` in a `WatchTogether` is refused with its line and +`disallowed-child`. An app holds one room. + +The renderer (`wai/wai-create/web/`) reconciles each snapshot into the page by key (a +statement's name), so a room already joined is never torn down by the next chunk. Every +action a person fires (join, leave, send a chat, vote, play) goes through +`joule-ui-interaction` in the page's WebAssembly build: authorized, metered (a page has +no meter, so the figure is the estimator's, from the handler's wall time, and labelled +so), and sealed into the page's hash-chained log under a key the page makes. `Receipts` +shows the log and the page's own `verify_chain` of it. A browser handler is asynchronous, +so the interaction crate gains a two-step form of its lifecycle (`begin_interaction`, then +`Pending::finish`) with the same three phases; a denied action yields no `Pending`, so +its handler cannot run. + +## 4. Export + +Export writes a starter project from the tree: the program (`app.oui`, in the canonical +form of the writer), the tree (`app.json`), the room service's configuration +(`room.toml`, derived from the room widget: a watch room's source, a show's +audience inputs), the page that renders the program with the same renderer and +components, a same-origin front for local development (`serve.mjs`), and a check +(`check.mjs`, the project's build) that parses `app.oui` against the registry, refuses a +project without its room configuration or with one that does not match the app, and +writes `dist/`. The file set is made in Rust (`wai_create::export`) and zipped by the +page. + +## 5. How a page talks to a model + +Create spends nobody's money and holds no one's key on a server. Two ways in, and the +page says which is in use: + +- **Your own model.** The visitor names an endpoint that streams the common + `chat/completions` server-sent-events format (a model server they run on their own + machine, or a provider they hold a key for), a model name and a key. The page calls + it from the browser. The key stays in the page's memory; it is never stored and never + sent anywhere else. +- **Your coding agent.** `wai-create mcp` is an MCP server the visitor's own agent runs + on their machine. Its tools give the agent the registry and the language + (`create_guide`), check a program (`create_check`), stream a program into the + visitor's open Create page through a bridge on `127.0.0.1` the page connects to with a + pairing code (`create_stream`), and write the starter project into the agent's + workspace (`create_export`). + +A third mode replays a recorded stream, which is how the end-to-end test drives the +page. A model hosted by the site is out of scope until the owner decides it. diff --git a/joule-ui/joule-ui-rs/Cargo.lock b/joule-ui/joule-ui-rs/Cargo.lock index 2e5dad160..49279f9ab 100644 --- a/joule-ui/joule-ui-rs/Cargo.lock +++ b/joule-ui/joule-ui-rs/Cargo.lock @@ -347,6 +347,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "joule-ui-lang" +version = "0.1.0" +dependencies = [ + "joule-ui-core", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "joule-ui-mcp" version = "0.1.0" diff --git a/joule-ui/joule-ui-rs/Cargo.toml b/joule-ui/joule-ui-rs/Cargo.toml index 460dfa726..980a64f40 100644 --- a/joule-ui/joule-ui-rs/Cargo.toml +++ b/joule-ui/joule-ui-rs/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/joule-ui-render", "crates/joule-ui-serve", "crates/joule-ui-mcp", + "crates/joule-ui-lang", ] [workspace.package] diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-core/src/lib.rs b/joule-ui/joule-ui-rs/crates/joule-ui-core/src/lib.rs index 857ca4f07..601c97e8b 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-core/src/lib.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-core/src/lib.rs @@ -227,6 +227,15 @@ pub struct WidgetSchema { /// name. Default is strict (`false`). #[serde(default)] pub allow_extra_props: bool, + /// The order a positional call passes this widget's props in (SPEC §3): + /// a language that writes `Card(title, [child])` maps its arguments to + /// these names in turn, the name `children` standing for the child list. + /// Empty when the host declares none; such a widget's order is its props + /// in key order, then `children` if it accepts any (SPEC §11.5). Absent + /// from the wire form when empty, so a registry that declares none + /// serialises as it did before. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub positional: Vec, } impl WidgetSchema { @@ -237,6 +246,7 @@ impl WidgetSchema { props: BTreeMap::new(), children: AllowedChildren::None, allow_extra_props: false, + positional: Vec::new(), } } pub fn prop(mut self, name: impl Into, schema: PropSchema) -> Self { @@ -251,6 +261,28 @@ impl WidgetSchema { self.allow_extra_props = true; self } + /// Declare the order a positional call passes the props in (SPEC §3); + /// `children` names the child list. + pub fn positional(mut self, names: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.positional = names.into_iter().map(Into::into).collect(); + self + } + /// The positional order in effect: the declared one, or the props in key + /// order followed by `children` when the widget accepts any (SPEC §11.5). + pub fn positional_order(&self) -> Vec { + if !self.positional.is_empty() { + return self.positional.clone(); + } + let mut order: Vec = self.props.keys().cloned().collect(); + if !matches!(self.children, AllowedChildren::None) { + order.push("children".into()); + } + order + } } /// The host's published widget catalog. Validation looks every widget @@ -886,6 +918,31 @@ mod tests { assert!(!json.contains("\"children\""), "got: {json}"); } + #[test] + fn positional_order_is_declared_or_derived() { + let declared = WidgetSchema::new("Card", "c") + .prop("title", PropSchema::required(PropType::Text)) + .children(AllowedChildren::Any) + .positional(["children", "title"]); + assert_eq!(declared.positional_order(), vec!["children", "title"]); + // None declared: the props in key order, then `children`. + let derived = WidgetSchema::new("Card", "c") + .prop("title", PropSchema::required(PropType::Text)) + .prop("subtitle", PropSchema::optional(PropType::Text)) + .children(AllowedChildren::Any); + assert_eq!(derived.positional_order(), vec!["subtitle", "title", "children"]); + let leaf = WidgetSchema::new("Text", "t").prop("body", PropSchema::required(PropType::Text)); + assert_eq!(leaf.positional_order(), vec!["body"]); + // An undeclared order is absent from the wire form, so registries that + // declare none serialise exactly as before. + let j = serde_json::to_value(&derived).unwrap(); + assert!(j.get("positional").is_none(), "{j}"); + let j = serde_json::to_value(&declared).unwrap(); + assert_eq!(j["positional"], serde_json::json!(["children", "title"])); + let back: WidgetSchema = serde_json::from_value(j).unwrap(); + assert_eq!(back, declared); + } + #[test] fn registry_round_trips_through_json() { let r = registry_minimal(); diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs index 5c6ac642c..5180f890f 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs @@ -228,6 +228,9 @@ pub struct Interaction { /// meter gives none, its wall-clock time is estimated at the meter's /// [`estimator_milliwatts`](Meter::estimator_milliwatts) and graded /// `Estimator`, so a budget binds on every host. +/// +/// This is [`begin_interaction`] and [`Pending::finish`] around a handler the +/// call runs itself, timed by the host's clock. pub fn run_interaction( surface: &ContentId, action: &str, @@ -241,14 +244,54 @@ where O: AsRef<[u8]>, F: FnOnce() -> O, { + let pending = match begin_interaction(surface, action, actor, inputs, meter, authorizer.as_deref_mut()) { + Ok(p) => p, + Err(denied) => return denied, + }; + let started = std::time::Instant::now(); + let output = handler(); + let wall = started.elapsed(); + pending.finish(output.as_ref(), meter, authorizer, wall) +} + +/// An interaction that was authorized and whose meter's first sample was taken, +/// its handler not yet run: phase 1 of SPEC §6 done, phases 2 and 3 waiting on +/// the handler. For a host whose handler cannot run inside one call (a +/// browser's, which awaits the network): [`begin_interaction`] gives a `Pending` +/// only when the action is allowed, so a denied action leaves nothing to run a +/// handler under, and [`Pending::finish`] meters, charges and hashes exactly as +/// [`run_interaction`] does. +#[must_use = "a Pending is finished once its handler has run"] +#[derive(Debug)] +pub struct Pending { + surface: ContentId, + action: String, + actor: String, + inputs: ContentId, + grant_id: Option, + before: Option, +} + +/// Phase 1 of SPEC §6: hash the inputs and authorize before anything runs. An +/// allowed action gives a [`Pending`], its meter sampled; a denied one gives its +/// sealed-ready denial (outputs the genesis sentinel, 0 µJ `Estimator`, the +/// actor named), and no `Pending` whose handler could run. +pub fn begin_interaction( + surface: &ContentId, + action: &str, + actor: &str, + inputs: &[u8], + meter: &dyn Meter, + authorizer: Option<&mut (dyn Authorizer + '_)>, +) -> Result { let inputs_id = ContentId::of_bytes(inputs); let grant_id = authorizer.as_ref().and_then(|a| a.grant_id()); // Phase 1: authorize before running. - if let Some(auth) = authorizer.as_deref_mut() + if let Some(auth) = authorizer && let Err(reason) = auth.authorize(action) { - return Interaction { + return Err(Interaction { surface: surface.clone(), action: action.to_string(), inputs: inputs_id, @@ -258,56 +301,81 @@ where budget_remaining_uj: Some(0), grant_id, actor: actor.to_string(), - }; + }); } - // Phase 2: meter the handler. The reading is tagged with the meter's - // DECLARED provenance — a number from a model or a stub is never promoted - // to `HwShunt`, and a meter that declares nothing is `Estimator`. - // `measured_uj` is carried only for a hardware shunt; a model/estimator - // figure rides in `estimated_uj` (SPEC §5). - let before = meter.sample_uj(); - let started = std::time::Instant::now(); - let output = handler(); - let wall = started.elapsed(); - let after = meter.sample_uj(); - let energy = match (before, after) { - (Some(a), Some(b)) => { - let uj = b.saturating_sub(a); - match meter.provenance() { - Provenance::HwShunt => Energy::measured(uj), - Provenance::ModelBased => Energy { - measured_uj: None, - estimated_uj: uj, - provenance: Provenance::ModelBased, - }, - Provenance::Estimator => Energy { - measured_uj: None, - estimated_uj: uj, - provenance: Provenance::Estimator, - }, - } - } - // No reading for work that ran: estimate it from the handler's own - // wall-clock time rather than seal 0 µJ, which would cost it nothing - // and let a budget never bind on a host without a counter (SPEC §6). - _ => Energy::estimator(estimate_uj(wall, meter.estimator_milliwatts())), - }; - - // Phase 3: charge the cost, with its provenance — an estimate is debited - // as an estimate, never handed on as a reading. - let budget_remaining_uj = authorizer.map(|a| a.charge(action, &energy)); - - Interaction { + Ok(Pending { surface: surface.clone(), action: action.to_string(), - inputs: inputs_id, - outputs: ContentId::of_bytes(output.as_ref()), - verdict: Verdict::Allow, - energy, - budget_remaining_uj, - grant_id, actor: actor.to_string(), + inputs: inputs_id, + grant_id, + before: meter.sample_uj(), + }) +} + +impl Pending { + /// The action this interaction fires. + pub fn action(&self) -> &str { + &self.action + } + + /// Phases 2 and 3 of SPEC §6, once the handler has run: the meter's second + /// sample, the energy graded by the meter's declared provenance (or, with no + /// reading, `wall` at the meter's estimator power, never 0 µJ), the cost + /// charged with its provenance, and the outputs hashed. `wall` is the + /// handler's wall-clock time on the host's own clock. + pub fn finish( + self, + output: &[u8], + meter: &dyn Meter, + authorizer: Option<&mut (dyn Authorizer + '_)>, + wall: std::time::Duration, + ) -> Interaction { + // Phase 2: meter the handler. The reading is tagged with the meter's + // DECLARED provenance — a number from a model or a stub is never promoted + // to `HwShunt`, and a meter that declares nothing is `Estimator`. + // `measured_uj` is carried only for a hardware shunt; a model/estimator + // figure rides in `estimated_uj` (SPEC §5). + let after = meter.sample_uj(); + let energy = match (self.before, after) { + (Some(a), Some(b)) => { + let uj = b.saturating_sub(a); + match meter.provenance() { + Provenance::HwShunt => Energy::measured(uj), + Provenance::ModelBased => Energy { + measured_uj: None, + estimated_uj: uj, + provenance: Provenance::ModelBased, + }, + Provenance::Estimator => Energy { + measured_uj: None, + estimated_uj: uj, + provenance: Provenance::Estimator, + }, + } + } + // No reading for work that ran: estimate it from the handler's own + // wall-clock time rather than seal 0 µJ, which would cost it nothing + // and let a budget never bind on a host without a counter (SPEC §6). + _ => Energy::estimator(estimate_uj(wall, meter.estimator_milliwatts())), + }; + + // Phase 3: charge the cost, with its provenance — an estimate is debited + // as an estimate, never handed on as a reading. + let budget_remaining_uj = authorizer.map(|a| a.charge(&self.action, &energy)); + + Interaction { + surface: self.surface, + action: self.action, + inputs: self.inputs, + outputs: ContentId::of_bytes(output), + verdict: Verdict::Allow, + energy, + budget_remaining_uj, + grant_id: self.grant_id, + actor: self.actor, + } } } @@ -748,6 +816,39 @@ mod tests { assert_eq!(verify_chain(log.entries()), Ok(log.entries().len())); } + /// The two-step lifecycle a browser host uses gives the interaction + /// `run_interaction` gives for the same handler, meter and budget. + #[test] + fn begin_and_finish_seal_what_run_interaction_seals() { + let mut b1 = budget(10_000, "g"); + let one = run_interaction(&surface(), "apply", "did:key:zA", b"in", &hw_step(1500), Some(&mut b1), || b"out".to_vec()); + let mut b2 = budget(10_000, "g"); + let meter = hw_step(1500); + let p = begin_interaction(&surface(), "apply", "did:key:zA", b"in", &meter, Some(&mut b2)).expect("allowed"); + assert_eq!(p.action(), "apply"); + let two = p.finish(b"out", &meter, Some(&mut b2), std::time::Duration::from_millis(3)); + assert_eq!(one, two); + assert_eq!((b1.remaining, b1.charged), (b2.remaining, b2.charged)); + + // With no reading, the host's wall time is the estimate's input. + let p = begin_interaction(&surface(), "apply", "did:key:zA", b"in", &NoMeter, None).expect("allowed"); + let it = p.finish(b"out", &NoMeter, None, std::time::Duration::from_millis(2)); + assert_eq!(it.energy, Energy::estimator(40_000)); // 2 ms at 20 W + } + + /// A denied action gives no `Pending`, so there is nothing a handler could + /// run under; the denial is the one `run_interaction` seals. + #[test] + fn a_denied_begin_gives_no_pending() { + let mut b = budget(0, "grant-xyz"); + let denied = begin_interaction(&surface(), "apply", "did:key:zA", b"{}", &NoMeter, Some(&mut b)).unwrap_err(); + let mut b2 = budget(0, "grant-xyz"); + let same = run_interaction(&surface(), "apply", "did:key:zA", b"{}", &NoMeter, Some(&mut b2), || b"x".to_vec()); + assert_eq!(denied, same); + assert_eq!(denied.outputs, ContentId::genesis()); + assert!(b.charged.is_empty()); + } + /// The authorizer is handed the figure the interaction is sealed with, and /// its provenance: a reading as a reading, an estimate as an estimate. #[test] diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/Cargo.toml b/joule-ui/joule-ui-rs/crates/joule-ui-lang/Cargo.toml new file mode 100644 index 000000000..5e626c36f --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "joule-ui-lang" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "The line language ingest for joule-ui (SPEC §11): a streaming parser for the line-per-component UI language (OpenUI Lang) that builds joule-ui widget trees, each widget validated against the host's registry as it arrives, with errors that name the line and the reason; and a canonical writer from a widget tree back to the language." + +[dependencies] +joule-ui-core = { path = "../joule-ui-core" } +serde = { workspace = true } +serde_json = { workspace = true } +# The pack manifest's digests (SPEC §11.9), SHA-256 so the independent verifier needs +# nothing outside the Python standard library. Already in the lockfile through ed25519-dalek. +sha2 = "0.10" + +[[bin]] +name = "gen_lang_vectors" +path = "src/bin/gen_lang_vectors.rs" diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/bin/gen_lang_vectors.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/bin/gen_lang_vectors.rs new file mode 100644 index 000000000..0a4108f62 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/bin/gen_lang_vectors.rs @@ -0,0 +1,17 @@ +//! Write the line language's conformance pack (SPEC §11.9) to +//! `joule-ui/conformance/lang/v1/`, or to the directory given. +//! +//! cargo run -p joule-ui-lang --bin gen_lang_vectors [-- ] + +use std::path::PathBuf; + +fn main() { + let dir = std::env::args().nth(1).map(PathBuf::from).unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/lang/v1") + }); + std::fs::create_dir_all(&dir).expect("the pack directory"); + for (name, bytes) in joule_ui_lang::vectors::pack() { + std::fs::write(dir.join(&name), &bytes).expect("a pack file"); + println!("{name} {} bytes", bytes.len()); + } +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/ingest.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/ingest.rs new file mode 100644 index 000000000..0e35d19eb --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/ingest.rs @@ -0,0 +1,643 @@ +//! From statements to a validated joule-ui tree (SPEC §11.5, §11.6). + +use crate::lex::lex; +use crate::parse::{close_implicitly, parse_expr, split, Expr, Head, E}; +use crate::prepare::{prepare, Prepared}; +use crate::{signature_of, Code, LangError, Library, Node, Snapshot, Statement}; +use joule_ui_core::{validate, AllowedChildren, PropValue, ValidationError, Widget}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// The built-in functions: they compute values, which joule-ui does not carry. +pub(crate) const BUILTINS: [&str; 14] = ["Count", "First", "Last", "Sum", "Avg", "Min", "Max", "Filter", "Sort", "Round", "Abs", "Floor", "Ceil", "Each"]; +/// The built-in actions that change state. +pub(crate) const STATE_ACTIONS: [&str; 3] = ["Set", "Reset", "Run"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Value, + State, + Query, + Mutation, +} + +impl Kind { + fn as_str(self) -> &'static str { + match self { + Kind::Value => "value", + Kind::State => "state", + Kind::Query => "query", + Kind::Mutation => "mutation", + } + } +} + +struct Bound { + line: u32, + complete: bool, + kind: Kind, + expr: E, + order: usize, +} + +/// A value as a program evaluates it, before it is placed in a prop. +enum V { + Data(PropValue), + Null, + List(Vec), + Object(Vec<(String, V)>), + Widget(Built), + Action(PropValue), + /// A reference to nothing written (yet), or one that closes a cycle. + Unresolved, + /// A widget held back: a required prop refers to something not yet written. + Held, + /// Refused, its error recorded (or a widget dropped). + Refused, +} + +struct Built { + widget: Widget, + nodes: Vec, +} + +/// Why a prop is absent. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Absent { + NotYet, + Null, + Refused, +} + +pub(crate) fn snapshot(lib: &Library, text: &str, finished: bool) -> Snapshot { + let prep = prepare(text); + let tokens = lex(&prep.text); + let raws = split(&tokens, finished); + let incomplete = raws.last().is_some_and(|r| r.open); + + let mut errors: Vec = Vec::new(); + let mut statements: Vec = Vec::new(); + let mut binds: HashMap = HashMap::new(); + for (order, raw) in raws.iter().enumerate() { + let toks = if raw.open { close_implicitly(&raw.tokens) } else { raw.tokens.clone() }; + let line = prep.source_line(raw.line); + let expr = match parse_expr(&toks) { + Ok(e) => e, + Err(pe) => { + // A statement that does not parse is not produced; a complete one + // is reported (SPEC §11.6 rule 2). + if raw.complete { + errors.push(LangError { + line: if toks.is_empty() { line } else { prep.source_line(pe.line) }, + statement: raw.name.clone(), + code: Code::Syntax, + component: None, + prop: None, + message: format!("`{}` does not parse: {}", raw.name, pe.message), + warning: false, + }); + statements.push(Statement { name: raw.name.clone(), line, kind: "syntax".into(), complete: true }); + } + continue; + } + }; + let kind = match &expr.expr { + Expr::Call { head: Head::Component, name, .. } if name == "Query" => Kind::Query, + Expr::Call { head: Head::Component, name, .. } if name == "Mutation" => Kind::Mutation, + _ if raw.state => Kind::State, + _ => Kind::Value, + }; + statements.push(Statement { name: raw.name.clone(), line, kind: kind.as_str().into(), complete: raw.complete }); + if raw.complete && kind != Kind::Value { + let what = match kind { + Kind::State => format!("`{}` is state; joule-ui carries no state (the host owns behaviour)", raw.name), + Kind::Query => format!("`{}` is a query; joule-ui carries no query (the host owns its data)", raw.name), + _ => format!("`{}` is a mutation; joule-ui carries no mutation (the host owns behaviour)", raw.name), + }; + errors.push(LangError { line, statement: raw.name.clone(), code: Code::NotDeclarative, component: None, prop: None, message: what, warning: false }); + } + // A later statement replaces an earlier one of the same name, except that + // a pending statement that had to be closed implicitly never replaces a + // complete one (SPEC §11.6 rule 3). + if let Some(prev) = binds.get(&raw.name) + && prev.complete + && !raw.complete + && raw.open + { + continue; + } + binds.insert(raw.name.clone(), Bound { line, complete: raw.complete, kind, expr, order }); + } + + let mut ev = Ev { + lib, + prep: &prep, + binds: &binds, + finished, + errors: Vec::new(), + unresolved: BTreeSet::new(), + visited: BTreeSet::new(), + resolving: Vec::new(), + occurrences: HashMap::new(), + }; + + // The entry: the `root` statement, else the first value statement whose + // value calls the library's root widget (SPEC §11.5). + let entry: Option<(String, bool)> = if binds.contains_key("root") { + Some(("root".into(), false)) + } else { + lib.root.as_ref().and_then(|r| { + let mut vals: Vec<(&String, &Bound)> = binds.iter().filter(|(_, b)| b.kind == Kind::Value).collect(); + vals.sort_by_key(|(_, b)| b.order); + vals.into_iter() + .find(|(_, b)| matches!(&b.expr.expr, Expr::Call { head: Head::Component, name, .. } if name == r)) + .map(|(n, _)| (n.clone(), true)) + }) + }; + + let mut tree: Option = None; + let mut nodes: Vec = Vec::new(); + if let Some((name, _)) = &entry { + let line = binds[name].line; + if let V::Widget(b) = ev.reference(name, line, "") { + tree = Some(b.widget); + nodes = b.nodes; + } + } + errors.append(&mut ev.errors); + + if finished { + match &entry { + None => { + if statements.is_empty() && !prep.text.is_empty() { + errors.push(whole(Code::ParseFailed, "the text holds no statement (`name = value`, one a line)".into(), false)); + } else { + let m = match &lib.root { + Some(r) => format!("no `root` statement, and no statement whose value calls `{r}`: nothing renders"), + None => "no `root` statement: nothing renders".into(), + }; + errors.push(whole(Code::NoRoot, m, false)); + } + } + Some((name, fallback)) => { + let line = binds[name].line; + if tree.is_none() { + errors.push(LangError { + line, + statement: name.clone(), + code: Code::NoRoot, + component: None, + prop: None, + message: format!("`{name}` is not a widget that renders: nothing renders"), + warning: false, + }); + } else if *fallback { + errors.push(LangError { + line, + statement: name.clone(), + code: Code::NoRoot, + component: None, + prop: None, + message: format!("no `root` statement: `{name}`, the first to call the root widget, renders"), + warning: true, + }); + } + } + } + } + + // A pending statement's errors are withheld until it completes (it may be + // whole by the next chunk). + if !finished { + errors.retain(|e| binds.get(&e.statement).is_none_or(|b| b.complete)); + } + // By line, statement, code, widget, prop and warning; one of each. + errors.sort_by(|a, b| sort_key(a).cmp(&sort_key(b)).then_with(|| a.message.cmp(&b.message))); + errors.dedup_by(|a, b| sort_key(a) == sort_key(b)); + + let orphans: Vec = if entry.is_some() { + let mut v: Vec = binds.iter().filter(|(n, b)| b.kind == Kind::Value && !ev.visited.contains(*n)).map(|(n, _)| n.clone()).collect(); + v.sort(); + v + } else { + Vec::new() + }; + + // Every tree a snapshot holds passes the registry's validation as a whole. + if let Some(t) = &tree { + let left = validate(t, &lib.registry); + debug_assert!(left.is_empty(), "the ingest built a tree its registry refuses: {left:?}"); + if !left.is_empty() { + tree = None; + nodes.clear(); + } + } + + Snapshot { tree, nodes, errors, unresolved: ev.unresolved.into_iter().collect(), orphans, statements, finished, incomplete } +} + +type ErrorKey<'a> = (u32, &'a str, &'static str, Option<&'a str>, Option<&'a str>, bool); + +fn sort_key(e: &LangError) -> ErrorKey<'_> { + (e.line, e.statement.as_str(), e.code.as_str(), e.component.as_deref(), e.prop.as_deref(), e.warning) +} + +fn whole(code: Code, message: String, warning: bool) -> LangError { + LangError { line: 0, statement: String::new(), code, component: None, prop: None, message, warning } +} + +struct Ev<'a> { + lib: &'a Library, + prep: &'a Prepared, + binds: &'a HashMap, + finished: bool, + errors: Vec, + unresolved: BTreeSet, + visited: BTreeSet, + resolving: Vec, + occurrences: HashMap, +} + +impl Ev<'_> { + fn err(&mut self, stmt: &str, line: usize, code: Code, component: Option<&str>, prop: Option<&str>, message: String) { + self.errors.push(LangError { + line: self.prep.source_line(line), + statement: stmt.to_string(), + code, + component: component.map(String::from), + prop: prop.map(String::from), + message, + warning: false, + }); + } + + /// A reference to a statement: its value, evaluated where it is used. + fn reference(&mut self, name: &str, _line: u32, _from: &str) -> V { + let Some(b) = self.binds.get(name) else { + self.unresolved.insert(name.to_string()); + return V::Unresolved; + }; + if self.resolving.iter().any(|r| r == name) { + // A reference that closes a cycle is unresolved at that point. + self.unresolved.insert(name.to_string()); + return V::Unresolved; + } + if b.kind != Kind::Value { + // State, a query, a mutation: refused at the statement already. + return V::Refused; + } + self.visited.insert(name.to_string()); + let n = self.occurrences.entry(name.to_string()).or_insert(0); + *n += 1; + let key = if *n == 1 { name.to_string() } else { format!("{name}#{n}") }; + self.resolving.push(name.to_string()); + let v = self.eval(&b.expr, name, &key); + self.resolving.pop(); + v + } + + fn eval(&mut self, e: &E, stmt: &str, key: &str) -> V { + match &e.expr { + Expr::Str(s) => V::Data(PropValue::Text(s.clone())), + Expr::Num(n) => V::Data(PropValue::Number(*n)), + Expr::Bool(b) => V::Data(PropValue::Bool(*b)), + Expr::Null => V::Null, + Expr::Ref(name) => self.reference(name, 0, stmt), + Expr::State(name) => { + self.err(stmt, e.line, Code::NotDeclarative, None, None, format!("`{name}` is state; joule-ui carries no state (the host owns behaviour)")); + V::Refused + } + Expr::Array(items) => V::List(items.iter().enumerate().map(|(i, it)| self.eval(it, stmt, &format!("{key}/{i}"))).collect()), + Expr::Object(entries) => V::Object(entries.iter().map(|(k, v)| (k.clone(), self.eval(v, stmt, &format!("{key}/{k}")))).collect()), + Expr::Call { head: Head::Component, name, args } => { + if name == "Query" || name == "Mutation" { + self.err(stmt, e.line, Code::InlineReserved, None, None, format!("`{name}` is a statement of its own, not a value")); + return V::Refused; + } + if name == "Action" { + return self.action_list(args, stmt, e.line, key); + } + self.build(name, args, e.line, stmt, key) + } + Expr::Call { head: Head::At, name, args } => { + if BUILTINS.contains(&name.as_str()) { + self.err(stmt, e.line, Code::NotDeclarative, None, None, format!("`@{name}` computes a value; joule-ui carries no expression: write the value")); + return V::Refused; + } + if STATE_ACTIONS.contains(&name.as_str()) { + self.err(stmt, e.line, Code::NotDeclarative, None, None, format!("`@{name}` changes state; joule-ui carries no state (the host owns behaviour)")); + return V::Refused; + } + if name == "Action" { + return self.action_list(args, stmt, e.line, key); + } + self.action(name, args, stmt, key) + } + Expr::Unary(op, _) => self.expression(stmt, e.line, &format!("the operator `{op}`")), + Expr::Binary(op, _, _) => self.expression(stmt, e.line, &format!("the operator `{op}`")), + Expr::Ternary(..) => self.expression(stmt, e.line, "a conditional (`? :`)"), + Expr::Member(_, f) => self.expression(stmt, e.line, &format!("member access (`.{f}`)")), + Expr::Index(..) => self.expression(stmt, e.line, "indexing (`[...]` after a value)"), + Expr::Assign(name, _) => self.expression(stmt, e.line, &format!("an assignment to `{name}`")), + } + } + + fn expression(&mut self, stmt: &str, line: usize, what: &str) -> V { + self.err(stmt, line, Code::NotDeclarative, None, None, format!("{what} is an expression; joule-ui carries no expression: write the value")); + V::Refused + } + + /// `@Name(args)`: an action, its arguments its payload (none; the one; or a + /// list of them, SPEC §11.5). + fn action(&mut self, name: &str, args: &[E], stmt: &str, key: &str) -> V { + // Every argument is evaluated, so every reference in it is reached. + let mut items = Vec::new(); + let (mut not_yet, mut refused) = (false, false); + for (i, a) in args.iter().enumerate() { + let v = self.eval(a, stmt, &format!("{key}/{i}")); + match self.data(v, stmt, a.line, true) { + Ok(Some(p)) => items.push(p), + Ok(None) => not_yet = true, + Err(()) => refused = true, + } + } + if refused { + return V::Refused; + } + if not_yet { + return V::Held; + } + let payload = match items.len() { + 0 => None, + 1 => Some(Box::new(items.pop().expect("one"))), + _ => Some(Box::new(PropValue::List(items))), + }; + V::Action(PropValue::Action { id: name.to_string(), payload }) + } + + /// `Action([one])` is that one action; any other list is a sequence, which is + /// behaviour. + fn action_list(&mut self, args: &[E], stmt: &str, line: usize, key: &str) -> V { + if let [E { expr: Expr::Array(items), .. }] = args + && let [one] = items.as_slice() + { + return match self.eval(one, stmt, &format!("{key}/0")) { + V::Action(p) => V::Action(p), + V::Unresolved | V::Held => V::Held, + V::Refused => V::Refused, + _ => { + self.err(stmt, one.line, Code::TypeMismatch, None, None, "`Action([...])` holds actions; this is not one".into()); + V::Refused + } + }; + } + self.err(stmt, line, Code::NotDeclarative, None, None, "a sequence of actions is behaviour the host owns; write one action".into()); + V::Refused + } + + /// A value placed where data goes: `Ok(None)` for what is not yet written + /// (left out), `Err` for what was refused (its error recorded). `keep_null`: + /// a `null` is a value here (in a list, an object, an action's arguments). + fn data(&mut self, v: V, stmt: &str, line: usize, keep_null: bool) -> Result, ()> { + match v { + V::Data(p) | V::Action(p) => Ok(Some(p)), + V::Null => Ok(if keep_null { Some(PropValue::Null) } else { None }), + V::List(items) => { + let mut out = Vec::new(); + for it in items { + if let Ok(Some(p)) = self.data(it, stmt, line, true) { + out.push(p); + } + } + Ok(Some(PropValue::List(out))) + } + V::Object(entries) => { + let mut out = BTreeMap::new(); + for (k, it) in entries { + if let Ok(Some(p)) = self.data(it, stmt, line, true) { + out.insert(k, p); + } + } + Ok(Some(PropValue::Object(out))) + } + V::Widget(b) => { + self.err(stmt, line, Code::TypeMismatch, Some(&b.widget.name), None, format!("a widget (`{}`) stands where data goes", b.widget.name)); + Err(()) + } + V::Unresolved | V::Held => Ok(None), + V::Refused => Err(()), + } + } + + /// A call of a registry widget: its arguments mapped to props in its + /// positional order, then validated by joule-ui, the smallest piece dropped. + fn build(&mut self, name: &str, args: &[E], line: usize, stmt: &str, key: &str) -> V { + let lib = self.lib; + let Some(schema) = lib.registry.get(name) else { + let have = lib.names().join(", "); + self.err(stmt, line, Code::UnknownComponent, Some(name), None, format!("`{name}` is not a widget of this registry; it has {have}")); + return V::Refused; + }; + let order = schema.positional_order(); + let sig = signature_of(schema); + if args.len() > order.len() { + self.err( + stmt, + args[order.len()].line, + Code::ExcessArgs, + Some(name), + None, + format!("`{name}` takes {} argument{}, {sig}; the extra ones are dropped", order.len(), if order.len() == 1 { "" } else { "s" }), + ); + } + let mut props: BTreeMap = BTreeMap::new(); + let mut absent: HashMap = HashMap::new(); + let mut children: Vec<(Widget, Vec)> = Vec::new(); + for (slot, arg) in order.iter().zip(args.iter()) { + if slot == "children" { + match self.eval(arg, stmt, key) { + V::List(items) => { + for it in items { + match it { + V::Widget(b) => children.push((b.widget, b.nodes)), + V::Unresolved | V::Held | V::Null | V::Refused => {} + _ => self.err(stmt, arg.line, Code::TypeMismatch, Some(name), Some("children"), format!("the children of `{name}` are widgets; data stands among them")), + } + } + } + V::Null | V::Unresolved | V::Held | V::Refused => {} + V::Widget(b) => self.err( + stmt, + arg.line, + Code::TypeMismatch, + Some(name), + Some("children"), + format!("the children of `{name}` are a list: write `[{}]`", b.nodes.first().map_or("child", |n| n.key.as_str())), + ), + _ => self.err(stmt, arg.line, Code::TypeMismatch, Some(name), Some("children"), format!("the children of `{name}` are a list of widgets")), + } + continue; + } + let v = self.eval(arg, stmt, &format!("{key}/{slot}")); + match v { + V::Null => { + absent.insert(slot.clone(), Absent::Null); + } + V::Unresolved | V::Held => { + absent.insert(slot.clone(), Absent::NotYet); + } + V::Refused => { + absent.insert(slot.clone(), Absent::Refused); + } + V::Widget(b) => { + self.err(stmt, arg.line, Code::TypeMismatch, Some(name), Some(slot), format!("`{slot}` of `{name}` takes data; a widget (`{}`) stands there", b.widget.name)); + absent.insert(slot.clone(), Absent::Refused); + } + other => match self.data(other, stmt, arg.line, false) { + Ok(Some(p)) => { + props.insert(slot.clone(), p); + } + Ok(None) => { + absent.insert(slot.clone(), Absent::NotYet); + } + Err(()) => { + absent.insert(slot.clone(), Absent::Refused); + } + }, + } + } + + let mut widget = Widget { name: name.to_string(), props, children: Vec::new() }; + let mut child_nodes: Vec> = Vec::new(); + for (w, n) in children { + widget.children.push(w); + child_nodes.push(n); + } + + // joule-ui's own validation decides; each error drops the smallest piece. + let mut props_before = widget.props.len(); + loop { + let errs = validate(&widget, &lib.registry); + if errs.is_empty() { + break; + } + // Held back, silently, while a required prop is not yet written. + if !self.finished + && errs.iter().any(|e| matches!(e, ValidationError::MissingProp { prop, path, .. } if path == "$" && absent.get(prop) == Some(&Absent::NotYet))) + { + return V::Held; + } + let mut drop_widget = false; + let mut drop_children: Vec = Vec::new(); + for e in errs { + match e { + ValidationError::MissingProp { prop, path, .. } if path == "$" => match absent.get(&prop) { + Some(Absent::NotYet) => { + self.err(stmt, line, Code::MissingRequired, Some(name), Some(&prop), format!("`{name}` needs `{prop}`, and it refers to something never written: {sig}")); + drop_widget = true; + } + Some(Absent::Null) => { + self.err(stmt, line, Code::NullRequired, Some(name), Some(&prop), format!("`{prop}` of `{name}` is required and written `null`: {sig}")); + drop_widget = true; + } + // Its value was refused, and that refusal is the error + // reported: the widget goes with it. + Some(Absent::Refused) => drop_widget = true, + None => { + self.err(stmt, line, Code::MissingRequired, Some(name), Some(&prop), format!("`{name}` needs `{prop}`: {sig}")); + drop_widget = true; + } + }, + ValidationError::WrongPropType { prop, expected, got, path, .. } if path == "$" => { + let required = schema.props.get(&prop).is_some_and(|p| p.required); + self.err( + stmt, + line, + Code::TypeMismatch, + Some(name), + Some(&prop), + format!("`{prop}` of `{name}` is {}, not {}{}", ty_name(expected), ty_name(got), if required { "" } else { "; it is left out" }), + ); + if required { + drop_widget = true; + } else { + widget.props.remove(&prop); + } + } + ValidationError::ExtraProp { prop, path, .. } if path == "$" => { + self.err(stmt, line, Code::TypeMismatch, Some(name), Some(&prop), format!("`{name}` declares no prop `{prop}`")); + widget.props.remove(&prop); + } + ValidationError::DisallowedChild { child, path, .. } => { + let Some(i) = child_index(&path) else { + drop_widget = true; + continue; + }; + let accepts = match &schema.children { + AllowedChildren::OnlyOf(list) => format!("it accepts {}", list.join(", ")), + AllowedChildren::None => "it accepts no children".into(), + AllowedChildren::Any => "it accepts any".into(), + }; + let at = child_nodes.get(i).and_then(|n| n.first()).map(|n| n.line).unwrap_or(0); + self.errors.push(LangError { + line: if at > 0 { at } else { self.prep.source_line(line) }, + statement: child_nodes.get(i).and_then(|n| n.first()).map_or_else(|| stmt.to_string(), |n| n.statement.clone()), + code: Code::DisallowedChild, + component: Some(child.clone()), + prop: None, + message: format!("`{name}` does not accept `{child}`: {accepts}"), + warning: false, + }); + drop_children.push(i); + } + // A child is valid before its parent holds it, so nothing below + // the parent can fail here; if it did, the parent goes. + _ => drop_widget = true, + } + } + if drop_widget { + return V::Refused; + } + drop_children.sort_unstable(); + drop_children.dedup(); + let changed = !drop_children.is_empty() || widget.props.len() != props_before; + for i in drop_children.into_iter().rev() { + widget.children.remove(i); + child_nodes.remove(i); + } + if !changed { + // Nothing left to drop but the widget itself. + return V::Refused; + } + props_before = widget.props.len(); + } + + let mut nodes = vec![Node { key: key.to_string(), line: self.prep.source_line(line), statement: stmt.to_string() }]; + for n in child_nodes { + nodes.extend(n); + } + V::Widget(Built { widget, nodes }) + } +} + +fn child_index(path: &str) -> Option { + let rest = path.strip_prefix("$.children[")?; + let end = rest.find(']')?; + if end + 1 != rest.len() { + return None; + } + rest[..end].parse().ok() +} + +fn ty_name(t: joule_ui_core::PropType) -> &'static str { + use joule_ui_core::PropType as T; + match t { + T::Null => "null", + T::Bool => "a boolean", + T::Number => "a number", + T::Text => "text", + T::List => "a list", + T::Object => "an object", + T::Action => "an action", + T::Any => "any value", + } +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lex.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lex.rs new file mode 100644 index 000000000..ade8ea84b --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lex.rs @@ -0,0 +1,351 @@ +//! Lexing (SPEC §11.2). Lexing never fails: a character that matches no rule +//! is skipped. A string still open at the end of the text is closed there, and +//! its token says so. + +/// A token's kind. +#[derive(Debug, Clone, PartialEq)] +pub enum Tok { + /// An identifier: a component name (uppercase first letter) or a + /// reference (lowercase or `_`). + Ident(String), + /// A state name, `$` included. + State(String), + /// An `@` name, the `@` left off. + At(String), + Str(String), + /// A number and the text it was written as. + Num(f64, String), + /// A number whose exponent marker has no digits after it: not usable. + BadNum(String), + True, + False, + Null, + /// An operator or a punctuation mark: `=` `==` `!=` `>` `<` `>=` `<=` `+` + /// `-` `*` `/` `%` `&&` `||` `!` `?` `:` `.` `,` `(` `)` `[` `]` `{` `}`. + P(&'static str), + Newline, +} + +impl Tok { + /// Whether this token ends a value: a literal, a name, or a closing bracket. + /// A `-` after one is subtraction, not a sign (SPEC §11.2). + pub fn ends_value(&self) -> bool { + matches!( + self, + Tok::Ident(_) | Tok::State(_) | Tok::Str(_) | Tok::Num(..) | Tok::BadNum(_) | Tok::True | Tok::False | Tok::Null | Tok::P(")") | Tok::P("]") + ) + } +} + +/// A token, the prepared line it starts on (0-based), and, for a string, whether +/// the text ended inside it. +#[derive(Debug, Clone, PartialEq)] +pub struct Token { + pub tok: Tok, + pub line: usize, + pub unterminated: bool, +} + +fn ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} +fn ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// Lex the prepared text. +pub fn lex(text: &str) -> Vec { + let cs: Vec = text.chars().collect(); + let mut out: Vec = Vec::new(); + let mut i = 0; + let mut line = 0usize; + let n = cs.len(); + while i < n { + let c = cs[i]; + let start_line = line; + let push = |tok: Tok, out: &mut Vec| out.push(Token { tok, line: start_line, unterminated: false }); + match c { + ' ' | '\t' => { + i += 1; + } + '\n' => { + push(Tok::Newline, &mut out); + line += 1; + i += 1; + } + '"' | '\'' => { + let (tok, used, lines, open) = string(&cs[i..], c); + out.push(Token { tok, line: start_line, unterminated: open }); + line += lines; + i += used; + } + '$' if cs.get(i + 1).is_some_and(|&d| ident_start(d)) => { + let mut j = i + 1; + while j < n && ident_char(cs[j]) { + j += 1; + } + push(Tok::State(cs[i..j].iter().collect()), &mut out); + i = j; + } + '@' if cs.get(i + 1).is_some_and(|&d| ident_start(d)) => { + let mut j = i + 1; + while j < n && ident_char(cs[j]) { + j += 1; + } + push(Tok::At(cs[i + 1..j].iter().collect()), &mut out); + i = j; + } + c if ident_start(c) => { + let mut j = i; + while j < n && ident_char(cs[j]) { + j += 1; + } + let word: String = cs[i..j].iter().collect(); + let tok = match word.as_str() { + "true" => Tok::True, + "false" => Tok::False, + "null" => Tok::Null, + _ => Tok::Ident(word), + }; + push(tok, &mut out); + i = j; + } + c if c.is_ascii_digit() + || (c == '-' + && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit()) + && !out.last().is_some_and(|t| t.tok.ends_value())) => + { + let (tok, used) = number(&cs[i..]); + push(tok, &mut out); + i += used; + } + _ => { + let two: String = cs[i..(i + 2).min(n)].iter().collect(); + let p2 = match two.as_str() { + "==" => Some("=="), + "!=" => Some("!="), + ">=" => Some(">="), + "<=" => Some("<="), + "&&" => Some("&&"), + "||" => Some("||"), + _ => None, + }; + if let Some(p) = p2 { + push(Tok::P(p), &mut out); + i += 2; + continue; + } + let p1 = match c { + '=' => Some("="), + '>' => Some(">"), + '<' => Some("<"), + '+' => Some("+"), + '-' => Some("-"), + '*' => Some("*"), + '/' => Some("/"), + '%' => Some("%"), + '&' => Some("&&"), + '|' => Some("||"), + '!' => Some("!"), + '?' => Some("?"), + ':' => Some(":"), + '.' => Some("."), + ',' => Some(","), + '(' => Some("("), + ')' => Some(")"), + '[' => Some("["), + ']' => Some("]"), + '{' => Some("{"), + '}' => Some("}"), + _ => None, + }; + if let Some(p) = p1 { + push(Tok::P(p), &mut out); + } + // Anything else is skipped: lexing never fails. + i += 1; + } + } + } + out +} + +/// A number from `-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?`, with an `e` or `E` +/// after the digits always taken as the exponent marker (SPEC §11.2). +fn number(cs: &[char]) -> (Tok, usize) { + let mut j = 0; + if cs[0] == '-' { + j = 1; + } + while j < cs.len() && cs[j].is_ascii_digit() { + j += 1; + } + if j + 1 < cs.len() && cs[j] == '.' && cs[j + 1].is_ascii_digit() { + j += 1; + while j < cs.len() && cs[j].is_ascii_digit() { + j += 1; + } + } + let mut bad = false; + if j < cs.len() && (cs[j] == 'e' || cs[j] == 'E') { + j += 1; + if j < cs.len() && (cs[j] == '+' || cs[j] == '-') { + j += 1; + } + let digits = j; + while j < cs.len() && cs[j].is_ascii_digit() { + j += 1; + } + bad = j == digits; + } + let text: String = cs[..j].iter().collect(); + if bad { + return (Tok::BadNum(text), j); + } + let v: f64 = text.parse().unwrap_or(0.0); + (Tok::Num(v, text), j) +} + +/// A string from its opening quote: the token, the characters used, the line +/// feeds inside it, and whether the text ended inside it (it is then closed +/// there, SPEC §11.2). +fn string(cs: &[char], q: char) -> (Tok, usize, usize, bool) { + let mut j = 1; + let mut lines = 0; + let mut closed = false; + while j < cs.len() { + let c = cs[j]; + if c == '\\' { + if j + 1 < cs.len() && cs[j + 1] == '\n' { + lines += 1; + } + j += 2; + continue; + } + if c == '\n' { + lines += 1; + } + if c == q { + closed = true; + break; + } + j += 1; + } + let end = j.min(cs.len()); + let mut raw: String = cs[1..end].iter().collect(); + let used = if closed { end + 1 } else { cs.len() }; + if !closed { + // Ended mid-escape: a backslash closes the escape first. + let trailing = raw.chars().rev().take_while(|&c| c == '\\').count(); + if trailing % 2 == 1 { + raw.push('\\'); + } + } + let value = if q == '"' { decode_dq(&raw) } else { decode_sq(&raw) }; + (Tok::Str(value), used, lines, !closed) +} + +/// A double-quoted string's content decoded as a JSON string (RFC 8259 §7); a +/// content that is not one (an unknown escape, a raw control character, a +/// short or lone-surrogate `\u` escape) is taken as written (SPEC §11.2). +pub fn decode_dq(raw: &str) -> String { + let quoted = format!("\"{raw}\""); + match serde_json::from_str::("ed) { + Ok(s) => s, + Err(_) => raw.to_string(), + } +} + +/// A single-quoted string's content: `\'`, `\\`, `\n` and `\t` are escapes; +/// a backslash before any other character is dropped and the character kept. +pub fn decode_sq(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut it = raw.chars(); + while let Some(c) = it.next() { + if c != '\\' { + out.push(c); + continue; + } + match it.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some(o) => out.push(o), + None => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toks(s: &str) -> Vec { + lex(s).into_iter().map(|t| t.tok).collect() + } + + #[test] + fn names_numbers_strings() { + assert_eq!( + toks("x = Card(\"hi\", 12.5, $s, @Go)"), + vec![ + Tok::Ident("x".into()), + Tok::P("="), + Tok::Ident("Card".into()), + Tok::P("("), + Tok::Str("hi".into()), + Tok::P(","), + Tok::Num(12.5, "12.5".into()), + Tok::P(","), + Tok::State("$s".into()), + Tok::P(","), + Tok::At("Go".into()), + Tok::P(")"), + ] + ); + } + + #[test] + fn minus_is_a_sign_only_after_no_value() { + assert_eq!(toks("[1 -2]"), vec![Tok::P("["), Tok::Num(1.0, "1".into()), Tok::P("-"), Tok::Num(2.0, "2".into()), Tok::P("]")]); + assert_eq!(toks("[-2]"), vec![Tok::P("["), Tok::Num(-2.0, "-2".into()), Tok::P("]")]); + assert_eq!(toks("1."), vec![Tok::Num(1.0, "1".into()), Tok::P(".")]); + assert_eq!(toks("1e"), vec![Tok::BadNum("1e".into())]); + assert_eq!(toks("2E-3"), vec![Tok::Num(0.002, "2E-3".into())]); + } + + #[test] + fn single_ampersand_and_bar_are_the_doubles_and_strays_skip() { + assert_eq!(toks("a & b | c ; d"), vec![ + Tok::Ident("a".into()), Tok::P("&&"), Tok::Ident("b".into()), Tok::P("||"), Tok::Ident("c".into()), Tok::Ident("d".into()) + ]); + } + + #[test] + fn string_escapes() { + assert_eq!(toks(r#""a\né""#), vec![Tok::Str("a\né".into())]); + // Not a JSON string (an unknown escape): taken as written. + assert_eq!(toks(r#""a\qb""#), vec![Tok::Str("a\\qb".into())]); + assert_eq!(toks(r"'it\'s\x'"), vec![Tok::Str("it'sx".into())]); + // A raw line feed is not JSON either: taken as written, and lines counted. + let t = lex("x = \"a\nb\"\ny"); + assert_eq!(t[2].tok, Tok::Str("a\nb".into())); + assert_eq!(t[4].line, 2); + } + + #[test] + fn an_open_string_is_closed_at_the_end() { + let t = lex("\"abc"); + assert_eq!((t[0].tok.clone(), t[0].unterminated), (Tok::Str("abc".into()), true)); + // Mid-escape: a backslash closes the escape first. + assert_eq!(lex("\"abc\\")[0].tok, Tok::Str("abc\\".into())); + // A partial \u escape is not JSON: taken as written. + assert_eq!(lex("\"a\\u00")[0].tok, Tok::Str("a\\u00".into())); + } + + #[test] + fn lone_surrogates_are_taken_as_written() { + assert_eq!(decode_dq(r"\ud800"), r"\ud800"); + assert_eq!(decode_dq(r"😀"), "😀"); + } +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lib.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lib.rs new file mode 100644 index 000000000..55e33fa6b --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/lib.rs @@ -0,0 +1,329 @@ +//! The line language ingest for joule-ui (SPEC §11). +//! +//! A model writes a user interface as a short program, one statement a line: +//! +//! ```text +//! root = App([room], "Friday show") +//! room = Show([chat, poll]) +//! chat = Chat() +//! poll = Poll("Which song next?", ["The first", "The second"]) +//! ``` +//! +//! This is OpenUI Lang, the open-source project's language (MIT licence; see +//! `joule-ui/NOTICE`). joule-ui takes it in the way it takes in A2UI: the +//! program is a way to write a joule-ui [`Widget`] tree against a host's +//! [`Registry`], and what comes out is an ordinary widget tree that joule-ui's +//! own [`validate`](joule_ui_core::validate) has checked, widget by widget. +//! +//! - [`StreamParser`] takes the text a chunk at a time and gives a +//! [`Snapshot`] after each: the tree as far as it can be built, every error +//! so far with its line and reason, the names still unresolved. Every tree a +//! snapshot holds passes `validate`. +//! - [`parse`] is the same over the whole text at once; at the end of a stream +//! the two agree. +//! - [`write`] writes a tree back to the language in its canonical form, which +//! parses back to the same tree. +//! +//! What joule-ui does not carry — state (`$name`), queries and mutations, +//! operators, conditionals, member access, built-in functions, sequences of +//! actions — is parsed and refused with the code `not-declarative`: joule-ui +//! puts no expression and no behaviour on the wire, and the smallest piece that +//! holds one is dropped. + +#![forbid(unsafe_code)] + +mod ingest; +pub mod lex; +pub mod parse; +pub mod prepare; +pub mod vectors; +mod write; + +pub use write::{number_text, write, WriteError}; + +/// Names the language reserves for its own call forms: no widget takes them. +pub const RESERVED: [&str; 3] = ["Query", "Mutation", "Action"]; + +use joule_ui_core::{AllowedChildren, Registry, Widget, WidgetSchema}; +use serde::{Deserialize, Serialize}; + +/// The library a program is written against: the host's registry (each +/// widget's props, children and positional order) and the name of its root +/// widget, the one a program without a `root` statement may start from +/// (SPEC §11.5). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Library { + pub registry: Registry, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root: Option, +} + +/// Why a registry cannot be written in the language. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LibraryError { + /// A widget name that does not start with an uppercase letter cannot be a + /// call's head. + NotCallable(String), + /// A positional name that is not a declared prop, or `children` on a widget + /// that takes none. + UnknownPositional { widget: String, name: String }, + /// A positional name listed twice. + DuplicatePositional { widget: String, name: String }, + /// The root names no widget of the registry. + UnknownRoot(String), + /// A widget named like one of the language's own call forms. + Reserved(String), +} + +impl std::fmt::Display for LibraryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LibraryError::NotCallable(w) => write!(f, "widget `{w}` does not start with an uppercase letter, so no call can name it"), + LibraryError::UnknownPositional { widget, name } => write!(f, "widget `{widget}` lists `{name}` in its positional order, and it is not one of its props"), + LibraryError::DuplicatePositional { widget, name } => write!(f, "widget `{widget}` lists `{name}` twice in its positional order"), + LibraryError::UnknownRoot(r) => write!(f, "the root `{r}` is not a widget of the registry"), + LibraryError::Reserved(w) => write!(f, "`{w}` is one of the language's own call forms, so no widget takes the name"), + } + } +} + +impl std::error::Error for LibraryError {} + +impl Library { + /// A library over `registry`, checked: every widget callable, every + /// positional name a prop it declares (or `children`, when it takes + /// children), none twice, and the root one of its widgets. + pub fn new(registry: Registry, root: Option) -> Result { + let mut names: Vec<&str> = registry.names().collect(); + names.sort_unstable(); + for name in names { + let s = registry.get(name).expect("listed"); + if !name.starts_with(|c: char| c.is_ascii_uppercase()) || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(LibraryError::NotCallable(name.into())); + } + if RESERVED.contains(&name) { + return Err(LibraryError::Reserved(name.into())); + } + let order = s.positional_order(); + for (i, p) in order.iter().enumerate() { + let known = if p == "children" { !matches!(s.children, AllowedChildren::None) } else { s.props.contains_key(p) }; + if !known { + return Err(LibraryError::UnknownPositional { widget: name.into(), name: p.clone() }); + } + if order[..i].contains(p) { + return Err(LibraryError::DuplicatePositional { widget: name.into(), name: p.clone() }); + } + } + } + if let Some(r) = &root + && registry.get(r).is_none() + { + return Err(LibraryError::UnknownRoot(r.clone())); + } + Ok(Library { registry, root }) + } + + /// A widget's signature as an error hint and a guide print it: + /// `Poll(question*, options*, seconds)`, `*` marking a required prop. + pub fn signature(&self, name: &str) -> Option { + let s = self.registry.get(name)?; + Some(signature_of(s)) + } + + /// The widget names, sorted. + pub fn names(&self) -> Vec { + let mut v: Vec = self.registry.names().map(String::from).collect(); + v.sort(); + v + } +} + +pub(crate) fn signature_of(s: &WidgetSchema) -> String { + let args: Vec = s + .positional_order() + .iter() + .map(|p| { + let req = s.props.get(p).is_some_and(|ps| ps.required); + if req { format!("{p}*") } else { p.clone() } + }) + .collect(); + format!("{}({})", s.name, args.join(", ")) +} + +/// The reason codes of the ingest (SPEC §11.8). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Code { + /// A call names a widget the registry does not have. The widget is dropped. + UnknownComponent, + /// A required prop is absent. The widget is dropped. + MissingRequired, + /// A required prop is written `null`. The widget is dropped. + NullRequired, + /// A value is not of the type its prop declares, or a widget stands where + /// data must, or data where a widget must. An optional prop or a list + /// element is left out; a required prop drops the widget. + TypeMismatch, + /// More arguments than the widget has positions. The extra ones are dropped. + ExcessArgs, + /// A widget a parent's `AllowedChildren` does not accept. The child is left out. + DisallowedChild, + /// State, a query or a mutation, an operator, a conditional, member access, + /// a built-in function, or a sequence of actions: joule-ui carries no + /// expression and no behaviour. The smallest piece that holds one is dropped. + NotDeclarative, + /// `Query` or `Mutation` written inside a value. It evaluates to nothing. + InlineReserved, + /// A complete statement whose expression does not parse. It is dropped. + Syntax, + /// At the end of the stream, no usable entry: nothing renders. A warning + /// when the entry is the first statement that calls the root widget. + NoRoot, + /// At the end of the stream, text that holds no statement at all. + ParseFailed, +} + +impl Code { + pub fn as_str(self) -> &'static str { + match self { + Code::UnknownComponent => "unknown-component", + Code::MissingRequired => "missing-required", + Code::NullRequired => "null-required", + Code::TypeMismatch => "type-mismatch", + Code::ExcessArgs => "excess-args", + Code::DisallowedChild => "disallowed-child", + Code::NotDeclarative => "not-declarative", + Code::InlineReserved => "inline-reserved", + Code::Syntax => "syntax", + Code::NoRoot => "no-root", + Code::ParseFailed => "parse-failed", + } + } +} + +/// One problem: the source line, the statement it is in, its reason code, +/// and what it is about, in words a model can act on. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct LangError { + /// The 1-based line of the source text (as it arrived, fences and prose + /// counted); 0 for the whole program. + pub line: u32, + /// The statement's name; empty for the whole program. + pub statement: String, + pub code: Code, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prop: Option, + pub message: String, + /// A warning: the page renders through it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub warning: bool, +} + +/// A widget of the tree, in pre-order beside it: the key a renderer keeps its +/// instance under across snapshots, and where it was written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Node { + /// The statement's name for a widget that is a statement's value (`room`; + /// `room#2` where one statement is used twice), else its parent's key and + /// its position among the children (`root/0`). + pub key: String, + /// The source line of its call. + pub line: u32, + /// The statement whose text holds its call. + pub statement: String, +} + +/// A statement, as the program holds it now. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Statement { + pub name: String, + pub line: u32, + /// `value`, `state`, `query`, `mutation`, or `syntax` for one that does not + /// parse. + pub kind: String, + pub complete: bool, +} + +/// What the program renders after a chunk (SPEC §11.6). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Snapshot { + /// The tree from the entry, every widget of it accepted by the registry; + /// none while there is no usable entry. + pub tree: Option, + /// The tree's widgets in pre-order, with their keys and lines. + pub nodes: Vec, + /// Every error so far, by line. A pending statement's are withheld until it + /// completes; a widget held back for something not yet written is not an + /// error until the stream ends. + pub errors: Vec, + /// Names referred to and not (yet) defined, sorted. + pub unresolved: Vec, + /// Value statements the entry does not reach, sorted. + pub orphans: Vec, + /// The statements in the order written, each binding its name (a later one + /// replaces an earlier, SPEC §11.5). + pub statements: Vec, + /// The host has ended the stream. + pub finished: bool, + /// The text ended inside an open string or bracket. + pub incomplete: bool, +} + +impl Snapshot { + /// The errors with a given code. + pub fn errors_with(&self, code: Code) -> Vec<&LangError> { + self.errors.iter().filter(|e| e.code == code).collect() + } +} + +/// The streaming parser (SPEC §11.6): push the text a chunk at a time, read a +/// snapshot after each; `finish` when the host ends the stream. +#[derive(Debug, Clone)] +pub struct StreamParser<'l> { + lib: &'l Library, + text: String, + finished: bool, +} + +impl<'l> StreamParser<'l> { + pub fn new(lib: &'l Library) -> Self { + StreamParser { lib, text: String::new(), finished: false } + } + + /// Take the next chunk. A chunk after `finish` starts a new stream. + pub fn push(&mut self, chunk: &str) -> Snapshot { + if self.finished { + self.text.clear(); + self.finished = false; + } + self.text.push_str(chunk); + self.snapshot() + } + + /// The host ends the stream: the last statement completes. + pub fn finish(&mut self) -> Snapshot { + self.finished = true; + self.snapshot() + } + + /// The snapshot of the text so far. + pub fn snapshot(&self) -> Snapshot { + ingest::snapshot(self.lib, &self.text, self.finished) + } + + /// The text taken so far. + pub fn text(&self) -> &str { + &self.text + } +} + +/// Parse a whole program at once: the snapshot a stream of the same text gives +/// at its end. +pub fn parse(lib: &Library, text: &str) -> Snapshot { + ingest::snapshot(lib, text, true) +} + +#[cfg(test)] +mod tests; diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/parse.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/parse.rs new file mode 100644 index 000000000..955f7dac8 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/parse.rs @@ -0,0 +1,487 @@ +//! Statements (SPEC §11.3) and expressions (SPEC §11.4). +//! +//! The grammar is parsed whole, the parts joule-ui does not carry included, so +//! a statement is refused for what it says (`not-declarative`), not because the +//! parser could not read it. + +use crate::lex::{Tok, Token}; + +/// One statement as the splitter found it. +#[derive(Debug, Clone, PartialEq)] +pub struct RawStmt { + /// The bound name (`$` included for a state name). + pub name: String, + /// Whether the name is a state name. + pub state: bool, + /// The prepared line the statement starts on (0-based). + pub line: usize, + /// The expression's tokens, line feeds inside it left out. + pub tokens: Vec, + /// Complete: its line feed arrived and the next character after it that is + /// not white space has too, and is not `?` (SPEC §11.6 rule 1); or the + /// stream has ended. + pub complete: bool, + /// The text ended inside it, in an open string or an open bracket, so it + /// is closed implicitly (SPEC §11.6 rule 2). + pub open: bool, +} + +/// Split the token stream into statements (SPEC §11.3). `finished`: the host +/// has ended the stream, so the last statement is complete too. +pub fn split(tokens: &[Token], finished: bool) -> Vec { + let mut out = Vec::new(); + let mut pos = 0; + let n = tokens.len(); + let skip_line = |pos: &mut usize| { + while *pos < n && tokens[*pos].tok != Tok::Newline { + *pos += 1; + } + }; + while pos < n { + while pos < n && tokens[pos].tok == Tok::Newline { + pos += 1; + } + if pos >= n { + break; + } + let (name, state) = match &tokens[pos].tok { + Tok::Ident(s) => (s.clone(), false), + Tok::State(s) => (s.clone(), true), + _ => { + skip_line(&mut pos); + continue; + } + }; + if pos + 1 >= n || tokens[pos + 1].tok != Tok::P("=") { + skip_line(&mut pos); + continue; + } + let line = tokens[pos].line; + pos += 2; + let mut expr = Vec::new(); + let mut depth = 0usize; + // Open ternaries at depth 0: (in its `:` branch, that branch has an operand). + let mut ternaries: Vec<(bool, bool)> = Vec::new(); + let mut complete = finished; + let mut ended_at_boundary = false; + while pos < n { + let t = &tokens[pos]; + if t.tok == Tok::Newline { + if depth == 0 && ternaries.is_empty() { + let mut peek = pos + 1; + while peek < n && tokens[peek].tok == Tok::Newline { + peek += 1; + } + if peek < n && tokens[peek].tok == Tok::P("?") { + pos += 1; + continue; + } + ended_at_boundary = true; + if peek < n { + complete = true; + } + break; + } + pos += 1; + continue; + } + let mut operand = false; + match &t.tok { + Tok::P("(") | Tok::P("[") | Tok::P("{") => depth += 1, + Tok::P(")") | Tok::P("]") | Tok::P("}") => { + if depth > 0 { + depth -= 1; + operand = depth == 0; + } + } + Tok::P("?") if depth == 0 => ternaries.push((false, false)), + Tok::P(":") if depth == 0 => { + if let Some(top) = ternaries.last_mut() + && !top.0 + { + *top = (true, false); + } + } + Tok::Ident(_) | Tok::State(_) | Tok::Str(_) | Tok::Num(..) | Tok::BadNum(_) | Tok::True | Tok::False | Tok::Null => { + operand = depth == 0; + } + _ => {} + } + if operand && let Some(top) = ternaries.last_mut() { + top.1 = true; + if top.0 { + ternaries.pop(); + } + } + expr.push(t.clone()); + pos += 1; + } + let open = !ended_at_boundary && (depth > 0 || expr.last().is_some_and(|t| t.unterminated)); + out.push(RawStmt { name, state, line, tokens: expr, complete, open }); + } + out +} + +/// An expression and the prepared line it starts on. +#[derive(Debug, Clone, PartialEq)] +pub struct E { + pub expr: Expr, + pub line: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Head { + /// A component name: an identifier with an uppercase first letter. + Component, + /// An `@` name. + At, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Str(String), + Num(f64), + Bool(bool), + Null, + Ref(String), + State(String), + Array(Vec), + Object(Vec<(String, E)>), + Call { head: Head, name: String, args: Vec }, + Unary(&'static str, Box), + Binary(&'static str, Box, Box), + Ternary(Box, Box, Box), + Member(Box, String), + Index(Box, Box), + /// `$name = expression` inside an expression. + Assign(String, Box), +} + +/// Why an expression did not parse, and the prepared line where it stopped. +#[derive(Debug, Clone, PartialEq)] +pub struct ParseError { + pub line: usize, + pub message: String, +} + +/// Close a pending statement implicitly (SPEC §11.6 rule 2): a closing bracket +/// that matches no open one is dropped, and the brackets still open are closed +/// in reverse order. (An open string was closed by the lexer.) +pub fn close_implicitly(tokens: &[Token]) -> Vec { + let mut out = Vec::with_capacity(tokens.len() + 4); + let mut stack: Vec<&'static str> = Vec::new(); + for t in tokens { + match t.tok { + Tok::P("(") => stack.push(")"), + Tok::P("[") => stack.push("]"), + Tok::P("{") => stack.push("}"), + Tok::P(c @ (")" | "]" | "}")) => { + if stack.last() == Some(&c) { + stack.pop(); + } else { + continue; + } + } + _ => {} + } + out.push(t.clone()); + } + let line = tokens.last().map_or(0, |t| t.line); + while let Some(c) = stack.pop() { + out.push(Token { tok: Tok::P(c), line, unterminated: false }); + } + out +} + +/// Parse a statement's expression: the whole of its tokens, nothing left over. +pub fn parse_expr(tokens: &[Token]) -> Result { + let mut p = Parser { t: tokens, i: 0 }; + if tokens.is_empty() { + return Err(ParseError { line: 0, message: "a statement with no expression".into() }); + } + let e = p.ternary()?; + if p.i < tokens.len() { + return Err(p.err("a token after the end of the expression")); + } + Ok(e) +} + +struct Parser<'a> { + t: &'a [Token], + i: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option<&Tok> { + self.t.get(self.i).map(|t| &t.tok) + } + fn at(&self, p: &str) -> bool { + matches!(self.peek(), Some(Tok::P(q)) if *q == p) + } + fn line(&self) -> usize { + self.t.get(self.i).or_else(|| self.t.last()).map_or(0, |t| t.line) + } + fn err(&self, m: &str) -> ParseError { + ParseError { line: self.line(), message: m.to_string() } + } + fn expect(&mut self, p: &str) -> Result<(), ParseError> { + if self.at(p) { + self.i += 1; + Ok(()) + } else { + Err(self.err(&format!("expected `{p}`"))) + } + } + + fn ternary(&mut self) -> Result { + let c = self.or()?; + if self.at("?") { + self.i += 1; + let a = self.ternary()?; + self.expect(":")?; + let b = self.ternary()?; + let line = c.line; + return Ok(E { expr: Expr::Ternary(Box::new(c), Box::new(a), Box::new(b)), line }); + } + Ok(c) + } + + fn binary_level(&mut self, ops: &[&'static str], next: fn(&mut Self) -> Result) -> Result { + let mut l = next(self)?; + loop { + let op = match self.peek() { + Some(Tok::P(p)) => ops.iter().find(|o| **o == *p).copied(), + _ => None, + }; + let Some(op) = op else { return Ok(l) }; + self.i += 1; + let r = next(self)?; + let line = l.line; + l = E { expr: Expr::Binary(op, Box::new(l), Box::new(r)), line }; + } + } + fn or(&mut self) -> Result { + self.binary_level(&["||"], Self::and) + } + fn and(&mut self) -> Result { + self.binary_level(&["&&"], Self::equality) + } + fn equality(&mut self) -> Result { + self.binary_level(&["==", "!="], Self::comparison) + } + fn comparison(&mut self) -> Result { + self.binary_level(&[">", "<", ">=", "<="], Self::additive) + } + fn additive(&mut self) -> Result { + self.binary_level(&["+", "-"], Self::multiplicative) + } + fn multiplicative(&mut self) -> Result { + self.binary_level(&["*", "/", "%"], Self::unary) + } + fn unary(&mut self) -> Result { + if self.at("!") || self.at("-") { + let op = if self.at("!") { "!" } else { "-" }; + let line = self.line(); + self.i += 1; + let e = self.unary()?; + return Ok(E { expr: Expr::Unary(op, Box::new(e)), line }); + } + self.postfix() + } + fn postfix(&mut self) -> Result { + let mut e = self.primary()?; + loop { + if self.at(".") { + self.i += 1; + let f = self.key()?; + let line = e.line; + e = E { expr: Expr::Member(Box::new(e), f), line }; + } else if self.at("[") { + self.i += 1; + let ix = self.ternary()?; + self.expect("]")?; + let line = e.line; + e = E { expr: Expr::Index(Box::new(e), Box::new(ix)), line }; + } else { + return Ok(e); + } + } + } + /// An object key or a field name: a name, a string, a number as written, a + /// component name, a keyword, or a state name without its `$`; any other + /// token is the key `?` (SPEC §11.4). + fn key(&mut self) -> Result { + let Some(t) = self.peek().cloned() else { return Err(self.err("expected a key")) }; + self.i += 1; + Ok(match t { + Tok::Ident(s) | Tok::Str(s) => s, + Tok::State(s) => s[1..].to_string(), + Tok::Num(_, text) | Tok::BadNum(text) => text, + Tok::True => "true".into(), + Tok::False => "false".into(), + Tok::Null => "null".into(), + Tok::At(s) => format!("@{s}"), + _ => "?".into(), + }) + } + /// Elements up to `close`, commas between them optional and ignored. + fn list(&mut self, close: &str) -> Result, ParseError> { + let mut v = Vec::new(); + loop { + while self.at(",") { + self.i += 1; + } + if self.at(close) { + self.i += 1; + return Ok(v); + } + if self.peek().is_none() { + return Err(self.err(&format!("expected `{close}`"))); + } + v.push(self.ternary()?); + } + } + fn primary(&mut self) -> Result { + let line = self.line(); + let Some(t) = self.peek().cloned() else { return Err(self.err("expected a value")) }; + self.i += 1; + let expr = match t { + Tok::Str(s) => Expr::Str(s), + Tok::Num(v, _) => Expr::Num(v), + Tok::BadNum(text) => { + self.i -= 1; + return Err(self.err(&format!("`{text}` is not a number: its exponent has no digits"))); + } + Tok::True => Expr::Bool(true), + Tok::False => Expr::Bool(false), + Tok::Null => Expr::Null, + Tok::P("[") => Expr::Array(self.list("]")?), + Tok::P("{") => { + let mut entries = Vec::new(); + loop { + while self.at(",") { + self.i += 1; + } + if self.at("}") { + self.i += 1; + break; + } + if self.peek().is_none() { + return Err(self.err("expected `}`")); + } + let k = self.key()?; + self.expect(":")?; + entries.push((k, self.ternary()?)); + } + Expr::Object(entries) + } + Tok::P("(") => { + let e = self.ternary()?; + self.expect(")")?; + return Ok(e); + } + Tok::Ident(name) => { + if self.at("(") && name.starts_with(|c: char| c.is_ascii_uppercase()) { + self.i += 1; + Expr::Call { head: Head::Component, name, args: self.list(")")? } + } else { + Expr::Ref(name) + } + } + Tok::At(name) => { + self.expect("(")?; + Expr::Call { head: Head::At, name, args: self.list(")")? } + } + Tok::State(name) => { + if self.at("=") { + self.i += 1; + Expr::Assign(name, Box::new(self.ternary()?)) + } else { + Expr::State(name) + } + } + _ => { + self.i -= 1; + return Err(self.err("expected a value")); + } + }; + Ok(E { expr, line }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lex::lex; + + fn names(src: &str, finished: bool) -> Vec<(String, bool, bool)> { + split(&lex(src), finished).into_iter().map(|s| (s.name, s.complete, s.open)).collect() + } + + #[test] + fn statements_complete_when_the_next_character_arrives() { + assert_eq!(names("a = 1\nb = 2", false), vec![("a".into(), true, false), ("b".into(), false, false)]); + assert_eq!(names("a = 1\n", false), vec![("a".into(), false, false)]); + assert_eq!(names("a = 1\n", true), vec![("a".into(), true, false)]); + // A next line starting `?` continues the statement. + assert_eq!(names("a = c\n? 1\n: 2\nb = 3", false), vec![("a".into(), true, false), ("b".into(), false, false)]); + assert_eq!(names("a = c\n", false), vec![("a".into(), false, false)]); + } + + #[test] + fn brackets_strings_and_ternaries_carry_a_statement_over_line_feeds() { + assert_eq!(names("a = A([\n b,\n c\n])\nd = 1", false).len(), 2); + assert_eq!(names("a = \"x\ny\"\nd = 1", false).len(), 2); + // A `:` branch still empty continues; an operand closes it. + assert_eq!(names("a = c ? 1 :\n 2\nd = 1", false).len(), 2); + assert_eq!(names("a = c ? 1\n : 2\nd = 1", false).len(), 2); + let s = split(&lex("a = A([b"), false); + assert!(s[0].open && !s[0].complete); + } + + #[test] + fn lines_without_the_shape_are_skipped() { + assert_eq!(names("hello there\n= 3\nx = 1\n", true), vec![("x".into(), true, false)]); + } + + fn p(src: &str) -> Result { + let s = split(&lex(src), true); + parse_expr(&s[0].tokens) + } + + #[test] + fn calls_arrays_objects() { + let e = p("x = Card(\"t\", [a, b,], {k: 1, \"q r\": [2] $s: 3})").unwrap(); + let Expr::Call { name, args, .. } = e.expr else { panic!() }; + assert_eq!(name, "Card"); + assert_eq!(args.len(), 3); + let Expr::Object(entries) = &args[2].expr else { panic!() }; + let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!(keys, vec!["k", "q r", "s"]); + } + + #[test] + fn precedence_and_postfix() { + let e = p("x = a + b * c > 1 && !d || e ? f.g[0] : -h").unwrap(); + assert!(matches!(e.expr, Expr::Ternary(..))); + assert!(matches!(p("x = m.0.1").unwrap().expr, Expr::Member(_, ref f) if f == "0.1")); + } + + #[test] + fn syntax_errors() { + assert!(p("x = Card(\"a\"").is_err()); + assert!(p("x = count(1)").is_err(), "a call head is uppercase or @"); + assert!(p("x = 1e").is_err()); + assert!(p("x = @Go").is_err()); + assert!(p("x = 1 2").is_err()); + } + + #[test] + fn implicit_closing_drops_strays_and_closes_in_reverse() { + let s = split(&lex("x = A([b, {k: \"v"), false); + let closed = close_implicitly(&s[0].tokens); + assert!(parse_expr(&closed).is_ok()); + let s = split(&lex("x = A(]"), false); + assert!(parse_expr(&close_implicitly(&s[0].tokens)).is_ok()); + } +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/prepare.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/prepare.rs new file mode 100644 index 000000000..f7e571818 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/prepare.rs @@ -0,0 +1,208 @@ +//! Source preparation (SPEC §11.1): the host's marker lines, fences, comments +//! and trimming, keeping for each line of the prepared text the line of the +//! source it came from, so that every error can name the line a person sees. + +/// The prepared program text and, for each of its lines, the 1-based line of +/// the source text it came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Prepared { + pub text: String, + pub lines: Vec, +} + +impl Prepared { + /// The source line of the prepared line `i` (0-based); 0 past the end. + pub fn source_line(&self, i: usize) -> u32 { + self.lines.get(i).copied().unwrap_or(0) + } +} + +/// The prefix of a host marker line (SPEC §11.1 step 2). +pub const MARKER: &str = "]]>openui:"; + +const FENCE: &str = "```"; + +/// Prepare `source` (SPEC §11.1): line feeds normalised, marker sections, +/// fences, comments, then trimming. +pub fn prepare(source: &str) -> Prepared { + // 1. A carriage return before a line feed goes with it. + let normalised = source.replace("\r\n", "\n"); + let mut lines: Vec<(u32, &str)> = normalised.split('\n').enumerate().map(|(i, l)| (i as u32 + 1, l)).collect(); + + // 2. Marker sections: with any marker line, the program is the lines of + // `content` sections alone. + if lines.iter().any(|(_, l)| l.starts_with(MARKER)) { + let mut kept = Vec::new(); + let mut in_content = false; + for (n, l) in lines { + if let Some(rest) = l.strip_prefix(MARKER) { + in_content = rest.starts_with("content"); + continue; + } + if in_content { + kept.push((n, l)); + } + } + lines = kept; + } + + // 3. Fences: with any fence, the program is the fences' contents, joined. + if lines.iter().any(|(_, l)| l.starts_with(FENCE)) { + let mut kept = Vec::new(); + let mut in_fence = false; + let mut in_dq = false; + let mut escaped = false; + for (n, l) in lines { + if !in_fence { + if l.starts_with(FENCE) { + in_fence = true; + in_dq = false; + escaped = false; + } + continue; + } + if !in_dq && l.starts_with(FENCE) { + in_fence = false; + continue; + } + kept.push((n, l)); + // Double-quoted strings shield a fence line; a string may run on + // over a line feed, so the state carries to the next line. + for c in l.chars() { + if escaped { + escaped = false; + } else if in_dq && c == '\\' { + escaped = true; + } else if c == '"' { + in_dq = !in_dq; + } + } + // The line feed between this line and the next is a character of + // the string too: it ends an escape. + escaped = false; + } + lines = kept; + } + + let mut map: Vec = lines.iter().map(|(n, _)| *n).collect(); + let joined: String = lines.iter().map(|(_, l)| *l).collect::>().join("\n"); + + // 4. Comments: `//` or `#` outside a string, to the end of its line. + let stripped = strip_comments(&joined); + + // 5. Trimming: leading and trailing whitespace (space, tab, line feed, + // carriage return). Lines dropped at the front leave the map too. + let is_ws = |c: char| matches!(c, ' ' | '\t' | '\n' | '\r'); + let lead: &str = &stripped[..stripped.len() - stripped.trim_start_matches(is_ws).len()]; + let dropped = lead.matches('\n').count(); + map.drain(..dropped.min(map.len())); + let text = stripped.trim_matches(is_ws).to_string(); + let n_lines = text.split('\n').count(); + map.truncate(n_lines); + Prepared { text, lines: map } +} + +/// Remove comments (SPEC §11.1 step 4): a `//` or a `#` outside a string starts +/// one, and it runs to the end of its line; the line feed stays. Strings, in +/// double or single quotes, may hold line feeds; a backslash in one escapes +/// the next character. +pub fn strip_comments(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let mut quote: Option = None; + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if let Some(q) = quote { + out.push(c); + if c == '\\' { + if let Some(&n) = chars.get(i + 1) { + out.push(n); + i += 2; + continue; + } + } else if c == q { + quote = None; + } + i += 1; + continue; + } + if c == '"' || c == '\'' { + quote = Some(c); + out.push(c); + i += 1; + continue; + } + if c == '#' || (c == '/' && chars.get(i + 1) == Some(&'/')) { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + continue; + } + out.push(c); + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_text_keeps_its_lines() { + let p = prepare("a = 1\nb = 2\n"); + assert_eq!(p.text, "a = 1\nb = 2"); + assert_eq!(p.lines, vec![1, 2]); + } + + #[test] + fn leading_blank_lines_shift_the_map() { + let p = prepare("\n\n a = 1\n\nb = 2 \n\n"); + assert_eq!(p.text, "a = 1\n\nb = 2"); + assert_eq!(p.lines, vec![3, 4, 5]); + } + + #[test] + fn fences_keep_their_contents_and_prose_goes() { + let src = "Here is your app:\n```openui-lang\nroot = A([x])\nx = B(\"a\")\n```\nEnjoy.\n```\ny = C()\n```"; + let p = prepare(src); + assert_eq!(p.text, "root = A([x])\nx = B(\"a\")\ny = C()"); + assert_eq!(p.lines, vec![3, 4, 8]); + } + + #[test] + fn a_fence_line_inside_a_double_quoted_string_does_not_close() { + let src = "```\nx = T(\"a\n```\nb\")\n```"; + let p = prepare(src); + assert_eq!(p.text, "x = T(\"a\n```\nb\")"); + } + + #[test] + fn an_unterminated_fence_runs_to_the_end() { + let p = prepare("prose\n```\nroot = A()\nx = B("); + assert_eq!(p.text, "root = A()\nx = B("); + assert_eq!(p.lines, vec![3, 4]); + } + + #[test] + fn comments_go_but_not_inside_strings() { + assert_eq!(strip_comments("a = 1 // one\nb = \"#fff\" # col"), "a = 1 \nb = \"#fff\" "); + assert_eq!(strip_comments("s = 'it''s // no'"), "s = 'it''s // no'"); + assert_eq!(strip_comments("s = \"a\\\" // still\" // gone"), "s = \"a\\\" // still\" "); + } + + #[test] + fn marker_sections_keep_content_alone() { + let src = "]]>openui:content?library=x%401\nroot = A()\n]]>openui:context\n{\"formState\": {}}\n]]>openui:end"; + let p = prepare(src); + assert_eq!(p.text, "root = A()"); + assert_eq!(p.lines, vec![2]); + } + + #[test] + fn crlf_is_one_line_feed() { + let p = prepare("a = 1\r\nb = 2\r\n"); + assert_eq!(p.text, "a = 1\nb = 2"); + } +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/tests.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/tests.rs new file mode 100644 index 000000000..0b2933287 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/tests.rs @@ -0,0 +1,363 @@ +//! The ingest's behaviour, rule by rule (SPEC §11). + +use crate::vectors::{ingest_cases, refusal_cases, stream_cases, test_library, write_cases}; +use crate::*; +use joule_ui_core::{validate, PropValue, Widget}; + +fn lib() -> Library { + test_library() +} + +fn text_of(w: &Widget) -> Option<&str> { + match w.props.get("text") { + Some(PropValue::Text(s)) => Some(s), + _ => None, + } +} + +fn names(w: &Widget) -> Vec { + let mut v = vec![w.name.clone()]; + for c in &w.children { + v.extend(names(c)); + } + v +} + +#[test] +fn a_program_becomes_a_validated_tree() { + let l = lib(); + let s = parse(&l, "root = Page([card], \"Home\")\ncard = Card(\"Hello\", [t1, t2], \"sub\")\nt1 = Text(\"one\")\nt2 = Text(\"two\", 14)\n"); + assert!(s.errors.is_empty(), "{:?}", s.errors); + let t = s.tree.expect("a tree"); + assert_eq!(names(&t), vec!["Page", "Card", "Text", "Text"]); + assert_eq!(t.props["title"], PropValue::Text("Home".into())); + assert_eq!(t.children[0].props["subtitle"], PropValue::Text("sub".into())); + assert_eq!(t.children[0].children[1].props["size"], PropValue::Number(14.0)); + assert!(validate(&t, &l.registry).is_empty()); + let keys: Vec<&str> = s.nodes.iter().map(|n| n.key.as_str()).collect(); + assert_eq!(keys, vec!["root", "card", "t1", "t2"]); + let lines: Vec = s.nodes.iter().map(|n| n.line).collect(); + assert_eq!(lines, vec![1, 2, 3, 4]); +} + +#[test] +fn unresolved_references_are_left_out_and_listed() { + let s = parse(&lib(), "root = Page([a, missing, b])\na = Text(\"a\")\nb = Text(\"b\")\n"); + assert_eq!(s.unresolved, vec!["missing"]); + assert!(s.errors.is_empty()); + assert_eq!(s.tree.unwrap().children.len(), 2); +} + +#[test] +fn inline_widgets_take_their_parents_key() { + let s = parse(&lib(), "root = Page([Card(\"c\", [Text(\"x\"), Text(\"y\")]), Text(\"z\")])\n"); + let keys: Vec<&str> = s.nodes.iter().map(|n| n.key.as_str()).collect(); + assert_eq!(keys, vec!["root", "root/0", "root/0/0", "root/0/1", "root/1"]); +} + +#[test] +fn every_refusal_names_its_code_and_line_and_the_rest_renders() { + let l = lib(); + for c in refusal_cases() { + let s = parse(&l, c.input); + let hit = s.errors.iter().find(|e| e.code.as_str() == c.code && e.line == c.line); + assert!(hit.is_some(), "{}: expected {} at line {}, got {:?}", c.name, c.code, c.line, s.errors); + assert!(!hit.unwrap().message.is_empty()); + // Everything else renders: the widget written to be kept is in the tree. + if c.input.contains("\"kept\"") { + let t = s.tree.as_ref().unwrap_or_else(|| panic!("{}: nothing rendered", c.name)); + let mut found = false; + fn any_kept(w: &Widget, found: &mut bool) { + if w.props.values().any(|v| *v == PropValue::Text("kept".into())) { + *found = true; + } + for c in &w.children { + any_kept(c, found); + } + } + any_kept(t, &mut found); + assert!(found, "{}: the kept widget is gone", c.name); + } + if let Some(t) = &s.tree { + assert!(validate(t, &l.registry).is_empty(), "{}: the tree must validate", c.name); + } + } +} + +#[test] +fn a_widget_the_registry_does_not_have_never_renders() { + let l = lib(); + let s = parse(&l, "root = Page([a, b])\na = Text(\"kept\")\nb = Chartt([1], [2])\n"); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page", "Text"]); + let e = &s.errors_with(Code::UnknownComponent)[0]; + assert_eq!((e.line, e.statement.as_str(), e.component.as_deref()), (3, "b", Some("Chartt"))); + assert!(e.message.contains("Card"), "the hint lists what the registry has: {}", e.message); +} + +#[test] +fn a_line_that_fails_validation_never_renders() { + let l = lib(); + // A required prop of the wrong type: the widget is dropped. + let s = parse(&l, "root = Page([a, b])\na = Text(\"kept\")\nb = Text(42)\n"); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page", "Text"]); + assert_eq!(s.errors_with(Code::TypeMismatch)[0].line, 3); + // A child its parent does not accept: the child is left out. + let s = parse(&l, "root = Page([tabs])\ntabs = Tabs([t1, x])\nt1 = Tab(\"kept\")\nx = Text(\"not a tab\")\n"); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page", "Tabs", "Tab"]); + let e = &s.errors_with(Code::DisallowedChild)[0]; + assert_eq!((e.line, e.component.as_deref()), (4, Some("Text"))); + // An optional prop of the wrong type: the prop is left out, the widget stays. + let s = parse(&l, "root = Page([a])\na = Text(\"kept\", \"big\")\n"); + let t = s.tree.unwrap(); + assert!(!t.children[0].props.contains_key("size")); + assert_eq!(text_of(&t.children[0]), Some("kept")); +} + +#[test] +fn nothing_the_host_owns_crosses_into_the_tree() { + let l = lib(); + for (src, line) in [ + ("root = Page([a])\na = Text($name)\n", 2), + ("root = Page([a])\na = Text(\"a\" + \"b\")\n", 2), + ("root = Page([a])\na = Text(x ? \"y\" : \"z\")\n", 2), + ("root = Page([a])\na = Text(d.title)\n", 2), + ("root = Page([a])\na = Text(\"t\", @Count([1]))\n", 2), + ("root = Page([a])\na = Button(\"t\", @Set($s, 1))\n", 2), + ("root = Page([a])\na = Button(\"t\", Action([@A(), @B()]))\n", 2), + ("root = Page()\n$s = 1\n", 2), + ("root = Page()\nq = Query(\"t\", {}, {})\n", 2), + ] { + let s = parse(&l, src); + let e = s.errors_with(Code::NotDeclarative); + assert_eq!(e.len(), 1, "{src}: {:?}", s.errors); + assert_eq!(e[0].line, line, "{src}"); + } +} + +#[test] +fn actions_carry_their_payload() { + let l = lib(); + let s = parse(&l, ingest_cases().iter().find(|c| c.name == "actions").unwrap().input); + assert!(s.errors.is_empty(), "{:?}", s.errors); + let t = s.tree.unwrap(); + let a = |i: usize| t.children[i].props["action"].clone(); + let act = |id: &str, p: Option| PropValue::Action { id: id.into(), payload: p.map(Box::new) }; + assert_eq!(a(0), act("Go", None)); + assert_eq!(a(1), act("Open", Some("x".into()))); + assert_eq!(a(2), act("Send", Some(PropValue::List(vec!["a".into(), PropValue::Number(2.0)])))); + assert_eq!(a(3), act("Pick", Some(PropValue::List(vec![PropValue::Number(1.0), PropValue::Number(2.0)])))); + assert_eq!(a(4), act("Go", None)); + assert!(matches!(a(5), PropValue::Action { ref id, .. } if id == "ToAssistant")); +} + +#[test] +fn the_entry_falls_back_to_the_root_widget_with_a_warning() { + let s = parse(&lib(), "t = Text(\"x\")\npage = Page([t])\n"); + assert!(s.tree.is_some()); + let e = &s.errors_with(Code::NoRoot)[0]; + assert!(e.warning); + let s = parse(&lib(), "a = Text(\"x\")\n"); + assert!(s.tree.is_none()); + assert!(!s.errors_with(Code::NoRoot)[0].warning); +} + +#[test] +fn a_cycle_is_unresolved_where_it_closes() { + let s = parse(&lib(), "root = Page([a])\na = Card(\"a\", [b])\nb = Card(\"b\", [a])\n"); + assert_eq!(s.unresolved, vec!["a"]); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page", "Card", "Card"]); +} + +// ── streaming ─────────────────────────────────────────────────────────────── + +/// Split `text` at the given byte offsets (each on a character boundary). +fn chunks_at(text: &str, cuts: &[usize]) -> Vec { + let mut v = Vec::new(); + let mut last = 0; + for &c in cuts { + if c > last && c < text.len() && text.is_char_boundary(c) { + v.push(text[last..c].to_string()); + last = c; + } + } + v.push(text[last..].to_string()); + v +} + +fn programs() -> Vec { + let mut v: Vec = ingest_cases().iter().map(|c| c.input.to_string()).collect(); + v.extend(refusal_cases().iter().map(|c| c.input.to_string())); + v.extend(stream_cases().iter().map(|c| c.chunks.concat())); + v +} + +/// Streaming equals parsing (SPEC §11.6): whatever the chunking, the snapshot at +/// the end of the stream is the batch parse of the whole text; and every +/// snapshot on the way holds only a tree the registry accepts. +#[test] +fn streaming_equals_parsing_for_every_chunking() { + let l = lib(); + let mut seed: u64 = 0x5eed_1234_abcd_0001; + for text in programs() { + let batch = parse(&l, &text); + for round in 0..12 { + let cuts: Vec = if round == 0 { + (1..text.len()).collect() // a byte at a time + } else { + let mut c: Vec = (0..round) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed as usize) % text.len().max(1) + }) + .collect(); + c.sort_unstable(); + c + }; + let mut p = StreamParser::new(&l); + for ch in chunks_at(&text, &cuts) { + let s = p.push(&ch); + if let Some(t) = &s.tree { + assert!(validate(t, &l.registry).is_empty(), "{text:?}: a snapshot's tree must validate"); + } + assert_eq!(s.nodes.len(), s.tree.as_ref().map_or(0, |t| names(t).len())); + } + let end = p.finish(); + assert_eq!(end, batch, "{text:?} cut at {cuts:?}"); + } + } +} + +#[test] +fn the_app_renders_progressively_as_lines_arrive() { + let l = lib(); + let text = "root = Page([card], \"Home\")\ncard = Card(\"Hello\", [t1, t2])\nt1 = Text(\"one\")\nt2 = Text(\"two\")\n"; + let mut p = StreamParser::new(&l); + let mut counts = Vec::new(); + for ch in chunks_at(text, &(1..text.len()).collect::>()) { + let s = p.push(&ch); + counts.push(s.tree.as_ref().map_or(0, |t| names(t).len())); + assert!(s.errors.is_empty(), "no error while a valid program streams: {:?}", s.errors); + } + // The page shows after its first line, before anything else has arrived ... + let first_line = text.find('\n').unwrap(); + assert!(counts[first_line] >= 1, "{counts:?}"); + // ... and the widgets only ever grow in number. + assert!(counts.windows(2).all(|w| w[1] >= w[0]), "{counts:?}"); + assert_eq!(*counts.last().unwrap(), 4); +} + +#[test] +fn a_pending_title_shows_as_it_streams() { + let l = lib(); + let mut p = StreamParser::new(&l); + p.push("root = Page([c])\nc = Card(\"A"); + let s = p.push(" long"); + assert_eq!(s.tree.unwrap().children[0].props["title"], PropValue::Text("A long".into())); + let s = p.push(" title\")"); + assert_eq!(s.tree.unwrap().children[0].props["title"], PropValue::Text("A long title".into())); +} + +#[test] +fn a_widget_waits_silently_for_its_required_props() { + let l = lib(); + let mut p = StreamParser::new(&l); + let s = p.push("root = Page([c])\nc = Card("); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page"]); + assert!(s.errors.is_empty()); + // A reference not yet written holds it back silently too ... + let s = p.push("title)\nd = 1\n"); + assert!(s.errors.is_empty(), "{:?}", s.errors); + assert_eq!(s.unresolved, vec!["title"]); + // ... until the stream ends without it. + let s = p.finish(); + assert_eq!(s.errors_with(Code::MissingRequired)[0].line, 2); +} + +#[test] +fn a_pending_statement_never_replaces_a_complete_one() { + let l = lib(); + let mut p = StreamParser::new(&l); + let s = p.push("root = Page([a])\na = Text(\"done\")\nx = 1\na = Text(\"partial"); + assert_eq!(text_of(&s.tree.unwrap().children[0]), Some("done")); + let s = p.push("\")\n"); + // Closed now, needing no implicit closing: the later statement binds. + assert_eq!(text_of(&s.tree.unwrap().children[0]), Some("partial")); +} + +#[test] +fn a_fence_arriving_late_restarts_the_parse() { + let l = lib(); + let mut p = StreamParser::new(&l); + let s = p.push("root = Page([a])\na = Text(\"x\")\n"); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page", "Text"]); + let s = p.push("```\nroot = Page()\n```"); + assert_eq!(names(s.tree.as_ref().unwrap()), vec!["Page"]); +} + +#[test] +fn the_reported_line_is_the_line_as_it_arrived() { + let s = parse(&lib(), "Here is the app.\n\n```openui-lang\nroot = Page([a, b])\na = Text(\"kept\")\nb = Text(42)\n```\n"); + assert_eq!(s.errors_with(Code::TypeMismatch)[0].line, 6); +} + +// ── the writer ────────────────────────────────────────────────────────────── + +#[test] +fn the_canonical_form_parses_back_to_the_same_tree() { + let l = lib(); + for (name, tree) in write_cases() { + let text = write(&tree, &l).unwrap_or_else(|e| panic!("{name}: {e}")); + let s = parse(&l, &text); + assert!(s.errors.is_empty(), "{name}: {:?}\n{text}", s.errors); + assert_eq!(s.tree.as_ref(), Some(&tree), "{name}:\n{text}"); + // And it is a fixed point. + assert_eq!(write(s.tree.as_ref().unwrap(), &l).unwrap(), text); + } + // Every tree the ingest cases build writes and reads back. + for c in ingest_cases() { + let s = parse(&l, c.input); + if let Some(t) = s.tree { + let text = write(&t, &l).unwrap(); + assert_eq!(parse(&l, &text).tree, Some(t), "{}", c.name); + } + } +} + +#[test] +fn the_canonical_form_is_one_statement_a_widget_in_pre_order() { + let l = lib(); + let tree = write_cases().into_iter().find(|(n, _)| *n == "page-of-cards").unwrap().1; + assert_eq!( + write(&tree, &l).unwrap(), + "root = Page([card1, card2], \"Home\")\ncard1 = Card(\"One\", [text1])\ntext1 = Text(\"a\")\ncard2 = Card(\"Two\", null, \"sub\")\n" + ); +} + +#[test] +fn what_cannot_be_written_is_refused() { + let l = lib(); + let null = Widget::leaf("Page").child(Widget::leaf("Text").prop("text", "t").prop("size", PropValue::Null)); + assert!(matches!(write(&null, &l), Err(WriteError::Invalid(_))), "a null number is not a number"); + let bad_id = Widget::leaf("Page").child(Widget::leaf("Button").prop("label", "x").prop("action", PropValue::Action { id: "not a name".into(), payload: None })); + assert_eq!(write(&bad_id, &l), Err(WriteError::ActionId("not a name".into()))); + let inf = Widget::leaf("Page").child(Widget::leaf("Text").prop("text", "t").prop("size", f64::INFINITY)); + assert!(matches!(write(&inf, &l), Err(WriteError::NotFinite { .. }))); + assert!(matches!(write(&Widget::leaf("Ghost"), &l), Err(WriteError::Invalid(_)))); +} + +#[test] +fn a_library_is_checked() { + use joule_ui_core::{AllowedChildren, PropSchema, PropType, Registry, WidgetSchema}; + let r = Registry::new().with(WidgetSchema::new("card", "lower")); + assert_eq!(Library::new(r, None).unwrap_err(), LibraryError::NotCallable("card".into())); + let r = Registry::new().with(WidgetSchema::new("Card", "c").prop("t", PropSchema::required(PropType::Text)).positional(["t", "children"])); + assert!(matches!(Library::new(r, None), Err(LibraryError::UnknownPositional { .. }))); + let r = Registry::new().with(WidgetSchema::new("Card", "c").children(AllowedChildren::Any).positional(["children", "children"])); + assert!(matches!(Library::new(r, None), Err(LibraryError::DuplicatePositional { .. }))); + let r = Registry::new().with(WidgetSchema::new("Action", "a")); + assert_eq!(Library::new(r, None).unwrap_err(), LibraryError::Reserved("Action".into())); + assert!(matches!(Library::new(Registry::new(), Some("Page".into())), Err(LibraryError::UnknownRoot(_)))); + assert_eq!(lib().signature("Card").unwrap(), "Card(title*, children, subtitle)"); +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/vectors.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/vectors.rs new file mode 100644 index 000000000..357f36bd6 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/vectors.rs @@ -0,0 +1,345 @@ +//! The conformance pack of the line language (SPEC §11.9): its library, its +//! cases, and the vectors the reference implementation gives for them. The +//! generator (`gen_lang_vectors`) writes them to `joule-ui/conformance/lang/v1/`; +//! the pack test regenerates them and compares byte for byte; the independent +//! verifier (`joule-ui/conformance/lang_verify.py`) recomputes them from the +//! specification text. + +use crate::{parse, write, Library, Snapshot, StreamParser}; +use joule_ui_core::{AllowedChildren, PropSchema, PropType, PropValue, Registry, Widget, WidgetSchema}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// The pack's library: a page of cards, text, buttons, tabs and a chart, its +/// root `Page`. +pub fn test_library() -> Library { + let r = Registry::new() + .with( + WidgetSchema::new("Page", "the page") + .prop("title", PropSchema::optional(PropType::Text)) + .children(AllowedChildren::Any) + .positional(["children", "title"]), + ) + .with( + WidgetSchema::new("Card", "a titled container") + .prop("title", PropSchema::required(PropType::Text)) + .prop("subtitle", PropSchema::optional(PropType::Text)) + .children(AllowedChildren::Any) + .positional(["title", "children", "subtitle"]), + ) + .with( + WidgetSchema::new("Text", "a run of text") + .prop("text", PropSchema::required(PropType::Text)) + .prop("size", PropSchema::optional(PropType::Number)) + .positional(["text", "size"]), + ) + .with( + WidgetSchema::new("Button", "a button") + .prop("label", PropSchema::required(PropType::Text)) + .prop("action", PropSchema::optional(PropType::Action)) + .prop("primary", PropSchema::optional(PropType::Bool)) + .positional(["label", "action", "primary"]), + ) + .with( + WidgetSchema::new("Tabs", "a strip of tabs") + .children(AllowedChildren::OnlyOf(vec!["Tab".into()])) + .positional(["children"]), + ) + .with( + WidgetSchema::new("Tab", "one tab") + .prop("title", PropSchema::required(PropType::Text)) + .children(AllowedChildren::Any) + .positional(["title", "children"]), + ) + .with( + WidgetSchema::new("Chart", "a chart of values") + .prop("labels", PropSchema::required(PropType::List)) + .prop("values", PropSchema::required(PropType::List)) + .prop("options", PropSchema::optional(PropType::Object)) + .positional(["labels", "values", "options"]), + ) + // No declared order: its props in key order, then `children`. + .with( + WidgetSchema::new("Note", "a note") + .prop("body", PropSchema::required(PropType::Text)) + .prop("author", PropSchema::optional(PropType::Text)) + .children(AllowedChildren::Any), + ); + Library::new(r, Some("Page".into())).expect("the pack's library is well-formed") +} + +/// A case of the batch classes: a name and the program text. +pub struct Case { + pub name: &'static str, + pub input: &'static str, +} + +/// The statement cases (L1): how text splits into statements. +pub fn statement_cases() -> Vec { + vec![ + Case { name: "one-a-line", input: "root = Page([a])\na = Text(\"hi\")\n" }, + Case { name: "blank-lines-and-prose-lines", input: "\n\nhello there\nroot = Page()\n= 3\n \nx = 1\n" }, + Case { name: "brackets-carry-over-lines", input: "root = Page([\n a,\n b\n])\na = Text(\"a\")\nb = Text(\"b\")" }, + Case { name: "string-carries-over-lines", input: "a = Text(\"line one\nline two\")\nroot = Page([a])" }, + Case { name: "ternary-continues-on-a-question-line", input: "x = c\n ? 1\n : 2\nroot = Page()" }, + Case { name: "ternary-empty-branch-continues", input: "x = c ? 1 :\n 2\nroot = Page()" }, + Case { name: "state-query-mutation-kinds", input: "$s = 1\nq = Query(\"t\", {}, {})\nm = Mutation(\"u\", {})\n$w = Query(\"t\", {}, {})\nroot = Page()" }, + Case { name: "syntax-error-is-a-statement-of-its-own", input: "root = Page([a])\na = Text(\"x\" 1 +)\nb = 1 2\n" }, + Case { name: "fenced-with-prose", input: "Here it is:\n```openui-lang\nroot = Page([a])\n```\nand a note\n```\na = Text(\"in a second fence\")\n```\n" }, + Case { name: "comments", input: "root = Page([a]) // the page\n# a heading comment\na = Text(\"#not a comment // nor this\")\n" }, + Case { name: "markers", input: "]]>openui:content?library=pack%401\nroot = Page([a])\na = Text(\"x\")\n]]>openui:context\n{\"formState\": {}}\n]]>openui:end\n" }, + Case { name: "unterminated-at-end", input: "root = Page([a\na = Text(\"open" }, + ] +} + +/// The ingest cases (L2): the tree, its nodes, its errors. +pub fn ingest_cases() -> Vec { + vec![ + Case { name: "hoisting", input: "root = Page([card], \"Home\")\ncard = Card(\"Hello\", [t1, t2], \"sub\")\nt1 = Text(\"one\")\nt2 = Text(\"two\", 14)\n" }, + Case { name: "unresolved-left-out", input: "root = Page([a, missing, b])\na = Text(\"a\")\nb = Text(\"b\")\n" }, + Case { name: "orphans", input: "root = Page([a])\na = Text(\"a\")\nold = Text(\"old\")\n" }, + Case { name: "inline-calls-and-keys", input: "root = Page([Card(\"c\", [Text(\"x\"), Text(\"y\")]), Text(\"z\")])\n" }, + Case { name: "used-twice", input: "root = Page([t, t])\nt = Text(\"again\")\n" }, + Case { name: "null-skips-an-optional", input: "root = Page([c])\nc = Card(\"t\", null, \"sub\")\n" }, + Case { name: "data-props", input: "root = Page([ch])\nch = Chart([\"a\", \"b\"], [1, -2.5, 3e2], {stacked: true, \"max value\": null, colours: ['red', \"blue\"]})\n" }, + Case { name: "actions", input: "root = Page([b1, b2, b3, b4, b5, b6])\nb1 = Button(\"none\", @Go())\nb2 = Button(\"one\", @Open(\"x\"))\nb3 = Button(\"two\", @Send(\"a\", 2))\nb4 = Button(\"list\", @Pick([1, 2]))\nb5 = Button(\"wrapped\", Action([@Go()]))\nb6 = Button(\"by name\", go)\ngo = @ToAssistant(\"hi\", {id: 1})\n" }, + Case { name: "only-of-children", input: "root = Page([tabs])\ntabs = Tabs([t1, t2])\nt1 = Tab(\"One\", [Text(\"1\")])\nt2 = Tab(\"Two\")\n" }, + Case { name: "derived-order", input: "root = Page([n])\nn = Note(\"by\", \"the body\", [Text(\"inside\")])\n" }, + Case { name: "duplicate-later-wins", input: "root = Page([a])\na = Text(\"first\")\na = Text(\"second\")\n" }, + Case { name: "cycle", input: "root = Page([a])\na = Card(\"a\", [b])\nb = Card(\"b\", [a])\n" }, + Case { name: "fallback-entry", input: "t = Text(\"x\")\npage = Page([t])\n" }, + Case { name: "escapes", input: "root = Page([a, b, c])\na = Text(\"tab\\there \\u00e9\")\nb = Text('it\\'s')\nc = Text(\"bad \\q escape\")\n" }, + Case { name: "empty", input: "" }, + ] +} + +/// The refusals (L5): negative controls, each refused with its code at its line. +pub struct Refusal { + pub name: &'static str, + pub input: &'static str, + pub code: &'static str, + pub line: u32, +} + +pub fn refusal_cases() -> Vec { + vec![ + Refusal { name: "unknown-component", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Chartt([1], [2])\n", code: "unknown-component", line: 3 }, + Refusal { name: "unknown-component-inline", input: "root = Page([\n Text(\"kept\"),\n Graph(1)\n])\n", code: "unknown-component", line: 3 }, + Refusal { name: "missing-required", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Card()\n", code: "missing-required", line: 3 }, + Refusal { name: "missing-required-never-written", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(words)\n", code: "missing-required", line: 3 }, + Refusal { name: "null-required", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(null)\n", code: "null-required", line: 3 }, + Refusal { name: "type-mismatch-required", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(42)\n", code: "type-mismatch", line: 3 }, + Refusal { name: "type-mismatch-optional-left-out", input: "root = Page([a])\na = Text(\"kept\", \"big\")\n", code: "type-mismatch", line: 2 }, + Refusal { name: "type-mismatch-widget-as-data", input: "root = Page([a])\na = Card(Text(\"no\"))\n", code: "type-mismatch", line: 2 }, + Refusal { name: "type-mismatch-bare-child", input: "root = Page(a)\na = Text(\"no list\")\n", code: "type-mismatch", line: 1 }, + Refusal { name: "type-mismatch-data-child", input: "root = Page([a, \"text\"])\na = Text(\"kept\")\n", code: "type-mismatch", line: 1 }, + Refusal { name: "excess-args", input: "root = Page([a])\na = Text(\"kept\", 12, \"extra\")\n", code: "excess-args", line: 2 }, + Refusal { name: "disallowed-child", input: "root = Page([tabs])\ntabs = Tabs([t1, x])\nt1 = Tab(\"kept\")\nx = Text(\"not a tab\")\n", code: "disallowed-child", line: 4 }, + Refusal { name: "state-statement", input: "root = Page([a])\na = Text(\"kept\")\n$count = 0\n", code: "not-declarative", line: 3 }, + Refusal { name: "state-in-a-prop", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text($name)\n", code: "not-declarative", line: 3 }, + Refusal { name: "query-statement", input: "root = Page([a])\na = Text(\"kept\")\nrows = Query(\"list\", {}, {rows: []})\n", code: "not-declarative", line: 3 }, + Refusal { name: "operator", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"a\" + \"b\")\n", code: "not-declarative", line: 3 }, + Refusal { name: "conditional", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(x\n ? \"y\"\n : \"z\")\n", code: "not-declarative", line: 3 }, + Refusal { name: "member-access", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(data.title)\n", code: "not-declarative", line: 3 }, + Refusal { name: "builtin-function", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"n\", @Count([1, 2]))\n", code: "not-declarative", line: 3 }, + Refusal { name: "state-action", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Button(\"x\", @Set($s, 1))\n", code: "not-declarative", line: 3 }, + Refusal { name: "action-sequence", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Button(\"x\", Action([@A(), @B()]))\n", code: "not-declarative", line: 3 }, + Refusal { name: "inline-reserved", input: "root = Page([a, b])\na = Text(\"kept\")\nb = Text(\"x\", Query(\"t\", {}, 1))\n", code: "inline-reserved", line: 3 }, + Refusal { name: "syntax", input: "root = Page([a])\na = Text(\"kept\")\nb = Text(\"x\" \"y\" +)\n", code: "syntax", line: 3 }, + Refusal { name: "no-root", input: "a = Text(\"nothing calls the root\")\n", code: "no-root", line: 0 }, + Refusal { name: "root-not-a-widget", input: "root = 42\n", code: "no-root", line: 1 }, + Refusal { name: "parse-failed", input: "just some prose a model wrote\n", code: "parse-failed", line: 0 }, + ] +} + +/// A streaming case (L3): the chunks in order. +pub struct StreamCase { + pub name: &'static str, + pub chunks: Vec<&'static str>, +} + +pub fn stream_cases() -> Vec { + vec![ + StreamCase { + name: "worked-example", + chunks: vec!["root = Page([card], \"Ho", "me\")\ncard = Card(\"Hel", "lo\", [t1, t2])\nt1 = Te", "xt(\"one\")\nt2 = Text(\"two\")\n"], + }, + StreamCase { name: "pending-title-grows", chunks: vec!["root = Page([c])\nc = Card(\"A", " long", " title\")"] }, + StreamCase { name: "held-until-required-arrives", chunks: vec!["root = Page([c])\nc = Card(", "\"t\")\n"] }, + StreamCase { name: "errors-wait-for-completion", chunks: vec!["root = Page([a, b])\na = Text(\"ok\")\nb = Text(4", "2)\nc = 1"] }, + StreamCase { name: "pending-does-not-overwrite", chunks: vec!["root = Page([a])\na = Text(\"done\")\nx = 1\na = Text(\"partial", "\")\n"] }, + StreamCase { name: "ternary-across-chunks", chunks: vec!["root = Page([a])\na = Text(\"t\")\nx = c\n", "? 1 : 2\ny = 3"] }, + StreamCase { name: "fence-arrives-late", chunks: vec!["root = Page([a])\na = Text(\"x\")\n", "```\nroot = Page()\n```"] }, + StreamCase { name: "open-string-mid-escape", chunks: vec!["root = Page([a])\na = Text(\"back\\", "slash\")"] }, + StreamCase { name: "unknown-then-known", chunks: vec!["root = Page([a, b])\nb = Text(\"b\")\na = Te", "x", "t(\"a\")\n"] }, + ] +} + +/// The writer cases (L4): trees and the canonical text of each. +pub fn write_cases() -> Vec<(&'static str, Widget)> { + let action = |id: &str, p: Option| PropValue::Action { id: id.into(), payload: p.map(Box::new) }; + let mut obj = BTreeMap::new(); + obj.insert("stacked".into(), PropValue::Bool(true)); + obj.insert("max value".into(), PropValue::Number(1e21)); + obj.insert("null".into(), PropValue::Null); + vec![ + ( + "page-of-cards", + Widget::leaf("Page") + .prop("title", "Home") + .child(Widget::leaf("Card").prop("title", "One").child(Widget::leaf("Text").prop("text", "a"))) + .child(Widget::leaf("Card").prop("title", "Two").prop("subtitle", "sub")), + ), + ("middle-absent-is-null", Widget::leaf("Page").child(Widget::leaf("Card").prop("title", "t").prop("subtitle", "s"))), + ( + "numbers-and-strings", + Widget::leaf("Page").child(Widget::leaf("Text").prop("text", "quote \" back \\ line\nfeed é \u{1}").prop("size", 0.1)).child(Widget::leaf("Text").prop("text", "neg").prop("size", -0.0)).child( + Widget::leaf("Text").prop("text", "small").prop("size", 1e-7), + ), + ), + ( + "data", + Widget::leaf("Page").child( + Widget::leaf("Chart") + .prop("labels", PropValue::List(vec!["a".into(), PropValue::Null])) + .prop("values", PropValue::List(vec![PropValue::Number(1.0), PropValue::List(vec![])])) + .prop("options", PropValue::Object(obj)), + ), + ), + ( + "actions", + Widget::leaf("Page") + .child(Widget::leaf("Button").prop("label", "none").prop("action", action("Go", None))) + .child(Widget::leaf("Button").prop("label", "one").prop("action", action("Open", Some("x".into())))) + .child(Widget::leaf("Button").prop("label", "two").prop("action", action("Send", Some(PropValue::List(vec!["a".into(), PropValue::Number(2.0)]))))) + .child(Widget::leaf("Button").prop("label", "list of one").prop("action", action("Pick", Some(PropValue::List(vec![PropValue::Number(1.0)]))))) + .child(Widget::leaf("Button").prop("label", "null").prop("action", action("Clear", Some(PropValue::Null))).prop("primary", true)), + ), + ("derived-order", Widget::leaf("Page").child(Widget::leaf("Note").prop("body", "b").child(Widget::leaf("Text").prop("text", "in")))), + ("tabs", Widget::leaf("Page").child(Widget::leaf("Tabs").child(Widget::leaf("Tab").prop("title", "x")).child(Widget::leaf("Tab").prop("title", "y")))), + ] +} + +/// A snapshot as the pack states it: the tree, its nodes, the errors without +/// their messages (prose for a model, not normative), the unresolved names, +/// the orphans, and whether the text ended open. +pub fn snapshot_json(s: &Snapshot) -> Value { + let errors: Vec = s + .errors + .iter() + .map(|e| { + let mut m = serde_json::Map::new(); + m.insert("line".into(), json!(e.line)); + m.insert("statement".into(), json!(e.statement)); + m.insert("code".into(), json!(e.code.as_str())); + if let Some(c) = &e.component { + m.insert("component".into(), json!(c)); + } + if let Some(p) = &e.prop { + m.insert("prop".into(), json!(p)); + } + if e.warning { + m.insert("warning".into(), json!(true)); + } + Value::Object(m) + }) + .collect(); + json!({ + "tree": s.tree, + "nodes": s.nodes, + "errors": errors, + "unresolved": s.unresolved, + "orphans": s.orphans, + "incomplete": s.incomplete, + }) +} + +/// Canonical JSON: keys sorted, two-space indent, a final line feed. +pub fn canonical(v: &Value) -> Vec { + fn sort(v: &Value) -> Value { + match v { + Value::Object(m) => { + let b: BTreeMap<&String, Value> = m.iter().map(|(k, x)| (k, sort(x))).collect(); + Value::Object(b.into_iter().map(|(k, x)| (k.clone(), x)).collect()) + } + Value::Array(a) => Value::Array(a.iter().map(sort).collect()), + o => o.clone(), + } + } + let mut out = serde_json::to_vec_pretty(&sort(v)).expect("json"); + out.push(b'\n'); + out +} + +/// The library as the pack publishes it: each widget's schema, its positional +/// order in effect written out, by name. +pub fn library_json(lib: &Library) -> Value { + let mut widgets = serde_json::Map::new(); + for name in lib.names() { + let s = lib.registry.get(&name).expect("listed"); + let mut v = serde_json::to_value(s).expect("schema"); + v["positional"] = json!(s.positional_order()); + widgets.insert(name, v); + } + json!({ "root": lib.root, "widgets": widgets }) +} + +/// Every file of the pack, by name, with its bytes; `MANIFEST.json` last. +pub fn pack() -> Vec<(String, Vec)> { + let lib = test_library(); + let mut files: Vec<(String, Vec)> = Vec::new(); + files.push(("library.json".into(), canonical(&library_json(&lib)))); + + let l1: Vec = statement_cases() + .iter() + .map(|c| { + let s = parse(&lib, c.input); + json!({ "name": c.name, "input": c.input, "statements": s.statements, "incomplete": s.incomplete }) + }) + .collect(); + files.push(("l1-statements.json".into(), canonical(&json!({ "class": "L1", "cases": l1 })))); + + let l2: Vec = ingest_cases() + .iter() + .map(|c| json!({ "name": c.name, "input": c.input, "expected": snapshot_json(&parse(&lib, c.input)) })) + .collect(); + files.push(("l2-ingest.json".into(), canonical(&json!({ "class": "L2", "cases": l2 })))); + + let l3: Vec = stream_cases() + .iter() + .map(|c| { + let mut p = StreamParser::new(&lib); + let after: Vec = c.chunks.iter().map(|ch| snapshot_json(&p.push(ch))).collect(); + let end = snapshot_json(&p.finish()); + json!({ "name": c.name, "chunks": c.chunks, "after_each": after, "at_end": end }) + }) + .collect(); + files.push(("l3-stream.json".into(), canonical(&json!({ "class": "L3", "cases": l3 })))); + + let l4: Vec = write_cases() + .iter() + .map(|(name, tree)| { + let text = write(tree, &lib).expect("the writer cases are writable"); + json!({ "name": name, "tree": tree, "text": text }) + }) + .collect(); + files.push(("l4-write.json".into(), canonical(&json!({ "class": "L4", "cases": l4 })))); + + let l5: Vec = refusal_cases() + .iter() + .map(|c| json!({ "name": c.name, "input": c.input, "code": c.code, "line": c.line, "expected": snapshot_json(&parse(&lib, c.input)) })) + .collect(); + files.push(("l5-refusals.json".into(), canonical(&json!({ "class": "L5", "cases": l5 })))); + + let mut digests = serde_json::Map::new(); + for (name, bytes) in &files { + digests.insert(name.clone(), json!(format!("sha256:{}", hex(&Sha256::digest(bytes))))); + } + files.push(("MANIFEST.json".into(), canonical(&json!({ "pack": "joule-ui-lang/v1", "files": digests })))); + files +} + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/write.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/write.rs new file mode 100644 index 000000000..3786aef3f --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/src/write.rs @@ -0,0 +1,179 @@ +//! The canonical writer (SPEC §11.7): a widget tree back to the language, one +//! statement a widget, in pre-order, so that streamed it renders from the top +//! down, and parsed it gives the same tree. + +use crate::ingest::{BUILTINS, STATE_ACTIONS}; +use crate::{Library, RESERVED}; +use joule_ui_core::{validate, PropValue, ValidationError, Widget}; +use std::collections::HashMap; + +/// Why a tree cannot be written. +#[derive(Debug, Clone, PartialEq)] +pub enum WriteError { + /// The tree does not pass the registry's validation. + Invalid(Vec), + /// A widget whose name does not start with an uppercase letter, or is + /// reserved (`Query`, `Mutation`, `Action`): no call can name it. + NotCallable(String), + /// A prop the widget's positional order does not list. + NotPositional { widget: String, prop: String }, + /// A prop whose value is `null`: a `null` argument is an absent one. + NullProp { widget: String, prop: String }, + /// A number that is not finite. + NotFinite { widget: String, prop: String }, + /// An action id that is not a name a call can carry (`@Id(...)`), or one the + /// language reserves. + ActionId(String), +} + +impl std::fmt::Display for WriteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WriteError::Invalid(e) => write!(f, "the tree does not validate against the registry ({} error(s))", e.len()), + WriteError::NotCallable(w) => write!(f, "no call can name the widget `{w}`"), + WriteError::NotPositional { widget, prop } => write!(f, "`{prop}` is not in the positional order of `{widget}`"), + WriteError::NullProp { widget, prop } => write!(f, "`{prop}` of `{widget}` is null, and a null argument is an absent one"), + WriteError::NotFinite { widget, prop } => write!(f, "`{prop}` of `{widget}` holds a number that is not finite"), + WriteError::ActionId(id) => write!(f, "the action id `{id}` cannot be written as `@{id}(...)`"), + } + } +} + +impl std::error::Error for WriteError {} + +fn is_name(s: &str) -> bool { + let mut c = s.chars(); + c.next().is_some_and(|f| f.is_ascii_alphabetic() || f == '_') && c.all(|x| x.is_ascii_alphanumeric() || x == '_') +} + +/// Write `tree` in the canonical form (SPEC §11.7). +pub fn write(tree: &Widget, lib: &Library) -> Result { + let errs = validate(tree, &lib.registry); + if !errs.is_empty() { + return Err(WriteError::Invalid(errs)); + } + // Names, in pre-order: `root`, then each widget's name with a lowercase + // first letter and a count of its own, from 1. + let mut flat: Vec<&Widget> = Vec::new(); + fn walk<'a>(w: &'a Widget, out: &mut Vec<&'a Widget>) { + out.push(w); + for c in &w.children { + walk(c, out); + } + } + walk(tree, &mut flat); + let mut counts: HashMap = HashMap::new(); + let mut names: Vec = Vec::with_capacity(flat.len()); + for (i, w) in flat.iter().enumerate() { + if i == 0 { + names.push("root".into()); + continue; + } + let mut base = String::new(); + let mut cs = w.name.chars(); + if let Some(f) = cs.next() { + base.push(f.to_ascii_lowercase()); + } + base.extend(cs); + let n = counts.entry(base.clone()).or_insert(0); + *n += 1; + names.push(format!("{base}{n}")); + } + // The index of each widget's first child in `flat`. + let mut out = String::new(); + let mut index = 0usize; + fn emit(w: &Widget, lib: &Library, names: &[String], index: &mut usize, out: &mut String) -> Result<(), WriteError> { + let me = *index; + *index += 1; + if !w.name.starts_with(|c: char| c.is_ascii_uppercase()) || RESERVED.contains(&w.name.as_str()) || !is_name(&w.name) { + return Err(WriteError::NotCallable(w.name.clone())); + } + let schema = lib.registry.get(&w.name).expect("validated"); + let order = schema.positional_order(); + for p in w.props.keys() { + if !order.contains(p) { + return Err(WriteError::NotPositional { widget: w.name.clone(), prop: p.clone() }); + } + } + // The children's names: each child's index in pre-order. + let mut child_names = Vec::new(); + let mut at = me + 1; + for c in &w.children { + child_names.push(names[at].clone()); + at += count(c); + } + let mut args: Vec> = Vec::new(); + for slot in &order { + if slot == "children" { + args.push(if child_names.is_empty() { None } else { Some(format!("[{}]", child_names.join(", "))) }); + continue; + } + match w.props.get(slot) { + None => args.push(None), + Some(PropValue::Null) => return Err(WriteError::NullProp { widget: w.name.clone(), prop: slot.clone() }), + Some(v) => args.push(Some(value(v, &w.name, slot)?)), + } + } + while args.last().is_some_and(Option::is_none) { + args.pop(); + } + let args: Vec = args.into_iter().map(|a| a.unwrap_or_else(|| "null".into())).collect(); + out.push_str(&format!("{} = {}({})\n", names[me], w.name, args.join(", "))); + for c in &w.children { + emit(c, lib, names, index, out)?; + } + Ok(()) + } + fn count(w: &Widget) -> usize { + 1 + w.children.iter().map(count).sum::() + } + emit(tree, lib, &names, &mut index, &mut out)?; + Ok(out) +} + +/// A number as the canonical form writes it: the shortest decimal that reads +/// back to the same value, in positional notation, with no exponent and no +/// trailing `.0` (`-0` for negative zero). +pub fn number_text(n: f64) -> String { + format!("{n}") +} + +fn value(v: &PropValue, widget: &str, prop: &str) -> Result { + Ok(match v { + PropValue::Null => "null".into(), + PropValue::Bool(b) => b.to_string(), + PropValue::Number(n) => { + if !n.is_finite() { + return Err(WriteError::NotFinite { widget: widget.into(), prop: prop.into() }); + } + number_text(*n) + } + PropValue::Text(s) => serde_json::to_string(s).expect("a string serialises"), + PropValue::List(items) => { + let v: Result, WriteError> = items.iter().map(|i| value(i, widget, prop)).collect(); + format!("[{}]", v?.join(", ")) + } + PropValue::Object(m) => { + let mut parts = Vec::new(); + for (k, x) in m { + let key = if is_name(k) && !matches!(k.as_str(), "true" | "false" | "null") { k.clone() } else { serde_json::to_string(k).expect("a string serialises") }; + parts.push(format!("{key}: {}", value(x, widget, prop)?)); + } + format!("{{{}}}", parts.join(", ")) + } + PropValue::Action { id, payload } => { + if !is_name(id) || id == "Action" || BUILTINS.contains(&id.as_str()) || STATE_ACTIONS.contains(&id.as_str()) { + return Err(WriteError::ActionId(id.clone())); + } + let args = match payload.as_deref() { + None => String::new(), + Some(PropValue::List(items)) if items.len() >= 2 => { + let v: Result, WriteError> = items.iter().map(|i| value(i, widget, prop)).collect(); + v?.join(", ") + } + Some(p) => value(p, widget, prop)?, + }; + format!("@{id}({args})") + } + }) +} diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-lang/tests/pack.rs b/joule-ui/joule-ui-rs/crates/joule-ui-lang/tests/pack.rs new file mode 100644 index 000000000..81b6305e2 --- /dev/null +++ b/joule-ui/joule-ui-rs/crates/joule-ui-lang/tests/pack.rs @@ -0,0 +1,20 @@ +//! The published pack (`joule-ui/conformance/lang/v1/`) is exactly what the +//! reference implementation gives: every file regenerated and compared byte for +//! byte, and no file in the directory that the pack does not hold. + +use std::path::PathBuf; + +#[test] +fn the_published_pack_is_what_the_implementation_gives() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../conformance/lang/v1"); + let pack = joule_ui_lang::vectors::pack(); + for (name, bytes) in &pack { + let on_disk = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e} (regenerate with `cargo run -p joule-ui-lang --bin gen_lang_vectors`)")); + assert!(on_disk == *bytes, "{name} differs from what the implementation gives (regenerate with `cargo run -p joule-ui-lang --bin gen_lang_vectors`)"); + } + let mut listed: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + listed.sort(); + let mut expected: Vec = pack.into_iter().map(|(n, _)| n).collect(); + expected.sort(); + assert_eq!(listed, expected, "the pack directory holds exactly the pack"); +} -- 2.54.0 (Apple Git-157) From 96129e5ef94df630a54e37f3d30769c2750c2802 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:31:20 -0400 Subject: [PATCH 02/14] wai-ui: WAI's widgets for joule-ui, and Create (describe an app, watch it build, export it) - a joule-ui registry of WAI widgets: App, Room, WatchTogether, Show, Player, Chat, Poll, Participants, Receipts, EnergyMeter, Text; each room kind accepts only the widgets its room service keeps (a watch room holds no Chat and no Poll), so a line that asks for more is refused disallowed-child at its line; - WAI's own rules on an app (one room, a poll's options, a show's audience, a watch room's source) and the room an app needs; - the guide a model writes against, made from the registry; - export: the one writer of a starter project (the program, its tree, the room service's configuration, the page, a check that is its build, a development front, README, AGENTS.md, the runtime under vendor/; no key); - the page's WebAssembly surface: the program streamed into snapshots, and every action sealed into the page's hash-chained log through joule-ui-interaction; - wai-ui mcp: an MCP server and a loopback bridge, so a person's own coding agent drives Create; - web/: the streaming renderer (keyed, so a joined room survives the next chunk), the components over the room client in a same-origin frame, the Create page with its three model sources (the visitor's own model and key, their coding agent, a recorded stream), the exported app's page, and the runtime layout; - e2e/: Create in a browser against a real room service and relay. Co-Authored-By: Claude Opus 5.5 --- wai/wai-ui/.gitignore | 1 + wai/wai-ui/Cargo.lock | 553 +++++++++++++++++++++ wai/wai-ui/Cargo.toml | 45 ++ wai/wai-ui/README.md | 65 +++ wai/wai-ui/e2e/create.e2e.mjs | 309 ++++++++++++ wai/wai-ui/e2e/package-lock.json | 58 +++ wai/wai-ui/e2e/package.json | 10 + wai/wai-ui/e2e/run.sh | 77 +++ wai/wai-ui/src/bin/wai_ui.rs | 64 +++ wai/wai-ui/src/export.rs | 330 ++++++++++++ wai/wai-ui/src/guide.rs | 62 +++ wai/wai-ui/src/lib.rs | 193 +++++++ wai/wai-ui/src/mcp.rs | 379 ++++++++++++++ wai/wai-ui/src/plan.rs | 265 ++++++++++ wai/wai-ui/src/registry.rs | 147 ++++++ wai/wai-ui/src/web.rs | 202 ++++++++ wai/wai-ui/templates/AGENTS.md | 20 + wai/wai-ui/templates/README.md | 37 ++ wai/wai-ui/templates/check.mjs | 109 ++++ wai/wai-ui/templates/gitignore | 4 + wai/wai-ui/templates/index.html | 15 + wai/wai-ui/templates/package.json | 15 + wai/wai-ui/templates/serve.mjs | 128 +++++ wai/wai-ui/tests/mcp.rs | 91 ++++ wai/wai-ui/web/app.js | 23 + wai/wai-ui/web/components.js | 460 +++++++++++++++++ wai/wai-ui/web/create.js | 203 ++++++++ wai/wai-ui/web/index.html | 58 +++ wai/wai-ui/web/model.js | 113 +++++ wai/wai-ui/web/recorded/room.json | 41 ++ wai/wai-ui/web/recorded/show.json | 85 ++++ wai/wai-ui/web/recorded/watch-refused.json | 51 ++ wai/wai-ui/web/renderer.js | 112 +++++ wai/wai-ui/web/room-frame.html | 197 ++++++++ wai/wai-ui/web/runtime.mjs | 119 +++++ wai/wai-ui/web/session.js | 59 +++ wai/wai-ui/web/ui.css | 112 +++++ wai/wai-ui/web/web.test.mjs | 118 +++++ wai/wai-ui/web/zip.js | 103 ++++ 39 files changed, 5033 insertions(+) create mode 100644 wai/wai-ui/.gitignore create mode 100644 wai/wai-ui/Cargo.lock create mode 100644 wai/wai-ui/Cargo.toml create mode 100644 wai/wai-ui/README.md create mode 100644 wai/wai-ui/e2e/create.e2e.mjs create mode 100644 wai/wai-ui/e2e/package-lock.json create mode 100644 wai/wai-ui/e2e/package.json create mode 100755 wai/wai-ui/e2e/run.sh create mode 100644 wai/wai-ui/src/bin/wai_ui.rs create mode 100644 wai/wai-ui/src/export.rs create mode 100644 wai/wai-ui/src/guide.rs create mode 100644 wai/wai-ui/src/lib.rs create mode 100644 wai/wai-ui/src/mcp.rs create mode 100644 wai/wai-ui/src/plan.rs create mode 100644 wai/wai-ui/src/registry.rs create mode 100644 wai/wai-ui/src/web.rs create mode 100644 wai/wai-ui/templates/AGENTS.md create mode 100644 wai/wai-ui/templates/README.md create mode 100644 wai/wai-ui/templates/check.mjs create mode 100644 wai/wai-ui/templates/gitignore create mode 100644 wai/wai-ui/templates/index.html create mode 100644 wai/wai-ui/templates/package.json create mode 100644 wai/wai-ui/templates/serve.mjs create mode 100644 wai/wai-ui/tests/mcp.rs create mode 100644 wai/wai-ui/web/app.js create mode 100644 wai/wai-ui/web/components.js create mode 100644 wai/wai-ui/web/create.js create mode 100644 wai/wai-ui/web/index.html create mode 100644 wai/wai-ui/web/model.js create mode 100644 wai/wai-ui/web/recorded/room.json create mode 100644 wai/wai-ui/web/recorded/show.json create mode 100644 wai/wai-ui/web/recorded/watch-refused.json create mode 100644 wai/wai-ui/web/renderer.js create mode 100644 wai/wai-ui/web/room-frame.html create mode 100644 wai/wai-ui/web/runtime.mjs create mode 100644 wai/wai-ui/web/session.js create mode 100644 wai/wai-ui/web/ui.css create mode 100644 wai/wai-ui/web/web.test.mjs create mode 100644 wai/wai-ui/web/zip.js diff --git a/wai/wai-ui/.gitignore b/wai/wai-ui/.gitignore new file mode 100644 index 000000000..53adb4007 --- /dev/null +++ b/wai/wai-ui/.gitignore @@ -0,0 +1 @@ +/e2e/node_modules/ diff --git a/wai/wai-ui/Cargo.lock b/wai/wai-ui/Cargo.lock new file mode 100644 index 000000000..5dabe2017 --- /dev/null +++ b/wai/wai-ui/Cargo.lock @@ -0,0 +1,553 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "joule-ui-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "joule-ui-interaction" +version = "0.1.0" +dependencies = [ + "blake3", + "ed25519-dalek", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "joule-ui-lang" +version = "0.1.0" +dependencies = [ + "joule-ui-core", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai-ui" +version = "0.1.0" +dependencies = [ + "bs58", + "ed25519-dalek", + "joule-ui-core", + "joule-ui-interaction", + "joule-ui-lang", + "serde", + "serde_json", + "sha2", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/wai/wai-ui/Cargo.toml b/wai/wai-ui/Cargo.toml new file mode 100644 index 000000000..cfa9060c9 --- /dev/null +++ b/wai/wai-ui/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "wai-ui" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +description = "WAI's widgets for joule-ui, and Create: a registry of WAI widgets (rooms, watching together, a show, chat, a poll, the room's video, receipts, energy) for agent-emitted surfaces, the guide a model writes against, the plan of the room service an app needs, the one writer of a starter project, a WebAssembly build for the page that renders an app while a model streams it, and an MCP server a coding agent drives Create through." +repository = "https://git.transaction.science/Transaction-Science/open-standards" +publish = false + +# Its own workspace and Cargo.lock, like the other crates beside wai-rs. +[workspace] + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "wai-ui" +path = "src/bin/wai_ui.rs" +required-features = ["cli"] + +[dependencies] +joule-ui-core = { path = "../../joule-ui/joule-ui-rs/crates/joule-ui-core" } +joule-ui-lang = { path = "../../joule-ui/joule-ui-rs/crates/joule-ui-lang" } +joule-ui-interaction = { path = "../../joule-ui/joule-ui-rs/crates/joule-ui-interaction" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ed25519-dalek = "2" +sha2 = "0.10" +bs58 = "0.5" +# Pinned to the wasm-bindgen CLI the build runs: the two must match exactly. +wasm-bindgen = { version = "=0.2.127", optional = true } + +[features] +default = ["cli"] +# The command line: `wai-ui guide | check | export | mcp | registry`. +cli = [] +# The page's WebAssembly surface (src/web.rs), built for wasm32-unknown-unknown with +# `--no-default-features --features web`. +web = ["dep:wasm-bindgen"] + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 diff --git a/wai/wai-ui/README.md b/wai/wai-ui/README.md new file mode 100644 index 000000000..418fe612e --- /dev/null +++ b/wai/wai-ui/README.md @@ -0,0 +1,65 @@ +# wai-ui + +WAI's widgets for joule-ui, and **Create**: describe a WAI app, watch it build itself +from WAI widgets while a model writes it, try its room, and export it as a starter +project. The design is in [`joule-ui/docs/create.md`](../../joule-ui/docs/create.md); the +line language a model writes the app in is joule-ui's (`joule-ui/SPEC.md` §11). + +## The widgets + +A joule-ui registry (`src/registry.rs`), each widget backed by the room client of +`joule-moq-web` (`web/components.js`): + +| Widget | What it is | Holds | +|---|---|---| +| `App(children, title)` | the app | one room; `Text`, `Receipts`, `EnergyMeter` | +| `Room(children, title)` | a live room: join with camera and microphone, or watch | `Player`, `Chat`, and any room's widgets | +| `WatchTogether(source, children, title)` | a watch room: one file, watched in sync | any room's widgets (a watch room keeps the shared controls alone) | +| `Show(children, title, audience)` | a show: hosts on camera, an audience that votes | `Player`, `Chat`, `Poll`, and any room's widgets | +| `Player(title)` | the room's video, laid out from its signed record | | +| `Chat(title)` | the room's chat, each message a signed, verified event | | +| `Poll(question, options, seconds)` | a vote the show's lead opens and its clock closes and signs | | +| `Participants(title)` | who is present, from the room's lifecycle | | +| `Receipts(title)` | the page's sealed log of every action, verified | | +| `EnergyMeter(title)` | each action's energy, and how it was obtained | | +| `Text(text)` | a paragraph | | + +Any room's widgets: `Participants`, `Receipts`, `EnergyMeter`, `Text`. An app holds one +room; a poll has 2 to 32 options; a show's audience is 1 to 255; a watch room's source is +a file within the app (`src/plan.rs`). A line that breaks a rule is refused with its line +and a reason code, and does not render. + +## The pieces + +- `src/guide.rs`: what a model is told before it writes an app, made from the registry. +- `src/lib.rs`: `check` and `Create`, a program as a snapshot the page renders, whole or + as it streams, with the room the app needs. +- `src/export.rs`: the one writer of a starter project. Every file and its layout: + `app.oui`, `app.json`, `room.toml`, `relay.toml`, `index.html`, `package.json`, + `check.mjs` (the project's build), `serve.mjs` (development only), `vendor.json`, + `README.md`, `AGENTS.md`, `.gitignore`, `vendor/` (the runtime), and for a watch room + `media/README.md`. No key is ever in it: development keys are made in `.wai-dev/` at run + time. +- `src/web.rs` (feature `web`): the page's WebAssembly surface, with the interaction log + every action on the page is sealed into (joule-ui-interaction). +- `src/mcp.rs`: `wai-ui mcp`, an MCP server a coding agent drives Create through + (`create_guide`, `create_check`, `create_stream`, `create_export`), and the bridge on + 127.0.0.1 that carries a program into the person's open Create page. +- `web/`: the streaming renderer, the components, the room frame, the Create page and the + exported app's page; `web/runtime.mjs` lays the runtime out as the site serves it. + +## Run it + +``` +cargo test # registry, plan, guide, export, MCP (and the binary over stdio) +node --test web/web.test.mjs # the renderer, the ZIP writer, the model streams +e2e/run.sh # Create in a browser, against a room service and a relay +cargo run -- guide # what a model is told +cargo run -- check app.oui # a program's tree, errors and room +cargo run -- export app.oui ./my-app --runtime # a starter project +cargo run -- mcp # the MCP server, for a coding agent +``` + +## Licence + +Apache-2.0. diff --git a/wai/wai-ui/e2e/create.e2e.mjs b/wai/wai-ui/e2e/create.e2e.mjs new file mode 100644 index 000000000..1db9ff473 --- /dev/null +++ b/wai/wai-ui/e2e/create.e2e.mjs @@ -0,0 +1,309 @@ +// Create, end to end, in the host's browser: a recorded model stream fed to the page, the +// app rendering as it arrives, its room joined on a real room service, a poll opened and +// answered, and the exported project built and run. +// +// node create.e2e.mjs --rt --relay --room +// --ui --out [--browser ] +// +// Scenarios, in order: +// create the `show` recording replayed into Create: widgets appear while the stream is +// still arriving and only grow in number; every snapshot's tree validates; the room +// widget's frame loads the room client while later lines still stream; the host +// joins (the room's clock records it), sends a chat, opens the poll; the page's +// sealed log holds each action and verifies; the export downloads, its text files +// the same bytes `wai-ui export` writes +// built the exported project: `node check.mjs` (its build) passes and writes dist/; its +// own front (`node serve.mjs`) serves the app against the same room service; a +// viewer there watches, sees the host's chat, votes, and both pages show the +// result the room's clock signed +// refused the `watch-refused` recording: the Chat and Poll a watch room cannot keep never +// render, each refused `disallowed-child` at its line, and export stays off +// up the exported project's `node serve.mjs --up` starts its own relay and room +// service from its configuration, keys made in .wai-dev/, and a page there watches +// +// Every wait is bounded; nothing listens beyond loopback. +import fs from "node:fs"; +import path from "node:path"; +import net from "node:net"; +import { createRequire } from "node:module"; +import { spawn, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const LIB = path.resolve(HERE, "../../../joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs"); +const { serveSameOrigin, browserArgs, memguardedBrowser, killStrays, until, sleep } = await import(LIB); + +const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), [])); +const need = (k) => { if (!args[k]) { console.error(`create e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); }; +const RT = need("rt"), RELAY = need("relay"), ROOM = need("room"), UI = need("ui"); +const OUT = need("out"); +const ONLY = (args.only || "create,built,refused,up").split(","); +fs.mkdirSync(OUT, { recursive: true }); +const require = createRequire(path.join(process.env.PLAYWRIGHT_HOST || path.join(HERE, "node_modules"), "x")); +const { chromium } = require("playwright"); + +const STEP = 60000; +let failed = 0; +const results = []; +function check(name, ok, detail = "") { + results.push({ name, ok, detail }); + if (!ok) failed++; + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +} +process.on("unhandledRejection", (e) => { console.error("unhandled:", (e && e.stack) || e); killStrays(); process.exit(1); }); + +const freePort = () => new Promise((ok) => { const s = net.createServer(); s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => ok(p)); }); }); +const procs = []; +function run(bin, argv, name, dir, env = {}) { + const log = fs.openSync(path.join(dir, `${name}.log`), "w"); + const p = spawn(bin, argv, { stdio: ["ignore", log, log], env: { ...process.env, NO_COLOR: "1", ...env }, cwd: env.CWD || dir }); + p.logFile = path.join(dir, `${name}.log`); + procs.push(p); + return p; +} +const tail = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").slice(-8).join(" | "); } catch { return ""; } }; + +// The room service and the relay for a project's room.toml and relay.toml, on free ports, +// keys made in `dir`: as the project's own `serve.mjs --up` makes them, by hand here so the +// test holds the processes. +async function services(project, dir) { + const keys = path.join(dir, "room-keys.toml"); + const lines = execFileSync(ROOM, ["keygen", keys], { stdio: ["ignore", "pipe", "pipe"] }).toString(); + const clock = lines.match(/^# clock\s+([0-9a-f]{64})/m)[1]; + const relayPort = await freePort(); + const roomPort = await freePort(); + const roomToml = fs.readFileSync(path.join(project, "room.toml"), "utf8"); + const show = /^\[co\][^[]*^audience_input\s*=\s*[1-9]/m.test(roomToml); + let relay = fs.readFileSync(path.join(project, "relay.toml"), "utf8").replace(/^websocket_listen = .*$/m, `websocket_listen = "127.0.0.1:${relayPort}"`).replace(/^out_path = .*$/m, `out_path = ${JSON.stringify(path.join(dir, "summaries.jsonl"))}`); + relay += `\n${lines}\n${show ? `\n[floor]\nroom_keys = ["${clock}"]\n` : ""}`; + fs.writeFileSync(path.join(dir, "relay.toml"), relay); + const room = roomToml.replace(/^listen = .*$/m, `listen = "127.0.0.1:${roomPort}"`).replace(/^keys = .*$/m, `keys = ${JSON.stringify(keys)}`).replace(/^relay_url = .*$/m, `relay_url = "ws://127.0.0.1:${relayPort}/moq"`); + fs.writeFileSync(path.join(dir, "room.toml"), room); + const r = run(RELAY, ["--config", path.join(dir, "relay.toml")], "relay", dir); + await until("the relay listens", 30000, () => new Promise((ok, no) => { if (r.exitCode != null) return no(new Error(`the relay exited: ${tail(r.logFile)}`)); const s = net.connect(relayPort, "127.0.0.1", () => { s.end(); ok(true); }); s.on("error", () => ok(false)); }), 200); + const s = run(ROOM, ["--config", path.join(dir, "room.toml")], "room", dir); + await until("the room service's clock on the relay", 30000, async () => { + if (s.exitCode != null) throw new Error(`the room service exited: ${tail(s.logFile)}`); + try { const x = await fetch(`http://127.0.0.1:${roomPort}/health`); return x.ok && (await x.json()).relay === true; } catch { return false; } + }, 200); + return { relayPort, roomPort, url: `http://127.0.0.1:${roomPort}`, show }; +} + +let browser; +async function launch() { + const bin = args.browser ? path.resolve(args.browser) : null; + const opts = { headless: true, args: browserArgs() }; + if (bin) opts.executablePath = memguardedBrowser(bin, OUT).executable; + browser = await chromium.launch(opts); + console.log(`browser: ${browser.version()}`); +} +async function page(url, name) { + const ctx = await browser.newContext({ permissions: ["camera", "microphone"], acceptDownloads: true }); + const p = await ctx.newPage(); + const logs = []; + p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); + p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`)); + p.on("response", (r) => { if (r.status() >= 400) logs.push(`[http ${r.status()}] ${r.url()}`); }); + p.dump = () => fs.writeFileSync(path.join(OUT, `${name}.console.log`), logs.join("\n")); + p.logs = logs; + await p.goto(url, { waitUntil: "domcontentloaded", timeout: STEP }); + return p; +} +const roomOf = (p) => p.evaluate(() => { const r = (window.createTest || window.appTest).ctx.room.current; return r ? { phase: r.phase(), me: r.me(), co: r.co(), chats: r.chats(), checked: r.checked(), present: r.present() } : null; }); + +const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `${n}.json`), "utf8")); +const SHOW = recording("show"); +const SHOW_TEXT = SHOW.chunks.join(""); + +let shared = null; // the room service and relay of the show, the Create page, the export +async function scenarioCreate() { + const dir = path.join(OUT, "create"); + fs.mkdirSync(dir, { recursive: true }); + // The project `wai-ui export` writes for the recording's program: its room.toml and relay.toml + // configure the room service the page tries the app on. + fs.writeFileSync(path.join(dir, "show.oui"), SHOW_TEXT); + const cli = path.join(dir, "cli-export"); + fs.rmSync(cli, { recursive: true, force: true }); + execFileSync(UI, ["export", path.join(dir, "show.oui"), cli], { stdio: ["ignore", "pipe", "pipe"] }); + const svc = await services(cli, dir); + check("create: the exported room.toml configures a room service that joins its relay", true, `room service :${svc.roomPort}, relay :${svc.relayPort}, show=${svc.show}`); + const srv = await serveSameOrigin(RT, { service: svc.url, relayPort: svc.relayPort }); + const p = await page(`${srv.url}/create/index.html`, "create-host"); + await until("Create loads its session", STEP, () => p.evaluate(() => !!window.createTest)); + // Watch the page while the stream arrives: when each widget appears, and how much of the + // program had arrived then. + await p.evaluate(() => { + window.__seen = []; + new MutationObserver(() => { + for (const el of document.querySelectorAll("#app [data-widget]")) { + if (el.__seen) continue; + el.__seen = true; + window.__seen.push({ widget: el.dataset.widget, key: el.dataset.key, chars: window.createTest.session.program().length, state: document.getElementById("create").dataset.state }); + } + }).observe(document.getElementById("app"), { childList: true, subtree: true }); + }); + await p.selectOption("#recording", "show"); + await p.click("#build"); + await until("the stream ends", STEP, () => p.evaluate(() => window.createTest.done >= 1)); + const snaps = await p.evaluate(() => window.createTest.snapshots); + const seen = await p.evaluate(() => window.__seen); + const total = SHOW_TEXT.length; + const counts = snaps.map((s) => s.widgets); + check("create: the app renders while the stream arrives, the first widget after a fraction of it", seen.length >= 8 && seen[0].state === "streaming" && seen[0].chars < total / 4, `${seen.length} widgets; first ${seen[0] && seen[0].widget} at ${seen[0] && seen[0].chars} of ${total} characters`); + check("create: widgets appear in steps over the stream, never fewer than before", counts.every((c, i) => i === 0 || c >= counts[i - 1]) && new Set(counts).size >= 6 && seen.filter((x) => x.state === "streaming").length >= 6, `${snaps.length} snapshots; counts ${[...new Set(counts)].join(" → ")}`); + const room = seen.find((x) => x.widget === "Show"); + check("create: the room widget (and its room client) appears while later lines still stream", !!room && room.chars < total / 2, room ? `Show at ${room.chars} of ${total}` : "no Show"); + const fin = await p.evaluate(() => window.createTest.last); + check("create: the final snapshot has no error, one show, eight widgets", fin.finished && fin.errors.length === 0 && fin.room && fin.room.kind === "show" && fin.nodes.length === 8, JSON.stringify({ errors: fin.errors, room: fin.room && fin.room.kind, n: fin.nodes.length })); + + // The host joins the show: the room client in its frame enters the room service's room. + await until("the room client is ready", STEP, () => p.evaluate(() => { const r = window.createTest.ctx.room.current; return r && r.phase() === "idle"; }), 300); + await p.click('[data-widget="Show"] [data-act="join"]'); + const host = await until("the host is in the room, publishing", STEP, async () => { const r = await roomOf(p); return r && r.phase === "publishing" && r; }, 300); + const joined = await until("the room's clock records the host joining", STEP, () => p.evaluate((me) => { + const s = window.createTest.ctx.room.current.state(); + return s.timeline.some((e) => e.kind === "join" && e.who === "room") && (s.room && s.room.present || []).some((x) => x.principal === me); + }, host.me), 300); + check("create: the host joins a real room: the room service granted its key, the relay took its proof, the room's clock recorded it", !!joined, `principal ${host.me.slice(0, 16)}…`); + + // A chat, sent from the Chat widget, signed and verified back into the room's record. + await p.fill('[data-widget="Chat"] input', "Welcome to the show"); + await p.click('[data-widget="Chat"] button[type="submit"]'); + await until("the host's chat is in the room's verified record", STEP, async () => (await roomOf(p)).chats.some((c) => c.text === "Welcome to the show"), 300); + check("create: a chat sent from the widget comes back as a verified event of the room", true); + + // The lead opens the poll. + await until("the host leads the show", STEP, async () => { const r = await roomOf(p); return r.co && r.co.lead; }, 300); + await p.click('[data-widget="Poll"] button.wai-primary'); + const w = await until("the room's clock opens the poll's window", STEP, async () => { const r = await roomOf(p); return r.co && r.co.windows.find((x) => x.question === "Which song next?"); }, 300); + check("create: the poll opened from the widget is a window the room's clock signed", !!w, `window ${w.digest.slice(0, 16)}…, closes at ${w.close}`); + + // The page's sealed log: every action, each verified as a link of the chain. + const log = await p.evaluate(() => ({ log: window.createTest.session.log(), v: window.createTest.session.verify(), actor: window.createTest.session.actor })); + const acts = log.log.map((e) => e.action); + check("create: the page sealed each action into its hash-chained log, which verifies", ["room.join", "chat.send", "poll.open"].every((a) => acts.includes(a)) && log.v.verified === log.log.length && log.log.every((e) => e.actor === log.actor && e.energy.provenance === "Estimator"), `${acts.join(", ")}; verified ${log.v.verified}`); + const shown = await p.evaluate(() => document.querySelector('[data-widget="Receipts"] .wai-verdict').dataset.ok); + check("create: the Receipts widget shows the page's own verification", shown === "true"); + const cut = await p.evaluate(async () => { + const raw = JSON.parse(window.createTest.session.logRaw()); + raw.splice(0, 1); + return (await import("./session.js")).verifyLog(JSON.stringify(raw)); + }); + check("create: the log with its first link cut out does not verify", JSON.parse(cut).broken_at === 0, cut); + + // Export. + const dl = p.waitForEvent("download", { timeout: STEP }); + await p.click("#export"); + const d = await dl; + const zipFile = path.join(dir, "export.zip"); + await d.saveAs(zipFile); + const ex = path.join(dir, "exported"); + fs.rmSync(ex, { recursive: true, force: true }); + fs.mkdirSync(ex, { recursive: true }); + execFileSync("unzip", ["-q", zipFile, "-d", ex]); + const top = fs.readdirSync(ex)[0]; + const project = path.join(ex, top); + const same = ["app.oui", "app.json", "room.toml", "relay.toml", "README.md", "AGENTS.md", "check.mjs", "serve.mjs", "index.html", "package.json", ".gitignore"].filter((f) => fs.readFileSync(path.join(project, f), "utf8") !== fs.readFileSync(path.join(cli, f), "utf8")); + check("create: the page's export holds the project files `wai-ui export` writes, byte for byte", same.length === 0, same.length ? `differ: ${same.join(", ")}` : `${top}/: ${fs.readdirSync(project).length} entries`); + const vendor = JSON.parse(fs.readFileSync(path.join(project, "vendor.json"), "utf8")); + check("create: the export carries the runtime under vendor/, its room configuration, a README and an AGENTS.md, and no key", vendor.files.length > 10 && fs.existsSync(path.join(project, "room.toml")) && fs.existsSync(path.join(project, "AGENTS.md")) && !/key\s*=\s*"[^.]/.test(fs.readFileSync(path.join(project, "room.toml"), "utf8")), `${vendor.files.length} runtime files`); + shared = { svc, srv, p, project, window: w, dir }; +} + +async function scenarioBuilt() { + if (!shared) return check("built: needs the create scenario", false); + const { svc, p, project, dir } = shared; + const out = execFileSync("node", ["check.mjs"], { cwd: project, stdio: ["ignore", "pipe", "pipe"] }).toString(); + check("built: the exported project's build (node check.mjs) passes and writes dist/", fs.existsSync(path.join(project, "dist/index.html")) && fs.existsSync(path.join(project, "dist/vendor/create/pkg/wai_ui_bg.wasm")), out.trim()); + // Its own front, against the show's room service and relay. + const port = await freePort(); + const front = run("node", [path.join(project, "serve.mjs")], "exported-front", dir, { PORT: String(port), ROOM_PORT: String(svc.roomPort), RELAY_PORT: String(svc.relayPort), CWD: project }); + await until("the exported app's front", 20000, async () => { if (front.exitCode != null) throw new Error(tail(front.logFile)); try { return (await fetch(`http://127.0.0.1:${port}/`)).ok; } catch { return false; } }, 200); + const v = await page(`http://127.0.0.1:${port}/`, "exported-viewer"); + await until("the exported app renders", STEP, () => v.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8)); + check("built: the exported app renders its eight widgets from app.oui", true); + await until("its room client is ready", STEP, () => v.evaluate(() => { const r = window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300); + await v.click('[data-widget="Show"] [data-act="watch"]'); + const viewer = await until("the viewer watches", STEP, async () => { const r = await roomOf(v); return r && r.phase === "watching" && r; }, 300); + check("built: a viewer of the exported app enters the same room", !!viewer.me, viewer.me.slice(0, 16)); + await until("the viewer reads the host's chat", STEP, async () => (await roomOf(v)).chats.some((c) => c.text === "Welcome to the show"), 300); + check("built: the viewer reads the host's chat, verified", true); + // The poll the host opened: open again if its window has closed meanwhile. + let r = await roomOf(v); + let open = r.co && r.co.windows.find((x) => x.question === "Which song next?" && x.close > r.co.now); + if (!open) { + await p.click('[data-widget="Poll"] button.wai-primary'); + open = await until("a window open now", STEP, async () => { const x = await roomOf(v); return x.co && x.co.windows.find((y) => y.question === "Which song next?" && y.close > x.co.now); }, 300); + } + await until("the viewer's poll offers its options", STEP, () => v.evaluate(() => [...document.querySelectorAll('[data-widget="Poll"] button.wai-option')].some((b) => !b.disabled)), 300); + await v.click('[data-widget="Poll"] button.wai-option[data-option="1"]'); + const chosen = await until("the room's clock selects and signs the result", STEP * 2, async () => { + const t = await v.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent); + return /chose “The loud one”, with 1 vote/.test(t) && t; + }, 500); + const hostSees = await until("the host's page shows the same result", STEP, async () => { const t = await p.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent); return /The loud one/.test(t) && t; }, 500); + check("built: the viewer's vote is selected by the room's clock, and both pages show it", !!chosen && !!hostSees, chosen); + const vlog = await v.evaluate(() => ({ acts: window.appTest.session.log().map((e) => e.action), v: window.appTest.session.verify() })); + check("built: the exported app sealed the viewer's actions too", vlog.acts.includes("room.watch") && vlog.acts.includes("poll.vote") && vlog.v.verified === vlog.acts.length, vlog.acts.join(", ")); + v.dump(); +} + +async function scenarioRefused() { + const srv = shared ? shared.srv : await serveSameOrigin(RT, { service: "http://127.0.0.1:9", relayPort: 9 }); + const p = await page(`${srv.url}/create/index.html`, "create-refused"); + await until("Create loads", STEP, () => p.evaluate(() => !!window.createTest)); + await p.evaluate(() => { + window.__ever = new Set(); + new MutationObserver(() => { for (const el of document.querySelectorAll("#app [data-widget]")) window.__ever.add(el.dataset.widget); }).observe(document.getElementById("app"), { childList: true, subtree: true }); + }); + await p.selectOption("#recording", "watch-refused"); + await p.click("#build"); + await until("the stream ends", STEP, () => p.evaluate(() => window.createTest.done >= 1)); + const ever = await p.evaluate(() => [...window.__ever]); + const fin = await p.evaluate(() => window.createTest.last); + const errs = fin.errors.map((e) => `${e.line}:${e.code}:${e.component}`); + check("refused: the Chat and the Poll a watch room cannot keep never render", !ever.includes("Chat") && !ever.includes("Poll") && ever.includes("WatchTogether") && ever.includes("Participants"), ever.join(", ")); + check("refused: each is refused disallowed-child at its line", errs.includes("3:disallowed-child:Chat") && errs.includes("4:disallowed-child:Poll"), errs.join(" ")); + const listed = await p.evaluate(() => [...document.querySelectorAll("#problems li")].map((li) => li.dataset.code)); + const off = await p.evaluate(() => document.getElementById("export").disabled); + check("refused: the page lists the refusals, and export stays off", listed.filter((c) => c === "disallowed-child").length === 2 && off, listed.join(" ")); + if (!shared) await srv.close(); +} + +async function scenarioUp() { + if (!shared) return check("up: needs the create scenario", false); + const dir = path.join(OUT, "up"); + fs.mkdirSync(dir, { recursive: true }); + const port = await freePort(); + const env = { PORT: String(port), ROOM_PORT: String(await freePort()), RELAY_PORT: String(await freePort()), JOULE_MOQ_RELAY: RELAY, JOULE_MOQ_ROOM: ROOM, CWD: shared.project }; + const up = run("node", [path.join(shared.project, "serve.mjs"), "--up"], "serve-up", dir, env); + await until("serve.mjs --up brings the relay, the room service and the app up", 60000, async () => { if (up.exitCode != null) throw new Error(tail(up.logFile)); return /relay and room service up/.test(fs.readFileSync(up.logFile, "utf8")); }, 300); + const keys = path.join(shared.project, ".wai-dev/room-keys.toml"); + const mode = (fs.statSync(keys).mode & 0o777).toString(8); + check("up: the development keys are made in .wai-dev/, which .gitignore keeps out", fs.existsSync(keys) && fs.readFileSync(path.join(shared.project, ".gitignore"), "utf8").includes(".wai-dev/"), `room-keys.toml mode ${mode}`); + const v = await page(`http://127.0.0.1:${port}/`, "up-viewer"); + await until("its room client is ready", STEP, () => v.evaluate(() => { const r = window.appTest && window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300); + await v.click('[data-widget="Show"] [data-act="watch"]'); + const r = await until("a page of the project's own setup watches", STEP, async () => { const x = await roomOf(v); return x && x.phase === "watching" && x; }, 300); + check("up: a page of the project's own development setup enters its room", !!r.me, r.me.slice(0, 16)); + up.kill("SIGTERM"); + v.dump(); +} + +try { + await launch(); + for (const [name, f] of [["create", scenarioCreate], ["built", scenarioBuilt], ["refused", scenarioRefused], ["up", scenarioUp]]) { + if (!ONLY.includes(name)) continue; + console.log(`== ${name}`); + try { await f(); } catch (e) { check(`${name}: ran to the end`, false, (e && e.stack) || String(e)); } + } + if (shared) shared.p.dump(); +} finally { + if (browser) await browser.close().catch(() => {}); + for (const p of procs) if (p.exitCode == null) p.kill("SIGTERM"); + if (shared) await shared.srv.close(); + killStrays(); +} +fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify({ results, failed }, null, 1)); +console.log(`create e2e: ${results.length - failed} passed, ${failed} failed`); +process.exit(failed ? 1 : 0); diff --git a/wai/wai-ui/e2e/package-lock.json b/wai/wai-ui/e2e/package-lock.json new file mode 100644 index 000000000..8f560a736 --- /dev/null +++ b/wai/wai-ui/e2e/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "wai-ui-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wai-ui-e2e", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/wai/wai-ui/e2e/package.json b/wai/wai-ui/e2e/package.json new file mode 100644 index 000000000..d8e417593 --- /dev/null +++ b/wai/wai-ui/e2e/package.json @@ -0,0 +1,10 @@ +{ + "name": "wai-ui-e2e", + "private": true, + "description": "Create, end to end: a browser automation package drives the host browser against a room service and a relay on loopback.", + "license": "Apache-2.0", + "type": "module", + "dependencies": { + "playwright": "1.60.0" + } +} diff --git a/wai/wai-ui/e2e/run.sh b/wai/wai-ui/e2e/run.sh new file mode 100755 index 000000000..96b8a911f --- /dev/null +++ b/wai/wai-ui/e2e/run.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Build what Create's end-to-end test needs, and run it. +# +# wai/wai-ui/e2e/run.sh [--only create,built,refused,up] +# +# 1. joule-moq-relay and joule-moq-room (native, release), and wai-ui's command line; +# 2. the WebAssembly packages, through wasm-bindgen's web target, each with its own --out-name, +# into the runtime directory: joule-moq-web (room/moq/pkg/wai_room_moq), wai-live-web +# (room/live/pkg/wai_room_live) and wai-ui with its feature `web` (create/pkg/wai_ui); +# 3. the runtime laid out as the site serves it (web/runtime.mjs): create/ and room/; +# 4. the page modules' unit tests (node --test web/web.test.mjs); +# 5. the browser-automation package from e2e/package-lock.json (`npm ci --ignore-scripts`), +# outside the tree unless PLAYWRIGHT_HOST names one installed; +# 6. e2e/create.e2e.mjs in the browser BROWSER_BIN names (the host's installed one), else the +# package's own. +# +# Environment: CARGO_TARGET_DIR (one target directory for every workspace), BROWSER_BIN, +# PLAYWRIGHT_HOST, CREATE_E2E_OUT (where the runtime and the results go), CARGO_LOCKED +# (default --locked). Nothing listens beyond loopback. +set -euo pipefail +HERE=$(cd "$(dirname "$0")" && pwd) +UI=$(cd "$HERE/.." && pwd) +ROOT=$(cd "$UI/../.." && pwd) +JC="$ROOT/joule-code/joule-code-rs" +WLW="$ROOT/wai/wai-live-web" +LOCKED=${CARGO_LOCKED---locked} +TARGET=${CARGO_TARGET_DIR:-$UI/target} +export CARGO_TARGET_DIR="$TARGET" +OUT=${CREATE_E2E_OUT:-${TMPDIR:-/tmp}/wai-create-e2e} +RT="$OUT/runtime" +rm -rf "$OUT/results" "$RT" +mkdir -p "$OUT/results" "$RT" + +step() { echo "== $*"; } + +step "native: joule-moq-relay, joule-moq-room, wai-ui" +(cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay) +(cd "$JC/crates/joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room) +(cd "$UI" && cargo build $LOCKED --release --bin wai-ui) + +step "wasm: joule-moq-web, wai-live-web, wai-ui" +command -v wasm-bindgen >/dev/null || { echo "the wasm-bindgen CLI 0.2.127 is needed"; exit 2; } +wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version the crates pin"; exit 2; } +(cd "$JC" && cargo build $LOCKED --release -p joule-moq-web --lib --target wasm32-unknown-unknown) +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/moq/pkg" --out-name wai_room_moq "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm" +(cd "$WLW" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown) +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/live/pkg" --out-name wai_room_live "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm" +(cd "$UI" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown --no-default-features --features web) +wasm-bindgen --target web --no-typescript --out-dir "$RT/create/pkg" --out-name wai_ui "$TARGET/wasm32-unknown-unknown/release/wai_ui.wasm" +ls -l "$RT"/create/pkg/*.wasm "$RT"/room/*/pkg/*.wasm + +step "the runtime, as the site serves it" +node "$UI/web/runtime.mjs" "$RT" + +step "unit tests of the page's modules" +node --test "$UI/web/web.test.mjs" + +step "browser automation" +if [ -z "${PLAYWRIGHT_HOST:-}" ]; then + PW="$OUT/pw" + mkdir -p "$PW" + cp "$HERE/package.json" "$HERE/package-lock.json" "$PW/" + (cd "$PW" && npm ci --ignore-scripts --no-audit --no-fund) + export PLAYWRIGHT_HOST="$PW/node_modules" +fi +browser=() +[ -n "${BROWSER_BIN:-}" ] && browser=(--browser "$BROWSER_BIN") + +step "end-to-end" +node "$HERE/create.e2e.mjs" \ + --rt "$RT" \ + --relay "$TARGET/release/joule-moq-relay" \ + --room "$TARGET/release/joule-moq-room" \ + --ui "$TARGET/release/wai-ui" \ + --out "$OUT/results" \ + ${browser[@]+"${browser[@]}"} \ + "$@" diff --git a/wai/wai-ui/src/bin/wai_ui.rs b/wai/wai-ui/src/bin/wai_ui.rs new file mode 100644 index 000000000..a8f6d809f --- /dev/null +++ b/wai/wai-ui/src/bin/wai_ui.rs @@ -0,0 +1,64 @@ +//! `wai-ui`: WAI's widgets for joule-ui, and Create, on the command line. +//! +//! wai-ui guide what a model is told before it writes an app +//! wai-ui registry the WAI registry, as JSON +//! wai-ui check the app's tree, errors and room; exit 1 on an error +//! wai-ui export [--runtime ] the starter project, into a new or empty directory +//! wai-ui mcp [--port ] the MCP server a coding agent drives Create through + +use std::io::Read; +use std::path::Path; + +fn read(p: &str) -> String { + if p == "-" { + let mut s = String::new(); + std::io::stdin().read_to_string(&mut s).expect("stdin"); + s + } else { + std::fs::read_to_string(p).unwrap_or_else(|e| die(&format!("{p}: {e}"))) + } +} + +fn die(m: &str) -> ! { + eprintln!("wai-ui: {m}"); + std::process::exit(2) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); + match args.first().map(String::as_str) { + Some("guide") => print!("{}", wai_ui::guide::guide()), + Some("registry") => println!("{}", serde_json::to_string_pretty(&joule_ui_lang::vectors::library_json(&wai_ui::registry::library())).expect("json")), + Some("check") => { + let p = read(args.get(1).map(String::as_str).unwrap_or_else(|| die("check "))); + let c = wai_ui::check(&p); + println!("{}", serde_json::to_string_pretty(&c).expect("json")); + let bad = c.errors.iter().filter(|e| !e.warning).count(); + for e in c.errors.iter().filter(|e| !e.warning) { + eprintln!("line {}: {}: {}", e.line, e.code, e.message); + } + std::process::exit(if bad > 0 { 1 } else { 0 }); + } + Some("export") => { + let (Some(p), Some(dir)) = (args.get(1), args.get(2)) else { die("export [--runtime ]") }; + let rt = flag("--runtime"); + match wai_ui::mcp::export_to(&read(p), Path::new(dir), rt.as_deref().map(Path::new)) { + Ok(files) => { + for f in &files { + println!("{f}"); + } + eprintln!("wai-ui: {} file(s) written to {dir}", files.len()); + } + Err(e) => die(&e), + } + } + Some("mcp") => { + let port = flag("--port").map(|p| p.parse().unwrap_or_else(|_| die("--port takes a number"))).unwrap_or(0); + if let Err(e) = wai_ui::mcp::run(port) { + die(&e.to_string()); + } + } + _ => die("guide | registry | check | export [--runtime ] | mcp [--port ]"), + } +} diff --git a/wai/wai-ui/src/export.rs b/wai/wai-ui/src/export.rs new file mode 100644 index 000000000..d52175fdb --- /dev/null +++ b/wai/wai-ui/src/export.rs @@ -0,0 +1,330 @@ +//! The one writer of a starter project. Every file an export holds, and its +//! layout, is decided here, whoever asks for it (the Create page, the command +//! line, the MCP server), so that the project format can change in one place. + +use serde::{Deserialize, Serialize}; + +use crate::plan::RoomPlan; +use crate::{guide, Checked}; + +/// A file of the runtime the project carries under `vendor/` (the WAI widgets, +/// the renderer, the room client and their WebAssembly builds), by its path in +/// the runtime and its SHA-256. The host supplies the bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeFile { + pub path: String, + pub sha256: String, +} + +/// What a project file holds: its text, or a runtime file the host copies in. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind", content = "value")] +pub enum Contents { + Text(String), + Runtime(String), +} + +/// One file of the project, by its path in the project. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectFile { + pub path: String, + pub contents: Contents, +} + +/// Why a program is not exported. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum ExportError { + /// The program has errors; the export is the app as it renders with none. + Errors { count: usize }, + /// The program holds no room, so there is nothing for a room service to keep. + NoRoom, + /// The tree cannot be written in the canonical form. + Unwritable { reason: String }, + /// A runtime file's path leaves `vendor/`. + BadRuntimePath { path: String }, +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::Errors { count } => write!(f, "the app has {count} error(s); fix them before exporting"), + ExportError::NoRoom => write!(f, "the app holds no room (a Room, a WatchTogether or a Show)"), + ExportError::Unwritable { reason } => write!(f, "the app cannot be written: {reason}"), + ExportError::BadRuntimePath { path } => write!(f, "the runtime path `{path}` leaves vendor/"), + } + } +} + +impl std::error::Error for ExportError {} + +/// The files every export holds, in the order they are written, beside +/// `vendor/` (and `media/README.md` for a watch room). +pub const PROJECT_FILES: [&str; 12] = [ + "app.oui", "app.json", "room.toml", "relay.toml", "index.html", "package.json", "check.mjs", "serve.mjs", "vendor.json", "README.md", "AGENTS.md", ".gitignore", +]; + +fn safe_runtime_path(p: &str) -> bool { + !p.is_empty() + && !p.starts_with('/') + && p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) + && p.split('/').all(|x| !x.is_empty() && x != "." && x != "..") +} + +/// A name a package manager takes: lowercase letters, digits and `-`. +fn package_name(title: &str) -> String { + let mut s = String::new(); + for c in title.chars() { + if c.is_ascii_alphanumeric() { + s.push(c.to_ascii_lowercase()); + } else if !s.ends_with('-') && !s.is_empty() { + s.push('-'); + } + } + let s = s.trim_end_matches('-').to_string(); + if s.is_empty() { "wai-app".into() } else { s.chars().take(60).collect() } +} + +/// The room service's configuration for the app's room (joule-moq-room), for +/// development: loopback, the keys in `.wai-dev/` and never in the project. +pub fn room_toml(plan: &RoomPlan) -> String { + let what = match plan.kind.as_str() { + "watch" => "a watch room: everyone watches one file together (WAI co-watch)", + "show" => "a show: hosts on camera, an audience that votes (WAI co-performance)", + _ => "a live room: people join with camera and microphone, or watch", + }; + let mut t = format!( + "# The room service's configuration for this app (joule-moq-room), written by Create from\n\ + # app.oui: {what}.\n\ + # Development: everything on loopback, the keys in .wai-dev/ (made by `node serve.mjs --up`,\n\ + # never committed). A production deployment keeps its own copy, with keys of its own.\n\ + listen = \"127.0.0.1:9682\"\n\ + public_prefix = \"/room-api\"\n\ + keys = \".wai-dev/room-keys.toml\"\n\ + relay_url = \"ws://127.0.0.1:9681/moq\"\n\ + max_resident_mb = 256\n\n\ + [room]\n\ + max_publishers = 4\n\ + max_present = 16\n\ + publisher_grant_s = 1200\n\ + viewer_grant_s = 1800\n\ + join_wait_s = 90\n\ + max_grants = 64\n" + ); + if plan.kind == "watch" { + let source = plan.source.as_deref().unwrap_or_default(); + t.push_str(&format!( + "# The file the room watches, served whole, and its length in milliseconds: put the file at\n\ + # media/{source} and set its length before `npm run dev`.\n\ + watch_source = \"media/{source}\"\n\ + watch_duration_ms = 0\n\ + # What everyone may control: 1 play, 2 pause, 4 seek, 8 rate.\n\ + watch_control = 15\n\ + watch_window_ms = 5000\n" + )); + } + if plan.kind == "show" { + t.push_str(&format!( + "\n[co]\n\ + # Each viewer's inputs to one poll; the first host leads (opens the polls).\n\ + audience_input = {}\n\ + latency_budget_ms = 0\n\ + lead = \"first\"\n\ + ai = false\n\ + fidelity_every_s = 0\n\ + commit_after_ms = 1500\n\ + lead_ms = 300\n", + plan.audience + )); + } + t.push_str( + "\n[rate]\n\ + client_burst = 20\n\ + client_every_s = 20\n\ + global_burst = 120\n\ + global_every_s = 10\n", + ); + t +} + +/// The relay's configuration for development (joule-moq-relay): the WebSocket +/// binding on loopback, grants required, each publisher's audio and video held +/// to its own key. `node serve.mjs --up` adds the lines that trust the app's +/// room service. +pub const RELAY_TOML: &str = "# The relay's configuration for development (joule-moq-relay): the WebSocket binding on\n\ +# loopback and grants required. `node serve.mjs --up` adds the lines that trust this app's room\n\ +# service (made with its keys) and writes the result to .wai-dev/relay.toml.\n\ +websocket_listen = \"127.0.0.1:9681\"\n\ +path = \"/moq\"\n\ +goaway_uri = \"\"\n\n\ +[tls]\n\ +dev_names = [\"localhost\", \"127.0.0.1\"]\n\n\ +[limits]\n\ +goaway_timeout_ms = 8000\n\n\ +[receipts]\n\ +receipts_track_suffix = \"_receipts\"\n\n\ +[[receipts.door]]\n\ +prefix = [\"*\", \"*\", \"audio\"]\n\ +trust = []\n\ +trust_field = 1\n\ +hold_ms = 2000\n\n\ +[[receipts.door]]\n\ +prefix = [\"*\", \"*\", \"video\"]\n\ +trust = []\n\ +trust_field = 1\n\ +hold_ms = 2000\n\n\ +[summary]\n\ +out_path = \".wai-dev/summaries.jsonl\"\n\n\ +[auth]\n\ +required = true\n"; + +fn fill(template: &str, vars: &[(&str, &str)]) -> String { + let mut s = template.to_string(); + for (k, v) in vars { + s = s.replace(&format!("{{{{{k}}}}}"), v); + } + s +} + +fn html_text(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">") +} + +/// Write the starter project for `program`: every file of the project, the +/// runtime files named by their runtime paths for the host to copy in. +pub fn export(program: &str, runtime: &[RuntimeFile]) -> Result, ExportError> { + let checked: Checked = crate::check(program); + let errors = checked.errors.iter().filter(|e| !e.warning).count(); + if errors > 0 { + return Err(ExportError::Errors { count: errors }); + } + let (Some(tree), Some(plan)) = (checked.tree.as_ref(), checked.room.as_ref()) else { + return Err(ExportError::NoRoom); + }; + for r in runtime { + if !safe_runtime_path(&r.path) { + return Err(ExportError::BadRuntimePath { path: r.path.clone() }); + } + } + let lib = crate::registry::library(); + let oui = joule_ui_lang::write(tree, &lib).map_err(|e| ExportError::Unwritable { reason: e.to_string() })?; + // The app's title, else its room's. + let title = match tree.props.get("title") { + Some(joule_ui_core::PropValue::Text(s)) => Some(s.clone()), + _ => None, + } + .or_else(|| plan.title.clone()); + let title = title.unwrap_or_else(|| "A WAI app".into()); + let (summary, what) = match plan.kind.as_str() { + "watch" => ( + "a watch room, where everyone watches one file together, in sync.", + "A watch room: press Watch, and the file plays at the room's position; play, pause, seek or change the speed, and everyone's player follows.", + ), + "show" => ( + "a show, with hosts on camera and an audience that watches and votes.", + "A show: the first host to join leads and opens the polls; viewers press Watch and vote, and the room's clock signs each result.", + ), + _ => ( + "a live room, where people join with their camera and microphone, or watch.", + "A live room: press Join to take part with your camera and microphone, or Watch; every frame is checked against its signed receipt before it is shown.", + ), + }; + let package = package_name(&title); + let guide_text = guide::guide(); + let vars: [(&str, &str); 5] = [("TITLE", &html_text(&title)), ("PACKAGE", &package), ("SUMMARY", summary), ("WHAT", what), ("GUIDE", &guide_text)]; + + let vendor = serde_json::json!({ + "note": "The runtime under vendor/, each file with its SHA-256; check.mjs verifies them.", + "files": runtime, + }); + let mut files = vec![ + ProjectFile { path: "app.oui".into(), contents: Contents::Text(oui) }, + ProjectFile { path: "app.json".into(), contents: Contents::Text(serde_json::to_string(tree).expect("a tree serialises")) }, + ProjectFile { path: "room.toml".into(), contents: Contents::Text(room_toml(plan)) }, + ProjectFile { path: "relay.toml".into(), contents: Contents::Text(RELAY_TOML.into()) }, + ProjectFile { path: "index.html".into(), contents: Contents::Text(fill(include_str!("../templates/index.html"), &vars)) }, + ProjectFile { path: "package.json".into(), contents: Contents::Text(fill(include_str!("../templates/package.json"), &vars)) }, + ProjectFile { path: "check.mjs".into(), contents: Contents::Text(include_str!("../templates/check.mjs").into()) }, + ProjectFile { path: "serve.mjs".into(), contents: Contents::Text(include_str!("../templates/serve.mjs").into()) }, + ProjectFile { path: "vendor.json".into(), contents: Contents::Text(serde_json::to_string_pretty(&vendor).expect("json") + "\n") }, + ProjectFile { path: "README.md".into(), contents: Contents::Text(fill(include_str!("../templates/README.md"), &vars)) }, + ProjectFile { path: "AGENTS.md".into(), contents: Contents::Text(fill(include_str!("../templates/AGENTS.md"), &vars)) }, + ProjectFile { path: ".gitignore".into(), contents: Contents::Text(include_str!("../templates/gitignore").into()) }, + ]; + if plan.kind == "watch" { + let source = plan.source.as_deref().unwrap_or_default(); + files.push(ProjectFile { + path: "media/README.md".into(), + contents: Contents::Text(format!("Put the file the room watches here, as `{source}`, and set `watch_duration_ms` in `../room.toml` to its length in milliseconds.\n")), + }); + } + for r in runtime { + files.push(ProjectFile { path: format!("vendor/{}", r.path), contents: Contents::Runtime(r.path.clone()) }); + } + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHOW: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll], \"Live\", 2)\nplayer = Player()\nchat = Chat()\npoll = Poll(\"Next?\", [\"A\", \"B\"])\n"; + + fn rt() -> Vec { + vec![RuntimeFile { path: "create/app.js".into(), sha256: "00".repeat(32) }] + } + + fn text<'a>(files: &'a [ProjectFile], p: &str) -> &'a str { + match &files.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p} is not in the export")).contents { + Contents::Text(t) => t, + Contents::Runtime(_) => panic!("{p} is a runtime file"), + } + } + + #[test] + fn an_export_holds_every_project_file_and_its_room_configuration() { + let files = export(SHOW, &rt()).unwrap(); + for p in PROJECT_FILES { + assert!(files.iter().any(|f| f.path == p), "{p} is missing"); + } + let room = text(&files, "room.toml"); + assert!(room.contains("[co]\n") && room.contains("audience_input = 2\n"), "{room}"); + assert!(!room.contains("watch_source")); + assert!(files.iter().any(|f| f.path == "vendor/create/app.js" && f.contents == Contents::Runtime("create/app.js".into()))); + // The program is the canonical form of the tree, and app.json the tree. + let oui = text(&files, "app.oui"); + let back = crate::check(oui); + assert!(back.errors.is_empty()); + assert_eq!(serde_json::to_string(back.tree.as_ref().unwrap()).unwrap(), text(&files, "app.json")); + // No secret: the development keys are made at run time, outside the project's files. + assert!(room.contains("keys = \".wai-dev/room-keys.toml\"")); + assert!(text(&files, ".gitignore").contains(".wai-dev/")); + assert!(text(&files, "AGENTS.md").contains("- Poll(question*, options*, seconds)")); + assert!(text(&files, "index.html").contains("Friday night")); + } + + #[test] + fn each_room_kind_has_its_configuration() { + let live = export("root = App([r], \"x\")\nr = Room([Chat()])\n", &[]).unwrap(); + let t = text(&live, "room.toml"); + assert!(!t.contains("[co]") && !t.contains("watch_source")); + let watch = export("root = App([w])\nw = WatchTogether(\"film.mp4\")\n", &[]).unwrap(); + let t = text(&watch, "room.toml"); + assert!(t.contains("watch_source = \"media/film.mp4\"\n") && !t.contains("[co]")); + assert!(watch.iter().any(|f| f.path == "media/README.md")); + } + + #[test] + fn what_is_not_exported() { + assert_eq!(export("root = App([t])\nt = Text(\"no room\")\n", &[]), Err(ExportError::NoRoom)); + assert_eq!(export("root = App([r])\nr = Room([Poll(\"q\", [\"a\", \"b\"])])\n", &[]), Err(ExportError::Errors { count: 1 })); + let bad = [RuntimeFile { path: "../etc/x".into(), sha256: String::new() }]; + assert!(matches!(export(SHOW, &bad), Err(ExportError::BadRuntimePath { .. }))); + } + + #[test] + fn a_package_name_is_one_a_package_manager_takes() { + assert_eq!(package_name("Friday night — live!"), "friday-night-live"); + assert_eq!(package_name("***"), "wai-app"); + } +} diff --git a/wai/wai-ui/src/guide.rs b/wai/wai-ui/src/guide.rs new file mode 100644 index 000000000..be3d2a0fe --- /dev/null +++ b/wai/wai-ui/src/guide.rs @@ -0,0 +1,62 @@ +//! What a model is told before it writes an app: the language as joule-ui takes +//! it in, and every widget of the registry, generated from the registry so the +//! two never disagree. + +use crate::registry::{library, ROOT}; + +/// The example a model is shown. +pub const EXAMPLE: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll, people], \"Live from the studio\")\nplayer = Player()\nchat = Chat(\"Talk\")\npoll = Poll(\"Which song next?\", [\"The first one\", \"The quiet one\", \"The loud one\"])\npeople = Participants()\n"; + +/// The model's instructions: the system prompt of a Create session. +pub fn guide() -> String { + let lib = library(); + let mut g = String::new(); + g.push_str( + "You build a WAI app from a person's description. Write it as a program in a line language, one widget a line, and nothing else: no prose, no code fence.\n\n\ +Rules:\n\ +1. Each line is `name = Widget(arguments)`. The first line is `root = App([...], \"Title\")`.\n\ +2. Arguments go in the order the widget's signature lists them; `*` marks a required one. Write `null` to skip one in the middle; leave out the ones at the end you do not need.\n\ +3. A widget's children are a list, even when there is one: `[chat]`. Give each child a name and define it on its own line below; a name may be used before its line.\n\ +4. Values are plain: text in double quotes, whole or decimal numbers, true or false, lists `[a, b]`, objects `{key: value}`. There are no variables (`$name`), no expressions or conditions, no queries and no `@` functions: the app's behaviour is the widgets' own.\n\ +5. An app holds one room. Choose the room for what the person asks: a Room when people join on camera and talk; a WatchTogether when everyone watches one file in sync; a Show when hosts perform and an audience watches and votes. Put in the room only the widgets its signature lists below.\n\ +6. Write the layout first (root, then the room), then each widget, so the app appears as you write.\n\n\ +Widgets (`*` required):\n", + ); + let mut names = lib.names(); + // The root first, then the rooms, then the rest, as a model reads them. + names.sort_by_key(|n| match n.as_str() { + ROOT => (0, n.clone()), + "Room" | "WatchTogether" | "Show" => (1, n.clone()), + _ => (2, n.clone()), + }); + for n in names { + let s = lib.registry.get(&n).expect("listed"); + let sig = lib.signature(&n).expect("listed"); + let holds = match &s.children { + joule_ui_core::AllowedChildren::OnlyOf(list) => format!(" Holds: {}.", list.join(", ")), + _ => String::new(), + }; + g.push_str(&format!("- {sig}: {}{holds}\n", s.description)); + } + g.push_str("\nExample, for \"a show for my channel with chat and a live poll\":\n"); + g.push_str(EXAMPLE); + g +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_guide_lists_every_widget_and_its_example_is_valid() { + let g = guide(); + for n in library().names() { + assert!(g.contains(&format!("- {n}(")), "{n} is missing from the guide"); + } + let s = joule_ui_lang::parse(&library(), EXAMPLE); + assert!(s.errors.is_empty(), "{:?}", s.errors); + let (t, _, p, e) = crate::plan::apply(s.tree.as_ref().unwrap(), &s.nodes); + assert!(t.is_some() && e.is_empty()); + assert_eq!(p.unwrap().kind, "show"); + } +} diff --git a/wai/wai-ui/src/lib.rs b/wai/wai-ui/src/lib.rs new file mode 100644 index 000000000..ab865e91f --- /dev/null +++ b/wai/wai-ui/src/lib.rs @@ -0,0 +1,193 @@ +//! WAI's widgets for joule-ui, and Create. +//! +//! - [`registry`]: the WAI registry, a joule-ui `Registry` of typed widgets +//! (rooms of three kinds, the room's video, chat, a poll, who is present, the +//! page's receipts, energy) whose children rules are what each room's service +//! keeps. +//! - [`guide`]: what a model is told before it writes an app, generated from the +//! registry. +//! - [`check`] and [`Create`]: a program (joule-ui's line language, SPEC §11) as +//! a snapshot the page renders, whole or as it streams, with WAI's own rules +//! ([`plan`]) applied: one room, a poll's options, a show's audience, a watch +//! room's source; and the room the app needs. +//! - [`export`]: the one writer of a starter project. +//! - `web` (feature): the page's WebAssembly surface, with the interaction log +//! every action on the page is sealed into. +//! - `mcp` (feature `cli`): an MCP server a coding agent drives Create through. + +#![forbid(unsafe_code)] + +pub mod export; +pub mod guide; +pub mod plan; +pub mod registry; + +#[cfg(feature = "cli")] +pub mod mcp; +#[cfg(feature = "web")] +pub mod web; + +use joule_ui_core::Widget; +use joule_ui_lang::{Node, Snapshot, Statement, StreamParser}; +use serde::{Deserialize, Serialize}; + +pub use plan::{AppError, PollPlan, RoomPlan}; + +/// One error, from the line language or from WAI's own rules, in one shape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorOut { + pub line: u32, + pub statement: String, + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prop: Option, + pub message: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub warning: bool, +} + +/// What the page renders: the snapshot of the line language, WAI's rules +/// applied, and the room the app needs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Checked { + pub tree: Option, + pub nodes: Vec, + pub errors: Vec, + pub unresolved: Vec, + pub orphans: Vec, + pub statements: Vec, + pub finished: bool, + pub incomplete: bool, + pub room: Option, +} + +/// A snapshot with WAI's rules applied (SPEC §11.6 for the snapshot itself): a +/// widget that breaks one is dropped from the tree, its error withheld while its +/// statement is pending, like the line language's own. +pub fn checked(s: Snapshot) -> Checked { + let mut errors: Vec = s + .errors + .iter() + .map(|e| ErrorOut { + line: e.line, + statement: e.statement.clone(), + code: e.code.as_str().into(), + component: e.component.clone(), + prop: e.prop.clone(), + message: e.message.clone(), + warning: e.warning, + }) + .collect(); + let (tree, nodes, room) = match &s.tree { + Some(t) => { + let (t2, n2, room, app) = plan::apply(t, &s.nodes); + for a in app { + let pending = !s.finished && s.statements.iter().rev().find(|x| x.name == a.statement).is_some_and(|x| !x.complete); + if !pending { + errors.push(ErrorOut { line: a.line, statement: a.statement, code: a.code, component: a.component, prop: a.prop, message: a.message, warning: false }); + } + } + (t2, n2, room) + } + None => (None, Vec::new(), None), + }; + if s.finished && tree.is_some() && room.is_none() { + errors.push(ErrorOut { + line: 0, + statement: String::new(), + code: "no-room".into(), + component: None, + prop: None, + message: "the app holds no room: add a Room, a WatchTogether or a Show to App's children".into(), + warning: true, + }); + } + errors.sort_by(|a, b| (a.line, &a.statement, &a.code).cmp(&(b.line, &b.statement, &b.code))); + Checked { tree, nodes, errors, unresolved: s.unresolved, orphans: s.orphans, statements: s.statements, finished: s.finished, incomplete: s.incomplete, room } +} + +/// Check a whole program: the snapshot at the end of its stream, WAI's rules +/// applied. +pub fn check(program: &str) -> Checked { + checked(joule_ui_lang::parse(®istry::library(), program)) +} + +/// A Create session's program as it streams. +pub struct Create { + lib: joule_ui_lang::Library, + text: String, + finished: bool, +} + +impl Default for Create { + fn default() -> Self { + Self::new() + } +} + +impl Create { + pub fn new() -> Self { + Create { lib: registry::library(), text: String::new(), finished: false } + } + /// The next chunk; after `finish`, the first chunk of a new program. + pub fn push(&mut self, chunk: &str) -> Checked { + if self.finished { + self.text.clear(); + self.finished = false; + } + self.text.push_str(chunk); + self.snapshot() + } + /// The stream has ended. + pub fn finish(&mut self) -> Checked { + self.finished = true; + self.snapshot() + } + /// Start again with no program. + pub fn reset(&mut self) { + self.text.clear(); + self.finished = false; + } + pub fn text(&self) -> &str { + &self.text + } + pub fn snapshot(&self) -> Checked { + let mut p = StreamParser::new(&self.lib); + let s = if self.finished { + p.push(&self.text); + p.finish() + } else { + p.push(&self.text) + }; + checked(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_stream_and_a_check_agree_at_the_end() { + let text = guide::EXAMPLE; + let mut c = Create::new(); + let mut widgets = Vec::new(); + for ch in text.as_bytes().chunks(7) { + let s = c.push(std::str::from_utf8(ch).unwrap()); + widgets.push(s.nodes.len()); + } + assert!(widgets.windows(2).all(|w| w[1] >= w[0]), "{widgets:?}"); + assert_eq!(c.finish(), check(text)); + } + + #[test] + fn a_line_against_the_rooms_rules_is_refused_with_its_line() { + let c = check("root = App([w])\nw = WatchTogether(\"film.mp4\", [chat])\nchat = Chat()\n"); + let e = c.errors.iter().find(|e| e.code == "disallowed-child").expect("refused"); + assert_eq!((e.line, e.component.as_deref()), (3, Some("Chat"))); + assert!(e.message.contains("WatchTogether")); + assert_eq!(c.nodes.len(), 2, "the chat is not rendered"); + } +} diff --git a/wai/wai-ui/src/mcp.rs b/wai/wai-ui/src/mcp.rs new file mode 100644 index 000000000..c030f3830 --- /dev/null +++ b/wai/wai-ui/src/mcp.rs @@ -0,0 +1,379 @@ +//! `wai-ui mcp`: an MCP server (JSON-RPC 2.0 over stdio, one message a line) a +//! person's own coding agent runs on their machine to drive Create, and the +//! bridge on 127.0.0.1 that carries a program into their open Create page. +//! +//! Tools: +//! - `create_guide`: the language and the WAI widgets, as a model is told them, +//! and how the person connects their Create page to the bridge. +//! - `create_check`: a program checked: the tree, every error with its line and +//! reason, the room the app needs. +//! - `create_stream`: a program streamed into the connected Create page, which +//! renders it as it arrives. +//! - `create_export`: the starter project written into a directory. +//! +//! Nothing is sent off the machine: the bridge listens on 127.0.0.1 alone, and a +//! page reads from it only with the pairing code the server makes at start. + +use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hasher}; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::export::{Contents, RuntimeFile}; + +/// A pairing code: 10 letters and digits a page presents to read the bridge. +fn pairing_code() -> String { + const A: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut out = String::new(); + for i in 0..10u64 { + let mut h = RandomState::new().build_hasher(); + h.write_u64(i); + h.write_u128(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)); + out.push(A[(h.finish() % A.len() as u64) as usize] as char); + } + out +} + +/// The bridge: Create pages connected by server-sent events, each with the code. +pub struct Bridge { + pub port: u16, + pub code: String, + listeners: Arc>>, +} + +impl Bridge { + /// Listen on 127.0.0.1:`port` (0 for any free port). + pub fn start(port: u16) -> std::io::Result { + let l = TcpListener::bind(("127.0.0.1", port))?; + let port = l.local_addr()?.port(); + let code = pairing_code(); + let listeners: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (ls, c) = (listeners.clone(), code.clone()); + std::thread::spawn(move || { + for s in l.incoming().flatten() { + let (ls, c) = (ls.clone(), c.clone()); + std::thread::spawn(move || { + let _ = serve(s, &c, &ls); + }); + } + }); + Ok(Bridge { port, code, listeners }) + } + + /// The pages connected now. + pub fn listening(&self) -> usize { + self.listeners.lock().map(|l| l.len()).unwrap_or(0) + } + + /// Send one event to every connected page; the pages it reached. + pub fn send(&self, event: &str, data: &Value) -> usize { + let msg = format!("event: {event}\ndata: {data}\n\n"); + let Ok(mut ls) = self.listeners.lock() else { return 0 }; + ls.retain_mut(|s| s.write_all(msg.as_bytes()).and_then(|_| s.flush()).is_ok()); + ls.len() + } +} + +fn serve(mut s: TcpStream, code: &str, listeners: &Arc>>) -> std::io::Result<()> { + s.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut r = BufReader::new(s.try_clone()?); + let mut first = String::new(); + r.read_line(&mut first)?; + let mut origin = None; + loop { + let mut h = String::new(); + if r.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" { + break; + } + if let Some((k, v)) = h.split_once(':') + && k.eq_ignore_ascii_case("origin") + { + origin = Some(v.trim().to_string()); + } + } + let mut parts = first.split_whitespace(); + let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or("")); + let allow = |o: &Option| match o { + Some(o) if !o.contains(['\r', '\n']) => format!("Access-Control-Allow-Origin: {o}\r\nVary: Origin\r\n"), + _ => String::new(), + }; + if method == "OPTIONS" { + // A page on a public origin asks before it reads a loopback address. + let resp = format!( + "HTTP/1.1 204 No Content\r\n{}Access-Control-Allow-Methods: GET\r\nAccess-Control-Allow-Headers: *\r\nAccess-Control-Allow-Private-Network: true\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + allow(&origin) + ); + return s.write_all(resp.as_bytes()); + } + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let given = query.split('&').find_map(|kv| kv.strip_prefix("code=")).unwrap_or(""); + if method == "GET" && path == "/create/stream" { + if given != code { + let resp = format!("HTTP/1.1 403 Forbidden\r\n{}Content-Length: 0\r\nConnection: close\r\n\r\n", allow(&origin)); + return s.write_all(resp.as_bytes()); + } + s.set_read_timeout(None)?; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n{}Access-Control-Allow-Private-Network: true\r\nConnection: keep-alive\r\n\r\nevent: hello\ndata: {{}}\n\n", + allow(&origin) + ); + s.write_all(head.as_bytes())?; + s.flush()?; + if let Ok(mut l) = listeners.lock() { + l.push(s); + } + return Ok(()); + } + let resp = format!("HTTP/1.1 404 Not Found\r\n{}Content-Length: 0\r\nConnection: close\r\n\r\n", allow(&origin)); + s.write_all(resp.as_bytes()) +} + +/// The tools, as `tools/list` gives them. +pub fn tools() -> Value { + let program = json!({ "type": "string", "description": "The app as a program in joule-ui's line language: one widget a line, `root = App([...], \"Title\")` first (see create_guide)." }); + json!([ + { "name": "create_guide", "description": "The line language and every WAI widget, as a model writing a WAI app is told them, and how to connect the person's Create page to this server's bridge. Read it before writing a program.", + "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false } }, + { "name": "create_check", "description": "Check a WAI app program: the widget tree it renders, every error with its line and reason code, and the room it needs. Fix each error at its line and check again.", + "inputSchema": { "type": "object", "properties": { "program": program }, "required": ["program"], "additionalProperties": false } }, + { "name": "create_stream", "description": "Stream a program into the person's open Create page (connected to this server's bridge), which renders the app as it arrives and lets them try its room and export it.", + "inputSchema": { "type": "object", "properties": { "program": program, "chunk": { "type": "integer", "minimum": 1, "maximum": 4096, "description": "Characters a chunk (24 by default)." }, "pace_ms": { "type": "integer", "minimum": 0, "maximum": 2000, "description": "Milliseconds between chunks (30 by default)." } }, "required": ["program"], "additionalProperties": false } }, + { "name": "create_export", "description": "Write the app's starter project into a new or empty directory: the program, the room service's configuration, the page, the check that is its build, a README and an AGENTS.md. With runtime_dir (a Create runtime: create/ and room/), the runtime is copied under vendor/.", + "inputSchema": { "type": "object", "properties": { "program": program, "dir": { "type": "string", "description": "The project directory: absent or empty." }, "runtime_dir": { "type": "string", "description": "A directory holding the Create runtime (create/ and room/)." } }, "required": ["program", "dir"], "additionalProperties": false } } + ]) +} + +fn text_result(text: String, structured: Value, is_error: bool) -> Value { + json!({ "content": [{ "type": "text", "text": text }], "structuredContent": structured, "isError": is_error }) +} + +/// Every file under `dir`, by its path from `dir`, sorted, with its SHA-256. +pub fn runtime_of(dir: &Path) -> std::io::Result> { + fn walk(base: &Path, d: &Path, out: &mut Vec) -> std::io::Result<()> { + let mut entries: Vec<_> = std::fs::read_dir(d)?.collect::>()?; + entries.sort_by_key(|e| e.file_name()); + for e in entries { + let p = e.path(); + if p.is_dir() { + walk(base, &p, out)?; + } else { + let rel = p.strip_prefix(base).expect("under base").to_string_lossy().replace('\\', "/"); + let sum = Sha256::digest(std::fs::read(&p)?); + out.push(RuntimeFile { path: rel, sha256: sum.iter().map(|b| format!("{b:02x}")).collect() }); + } + } + Ok(()) + } + let mut out = Vec::new(); + for top in ["create", "room"] { + let d = dir.join(top); + if d.is_dir() { + walk(dir, &d, &mut out)?; + } + } + Ok(out) +} + +/// Write the starter project for `program` into `dir` (absent or empty), the +/// runtime copied from `runtime_dir` when given. The files written, by path. +pub fn export_to(program: &str, dir: &Path, runtime_dir: Option<&Path>) -> Result, String> { + if dir.exists() && std::fs::read_dir(dir).map_err(|e| e.to_string())?.next().is_some() { + return Err(format!("{} is not empty: export writes into a new or empty directory", dir.display())); + } + let runtime = match runtime_dir { + Some(r) => runtime_of(r).map_err(|e| format!("runtime {}: {e}", r.display()))?, + None => Vec::new(), + }; + let files = crate::export::export(program, &runtime).map_err(|e| e.to_string())?; + let mut written = Vec::new(); + for f in files { + let to = dir.join(&f.path); + if let Some(p) = to.parent() { + std::fs::create_dir_all(p).map_err(|e| e.to_string())?; + } + match &f.contents { + Contents::Text(t) => std::fs::write(&to, t), + Contents::Runtime(from) => std::fs::copy(runtime_dir.expect("runtime files come from it").join(from), &to).map(|_| ()), + } + .map_err(|e| format!("{}: {e}", f.path))?; + written.push(f.path); + } + Ok(written) +} + +/// Answer one JSON-RPC message; `None` for a notification. +pub fn handle(msg: &Value, bridge: &Bridge) -> Option { + let id = msg.get("id").cloned()?; + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let ok = |result: Value| Some(json!({ "jsonrpc": "2.0", "id": id, "result": result })); + let fail = |code: i64, m: &str| Some(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": m } })); + match method { + "initialize" => ok(json!({ + "protocolVersion": msg.pointer("/params/protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"), + "capabilities": { "tools": {} }, + "serverInfo": { "name": "wai-ui", "version": env!("CARGO_PKG_VERSION") }, + "instructions": "Create builds a WAI app from a description: read create_guide, write the program, check it with create_check, stream it into the person's Create page with create_stream, and write the project with create_export." + })), + "ping" => ok(json!({})), + "tools/list" => ok(json!({ "tools": tools() })), + "tools/call" => { + let name = msg.pointer("/params/name").and_then(Value::as_str).unwrap_or(""); + let args = msg.pointer("/params/arguments").cloned().unwrap_or(json!({})); + let program = args.get("program").and_then(Value::as_str); + match name { + "create_guide" => { + let how = format!( + "\n\nThe person's Create page reads programs from this server's bridge: in Create, choose \"Your coding agent\" and enter the bridge http://127.0.0.1:{} and the code {}.", + bridge.port, bridge.code + ); + ok(text_result(crate::guide::guide() + &how, json!({ "bridge": format!("http://127.0.0.1:{}", bridge.port), "code": bridge.code }), false)) + } + "create_check" => { + let Some(p) = program else { return fail(-32602, "`program` is required") }; + let c = crate::check(p); + let bad: Vec = c.errors.iter().filter(|e| !e.warning).map(|e| format!("line {}: {}: {}", e.line, e.code, e.message)).collect(); + let text = if bad.is_empty() { + format!("No error. {} widget(s){}.", c.nodes.len(), c.room.as_ref().map(|r| format!(", a {} room", r.kind)).unwrap_or_default()) + } else { + bad.join("\n") + }; + ok(text_result(text, serde_json::to_value(&c).expect("json"), !bad.is_empty())) + } + "create_stream" => { + let Some(p) = program else { return fail(-32602, "`program` is required") }; + if bridge.listening() == 0 { + let m = format!("No Create page is connected. Ask the person to open Create, choose \"Your coding agent\", and enter the bridge http://127.0.0.1:{} and the code {}.", bridge.port, bridge.code); + return ok(text_result(m, json!({ "delivered": 0 }), true)); + } + let chunk = args.get("chunk").and_then(Value::as_u64).unwrap_or(24).clamp(1, 4096) as usize; + let pace = args.get("pace_ms").and_then(Value::as_u64).unwrap_or(30).min(2000); + bridge.send("start", &json!({})); + let chars: Vec = p.chars().collect(); + let mut n = 0; + let mut reached = 0; + for piece in chars.chunks(chunk) { + reached = bridge.send("chunk", &json!({ "text": piece.iter().collect::() })); + n += 1; + if pace > 0 { + std::thread::sleep(Duration::from_millis(pace)); + } + } + bridge.send("end", &json!({})); + let c = crate::check(p); + let bad = c.errors.iter().filter(|e| !e.warning).count(); + ok(text_result(format!("Streamed {n} chunk(s) to {reached} page(s); the app has {bad} error(s)."), json!({ "delivered": reached, "chunks": n, "errors": c.errors }), false)) + } + "create_export" => { + let Some(p) = program else { return fail(-32602, "`program` is required") }; + let Some(dir) = args.get("dir").and_then(Value::as_str) else { return fail(-32602, "`dir` is required") }; + let rt = args.get("runtime_dir").and_then(Value::as_str).map(PathBuf::from); + match export_to(p, Path::new(dir), rt.as_deref()) { + Ok(files) => ok(text_result(format!("Wrote {} file(s) to {dir}. Run `npm run check` there.", files.len()), json!({ "files": files }), false)), + Err(e) => ok(text_result(e, json!({ "files": [] }), true)), + } + } + _ => fail(-32602, &format!("no tool `{name}`")), + } + } + _ => fail(-32601, &format!("no method `{method}`")), + } +} + +/// Run the server over stdin and stdout until stdin closes. +pub fn run(port: u16) -> std::io::Result<()> { + let bridge = Bridge::start(port)?; + eprintln!("wai-ui mcp: the bridge for Create pages is http://127.0.0.1:{}, pairing code {}", bridge.port, bridge.code); + let stdin = std::io::stdin(); + let mut out = std::io::stdout(); + let mut line = String::new(); + let mut input = stdin.lock(); + loop { + line.clear(); + if input.read_line(&mut line)? == 0 { + return Ok(()); + } + let msg: Value = match serde_json::from_str(line.trim()) { + Ok(v) => v, + Err(_) => { + writeln!(out, "{}", json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }))?; + out.flush()?; + continue; + } + }; + if let Some(resp) = handle(&msg, &bridge) { + writeln!(out, "{resp}")?; + out.flush()?; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn the_bridge_refuses_a_page_without_the_code_and_streams_to_one_with_it() { + let b = Bridge::start(0).unwrap(); + let get = |path: &str| { + let mut s = TcpStream::connect(("127.0.0.1", b.port)).unwrap(); + write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: https://site.example\r\n\r\n").unwrap(); + s + }; + let mut bad = get("/create/stream?code=wrong"); + let mut resp = String::new(); + bad.read_to_string(&mut resp).unwrap(); + assert!(resp.starts_with("HTTP/1.1 403"), "{resp}"); + let good = get(&format!("/create/stream?code={}", b.code)); + let mut r = BufReader::new(good); + let mut head = String::new(); + r.read_line(&mut head).unwrap(); + assert!(head.starts_with("HTTP/1.1 200"), "{head}"); + for _ in 0..50 { + if b.listening() == 1 { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(b.send("chunk", &json!({ "text": "root = App(" })), 1); + let mut seen = String::new(); + while !seen.contains("root = App(") { + let mut l = String::new(); + r.read_line(&mut l).unwrap(); + seen.push_str(&l); + } + assert!(seen.contains("event: chunk")); + } + + #[test] + fn the_tools_answer_through_the_protocol() { + let b = Bridge::start(0).unwrap(); + let call = |name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": name, "arguments": args } }), &b).unwrap(); + let init = handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }), &b).unwrap(); + assert_eq!(init["result"]["serverInfo"]["name"], "wai-ui"); + assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), &b).is_none()); + let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), &b).unwrap(); + assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 4); + let g = call("create_guide", json!({})); + assert!(g["result"]["content"][0]["text"].as_str().unwrap().contains(&b.code)); + let c = call("create_check", json!({ "program": "root = App([w])\nw = WatchTogether(\"f.mp4\", [Chat()])\n" })); + assert_eq!(c["result"]["isError"], true); + assert!(c["result"]["content"][0]["text"].as_str().unwrap().contains("line 2: disallowed-child")); + let s = call("create_stream", json!({ "program": crate::guide::EXAMPLE })); + assert_eq!(s["result"]["isError"], true, "no page is connected"); + let dir = std::env::temp_dir().join(format!("wai-ui-mcp-{}", b.port)); + let _ = std::fs::remove_dir_all(&dir); + let e = call("create_export", json!({ "program": crate::guide::EXAMPLE, "dir": dir.to_string_lossy() })); + assert_eq!(e["result"]["isError"], false, "{e}"); + assert!(dir.join("room.toml").exists() && dir.join("AGENTS.md").exists()); + let again = call("create_export", json!({ "program": crate::guide::EXAMPLE, "dir": dir.to_string_lossy() })); + assert_eq!(again["result"]["isError"], true, "a directory with files in it is never written over"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/wai/wai-ui/src/plan.rs b/wai/wai-ui/src/plan.rs new file mode 100644 index 000000000..89c524a69 --- /dev/null +++ b/wai/wai-ui/src/plan.rs @@ -0,0 +1,265 @@ +//! What an app asks of the room service, and the rules of an app that joule-ui's +//! structural validation cannot state (one room, a poll's options, a show's +//! audience, a watch room's source). A widget that breaks one is dropped from +//! the tree the page renders, and the error names its line, as a line the +//! registry refuses is. + +use joule_ui_core::{PropValue, Widget}; +use joule_ui_lang::Node; +use serde::{Deserialize, Serialize}; + +use crate::registry::ROOMS; + +/// An app's own error: the shape of a line-language error (SPEC §11.8), its +/// code one of WAI's (`one-room`, `poll-options`, `poll-seconds`, +/// `audience-range`, `watch-source`, `no-room`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppError { + pub line: u32, + pub statement: String, + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prop: Option, + pub message: String, +} + +/// A poll as the room service is asked for it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PollPlan { + pub question: String, + pub options: Vec, + pub seconds: u32, +} + +/// The room an app needs: its kind, which decides the room service's +/// configuration, and what the app shows in it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoomPlan { + /// `live` (a Room), `watch` (a WatchTogether) or `show` (a Show). + pub kind: String, + /// The widget's key in the snapshot, so the page finds it. + pub key: String, + pub title: Option, + /// A watch room's file, as the app names it. + pub source: Option, + /// A show's inputs per viewer per poll. + pub audience: u8, + /// The widgets inside the room, by name, in order. + pub features: Vec, + pub polls: Vec, +} + +/// The default seconds a poll stays open, and its bounds. +pub const POLL_SECONDS: (u32, u32, u32) = (30, 5, 300); + +fn text(w: &Widget, p: &str) -> Option { + match w.props.get(p) { + Some(PropValue::Text(s)) => Some(s.clone()), + _ => None, + } +} + +fn whole(n: f64, lo: f64, hi: f64) -> Option { + (n.fract() == 0.0 && n >= lo && n <= hi).then_some(n as u32) +} + +/// A watch room's source: a file within the app, named by a relative path of +/// letters, digits, `.`, `_`, `-` and `/`, no part of it `.` or `..`. +pub fn source_ok(s: &str) -> bool { + !s.is_empty() + && s.len() <= 200 + && !s.starts_with('/') + && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) + && s.split('/').all(|part| !part.is_empty() && part != "." && part != "..") +} + +/// Check the app's own rules on a validated tree and its nodes (pre-order): +/// the tree with every widget that breaks one dropped, its nodes, the room the +/// app needs (none when it holds none), and the errors. +pub fn apply(tree: &Widget, nodes: &[Node]) -> (Option, Vec, Option, Vec) { + let mut errors = Vec::new(); + let mut index = 0usize; + let mut rooms = 0usize; + let mut out_nodes = Vec::new(); + let Some(out) = prune(tree, nodes, &mut index, &mut out_nodes, &mut errors, &mut rooms) else { + // The root itself broke a rule: nothing renders. + return (None, Vec::new(), None, errors); + }; + // The plan, from the room kept. + let mut plan = None; + if ROOMS.contains(&out.name.as_str()) { + plan = Some(plan_of(&out, out_nodes[0].key.clone())); + } else { + let mut k = 1usize; + for c in &out.children { + if ROOMS.contains(&c.name.as_str()) { + let key = out_nodes.get(k).map(|n| n.key.clone()).unwrap_or_default(); + plan = Some(plan_of(c, key)); + break; + } + k += count(c); + } + } + (Some(out), out_nodes, plan, errors) +} + +fn count(w: &Widget) -> usize { + 1 + w.children.iter().map(count).sum::() +} + +fn err(errors: &mut Vec, n: &Node, code: &str, component: &str, prop: Option<&str>, message: String) { + errors.push(AppError { line: n.line, statement: n.statement.clone(), code: code.into(), component: Some(component.into()), prop: prop.map(String::from), message }); +} + +fn prune(w: &Widget, nodes: &[Node], index: &mut usize, out_nodes: &mut Vec, errors: &mut Vec, rooms: &mut usize) -> Option { + let me = *index; + let node = nodes[me].clone(); + *index += count(w); + let mut keep = true; + match w.name.as_str() { + n if ROOMS.contains(&n) => { + *rooms += 1; + if *rooms > 1 { + err(errors, &node, "one-room", n, None, format!("an app holds one room; this `{n}` is a second, and is left out")); + keep = false; + } + if n == "WatchTogether" && !text(w, "source").is_some_and(|s| source_ok(&s)) { + err(errors, &node, "watch-source", n, Some("source"), "a watch room's `source` is a file within the app: a relative path such as \"film.mp4\" or \"media/film.mp4\", of letters, digits, `.`, `_`, `-` and `/`".into()); + keep = false; + } + if n == "Show" + && let Some(PropValue::Number(a)) = w.props.get("audience") + && whole(*a, 1.0, 255.0).is_none() + { + err(errors, &node, "audience-range", n, Some("audience"), "a show's `audience` is a whole number from 1 to 255".into()); + keep = false; + } + } + "Poll" => { + let ok = match w.props.get("options") { + Some(PropValue::List(items)) => { + (2..=32).contains(&items.len()) && items.iter().all(|i| matches!(i, PropValue::Text(s) if !s.is_empty() && s.chars().count() <= 100)) + } + _ => false, + }; + if !ok { + err(errors, &node, "poll-options", "Poll", Some("options"), "a poll's `options` are a list of 2 to 32 texts, each of 1 to 100 characters".into()); + keep = false; + } + if let Some(PropValue::Number(s)) = w.props.get("seconds") + && whole(*s, POLL_SECONDS.1 as f64, POLL_SECONDS.2 as f64).is_none() + { + err(errors, &node, "poll-seconds", "Poll", Some("seconds"), format!("a poll's `seconds` is a whole number from {} to {}", POLL_SECONDS.1, POLL_SECONDS.2)); + keep = false; + } + } + _ => {} + } + if !keep { + return None; + } + out_nodes.push(node); + let mut kept = Widget { name: w.name.clone(), props: w.props.clone(), children: Vec::new() }; + let mut i = me + 1; + for c in &w.children { + let mut ci = i; + if let Some(k) = prune(c, nodes, &mut ci, out_nodes, errors, rooms) { + kept.children.push(k); + } + i += count(c); + } + Some(kept) +} + +fn plan_of(room: &Widget, key: String) -> RoomPlan { + let kind = match room.name.as_str() { + "WatchTogether" => "watch", + "Show" => "show", + _ => "live", + }; + let audience = match room.props.get("audience") { + Some(PropValue::Number(a)) => *a as u8, + _ => 1, + }; + let polls = room + .children + .iter() + .filter(|c| c.name == "Poll") + .map(|p| PollPlan { + question: text(p, "question").unwrap_or_default(), + options: match p.props.get("options") { + Some(PropValue::List(items)) => items.iter().filter_map(|i| if let PropValue::Text(s) = i { Some(s.clone()) } else { None }).collect(), + _ => Vec::new(), + }, + seconds: match p.props.get("seconds") { + Some(PropValue::Number(s)) => *s as u32, + _ => POLL_SECONDS.0, + }, + }) + .collect(); + RoomPlan { + kind: kind.into(), + key, + title: text(room, "title"), + source: if kind == "watch" { text(room, "source") } else { None }, + audience: if kind == "show" { audience } else { 0 }, + features: room.children.iter().map(|c| c.name.clone()).collect(), + polls, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::library; + + fn run(src: &str) -> (Widget, Option, Vec) { + let s = joule_ui_lang::parse(&library(), src); + assert!(s.errors.iter().all(|e| e.warning), "{:?}", s.errors); + let (t, _n, p, e) = apply(s.tree.as_ref().unwrap(), &s.nodes); + (t.unwrap(), p, e) + } + + #[test] + fn a_show_with_chat_and_a_poll() { + let (_, p, e) = run("root = App([show], \"Friday\")\nshow = Show([player, chat, poll], \"Live\", 2)\nplayer = Player()\nchat = Chat()\npoll = Poll(\"Next?\", [\"A\", \"B\"], 20)\n"); + assert!(e.is_empty()); + let p = p.unwrap(); + assert_eq!((p.kind.as_str(), p.key.as_str(), p.audience), ("show", "show", 2)); + assert_eq!(p.features, vec!["Player", "Chat", "Poll"]); + assert_eq!(p.polls, vec![PollPlan { question: "Next?".into(), options: vec!["A".into(), "B".into()], seconds: 20 }]); + } + + #[test] + fn a_second_room_is_left_out_with_its_line() { + let (t, p, e) = run("root = App([a, b])\na = Room([])\nb = Show([])\n"); + assert_eq!(e.len(), 1); + assert_eq!((e[0].code.as_str(), e[0].line, e[0].statement.as_str()), ("one-room", 3, "b")); + assert_eq!(t.children.len(), 1); + assert_eq!(p.unwrap().kind, "live"); + } + + #[test] + fn a_poll_needs_two_to_thirty_two_options() { + let (t, _, e) = run("root = App([s])\ns = Show([p, q])\np = Poll(\"one?\", [\"only\"])\nq = Poll(\"ok?\", [\"y\", \"n\"], 3)\n"); + let codes: Vec<(&str, u32)> = e.iter().map(|x| (x.code.as_str(), x.line)).collect(); + assert_eq!(codes, vec![("poll-options", 3), ("poll-seconds", 4)]); + assert!(t.children[0].children.is_empty()); + } + + #[test] + fn a_watch_source_stays_within_the_app() { + for bad in ["/etc/passwd", "../x.mp4", "https://example.com/x.mp4", "a//b", ""] { + assert!(!source_ok(bad), "{bad}"); + } + assert!(source_ok("media/film-1.mp4")); + let (_, p, e) = run("root = App([w])\nw = WatchTogether(\"film.mp4\")\n"); + assert!(e.is_empty()); + assert_eq!(p.unwrap().source.as_deref(), Some("film.mp4")); + let (_, p, e) = run("root = App([w])\nw = WatchTogether(\"../film.mp4\")\n"); + assert_eq!(e[0].code, "watch-source"); + assert!(p.is_none()); + } +} diff --git a/wai/wai-ui/src/registry.rs b/wai/wai-ui/src/registry.rs new file mode 100644 index 000000000..1b386079d --- /dev/null +++ b/wai/wai-ui/src/registry.rs @@ -0,0 +1,147 @@ +//! The WAI widgets for joule-ui: each a typed schema, its positional order the +//! one a model writes it in, its children the ones its room's service keeps. + +use joule_ui_core::{AllowedChildren, PropSchema, PropType, Registry, WidgetSchema}; +use joule_ui_lang::Library; + +/// The root widget. +pub const ROOT: &str = "App"; + +/// The three kinds of room, by widget. +pub const ROOMS: [&str; 3] = ["Room", "WatchTogether", "Show"]; + +/// What any room may also hold. +const IN_ANY_ROOM: [&str; 4] = ["Participants", "Receipts", "EnergyMeter", "Text"]; + +fn only(extra: &[&str]) -> AllowedChildren { + AllowedChildren::OnlyOf(extra.iter().chain(IN_ANY_ROOM.iter()).map(|s| s.to_string()).collect()) +} + +/// The WAI registry. +pub fn registry() -> Registry { + use PropSchema as P; + use PropType as T; + Registry::new() + .with( + WidgetSchema::new( + "App", + "The app: its title and what it holds. An app holds one room (a Room, a WatchTogether or a Show), and text, receipts and an energy meter beside it.", + ) + .prop("title", P::optional(T::Text)) + .children(AllowedChildren::OnlyOf( + ["Room", "WatchTogether", "Show", "Text", "Receipts", "EnergyMeter"].iter().map(|s| s.to_string()).collect(), + )) + .positional(["children", "title"]), + ) + .with( + WidgetSchema::new("Text", "A paragraph of text.") + .prop("text", P::required(T::Text)) + .positional(["text"]), + ) + .with( + WidgetSchema::new( + "Room", + "A live room: people join with their camera and microphone, or watch. Every audio and video object is checked against its signed receipt before it is shown. Holds a Player for the room's video and a Chat; those who join can chat, those who watch read it.", + ) + .prop("title", P::optional(T::Text)) + .children(only(&["Player", "Chat"])) + .positional(["children", "title"]), + ) + .with( + WidgetSchema::new( + "WatchTogether", + "A watch room: everyone watches one file together, in sync; anyone may play, pause, seek or change the speed for everyone. `source` is the file's name within the app (for example \"film.mp4\"). A watch room carries the shared controls alone: it holds no Chat and no Poll.", + ) + .prop("source", P::required(T::Text)) + .prop("title", P::optional(T::Text)) + .children(only(&[])) + .positional(["source", "children", "title"]), + ) + .with( + WidgetSchema::new( + "Show", + "A show: hosts on camera, the first of them leading; an audience that watches and votes. Holds a Player for the hosts' video, a Chat for the hosts, and Polls the lead opens and the audience answers. `audience` is how many inputs each viewer may send to one poll (1 to 255; 1 when left out).", + ) + .prop("title", P::optional(T::Text)) + .prop("audience", P::optional(T::Number)) + .children(only(&["Player", "Chat", "Poll"])) + .positional(["children", "title", "audience"]), + ) + .with( + WidgetSchema::new("Player", "The room's video: each participant's camera laid out from the room's signed record, every frame checked before it is shown.") + .prop("title", P::optional(T::Text)) + .positional(["title"]), + ) + .with( + WidgetSchema::new("Chat", "The room's chat: each message a signed event, verified before it is shown. Those who joined write; those who watch read.") + .prop("title", P::optional(T::Text)) + .positional(["title"]), + ) + .with( + WidgetSchema::new( + "Poll", + "A vote in a Show: the lead opens it, each viewer chooses one option, and the room's clock closes it and signs the result. `options` is a list of 2 to 32 texts; `seconds` how long it stays open (5 to 300; 30 when left out).", + ) + .prop("question", P::required(T::Text)) + .prop("options", P::required(T::List)) + .prop("seconds", P::optional(T::Number)) + .positional(["question", "options", "seconds"]), + ) + .with( + WidgetSchema::new("Participants", "Who is in the room now, from the room's signed lifecycle.") + .prop("title", P::optional(T::Text)) + .positional(["title"]), + ) + .with( + WidgetSchema::new( + "Receipts", + "This page's receipts: every action a person takes here is sealed into a hash-chained log this page signs and verifies, and the room's objects checked against their receipts are counted.", + ) + .prop("title", P::optional(T::Text)) + .positional(["title"]), + ) + .with( + WidgetSchema::new("EnergyMeter", "What each action cost in energy, and how the figure was obtained (a page has no meter, so its figures are estimates, labelled so).") + .prop("title", P::optional(T::Text)) + .positional(["title"]), + ) +} + +/// The WAI library: the registry and its root. +pub fn library() -> Library { + Library::new(registry(), Some(ROOT.into())).expect("the WAI registry is a well-formed library") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_widget_is_reachable_from_the_root() { + let r = registry(); + let mut seen = std::collections::BTreeSet::new(); + let mut todo = vec![ROOT.to_string()]; + while let Some(n) = todo.pop() { + if !seen.insert(n.clone()) { + continue; + } + if let AllowedChildren::OnlyOf(list) = &r.get(&n).unwrap().children { + todo.extend(list.iter().cloned()); + } + } + let mut all: Vec = r.names().map(String::from).collect(); + all.sort(); + assert_eq!(seen.into_iter().collect::>(), all); + } + + #[test] + fn a_watch_room_holds_no_chat_and_no_poll() { + let r = registry(); + let AllowedChildren::OnlyOf(w) = &r.get("WatchTogether").unwrap().children else { panic!() }; + assert!(!w.iter().any(|c| c == "Chat" || c == "Poll")); + let AllowedChildren::OnlyOf(s) = &r.get("Show").unwrap().children else { panic!() }; + assert!(s.iter().any(|c| c == "Poll")); + let AllowedChildren::OnlyOf(l) = &r.get("Room").unwrap().children else { panic!() }; + assert!(!l.iter().any(|c| c == "Poll"), "only a show's clock opens and closes a vote"); + } +} diff --git a/wai/wai-ui/src/web.rs b/wai/wai-ui/src/web.rs new file mode 100644 index 000000000..e1d67341b --- /dev/null +++ b/wai/wai-ui/src/web.rs @@ -0,0 +1,202 @@ +//! The page's WebAssembly surface (feature `web`): a Create session, its +//! program streamed into snapshots, and the interaction log every action a +//! person takes on the page is sealed into (joule-ui SPEC §6, §7). +//! +//! A page has no energy meter, so each interaction's figure is the estimator's, +//! the handler's wall time on the page's clock at the default estimator power, +//! graded `Estimator` (SPEC §5). No capability gates the page's own actions: the +//! room's grants gate what the room does. + +use ed25519_dalek::SigningKey; +use joule_ui_interaction::{begin_interaction, verify_chain, ContentId, InteractionLog, NoMeter, Pending, SealedInteraction, Verdict}; +use serde_json::{json, Value}; +use wasm_bindgen::prelude::*; + +use crate::export::RuntimeFile; +use crate::{registry, Checked, Create}; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// A `did:key` for an Ed25519 key: multibase base58btc of the multicodec +/// prefix `0xed 0x01` and the key. +pub fn did_key(key: &[u8; 32]) -> String { + let mut mc = vec![0xed, 0x01]; + mc.extend_from_slice(key); + format!("did:key:z{}", bs58::encode(mc).into_string()) +} + +fn entry_view(e: &SealedInteraction) -> Value { + let it = &e.interaction; + json!({ + "seq": e.seq, + "action": it.action, + "actor": it.actor, + "verdict": it.verdict.tag(), + "reason": match &it.verdict { Verdict::Deny(r) => Some(r.clone()), Verdict::Allow => None }, + "energy": { "estimated_uj": it.energy.estimated_uj, "measured_uj": it.energy.measured_uj, "provenance": it.energy.provenance.label() }, + "surface": it.surface.0, + "inputs": it.inputs.0, + "outputs": it.outputs.0, + "prev": e.prev.0, + "id": e.content_id().0, + "signature": hex(&e.signature), + "signer": hex(&e.signer), + }) +} + +/// A Create session in the page. +#[wasm_bindgen] +pub struct Session { + create: Create, + log: InteractionLog, + actor: String, + pending: Vec>, + surface: ContentId, +} + +#[wasm_bindgen] +impl Session { + /// A session whose log is signed by the Ed25519 key from `seed` (32 bytes the + /// page draws from its random generator and keeps in memory alone). + #[wasm_bindgen(constructor)] + pub fn new(seed: &[u8]) -> Result { + let seed: [u8; 32] = seed.try_into().map_err(|_| JsError::new("a session key is 32 bytes"))?; + let sk = SigningKey::from_bytes(&seed); + let actor = did_key(&sk.verifying_key().to_bytes()); + Ok(Session { create: Create::new(), log: InteractionLog::new(sk), actor, pending: Vec::new(), surface: ContentId::genesis() }) + } + + /// The principal every action on this page is attributed to: the page's own key. + pub fn actor(&self) -> String { + self.actor.clone() + } + + fn view(&mut self, c: Checked) -> String { + if let Some(t) = &c.tree { + self.surface = ContentId::of_bytes(&serde_json::to_vec(t).expect("a tree serialises")); + } + let mut v = serde_json::to_value(&c).expect("a snapshot serialises"); + v["surface"] = json!(self.surface.0); + v.to_string() + } + + /// The next chunk of the program: the snapshot, as JSON. + pub fn push(&mut self, chunk: &str) -> String { + let c = self.create.push(chunk); + self.view(c) + } + + /// The stream has ended: the final snapshot. + pub fn finish(&mut self) -> String { + let c = self.create.finish(); + self.view(c) + } + + /// Start a new program (the log goes on). + pub fn reset(&mut self) { + self.create.reset(); + } + + /// The program so far. + pub fn program(&self) -> String { + self.create.text().to_string() + } + + /// Phase 1 of an interaction (SPEC §6), before its handler runs: `{"pending": id}` + /// for an allowed action, or `{"sealed": entry}` for a denied one, whose handler + /// must not run. + #[wasm_bindgen(js_name = beginAction)] + pub fn begin_action(&mut self, action: &str, inputs: &str) -> String { + match begin_interaction(&self.surface, action, &self.actor, inputs.as_bytes(), &NoMeter, None) { + Ok(p) => { + self.pending.push(Some(p)); + json!({ "pending": self.pending.len() - 1 }).to_string() + } + Err(denied) => { + let e = self.log.record(denied); + json!({ "sealed": entry_view(e) }).to_string() + } + } + } + + /// Phases 2 and 3, once the handler ran for `wall_ms` on the page's clock and + /// produced `output`: the interaction sealed as the next link of the log. + #[wasm_bindgen(js_name = finishAction)] + pub fn finish_action(&mut self, id: usize, output: &str, wall_ms: f64) -> Result { + let p = self.pending.get_mut(id).and_then(Option::take).ok_or_else(|| JsError::new("no such pending action"))?; + let wall = if wall_ms.is_finite() && wall_ms > 0.0 { std::time::Duration::from_secs_f64(wall_ms / 1000.0) } else { std::time::Duration::ZERO }; + let it = p.finish(output.as_bytes(), &NoMeter, None, wall); + Ok(entry_view(self.log.record(it)).to_string()) + } + + /// The log, every link as the page shows it. + pub fn log(&self) -> String { + Value::Array(self.log.entries().iter().map(entry_view).collect()).to_string() + } + + /// The log as sealed, for anyone to verify (`verifyLog`). + #[wasm_bindgen(js_name = logRaw)] + pub fn log_raw(&self) -> String { + serde_json::to_string(self.log.entries()).expect("entries serialise") + } + + /// The page's own check of its log (SPEC §7): `{"verified": n}` or `{"broken_at": i}`. + pub fn verify(&self) -> String { + verify_result(self.log.entries()) + } + + /// The energy of every allowed interaction, in microjoules. + #[wasm_bindgen(js_name = totalUj)] + pub fn total_uj(&self) -> f64 { + self.log.total_uj() as f64 + } + + /// The starter project for the program so far (`export::export`): its files, + /// as JSON; `runtime` lists the runtime files the page carries, with their SHA-256. + pub fn export(&self, runtime: &str) -> Result { + let rt: Vec = serde_json::from_str(runtime).map_err(|e| JsError::new(&format!("runtime: {e}")))?; + let files = crate::export::export(self.create.text(), &rt).map_err(|e| JsError::new(&e.to_string()))?; + Ok(serde_json::to_string(&files).expect("files serialise")) + } + + /// The program in its canonical form (joule-ui SPEC §11.7). + pub fn canonical(&self) -> Result { + let c = self.create.snapshot(); + let t = c.tree.ok_or_else(|| JsError::new("nothing renders yet"))?; + joule_ui_lang::write(&t, ®istry::library()).map_err(|e| JsError::new(&e.to_string())) + } +} + +fn verify_result(entries: &[SealedInteraction]) -> String { + match verify_chain(entries) { + Ok(n) => json!({ "verified": n }).to_string(), + Err(i) => json!({ "broken_at": i }).to_string(), + } +} + +/// Verify a log as `logRaw` gives it. +#[wasm_bindgen(js_name = verifyLog)] +pub fn verify_log(raw: &str) -> Result { + let entries: Vec = serde_json::from_str(raw).map_err(|e| JsError::new(&e.to_string()))?; + Ok(verify_result(&entries)) +} + +/// The WAI registry as a library: each widget's schema and positional order, and the root. +#[wasm_bindgen] +pub fn registry() -> String { + joule_ui_lang::vectors::library_json(®istry::library()).to_string() +} + +/// What a model is told before it writes an app. +#[wasm_bindgen] +pub fn guide() -> String { + crate::guide::guide() +} + +/// Check a whole program: its final snapshot, as JSON. +#[wasm_bindgen] +pub fn check(program: &str) -> String { + serde_json::to_string(&crate::check(program)).expect("a snapshot serialises") +} diff --git a/wai/wai-ui/templates/AGENTS.md b/wai/wai-ui/templates/AGENTS.md new file mode 100644 index 000000000..c90eb913c --- /dev/null +++ b/wai/wai-ui/templates/AGENTS.md @@ -0,0 +1,20 @@ +# Working on this project + +This is a WAI app. `app.oui` is the app, written in joule-ui's line language (SPEC §11 of +joule-ui): one widget a line, `name = Widget(arguments)`, `root` first. The page renders +it with the WAI widgets in `vendor/create/`, and its room runs on the room service that +`room.toml` configures. + +- To change the app, edit `app.oui`, then run `npm run check`. A line the registry does + not accept is refused with its line and a reason code; fix that line. Keep `app.json` + the tree `app.oui` gives (the check says when it is not). +- Keep `room.toml` matching the app's room: a `WatchTogether` needs `[room] + watch_source = "media/"` and `watch_duration_ms`; a `Show` needs `[co] + audience_input` equal to its `audience`; a `Room` needs neither. +- Do not edit `vendor/`: it is the runtime, checked file by file against `vendor.json`. +- Never put a key, a token or any other secret in this repository. Development keys are + made in `.wai-dev/` by `npm run dev` and stay there. + +## The language and the widgets + +{{GUIDE}} diff --git a/wai/wai-ui/templates/README.md b/wai/wai-ui/templates/README.md new file mode 100644 index 000000000..8275ef477 --- /dev/null +++ b/wai/wai-ui/templates/README.md @@ -0,0 +1,37 @@ +# {{TITLE}} + +A WAI app made with Create: {{SUMMARY}} + +It is a plain web project you own. `app.oui` is the app, one widget a line; the WAI +widgets that render it, and the room client under them, are in `vendor/`; `room.toml` +configures the room service that keeps its room. + +## Run it + +You need Node 22 or later, and the WAI room service and relay (`joule-moq-room` and +`joule-moq-relay`, built from the open-standards repository) on your `PATH`, or named by +`JOULE_MOQ_ROOM` and `JOULE_MOQ_RELAY`. + +``` +npm run check # checks the app, its room configuration and its runtime; writes dist/ +npm run dev # relay, room service and the app on http://127.0.0.1:8080/ +``` + +## What you see + +{{WHAT}} The page's receipts show every action you take there, sealed into a log the +page signs and checks. + +## Change it + +Edit `app.oui` (the widgets and their signatures are in `AGENTS.md`), then run +`npm run check`; it refuses a line the registry does not accept, naming the line and the +reason. Update `app.json` from the check's output, or export again from Create. + +## Development and production + +Everything `npm run dev` starts listens on 127.0.0.1 alone, and its keys are made in +`.wai-dev/`, which is never committed. In production, the room service runs with keys of +its own, kept outside the repository, behind a front that serves `dist/` with TLS and +passes `/room-api/` to the room service and `/moq` to the relay on the same origin. No +model key or other secret is part of this project. diff --git a/wai/wai-ui/templates/check.mjs b/wai/wai-ui/templates/check.mjs new file mode 100644 index 000000000..e5d239c88 --- /dev/null +++ b/wai/wai-ui/templates/check.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// The project's build: check everything the app is made of, then write dist/. +// +// node check.mjs +// +// 1. Every runtime file under vendor/ is present, with the SHA-256 vendor.json names. +// 2. app.oui parses, with the runtime's own parser (its WebAssembly build), into the tree in +// app.json, every widget accepted by the WAI registry, with no error and one room. +// 3. room.toml is present and configures the room the app holds: a watch room's source +// (the file present under media/, its duration set), a show's audience inputs, a live +// room neither. +// 4. dist/ holds what a front serves: index.html, app.oui, app.json, vendor/ (and media/). +// +// Exit status 0 when all of it holds; 1, with every failure named, otherwise. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const failures = []; +const fail = (m) => failures.push(m); +const read = (p) => fs.readFileSync(path.join(HERE, p)); + +// 1. The runtime. +let vendor = { files: [] }; +try { + vendor = JSON.parse(read("vendor.json")); +} catch (e) { + fail(`vendor.json: ${e.message}`); +} +for (const f of vendor.files) { + const p = path.join("vendor", f.path); + if (!fs.existsSync(path.join(HERE, p))) { + fail(`${p} is missing (vendor.json names it)`); + continue; + } + const sum = crypto.createHash("sha256").update(read(p)).digest("hex"); + if (sum !== f.sha256) fail(`${p}: its SHA-256 is ${sum}, vendor.json says ${f.sha256}`); +} + +// 2. The program, by the runtime's parser. +let checked = null; +try { + const wasm = path.join(HERE, "vendor/create/pkg/wai_ui_bg.wasm"); + const ui = await import(pathToFileURL(path.join(HERE, "vendor/create/pkg/wai_ui.js")).href); + ui.initSync({ module: fs.readFileSync(wasm) }); + checked = JSON.parse(ui.check(read("app.oui").toString())); +} catch (e) { + fail(`app.oui could not be checked: ${e.message}`); +} +if (checked) { + for (const e of checked.errors.filter((x) => !x.warning)) fail(`app.oui line ${e.line}: ${e.code}: ${e.message}`); + let tree = null; + try { + tree = JSON.parse(read("app.json")); + } catch (e) { + fail(`app.json: ${e.message}`); + } + if (tree && JSON.stringify(checked.tree) !== JSON.stringify(tree)) fail("app.json is not the tree app.oui gives: run the export again, or update app.json from app.oui"); + if (!checked.room) fail("app.oui holds no room (a Room, a WatchTogether or a Show)"); +} + +// 3. The room service's configuration matches the room. +const toml = fs.existsSync(path.join(HERE, "room.toml")) ? read("room.toml").toString() : null; +if (toml === null) fail("room.toml is missing: the room service has no configuration for this app's room"); +const setting = (section, key) => { + if (!toml) return undefined; + let cur = ""; + for (const line of toml.split("\n")) { + const s = line.replace(/#.*$/, "").trim(); + const h = s.match(/^\[([^\]]+)\]$/); + if (h) { cur = h[1]; continue; } + const kv = s.match(/^([A-Za-z_]+)\s*=\s*(.+)$/); + if (kv && cur === section && kv[1] === key) return kv[2].trim().replace(/^"(.*)"$/, "$1"); + } + return undefined; +}; +if (toml !== null && checked && checked.room) { + const room = checked.room; + const source = setting("room", "watch_source") || ""; + const audience = Number(setting("co", "audience_input") || 0); + if (room.kind === "watch") { + const want = `media/${room.source}`; + if (source !== want) fail(`room.toml: [room] watch_source is "${source}"; the app watches "${want}"`); + else if (!fs.existsSync(path.join(HERE, want))) fail(`${want} is missing: put the file the room watches there`); + if (!(Number(setting("room", "watch_duration_ms") || 0) > 0)) fail("room.toml: set [room] watch_duration_ms to the file's length in milliseconds"); + } else if (source) fail(`room.toml: [room] watch_source is set, and the app's room (${room.kind}) watches no file`); + if (room.kind === "show" && audience !== room.audience) fail(`room.toml: [co] audience_input is ${audience}; the app's show takes ${room.audience}`); + if (room.kind !== "show" && audience > 0) fail(`room.toml: [co] audience_input is ${audience}, and the app's room (${room.kind}) is not a show`); + for (const k of ["listen", "keys", "relay_url"]) if (!setting("", k)) fail(`room.toml: \`${k}\` is not set`); +} + +if (failures.length) { + for (const f of failures) console.error(`check: ${f}`); + console.error(`check: ${failures.length} failure(s)`); + process.exit(1); +} + +// 4. dist/. +const DIST = path.join(HERE, "dist"); +fs.rmSync(DIST, { recursive: true, force: true }); +const copy = (rel) => { + const from = path.join(HERE, rel); + if (!fs.existsSync(from)) return; + fs.cpSync(from, path.join(DIST, rel), { recursive: true }); +}; +for (const rel of ["index.html", "app.oui", "app.json", "vendor", "media"]) copy(rel); +console.log(`check: app.oui gives ${checked.nodes.length} widget(s) in a ${checked.room.kind} room; room.toml matches; ${vendor.files.length} runtime file(s) verified; dist/ written`); diff --git a/wai/wai-ui/templates/gitignore b/wai/wai-ui/templates/gitignore new file mode 100644 index 000000000..65c707e79 --- /dev/null +++ b/wai/wai-ui/templates/gitignore @@ -0,0 +1,4 @@ +# Development keys, logs and generated configuration (node serve.mjs --up): never committed. +.wai-dev/ +dist/ +node_modules/ diff --git a/wai/wai-ui/templates/index.html b/wai/wai-ui/templates/index.html new file mode 100644 index 000000000..841cb1e56 --- /dev/null +++ b/wai/wai-ui/templates/index.html @@ -0,0 +1,15 @@ + + + + + +{{TITLE}} + + + + +
+ + + diff --git a/wai/wai-ui/templates/package.json b/wai/wai-ui/templates/package.json new file mode 100644 index 000000000..05d8cf53f --- /dev/null +++ b/wai/wai-ui/templates/package.json @@ -0,0 +1,15 @@ +{ + "name": "{{PACKAGE}}", + "private": true, + "version": "0.1.0", + "description": "{{TITLE}}: a WAI app made with Create.", + "license": "Apache-2.0", + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { + "check": "node check.mjs", + "build": "node check.mjs", + "dev": "node serve.mjs --up --source", + "start": "node serve.mjs" + } +} diff --git a/wai/wai-ui/templates/serve.mjs b/wai/wai-ui/templates/serve.mjs new file mode 100644 index 000000000..b7b42c9ea --- /dev/null +++ b/wai/wai-ui/templates/serve.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Development only: serve this app on loopback, the room service and the relay behind the +// same origin, as a production front would (the page refuses endpoints on another origin). +// +// node serve.mjs the app at http://127.0.0.1:8080/, /room-api/* proxied to the room +// service ($ROOM_API, http://127.0.0.1:9682) and /moq to the relay +// ($RELAY_PORT, 9681), which you run yourself +// node serve.mjs --up the same, and it starts both: joule-moq-relay and joule-moq-room +// (from $JOULE_MOQ_RELAY and $JOULE_MOQ_ROOM, else from your PATH), +// their keys made in .wai-dev/ the first time (never committed) +// +// Environment: PORT (the front, 8080), ROOM_PORT (9682), RELAY_PORT (9681). Everything listens +// on 127.0.0.1 alone. Nothing here is for production: see README.md. +import http from "node:http"; +import net from "node:net"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT || 8080); +const ROOM_PORT = Number(process.env.ROOM_PORT || 9682); +const RELAY_PORT = Number(process.env.RELAY_PORT || 9681); +const UP = process.argv.includes("--up"); +const DEV = path.join(HERE, ".wai-dev"); +const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".wasm": "application/wasm", ".json": "application/json", ".css": "text/css", ".oui": "text/plain; charset=utf-8", ".mp4": "video/mp4", ".webm": "video/webm", ".wav": "audio/wav" }; + +const children = []; +function bin(envName, name) { + return process.env[envName] || name; +} +function waitFor(what, ms, f) { + const end = Date.now() + ms; + return new Promise((ok, fail) => { + const tick = async () => { + try { if (await f()) return ok(); } catch { /* not yet */ } + if (Date.now() > end) return fail(new Error(`${what}: not within ${ms} ms`)); + setTimeout(tick, 200); + }; + tick(); + }); +} + +async function up() { + fs.mkdirSync(DEV, { recursive: true, mode: 0o700 }); + const keys = path.join(DEV, "room-keys.toml"); + const linesFile = path.join(DEV, "relay-lines.toml"); + if (!fs.existsSync(keys)) { + const lines = execFileSync(bin("JOULE_MOQ_ROOM", "joule-moq-room"), ["keygen", keys], { stdio: ["ignore", "pipe", "inherit"] }).toString(); + fs.writeFileSync(linesFile, lines, { mode: 0o600 }); + } + const lines = fs.readFileSync(linesFile, "utf8"); + const clock = (lines.match(/^# clock\s+([0-9a-f]{64})/m) || [])[1]; + const roomToml = fs.readFileSync(path.join(HERE, "room.toml"), "utf8"); + const show = /^\[co\][^[]*^audience_input\s*=\s*[1-9]/m.test(roomToml); + let relay = fs.readFileSync(path.join(HERE, "relay.toml"), "utf8").replace(/^websocket_listen = .*$/m, `websocket_listen = "127.0.0.1:${RELAY_PORT}"`).replace(/^out_path = .*$/m, `out_path = ${JSON.stringify(path.join(DEV, "summaries.jsonl"))}`); + relay += `\n${lines}\n`; + // A show's relay holds the floor and the audience's inputs, honouring the room's clock. + if (show && clock) relay += `\n[floor]\nroom_keys = ["${clock}"]\n`; + fs.writeFileSync(path.join(DEV, "relay.toml"), relay); + const room = roomToml + .replace(/^listen = .*$/m, `listen = "127.0.0.1:${ROOM_PORT}"`) + .replace(/^keys = .*$/m, `keys = ${JSON.stringify(keys)}`) + .replace(/^relay_url = .*$/m, `relay_url = "ws://127.0.0.1:${RELAY_PORT}/moq"`) + .replace(/^watch_source = "(.+)"$/m, (_, p) => `watch_source = ${JSON.stringify(path.join(HERE, p))}`); + fs.writeFileSync(path.join(DEV, "room.toml"), room); + const run = (b, args, name) => { + const log = fs.openSync(path.join(DEV, `${name}.log`), "w"); + const p = spawn(b, args, { stdio: ["ignore", log, log], env: { ...process.env, NO_COLOR: "1" } }); + p.on("exit", (code) => { if (!stopping) console.error(`${name} exited (${code}); see .wai-dev/${name}.log`); }); + children.push(p); + return p; + }; + run(bin("JOULE_MOQ_RELAY", "joule-moq-relay"), ["--config", path.join(DEV, "relay.toml")], "relay"); + await waitFor("the relay", 30000, () => new Promise((ok) => { const s = net.connect(RELAY_PORT, "127.0.0.1", () => { s.end(); ok(true); }); s.on("error", () => ok(false)); })); + run(bin("JOULE_MOQ_ROOM", "joule-moq-room"), ["--config", path.join(DEV, "room.toml")], "room"); + await waitFor("the room service on the relay", 30000, async () => { const r = await fetch(`http://127.0.0.1:${ROOM_PORT}/health`); return r.ok && (await r.json()).relay === true; }); +} + +let stopping = false; +function stop() { + stopping = true; + for (const c of children) if (c.exitCode == null) c.kill("SIGTERM"); + process.exit(0); +} +process.on("SIGINT", stop); +process.on("SIGTERM", stop); + +if (UP) await up(); + +const ROOT = fs.existsSync(path.join(HERE, "dist", "index.html")) && !process.argv.includes("--source") ? path.join(HERE, "dist") : HERE; +const server = http.createServer((req, res) => { + const u = new URL(req.url, "http://127.0.0.1"); + if (u.pathname.startsWith("/room-api/")) { + const up = http.request({ host: "127.0.0.1", port: ROOM_PORT, method: req.method, path: req.url.slice("/room-api".length), headers: req.headers }, (r) => { + res.writeHead(r.statusCode, r.headers); + r.pipe(res); + }); + up.on("error", () => { res.writeHead(502); res.end(); }); + req.pipe(up); + return; + } + let file = path.normalize(path.join(ROOT, decodeURIComponent(u.pathname))); + if (!file.startsWith(ROOT) || file.includes(`${path.sep}.wai-dev`)) { res.writeHead(403); return res.end(); } + if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html"); + fs.readFile(file, (err, b) => { + if (err) { res.writeHead(404); return res.end("not found"); } + res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" }); + res.end(b); + }); +}); +// The relay's WebSocket binding at /moq, its request passed as it came (the page's proof +// covers the URL it addressed). +server.on("upgrade", (req, socket, head) => { + if (!req.url.startsWith("/moq")) return socket.destroy(); + const up = net.connect(RELAY_PORT, "127.0.0.1", () => { + let raw = `${req.method} ${req.url} HTTP/1.1\r\n`; + for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`; + up.write(raw + "\r\n"); + if (head && head.length) up.write(head); + up.pipe(socket); + socket.pipe(up); + }); + up.on("error", () => socket.destroy()); + socket.on("error", () => up.destroy()); +}); +server.listen(PORT, "127.0.0.1", () => console.log(`the app at http://127.0.0.1:${server.address().port}/${UP ? " (relay and room service up)" : ""}`)); diff --git a/wai/wai-ui/tests/mcp.rs b/wai/wai-ui/tests/mcp.rs new file mode 100644 index 000000000..514261b49 --- /dev/null +++ b/wai/wai-ui/tests/mcp.rs @@ -0,0 +1,91 @@ +//! `wai-ui mcp`, the binary, through the protocol over its stdio: a client initializes, +//! lists the tools, checks a program, and streams one to a Create page connected to the +//! bridge, which reads it chunk by chunk. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use serde_json::{json, Value}; + +#[test] +fn an_agent_drives_create_through_the_mcp_server() { + let mut child = Command::new(env!("CARGO_BIN_EXE_wai-ui")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("wai-ui mcp starts"); + let mut stdin = child.stdin.take().unwrap(); + let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut err = BufReader::new(child.stderr.take().unwrap()); + // The bridge's address and pairing code, as the server says them at start. + let mut first = String::new(); + err.read_line(&mut first).unwrap(); + let port: u16 = first.split("http://127.0.0.1:").nth(1).and_then(|s| s.split(',').next()).and_then(|s| s.parse().ok()).expect(&first); + let code = first.trim().rsplit(' ').next().unwrap().to_string(); + + let mut id = 0; + let mut call = |method: &str, params: Value| -> Value { + id += 1; + writeln!(stdin, "{}", json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).unwrap(); + stdin.flush().unwrap(); + let mut line = String::new(); + out.read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], id); + v + }; + + let init = call("initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } })); + assert_eq!(init["result"]["capabilities"]["tools"], json!({})); + let tools = call("tools/list", json!({})); + let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!(names, vec!["create_guide", "create_check", "create_stream", "create_export"]); + + let checked = call("tools/call", json!({ "name": "create_check", "arguments": { "program": wai_ui::guide::EXAMPLE } })); + assert_eq!(checked["result"]["isError"], false, "{checked}"); + assert_eq!(checked["result"]["structuredContent"]["room"]["kind"], "show"); + + // A Create page connects to the bridge with the code ... + let mut page = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!(page, "GET /create/stream?code={code} HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: https://site.example\r\n\r\n").unwrap(); + page.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + let mut events = BufReader::new(page); + let mut status = String::new(); + events.read_line(&mut status).unwrap(); + assert!(status.starts_with("HTTP/1.1 200"), "{status}"); + std::thread::sleep(Duration::from_millis(200)); + + // ... and the agent streams the program into it. + let streamed = call("tools/call", json!({ "name": "create_stream", "arguments": { "program": wai_ui::guide::EXAMPLE, "chunk": 16, "pace_ms": 0 } })); + assert_eq!(streamed["result"]["isError"], false, "{streamed}"); + assert_eq!(streamed["result"]["structuredContent"]["delivered"], 1); + let mut got = String::new(); + let mut chunks = 0; + loop { + let mut l = String::new(); + if events.read_line(&mut l).unwrap() == 0 { + break; + } + if let Some(d) = l.strip_prefix("data: ") { + let v: Value = serde_json::from_str(d.trim()).unwrap(); + if let Some(t) = v["text"].as_str() { + got.push_str(t); + chunks += 1; + } + } + if l.starts_with("event: end") { + break; + } + } + assert_eq!(got, wai_ui::guide::EXAMPLE); + assert!(chunks > 5, "{chunks} chunks"); + + drop(stdin); + let mut rest = String::new(); + let _ = err.read_to_string(&mut rest); + assert!(child.wait().unwrap().success()); +} diff --git a/wai/wai-ui/web/app.js b/wai/wai-ui/web/app.js new file mode 100644 index 000000000..4fb5936c5 --- /dev/null +++ b/wai/wai-ui/web/app.js @@ -0,0 +1,23 @@ +// An app made with Create, as its exported page runs it: the program (app.oui) parsed by +// wai-ui's WebAssembly build, rendered by the same widgets Create renders, its room on the +// room service behind this page's origin, every action sealed into the page's log. +import { openSession } from "./session.js"; +import { Renderer } from "./renderer.js"; +import { components, context } from "./components.js"; + +const main = document.getElementById("wai-app"); +const session = await openSession(); +const program = await (await fetch(main.dataset.program, { cache: "no-store" })).text(); +session.push(program); +const snap = session.finish(); +const ctx = context({ session, service: main.dataset.service, frame: main.dataset.frame }); +const renderer = new Renderer(main, components, ctx); +renderer.render(snap); +const bad = snap.errors.filter((e) => !e.warning); +if (bad.length) { + const p = document.createElement("pre"); + p.className = "wai-problems"; + p.textContent = bad.map((e) => `app.oui line ${e.line}: ${e.message} (${e.code})`).join("\n"); + main.after(p); +} +window.appTest = { session, ctx, renderer, snapshot: snap }; diff --git a/wai/wai-ui/web/components.js b/wai/wai-ui/web/components.js new file mode 100644 index 000000000..10f309bf0 --- /dev/null +++ b/wai/wai-ui/web/components.js @@ -0,0 +1,460 @@ +// The WAI widgets of wai-ui's registry, as components of the streaming renderer. +// +// A room widget (Room, WatchTogether, Show) runs the room client of joule-moq-web in a frame +// of this page's own origin (room-frame.html): the page that makes its own key, asks the +// room service for a grant, proves the key to the relay, checks every audio and video +// object against its signed receipt before it is shown, keeps a watch room in sync and +// follows a show's polls. The widgets inside the room drive that client through its own +// interface (`roomTest`), from this page: every action a person takes (join, watch, leave, +// send a chat, open a poll, vote, play, pause) goes through the page's session and is +// sealed into its log (session.js). + +const h = (tag, attrs = {}, ...kids) => { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v == null || v === false) continue; + if (k === "class") el.className = v; + else if (k === "text") el.textContent = v; + else if (k.startsWith("on")) el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? "" : String(v)); + } + for (const c of kids) if (c != null) el.append(c); + return el; +}; +const short = (p) => (p ? `${p.slice(0, 8)}…` : ""); +const text = (props, k, d = "") => (typeof props[k] === "string" ? props[k] : d); + +// ---------------------------------------------------------------------------- the room + +// The page's one room: the widget that holds it sets it, the widgets inside it read it. +export class RoomSlot { + constructor() { this.current = null; this.subs = new Set(); } + set(handle) { this.current = handle; for (const f of this.subs) f(handle); } + clear(handle) { if (this.current === handle) this.set(null); } + on(f) { this.subs.add(f); if (this.current) f(this.current); return () => this.subs.delete(f); } +} + +// The room client in its frame, and what the widgets ask of it. +export class RoomHandle { + constructor(kind, frame) { + this.kind = kind; + this.frame = frame; + this.subs = new Set(); + this.last = null; + this.timer = setInterval(() => this.tick(), 600); + } + get t() { + try { return this.frame.contentWindow && this.frame.contentWindow.roomTest; } catch { return null; } + } + // Ready to enter: the client has read its room service and waits for a role. + async ready(ms = 30000) { + const end = Date.now() + ms; + for (;;) { + const t = this.t; + if (t && t.enter && t.state && t.state.phase === "idle") return t; + if (t && t.state && t.state.phase === "refused") throw new Error("the room client refused its endpoints"); + if (Date.now() > end) throw new Error("the room client did not load"); + await new Promise((r) => setTimeout(r, 150)); + } + } + async enter(role) { + const t = await this.ready(); + const phase = await t.enter(role); + this.tick(); + return { phase, principal: this.me() }; + } + async leave() { const t = this.t; if (t && t.leave) await t.leave(); this.tick(); return "left"; } + state() { const t = this.t; return (t && t.state) || null; } + phase() { const s = this.state(); return s ? s.phase : "loading"; } + me() { return (this.last && this.last.me && this.last.me.principal) || null; } + role() { const t = this.t; return t && t.role; } + snapshot() { try { return this.t && this.t.snapshot ? this.t.snapshot() : null; } catch { return null; } } + chats() { + const s = this.state(); + return ((s && s.timeline) || []).filter((e) => e.kind === "chat").map((e) => ({ t: e.t, who: e.who, text: e.payload && e.payload.text, digest: e.digest })); + } + async sendChat(text) { const v = await this.t.sendChat(text); this.tick(); return { t: v && v.t, digest: v && v.digest }; } + co() { try { return this.t && this.t.coState ? this.t.coState() : null; } catch { return null; } } + async openPoll(question, options, seconds) { + const r = await this.t.coRequest("/co/window", { rule: "plurality", options, k: 1, seconds, question }); + if (r.status !== 200) throw new Error(`the room's clock answered ${r.status}: ${JSON.stringify(r.body)}`); + return { status: r.status }; + } + async vote(window, option) { const v = await this.t.coInput({ window, type: "vote", option }); return { window, option, t: v && v.t }; } + async watch(name, arg) { const at = await this.t.watchControl(name, arg); return { name, arg, at }; } + // The watch room's player position, in microseconds. + position() { + try { const m = this.frame.contentDocument.getElementById("cowatch-media"); return Math.round((m.currentTime || 0) * 1e6); } catch { return 0; } + } + // Objects received and checked against their receipts, over every participant and track. + checked() { + const s = this.state(); + const out = { received: 0, verified: 0, refused: 0 }; + for (const sub of Object.values((s && s.subs) || {})) { + for (const m of ["audio", "video", "screen"]) { + const x = sub[m]; + if (!x) continue; + out.received += x.received || 0; + out.verified += x.verified || 0; + out.refused += x.refused || 0; + } + } + return out; + } + present() { + const s = this.state(); + return ((s && s.room && s.room.present) || []).map((p) => p.principal); + } + setView(view) { + try { this.frame.contentDocument.body.dataset.view = view; } catch { /* not loaded yet: the query has it */ } + } + tick() { + this.last = this.snapshot(); + for (const f of this.subs) { try { f(this); } catch { /* a view's own fault */ } } + } + on(f) { this.subs.add(f); f(this); return () => this.subs.delete(f); } + destroy() { + clearInterval(this.timer); + this.subs.clear(); + try { if (this.t && this.t.leave && ["publishing", "watching"].includes(this.phase())) this.t.leave(); } catch { /* gone */ } + } +} + +const KIND = { + Room: { kind: "live", label: "Live room", join: "Join with camera and microphone", watch: "Watch", view: "none" }, + WatchTogether: { kind: "watch", label: "Watching together", join: null, watch: "Watch together", view: "watch" }, + Show: { kind: "show", label: "Show", join: "Join as a host", watch: "Watch", view: "none" }, +}; + +const PHASE = { + loading: "The room client is loading…", + idle: "Not in the room yet.", + entering: "Entering…", + publishing: "In the room, on camera and microphone.", + watching: "Watching.", + full: "The room is full: you can watch.", + left: "You left the room.", + refused: "This page cannot reach the room service.", +}; + +function roomWidget(name) { + const k = KIND[name]; + return (ctx, props) => { + const title = h("h2", { class: "wai-room-title", text: text(props, "title", k.label) }); + const status = h("span", { class: "wai-room-status", role: "status", "aria-live": "polite", text: PHASE.loading }); + const join = k.join ? h("button", { type: "button", class: "wai-btn wai-primary", "data-act": "join", text: k.join }) : null; + const watch = h("button", { type: "button", class: "wai-btn", "data-act": "watch", text: k.watch }); + const leave = h("button", { type: "button", class: "wai-btn", "data-act": "leave", hidden: true, text: "Leave" }); + const frame = h("iframe", { class: "wai-frame", title: `${k.label}: the room`, allow: "camera; microphone; autoplay", loading: "eager" }); + const stage = h("div", { class: "wai-room-stage", "data-view": k.view }, frame); + const slot = h("div", { class: "wai-room-children" }); + const el = h("section", { class: "wai-room", "data-kind": k.kind }, + h("header", { class: "wai-room-head" }, title, h("span", { class: "wai-kind", text: k.label })), + h("div", { class: "wai-room-controls" }, join, watch, leave, status), + stage, slot); + const u = new URL(ctx.config.frame, location.href); + u.searchParams.set("service", ctx.config.service); + u.searchParams.set("view", k.view); + frame.src = u.toString(); + const handle = new RoomHandle(k.kind, frame); + handle.stage = stage; + handle.views = new Set(); + handle.showVideo = (on) => { + if (on) handle.views.add("video"); else handle.views.delete("video"); + const view = k.view === "watch" ? "watch" : handle.views.size ? "stage" : "none"; + stage.dataset.view = view; + handle.setView(view); + }; + // The frame's height follows its content. + const fit = () => { + try { + const d = frame.contentDocument; + if (d && d.body) frame.style.height = `${Math.max(0, d.body.scrollHeight)}px`; + } catch { /* not ours yet */ } + }; + frame.addEventListener("load", () => { + handle.setView(stage.dataset.view); + try { new ResizeObserver(fit).observe(frame.contentDocument.body); } catch { /* no observer */ } + fit(); + }); + const busy = (b) => { for (const x of [join, watch, leave]) if (x) x.disabled = b; }; + const act = (action, role) => async () => { + busy(true); + try { + if (role) await ctx.session.fire(action, { room: k.kind, role }, () => handle.enter(role)); + else await ctx.session.fire(action, { room: k.kind }, () => handle.leave()); + } catch (e) { + status.textContent = `${PHASE[handle.phase()] || ""} ${e.message || e}`; + } + busy(false); + render(); + }; + if (join) join.addEventListener("click", act("room.join", "publisher")); + watch.addEventListener("click", act("room.watch", "viewer")); + leave.addEventListener("click", act("room.leave", null)); + const render = () => { + const p = handle.phase(); + const inRoom = p === "publishing" || p === "watching"; + status.textContent = PHASE[p] || p; + if (join) join.hidden = inRoom || p === "left"; + watch.hidden = inRoom || p === "left"; + leave.hidden = !inRoom; + el.dataset.phase = p; + }; + handle.on(render); + ctx.room.set(handle); + return { + el, + slot, + handle, + update(next) { title.textContent = text(next, "title", k.label); }, + destroy() { ctx.room.clear(handle); handle.destroy(); }, + }; + }; +} + +// The widgets inside a room find it through the page's room slot. +function inRoom(ctx, draw) { + let handle = null; + let off = null; + const offSlot = ctx.room.on((hd) => { + if (off) off(); + handle = hd; + off = hd ? hd.on(() => draw(hd)) : null; + if (!hd) draw(null); + }); + return { get handle() { return handle; }, stop() { if (off) off(); offSlot(); } }; +} + +// ---------------------------------------------------------------------------- the widgets + +function App(ctx, props) { + const title = h("h1", { class: "wai-app-title", text: text(props, "title") }); + const slot = h("div", { class: "wai-app-children" }); + const el = h("div", { class: "wai-app-shell" }, title, slot); + return { el, slot, update(p) { title.textContent = text(p, "title"); } }; +} + +function Text(ctx, props) { + const el = h("p", { class: "wai-text", text: text(props, "text") }); + return { el, update(p) { el.textContent = text(p, "text"); } }; +} + +function Player(ctx, props) { + const el = h("div", { class: "wai-player", text: text(props, "title", "The room's video") }); + const r = inRoom(ctx, () => {}); + const on = ctx.room.on((hd) => { if (hd && hd.showVideo) hd.showVideo(true); }); + return { + el, + update(p) { el.textContent = text(p, "title", "The room's video"); }, + destroy() { on(); const hd = r.handle; if (hd && hd.showVideo) hd.showVideo(false); r.stop(); }, + }; +} + +function Chat(ctx, props) { + const head = h("h3", { class: "wai-h", text: text(props, "title", "Chat") }); + const list = h("ul", { class: "wai-chat-list", "aria-live": "polite" }); + const input = h("input", { class: "wai-input", maxlength: "500", autocomplete: "off", "aria-label": "Your message" }); + const send = h("button", { type: "submit", class: "wai-btn", text: "Send" }); + const note = h("p", { class: "wai-note" }); + const form = h("form", { class: "wai-chat-form" }, input, send); + const el = h("section", { class: "wai-chat" }, head, list, form, note); + let shown = 0; + const r = inRoom(ctx, (hd) => { + const chats = hd ? hd.chats() : []; + if (chats.length !== shown) { + list.replaceChildren(...chats.map((c) => h("li", { "data-digest": c.digest || "" }, h("b", { text: `${c.who}: ` }), c.text || ""))); + shown = chats.length; + list.scrollTop = list.scrollHeight; + } + const p = hd ? hd.phase() : "loading"; + const can = p === "publishing"; + input.disabled = send.disabled = !can; + note.textContent = can ? "Each message is signed by your key and checked by everyone before it shows." : p === "watching" ? "Viewers read the chat; those who join write it." : "Join the room to write."; + }); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const t = input.value.trim(); + const hd = r.handle; + if (!t || !hd) return; + input.value = ""; + try { await ctx.session.fire("chat.send", { text: t }, () => hd.sendChat(t)); } catch (err) { note.textContent = String(err.message || err); } + }); + return { el, update(p) { head.textContent = text(p, "title", "Chat"); }, destroy() { r.stop(); } }; +} + +function Poll(ctx, props) { + let p = props; + const q = h("h3", { class: "wai-h wai-poll-q" }); + const opts = h("div", { class: "wai-poll-options" }); + const open = h("button", { type: "button", class: "wai-btn wai-primary", hidden: true, text: "Open the poll" }); + const result = h("p", { class: "wai-poll-result", role: "status", "aria-live": "polite" }); + const note = h("p", { class: "wai-note" }); + const el = h("section", { class: "wai-poll" }, q, opts, open, result, note); + const options = () => (Array.isArray(p.options) ? p.options : []).map(String); + const question = () => text(p, "question"); + const seconds = () => (typeof p.seconds === "number" ? p.seconds : 30); + let voted = null; + const draw = (hd) => { + q.textContent = question(); + const co = hd ? hd.co() : null; + const labels = options(); + const windows = co ? co.windows.filter((w) => w.question === question()) : []; + const w = windows[windows.length - 1] || null; + const now = co ? co.now : 0; + const isOpen = !!w && w.close > now; + const audience = co && co.audience && co.audience.limit > 0; + open.hidden = !(co && co.lead) || isOpen; + open.disabled = false; + if (opts.dataset.labels !== JSON.stringify(labels) || opts.dataset.open !== String(isOpen && audience)) { + opts.dataset.labels = JSON.stringify(labels); + opts.dataset.open = String(isOpen && audience); + opts.replaceChildren(...labels.map((label, i) => { + const b = h("button", { type: "button", class: "wai-btn wai-option", "data-option": String(i), text: label, disabled: !(isOpen && audience) }); + b.addEventListener("click", async () => { + const ww = w; + if (!ww || !hd) return; + for (const x of opts.querySelectorAll("button")) x.disabled = true; + try { await ctx.session.fire("poll.vote", { question: question(), option: i, label }, () => hd.vote(ww.digest, i)); voted = i; } catch (e) { note.textContent = String(e.message || e); } + }); + return b; + })); + } + if (w && w.selection) { + const pick = w.selection.picks[0]; + const credit = w.selection.credits[0]; + result.textContent = pick && pick.option != null ? `The room chose “${labels[pick.option] || `option ${pick.option + 1}`}”, with ${credit ? credit.count : 0} vote${credit && credit.count === 1 ? "" : "s"}; the room's clock signed the result.` : "The poll closed with no vote."; + el.dataset.state = "closed"; + } else if (isOpen) { + result.textContent = `Open: closes in ${Math.max(0, Math.round((w.close - now) / 1e6))} s · ${w.inputs} vote${w.inputs === 1 ? "" : "s"} so far${voted != null ? ` · you chose “${labels[voted]}”` : ""}`; + el.dataset.state = "open"; + } else if (w) { + result.textContent = "Closed: the room's clock is counting."; + el.dataset.state = "counting"; + } else { + result.textContent = co ? (co.lead ? "Open it when you are ready." : "The host opens the poll.") : "Join or watch the show to take part."; + el.dataset.state = "waiting"; + } + note.textContent = audience ? "Your vote is signed by your key; the room's clock selects and signs the result." : co && co.lead ? "You lead this show: you open its polls." : ""; + }; + const r = inRoom(ctx, draw); + open.addEventListener("click", async () => { + const hd = r.handle; + if (!hd) return; + open.disabled = true; + try { await ctx.session.fire("poll.open", { question: question(), options: options() }, () => hd.openPoll(question(), options().length, seconds())); } catch (e) { note.textContent = String(e.message || e); } + }); + draw(null); + return { el, update(next) { p = next; draw(r.handle); }, destroy() { r.stop(); } }; +} + +function Participants(ctx, props) { + const head = h("h3", { class: "wai-h", text: text(props, "title", "In the room") }); + const list = h("ul", { class: "wai-people" }); + const el = h("section", { class: "wai-participants" }, head, list); + const r = inRoom(ctx, (hd) => { + const ps = hd ? hd.present() : []; + const me = hd ? hd.me() : null; + const json = JSON.stringify([ps, me]); + if (list.dataset.v === json) return; + list.dataset.v = json; + list.replaceChildren(...(ps.length ? ps.map((p) => h("li", { "data-principal": p, text: p === me ? `you (${short(p)})` : short(p) })) : [h("li", { class: "wai-note", text: "No one yet." })])); + }); + return { el, update(p) { head.textContent = text(p, "title", "In the room"); }, destroy() { r.stop(); } }; +} + +function Receipts(ctx, props) { + const head = h("h3", { class: "wai-h", text: text(props, "title", "Receipts") }); + const verdict = h("p", { class: "wai-verdict", role: "status" }); + const table = h("table", { class: "wai-log" }, h("thead", {}, h("tr", {}, ...["#", "Action", "Verdict", "Energy", "Link"].map((t) => h("th", { text: t }))))); + const body = h("tbody"); + table.append(body); + const room = h("p", { class: "wai-note" }); + const el = h("section", { class: "wai-receipts" }, head, verdict, h("div", { class: "wai-scroll" }, table), room); + const draw = () => { + const log = ctx.session.log(); + const v = ctx.session.verify(); + verdict.textContent = v.verified != null + ? `${v.verified} action${v.verified === 1 ? "" : "s"} sealed by this page's key (${short(ctx.session.actor.replace("did:key:", ""))}), the chain verified link by link.` + : `The chain breaks at link ${v.broken_at}.`; + verdict.dataset.ok = String(v.verified != null); + body.replaceChildren(...log.slice(-12).map((e) => h("tr", { "data-seq": String(e.seq) }, + h("td", { text: String(e.seq) }), h("td", { text: e.action }), h("td", { text: e.verdict }), + h("td", { text: `${e.energy.estimated_uj} µJ (${e.energy.provenance})` }), h("td", { class: "wai-mono", text: e.id.slice(3, 15) })))); + }; + const off = ctx.session.on(draw); + const r = inRoom(ctx, (hd) => { + if (!hd) { room.textContent = ""; return; } + const c = hd.checked(); + room.textContent = `The room: ${c.verified} of ${c.received} objects received checked against their receipts${c.refused ? `, ${c.refused} refused` : ""}.`; + }); + draw(); + return { el, update(p) { head.textContent = text(p, "title", "Receipts"); }, destroy() { off(); r.stop(); } }; +} + +function EnergyMeter(ctx, props) { + const head = h("h3", { class: "wai-h", text: text(props, "title", "Energy") }); + const total = h("p", { class: "wai-energy-total" }); + const list = h("ul", { class: "wai-energy" }); + const note = h("p", { class: "wai-note", text: "A page has no energy meter: each figure is an estimate (the action's time on this page at 20 W), labelled Estimator, never a reading." }); + const el = h("section", { class: "wai-energymeter" }, head, total, list, note); + const draw = () => { + const log = ctx.session.log(); + const by = new Map(); + for (const e of log) if (e.verdict === "allow") by.set(e.action, (by.get(e.action) || 0) + e.energy.estimated_uj); + const sum = ctx.session.totalUj(); + total.textContent = `${(sum / 1e6).toFixed(3)} J over ${log.length} action${log.length === 1 ? "" : "s"} (Estimator)`; + list.replaceChildren(...[...by.entries()].map(([a, uj]) => h("li", { text: `${a}: ${(uj / 1e3).toFixed(1)} mJ` }))); + }; + const off = ctx.session.on(draw); + draw(); + return { el, update(p) { head.textContent = text(p, "title", "Energy"); }, destroy() { off(); } }; +} + +function WatchTogetherControls(ctx) { + // The shared player's controls, each a sealed action: they apply to everyone in the room. + const mk = (label, act) => h("button", { type: "button", class: "wai-btn", "data-act": act, text: label }); + const play = mk("Play", "play"), pause = mk("Pause", "pause"), back = mk("Back 10 s", "back"), fwd = mk("Ahead 10 s", "ahead"); + const el = h("div", { class: "wai-watch-controls", hidden: true }, play, pause, back, fwd); + const r = inRoom(ctx, (hd) => { el.hidden = !hd || hd.phase() !== "watching"; }); + const go = (name, arg) => async () => { + const hd = r.handle; + if (!hd) return; + const a = typeof arg === "function" ? arg(hd) : arg; + try { await ctx.session.fire(`watch.${name}`, { arg: a }, () => hd.watch(name, a)); } catch { /* shown by the room */ } + }; + play.addEventListener("click", go("play", 0)); + pause.addEventListener("click", go("pause", 0)); + back.addEventListener("click", go("seek", (hd) => Math.max(0, hd.position() - 10e6))); + fwd.addEventListener("click", go("seek", (hd) => hd.position() + 10e6)); + return { el, stop: () => r.stop() }; +} + +function WatchTogether(ctx, props, node) { + const c = roomWidget("WatchTogether")(ctx, props, node); + const controls = WatchTogetherControls(ctx); + c.el.insertBefore(controls.el, c.slot); + const destroy = c.destroy; + c.destroy = () => { controls.stop(); destroy(); }; + return c; +} + +export const components = { + App, + Text, + Room: roomWidget("Room"), + WatchTogether, + Show: roomWidget("Show"), + Player, + Chat, + Poll, + Participants, + Receipts, + EnergyMeter, +}; + +// The context the components share: the page's session, its one room, and where the room +// service and the room client's frame are (`service` on this page's origin). +export function context({ session, service, frame }) { + return { session, room: new RoomSlot(), config: { service, frame } }; +} diff --git a/wai/wai-ui/web/create.js b/wai/wai-ui/web/create.js new file mode 100644 index 000000000..6ec3c7efe --- /dev/null +++ b/wai/wai-ui/web/create.js @@ -0,0 +1,203 @@ +// Create: describe a WAI app, watch it build itself from WAI widgets as a model writes it, +// try its room, export it as a starter project. +// +// The page calls no model of its own and holds no key on any server. A program comes from +// the visitor's own model (an endpoint and a key they type, called from this page alone), +// from their coding agent (`wai-ui mcp`, through its bridge on 127.0.0.1), or from a +// recorded stream; the page says which. Each chunk is parsed by wai-ui's WebAssembly build +// (joule-ui's line language), and the snapshot, every widget of it accepted by the WAI +// registry, is rendered at once. +import { openSession, guide } from "./session.js"; +import { Renderer } from "./renderer.js"; +import { components, context } from "./components.js"; +import { ownModel, bridge, replay } from "./model.js"; +import { zip } from "./zip.js"; + +const root = document.getElementById("create"); +const $ = (id) => document.getElementById(id); +const enc = new TextEncoder(); + +const session = await openSession(); +const ctx = context({ session, service: root.dataset.service, frame: root.dataset.frame }); +const renderer = new Renderer($("app"), components, ctx); +const test = (window.createTest = { session, ctx, renderer, snapshots: [], last: null, source: null, done: 0, errors: [] }); + +const SAYS = { + none: "No model is called until you choose one. This page has no model of its own and holds no key.", + own: (u) => `Model: your own, at ${u}, called from this page with the key you typed. The key stays in this tab's memory and goes to that address alone; it is never stored.`, + agent: (u) => `Model: your coding agent, which streams the app to this page through the bridge on your machine (${u}).`, + replay: (n) => `Model: none. This replays a recorded stream (${n}).`, +}; +const says = (t) => { $("model-says").textContent = t; }; + +function sourceNow() { + const r = document.querySelector('input[name="source"]:checked'); + return r ? r.value : "replay"; +} +function showSource() { + const s = sourceNow(); + for (const id of ["own", "agent", "replay"]) $(id).hidden = id !== s; + says(SAYS.none); +} +for (const r of document.querySelectorAll('input[name="source"]')) r.addEventListener("change", showSource); +showSource(); + +// ---------------------------------------------------------------------------- the stream + +let running = 0; +async function build(chunks, label) { + const run = ++running; + session.reset(); + test.snapshots = []; + test.source = label; + test.errors = []; + $("export").disabled = true; + $("download").hidden = true; + $("export-status").textContent = ""; + root.dataset.state = "streaming"; + try { + for await (const c of chunks) { + if (run !== running) return; + show(session.push(c)); + } + if (run !== running) return; + show(session.finish()); + root.dataset.state = "done"; + test.done++; + } catch (e) { + root.dataset.state = "failed"; + test.errors.push(String(e.message || e)); + says(`The stream stopped: ${e.message || e}`); + } +} + +function show(s) { + renderer.render(s); + test.last = s; + test.snapshots.push({ widgets: s.nodes.length, keys: s.nodes.map((n) => n.key), errors: s.errors.map((e) => `${e.line}:${e.code}`), chars: session.program().length, finished: s.finished }); + drawProgram(s); + drawProblems(s); + const ok = s.finished && s.room && !s.errors.some((e) => !e.warning); + $("export").disabled = !ok; +} + +function drawProgram(s) { + const lines = session.program().split("\n"); + const bad = new Map(); + for (const e of s.errors) if (e.line) bad.set(e.line, `${e.code}: ${e.message}`); + const pending = s.statements.filter((x) => !x.complete).map((x) => x.line); + const ol = $("program"); + ol.replaceChildren(...lines.map((l, i) => { + const li = document.createElement("li"); + li.textContent = l || " "; + li.value = i + 1; + if (bad.has(i + 1)) { li.className = "bad"; li.title = bad.get(i + 1); } + else if (pending.includes(i + 1)) li.className = "pending"; + return li; + })); + ol.scrollTop = ol.scrollHeight; +} + +function drawProblems(s) { + const ul = $("problems"); + const shown = s.errors.length ? s.errors : []; + ul.replaceChildren(...shown.map((e) => { + const li = document.createElement("li"); + li.dataset.code = e.code; + li.className = e.warning ? "warn" : "bad"; + li.textContent = `${e.line ? `line ${e.line}` : "the app"}: ${e.message} (${e.code})`; + return li; + })); + if (!shown.length) { + const li = document.createElement("li"); + li.className = "ok"; + li.textContent = s.finished ? "None: every line was accepted." : s.tree ? "None so far." : "Waiting for the first line."; + ul.append(li); + } +} + +// ---------------------------------------------------------------------------- the sources + +let disconnect = null; +$("describe").addEventListener("submit", async (e) => { + e.preventDefault(); + const prompt = $("prompt").value.trim(); + const s = sourceNow(); + if (s === "own") { + const endpoint = $("own-endpoint").value.trim(); + const model = $("own-model").value.trim(); + let u; + try { u = new URL(endpoint); } catch { return says("Give your model's address: the full URL of its chat/completions route."); } + says(SAYS.own(u.host)); + // The key is read here, at the call, and handed to the call alone. + await build(ownModel({ endpoint, model, key: $("own-key").value, system: guide(), prompt }), "own"); + } else if (s === "agent") { + connectAgent(); + } else { + const name = $("recording").value; + says(SAYS.replay(name)); + const rec = await (await fetch(`${root.dataset.recorded}${name}.json`, { cache: "no-store" })).json(); + $("prompt").value = rec.prompt; + await build(replay(rec, { speed: Number(root.dataset.speed || 1) }), "replay"); + } +}); + +function connectAgent() { + if (disconnect) disconnect(); + const url = $("agent-bridge").value.trim(); + const code = $("agent-code").value.trim(); + try { + says(SAYS.agent(new URL(url).host)); + disconnect = bridge({ + url, code, + onProgram: (iter) => build(iter, "agent"), + onState: (st) => { $("agent-state").textContent = st === "connected" ? "Connected: ask your agent to build the app." : st === "retrying" ? "Not connected: is `wai-ui mcp` running, and the code right?" : "Closed."; }, + }); + } catch (e) { + says(String(e.message || e)); + } +} + +// ---------------------------------------------------------------------------- the export + +async function sha256(bytes) { + const d = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return Array.from(d, (x) => x.toString(16).padStart(2, "0")).join(""); +} + +$("export").addEventListener("click", async () => { + const status = $("export-status"); + $("export").disabled = true; + try { + const rtUrl = new URL(root.dataset.runtime, location.href); + const rt = await (await fetch(rtUrl, { cache: "no-store" })).json(); + const files = session.export(rt.files); + const runtimeRoot = new URL("../", rtUrl); + const want = new Map(rt.files.map((f) => [f.path, f.sha256])); + const out = []; + for (const f of files) { + if (f.contents.kind === "text") { + out.push({ path: f.path, bytes: enc.encode(f.contents.value) }); + continue; + } + const b = new Uint8Array(await (await fetch(new URL(f.contents.value, runtimeRoot), { cache: "no-store" })).arrayBuffer()); + if ((await sha256(b)) !== want.get(f.contents.value)) throw new Error(`${f.contents.value} is not the file the runtime lists`); + out.push({ path: f.path, bytes: b }); + } + const title = (test.last && test.last.tree && test.last.tree.props && test.last.tree.props.title && test.last.tree.props.title.value) || "wai-app"; + const dir = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wai-app"; + const z = zip(out.map((x) => ({ path: `${dir}/${x.path}`, bytes: x.bytes }))); + const a = $("download"); + if (a.href) URL.revokeObjectURL(a.href); + a.href = URL.createObjectURL(new Blob([z], { type: "application/zip" })); + a.download = `${dir}.zip`; + a.textContent = `${dir}.zip (${Math.round(z.length / 1024)} KB)`; + a.hidden = false; + test.exported = { dir, files: out.map((x) => x.path), bytes: z.length }; + status.textContent = `${out.length} files: the program, its room's configuration, the page and its runtime, a check that is its build, a README and an AGENTS.md. No key is in it.`; + a.click(); + } catch (e) { + status.textContent = `Not exported: ${e.message || e}`; + } + $("export").disabled = false; +}); diff --git a/wai/wai-ui/web/index.html b/wai/wai-ui/web/index.html new file mode 100644 index 000000000..21647c3bf --- /dev/null +++ b/wai/wai-ui/web/index.html @@ -0,0 +1,58 @@ + + + + + +Create + + + + + +

Create

+ +
+
+ + +
+ The model that writes it + + + +
+ + +
+ +
+
+
+

+
+
+ +
+
+ + + diff --git a/wai/wai-ui/web/model.js b/wai/wai-ui/web/model.js new file mode 100644 index 000000000..4bec15058 --- /dev/null +++ b/wai/wai-ui/web/model.js @@ -0,0 +1,113 @@ +// Where a program comes from, each an async iterator of text chunks: +// +// ownModel the visitor's own model: an endpoint that streams the common +// `chat/completions` server-sent-events format (a model server on their own +// machine, or a provider they hold a key for), called from this page with +// their key. The key is held by the caller in memory and sent to that +// endpoint alone; nothing here stores it. +// bridge the visitor's coding agent, through `wai-ui mcp`'s bridge on 127.0.0.1: +// each program the agent streams, read with the pairing code. +// replay a recorded stream: chunks and the pauses between them. +// +// No source calls a model of this site's own: there is none. + +// Lines of a server-sent-events body, as they arrive. +async function* sseLines(body) { + const dec = new TextDecoder(); + let buf = ""; + const reader = body.getReader(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + yield line; + } + } + if (buf) yield buf; +} + +// The text of each `data:` event of a `chat/completions` stream: `choices[0].delta.content`. +export async function* completionChunks(lines) { + for await (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (!data || data === "[DONE]") continue; + let j; + try { j = JSON.parse(data); } catch { continue; } + if (j.error) throw new Error(`the model answered: ${j.error.message || JSON.stringify(j.error)}`); + const c = j.choices && j.choices[0] && j.choices[0].delta && j.choices[0].delta.content; + if (typeof c === "string" && c) yield c; + } +} + +// The visitor's own model. `endpoint` is the full URL of its `chat/completions` route. +export async function* ownModel({ endpoint, model, key, system, prompt, signal }) { + const u = new URL(endpoint); + if (u.protocol !== "https:" && !(u.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(u.hostname))) { + throw new Error("the endpoint must be https, or http on this machine (127.0.0.1)"); + } + const headers = { "content-type": "application/json" }; + if (key) headers.authorization = `Bearer ${key}`; + const r = await fetch(u.toString(), { + method: "POST", + headers, + signal, + credentials: "omit", + referrerPolicy: "no-referrer", + cache: "no-store", + body: JSON.stringify({ model, stream: true, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }), + }); + if (!r.ok || !r.body) throw new Error(`the model's endpoint answered ${r.status}`); + yield* completionChunks(sseLines(r.body)); +} + +// The coding agent's bridge: `onProgram(iterator)` is called for each program the agent +// streams. Returns a function that disconnects. +export function bridge({ url, code, onProgram, onState }) { + const u = new URL(url); + if (!["127.0.0.1", "localhost", "[::1]"].includes(u.hostname)) throw new Error("the bridge is on this machine: http://127.0.0.1:"); + const es = new EventSource(`${u.origin}/create/stream?code=${encodeURIComponent(code)}`); + let queue = null; + es.addEventListener("hello", () => onState && onState("connected")); + es.addEventListener("start", () => { + if (queue) queue.end(); + queue = channel(); + onProgram(queue.iter); + }); + es.addEventListener("chunk", (e) => { if (queue) queue.put(JSON.parse(e.data).text); }); + es.addEventListener("end", () => { if (queue) { queue.end(); queue = null; } }); + es.onerror = () => onState && onState(es.readyState === EventSource.CLOSED ? "closed" : "retrying"); + return () => { es.close(); if (queue) queue.end(); }; +} + +function channel() { + const items = []; + let wake = null; + let done = false; + return { + put(x) { items.push(x); if (wake) { wake(); wake = null; } }, + end() { done = true; if (wake) { wake(); wake = null; } }, + iter: (async function* () { + for (;;) { + if (items.length) { yield items.shift(); continue; } + if (done) return; + await new Promise((r) => { wake = r; }); + } + })(), + }; +} + +// A recorded stream: `{ chunks: [...], pause_ms }` (or `pauses: [...]`), replayed with its +// pauses, `speed` times faster. +export async function* replay(rec, { speed = 1, signal } = {}) { + for (let i = 0; i < rec.chunks.length; i++) { + if (signal && signal.aborted) return; + const ms = rec.pauses ? rec.pauses[i] || 0 : rec.pause_ms || 0; + if (ms > 0) await new Promise((r) => setTimeout(r, ms / speed)); + yield rec.chunks[i]; + } +} diff --git a/wai/wai-ui/web/recorded/room.json b/wai/wai-ui/web/recorded/room.json new file mode 100644 index 000000000..f3e4f8ccc --- /dev/null +++ b/wai/wai-ui/web/recorded/room.json @@ -0,0 +1,41 @@ +{ + "prompt": "A video room for my team with a chat beside it", + "note": "A stream written in the shape a model streams it (chunks of 1 to 9 characters, cut mid-token), not a recording of a model.", + "pause_ms": 25, + "chunks": [ + "root = ", + "App(", + "[room", + ", r", + "eceipts],", + " \"Team", + " r", + "oom\")\nr", + "o", + "om = Roo", + "m([", + "pl", + "ayer", + ", c", + "hat, p", + "eople], ", + "\"", + "S", + "tand-up\")", + "\npl", + "ayer = ", + "Player()", + "\nch", + "at = ", + "Chat(", + ")\n", + "people ", + "= Parti", + "cipants", + "()", + "\nrecei", + "pts =", + " Receipt", + "s()\n" + ] +} diff --git a/wai/wai-ui/web/recorded/show.json b/wai/wai-ui/web/recorded/show.json new file mode 100644 index 000000000..eb8481752 --- /dev/null +++ b/wai/wai-ui/web/recorded/show.json @@ -0,0 +1,85 @@ +{ + "prompt": "A show for my channel, with chat and a live poll", + "note": "A stream written in the shape a model streams it (chunks of 1 to 9 characters, cut mid-token), not a recording of a model.", + "pause_ms": 25, + "chunks": [ + "Here is ", + "your s", + "how.\n`", + "``\nr", + "oot = ", + "App([show", + ", re", + "cei", + "pts", + ", ener", + "gy], \"Fri", + "da", + "y N", + "ight Live", + "\")\n", + "show = S", + "ho", + "w([", + "player, ", + "chat, pol", + "l", + ", people]", + ", \"L", + "ive ", + "from the", + " studio\",", + " 1)\np", + "laye", + "r = Pl", + "ayer(\"On ", + "stage\"", + ")\ncha", + "t = Chat", + "(\"Talk to", + " the ho", + "st", + "s\"", + ")\npo", + "ll = Pol", + "l(", + "\"Whi", + "ch song n", + "e", + "xt", + "?\", [", + "\"The", + " quiet", + " one", + "\", \"Th", + "e loud on", + "e\", \"", + "Somethin", + "g new\"]", + ", 20)\npeo", + "ple", + " = Part", + "i", + "cipants(\"", + "In the ", + "room\")\nr", + "ecei", + "pts ", + "=", + " R", + "ece", + "ipts(\"", + "This", + " p", + "age's rec", + "eipts\")\n", + "en", + "er", + "gy = Ener", + "gyMeter(", + "\"What it ", + "cos", + "t\"", + ")\n```\n" + ] +} diff --git a/wai/wai-ui/web/recorded/watch-refused.json b/wai/wai-ui/web/recorded/watch-refused.json new file mode 100644 index 000000000..7a0d76d2a --- /dev/null +++ b/wai/wai-ui/web/recorded/watch-refused.json @@ -0,0 +1,51 @@ +{ + "prompt": "A watch party for my channel with chat and a live poll", + "note": "A stream written in the shape a model streams it (chunks of 1 to 9 characters, cut mid-token), not a recording of a model.", + "pause_ms": 25, + "chunks": [ + "root = Ap", + "p([part", + "y, note", + "], \"Mo", + "vie ", + "ni", + "ght\")\np", + "a", + "rty", + " =", + " Watch", + "To", + "geth", + "er(\"fil", + "m.mp4\", ", + "[chat, p", + "oll", + ", peop", + "le], \"W", + "atch ", + "par", + "t", + "y\")", + "\nch", + "at =", + " Chat(\"", + "Talk", + "\")", + "\npoll =", + " Poll(\"", + "Again?", + "\", [", + "\"Yes\", \"N", + "o\"", + "])\npeop", + "le = Par", + "ticipants", + "()\nn", + "ote", + " = Tex", + "t(\"Everyo", + "ne w", + "atches in", + " sync.\")\n" + ] +} diff --git a/wai/wai-ui/web/renderer.js b/wai/wai-ui/web/renderer.js new file mode 100644 index 000000000..74667b98a --- /dev/null +++ b/wai/wai-ui/web/renderer.js @@ -0,0 +1,112 @@ +// The streaming renderer: each snapshot of a program (joule-ui's line language, a tree +// every widget of which the registry accepted) reconciled into the page by key. A widget +// keeps its instance, and so its state (a room joined, a chat typed), across snapshots +// while its key and its widget stay the same; only its props are updated. A widget that +// appears is created (and marked, so the page shows it arriving); one that is gone is +// destroyed. +// +// A component is `(ctx, props, node) => ({ el, slot?, update?(props), destroy?() })`: +// `el` its element, `slot` where its children go (it holds nothing else). + +export class Renderer { + constructor(container, components, ctx) { + this.container = container; + this.components = components; + this.ctx = ctx; + this.inst = new Map(); + this.created = 0; + this.renders = 0; + } + + render(snap) { + this.renders++; + const tree = snap && snap.tree; + if (!tree) { + for (const [k, i] of [...this.inst]) this.destroy(k, i); + this.container.replaceChildren(); + return; + } + const nodes = snap.nodes || []; + let idx = 0; + const seen = new Set(); + const walk = (w) => { + const node = nodes[idx++] || { key: `#${idx}`, line: 0, statement: "" }; + const key = node.key; + seen.add(key); + let inst = this.inst.get(key); + if (inst && inst.name !== w.name) { + this.destroy(key, inst); + inst = null; + } + const json = JSON.stringify(w.props || {}); + const props = plainProps(w.props); + if (!inst) { + const make = this.components[w.name] || unknown(w.name); + const comp = make(this.ctx, props, node); + comp.el.classList.add("wai-w", "wai-new"); + comp.el.dataset.key = key; + comp.el.dataset.widget = w.name; + comp.el.dataset.line = String(node.line); + setTimeout(() => comp.el.classList.remove("wai-new"), 1400); + inst = { name: w.name, comp, json }; + this.inst.set(key, inst); + this.created++; + } else if (inst.json !== json) { + if (inst.comp.update) inst.comp.update(props); + inst.json = json; + } + inst.comp.el.dataset.line = String(node.line); + const kids = (w.children || []).map(walk); + if (inst.comp.slot) place(inst.comp.slot, kids.map((k) => k.comp.el)); + return inst; + }; + const root = walk(tree); + for (const [k, i] of [...this.inst]) if (!seen.has(k)) this.destroy(k, i); + if (this.container.children.length !== 1 || this.container.firstElementChild !== root.comp.el) this.container.replaceChildren(root.comp.el); + } + + destroy(key, inst) { + try { if (inst.comp.destroy) inst.comp.destroy(); } catch { /* the component's own fault */ } + inst.comp.el.remove(); + this.inst.delete(key); + } + + // The widgets on the page now, by key. + keys() { return [...this.inst.keys()]; } +} + +// A prop value in joule-ui's wire form (`{type, value}`) as a plain value: text, number, +// boolean, null, an array, an object, or an action `{ action, payload }`. +export function plain(v) { + if (!v || typeof v !== "object") return v; + switch (v.type) { + case "null": return null; + case "list": return (v.value || []).map(plain); + case "object": return Object.fromEntries(Object.entries(v.value || {}).map(([k, x]) => [k, plain(x)])); + case "action": return { action: v.value.id, payload: v.value.payload == null ? null : plain(v.value.payload) }; + default: return v.value; + } +} + +export function plainProps(props) { + return Object.fromEntries(Object.entries(props || {}).map(([k, v]) => [k, plain(v)])); +} + +// Put `els` in `slot` in order, moving only what is out of place (an iframe moved in the +// document reloads, so nothing in place is touched). +export function place(slot, els) { + for (const c of [...slot.children]) if (!els.includes(c)) c.remove(); + for (let i = 0; i < els.length; i++) { + const at = slot.children[i]; + if (at !== els[i]) slot.insertBefore(els[i], at || null); + } +} + +function unknown(name) { + return () => { + const el = document.createElement("div"); + el.className = "wai-unknown"; + el.textContent = `(${name}: no component for this widget on this page)`; + return { el }; + }; +} diff --git a/wai/wai-ui/web/room-frame.html b/wai/wai-ui/web/room-frame.html new file mode 100644 index 000000000..051edcdd2 --- /dev/null +++ b/wai/wai-ui/web/room-frame.html @@ -0,0 +1,197 @@ + + + + + +The room + + + + + + +

MoQ room client

loading
+ + + +
+ + + + + +
+
+
+

The room, composed

+ +
+
+ + + + + +
+ +
+ + + + +
+

Events

+
    +
    + + + + +
    +

    Log

    +
    
    +  
    +
    + + + + + diff --git a/wai/wai-ui/web/runtime.mjs b/wai/wai-ui/web/runtime.mjs new file mode 100644 index 000000000..94841b69f --- /dev/null +++ b/wai/wai-ui/web/runtime.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +// Lay out the Create runtime from this repository's working tree, as the WAI site serves it +// and as an exported project carries it under vendor/: +// +// /create/ wai-ui's page files (the widgets, the renderer, the session, the room +// frame, the styles, Create's own page and its recordings) and +// create/pkg/ (wai-ui's WebAssembly build, which this script does not make) +// /room/ joule-moq-web's room client, each file renamed room-.js +// (app.js as room-client.js) and its imports rewritten to those names and +// to the WebAssembly packages room/moq/pkg/wai_room_moq.js, +// room/moq-e2ee/pkg/wai_room_moq_e2ee.js and room/live/pkg/wai_room_live.js +// (also not made here) +// /create/runtime.json every file an app needs, by its path under , with its +// SHA-256: what an export copies under vendor/ +// +// node wai/wai-ui/web/runtime.mjs +// +// The room client's files are those app.js reaches by its imports. A relative path string +// in any of them that this script does not map fails the layout, so a new import is never +// served unrenamed. Run it after the WebAssembly packages are in place: runtime.json lists +// what is there. +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "../../.."); +const WEB = path.join(REPO, "joule-code/joule-code-rs/crates/joule-moq-web/web"); +const out = process.argv[2]; +if (!out) { + console.error("usage: node runtime.mjs "); + process.exit(2); +} +const OUT = path.resolve(out); + +// wai-ui's own files, and which of them an app needs. +const CREATE_FILES = ["app.js", "renderer.js", "components.js", "session.js", "ui.css", "room-frame.html", "create.js", "model.js", "zip.js", "index.html"]; +const APP_FILES = ["app.js", "renderer.js", "components.js", "session.js", "ui.css", "room-frame.html", "pkg/wai_ui.js", "pkg/wai_ui_bg.wasm"]; +const FRAME_CLIENT = "../../../joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js"; + +// The room client's served name for each file of joule-moq-web/web it is made of. +function servedName(rel) { + const fixed = { + "room/app.js": "room/room-client.js", + "pkg/joule_moq_web.js": "room/moq/pkg/wai_room_moq.js", + "pkg/joule_moq_web_e2ee.js": "room/moq-e2ee/pkg/wai_room_moq_e2ee.js", + "pkg/wai_live_web.js": "room/live/pkg/wai_room_live.js", + }; + if (fixed[rel]) return fixed[rel]; + const m = rel.match(/^(room|moq)\/([a-z0-9-]+)\.js$/); + if (m) return `room/room-${m[2]}.js`; + return null; +} + +const SPEC = /(["'`])(\.{1,2}\/[^"'`\s]*)\1/g; +const sha256 = (b) => crypto.createHash("sha256").update(b).digest("hex"); + +function layRoomClient() { + const todo = ["room/app.js"]; + const done = new Set(); + while (todo.length) { + const rel = todo.pop(); + if (done.has(rel)) continue; + done.add(rel); + const served = servedName(rel); + const text = fs.readFileSync(path.join(WEB, rel), "utf8"); + const rewritten = text.replace(SPEC, (all, q, spec) => { + const target = path.posix.normalize(path.posix.join(path.posix.dirname(rel), spec)); + const to = servedName(target); + if (!to) throw new Error(`runtime: ${rel} names ${spec} (${target}), which the room client's layout does not map`); + if (!target.startsWith("pkg/")) todo.push(target); + let r = path.posix.relative(path.posix.dirname(served), to); + if (!r.startsWith(".")) r = `./${r}`; + return `${q}${r}${q}`; + }); + const header = `// joule-moq-web/web/${rel} (Apache-2.0), sha256 ${sha256(text)}; its imports renamed by wai/wai-ui/web/runtime.mjs.\n`; + fs.mkdirSync(path.join(OUT, path.dirname(served)), { recursive: true }); + fs.writeFileSync(path.join(OUT, served), header + rewritten); + } + return [...done].sort(); +} + +function layCreate() { + const dir = path.join(OUT, "create"); + fs.mkdirSync(dir, { recursive: true }); + for (const f of CREATE_FILES) { + let text = fs.readFileSync(path.join(HERE, f), "utf8"); + if (f === "room-frame.html") { + if (!text.includes(`src="${FRAME_CLIENT}"`)) throw new Error("runtime: room-frame.html does not load the room client by its source path"); + text = text.replace(`src="${FRAME_CLIENT}"`, 'src="../room/room-client.js"'); + } + fs.writeFileSync(path.join(dir, f), text); + } + fs.cpSync(path.join(HERE, "recorded"), path.join(dir, "recorded"), { recursive: true }); +} + +function walk(d, base, acc) { + if (!fs.existsSync(d)) return acc; + for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) { + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p, base, acc); + else acc.push(path.relative(base, p).split(path.sep).join("/")); + } + return acc; +} + +const room = layRoomClient(); +layCreate(); +const files = []; +for (const f of APP_FILES) { + const p = path.join(OUT, "create", f); + if (!fs.existsSync(p)) throw new Error(`runtime: create/${f} is missing (build wai-ui's WebAssembly package into ${path.join(OUT, "create/pkg")} first)`); + files.push(`create/${f}`); +} +for (const f of walk(path.join(OUT, "room"), OUT, [])) files.push(f); +const listed = files.map((f) => ({ path: f, sha256: sha256(fs.readFileSync(path.join(OUT, f))) })); +fs.writeFileSync(path.join(OUT, "create/runtime.json"), JSON.stringify({ note: "The runtime an app made with Create carries under vendor/, by path, with each file's SHA-256.", files: listed }, null, 1) + "\n"); +console.log(`runtime: ${room.length} room client file(s), ${listed.length} runtime file(s) listed in create/runtime.json`); diff --git a/wai/wai-ui/web/session.js b/wai/wai-ui/web/session.js new file mode 100644 index 000000000..36d307942 --- /dev/null +++ b/wai/wai-ui/web/session.js @@ -0,0 +1,59 @@ +// The page's session: wai-ui's WebAssembly build, the program as it streams, and the log +// every action a person takes on the page is sealed into (joule-ui SPEC §6, §7). +// +// `fire(action, inputs, handler)` is the interaction lifecycle around an asynchronous +// handler: authorized first (a denied action's handler never runs), the handler run and +// timed on the page's clock, then sealed as the next link of the log under the page's own +// key. A page has no energy meter: the figure is the estimator's, from the handler's wall +// time, and the log says so (`Estimator`). +import init, { Session, verifyLog, registry, guide, check } from "./pkg/wai_ui.js"; + +export { verifyLog, registry, guide, check }; + +export async function openSession() { + await init(); + const seed = crypto.getRandomValues(new Uint8Array(32)); + const s = new Session(seed); + seed.fill(0); + return new PageSession(s); +} + +export class PageSession { + constructor(wasm) { + this.w = wasm; + this.listeners = new Set(); + this.actor = wasm.actor(); + } + push(chunk) { return JSON.parse(this.w.push(chunk)); } + finish() { return JSON.parse(this.w.finish()); } + reset() { this.w.reset(); } + program() { return this.w.program(); } + canonical() { return this.w.canonical(); } + log() { return JSON.parse(this.w.log()); } + logRaw() { return this.w.logRaw(); } + verify() { return JSON.parse(this.w.verify()); } + totalUj() { return this.w.totalUj(); } + export(runtime) { return JSON.parse(this.w.export(JSON.stringify(runtime))); } + on(f) { this.listeners.add(f); return () => this.listeners.delete(f); } + note(entry) { for (const f of this.listeners) { try { f(entry); } catch { /* a view's own fault */ } } } + + async fire(action, inputs, handler) { + const b = JSON.parse(this.w.beginAction(action, JSON.stringify(inputs === undefined ? null : inputs))); + if (b.sealed) { + this.note(b.sealed); + throw new Error(`${action} was denied: ${b.sealed.reason}`); + } + const t0 = performance.now(); + let out; + let err = null; + try { out = await handler(); } catch (e) { err = e; } + const wall = performance.now() - t0; + const output = err ? { error: String((err && err.message) || err) } : out === undefined ? null : out; + let text; + try { text = JSON.stringify(output); } catch { text = JSON.stringify(String(output)); } + const entry = JSON.parse(this.w.finishAction(b.pending, text === undefined ? "null" : text, wall)); + this.note(entry); + if (err) throw err; + return out; + } +} diff --git a/wai/wai-ui/web/ui.css b/wai/wai-ui/web/ui.css new file mode 100644 index 000000000..37709657c --- /dev/null +++ b/wai/wai-ui/web/ui.css @@ -0,0 +1,112 @@ +/* The WAI widgets (components.js) and Create (create.js). Light and dark, from the + visitor's preference; every colour a token on the root. */ +:root { + --wai-bg: #f6f7f9; + --wai-panel: #ffffff; + --wai-fg: #15171a; + --wai-muted: #5d6470; + --wai-line: #d9dde3; + --wai-accent: #1f5fd1; + --wai-accent-fg: #ffffff; + --wai-ok: #1f6f43; + --wai-bad: #a3271f; + --wai-warn: #8a4b00; + --wai-new: #fff3c4; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --wai-bg: #111317; + --wai-panel: #1a1d23; + --wai-fg: #e8eaee; + --wai-muted: #9aa2ae; + --wai-line: #2c313a; + --wai-accent: #6f9cff; + --wai-accent-fg: #0b0d10; + --wai-ok: #6fd39b; + --wai-bad: #ff8a80; + --wai-warn: #f0a957; + --wai-new: #3a3420; + } +} +:root[data-theme="dark"] { + --wai-bg: #111317; --wai-panel: #1a1d23; --wai-fg: #e8eaee; --wai-muted: #9aa2ae; --wai-line: #2c313a; + --wai-accent: #6f9cff; --wai-accent-fg: #0b0d10; --wai-ok: #6fd39b; --wai-bad: #ff8a80; --wai-warn: #f0a957; --wai-new: #3a3420; +} + +.wai-app { color: var(--wai-fg); font: 15px/1.5 system-ui, sans-serif; } +body:has(> .wai-app) { margin: 0; padding: 16px; background: var(--wai-bg); } +.wai-app *, .create * { box-sizing: border-box; } +[hidden] { display: none !important; } + +.wai-w { transition: background-color 1.2s ease; border-radius: 8px; } +.wai-new { background-color: var(--wai-new); } + +.wai-app-shell { display: flex; flex-direction: column; gap: 12px; } +.wai-app-title { font-size: 1.4rem; margin: 0; letter-spacing: -0.01em; } +.wai-app-title:empty { display: none; } +.wai-app-children { display: flex; flex-direction: column; gap: 12px; } +.wai-text { margin: 0; color: var(--wai-muted); } + +.wai-room { background: var(--wai-panel); border: 1px solid var(--wai-line); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 10px; min-width: 0; } +.wai-room-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } +.wai-room-title { font-size: 1.1rem; margin: 0; } +.wai-kind { font-size: 12px; color: var(--wai-muted); border: 1px solid var(--wai-line); border-radius: 999px; padding: 1px 8px; } +.wai-room-controls, .wai-watch-controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.wai-room-status { color: var(--wai-muted); font-size: 14px; } +.wai-frame { width: 100%; border: 0; display: block; min-height: 0; } +.wai-room-stage[data-view="none"] .wai-frame { min-height: 0; } +.wai-room-children { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; } +.wai-room-children > .wai-player, .wai-room-children > .wai-text { grid-column: 1 / -1; } + +.wai-btn { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid var(--wai-line); background: var(--wai-panel); color: var(--wai-fg); cursor: pointer; } +.wai-btn:disabled { opacity: 0.5; cursor: default; } +.wai-primary { background: var(--wai-accent); color: var(--wai-accent-fg); border-color: var(--wai-accent); } +.wai-input { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--wai-line); background: var(--wai-bg); color: var(--wai-fg); flex: 1; min-width: 0; } + +.wai-h { font-size: 13px; margin: 0 0 6px; color: var(--wai-muted); font-weight: 600; } +.wai-note { font-size: 12px; color: var(--wai-muted); margin: 6px 0 0; } +.wai-player { font-size: 13px; color: var(--wai-muted); } +.wai-chat, .wai-poll, .wai-participants, .wai-receipts, .wai-energymeter { border: 1px solid var(--wai-line); border-radius: 8px; padding: 10px; min-width: 0; background: var(--wai-panel); } +.wai-chat-list { list-style: none; margin: 0 0 8px; padding: 0; max-height: 200px; overflow: auto; font-size: 14px; } +.wai-chat-list li { padding: 2px 0; overflow-wrap: anywhere; } +.wai-chat-form { display: flex; gap: 6px; } +.wai-poll-options { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0; } +.wai-poll-result { margin: 6px 0 0; font-size: 14px; } +.wai-poll[data-state="closed"] .wai-poll-result { color: var(--wai-ok); } +.wai-people { margin: 0; padding-left: 18px; font-family: ui-monospace, monospace; font-size: 13px; } +.wai-verdict { margin: 0 0 6px; font-size: 13px; } +.wai-verdict[data-ok="true"] { color: var(--wai-ok); } +.wai-verdict[data-ok="false"] { color: var(--wai-bad); } +.wai-scroll { overflow-x: auto; } +.wai-log { border-collapse: collapse; font-size: 12px; width: 100%; } +.wai-log th, .wai-log td { text-align: left; padding: 2px 8px 2px 0; border-bottom: 1px solid var(--wai-line); white-space: nowrap; } +.wai-mono { font-family: ui-monospace, monospace; } +.wai-energy { margin: 0; padding-left: 18px; font-size: 13px; } +.wai-energy-total { margin: 0; font-weight: 600; } +.wai-problems { color: var(--wai-bad); white-space: pre-wrap; } +.wai-unknown { color: var(--wai-warn); font-size: 13px; } + +/* Create */ +.create { color: var(--wai-fg); font: 15px/1.5 system-ui, sans-serif; display: flex; flex-direction: column; gap: 14px; } +.create form { display: flex; flex-direction: column; gap: 10px; background: var(--wai-panel); border: 1px solid var(--wai-line); border-radius: 10px; padding: 12px; } +.create textarea { font: inherit; width: 100%; min-height: 4.5em; padding: 8px; border-radius: 8px; border: 1px solid var(--wai-line); background: var(--wai-bg); color: var(--wai-fg); } +.create fieldset { border: 0; padding: 0; margin: 0; display: flex; gap: 14px; flex-wrap: wrap; } +.create legend { font-size: 13px; color: var(--wai-muted); margin-bottom: 4px; } +.create .source-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; } +.create .source-fields label { display: flex; flex-direction: column; font-size: 13px; color: var(--wai-muted); gap: 2px; } +.create .source-fields input, .create .source-fields select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--wai-line); background: var(--wai-bg); color: var(--wai-fg); } +#model-says { margin: 0; font-size: 14px; color: var(--wai-muted); } +.create-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: 14px; align-items: start; } +@media (max-width: 860px) { .create-grid { grid-template-columns: minmax(0, 1fr); } } +.create aside { background: var(--wai-panel); border: 1px solid var(--wai-line); border-radius: 10px; padding: 12px; min-width: 0; } +.create aside h2 { font-size: 13px; color: var(--wai-muted); margin: 10px 0 6px; font-weight: 600; } +.create aside h2:first-child { margin-top: 0; } +#program { font-family: ui-monospace, monospace; font-size: 12px; margin: 0; padding-left: 3.2em; max-height: 320px; overflow: auto; } +#program li { white-space: pre-wrap; overflow-wrap: anywhere; } +#program li.bad { color: var(--wai-bad); } +#program li.pending { color: var(--wai-muted); } +#problems { margin: 0; padding-left: 18px; font-size: 13px; } +#problems li.bad { color: var(--wai-bad); } +#problems li.warn { color: var(--wai-warn); } +#problems li.ok { color: var(--wai-ok); } +#app:empty::before { content: "The app appears here, line by line, as the model writes it."; color: var(--wai-muted); font-size: 14px; } diff --git a/wai/wai-ui/web/web.test.mjs b/wai/wai-ui/web/web.test.mjs new file mode 100644 index 000000000..a5758875c --- /dev/null +++ b/wai/wai-ui/web/web.test.mjs @@ -0,0 +1,118 @@ +// The page modules that need no browser: the renderer's reconciliation (on a small stand-in +// for the document), the prop values' plain form, the ZIP writer, and the model stream +// readers. +// +// node --test wai/wai-ui/web/web.test.mjs +import test from "node:test"; +import assert from "node:assert/strict"; +import zlib from "node:zlib"; + +// A stand-in for the few DOM operations the renderer makes. +class El { + constructor(tag) { this.tag = tag; this.children = []; this.parent = null; this.dataset = {}; this.classList = new Set(); this.classList.remove = Set.prototype.delete; } + get firstElementChild() { return this.children[0] || null; } + remove() { if (this.parent) { this.parent.children.splice(this.parent.children.indexOf(this), 1); this.parent = null; } } + insertBefore(el, ref) { + el.remove(); + const i = ref ? this.children.indexOf(ref) : this.children.length; + this.children.splice(i < 0 ? this.children.length : i, 0, el); + el.parent = this; + this.moves = (this.moves || 0) + 1; + } + replaceChildren(...els) { for (const c of [...this.children]) c.remove(); for (const e of els) this.insertBefore(e, null); } +} +globalThis.setTimeout = ((orig) => (f, ms) => orig(f, ms))(globalThis.setTimeout); + +const { Renderer, plain, plainProps } = await import("./renderer.js"); +const { zip, unzip, crc32 } = await import("./zip.js"); +const { completionChunks, replay } = await import("./model.js"); + +const T = (v) => ({ type: "text", value: v }); +function comps(log) { + const make = (name, withSlot) => (ctx, props, node) => { + const el = new El(name); + const c = { el, props, update(p) { log.push(`update ${node.key}`); c.props = p; }, destroy() { log.push(`destroy ${node.key}`); } }; + if (withSlot) { c.slot = new El("slot"); el.children.push(c.slot); } + log.push(`create ${node.key}`); + return c; + }; + return { App: make("App", true), Room: make("Room", true), Chat: make("Chat"), Text: make("Text") }; +} +const snap = (tree, keys) => ({ tree, nodes: keys.map((key, i) => ({ key, line: i + 1, statement: key })) }); + +test("a widget keeps its instance across snapshots while its key and widget stay", () => { + const log = []; + const root = new El("root"); + const r = new Renderer(root, comps(log), {}); + const app = (kids, title) => ({ name: "App", props: { title: T(title) }, children: kids }); + r.render(snap(app([], "A"), ["root"])); + const room0 = { name: "Room", props: {}, children: [] }; + r.render(snap(app([room0], "A"), ["root", "room"])); + const roomEl = r.inst.get("room").comp.el; + r.render(snap(app([{ name: "Room", props: {}, children: [{ name: "Chat", props: {} }] }], "A b"), ["root", "room", "chat"])); + assert.equal(r.inst.get("room").comp.el, roomEl, "the room is the same instance"); + assert.deepEqual(log, ["create root", "create room", "update root", "create chat"]); + // The room's element was placed once and never moved again (a frame moved reloads). + assert.equal(r.inst.get("root").comp.slot.moves, 1); + // A widget gone is destroyed; one whose widget changed under its key is made again. + r.render(snap(app([{ name: "Text", props: { text: T("x") } }], "A b"), ["root", "room"])); + assert.deepEqual(log.slice(4), ["destroy room", "create room", "destroy chat"]); + r.render({ tree: null, nodes: [] }); + assert.equal(root.children.length, 0); +}); + +test("children are placed in order, inserting without moving what is in place", () => { + const log = []; + const root = new El("root"); + const r = new Renderer(root, comps(log), {}); + const kid = (n) => ({ name: "Text", props: { text: T(n) } }); + r.render(snap({ name: "App", props: {}, children: [kid("a"), kid("c")] }, ["root", "a", "c"])); + const slot = r.inst.get("root").comp.slot; + const c = r.inst.get("c").comp.el; + slot.moves = 0; + r.render(snap({ name: "App", props: {}, children: [kid("a"), kid("b"), kid("c")] }, ["root", "a", "b", "c"])); + assert.deepEqual(slot.children.map((e) => e.tag), ["Text", "Text", "Text"]); + assert.equal(slot.children[2], c); + assert.equal(slot.moves, 1, "only the new widget was inserted"); +}); + +test("prop values in their plain form", () => { + assert.deepEqual(plainProps({ a: T("x"), n: { type: "number", value: 2 }, b: { type: "bool", value: false }, z: { type: "null" } }), { a: "x", n: 2, b: false, z: null }); + assert.deepEqual(plain({ type: "list", value: [T("a"), { type: "list", value: [] }] }), ["a", []]); + assert.deepEqual(plain({ type: "object", value: { k: T("v") } }), { k: "v" }); + assert.deepEqual(plain({ type: "action", value: { id: "Go", payload: { type: "list", value: [T("a")] } } }), { action: "Go", payload: ["a"] }); +}); + +test("the ZIP writer's archive reads back, and its CRC-32 is zlib's", () => { + const enc = new TextEncoder(); + const files = [{ path: "p/app.oui", bytes: enc.encode("root = App()\n") }, { path: "p/vendor/a.wasm", bytes: new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]) }, { path: "p/é.txt", bytes: new Uint8Array(0) }]; + const z = zip(files); + assert.deepEqual(unzip(z).map((f) => [f.path, [...f.bytes]]), files.map((f) => [f.path, [...f.bytes]])); + for (const f of files) assert.equal(crc32(f.bytes), zlib.crc32(f.bytes)); + assert.deepEqual(zip(files), z, "the same files give the same bytes"); + const bad = z.slice(); + bad[40] ^= 1; // a byte of the first file's contents + assert.throws(() => unzip(bad), /CRC-32/); +}); + +test("a chat/completions stream gives its content chunks", async () => { + const lines = (async function* () { + yield ": keep-alive"; + yield 'data: {"choices":[{"delta":{"role":"assistant"}}]}'; + yield 'data: {"choices":[{"delta":{"content":"root = "}}]}'; + yield ""; + yield 'data: {"choices":[{"delta":{"content":"App()"}}]}'; + yield "data: [DONE]"; + })(); + const got = []; + for await (const c of completionChunks(lines)) got.push(c); + assert.deepEqual(got, ["root = ", "App()"]); + const err = (async function* () { yield 'data: {"error":{"message":"bad key"}}'; })(); + await assert.rejects(async () => { for await (const _ of completionChunks(err)) { /* nothing */ } }, /bad key/); +}); + +test("a recorded stream replays its chunks in order", async () => { + const got = []; + for await (const c of replay({ chunks: ["a", "b", "c"], pause_ms: 1 })) got.push(c); + assert.deepEqual(got, ["a", "b", "c"]); +}); diff --git a/wai/wai-ui/web/zip.js b/wai/wai-ui/web/zip.js new file mode 100644 index 000000000..3b11a5634 --- /dev/null +++ b/wai/wai-ui/web/zip.js @@ -0,0 +1,103 @@ +// A ZIP archive of stored (uncompressed) files, for the export a person downloads: local +// headers, the central directory and its end record (APPNOTE 6.3.10, store method 0), names +// in UTF-8 (general purpose bit 11), every time stamp 1980-01-01 00:00 so that one project +// always gives the same bytes. + +const TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +export function crc32(bytes) { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +const DATE = (0 << 9) | (1 << 5) | 1; // 1980-01-01 +const TIME = 0; + +// `files`: [{ path, bytes: Uint8Array }]. Returns the archive's bytes. +export function zip(files) { + const enc = new TextEncoder(); + const parts = []; + const central = []; + let offset = 0; + for (const f of files) { + const name = enc.encode(f.path); + const crc = crc32(f.bytes); + const local = new DataView(new ArrayBuffer(30)); + local.setUint32(0, 0x04034b50, true); + local.setUint16(4, 20, true); // version needed + local.setUint16(6, 0x0800, true); // UTF-8 names + local.setUint16(8, 0, true); // stored + local.setUint16(10, TIME, true); + local.setUint16(12, DATE, true); + local.setUint32(14, crc, true); + local.setUint32(18, f.bytes.length, true); + local.setUint32(22, f.bytes.length, true); + local.setUint16(26, name.length, true); + local.setUint16(28, 0, true); + parts.push(new Uint8Array(local.buffer), name, f.bytes); + const c = new DataView(new ArrayBuffer(46)); + c.setUint32(0, 0x02014b50, true); + c.setUint16(4, 0x0314, true); // made by: version 2.0, unix + c.setUint16(6, 20, true); + c.setUint16(8, 0x0800, true); + c.setUint16(10, 0, true); + c.setUint16(12, TIME, true); + c.setUint16(14, DATE, true); + c.setUint32(16, crc, true); + c.setUint32(20, f.bytes.length, true); + c.setUint32(24, f.bytes.length, true); + c.setUint16(28, name.length, true); + c.setUint16(30, 0, true); + c.setUint16(32, 0, true); + c.setUint16(34, 0, true); + c.setUint16(36, 0, true); + c.setUint32(38, (0o100644 << 16) >>> 0, true); // a regular file, rw-r--r-- + c.setUint32(42, offset, true); + central.push(new Uint8Array(c.buffer), name); + offset += 30 + name.length + f.bytes.length; + } + const cdSize = central.reduce((n, p) => n + p.length, 0); + const end = new DataView(new ArrayBuffer(22)); + end.setUint32(0, 0x06054b50, true); + end.setUint16(8, files.length, true); + end.setUint16(10, files.length, true); + end.setUint32(12, cdSize, true); + end.setUint32(16, offset, true); + const all = [...parts, ...central, new Uint8Array(end.buffer)]; + const out = new Uint8Array(all.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of all) { + out.set(p, at); + at += p.length; + } + return out; +} + +// Read back an archive this module wrote (stored entries): [{ path, bytes }]. +export function unzip(bytes) { + const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const dec = new TextDecoder(); + const out = []; + let at = 0; + while (at + 4 <= bytes.length && v.getUint32(at, true) === 0x04034b50) { + const size = v.getUint32(at + 18, true); + const n = v.getUint16(at + 26, true); + const x = v.getUint16(at + 28, true); + const path = dec.decode(bytes.subarray(at + 30, at + 30 + n)); + const start = at + 30 + n + x; + const data = bytes.slice(start, start + size); + if (crc32(data) !== v.getUint32(at + 14, true)) throw new Error(`${path}: CRC-32 does not match`); + out.push({ path, bytes: data }); + at = start + size; + } + return out; +} -- 2.54.0 (Apple Git-157) From d4c29408e76ab77a9130255b0aa0292948acdbf3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:38:53 -0400 Subject: [PATCH 03/14] wai-ui: unique served names, room services by kind, CI, and a pending statement's values checked once it is whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime.mjs gives every runtime file a unique served name (wai-ui-, wai-ui-room-.js, wai_ui_room_moq, wai_ui_room_live): the WAI site fingerprints assets keyed by basename, and an export's vendor/ carries the same layout; - a room widget takes the room service for its kind (data-service-live, -watch, -show) when the page names one; - WAI's value rules (a poll's options and seconds, a show's audience, a watch room's source) wait for a pending statement to complete, as the line language's own errors do: a poll's `seconds` reads 2 before 20, and the poll no longer leaves the page for that chunk (found by the end-to-end run; a unit test now streams every recording a character at a time and requires that no widget, once shown, goes); - .github/workflows/wai-ui.yml: tests, clippy and the wasm32 surface, the line language's verifier and its mutations, the page modules (linux); Create in the browser (macos); - the e2e drives a WAI site build with --site; - wai-ui cites joule-ui's spec by its file (joule-ui/SPEC.md §N). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-ui.yml | 150 ++++++++++++++++++ wai/wai-ui/e2e/create.e2e.mjs | 27 +++- wai/wai-ui/e2e/run.sh | 4 +- wai/wai-ui/src/lib.rs | 37 ++++- wai/wai-ui/src/plan.rs | 28 +++- wai/wai-ui/src/web.rs | 10 +- wai/wai-ui/templates/index.html | 6 +- wai/wai-ui/web/components.js | 9 +- wai/wai-ui/web/create.js | 7 +- .../recorded/{room.json => wai-ui-room.json} | 0 .../recorded/{show.json => wai-ui-show.json} | 0 ...refused.json => wai-ui-watch-refused.json} | 0 wai/wai-ui/web/runtime.mjs | 79 ++++++--- 13 files changed, 296 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/wai-ui.yml rename wai/wai-ui/web/recorded/{room.json => wai-ui-room.json} (100%) rename wai/wai-ui/web/recorded/{show.json => wai-ui-show.json} (100%) rename wai/wai-ui/web/recorded/{watch-refused.json => wai-ui-watch-refused.json} (100%) diff --git a/.github/workflows/wai-ui.yml b/.github/workflows/wai-ui.yml new file mode 100644 index 000000000..14edf5409 --- /dev/null +++ b/.github/workflows/wai-ui.yml @@ -0,0 +1,150 @@ +name: wai-ui and Create + +# WAI's widgets for joule-ui and Create (wai/wai-ui), and the line language they take a +# program in (joule-ui SPEC §11): +# +# unit wai-ui's tests (its registry, its rules, the guide, the export writer, the MCP +# server, and the binary driven over stdio by a client), clippy with warnings as +# errors, its WebAssembly surface checked for wasm32; the line language's +# independent verifier over the conformance pack, and its mutations refused; the +# page modules' unit tests (the renderer, the ZIP writer, the model streams); +# e2e Create in the host's browser (wai/wai-ui/e2e/run.sh): a recorded stream renders +# as it arrives, the room joins a real room service, a chat and a poll go through +# it, the page's sealed log verifies, the export builds and runs against the same +# room, the refused lines never render, and the export's own development setup +# brings its relay and room service up. +# +# The line language's own tests and its pack test run in standards-build (joule-ui-rs). +# +# Push filters match the push's delta, so every path the jobs read is listed. + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + paths: + - 'wai/wai-ui/**' + - 'joule-ui/**' + - 'wai/wai-live-web/**' + - 'wai/wai-rs/**' + - 'joule-code/joule-code-rs/crates/joule-moq-web/**' + - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/joule-moq-room/**' + - 'joule-code/joule-code-rs/crates/jwp/**' + - 'joule-code/joule-code-rs/Cargo.toml' + - 'joule-code/joule-code-rs/Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/wai-ui.yml' + pull_request: + paths: + - 'wai/wai-ui/**' + - 'joule-ui/**' + - 'wai/wai-live-web/**' + - 'wai/wai-rs/**' + - 'joule-code/joule-code-rs/crates/joule-moq-web/**' + - 'joule-code/joule-code-rs/crates/joule-moq/**' + - 'joule-code/joule-code-rs/crates/joule-moq-relay/**' + - 'joule-code/joule-code-rs/crates/joule-moq-room/**' + - 'joule-code/joule-code-rs/crates/jwp/**' + - 'joule-code/joule-code-rs/Cargo.toml' + - 'joule-code/joule-code-rs/Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/wai-ui.yml' + +jobs: + # Forgejo matches `paths:` against the push delta, so a rebase fires this workflow + # whatever the pull request changes. This job reads the pull request's own diff against + # the paths above (scripts/pr-touches-paths.py, which fails open). + relevant: + name: wai-ui · does this change touch it + runs-on: linux + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ui.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches wai-ui: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + # Nothing here needs macOS: it runs on the linux runner, leaving the macOS host to the + # browser job. + unit: + name: wai-ui · tests, clippy, the line language's verifier (linux) + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal --component clippy + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Tests and clippy + shell: bash + working-directory: wai/wai-ui + run: | + set -euo pipefail + rustup target add wasm32-unknown-unknown + nice -n 10 cargo test --locked 2>&1 | tee "$RUNNER_TEMP/test.log" + grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/test.log" || { echo "::error::no test ran"; exit 1; } + nice -n 10 cargo clippy --locked --all-targets -- -D warnings + nice -n 10 cargo clippy --locked --lib --target wasm32-unknown-unknown --no-default-features --features web -- -D warnings + - name: The line language's independent verifier, and its mutations + shell: bash + run: | + set -euo pipefail + python3 -I joule-ui/conformance/lang_verify.py + python3 -I joule-ui/conformance/lang_verify.py --mutations + - name: The page modules + shell: bash + run: node --test wai/wai-ui/web/web.test.mjs + + e2e: + name: wai-ui · Create, browser end-to-end (macos) + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: macos + timeout-minutes: 90 + steps: + - uses: actions/checkout@v5 + # launchd's PATH omits cargo on the macOS host runner. + - name: Put cargo on PATH + shell: bash + run: echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Pinned to the wasm-bindgen the crates pin: the CLI and the crate must match. + - name: wasm32 target and wasm-bindgen CLI 0.2.127 + shell: bash + run: | + set -euo pipefail + rustup target add wasm32-unknown-unknown + cargo install --locked wasm-bindgen-cli --version 0.2.127 + - name: Create in the browser + shell: bash + env: + BROWSER_BIN: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome # allow-name: the macOS runner's browser executable, a file path + CREATE_E2E_OUT: ${{ runner.temp }}/wai-create + run: | + set -euo pipefail + test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; } + mg=/Volumes/macos_4TB_external_1/wai-build/bin/memguard + if [ -x "$mg" ]; then export MEMGUARD="$mg"; fi + nice -n 10 bash wai/wai-ui/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/create-e2e.log" + grep -qE '^create e2e: [1-9][0-9]* passed, 0 failed$' "$RUNNER_TEMP/create-e2e.log" \ + || { echo "::error::the Create end-to-end run did not pass"; exit 1; } diff --git a/wai/wai-ui/e2e/create.e2e.mjs b/wai/wai-ui/e2e/create.e2e.mjs index 1db9ff473..9e8213c6e 100644 --- a/wai/wai-ui/e2e/create.e2e.mjs +++ b/wai/wai-ui/e2e/create.e2e.mjs @@ -3,7 +3,10 @@ // answered, and the exported project built and run. // // node create.e2e.mjs --rt --relay --room -// --ui --out [--browser ] +// --ui --out [--browser ] [--site ] +// +// With --site, the WAI site's build is served instead of the runtime's directory, and its +// /create/ page is driven: the page as the site serves it, with its runtime under /wai-ui/. // // Scenarios, in order: // create the `show` recording replayed into Create: widgets appear while the stream is @@ -35,7 +38,14 @@ const { serveSameOrigin, browserArgs, memguardedBrowser, killStrays, until, slee const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), [])); const need = (k) => { if (!args[k]) { console.error(`create e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); }; -const RT = need("rt"), RELAY = need("relay"), ROOM = need("room"), UI = need("ui"); +const RELAY = need("relay"), ROOM = need("room"), UI = need("ui"); +// The runtime's directory (e2e/run.sh lays it out), served as the root with Create's +// development page at /create/index.html; or, with --site, a WAI site build, whose /create/ +// page loads the runtime from /wai-ui/. +const SITE = args.site ? path.resolve(args.site) : null; +const RT = SITE ? path.join(SITE, "wai-ui") : need("rt"); +const ROOT = SITE || RT; +const CREATE = SITE ? "/create/" : "/create/index.html"; const OUT = need("out"); const ONLY = (args.only || "create,built,refused,up").split(","); fs.mkdirSync(OUT, { recursive: true }); @@ -111,7 +121,7 @@ async function page(url, name) { } const roomOf = (p) => p.evaluate(() => { const r = (window.createTest || window.appTest).ctx.room.current; return r ? { phase: r.phase(), me: r.me(), co: r.co(), chats: r.chats(), checked: r.checked(), present: r.present() } : null; }); -const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `${n}.json`), "utf8")); +const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `wai-ui-${n}.json`), "utf8")); const SHOW = recording("show"); const SHOW_TEXT = SHOW.chunks.join(""); @@ -127,8 +137,8 @@ async function scenarioCreate() { execFileSync(UI, ["export", path.join(dir, "show.oui"), cli], { stdio: ["ignore", "pipe", "pipe"] }); const svc = await services(cli, dir); check("create: the exported room.toml configures a room service that joins its relay", true, `room service :${svc.roomPort}, relay :${svc.relayPort}, show=${svc.show}`); - const srv = await serveSameOrigin(RT, { service: svc.url, relayPort: svc.relayPort }); - const p = await page(`${srv.url}/create/index.html`, "create-host"); + const srv = await serveSameOrigin(ROOT, { service: svc.url, relayPort: svc.relayPort }); + const p = await page(`${srv.url}${CREATE}`, "create-host"); await until("Create loads its session", STEP, () => p.evaluate(() => !!window.createTest)); // Watch the page while the stream arrives: when each widget appears, and how much of the // program had arrived then. @@ -187,7 +197,8 @@ async function scenarioCreate() { const cut = await p.evaluate(async () => { const raw = JSON.parse(window.createTest.session.logRaw()); raw.splice(0, 1); - return (await import("./session.js")).verifyLog(JSON.stringify(raw)); + const at = new URL("./wai-ui-session.js", new URL(document.getElementById("create").dataset.runtime, location.href)); + return (await import(at.href)).verifyLog(JSON.stringify(raw)); }); check("create: the log with its first link cut out does not verify", JSON.parse(cut).broken_at === 0, cut); @@ -249,8 +260,8 @@ async function scenarioBuilt() { } async function scenarioRefused() { - const srv = shared ? shared.srv : await serveSameOrigin(RT, { service: "http://127.0.0.1:9", relayPort: 9 }); - const p = await page(`${srv.url}/create/index.html`, "create-refused"); + const srv = shared ? shared.srv : await serveSameOrigin(ROOT, { service: "http://127.0.0.1:9", relayPort: 9 }); + const p = await page(`${srv.url}${CREATE}`, "create-refused"); await until("Create loads", STEP, () => p.evaluate(() => !!window.createTest)); await p.evaluate(() => { window.__ever = new Set(); diff --git a/wai/wai-ui/e2e/run.sh b/wai/wai-ui/e2e/run.sh index 96b8a911f..5e230d499 100755 --- a/wai/wai-ui/e2e/run.sh +++ b/wai/wai-ui/e2e/run.sh @@ -42,9 +42,9 @@ step "wasm: joule-moq-web, wai-live-web, wai-ui" command -v wasm-bindgen >/dev/null || { echo "the wasm-bindgen CLI 0.2.127 is needed"; exit 2; } wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version the crates pin"; exit 2; } (cd "$JC" && cargo build $LOCKED --release -p joule-moq-web --lib --target wasm32-unknown-unknown) -wasm-bindgen --target web --no-typescript --out-dir "$RT/room/moq/pkg" --out-name wai_room_moq "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm" +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/moq/pkg" --out-name wai_ui_room_moq "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm" (cd "$WLW" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown) -wasm-bindgen --target web --no-typescript --out-dir "$RT/room/live/pkg" --out-name wai_room_live "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm" +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/live/pkg" --out-name wai_ui_room_live "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm" (cd "$UI" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown --no-default-features --features web) wasm-bindgen --target web --no-typescript --out-dir "$RT/create/pkg" --out-name wai_ui "$TARGET/wasm32-unknown-unknown/release/wai_ui.wasm" ls -l "$RT"/create/pkg/*.wasm "$RT"/room/*/pkg/*.wasm diff --git a/wai/wai-ui/src/lib.rs b/wai/wai-ui/src/lib.rs index ab865e91f..5bed70ae4 100644 --- a/wai/wai-ui/src/lib.rs +++ b/wai/wai-ui/src/lib.rs @@ -6,7 +6,7 @@ //! keeps. //! - [`guide`]: what a model is told before it writes an app, generated from the //! registry. -//! - [`check`] and [`Create`]: a program (joule-ui's line language, SPEC §11) as +//! - [`check`] and [`Create`]: a program (joule-ui's line language, joule-ui/SPEC.md §11) as //! a snapshot the page renders, whole or as it streams, with WAI's own rules //! ([`plan`]) applied: one room, a poll's options, a show's audience, a watch //! room's source; and the room the app needs. @@ -63,7 +63,7 @@ pub struct Checked { pub room: Option, } -/// A snapshot with WAI's rules applied (SPEC §11.6 for the snapshot itself): a +/// A snapshot with WAI's rules applied (joule-ui/SPEC.md §11.6 for the snapshot itself): a /// widget that breaks one is dropped from the tree, its error withheld while its /// statement is pending, like the line language's own. pub fn checked(s: Snapshot) -> Checked { @@ -82,10 +82,11 @@ pub fn checked(s: Snapshot) -> Checked { .collect(); let (tree, nodes, room) = match &s.tree { Some(t) => { - let (t2, n2, room, app) = plan::apply(t, &s.nodes); + // A statement is pending while its binding is the last, incomplete one. + let pending = |name: &str| !s.finished && s.statements.iter().rev().find(|x| x.name == name).is_some_and(|x| !x.complete); + let (t2, n2, room, app) = plan::apply_streaming(t, &s.nodes, &pending); for a in app { - let pending = !s.finished && s.statements.iter().rev().find(|x| x.name == a.statement).is_some_and(|x| !x.complete); - if !pending { + if !pending(&a.statement) { errors.push(ErrorOut { line: a.line, statement: a.statement, code: a.code, component: a.component, prop: a.prop, message: a.message, warning: false }); } } @@ -182,6 +183,32 @@ mod tests { assert_eq!(c.finish(), check(text)); } + /// The recorded streams, cut one character at a time: no widget that has + /// appeared ever goes, and every key appears once (the renderer keeps its + /// instance), until the stream ends as a check of the whole text has it. + #[test] + fn widgets_only_ever_arrive_while_a_recording_streams() { + for rec in [include_str!("../web/recorded/wai-ui-show.json"), include_str!("../web/recorded/wai-ui-room.json"), include_str!("../web/recorded/wai-ui-watch-refused.json")] { + let v: serde_json::Value = serde_json::from_str(rec).unwrap(); + let text: String = v["chunks"].as_array().unwrap().iter().map(|c| c.as_str().unwrap()).collect(); + let mut c = Create::new(); + let mut seen: Vec = Vec::new(); + for ch in text.chars() { + let s = c.push(&ch.to_string()); + let keys: Vec = s.nodes.iter().map(|n| n.key.clone()).collect(); + for k in &seen { + assert!(keys.contains(k), "{k} went away after {:?}", c.text()); + } + for k in keys { + if !seen.contains(&k) { + seen.push(k); + } + } + } + assert_eq!(c.finish(), check(&text)); + } + } + #[test] fn a_line_against_the_rooms_rules_is_refused_with_its_line() { let c = check("root = App([w])\nw = WatchTogether(\"film.mp4\", [chat])\nchat = Chat()\n"); diff --git a/wai/wai-ui/src/plan.rs b/wai/wai-ui/src/plan.rs index 89c524a69..9d4d34641 100644 --- a/wai/wai-ui/src/plan.rs +++ b/wai/wai-ui/src/plan.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::registry::ROOMS; -/// An app's own error: the shape of a line-language error (SPEC §11.8), its +/// An app's own error: the shape of a line-language error (joule-ui/SPEC.md §11.8), its /// code one of WAI's (`one-room`, `poll-options`, `poll-seconds`, /// `audience-range`, `watch-source`, `no-room`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -78,12 +78,17 @@ pub fn source_ok(s: &str) -> bool { /// Check the app's own rules on a validated tree and its nodes (pre-order): /// the tree with every widget that breaks one dropped, its nodes, the room the /// app needs (none when it holds none), and the errors. -pub fn apply(tree: &Widget, nodes: &[Node]) -> (Option, Vec, Option, Vec) { +/// +/// `pending(statement)`: whether a statement is still arriving. A pending +/// statement's values are checked once it completes (a poll's `seconds` reads +/// `2` before it reads `20`, its options arrive one by one), so its widget is +/// kept meanwhile; a second room is never rendered, pending or not. +pub fn apply_streaming(tree: &Widget, nodes: &[Node], pending: &dyn Fn(&str) -> bool) -> (Option, Vec, Option, Vec) { let mut errors = Vec::new(); let mut index = 0usize; let mut rooms = 0usize; let mut out_nodes = Vec::new(); - let Some(out) = prune(tree, nodes, &mut index, &mut out_nodes, &mut errors, &mut rooms) else { + let Some(out) = prune(tree, nodes, &mut index, &mut out_nodes, &mut errors, &mut rooms, pending) else { // The root itself broke a rule: nothing renders. return (None, Vec::new(), None, errors); }; @@ -105,6 +110,11 @@ pub fn apply(tree: &Widget, nodes: &[Node]) -> (Option, Vec, Optio (Some(out), out_nodes, plan, errors) } +/// [`apply_streaming`] on a whole program: every statement checked. +pub fn apply(tree: &Widget, nodes: &[Node]) -> (Option, Vec, Option, Vec) { + apply_streaming(tree, nodes, &|_| false) +} + fn count(w: &Widget) -> usize { 1 + w.children.iter().map(count).sum::() } @@ -113,11 +123,14 @@ fn err(errors: &mut Vec, n: &Node, code: &str, component: &str, prop: errors.push(AppError { line: n.line, statement: n.statement.clone(), code: code.into(), component: Some(component.into()), prop: prop.map(String::from), message }); } -fn prune(w: &Widget, nodes: &[Node], index: &mut usize, out_nodes: &mut Vec, errors: &mut Vec, rooms: &mut usize) -> Option { +#[allow(clippy::too_many_arguments)] +fn prune(w: &Widget, nodes: &[Node], index: &mut usize, out_nodes: &mut Vec, errors: &mut Vec, rooms: &mut usize, pending: &dyn Fn(&str) -> bool) -> Option { let me = *index; let node = nodes[me].clone(); *index += count(w); let mut keep = true; + // A statement still arriving: its values are checked once it is whole. + let later = pending(&node.statement); match w.name.as_str() { n if ROOMS.contains(&n) => { *rooms += 1; @@ -125,11 +138,12 @@ fn prune(w: &Widget, nodes: &[Node], index: &mut usize, out_nodes: &mut Vec { + "Poll" if !later => { let ok = match w.props.get("options") { Some(PropValue::List(items)) => { (2..=32).contains(&items.len()) && items.iter().all(|i| matches!(i, PropValue::Text(s) if !s.is_empty() && s.chars().count() <= 100)) @@ -165,7 +179,7 @@ fn prune(w: &Widget, nodes: &[Node], index: &mut usize, out_nodes: &mut Vec String { verify_result(self.log.entries()) } @@ -161,7 +161,7 @@ impl Session { Ok(serde_json::to_string(&files).expect("files serialise")) } - /// The program in its canonical form (joule-ui SPEC §11.7). + /// The program in its canonical form (joule-ui/SPEC.md §11.7). pub fn canonical(&self) -> Result { let c = self.create.snapshot(); let t = c.tree.ok_or_else(|| JsError::new("nothing renders yet"))?; diff --git a/wai/wai-ui/templates/index.html b/wai/wai-ui/templates/index.html index 841cb1e56..9eab31854 100644 --- a/wai/wai-ui/templates/index.html +++ b/wai/wai-ui/templates/index.html @@ -4,12 +4,12 @@ {{TITLE}} - + -
    - +
    + diff --git a/wai/wai-ui/web/components.js b/wai/wai-ui/web/components.js index 10f309bf0..8ac6e8382 100644 --- a/wai/wai-ui/web/components.js +++ b/wai/wai-ui/web/components.js @@ -153,7 +153,7 @@ function roomWidget(name) { h("div", { class: "wai-room-controls" }, join, watch, leave, status), stage, slot); const u = new URL(ctx.config.frame, location.href); - u.searchParams.set("service", ctx.config.service); + u.searchParams.set("service", (ctx.config.services && ctx.config.services[k.kind]) || ctx.config.service); u.searchParams.set("view", k.view); frame.src = u.toString(); const handle = new RoomHandle(k.kind, frame); @@ -454,7 +454,8 @@ export const components = { }; // The context the components share: the page's session, its one room, and where the room -// service and the room client's frame are (`service` on this page's origin). -export function context({ session, service, frame }) { - return { session, room: new RoomSlot(), config: { service, frame } }; +// service (`service`, or one for each kind of room, `services`) and the room client's frame +// are, each on this page's origin. +export function context({ session, service, services = {}, frame }) { + return { session, room: new RoomSlot(), config: { service, services, frame } }; } diff --git a/wai/wai-ui/web/create.js b/wai/wai-ui/web/create.js index 6ec3c7efe..36a6950af 100644 --- a/wai/wai-ui/web/create.js +++ b/wai/wai-ui/web/create.js @@ -18,7 +18,10 @@ const $ = (id) => document.getElementById(id); const enc = new TextEncoder(); const session = await openSession(); -const ctx = context({ session, service: root.dataset.service, frame: root.dataset.frame }); +// The room service each kind of room is tried on (`data-service-live`, `-watch`, `-show`), +// else `data-service`; every one on this page's own origin. +const services = { live: root.dataset.serviceLive, watch: root.dataset.serviceWatch, show: root.dataset.serviceShow }; +const ctx = context({ session, service: root.dataset.service, services, frame: root.dataset.frame }); const renderer = new Renderer($("app"), components, ctx); const test = (window.createTest = { session, ctx, renderer, snapshots: [], last: null, source: null, done: 0, errors: [] }); @@ -136,7 +139,7 @@ $("describe").addEventListener("submit", async (e) => { } else { const name = $("recording").value; says(SAYS.replay(name)); - const rec = await (await fetch(`${root.dataset.recorded}${name}.json`, { cache: "no-store" })).json(); + const rec = await (await fetch(`${root.dataset.recorded}wai-ui-${name}.json`, { cache: "no-store" })).json(); $("prompt").value = rec.prompt; await build(replay(rec, { speed: Number(root.dataset.speed || 1) }), "replay"); } diff --git a/wai/wai-ui/web/recorded/room.json b/wai/wai-ui/web/recorded/wai-ui-room.json similarity index 100% rename from wai/wai-ui/web/recorded/room.json rename to wai/wai-ui/web/recorded/wai-ui-room.json diff --git a/wai/wai-ui/web/recorded/show.json b/wai/wai-ui/web/recorded/wai-ui-show.json similarity index 100% rename from wai/wai-ui/web/recorded/show.json rename to wai/wai-ui/web/recorded/wai-ui-show.json diff --git a/wai/wai-ui/web/recorded/watch-refused.json b/wai/wai-ui/web/recorded/wai-ui-watch-refused.json similarity index 100% rename from wai/wai-ui/web/recorded/watch-refused.json rename to wai/wai-ui/web/recorded/wai-ui-watch-refused.json diff --git a/wai/wai-ui/web/runtime.mjs b/wai/wai-ui/web/runtime.mjs index 94841b69f..fe55db163 100644 --- a/wai/wai-ui/web/runtime.mjs +++ b/wai/wai-ui/web/runtime.mjs @@ -2,16 +2,20 @@ // Lay out the Create runtime from this repository's working tree, as the WAI site serves it // and as an exported project carries it under vendor/: // -// /create/ wai-ui's page files (the widgets, the renderer, the session, the room -// frame, the styles, Create's own page and its recordings) and -// create/pkg/ (wai-ui's WebAssembly build, which this script does not make) -// /room/ joule-moq-web's room client, each file renamed room-.js -// (app.js as room-client.js) and its imports rewritten to those names and -// to the WebAssembly packages room/moq/pkg/wai_room_moq.js, -// room/moq-e2ee/pkg/wai_room_moq_e2ee.js and room/live/pkg/wai_room_live.js +// /create/ wai-ui's page files, each named wai-ui- (the widgets, the +// renderer, the session, the room frame, the styles, Create's own page +// and its recordings), and create/pkg/ (wai-ui's WebAssembly build, which +// this script does not make) +// /room/ joule-moq-web's room client, each file named wai-ui-room-.js +// (app.js as wai-ui-room-client.js), its imports rewritten to those names +// and to the WebAssembly packages room/moq/pkg/wai_ui_room_moq.js, +// room/moq-e2ee/pkg/wai_ui_room_moq_e2ee.js and room/live/pkg/wai_ui_room_live.js // (also not made here) -// /create/runtime.json every file an app needs, by its path under , with its -// SHA-256: what an export copies under vendor/ +// /create/wai-ui-runtime.json every file an app needs, by its path under , +// with its SHA-256: what an export copies under vendor/ +// +// Every served name is unique across the WAI site, whose deploy fingerprints assets keyed +// by basename. // // node wai/wai-ui/web/runtime.mjs // @@ -34,22 +38,36 @@ if (!out) { } const OUT = path.resolve(out); -// wai-ui's own files, and which of them an app needs. -const CREATE_FILES = ["app.js", "renderer.js", "components.js", "session.js", "ui.css", "room-frame.html", "create.js", "model.js", "zip.js", "index.html"]; -const APP_FILES = ["app.js", "renderer.js", "components.js", "session.js", "ui.css", "room-frame.html", "pkg/wai_ui.js", "pkg/wai_ui_bg.wasm"]; +// wai-ui's own files by their served names: unique across the site, which fingerprints its +// assets keyed by basename, and the same in an export's vendor/. Which of them an app needs. +const CREATE_FILES = { + "app.js": "wai-ui-app.js", + "renderer.js": "wai-ui-renderer.js", + "components.js": "wai-ui-components.js", + "session.js": "wai-ui-session.js", + "ui.css": "wai-ui.css", + "room-frame.html": "wai-ui-room-frame.html", + "create.js": "wai-ui-create.js", + "model.js": "wai-ui-model.js", + "zip.js": "wai-ui-zip.js", + "index.html": "index.html", + "runtime.json": "wai-ui-runtime.json", + "pkg/wai_ui.js": "pkg/wai_ui.js", +}; +const APP_FILES = ["wai-ui-app.js", "wai-ui-renderer.js", "wai-ui-components.js", "wai-ui-session.js", "wai-ui.css", "wai-ui-room-frame.html", "pkg/wai_ui.js", "pkg/wai_ui_bg.wasm"]; const FRAME_CLIENT = "../../../joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js"; // The room client's served name for each file of joule-moq-web/web it is made of. function servedName(rel) { const fixed = { - "room/app.js": "room/room-client.js", - "pkg/joule_moq_web.js": "room/moq/pkg/wai_room_moq.js", - "pkg/joule_moq_web_e2ee.js": "room/moq-e2ee/pkg/wai_room_moq_e2ee.js", - "pkg/wai_live_web.js": "room/live/pkg/wai_room_live.js", + "room/app.js": "room/wai-ui-room-client.js", + "pkg/joule_moq_web.js": "room/moq/pkg/wai_ui_room_moq.js", + "pkg/joule_moq_web_e2ee.js": "room/moq-e2ee/pkg/wai_ui_room_moq_e2ee.js", + "pkg/wai_live_web.js": "room/live/pkg/wai_ui_room_live.js", }; if (fixed[rel]) return fixed[rel]; const m = rel.match(/^(room|moq)\/([a-z0-9-]+)\.js$/); - if (m) return `room/room-${m[2]}.js`; + if (m) return `room/wai-ui-room-${m[2]}.js`; return null; } @@ -81,18 +99,29 @@ function layRoomClient() { return [...done].sort(); } +// wai-ui's files under their served names, each relative path in them (an import, a +// stylesheet, a frame, a data attribute) rewritten to those names; one the table does not +// map fails the layout. The room frame loads the room client laid out beside it. function layCreate() { const dir = path.join(OUT, "create"); fs.mkdirSync(dir, { recursive: true }); - for (const f of CREATE_FILES) { - let text = fs.readFileSync(path.join(HERE, f), "utf8"); - if (f === "room-frame.html") { + for (const [src, served] of Object.entries(CREATE_FILES)) { + if (src === "runtime.json" || src.startsWith("pkg/")) continue; + let text = fs.readFileSync(path.join(HERE, src), "utf8"); + if (src === "room-frame.html") { if (!text.includes(`src="${FRAME_CLIENT}"`)) throw new Error("runtime: room-frame.html does not load the room client by its source path"); - text = text.replace(`src="${FRAME_CLIENT}"`, 'src="../room/room-client.js"'); + text = text.replace(`src="${FRAME_CLIENT}"`, 'src="../room/wai-ui-room-client.js"'); } - fs.writeFileSync(path.join(dir, f), text); + text = text.replace(/(["'`])\.\/([a-z0-9_./-]+)\1/gi, (all, q, rel) => { + if (rel === "recorded/") return all; + const to = CREATE_FILES[rel]; + if (!to) throw new Error(`runtime: ${src} names ./${rel}, which the layout does not map`); + return `${q}./${to}${q}`; + }); + fs.writeFileSync(path.join(dir, served), text); } - fs.cpSync(path.join(HERE, "recorded"), path.join(dir, "recorded"), { recursive: true }); + fs.mkdirSync(path.join(dir, "recorded"), { recursive: true }); + for (const f of fs.readdirSync(path.join(HERE, "recorded"))) fs.copyFileSync(path.join(HERE, "recorded", f), path.join(dir, "recorded", f)); } function walk(d, base, acc) { @@ -115,5 +144,5 @@ for (const f of APP_FILES) { } for (const f of walk(path.join(OUT, "room"), OUT, [])) files.push(f); const listed = files.map((f) => ({ path: f, sha256: sha256(fs.readFileSync(path.join(OUT, f))) })); -fs.writeFileSync(path.join(OUT, "create/runtime.json"), JSON.stringify({ note: "The runtime an app made with Create carries under vendor/, by path, with each file's SHA-256.", files: listed }, null, 1) + "\n"); -console.log(`runtime: ${room.length} room client file(s), ${listed.length} runtime file(s) listed in create/runtime.json`); +fs.writeFileSync(path.join(OUT, "create/wai-ui-runtime.json"), JSON.stringify({ note: "The runtime an app made with Create carries under vendor/, by path, with each file's SHA-256.", files: listed }, null, 1) + "\n"); +console.log(`runtime: ${room.length} room client file(s), ${listed.length} runtime file(s) listed in create/wai-ui-runtime.json`); -- 2.54.0 (Apple Git-157) From ac13339e682ba1e1829e300cf51740e7c58222a8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:40:15 -0400 Subject: [PATCH 04/14] joule-ui-interaction: a denial from begin_interaction is boxed (clippy result_large_err); wai/CHANGELOG: wai-ui and Create Co-Authored-By: Claude Opus 5.5 --- .../crates/joule-ui-interaction/src/lib.rs | 10 ++++----- wai/CHANGELOG.md | 22 +++++++++++++++++++ wai/wai-ui/src/web.rs | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs index 5180f890f..8548e9e42 100644 --- a/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs +++ b/joule-ui/joule-ui-rs/crates/joule-ui-interaction/src/lib.rs @@ -246,7 +246,7 @@ where { let pending = match begin_interaction(surface, action, actor, inputs, meter, authorizer.as_deref_mut()) { Ok(p) => p, - Err(denied) => return denied, + Err(denied) => return *denied, }; let started = std::time::Instant::now(); let output = handler(); @@ -283,7 +283,7 @@ pub fn begin_interaction( inputs: &[u8], meter: &dyn Meter, authorizer: Option<&mut (dyn Authorizer + '_)>, -) -> Result { +) -> Result> { let inputs_id = ContentId::of_bytes(inputs); let grant_id = authorizer.as_ref().and_then(|a| a.grant_id()); @@ -291,7 +291,7 @@ pub fn begin_interaction( if let Some(auth) = authorizer && let Err(reason) = auth.authorize(action) { - return Err(Interaction { + return Err(Box::new(Interaction { surface: surface.clone(), action: action.to_string(), inputs: inputs_id, @@ -301,7 +301,7 @@ pub fn begin_interaction( budget_remaining_uj: Some(0), grant_id, actor: actor.to_string(), - }); + })); } Ok(Pending { @@ -844,7 +844,7 @@ mod tests { let denied = begin_interaction(&surface(), "apply", "did:key:zA", b"{}", &NoMeter, Some(&mut b)).unwrap_err(); let mut b2 = budget(0, "grant-xyz"); let same = run_interaction(&surface(), "apply", "did:key:zA", b"{}", &NoMeter, Some(&mut b2), || b"x".to_vec()); - assert_eq!(denied, same); + assert_eq!(*denied, same); assert_eq!(denied.outputs, ContentId::genesis()); assert!(b.charged.is_empty()); } diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 424dd6d06..990354a5f 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,28 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### `wai-ui` and Create: a WAI app described, built as a model writes it, exported + +- **The widgets** ([`wai-ui`](wai-ui/)): a joule-ui registry of WAI widgets, `App`, `Room`, + `WatchTogether`, `Show`, `Player`, `Chat`, `Poll`, `Participants`, `Receipts`, + `EnergyMeter` and `Text`, each backed by the room client of `joule-moq-web` running in a + frame of the page. Each kind of room accepts only the widgets its room service keeps: a + watch room holds no `Chat` and no `Poll`, and a line that asks for one is refused + `disallowed-child` at its line. An app holds one room; a poll has 2 to 32 options; a show's + audience is 1 to 255; a watch room's source is a file within the app. +- **Create**: a page where a person describes an app and a model writes it in joule-ui's line + language ([joule-ui SPEC](../joule-ui/SPEC.md) §11), which the page parses as it streams and + renders widget by widget, every widget validated against the registry. The page calls no + model of its own and holds no key: the visitor's own model (its address and their key, + called from the page alone), their coding agent (`wai-ui mcp`, an MCP server with a bridge + on 127.0.0.1), or a recorded stream. Every action on the page is sealed into a hash-chained + log the page signs with its own key and verifies (joule-ui-interaction; the figures are + estimates, labelled `Estimator`: a page has no meter). +- **Export**: one writer (`wai_ui::export`) makes the starter project: the program in its + canonical form, its tree, the room service's configuration for its kind of room, the relay's, + the page and its runtime, a check that is the project's build, a development front, a + README and an AGENTS.md. No key is in it. + ### Coding agents: `wai mcp`, `wai setup-agent`, and the room service's API described - **`wai mcp`** (`agent.mcp`; the feature `mcp`, which the quickstart's `wai` holds): WAI's diff --git a/wai/wai-ui/src/web.rs b/wai/wai-ui/src/web.rs index 321bb17ae..424c451d9 100644 --- a/wai/wai-ui/src/web.rs +++ b/wai/wai-ui/src/web.rs @@ -115,7 +115,7 @@ impl Session { json!({ "pending": self.pending.len() - 1 }).to_string() } Err(denied) => { - let e = self.log.record(denied); + let e = self.log.record(*denied); json!({ "sealed": entry_view(e) }).to_string() } } -- 2.54.0 (Apple Git-157) From 6e44a3850ef9c24a690803ce0c3ee39787a69aae Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:41:58 -0400 Subject: [PATCH 05/14] wai-ui e2e: the exported project's build refuses a project without its room configuration, with one not its app's, with a runtime file changed, or with a refused line Co-Authored-By: Claude Opus 5.5 --- wai/wai-ui/e2e/create.e2e.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/wai/wai-ui/e2e/create.e2e.mjs b/wai/wai-ui/e2e/create.e2e.mjs index 9e8213c6e..bdc3d1e48 100644 --- a/wai/wai-ui/e2e/create.e2e.mjs +++ b/wai/wai-ui/e2e/create.e2e.mjs @@ -226,6 +226,22 @@ async function scenarioBuilt() { const { svc, p, project, dir } = shared; const out = execFileSync("node", ["check.mjs"], { cwd: project, stdio: ["ignore", "pipe", "pipe"] }).toString(); check("built: the exported project's build (node check.mjs) passes and writes dist/", fs.existsSync(path.join(project, "dist/index.html")) && fs.existsSync(path.join(project, "dist/vendor/create/pkg/wai_ui_bg.wasm")), out.trim()); + // Negative controls: the build refuses a project that is not whole. + const broken = path.join(dir, "exported-broken"); + for (const [what, mutate, expect] of [ + ["without its room configuration", (d) => fs.rmSync(path.join(d, "room.toml")), /room\.toml is missing/], + ["whose room configuration is not its app's", (d) => fs.writeFileSync(path.join(d, "room.toml"), fs.readFileSync(path.join(d, "room.toml"), "utf8").replace(/^audience_input = \d+$/m, "audience_input = 7")), /audience_input is 7/], + ["with a runtime file changed", (d) => fs.appendFileSync(path.join(d, "vendor/create/wai-ui-components.js"), "\n// changed\n"), /wai-ui-components\.js: its SHA-256/], + ["with a line the registry refuses", (d) => fs.writeFileSync(path.join(d, "app.oui"), fs.readFileSync(path.join(d, "app.oui"), "utf8").replace(/^chat1 = Chat\(.*\)$/m, "chat1 = Hologram()")), /line \d+: unknown-component/], + ]) { + fs.rmSync(broken, { recursive: true, force: true }); + fs.cpSync(project, broken, { recursive: true }); + fs.rmSync(path.join(broken, "dist"), { recursive: true, force: true }); + mutate(broken); + let code = 0, err = ""; + try { execFileSync("node", ["check.mjs"], { cwd: broken, stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { code = e.status; err = String(e.stderr); } + check(`built: the build refuses a project ${what}`, code === 1 && expect.test(err) && !fs.existsSync(path.join(broken, "dist")), err.split("\n").filter(Boolean).slice(0, 2).join(" | ")); + } // Its own front, against the show's room service and relay. const port = await freePort(); const front = run("node", [path.join(project, "serve.mjs")], "exported-front", dir, { PORT: String(port), ROOM_PORT: String(svc.roomPort), RELAY_PORT: String(svc.relayPort), CWD: project }); -- 2.54.0 (Apple Git-157) From e10db0d73d443e8f822be08291541727a71d744c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 26 Oct 2026 18:43:39 -0400 Subject: [PATCH 06/14] wai-ui: the room frame is marked noindex (a frame of a page, not a page) Co-Authored-By: Claude Opus 5.5 --- wai/wai-ui/web/room-frame.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wai/wai-ui/web/room-frame.html b/wai/wai-ui/web/room-frame.html index 051edcdd2..efe9b2622 100644 --- a/wai/wai-ui/web/room-frame.html +++ b/wai/wai-ui/web/room-frame.html @@ -4,6 +4,8 @@ The room + + -
    - +
    + diff --git a/wai/wai-ui/web/app.js b/wai/wai-app/web/app.js similarity index 91% rename from wai/wai-ui/web/app.js rename to wai/wai-app/web/app.js index 4fb5936c5..af1ce6063 100644 --- a/wai/wai-ui/web/app.js +++ b/wai/wai-app/web/app.js @@ -1,5 +1,5 @@ // An app made with Create, as its exported page runs it: the program (app.oui) parsed by -// wai-ui's WebAssembly build, rendered by the same widgets Create renders, its room on the +// wai-app's WebAssembly build, rendered by the same widgets Create renders, its room on the // room service behind this page's origin, every action sealed into the page's log. import { openSession } from "./session.js"; import { Renderer } from "./renderer.js"; diff --git a/wai/wai-ui/web/components.js b/wai/wai-app/web/components.js similarity index 99% rename from wai/wai-ui/web/components.js rename to wai/wai-app/web/components.js index 8ac6e8382..a1ec92b62 100644 --- a/wai/wai-ui/web/components.js +++ b/wai/wai-app/web/components.js @@ -1,4 +1,4 @@ -// The WAI widgets of wai-ui's registry, as components of the streaming renderer. +// The WAI widgets of wai-app's registry, as components of the streaming renderer. // // A room widget (Room, WatchTogether, Show) runs the room client of joule-moq-web in a frame // of this page's own origin (room-frame.html): the page that makes its own key, asks the diff --git a/wai/wai-ui/web/create.js b/wai/wai-app/web/create.js similarity index 77% rename from wai/wai-ui/web/create.js rename to wai/wai-app/web/create.js index 36a6950af..aca8adfbc 100644 --- a/wai/wai-ui/web/create.js +++ b/wai/wai-app/web/create.js @@ -1,21 +1,20 @@ // Create: describe a WAI app, watch it build itself from WAI widgets as a model writes it, -// try its room, export it as a starter project. +// try its room, and take its program (app.oui) away: `wai create --from app.oui` makes +// it a starter project on the person's machine (catalogue `app.export`). // // The page calls no model of its own and holds no key on any server. A program comes from // the visitor's own model (an endpoint and a key they type, called from this page alone), -// from their coding agent (`wai-ui mcp`, through its bridge on 127.0.0.1), or from a -// recorded stream; the page says which. Each chunk is parsed by wai-ui's WebAssembly build +// from their coding agent (`wai mcp --toolset app`, through its bridge on 127.0.0.1), or from a +// recorded stream; the page says which. Each chunk is parsed by wai-app's WebAssembly build // (joule-ui's line language), and the snapshot, every widget of it accepted by the WAI // registry, is rendered at once. import { openSession, guide } from "./session.js"; import { Renderer } from "./renderer.js"; import { components, context } from "./components.js"; import { ownModel, bridge, replay } from "./model.js"; -import { zip } from "./zip.js"; const root = document.getElementById("create"); const $ = (id) => document.getElementById(id); -const enc = new TextEncoder(); const session = await openSession(); // The room service each kind of room is tried on (`data-service-live`, `-watch`, `-show`), @@ -57,6 +56,7 @@ async function build(chunks, label) { $("export").disabled = true; $("download").hidden = true; $("export-status").textContent = ""; + $("export-command").textContent = ""; root.dataset.state = "streaming"; try { for await (const c of chunks) { @@ -139,7 +139,7 @@ $("describe").addEventListener("submit", async (e) => { } else { const name = $("recording").value; says(SAYS.replay(name)); - const rec = await (await fetch(`${root.dataset.recorded}wai-ui-${name}.json`, { cache: "no-store" })).json(); + const rec = await (await fetch(`${root.dataset.recorded}wai-app-${name}.json`, { cache: "no-store" })).json(); $("prompt").value = rec.prompt; await build(replay(rec, { speed: Number(root.dataset.speed || 1) }), "replay"); } @@ -154,7 +154,7 @@ function connectAgent() { disconnect = bridge({ url, code, onProgram: (iter) => build(iter, "agent"), - onState: (st) => { $("agent-state").textContent = st === "connected" ? "Connected: ask your agent to build the app." : st === "retrying" ? "Not connected: is `wai-ui mcp` running, and the code right?" : "Closed."; }, + onState: (st) => { $("agent-state").textContent = st === "connected" ? "Connected: ask your agent to build the app." : st === "retrying" ? "Not connected: is `wai mcp --toolset app` running, and the code right?" : "Closed."; }, }); } catch (e) { says(String(e.message || e)); @@ -163,44 +163,26 @@ function connectAgent() { // ---------------------------------------------------------------------------- the export -async function sha256(bytes) { - const d = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); - return Array.from(d, (x) => x.toString(16).padStart(2, "0")).join(""); -} - -$("export").addEventListener("click", async () => { +// The program in its canonical form, as a file: `wai create --from app.oui` makes the +// project of it, on the person's machine, with the one writer every WAI project has. +$("export").addEventListener("click", () => { const status = $("export-status"); - $("export").disabled = true; try { - const rtUrl = new URL(root.dataset.runtime, location.href); - const rt = await (await fetch(rtUrl, { cache: "no-store" })).json(); - const files = session.export(rt.files); - const runtimeRoot = new URL("../", rtUrl); - const want = new Map(rt.files.map((f) => [f.path, f.sha256])); - const out = []; - for (const f of files) { - if (f.contents.kind === "text") { - out.push({ path: f.path, bytes: enc.encode(f.contents.value) }); - continue; - } - const b = new Uint8Array(await (await fetch(new URL(f.contents.value, runtimeRoot), { cache: "no-store" })).arrayBuffer()); - if ((await sha256(b)) !== want.get(f.contents.value)) throw new Error(`${f.contents.value} is not the file the runtime lists`); - out.push({ path: f.path, bytes: b }); - } + const text = session.canonical(); const title = (test.last && test.last.tree && test.last.tree.props && test.last.tree.props.title && test.last.tree.props.title.value) || "wai-app"; const dir = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wai-app"; - const z = zip(out.map((x) => ({ path: `${dir}/${x.path}`, bytes: x.bytes }))); const a = $("download"); if (a.href) URL.revokeObjectURL(a.href); - a.href = URL.createObjectURL(new Blob([z], { type: "application/zip" })); - a.download = `${dir}.zip`; - a.textContent = `${dir}.zip (${Math.round(z.length / 1024)} KB)`; + a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + a.download = "app.oui"; + a.textContent = `app.oui (${text.split("\n").filter(Boolean).length} lines)`; a.hidden = false; - test.exported = { dir, files: out.map((x) => x.path), bytes: z.length }; - status.textContent = `${out.length} files: the program, its room's configuration, the page and its runtime, a check that is its build, a README and an AGENTS.md. No key is in it.`; + const command = `wai create ${dir} --from app.oui`; + $("export-command").textContent = command; + test.exported = { file: "app.oui", text, command }; + status.textContent = "Then, where you saved it: run the command below, and `wai dev` in the project it makes. The project is Apache-2.0, and no key is in it."; a.click(); } catch (e) { status.textContent = `Not exported: ${e.message || e}`; } - $("export").disabled = false; }); diff --git a/wai/wai-ui/web/index.html b/wai/wai-app/web/index.html similarity index 89% rename from wai/wai-ui/web/index.html rename to wai/wai-app/web/index.html index 21647c3bf..feff67aec 100644 --- a/wai/wai-ui/web/index.html +++ b/wai/wai-app/web/index.html @@ -10,7 +10,7 @@

    Create

    -
    @@ -29,7 +29,7 @@
    @@ -47,9 +47,10 @@

    Problems

      Export

      - +

      +
      
           
         
       
      diff --git a/wai/wai-ui/web/model.js b/wai/wai-app/web/model.js
      similarity index 97%
      rename from wai/wai-ui/web/model.js
      rename to wai/wai-app/web/model.js
      index 4bec15058..e38676320 100644
      --- a/wai/wai-ui/web/model.js
      +++ b/wai/wai-app/web/model.js
      @@ -5,7 +5,7 @@
       //              machine, or a provider they hold a key for), called from this page with
       //              their key. The key is held by the caller in memory and sent to that
       //              endpoint alone; nothing here stores it.
      -//   bridge     the visitor's coding agent, through `wai-ui mcp`'s bridge on 127.0.0.1:
      +//   bridge     the visitor's coding agent, through the bridge `wai mcp --toolset app` runs on 127.0.0.1:
       //              each program the agent streams, read with the pairing code.
       //   replay     a recorded stream: chunks and the pauses between them.
       //
      diff --git a/wai/wai-ui/web/recorded/wai-ui-room.json b/wai/wai-app/web/recorded/wai-app-room.json
      similarity index 100%
      rename from wai/wai-ui/web/recorded/wai-ui-room.json
      rename to wai/wai-app/web/recorded/wai-app-room.json
      diff --git a/wai/wai-ui/web/recorded/wai-ui-show.json b/wai/wai-app/web/recorded/wai-app-show.json
      similarity index 100%
      rename from wai/wai-ui/web/recorded/wai-ui-show.json
      rename to wai/wai-app/web/recorded/wai-app-show.json
      diff --git a/wai/wai-ui/web/recorded/wai-ui-watch-refused.json b/wai/wai-app/web/recorded/wai-app-watch-refused.json
      similarity index 100%
      rename from wai/wai-ui/web/recorded/wai-ui-watch-refused.json
      rename to wai/wai-app/web/recorded/wai-app-watch-refused.json
      diff --git a/wai/wai-ui/web/renderer.js b/wai/wai-app/web/renderer.js
      similarity index 100%
      rename from wai/wai-ui/web/renderer.js
      rename to wai/wai-app/web/renderer.js
      diff --git a/wai/wai-ui/web/room-frame.html b/wai/wai-app/web/room-frame.html
      similarity index 99%
      rename from wai/wai-ui/web/room-frame.html
      rename to wai/wai-app/web/room-frame.html
      index efe9b2622..174ed0cbe 100644
      --- a/wai/wai-ui/web/room-frame.html
      +++ b/wai/wai-app/web/room-frame.html
      @@ -8,7 +8,7 @@
       
       
       
      diff --git a/wai/wai-ui/web/runtime.mjs b/wai/wai-app/web/runtime.mjs
      similarity index 71%
      rename from wai/wai-ui/web/runtime.mjs
      rename to wai/wai-app/web/runtime.mjs
      index fe55db163..582523308 100644
      --- a/wai/wai-ui/web/runtime.mjs
      +++ b/wai/wai-app/web/runtime.mjs
      @@ -2,22 +2,22 @@
       // Lay out the Create runtime from this repository's working tree, as the WAI site serves it
       // and as an exported project carries it under vendor/:
       //
      -//   /create/   wai-ui's page files, each named wai-ui- (the widgets, the
      +//   /create/   wai-app's page files, each named wai-app- (the widgets, the
       //                   renderer, the session, the room frame, the styles, Create's own page
      -//                   and its recordings), and create/pkg/ (wai-ui's WebAssembly build, which
      +//                   and its recordings), and create/pkg/ (wai-app's WebAssembly build, which
       //                   this script does not make)
      -//   /room/     joule-moq-web's room client, each file named wai-ui-room-.js
      -//                   (app.js as wai-ui-room-client.js), its imports rewritten to those names
      -//                   and to the WebAssembly packages room/moq/pkg/wai_ui_room_moq.js,
      -//                   room/moq-e2ee/pkg/wai_ui_room_moq_e2ee.js and room/live/pkg/wai_ui_room_live.js
      +//   /room/     joule-moq-web's room client, each file named wai-app-room-.js
      +//                   (app.js as wai-app-room-client.js), its imports rewritten to those names
      +//                   and to the WebAssembly packages room/moq/pkg/wai_app_room_moq.js,
      +//                   room/moq-e2ee/pkg/wai_app_room_moq_e2ee.js and room/live/pkg/wai_app_room_live.js
       //                   (also not made here)
      -//   /create/wai-ui-runtime.json   every file an app needs, by its path under ,
      +//   /create/wai-app-runtime.json   every file an app needs, by its path under ,
       //                   with its SHA-256: what an export copies under vendor/
       //
       // Every served name is unique across the WAI site, whose deploy fingerprints assets keyed
       // by basename.
       //
      -//   node wai/wai-ui/web/runtime.mjs 
      +//   node wai/wai-app/web/runtime.mjs 
       //
       // The room client's files are those app.js reaches by its imports. A relative path string
       // in any of them that this script does not map fails the layout, so a new import is never
      @@ -38,36 +38,35 @@ if (!out) {
       }
       const OUT = path.resolve(out);
       
      -// wai-ui's own files by their served names: unique across the site, which fingerprints its
      +// wai-app's own files by their served names: unique across the site, which fingerprints its
       // assets keyed by basename, and the same in an export's vendor/. Which of them an app needs.
       const CREATE_FILES = {
      -  "app.js": "wai-ui-app.js",
      -  "renderer.js": "wai-ui-renderer.js",
      -  "components.js": "wai-ui-components.js",
      -  "session.js": "wai-ui-session.js",
      -  "ui.css": "wai-ui.css",
      -  "room-frame.html": "wai-ui-room-frame.html",
      -  "create.js": "wai-ui-create.js",
      -  "model.js": "wai-ui-model.js",
      -  "zip.js": "wai-ui-zip.js",
      +  "app.js": "wai-app-app.js",
      +  "renderer.js": "wai-app-renderer.js",
      +  "components.js": "wai-app-components.js",
      +  "session.js": "wai-app-session.js",
      +  "ui.css": "wai-app.css",
      +  "room-frame.html": "wai-app-room-frame.html",
      +  "create.js": "wai-app-create.js",
      +  "model.js": "wai-app-model.js",
         "index.html": "index.html",
      -  "runtime.json": "wai-ui-runtime.json",
      -  "pkg/wai_ui.js": "pkg/wai_ui.js",
      +  "runtime.json": "wai-app-runtime.json",
      +  "pkg/wai_app.js": "pkg/wai_app.js",
       };
      -const APP_FILES = ["wai-ui-app.js", "wai-ui-renderer.js", "wai-ui-components.js", "wai-ui-session.js", "wai-ui.css", "wai-ui-room-frame.html", "pkg/wai_ui.js", "pkg/wai_ui_bg.wasm"];
      +const APP_FILES = ["wai-app-app.js", "wai-app-renderer.js", "wai-app-components.js", "wai-app-session.js", "wai-app.css", "wai-app-room-frame.html", "pkg/wai_app.js", "pkg/wai_app_bg.wasm"];
       const FRAME_CLIENT = "../../../joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js";
       
       // The room client's served name for each file of joule-moq-web/web it is made of.
       function servedName(rel) {
         const fixed = {
      -    "room/app.js": "room/wai-ui-room-client.js",
      -    "pkg/joule_moq_web.js": "room/moq/pkg/wai_ui_room_moq.js",
      -    "pkg/joule_moq_web_e2ee.js": "room/moq-e2ee/pkg/wai_ui_room_moq_e2ee.js",
      -    "pkg/wai_live_web.js": "room/live/pkg/wai_ui_room_live.js",
      +    "room/app.js": "room/wai-app-room-client.js",
      +    "pkg/joule_moq_web.js": "room/moq/pkg/wai_app_room_moq.js",
      +    "pkg/joule_moq_web_e2ee.js": "room/moq-e2ee/pkg/wai_app_room_moq_e2ee.js",
      +    "pkg/wai_live_web.js": "room/live/pkg/wai_app_room_live.js",
         };
         if (fixed[rel]) return fixed[rel];
         const m = rel.match(/^(room|moq)\/([a-z0-9-]+)\.js$/);
      -  if (m) return `room/wai-ui-room-${m[2]}.js`;
      +  if (m) return `room/wai-app-room-${m[2]}.js`;
         return null;
       }
       
      @@ -92,14 +91,14 @@ function layRoomClient() {
             if (!r.startsWith(".")) r = `./${r}`;
             return `${q}${r}${q}`;
           });
      -    const header = `// joule-moq-web/web/${rel} (Apache-2.0), sha256 ${sha256(text)}; its imports renamed by wai/wai-ui/web/runtime.mjs.\n`;
      +    const header = `// joule-moq-web/web/${rel} (Apache-2.0), sha256 ${sha256(text)}; its imports renamed by wai/wai-app/web/runtime.mjs.\n`;
           fs.mkdirSync(path.join(OUT, path.dirname(served)), { recursive: true });
           fs.writeFileSync(path.join(OUT, served), header + rewritten);
         }
         return [...done].sort();
       }
       
      -// wai-ui's files under their served names, each relative path in them (an import, a
      +// wai-app's files under their served names, each relative path in them (an import, a
       // stylesheet, a frame, a data attribute) rewritten to those names; one the table does not
       // map fails the layout. The room frame loads the room client laid out beside it.
       function layCreate() {
      @@ -110,7 +109,7 @@ function layCreate() {
           let text = fs.readFileSync(path.join(HERE, src), "utf8");
           if (src === "room-frame.html") {
             if (!text.includes(`src="${FRAME_CLIENT}"`)) throw new Error("runtime: room-frame.html does not load the room client by its source path");
      -      text = text.replace(`src="${FRAME_CLIENT}"`, 'src="../room/wai-ui-room-client.js"');
      +      text = text.replace(`src="${FRAME_CLIENT}"`, 'src="../room/wai-app-room-client.js"');
           }
           text = text.replace(/(["'`])\.\/([a-z0-9_./-]+)\1/gi, (all, q, rel) => {
             if (rel === "recorded/") return all;
      @@ -139,10 +138,10 @@ layCreate();
       const files = [];
       for (const f of APP_FILES) {
         const p = path.join(OUT, "create", f);
      -  if (!fs.existsSync(p)) throw new Error(`runtime: create/${f} is missing (build wai-ui's WebAssembly package into ${path.join(OUT, "create/pkg")} first)`);
      +  if (!fs.existsSync(p)) throw new Error(`runtime: create/${f} is missing (build wai-app's WebAssembly package into ${path.join(OUT, "create/pkg")} first)`);
         files.push(`create/${f}`);
       }
       for (const f of walk(path.join(OUT, "room"), OUT, [])) files.push(f);
       const listed = files.map((f) => ({ path: f, sha256: sha256(fs.readFileSync(path.join(OUT, f))) }));
      -fs.writeFileSync(path.join(OUT, "create/wai-ui-runtime.json"), JSON.stringify({ note: "The runtime an app made with Create carries under vendor/, by path, with each file's SHA-256.", files: listed }, null, 1) + "\n");
      -console.log(`runtime: ${room.length} room client file(s), ${listed.length} runtime file(s) listed in create/wai-ui-runtime.json`);
      +fs.writeFileSync(path.join(OUT, "create/wai-app-runtime.json"), JSON.stringify({ note: "The runtime an app made with Create carries under vendor/, by path, with each file's SHA-256.", files: listed }, null, 1) + "\n");
      +console.log(`runtime: ${room.length} room client file(s), ${listed.length} runtime file(s) listed in create/wai-app-runtime.json`);
      diff --git a/wai/wai-ui/web/session.js b/wai/wai-app/web/session.js
      similarity index 92%
      rename from wai/wai-ui/web/session.js
      rename to wai/wai-app/web/session.js
      index 36d307942..eca29d05f 100644
      --- a/wai/wai-ui/web/session.js
      +++ b/wai/wai-app/web/session.js
      @@ -1,4 +1,4 @@
      -// The page's session: wai-ui's WebAssembly build, the program as it streams, and the log
      +// The page's session: wai-app's WebAssembly build, the program as it streams, and the log
       // every action a person takes on the page is sealed into (joule-ui SPEC §6, §7).
       //
       // `fire(action, inputs, handler)` is the interaction lifecycle around an asynchronous
      @@ -6,7 +6,7 @@
       // timed on the page's clock, then sealed as the next link of the log under the page's own
       // key. A page has no energy meter: the figure is the estimator's, from the handler's wall
       // time, and the log says so (`Estimator`).
      -import init, { Session, verifyLog, registry, guide, check } from "./pkg/wai_ui.js";
      +import init, { Session, verifyLog, registry, guide, check } from "./pkg/wai_app.js";
       
       export { verifyLog, registry, guide, check };
       
      @@ -33,7 +33,6 @@ export class PageSession {
         logRaw() { return this.w.logRaw(); }
         verify() { return JSON.parse(this.w.verify()); }
         totalUj() { return this.w.totalUj(); }
      -  export(runtime) { return JSON.parse(this.w.export(JSON.stringify(runtime))); }
         on(f) { this.listeners.add(f); return () => this.listeners.delete(f); }
         note(entry) { for (const f of this.listeners) { try { f(entry); } catch { /* a view's own fault */ } } }
       
      diff --git a/wai/wai-ui/web/ui.css b/wai/wai-app/web/ui.css
      similarity index 100%
      rename from wai/wai-ui/web/ui.css
      rename to wai/wai-app/web/ui.css
      diff --git a/wai/wai-ui/web/web.test.mjs b/wai/wai-app/web/web.test.mjs
      similarity index 86%
      rename from wai/wai-ui/web/web.test.mjs
      rename to wai/wai-app/web/web.test.mjs
      index a5758875c..2d9a9fc3d 100644
      --- a/wai/wai-ui/web/web.test.mjs
      +++ b/wai/wai-app/web/web.test.mjs
      @@ -2,7 +2,7 @@
       // for the document), the prop values' plain form, the ZIP writer, and the model stream
       // readers.
       //
      -//   node --test wai/wai-ui/web/web.test.mjs
      +//   node --test wai/wai-app/web/web.test.mjs
       import test from "node:test";
       import assert from "node:assert/strict";
       import zlib from "node:zlib";
      @@ -24,7 +24,6 @@ class El {
       globalThis.setTimeout = ((orig) => (f, ms) => orig(f, ms))(globalThis.setTimeout);
       
       const { Renderer, plain, plainProps } = await import("./renderer.js");
      -const { zip, unzip, crc32 } = await import("./zip.js");
       const { completionChunks, replay } = await import("./model.js");
       
       const T = (v) => ({ type: "text", value: v });
      @@ -83,18 +82,6 @@ test("prop values in their plain form", () => {
         assert.deepEqual(plain({ type: "action", value: { id: "Go", payload: { type: "list", value: [T("a")] } } }), { action: "Go", payload: ["a"] });
       });
       
      -test("the ZIP writer's archive reads back, and its CRC-32 is zlib's", () => {
      -  const enc = new TextEncoder();
      -  const files = [{ path: "p/app.oui", bytes: enc.encode("root = App()\n") }, { path: "p/vendor/a.wasm", bytes: new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]) }, { path: "p/é.txt", bytes: new Uint8Array(0) }];
      -  const z = zip(files);
      -  assert.deepEqual(unzip(z).map((f) => [f.path, [...f.bytes]]), files.map((f) => [f.path, [...f.bytes]]));
      -  for (const f of files) assert.equal(crc32(f.bytes), zlib.crc32(f.bytes));
      -  assert.deepEqual(zip(files), z, "the same files give the same bytes");
      -  const bad = z.slice();
      -  bad[40] ^= 1; // a byte of the first file's contents
      -  assert.throws(() => unzip(bad), /CRC-32/);
      -});
      -
       test("a chat/completions stream gives its content chunks", async () => {
         const lines = (async function* () {
           yield ": keep-alive";
      diff --git a/wai/wai-ui/src/bin/wai_ui.rs b/wai/wai-ui/src/bin/wai_ui.rs
      deleted file mode 100644
      index a8f6d809f..000000000
      --- a/wai/wai-ui/src/bin/wai_ui.rs
      +++ /dev/null
      @@ -1,64 +0,0 @@
      -//! `wai-ui`: WAI's widgets for joule-ui, and Create, on the command line.
      -//!
      -//!     wai-ui guide                                  what a model is told before it writes an app
      -//!     wai-ui registry                               the WAI registry, as JSON
      -//!     wai-ui check                 the app's tree, errors and room; exit 1 on an error
      -//!     wai-ui export   [--runtime ]   the starter project, into a new or empty directory
      -//!     wai-ui mcp [--port ]                       the MCP server a coding agent drives Create through
      -
      -use std::io::Read;
      -use std::path::Path;
      -
      -fn read(p: &str) -> String {
      -    if p == "-" {
      -        let mut s = String::new();
      -        std::io::stdin().read_to_string(&mut s).expect("stdin");
      -        s
      -    } else {
      -        std::fs::read_to_string(p).unwrap_or_else(|e| die(&format!("{p}: {e}")))
      -    }
      -}
      -
      -fn die(m: &str) -> ! {
      -    eprintln!("wai-ui: {m}");
      -    std::process::exit(2)
      -}
      -
      -fn main() {
      -    let args: Vec = std::env::args().skip(1).collect();
      -    let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
      -    match args.first().map(String::as_str) {
      -        Some("guide") => print!("{}", wai_ui::guide::guide()),
      -        Some("registry") => println!("{}", serde_json::to_string_pretty(&joule_ui_lang::vectors::library_json(&wai_ui::registry::library())).expect("json")),
      -        Some("check") => {
      -            let p = read(args.get(1).map(String::as_str).unwrap_or_else(|| die("check ")));
      -            let c = wai_ui::check(&p);
      -            println!("{}", serde_json::to_string_pretty(&c).expect("json"));
      -            let bad = c.errors.iter().filter(|e| !e.warning).count();
      -            for e in c.errors.iter().filter(|e| !e.warning) {
      -                eprintln!("line {}: {}: {}", e.line, e.code, e.message);
      -            }
      -            std::process::exit(if bad > 0 { 1 } else { 0 });
      -        }
      -        Some("export") => {
      -            let (Some(p), Some(dir)) = (args.get(1), args.get(2)) else { die("export   [--runtime ]") };
      -            let rt = flag("--runtime");
      -            match wai_ui::mcp::export_to(&read(p), Path::new(dir), rt.as_deref().map(Path::new)) {
      -                Ok(files) => {
      -                    for f in &files {
      -                        println!("{f}");
      -                    }
      -                    eprintln!("wai-ui: {} file(s) written to {dir}", files.len());
      -                }
      -                Err(e) => die(&e),
      -            }
      -        }
      -        Some("mcp") => {
      -            let port = flag("--port").map(|p| p.parse().unwrap_or_else(|_| die("--port takes a number"))).unwrap_or(0);
      -            if let Err(e) = wai_ui::mcp::run(port) {
      -                die(&e.to_string());
      -            }
      -        }
      -        _ => die("guide | registry | check  | export   [--runtime ] | mcp [--port ]"),
      -    }
      -}
      diff --git a/wai/wai-ui/src/export.rs b/wai/wai-ui/src/export.rs
      deleted file mode 100644
      index d52175fdb..000000000
      --- a/wai/wai-ui/src/export.rs
      +++ /dev/null
      @@ -1,330 +0,0 @@
      -//! The one writer of a starter project. Every file an export holds, and its
      -//! layout, is decided here, whoever asks for it (the Create page, the command
      -//! line, the MCP server), so that the project format can change in one place.
      -
      -use serde::{Deserialize, Serialize};
      -
      -use crate::plan::RoomPlan;
      -use crate::{guide, Checked};
      -
      -/// A file of the runtime the project carries under `vendor/` (the WAI widgets,
      -/// the renderer, the room client and their WebAssembly builds), by its path in
      -/// the runtime and its SHA-256. The host supplies the bytes.
      -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
      -pub struct RuntimeFile {
      -    pub path: String,
      -    pub sha256: String,
      -}
      -
      -/// What a project file holds: its text, or a runtime file the host copies in.
      -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
      -#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
      -pub enum Contents {
      -    Text(String),
      -    Runtime(String),
      -}
      -
      -/// One file of the project, by its path in the project.
      -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
      -pub struct ProjectFile {
      -    pub path: String,
      -    pub contents: Contents,
      -}
      -
      -/// Why a program is not exported.
      -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
      -#[serde(rename_all = "snake_case", tag = "kind")]
      -pub enum ExportError {
      -    /// The program has errors; the export is the app as it renders with none.
      -    Errors { count: usize },
      -    /// The program holds no room, so there is nothing for a room service to keep.
      -    NoRoom,
      -    /// The tree cannot be written in the canonical form.
      -    Unwritable { reason: String },
      -    /// A runtime file's path leaves `vendor/`.
      -    BadRuntimePath { path: String },
      -}
      -
      -impl std::fmt::Display for ExportError {
      -    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      -        match self {
      -            ExportError::Errors { count } => write!(f, "the app has {count} error(s); fix them before exporting"),
      -            ExportError::NoRoom => write!(f, "the app holds no room (a Room, a WatchTogether or a Show)"),
      -            ExportError::Unwritable { reason } => write!(f, "the app cannot be written: {reason}"),
      -            ExportError::BadRuntimePath { path } => write!(f, "the runtime path `{path}` leaves vendor/"),
      -        }
      -    }
      -}
      -
      -impl std::error::Error for ExportError {}
      -
      -/// The files every export holds, in the order they are written, beside
      -/// `vendor/` (and `media/README.md` for a watch room).
      -pub const PROJECT_FILES: [&str; 12] = [
      -    "app.oui", "app.json", "room.toml", "relay.toml", "index.html", "package.json", "check.mjs", "serve.mjs", "vendor.json", "README.md", "AGENTS.md", ".gitignore",
      -];
      -
      -fn safe_runtime_path(p: &str) -> bool {
      -    !p.is_empty()
      -        && !p.starts_with('/')
      -        && p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/'))
      -        && p.split('/').all(|x| !x.is_empty() && x != "." && x != "..")
      -}
      -
      -/// A name a package manager takes: lowercase letters, digits and `-`.
      -fn package_name(title: &str) -> String {
      -    let mut s = String::new();
      -    for c in title.chars() {
      -        if c.is_ascii_alphanumeric() {
      -            s.push(c.to_ascii_lowercase());
      -        } else if !s.ends_with('-') && !s.is_empty() {
      -            s.push('-');
      -        }
      -    }
      -    let s = s.trim_end_matches('-').to_string();
      -    if s.is_empty() { "wai-app".into() } else { s.chars().take(60).collect() }
      -}
      -
      -/// The room service's configuration for the app's room (joule-moq-room), for
      -/// development: loopback, the keys in `.wai-dev/` and never in the project.
      -pub fn room_toml(plan: &RoomPlan) -> String {
      -    let what = match plan.kind.as_str() {
      -        "watch" => "a watch room: everyone watches one file together (WAI co-watch)",
      -        "show" => "a show: hosts on camera, an audience that votes (WAI co-performance)",
      -        _ => "a live room: people join with camera and microphone, or watch",
      -    };
      -    let mut t = format!(
      -        "# The room service's configuration for this app (joule-moq-room), written by Create from\n\
      -         # app.oui: {what}.\n\
      -         # Development: everything on loopback, the keys in .wai-dev/ (made by `node serve.mjs --up`,\n\
      -         # never committed). A production deployment keeps its own copy, with keys of its own.\n\
      -         listen = \"127.0.0.1:9682\"\n\
      -         public_prefix = \"/room-api\"\n\
      -         keys = \".wai-dev/room-keys.toml\"\n\
      -         relay_url = \"ws://127.0.0.1:9681/moq\"\n\
      -         max_resident_mb = 256\n\n\
      -         [room]\n\
      -         max_publishers = 4\n\
      -         max_present = 16\n\
      -         publisher_grant_s = 1200\n\
      -         viewer_grant_s = 1800\n\
      -         join_wait_s = 90\n\
      -         max_grants = 64\n"
      -    );
      -    if plan.kind == "watch" {
      -        let source = plan.source.as_deref().unwrap_or_default();
      -        t.push_str(&format!(
      -            "# The file the room watches, served whole, and its length in milliseconds: put the file at\n\
      -             # media/{source} and set its length before `npm run dev`.\n\
      -             watch_source = \"media/{source}\"\n\
      -             watch_duration_ms = 0\n\
      -             # What everyone may control: 1 play, 2 pause, 4 seek, 8 rate.\n\
      -             watch_control = 15\n\
      -             watch_window_ms = 5000\n"
      -        ));
      -    }
      -    if plan.kind == "show" {
      -        t.push_str(&format!(
      -            "\n[co]\n\
      -             # Each viewer's inputs to one poll; the first host leads (opens the polls).\n\
      -             audience_input = {}\n\
      -             latency_budget_ms = 0\n\
      -             lead = \"first\"\n\
      -             ai = false\n\
      -             fidelity_every_s = 0\n\
      -             commit_after_ms = 1500\n\
      -             lead_ms = 300\n",
      -            plan.audience
      -        ));
      -    }
      -    t.push_str(
      -        "\n[rate]\n\
      -         client_burst = 20\n\
      -         client_every_s = 20\n\
      -         global_burst = 120\n\
      -         global_every_s = 10\n",
      -    );
      -    t
      -}
      -
      -/// The relay's configuration for development (joule-moq-relay): the WebSocket
      -/// binding on loopback, grants required, each publisher's audio and video held
      -/// to its own key. `node serve.mjs --up` adds the lines that trust the app's
      -/// room service.
      -pub const RELAY_TOML: &str = "# The relay's configuration for development (joule-moq-relay): the WebSocket binding on\n\
      -# loopback and grants required. `node serve.mjs --up` adds the lines that trust this app's room\n\
      -# service (made with its keys) and writes the result to .wai-dev/relay.toml.\n\
      -websocket_listen = \"127.0.0.1:9681\"\n\
      -path = \"/moq\"\n\
      -goaway_uri = \"\"\n\n\
      -[tls]\n\
      -dev_names = [\"localhost\", \"127.0.0.1\"]\n\n\
      -[limits]\n\
      -goaway_timeout_ms = 8000\n\n\
      -[receipts]\n\
      -receipts_track_suffix = \"_receipts\"\n\n\
      -[[receipts.door]]\n\
      -prefix = [\"*\", \"*\", \"audio\"]\n\
      -trust = []\n\
      -trust_field = 1\n\
      -hold_ms = 2000\n\n\
      -[[receipts.door]]\n\
      -prefix = [\"*\", \"*\", \"video\"]\n\
      -trust = []\n\
      -trust_field = 1\n\
      -hold_ms = 2000\n\n\
      -[summary]\n\
      -out_path = \".wai-dev/summaries.jsonl\"\n\n\
      -[auth]\n\
      -required = true\n";
      -
      -fn fill(template: &str, vars: &[(&str, &str)]) -> String {
      -    let mut s = template.to_string();
      -    for (k, v) in vars {
      -        s = s.replace(&format!("{{{{{k}}}}}"), v);
      -    }
      -    s
      -}
      -
      -fn html_text(s: &str) -> String {
      -    s.replace('&', "&").replace('<', "<").replace('>', ">")
      -}
      -
      -/// Write the starter project for `program`: every file of the project, the
      -/// runtime files named by their runtime paths for the host to copy in.
      -pub fn export(program: &str, runtime: &[RuntimeFile]) -> Result, ExportError> {
      -    let checked: Checked = crate::check(program);
      -    let errors = checked.errors.iter().filter(|e| !e.warning).count();
      -    if errors > 0 {
      -        return Err(ExportError::Errors { count: errors });
      -    }
      -    let (Some(tree), Some(plan)) = (checked.tree.as_ref(), checked.room.as_ref()) else {
      -        return Err(ExportError::NoRoom);
      -    };
      -    for r in runtime {
      -        if !safe_runtime_path(&r.path) {
      -            return Err(ExportError::BadRuntimePath { path: r.path.clone() });
      -        }
      -    }
      -    let lib = crate::registry::library();
      -    let oui = joule_ui_lang::write(tree, &lib).map_err(|e| ExportError::Unwritable { reason: e.to_string() })?;
      -    // The app's title, else its room's.
      -    let title = match tree.props.get("title") {
      -        Some(joule_ui_core::PropValue::Text(s)) => Some(s.clone()),
      -        _ => None,
      -    }
      -    .or_else(|| plan.title.clone());
      -    let title = title.unwrap_or_else(|| "A WAI app".into());
      -    let (summary, what) = match plan.kind.as_str() {
      -        "watch" => (
      -            "a watch room, where everyone watches one file together, in sync.",
      -            "A watch room: press Watch, and the file plays at the room's position; play, pause, seek or change the speed, and everyone's player follows.",
      -        ),
      -        "show" => (
      -            "a show, with hosts on camera and an audience that watches and votes.",
      -            "A show: the first host to join leads and opens the polls; viewers press Watch and vote, and the room's clock signs each result.",
      -        ),
      -        _ => (
      -            "a live room, where people join with their camera and microphone, or watch.",
      -            "A live room: press Join to take part with your camera and microphone, or Watch; every frame is checked against its signed receipt before it is shown.",
      -        ),
      -    };
      -    let package = package_name(&title);
      -    let guide_text = guide::guide();
      -    let vars: [(&str, &str); 5] = [("TITLE", &html_text(&title)), ("PACKAGE", &package), ("SUMMARY", summary), ("WHAT", what), ("GUIDE", &guide_text)];
      -
      -    let vendor = serde_json::json!({
      -        "note": "The runtime under vendor/, each file with its SHA-256; check.mjs verifies them.",
      -        "files": runtime,
      -    });
      -    let mut files = vec![
      -        ProjectFile { path: "app.oui".into(), contents: Contents::Text(oui) },
      -        ProjectFile { path: "app.json".into(), contents: Contents::Text(serde_json::to_string(tree).expect("a tree serialises")) },
      -        ProjectFile { path: "room.toml".into(), contents: Contents::Text(room_toml(plan)) },
      -        ProjectFile { path: "relay.toml".into(), contents: Contents::Text(RELAY_TOML.into()) },
      -        ProjectFile { path: "index.html".into(), contents: Contents::Text(fill(include_str!("../templates/index.html"), &vars)) },
      -        ProjectFile { path: "package.json".into(), contents: Contents::Text(fill(include_str!("../templates/package.json"), &vars)) },
      -        ProjectFile { path: "check.mjs".into(), contents: Contents::Text(include_str!("../templates/check.mjs").into()) },
      -        ProjectFile { path: "serve.mjs".into(), contents: Contents::Text(include_str!("../templates/serve.mjs").into()) },
      -        ProjectFile { path: "vendor.json".into(), contents: Contents::Text(serde_json::to_string_pretty(&vendor).expect("json") + "\n") },
      -        ProjectFile { path: "README.md".into(), contents: Contents::Text(fill(include_str!("../templates/README.md"), &vars)) },
      -        ProjectFile { path: "AGENTS.md".into(), contents: Contents::Text(fill(include_str!("../templates/AGENTS.md"), &vars)) },
      -        ProjectFile { path: ".gitignore".into(), contents: Contents::Text(include_str!("../templates/gitignore").into()) },
      -    ];
      -    if plan.kind == "watch" {
      -        let source = plan.source.as_deref().unwrap_or_default();
      -        files.push(ProjectFile {
      -            path: "media/README.md".into(),
      -            contents: Contents::Text(format!("Put the file the room watches here, as `{source}`, and set `watch_duration_ms` in `../room.toml` to its length in milliseconds.\n")),
      -        });
      -    }
      -    for r in runtime {
      -        files.push(ProjectFile { path: format!("vendor/{}", r.path), contents: Contents::Runtime(r.path.clone()) });
      -    }
      -    Ok(files)
      -}
      -
      -#[cfg(test)]
      -mod tests {
      -    use super::*;
      -
      -    const SHOW: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll], \"Live\", 2)\nplayer = Player()\nchat = Chat()\npoll = Poll(\"Next?\", [\"A\", \"B\"])\n";
      -
      -    fn rt() -> Vec {
      -        vec![RuntimeFile { path: "create/app.js".into(), sha256: "00".repeat(32) }]
      -    }
      -
      -    fn text<'a>(files: &'a [ProjectFile], p: &str) -> &'a str {
      -        match &files.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p} is not in the export")).contents {
      -            Contents::Text(t) => t,
      -            Contents::Runtime(_) => panic!("{p} is a runtime file"),
      -        }
      -    }
      -
      -    #[test]
      -    fn an_export_holds_every_project_file_and_its_room_configuration() {
      -        let files = export(SHOW, &rt()).unwrap();
      -        for p in PROJECT_FILES {
      -            assert!(files.iter().any(|f| f.path == p), "{p} is missing");
      -        }
      -        let room = text(&files, "room.toml");
      -        assert!(room.contains("[co]\n") && room.contains("audience_input = 2\n"), "{room}");
      -        assert!(!room.contains("watch_source"));
      -        assert!(files.iter().any(|f| f.path == "vendor/create/app.js" && f.contents == Contents::Runtime("create/app.js".into())));
      -        // The program is the canonical form of the tree, and app.json the tree.
      -        let oui = text(&files, "app.oui");
      -        let back = crate::check(oui);
      -        assert!(back.errors.is_empty());
      -        assert_eq!(serde_json::to_string(back.tree.as_ref().unwrap()).unwrap(), text(&files, "app.json"));
      -        // No secret: the development keys are made at run time, outside the project's files.
      -        assert!(room.contains("keys = \".wai-dev/room-keys.toml\""));
      -        assert!(text(&files, ".gitignore").contains(".wai-dev/"));
      -        assert!(text(&files, "AGENTS.md").contains("- Poll(question*, options*, seconds)"));
      -        assert!(text(&files, "index.html").contains("Friday night"));
      -    }
      -
      -    #[test]
      -    fn each_room_kind_has_its_configuration() {
      -        let live = export("root = App([r], \"x\")\nr = Room([Chat()])\n", &[]).unwrap();
      -        let t = text(&live, "room.toml");
      -        assert!(!t.contains("[co]") && !t.contains("watch_source"));
      -        let watch = export("root = App([w])\nw = WatchTogether(\"film.mp4\")\n", &[]).unwrap();
      -        let t = text(&watch, "room.toml");
      -        assert!(t.contains("watch_source = \"media/film.mp4\"\n") && !t.contains("[co]"));
      -        assert!(watch.iter().any(|f| f.path == "media/README.md"));
      -    }
      -
      -    #[test]
      -    fn what_is_not_exported() {
      -        assert_eq!(export("root = App([t])\nt = Text(\"no room\")\n", &[]), Err(ExportError::NoRoom));
      -        assert_eq!(export("root = App([r])\nr = Room([Poll(\"q\", [\"a\", \"b\"])])\n", &[]), Err(ExportError::Errors { count: 1 }));
      -        let bad = [RuntimeFile { path: "../etc/x".into(), sha256: String::new() }];
      -        assert!(matches!(export(SHOW, &bad), Err(ExportError::BadRuntimePath { .. })));
      -    }
      -
      -    #[test]
      -    fn a_package_name_is_one_a_package_manager_takes() {
      -        assert_eq!(package_name("Friday night — live!"), "friday-night-live");
      -        assert_eq!(package_name("***"), "wai-app");
      -    }
      -}
      diff --git a/wai/wai-ui/src/mcp.rs b/wai/wai-ui/src/mcp.rs
      deleted file mode 100644
      index c030f3830..000000000
      --- a/wai/wai-ui/src/mcp.rs
      +++ /dev/null
      @@ -1,379 +0,0 @@
      -//! `wai-ui mcp`: an MCP server (JSON-RPC 2.0 over stdio, one message a line) a
      -//! person's own coding agent runs on their machine to drive Create, and the
      -//! bridge on 127.0.0.1 that carries a program into their open Create page.
      -//!
      -//! Tools:
      -//! - `create_guide`: the language and the WAI widgets, as a model is told them,
      -//!   and how the person connects their Create page to the bridge.
      -//! - `create_check`: a program checked: the tree, every error with its line and
      -//!   reason, the room the app needs.
      -//! - `create_stream`: a program streamed into the connected Create page, which
      -//!   renders it as it arrives.
      -//! - `create_export`: the starter project written into a directory.
      -//!
      -//! Nothing is sent off the machine: the bridge listens on 127.0.0.1 alone, and a
      -//! page reads from it only with the pairing code the server makes at start.
      -
      -use std::collections::hash_map::RandomState;
      -use std::hash::{BuildHasher, Hasher};
      -use std::io::{BufRead, BufReader, Write};
      -use std::net::{TcpListener, TcpStream};
      -use std::path::{Path, PathBuf};
      -use std::sync::{Arc, Mutex};
      -use std::time::Duration;
      -
      -use serde_json::{json, Value};
      -use sha2::{Digest, Sha256};
      -
      -use crate::export::{Contents, RuntimeFile};
      -
      -/// A pairing code: 10 letters and digits a page presents to read the bridge.
      -fn pairing_code() -> String {
      -    const A: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
      -    let mut out = String::new();
      -    for i in 0..10u64 {
      -        let mut h = RandomState::new().build_hasher();
      -        h.write_u64(i);
      -        h.write_u128(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0));
      -        out.push(A[(h.finish() % A.len() as u64) as usize] as char);
      -    }
      -    out
      -}
      -
      -/// The bridge: Create pages connected by server-sent events, each with the code.
      -pub struct Bridge {
      -    pub port: u16,
      -    pub code: String,
      -    listeners: Arc>>,
      -}
      -
      -impl Bridge {
      -    /// Listen on 127.0.0.1:`port` (0 for any free port).
      -    pub fn start(port: u16) -> std::io::Result {
      -        let l = TcpListener::bind(("127.0.0.1", port))?;
      -        let port = l.local_addr()?.port();
      -        let code = pairing_code();
      -        let listeners: Arc>> = Arc::new(Mutex::new(Vec::new()));
      -        let (ls, c) = (listeners.clone(), code.clone());
      -        std::thread::spawn(move || {
      -            for s in l.incoming().flatten() {
      -                let (ls, c) = (ls.clone(), c.clone());
      -                std::thread::spawn(move || {
      -                    let _ = serve(s, &c, &ls);
      -                });
      -            }
      -        });
      -        Ok(Bridge { port, code, listeners })
      -    }
      -
      -    /// The pages connected now.
      -    pub fn listening(&self) -> usize {
      -        self.listeners.lock().map(|l| l.len()).unwrap_or(0)
      -    }
      -
      -    /// Send one event to every connected page; the pages it reached.
      -    pub fn send(&self, event: &str, data: &Value) -> usize {
      -        let msg = format!("event: {event}\ndata: {data}\n\n");
      -        let Ok(mut ls) = self.listeners.lock() else { return 0 };
      -        ls.retain_mut(|s| s.write_all(msg.as_bytes()).and_then(|_| s.flush()).is_ok());
      -        ls.len()
      -    }
      -}
      -
      -fn serve(mut s: TcpStream, code: &str, listeners: &Arc>>) -> std::io::Result<()> {
      -    s.set_read_timeout(Some(Duration::from_secs(10)))?;
      -    let mut r = BufReader::new(s.try_clone()?);
      -    let mut first = String::new();
      -    r.read_line(&mut first)?;
      -    let mut origin = None;
      -    loop {
      -        let mut h = String::new();
      -        if r.read_line(&mut h)? == 0 || h == "\r\n" || h == "\n" {
      -            break;
      -        }
      -        if let Some((k, v)) = h.split_once(':')
      -            && k.eq_ignore_ascii_case("origin")
      -        {
      -            origin = Some(v.trim().to_string());
      -        }
      -    }
      -    let mut parts = first.split_whitespace();
      -    let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or(""));
      -    let allow = |o: &Option| match o {
      -        Some(o) if !o.contains(['\r', '\n']) => format!("Access-Control-Allow-Origin: {o}\r\nVary: Origin\r\n"),
      -        _ => String::new(),
      -    };
      -    if method == "OPTIONS" {
      -        // A page on a public origin asks before it reads a loopback address.
      -        let resp = format!(
      -            "HTTP/1.1 204 No Content\r\n{}Access-Control-Allow-Methods: GET\r\nAccess-Control-Allow-Headers: *\r\nAccess-Control-Allow-Private-Network: true\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
      -            allow(&origin)
      -        );
      -        return s.write_all(resp.as_bytes());
      -    }
      -    let (path, query) = target.split_once('?').unwrap_or((target, ""));
      -    let given = query.split('&').find_map(|kv| kv.strip_prefix("code=")).unwrap_or("");
      -    if method == "GET" && path == "/create/stream" {
      -        if given != code {
      -            let resp = format!("HTTP/1.1 403 Forbidden\r\n{}Content-Length: 0\r\nConnection: close\r\n\r\n", allow(&origin));
      -            return s.write_all(resp.as_bytes());
      -        }
      -        s.set_read_timeout(None)?;
      -        let head = format!(
      -            "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n{}Access-Control-Allow-Private-Network: true\r\nConnection: keep-alive\r\n\r\nevent: hello\ndata: {{}}\n\n",
      -            allow(&origin)
      -        );
      -        s.write_all(head.as_bytes())?;
      -        s.flush()?;
      -        if let Ok(mut l) = listeners.lock() {
      -            l.push(s);
      -        }
      -        return Ok(());
      -    }
      -    let resp = format!("HTTP/1.1 404 Not Found\r\n{}Content-Length: 0\r\nConnection: close\r\n\r\n", allow(&origin));
      -    s.write_all(resp.as_bytes())
      -}
      -
      -/// The tools, as `tools/list` gives them.
      -pub fn tools() -> Value {
      -    let program = json!({ "type": "string", "description": "The app as a program in joule-ui's line language: one widget a line, `root = App([...], \"Title\")` first (see create_guide)." });
      -    json!([
      -        { "name": "create_guide", "description": "The line language and every WAI widget, as a model writing a WAI app is told them, and how to connect the person's Create page to this server's bridge. Read it before writing a program.",
      -          "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false } },
      -        { "name": "create_check", "description": "Check a WAI app program: the widget tree it renders, every error with its line and reason code, and the room it needs. Fix each error at its line and check again.",
      -          "inputSchema": { "type": "object", "properties": { "program": program }, "required": ["program"], "additionalProperties": false } },
      -        { "name": "create_stream", "description": "Stream a program into the person's open Create page (connected to this server's bridge), which renders the app as it arrives and lets them try its room and export it.",
      -          "inputSchema": { "type": "object", "properties": { "program": program, "chunk": { "type": "integer", "minimum": 1, "maximum": 4096, "description": "Characters a chunk (24 by default)." }, "pace_ms": { "type": "integer", "minimum": 0, "maximum": 2000, "description": "Milliseconds between chunks (30 by default)." } }, "required": ["program"], "additionalProperties": false } },
      -        { "name": "create_export", "description": "Write the app's starter project into a new or empty directory: the program, the room service's configuration, the page, the check that is its build, a README and an AGENTS.md. With runtime_dir (a Create runtime: create/ and room/), the runtime is copied under vendor/.",
      -          "inputSchema": { "type": "object", "properties": { "program": program, "dir": { "type": "string", "description": "The project directory: absent or empty." }, "runtime_dir": { "type": "string", "description": "A directory holding the Create runtime (create/ and room/)." } }, "required": ["program", "dir"], "additionalProperties": false } }
      -    ])
      -}
      -
      -fn text_result(text: String, structured: Value, is_error: bool) -> Value {
      -    json!({ "content": [{ "type": "text", "text": text }], "structuredContent": structured, "isError": is_error })
      -}
      -
      -/// Every file under `dir`, by its path from `dir`, sorted, with its SHA-256.
      -pub fn runtime_of(dir: &Path) -> std::io::Result> {
      -    fn walk(base: &Path, d: &Path, out: &mut Vec) -> std::io::Result<()> {
      -        let mut entries: Vec<_> = std::fs::read_dir(d)?.collect::>()?;
      -        entries.sort_by_key(|e| e.file_name());
      -        for e in entries {
      -            let p = e.path();
      -            if p.is_dir() {
      -                walk(base, &p, out)?;
      -            } else {
      -                let rel = p.strip_prefix(base).expect("under base").to_string_lossy().replace('\\', "/");
      -                let sum = Sha256::digest(std::fs::read(&p)?);
      -                out.push(RuntimeFile { path: rel, sha256: sum.iter().map(|b| format!("{b:02x}")).collect() });
      -            }
      -        }
      -        Ok(())
      -    }
      -    let mut out = Vec::new();
      -    for top in ["create", "room"] {
      -        let d = dir.join(top);
      -        if d.is_dir() {
      -            walk(dir, &d, &mut out)?;
      -        }
      -    }
      -    Ok(out)
      -}
      -
      -/// Write the starter project for `program` into `dir` (absent or empty), the
      -/// runtime copied from `runtime_dir` when given. The files written, by path.
      -pub fn export_to(program: &str, dir: &Path, runtime_dir: Option<&Path>) -> Result, String> {
      -    if dir.exists() && std::fs::read_dir(dir).map_err(|e| e.to_string())?.next().is_some() {
      -        return Err(format!("{} is not empty: export writes into a new or empty directory", dir.display()));
      -    }
      -    let runtime = match runtime_dir {
      -        Some(r) => runtime_of(r).map_err(|e| format!("runtime {}: {e}", r.display()))?,
      -        None => Vec::new(),
      -    };
      -    let files = crate::export::export(program, &runtime).map_err(|e| e.to_string())?;
      -    let mut written = Vec::new();
      -    for f in files {
      -        let to = dir.join(&f.path);
      -        if let Some(p) = to.parent() {
      -            std::fs::create_dir_all(p).map_err(|e| e.to_string())?;
      -        }
      -        match &f.contents {
      -            Contents::Text(t) => std::fs::write(&to, t),
      -            Contents::Runtime(from) => std::fs::copy(runtime_dir.expect("runtime files come from it").join(from), &to).map(|_| ()),
      -        }
      -        .map_err(|e| format!("{}: {e}", f.path))?;
      -        written.push(f.path);
      -    }
      -    Ok(written)
      -}
      -
      -/// Answer one JSON-RPC message; `None` for a notification.
      -pub fn handle(msg: &Value, bridge: &Bridge) -> Option {
      -    let id = msg.get("id").cloned()?;
      -    let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
      -    let ok = |result: Value| Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }));
      -    let fail = |code: i64, m: &str| Some(json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": m } }));
      -    match method {
      -        "initialize" => ok(json!({
      -            "protocolVersion": msg.pointer("/params/protocolVersion").and_then(Value::as_str).unwrap_or("2025-06-18"),
      -            "capabilities": { "tools": {} },
      -            "serverInfo": { "name": "wai-ui", "version": env!("CARGO_PKG_VERSION") },
      -            "instructions": "Create builds a WAI app from a description: read create_guide, write the program, check it with create_check, stream it into the person's Create page with create_stream, and write the project with create_export."
      -        })),
      -        "ping" => ok(json!({})),
      -        "tools/list" => ok(json!({ "tools": tools() })),
      -        "tools/call" => {
      -            let name = msg.pointer("/params/name").and_then(Value::as_str).unwrap_or("");
      -            let args = msg.pointer("/params/arguments").cloned().unwrap_or(json!({}));
      -            let program = args.get("program").and_then(Value::as_str);
      -            match name {
      -                "create_guide" => {
      -                    let how = format!(
      -                        "\n\nThe person's Create page reads programs from this server's bridge: in Create, choose \"Your coding agent\" and enter the bridge http://127.0.0.1:{} and the code {}.",
      -                        bridge.port, bridge.code
      -                    );
      -                    ok(text_result(crate::guide::guide() + &how, json!({ "bridge": format!("http://127.0.0.1:{}", bridge.port), "code": bridge.code }), false))
      -                }
      -                "create_check" => {
      -                    let Some(p) = program else { return fail(-32602, "`program` is required") };
      -                    let c = crate::check(p);
      -                    let bad: Vec = c.errors.iter().filter(|e| !e.warning).map(|e| format!("line {}: {}: {}", e.line, e.code, e.message)).collect();
      -                    let text = if bad.is_empty() {
      -                        format!("No error. {} widget(s){}.", c.nodes.len(), c.room.as_ref().map(|r| format!(", a {} room", r.kind)).unwrap_or_default())
      -                    } else {
      -                        bad.join("\n")
      -                    };
      -                    ok(text_result(text, serde_json::to_value(&c).expect("json"), !bad.is_empty()))
      -                }
      -                "create_stream" => {
      -                    let Some(p) = program else { return fail(-32602, "`program` is required") };
      -                    if bridge.listening() == 0 {
      -                        let m = format!("No Create page is connected. Ask the person to open Create, choose \"Your coding agent\", and enter the bridge http://127.0.0.1:{} and the code {}.", bridge.port, bridge.code);
      -                        return ok(text_result(m, json!({ "delivered": 0 }), true));
      -                    }
      -                    let chunk = args.get("chunk").and_then(Value::as_u64).unwrap_or(24).clamp(1, 4096) as usize;
      -                    let pace = args.get("pace_ms").and_then(Value::as_u64).unwrap_or(30).min(2000);
      -                    bridge.send("start", &json!({}));
      -                    let chars: Vec = p.chars().collect();
      -                    let mut n = 0;
      -                    let mut reached = 0;
      -                    for piece in chars.chunks(chunk) {
      -                        reached = bridge.send("chunk", &json!({ "text": piece.iter().collect::() }));
      -                        n += 1;
      -                        if pace > 0 {
      -                            std::thread::sleep(Duration::from_millis(pace));
      -                        }
      -                    }
      -                    bridge.send("end", &json!({}));
      -                    let c = crate::check(p);
      -                    let bad = c.errors.iter().filter(|e| !e.warning).count();
      -                    ok(text_result(format!("Streamed {n} chunk(s) to {reached} page(s); the app has {bad} error(s)."), json!({ "delivered": reached, "chunks": n, "errors": c.errors }), false))
      -                }
      -                "create_export" => {
      -                    let Some(p) = program else { return fail(-32602, "`program` is required") };
      -                    let Some(dir) = args.get("dir").and_then(Value::as_str) else { return fail(-32602, "`dir` is required") };
      -                    let rt = args.get("runtime_dir").and_then(Value::as_str).map(PathBuf::from);
      -                    match export_to(p, Path::new(dir), rt.as_deref()) {
      -                        Ok(files) => ok(text_result(format!("Wrote {} file(s) to {dir}. Run `npm run check` there.", files.len()), json!({ "files": files }), false)),
      -                        Err(e) => ok(text_result(e, json!({ "files": [] }), true)),
      -                    }
      -                }
      -                _ => fail(-32602, &format!("no tool `{name}`")),
      -            }
      -        }
      -        _ => fail(-32601, &format!("no method `{method}`")),
      -    }
      -}
      -
      -/// Run the server over stdin and stdout until stdin closes.
      -pub fn run(port: u16) -> std::io::Result<()> {
      -    let bridge = Bridge::start(port)?;
      -    eprintln!("wai-ui mcp: the bridge for Create pages is http://127.0.0.1:{}, pairing code {}", bridge.port, bridge.code);
      -    let stdin = std::io::stdin();
      -    let mut out = std::io::stdout();
      -    let mut line = String::new();
      -    let mut input = stdin.lock();
      -    loop {
      -        line.clear();
      -        if input.read_line(&mut line)? == 0 {
      -            return Ok(());
      -        }
      -        let msg: Value = match serde_json::from_str(line.trim()) {
      -            Ok(v) => v,
      -            Err(_) => {
      -                writeln!(out, "{}", json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": "parse error" } }))?;
      -                out.flush()?;
      -                continue;
      -            }
      -        };
      -        if let Some(resp) = handle(&msg, &bridge) {
      -            writeln!(out, "{resp}")?;
      -            out.flush()?;
      -        }
      -    }
      -}
      -
      -#[cfg(test)]
      -mod tests {
      -    use super::*;
      -    use std::io::Read;
      -
      -    #[test]
      -    fn the_bridge_refuses_a_page_without_the_code_and_streams_to_one_with_it() {
      -        let b = Bridge::start(0).unwrap();
      -        let get = |path: &str| {
      -            let mut s = TcpStream::connect(("127.0.0.1", b.port)).unwrap();
      -            write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: https://site.example\r\n\r\n").unwrap();
      -            s
      -        };
      -        let mut bad = get("/create/stream?code=wrong");
      -        let mut resp = String::new();
      -        bad.read_to_string(&mut resp).unwrap();
      -        assert!(resp.starts_with("HTTP/1.1 403"), "{resp}");
      -        let good = get(&format!("/create/stream?code={}", b.code));
      -        let mut r = BufReader::new(good);
      -        let mut head = String::new();
      -        r.read_line(&mut head).unwrap();
      -        assert!(head.starts_with("HTTP/1.1 200"), "{head}");
      -        for _ in 0..50 {
      -            if b.listening() == 1 {
      -                break;
      -            }
      -            std::thread::sleep(Duration::from_millis(20));
      -        }
      -        assert_eq!(b.send("chunk", &json!({ "text": "root = App(" })), 1);
      -        let mut seen = String::new();
      -        while !seen.contains("root = App(") {
      -            let mut l = String::new();
      -            r.read_line(&mut l).unwrap();
      -            seen.push_str(&l);
      -        }
      -        assert!(seen.contains("event: chunk"));
      -    }
      -
      -    #[test]
      -    fn the_tools_answer_through_the_protocol() {
      -        let b = Bridge::start(0).unwrap();
      -        let call = |name: &str, args: Value| handle(&json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": name, "arguments": args } }), &b).unwrap();
      -        let init = handle(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }), &b).unwrap();
      -        assert_eq!(init["result"]["serverInfo"]["name"], "wai-ui");
      -        assert!(handle(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), &b).is_none());
      -        let list = handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), &b).unwrap();
      -        assert_eq!(list["result"]["tools"].as_array().unwrap().len(), 4);
      -        let g = call("create_guide", json!({}));
      -        assert!(g["result"]["content"][0]["text"].as_str().unwrap().contains(&b.code));
      -        let c = call("create_check", json!({ "program": "root = App([w])\nw = WatchTogether(\"f.mp4\", [Chat()])\n" }));
      -        assert_eq!(c["result"]["isError"], true);
      -        assert!(c["result"]["content"][0]["text"].as_str().unwrap().contains("line 2: disallowed-child"));
      -        let s = call("create_stream", json!({ "program": crate::guide::EXAMPLE }));
      -        assert_eq!(s["result"]["isError"], true, "no page is connected");
      -        let dir = std::env::temp_dir().join(format!("wai-ui-mcp-{}", b.port));
      -        let _ = std::fs::remove_dir_all(&dir);
      -        let e = call("create_export", json!({ "program": crate::guide::EXAMPLE, "dir": dir.to_string_lossy() }));
      -        assert_eq!(e["result"]["isError"], false, "{e}");
      -        assert!(dir.join("room.toml").exists() && dir.join("AGENTS.md").exists());
      -        let again = call("create_export", json!({ "program": crate::guide::EXAMPLE, "dir": dir.to_string_lossy() }));
      -        assert_eq!(again["result"]["isError"], true, "a directory with files in it is never written over");
      -        let _ = std::fs::remove_dir_all(&dir);
      -    }
      -}
      diff --git a/wai/wai-ui/templates/AGENTS.md b/wai/wai-ui/templates/AGENTS.md
      deleted file mode 100644
      index c90eb913c..000000000
      --- a/wai/wai-ui/templates/AGENTS.md
      +++ /dev/null
      @@ -1,20 +0,0 @@
      -# Working on this project
      -
      -This is a WAI app. `app.oui` is the app, written in joule-ui's line language (SPEC §11 of
      -joule-ui): one widget a line, `name = Widget(arguments)`, `root` first. The page renders
      -it with the WAI widgets in `vendor/create/`, and its room runs on the room service that
      -`room.toml` configures.
      -
      -- To change the app, edit `app.oui`, then run `npm run check`. A line the registry does
      -  not accept is refused with its line and a reason code; fix that line. Keep `app.json`
      -  the tree `app.oui` gives (the check says when it is not).
      -- Keep `room.toml` matching the app's room: a `WatchTogether` needs `[room]
      -  watch_source = "media/"` and `watch_duration_ms`; a `Show` needs `[co]
      -  audience_input` equal to its `audience`; a `Room` needs neither.
      -- Do not edit `vendor/`: it is the runtime, checked file by file against `vendor.json`.
      -- Never put a key, a token or any other secret in this repository. Development keys are
      -  made in `.wai-dev/` by `npm run dev` and stay there.
      -
      -## The language and the widgets
      -
      -{{GUIDE}}
      diff --git a/wai/wai-ui/templates/README.md b/wai/wai-ui/templates/README.md
      deleted file mode 100644
      index 8275ef477..000000000
      --- a/wai/wai-ui/templates/README.md
      +++ /dev/null
      @@ -1,37 +0,0 @@
      -# {{TITLE}}
      -
      -A WAI app made with Create: {{SUMMARY}}
      -
      -It is a plain web project you own. `app.oui` is the app, one widget a line; the WAI
      -widgets that render it, and the room client under them, are in `vendor/`; `room.toml`
      -configures the room service that keeps its room.
      -
      -## Run it
      -
      -You need Node 22 or later, and the WAI room service and relay (`joule-moq-room` and
      -`joule-moq-relay`, built from the open-standards repository) on your `PATH`, or named by
      -`JOULE_MOQ_ROOM` and `JOULE_MOQ_RELAY`.
      -
      -```
      -npm run check   # checks the app, its room configuration and its runtime; writes dist/
      -npm run dev     # relay, room service and the app on http://127.0.0.1:8080/
      -```
      -
      -## What you see
      -
      -{{WHAT}} The page's receipts show every action you take there, sealed into a log the
      -page signs and checks.
      -
      -## Change it
      -
      -Edit `app.oui` (the widgets and their signatures are in `AGENTS.md`), then run
      -`npm run check`; it refuses a line the registry does not accept, naming the line and the
      -reason. Update `app.json` from the check's output, or export again from Create.
      -
      -## Development and production
      -
      -Everything `npm run dev` starts listens on 127.0.0.1 alone, and its keys are made in
      -`.wai-dev/`, which is never committed. In production, the room service runs with keys of
      -its own, kept outside the repository, behind a front that serves `dist/` with TLS and
      -passes `/room-api/` to the room service and `/moq` to the relay on the same origin. No
      -model key or other secret is part of this project.
      diff --git a/wai/wai-ui/templates/check.mjs b/wai/wai-ui/templates/check.mjs
      deleted file mode 100644
      index e5d239c88..000000000
      --- a/wai/wai-ui/templates/check.mjs
      +++ /dev/null
      @@ -1,109 +0,0 @@
      -#!/usr/bin/env node
      -// The project's build: check everything the app is made of, then write dist/.
      -//
      -//   node check.mjs
      -//
      -// 1. Every runtime file under vendor/ is present, with the SHA-256 vendor.json names.
      -// 2. app.oui parses, with the runtime's own parser (its WebAssembly build), into the tree in
      -//    app.json, every widget accepted by the WAI registry, with no error and one room.
      -// 3. room.toml is present and configures the room the app holds: a watch room's source
      -//    (the file present under media/, its duration set), a show's audience inputs, a live
      -//    room neither.
      -// 4. dist/ holds what a front serves: index.html, app.oui, app.json, vendor/ (and media/).
      -//
      -// Exit status 0 when all of it holds; 1, with every failure named, otherwise.
      -import fs from "node:fs";
      -import path from "node:path";
      -import crypto from "node:crypto";
      -import { fileURLToPath, pathToFileURL } from "node:url";
      -
      -const HERE = path.dirname(fileURLToPath(import.meta.url));
      -const failures = [];
      -const fail = (m) => failures.push(m);
      -const read = (p) => fs.readFileSync(path.join(HERE, p));
      -
      -// 1. The runtime.
      -let vendor = { files: [] };
      -try {
      -  vendor = JSON.parse(read("vendor.json"));
      -} catch (e) {
      -  fail(`vendor.json: ${e.message}`);
      -}
      -for (const f of vendor.files) {
      -  const p = path.join("vendor", f.path);
      -  if (!fs.existsSync(path.join(HERE, p))) {
      -    fail(`${p} is missing (vendor.json names it)`);
      -    continue;
      -  }
      -  const sum = crypto.createHash("sha256").update(read(p)).digest("hex");
      -  if (sum !== f.sha256) fail(`${p}: its SHA-256 is ${sum}, vendor.json says ${f.sha256}`);
      -}
      -
      -// 2. The program, by the runtime's parser.
      -let checked = null;
      -try {
      -  const wasm = path.join(HERE, "vendor/create/pkg/wai_ui_bg.wasm");
      -  const ui = await import(pathToFileURL(path.join(HERE, "vendor/create/pkg/wai_ui.js")).href);
      -  ui.initSync({ module: fs.readFileSync(wasm) });
      -  checked = JSON.parse(ui.check(read("app.oui").toString()));
      -} catch (e) {
      -  fail(`app.oui could not be checked: ${e.message}`);
      -}
      -if (checked) {
      -  for (const e of checked.errors.filter((x) => !x.warning)) fail(`app.oui line ${e.line}: ${e.code}: ${e.message}`);
      -  let tree = null;
      -  try {
      -    tree = JSON.parse(read("app.json"));
      -  } catch (e) {
      -    fail(`app.json: ${e.message}`);
      -  }
      -  if (tree && JSON.stringify(checked.tree) !== JSON.stringify(tree)) fail("app.json is not the tree app.oui gives: run the export again, or update app.json from app.oui");
      -  if (!checked.room) fail("app.oui holds no room (a Room, a WatchTogether or a Show)");
      -}
      -
      -// 3. The room service's configuration matches the room.
      -const toml = fs.existsSync(path.join(HERE, "room.toml")) ? read("room.toml").toString() : null;
      -if (toml === null) fail("room.toml is missing: the room service has no configuration for this app's room");
      -const setting = (section, key) => {
      -  if (!toml) return undefined;
      -  let cur = "";
      -  for (const line of toml.split("\n")) {
      -    const s = line.replace(/#.*$/, "").trim();
      -    const h = s.match(/^\[([^\]]+)\]$/);
      -    if (h) { cur = h[1]; continue; }
      -    const kv = s.match(/^([A-Za-z_]+)\s*=\s*(.+)$/);
      -    if (kv && cur === section && kv[1] === key) return kv[2].trim().replace(/^"(.*)"$/, "$1");
      -  }
      -  return undefined;
      -};
      -if (toml !== null && checked && checked.room) {
      -  const room = checked.room;
      -  const source = setting("room", "watch_source") || "";
      -  const audience = Number(setting("co", "audience_input") || 0);
      -  if (room.kind === "watch") {
      -    const want = `media/${room.source}`;
      -    if (source !== want) fail(`room.toml: [room] watch_source is "${source}"; the app watches "${want}"`);
      -    else if (!fs.existsSync(path.join(HERE, want))) fail(`${want} is missing: put the file the room watches there`);
      -    if (!(Number(setting("room", "watch_duration_ms") || 0) > 0)) fail("room.toml: set [room] watch_duration_ms to the file's length in milliseconds");
      -  } else if (source) fail(`room.toml: [room] watch_source is set, and the app's room (${room.kind}) watches no file`);
      -  if (room.kind === "show" && audience !== room.audience) fail(`room.toml: [co] audience_input is ${audience}; the app's show takes ${room.audience}`);
      -  if (room.kind !== "show" && audience > 0) fail(`room.toml: [co] audience_input is ${audience}, and the app's room (${room.kind}) is not a show`);
      -  for (const k of ["listen", "keys", "relay_url"]) if (!setting("", k)) fail(`room.toml: \`${k}\` is not set`);
      -}
      -
      -if (failures.length) {
      -  for (const f of failures) console.error(`check: ${f}`);
      -  console.error(`check: ${failures.length} failure(s)`);
      -  process.exit(1);
      -}
      -
      -// 4. dist/.
      -const DIST = path.join(HERE, "dist");
      -fs.rmSync(DIST, { recursive: true, force: true });
      -const copy = (rel) => {
      -  const from = path.join(HERE, rel);
      -  if (!fs.existsSync(from)) return;
      -  fs.cpSync(from, path.join(DIST, rel), { recursive: true });
      -};
      -for (const rel of ["index.html", "app.oui", "app.json", "vendor", "media"]) copy(rel);
      -console.log(`check: app.oui gives ${checked.nodes.length} widget(s) in a ${checked.room.kind} room; room.toml matches; ${vendor.files.length} runtime file(s) verified; dist/ written`);
      diff --git a/wai/wai-ui/templates/gitignore b/wai/wai-ui/templates/gitignore
      deleted file mode 100644
      index 65c707e79..000000000
      --- a/wai/wai-ui/templates/gitignore
      +++ /dev/null
      @@ -1,4 +0,0 @@
      -# Development keys, logs and generated configuration (node serve.mjs --up): never committed.
      -.wai-dev/
      -dist/
      -node_modules/
      diff --git a/wai/wai-ui/templates/package.json b/wai/wai-ui/templates/package.json
      deleted file mode 100644
      index 05d8cf53f..000000000
      --- a/wai/wai-ui/templates/package.json
      +++ /dev/null
      @@ -1,15 +0,0 @@
      -{
      -  "name": "{{PACKAGE}}",
      -  "private": true,
      -  "version": "0.1.0",
      -  "description": "{{TITLE}}: a WAI app made with Create.",
      -  "license": "Apache-2.0",
      -  "type": "module",
      -  "engines": { "node": ">=22" },
      -  "scripts": {
      -    "check": "node check.mjs",
      -    "build": "node check.mjs",
      -    "dev": "node serve.mjs --up --source",
      -    "start": "node serve.mjs"
      -  }
      -}
      diff --git a/wai/wai-ui/templates/serve.mjs b/wai/wai-ui/templates/serve.mjs
      deleted file mode 100644
      index b7b42c9ea..000000000
      --- a/wai/wai-ui/templates/serve.mjs
      +++ /dev/null
      @@ -1,128 +0,0 @@
      -#!/usr/bin/env node
      -// Development only: serve this app on loopback, the room service and the relay behind the
      -// same origin, as a production front would (the page refuses endpoints on another origin).
      -//
      -//   node serve.mjs          the app at http://127.0.0.1:8080/, /room-api/* proxied to the room
      -//                           service ($ROOM_API, http://127.0.0.1:9682) and /moq to the relay
      -//                           ($RELAY_PORT, 9681), which you run yourself
      -//   node serve.mjs --up     the same, and it starts both: joule-moq-relay and joule-moq-room
      -//                           (from $JOULE_MOQ_RELAY and $JOULE_MOQ_ROOM, else from your PATH),
      -//                           their keys made in .wai-dev/ the first time (never committed)
      -//
      -// Environment: PORT (the front, 8080), ROOM_PORT (9682), RELAY_PORT (9681). Everything listens
      -// on 127.0.0.1 alone. Nothing here is for production: see README.md.
      -import http from "node:http";
      -import net from "node:net";
      -import fs from "node:fs";
      -import path from "node:path";
      -import { spawn, execFileSync } from "node:child_process";
      -import { fileURLToPath } from "node:url";
      -
      -const HERE = path.dirname(fileURLToPath(import.meta.url));
      -const PORT = Number(process.env.PORT || 8080);
      -const ROOM_PORT = Number(process.env.ROOM_PORT || 9682);
      -const RELAY_PORT = Number(process.env.RELAY_PORT || 9681);
      -const UP = process.argv.includes("--up");
      -const DEV = path.join(HERE, ".wai-dev");
      -const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".wasm": "application/wasm", ".json": "application/json", ".css": "text/css", ".oui": "text/plain; charset=utf-8", ".mp4": "video/mp4", ".webm": "video/webm", ".wav": "audio/wav" };
      -
      -const children = [];
      -function bin(envName, name) {
      -  return process.env[envName] || name;
      -}
      -function waitFor(what, ms, f) {
      -  const end = Date.now() + ms;
      -  return new Promise((ok, fail) => {
      -    const tick = async () => {
      -      try { if (await f()) return ok(); } catch { /* not yet */ }
      -      if (Date.now() > end) return fail(new Error(`${what}: not within ${ms} ms`));
      -      setTimeout(tick, 200);
      -    };
      -    tick();
      -  });
      -}
      -
      -async function up() {
      -  fs.mkdirSync(DEV, { recursive: true, mode: 0o700 });
      -  const keys = path.join(DEV, "room-keys.toml");
      -  const linesFile = path.join(DEV, "relay-lines.toml");
      -  if (!fs.existsSync(keys)) {
      -    const lines = execFileSync(bin("JOULE_MOQ_ROOM", "joule-moq-room"), ["keygen", keys], { stdio: ["ignore", "pipe", "inherit"] }).toString();
      -    fs.writeFileSync(linesFile, lines, { mode: 0o600 });
      -  }
      -  const lines = fs.readFileSync(linesFile, "utf8");
      -  const clock = (lines.match(/^# clock\s+([0-9a-f]{64})/m) || [])[1];
      -  const roomToml = fs.readFileSync(path.join(HERE, "room.toml"), "utf8");
      -  const show = /^\[co\][^[]*^audience_input\s*=\s*[1-9]/m.test(roomToml);
      -  let relay = fs.readFileSync(path.join(HERE, "relay.toml"), "utf8").replace(/^websocket_listen = .*$/m, `websocket_listen = "127.0.0.1:${RELAY_PORT}"`).replace(/^out_path = .*$/m, `out_path = ${JSON.stringify(path.join(DEV, "summaries.jsonl"))}`);
      -  relay += `\n${lines}\n`;
      -  // A show's relay holds the floor and the audience's inputs, honouring the room's clock.
      -  if (show && clock) relay += `\n[floor]\nroom_keys = ["${clock}"]\n`;
      -  fs.writeFileSync(path.join(DEV, "relay.toml"), relay);
      -  const room = roomToml
      -    .replace(/^listen = .*$/m, `listen = "127.0.0.1:${ROOM_PORT}"`)
      -    .replace(/^keys = .*$/m, `keys = ${JSON.stringify(keys)}`)
      -    .replace(/^relay_url = .*$/m, `relay_url = "ws://127.0.0.1:${RELAY_PORT}/moq"`)
      -    .replace(/^watch_source = "(.+)"$/m, (_, p) => `watch_source = ${JSON.stringify(path.join(HERE, p))}`);
      -  fs.writeFileSync(path.join(DEV, "room.toml"), room);
      -  const run = (b, args, name) => {
      -    const log = fs.openSync(path.join(DEV, `${name}.log`), "w");
      -    const p = spawn(b, args, { stdio: ["ignore", log, log], env: { ...process.env, NO_COLOR: "1" } });
      -    p.on("exit", (code) => { if (!stopping) console.error(`${name} exited (${code}); see .wai-dev/${name}.log`); });
      -    children.push(p);
      -    return p;
      -  };
      -  run(bin("JOULE_MOQ_RELAY", "joule-moq-relay"), ["--config", path.join(DEV, "relay.toml")], "relay");
      -  await waitFor("the relay", 30000, () => new Promise((ok) => { const s = net.connect(RELAY_PORT, "127.0.0.1", () => { s.end(); ok(true); }); s.on("error", () => ok(false)); }));
      -  run(bin("JOULE_MOQ_ROOM", "joule-moq-room"), ["--config", path.join(DEV, "room.toml")], "room");
      -  await waitFor("the room service on the relay", 30000, async () => { const r = await fetch(`http://127.0.0.1:${ROOM_PORT}/health`); return r.ok && (await r.json()).relay === true; });
      -}
      -
      -let stopping = false;
      -function stop() {
      -  stopping = true;
      -  for (const c of children) if (c.exitCode == null) c.kill("SIGTERM");
      -  process.exit(0);
      -}
      -process.on("SIGINT", stop);
      -process.on("SIGTERM", stop);
      -
      -if (UP) await up();
      -
      -const ROOT = fs.existsSync(path.join(HERE, "dist", "index.html")) && !process.argv.includes("--source") ? path.join(HERE, "dist") : HERE;
      -const server = http.createServer((req, res) => {
      -  const u = new URL(req.url, "http://127.0.0.1");
      -  if (u.pathname.startsWith("/room-api/")) {
      -    const up = http.request({ host: "127.0.0.1", port: ROOM_PORT, method: req.method, path: req.url.slice("/room-api".length), headers: req.headers }, (r) => {
      -      res.writeHead(r.statusCode, r.headers);
      -      r.pipe(res);
      -    });
      -    up.on("error", () => { res.writeHead(502); res.end(); });
      -    req.pipe(up);
      -    return;
      -  }
      -  let file = path.normalize(path.join(ROOT, decodeURIComponent(u.pathname)));
      -  if (!file.startsWith(ROOT) || file.includes(`${path.sep}.wai-dev`)) { res.writeHead(403); return res.end(); }
      -  if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
      -  fs.readFile(file, (err, b) => {
      -    if (err) { res.writeHead(404); return res.end("not found"); }
      -    res.writeHead(200, { "content-type": MIME[path.extname(file)] || "application/octet-stream", "cache-control": "no-store" });
      -    res.end(b);
      -  });
      -});
      -// The relay's WebSocket binding at /moq, its request passed as it came (the page's proof
      -// covers the URL it addressed).
      -server.on("upgrade", (req, socket, head) => {
      -  if (!req.url.startsWith("/moq")) return socket.destroy();
      -  const up = net.connect(RELAY_PORT, "127.0.0.1", () => {
      -    let raw = `${req.method} ${req.url} HTTP/1.1\r\n`;
      -    for (let i = 0; i < req.rawHeaders.length; i += 2) raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`;
      -    up.write(raw + "\r\n");
      -    if (head && head.length) up.write(head);
      -    up.pipe(socket);
      -    socket.pipe(up);
      -  });
      -  up.on("error", () => socket.destroy());
      -  socket.on("error", () => up.destroy());
      -});
      -server.listen(PORT, "127.0.0.1", () => console.log(`the app at http://127.0.0.1:${server.address().port}/${UP ? " (relay and room service up)" : ""}`));
      diff --git a/wai/wai-ui/tests/mcp.rs b/wai/wai-ui/tests/mcp.rs
      deleted file mode 100644
      index 514261b49..000000000
      --- a/wai/wai-ui/tests/mcp.rs
      +++ /dev/null
      @@ -1,91 +0,0 @@
      -//! `wai-ui mcp`, the binary, through the protocol over its stdio: a client initializes,
      -//! lists the tools, checks a program, and streams one to a Create page connected to the
      -//! bridge, which reads it chunk by chunk.
      -
      -use std::io::{BufRead, BufReader, Read, Write};
      -use std::net::TcpStream;
      -use std::process::{Command, Stdio};
      -use std::time::Duration;
      -
      -use serde_json::{json, Value};
      -
      -#[test]
      -fn an_agent_drives_create_through_the_mcp_server() {
      -    let mut child = Command::new(env!("CARGO_BIN_EXE_wai-ui"))
      -        .arg("mcp")
      -        .stdin(Stdio::piped())
      -        .stdout(Stdio::piped())
      -        .stderr(Stdio::piped())
      -        .spawn()
      -        .expect("wai-ui mcp starts");
      -    let mut stdin = child.stdin.take().unwrap();
      -    let mut out = BufReader::new(child.stdout.take().unwrap());
      -    let mut err = BufReader::new(child.stderr.take().unwrap());
      -    // The bridge's address and pairing code, as the server says them at start.
      -    let mut first = String::new();
      -    err.read_line(&mut first).unwrap();
      -    let port: u16 = first.split("http://127.0.0.1:").nth(1).and_then(|s| s.split(',').next()).and_then(|s| s.parse().ok()).expect(&first);
      -    let code = first.trim().rsplit(' ').next().unwrap().to_string();
      -
      -    let mut id = 0;
      -    let mut call = |method: &str, params: Value| -> Value {
      -        id += 1;
      -        writeln!(stdin, "{}", json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })).unwrap();
      -        stdin.flush().unwrap();
      -        let mut line = String::new();
      -        out.read_line(&mut line).unwrap();
      -        let v: Value = serde_json::from_str(&line).unwrap();
      -        assert_eq!(v["id"], id);
      -        v
      -    };
      -
      -    let init = call("initialize", json!({ "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } }));
      -    assert_eq!(init["result"]["capabilities"]["tools"], json!({}));
      -    let tools = call("tools/list", json!({}));
      -    let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
      -    assert_eq!(names, vec!["create_guide", "create_check", "create_stream", "create_export"]);
      -
      -    let checked = call("tools/call", json!({ "name": "create_check", "arguments": { "program": wai_ui::guide::EXAMPLE } }));
      -    assert_eq!(checked["result"]["isError"], false, "{checked}");
      -    assert_eq!(checked["result"]["structuredContent"]["room"]["kind"], "show");
      -
      -    // A Create page connects to the bridge with the code ...
      -    let mut page = TcpStream::connect(("127.0.0.1", port)).unwrap();
      -    write!(page, "GET /create/stream?code={code} HTTP/1.1\r\nHost: 127.0.0.1\r\nOrigin: https://site.example\r\n\r\n").unwrap();
      -    page.set_read_timeout(Some(Duration::from_secs(20))).unwrap();
      -    let mut events = BufReader::new(page);
      -    let mut status = String::new();
      -    events.read_line(&mut status).unwrap();
      -    assert!(status.starts_with("HTTP/1.1 200"), "{status}");
      -    std::thread::sleep(Duration::from_millis(200));
      -
      -    // ... and the agent streams the program into it.
      -    let streamed = call("tools/call", json!({ "name": "create_stream", "arguments": { "program": wai_ui::guide::EXAMPLE, "chunk": 16, "pace_ms": 0 } }));
      -    assert_eq!(streamed["result"]["isError"], false, "{streamed}");
      -    assert_eq!(streamed["result"]["structuredContent"]["delivered"], 1);
      -    let mut got = String::new();
      -    let mut chunks = 0;
      -    loop {
      -        let mut l = String::new();
      -        if events.read_line(&mut l).unwrap() == 0 {
      -            break;
      -        }
      -        if let Some(d) = l.strip_prefix("data: ") {
      -            let v: Value = serde_json::from_str(d.trim()).unwrap();
      -            if let Some(t) = v["text"].as_str() {
      -                got.push_str(t);
      -                chunks += 1;
      -            }
      -        }
      -        if l.starts_with("event: end") {
      -            break;
      -        }
      -    }
      -    assert_eq!(got, wai_ui::guide::EXAMPLE);
      -    assert!(chunks > 5, "{chunks} chunks");
      -
      -    drop(stdin);
      -    let mut rest = String::new();
      -    let _ = err.read_to_string(&mut rest);
      -    assert!(child.wait().unwrap().success());
      -}
      diff --git a/wai/wai-ui/web/zip.js b/wai/wai-ui/web/zip.js
      deleted file mode 100644
      index 3b11a5634..000000000
      --- a/wai/wai-ui/web/zip.js
      +++ /dev/null
      @@ -1,103 +0,0 @@
      -// A ZIP archive of stored (uncompressed) files, for the export a person downloads: local
      -// headers, the central directory and its end record (APPNOTE 6.3.10, store method 0), names
      -// in UTF-8 (general purpose bit 11), every time stamp 1980-01-01 00:00 so that one project
      -// always gives the same bytes.
      -
      -const TABLE = (() => {
      -  const t = new Uint32Array(256);
      -  for (let n = 0; n < 256; n++) {
      -    let c = n;
      -    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
      -    t[n] = c >>> 0;
      -  }
      -  return t;
      -})();
      -
      -export function crc32(bytes) {
      -  let c = 0xffffffff;
      -  for (let i = 0; i < bytes.length; i++) c = TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
      -  return (c ^ 0xffffffff) >>> 0;
      -}
      -
      -const DATE = (0 << 9) | (1 << 5) | 1; // 1980-01-01
      -const TIME = 0;
      -
      -// `files`: [{ path, bytes: Uint8Array }]. Returns the archive's bytes.
      -export function zip(files) {
      -  const enc = new TextEncoder();
      -  const parts = [];
      -  const central = [];
      -  let offset = 0;
      -  for (const f of files) {
      -    const name = enc.encode(f.path);
      -    const crc = crc32(f.bytes);
      -    const local = new DataView(new ArrayBuffer(30));
      -    local.setUint32(0, 0x04034b50, true);
      -    local.setUint16(4, 20, true); // version needed
      -    local.setUint16(6, 0x0800, true); // UTF-8 names
      -    local.setUint16(8, 0, true); // stored
      -    local.setUint16(10, TIME, true);
      -    local.setUint16(12, DATE, true);
      -    local.setUint32(14, crc, true);
      -    local.setUint32(18, f.bytes.length, true);
      -    local.setUint32(22, f.bytes.length, true);
      -    local.setUint16(26, name.length, true);
      -    local.setUint16(28, 0, true);
      -    parts.push(new Uint8Array(local.buffer), name, f.bytes);
      -    const c = new DataView(new ArrayBuffer(46));
      -    c.setUint32(0, 0x02014b50, true);
      -    c.setUint16(4, 0x0314, true); // made by: version 2.0, unix
      -    c.setUint16(6, 20, true);
      -    c.setUint16(8, 0x0800, true);
      -    c.setUint16(10, 0, true);
      -    c.setUint16(12, TIME, true);
      -    c.setUint16(14, DATE, true);
      -    c.setUint32(16, crc, true);
      -    c.setUint32(20, f.bytes.length, true);
      -    c.setUint32(24, f.bytes.length, true);
      -    c.setUint16(28, name.length, true);
      -    c.setUint16(30, 0, true);
      -    c.setUint16(32, 0, true);
      -    c.setUint16(34, 0, true);
      -    c.setUint16(36, 0, true);
      -    c.setUint32(38, (0o100644 << 16) >>> 0, true); // a regular file, rw-r--r--
      -    c.setUint32(42, offset, true);
      -    central.push(new Uint8Array(c.buffer), name);
      -    offset += 30 + name.length + f.bytes.length;
      -  }
      -  const cdSize = central.reduce((n, p) => n + p.length, 0);
      -  const end = new DataView(new ArrayBuffer(22));
      -  end.setUint32(0, 0x06054b50, true);
      -  end.setUint16(8, files.length, true);
      -  end.setUint16(10, files.length, true);
      -  end.setUint32(12, cdSize, true);
      -  end.setUint32(16, offset, true);
      -  const all = [...parts, ...central, new Uint8Array(end.buffer)];
      -  const out = new Uint8Array(all.reduce((n, p) => n + p.length, 0));
      -  let at = 0;
      -  for (const p of all) {
      -    out.set(p, at);
      -    at += p.length;
      -  }
      -  return out;
      -}
      -
      -// Read back an archive this module wrote (stored entries): [{ path, bytes }].
      -export function unzip(bytes) {
      -  const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
      -  const dec = new TextDecoder();
      -  const out = [];
      -  let at = 0;
      -  while (at + 4 <= bytes.length && v.getUint32(at, true) === 0x04034b50) {
      -    const size = v.getUint32(at + 18, true);
      -    const n = v.getUint16(at + 26, true);
      -    const x = v.getUint16(at + 28, true);
      -    const path = dec.decode(bytes.subarray(at + 30, at + 30 + n));
      -    const start = at + 30 + n + x;
      -    const data = bytes.slice(start, start + size);
      -    if (crc32(data) !== v.getUint32(at + 14, true)) throw new Error(`${path}: CRC-32 does not match`);
      -    out.push({ path, bytes: data });
      -    at = start + size;
      -  }
      -  return out;
      -}
      -- 
      2.54.0 (Apple Git-157)
      
      
      From f48695155df3ef3a3dc16eaf932f87b752b2278e Mon Sep 17 00:00:00 2001
      From: Transaction Science 
      Date: Tue, 27 Oct 2026 05:14:02 -0400
      Subject: [PATCH 11/14] wai: Create through the one toolchain (create --from
       app.oui, app guide|check, mcp --toolset app, dev --show)
      
      The feature `app` links wai-app (with `mcp`):
      
      - `wai create  --from app.oui [--runtime DIR]` (app.export): the app's files
        from wai_app::export, its runtime copied from a Create runtime (the flag,
        $WAI_APP_RUNTIME, or share/wai/app beside wai) and checked against its listing,
        and its wai.json from project::render, the one writer every project file now
        has (`minimal` is a call of it, and a test holds it to three templates' own
        files byte for byte). The project file names the program (`app`) and the room
        `wai dev` runs (`dev.mode` room, watch or show; `dev.watch`; `dev.audience`).
      - Every project `wai create` writes, from a template or an app, carries LICENSE
        (Apache-2.0, wai/LICENSE held in the binary); `--list --json` lists it, and each
        example's README names the licence.
      - `wai app guide` and `wai app check [ | ] [--json]` (app.guide,
        app.check): the program against the registry, and in a project the room wai.json
        names against the app's own and vendor/ against vendor.json; exit 1 with each
        failure named, the JSON envelope off a terminal.
      - `wai mcp --toolset app`: app_guide, app_check, app_stream and app_export, a bridge
        on 127.0.0.1 started when the toolset is served and not read-only;
        setup-agent's --toolset takes app.
      - project.rs reads `app` and `dev.audience` (a show's alone, 1 to 255) and the mode
        show. `wai dev --show [--audience N]`: the room's [co] block, and the relay's
        [floor] holding the floor to the room's clock (its key read from the relay
        lines). The front refuses any path segment that starts with a dot, so a
        project's .wai/, where the room's keys are, is never served; .oui is served as
        text.
      - tests/cli_app.rs (required-features app); the lock gains wai-app and the
        joule-ui crates it builds on, additions only.
      
      Co-Authored-By: Claude Opus 5.5 
      ---
       wai/examples/android-app/README.md         |   2 +
       wai/examples/apple-app/README.md           |   2 +
       wai/examples/encoder-ingest/README.md      |   2 +
       wai/examples/messaging/README.md           |   2 +
       wai/examples/package-and-deliver/README.md |   2 +
       wai/examples/remote-desktop/README.md      |   2 +
       wai/examples/verify-receipts/README.md     |   2 +
       wai/examples/video-room/README.md          |   2 +
       wai/examples/watch-together/README.md      |   2 +
       wai/wai-rs/Cargo.lock                      |  45 +++
       wai/wai-rs/Cargo.toml                      |  15 +
       wai/wai-rs/src/bin/wai_cli/dx/app.rs       | 169 +++++++++++
       wai/wai-rs/src/bin/wai_cli/dx/create.rs    | 133 ++++++++-
       wai/wai-rs/src/bin/wai_cli/dx/dev.rs       |  61 +++-
       wai/wai-rs/src/bin/wai_cli/dx/mod.rs       |   9 +-
       wai/wai-rs/src/bin/wai_cli/dx/project.rs   |  76 ++++-
       wai/wai-rs/src/bin/wai_cli/mcp/app.rs      |  64 ++++
       wai/wai-rs/src/bin/wai_cli/mcp/mod.rs      |  37 ++-
       wai/wai-rs/src/bin/wai_cli/mcp/tools.rs    |  60 +++-
       wai/wai-rs/src/bin/wai_cli/setup_agent.rs  |   8 +-
       wai/wai-rs/tests/cli_app.rs                | 330 +++++++++++++++++++++
       21 files changed, 981 insertions(+), 44 deletions(-)
       create mode 100644 wai/wai-rs/src/bin/wai_cli/dx/app.rs
       create mode 100644 wai/wai-rs/src/bin/wai_cli/mcp/app.rs
       create mode 100644 wai/wai-rs/tests/cli_app.rs
      
      diff --git a/wai/examples/android-app/README.md b/wai/examples/android-app/README.md
      index b6aeaf8f2..58d2d1603 100644
      --- a/wai/examples/android-app/README.md
      +++ b/wai/examples/android-app/README.md
      @@ -27,3 +27,5 @@ README covers it.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/apple-app/README.md b/wai/examples/apple-app/README.md
      index 0e79c9a6f..e2d842232 100644
      --- a/wai/examples/apple-app/README.md
      +++ b/wai/examples/apple-app/README.md
      @@ -29,3 +29,5 @@ that the bundle verifies. The player opens a window: give it an HLS address (the
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/encoder-ingest/README.md b/wai/examples/encoder-ingest/README.md
      index a00c0f2b5..4c86cb8b9 100644
      --- a/wai/examples/encoder-ingest/README.md
      +++ b/wai/examples/encoder-ingest/README.md
      @@ -28,3 +28,5 @@ first build fetches the repository and compiles the relay and the gateway.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/messaging/README.md b/wai/examples/messaging/README.md
      index 8682bb057..04838080c 100644
      --- a/wai/examples/messaging/README.md
      +++ b/wai/examples/messaging/README.md
      @@ -29,3 +29,5 @@ first tab's inbox rings, and answering opens the call, camera and microphone on
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/package-and-deliver/README.md b/wai/examples/package-and-deliver/README.md
      index 918dc2502..7c1579cc9 100644
      --- a/wai/examples/package-and-deliver/README.md
      +++ b/wai/examples/package-and-deliver/README.md
      @@ -27,3 +27,5 @@ prints its commands, live packaging among them.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/remote-desktop/README.md b/wai/examples/remote-desktop/README.md
      index f77a6bfb6..30a8d1d50 100644
      --- a/wai/examples/remote-desktop/README.md
      +++ b/wai/examples/remote-desktop/README.md
      @@ -29,3 +29,5 @@ session's records; the independent verifier in the WAI repository checks them:
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/verify-receipts/README.md b/wai/examples/verify-receipts/README.md
      index 873e5ac15..9873d0609 100644
      --- a/wai/examples/verify-receipts/README.md
      +++ b/wai/examples/verify-receipts/README.md
      @@ -27,3 +27,5 @@ command line.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/video-room/README.md b/wai/examples/video-room/README.md
      index aedfee725..77d03cad4 100644
      --- a/wai/examples/video-room/README.md
      +++ b/wai/examples/video-room/README.md
      @@ -23,3 +23,5 @@ origin; the API is described at https://wai.transaction.science/docs/api/.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/examples/watch-together/README.md b/wai/examples/watch-together/README.md
      index 4ced6afb3..aaaa7fc95 100644
      --- a/wai/examples/watch-together/README.md
      +++ b/wai/examples/watch-together/README.md
      @@ -26,3 +26,5 @@ but a WAV file. Or run `wai dev --watch film.webm --watch-ms 90000`.
       
       **With a coding assistant.** Run `wai setup-agent` in this directory: it writes the
       instructions an assistant reads and connects it to WAI's tools.
      +
      +**Licence.** Apache-2.0: `wai create` writes the licence beside the project, as `LICENSE`.
      diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock
      index ea5291eb0..90c95e700 100644
      --- a/wai/wai-rs/Cargo.lock
      +++ b/wai/wai-rs/Cargo.lock
      @@ -2092,6 +2092,36 @@ dependencies = [
        "libc",
       ]
       
      +[[package]]
      +name = "joule-ui-core"
      +version = "0.1.0"
      +dependencies = [
      + "serde",
      + "serde_json",
      + "thiserror 2.0.20",
      +]
      +
      +[[package]]
      +name = "joule-ui-interaction"
      +version = "0.1.0"
      +dependencies = [
      + "blake3",
      + "ed25519-dalek",
      + "serde",
      + "serde_json",
      + "thiserror 2.0.20",
      +]
      +
      +[[package]]
      +name = "joule-ui-lang"
      +version = "0.1.0"
      +dependencies = [
      + "joule-ui-core",
      + "serde",
      + "serde_json",
      + "sha2 0.10.9",
      +]
      +
       [[package]]
       name = "jpeg-encoder"
       version = "0.7.1"
      @@ -4520,6 +4550,7 @@ dependencies = [
        "serde",
        "serde_json",
        "sha2 0.10.9",
      + "wai-app",
        "wai-ops",
        "wai-ops-gpu",
        "wai-quantum",
      @@ -4528,6 +4559,20 @@ dependencies = [
        "zstd-safe",
       ]
       
      +[[package]]
      +name = "wai-app"
      +version = "0.1.0"
      +dependencies = [
      + "bs58",
      + "ed25519-dalek",
      + "joule-ui-core",
      + "joule-ui-interaction",
      + "joule-ui-lang",
      + "serde",
      + "serde_json",
      + "sha2 0.10.9",
      +]
      +
       [[package]]
       name = "wai-ops"
       version = "0.1.0"
      diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml
      index 58e886347..866b29228 100644
      --- a/wai/wai-rs/Cargo.toml
      +++ b/wai/wai-rs/Cargo.toml
      @@ -116,6 +116,9 @@ getrandom = { version = "0.3", optional = true }
       # The quantum stack now lives in its own dependency-light crate so it can be used
       # without the media/codec chain. `wai` re-exports it unchanged.
       wai-quantum = { version = "0.6.0", path = "../wai-quantum", optional = true, default-features = false }
      +# Create's library (WAI's widgets for joule-ui): `wai create --from app.oui`, `wai app` and
      +# `wai mcp --toolset app`, in a build with the feature `app`.
      +wai-app = { path = "../wai-app", optional = true }
       
       # ---- the integer operator set (behind `neural_int`) ----
       # `wai-ops/1` (extensions/integer-operators.md): an integer decoder that decodes
      @@ -714,6 +717,11 @@ cli_tls = ["http", "key_discovery", "dep:rustls", "dep:rustls-platform-verifier"
       # development room's test principals. It opens no connection but to a room service on a loopback
       # address; no new dependency.
       mcp = ["receipt_claims", "live_room", "stream_audit", "ed25519"]
      +# Apps made with Create (wai/wai-app, a library): `wai create  --from app.oui` (the project,
      +# its project file written as a template's is), `wai app guide|check`, and `wai mcp`'s `app`
      +# toolset (the guide, a program checked, streamed into a person's open Create page through a
      +# bridge on 127.0.0.1, exported). Built into the binary only.
      +app = ["mcp", "dep:wai-app"]
       # Emit a REAL C2PA manifest (not just the assertion shapes). Off by default:
       # the SDK is a large dependency chain and most WAI consumers only need the
       # binding types, which `provenance` already gives them.
      @@ -1505,6 +1513,13 @@ name = "cli_mcp"
       path = "tests/cli_mcp.rs"
       required-features = ["mcp"]
       
      +# An app made with Create: `wai create --from app.oui`, `wai app check`, and `wai mcp`'s app
      +# toolset driven by an MCP client.
      +[[test]]
      +name = "cli_app"
      +path = "tests/cli_app.rs"
      +required-features = ["app"]
      +
       [[test]]
       name = "int_kernels_vectors"
       path = "tests/int_kernels_vectors.rs"
      diff --git a/wai/wai-rs/src/bin/wai_cli/dx/app.rs b/wai/wai-rs/src/bin/wai_cli/dx/app.rs
      new file mode 100644
      index 000000000..3e639c350
      --- /dev/null
      +++ b/wai/wai-rs/src/bin/wai_cli/dx/app.rs
      @@ -0,0 +1,169 @@
      +//! `wai app`: apps made with Create (a build with the feature `app`, `wai-app` linked in).
      +//!
      +//! - `wai app guide` (`app.guide`): the line language and the WAI widgets, as a model writing
      +//!   an app is told them.
      +//! - `wai app check [ | ] [--json]` (`app.check`): a program checked against the
      +//!   WAI registry and WAI's rules, every error with its line and reason code. In a project
      +//!   (a `wai.json` beside the program, as `wai create --from app.oui` writes one), also the
      +//!   room the project file names for `wai dev` against the app's own, and the runtime under
      +//!   `vendor/` file by file against `vendor.json`. Exit 0 when everything holds, 1 when
      +//!   something does not, each failure named.
      +
      +pub const USAGE: &str = "wai app guide | wai app check [ | ] [--json]\n            an app made with Create: what a model is told, and the app, its room and its runtime checked";
      +
      +#[cfg(not(feature = "app"))]
      +pub fn main(_: &[String]) -> Result {
      +    Err("`wai app` needs a build with the feature `app` (cargo install --path wai/wai-rs --no-default-features --features app)".into())
      +}
      +
      +#[cfg(feature = "app")]
      +pub fn main(args: &[String]) -> Result {
      +    use super::{flag, positional};
      +    use std::path::Path;
      +    match args.first().map(String::as_str) {
      +        Some("guide") => {
      +            print!("{}", wai_app::guide::guide());
      +            Ok(true)
      +        }
      +        Some("check") => {
      +            if let Some(a) = args[1..].iter().find(|a| a.starts_with("--") && *a != "--json") {
      +                return Err(format!("wai app check: {a}: not an option ({USAGE})"));
      +            }
      +            let pos = positional(&args[1..], &[]);
      +            if pos.len() > 1 {
      +                return Err(USAGE.into());
      +            }
      +            let at = Path::new(pos.first().copied().unwrap_or("."));
      +            let report = check(at)?;
      +            let ok = report.failures.is_empty();
      +            if flag(args, "--json") || !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
      +                println!(
      +                    "{}",
      +                    serde_json::json!({
      +                        "wai": "envelope/1", "op": "app.check", "ok": ok,
      +                        "result": { "program": report.program, "project": report.project, "room": report.room, "errors": report.errors, "failures": report.failures, "checked": report.checked },
      +                    })
      +                );
      +            } else {
      +                println!("wai app check: {}", report.program);
      +                for c in &report.checked {
      +                    println!("  ok    {c}");
      +                }
      +                for f in &report.failures {
      +                    println!("  FAIL  {f}");
      +                }
      +            }
      +            Ok(ok)
      +        }
      +        _ => Err(USAGE.into()),
      +    }
      +}
      +
      +/// What `wai app check` found.
      +#[cfg(feature = "app")]
      +pub(crate) struct Report {
      +    pub program: String,
      +    pub project: Option,
      +    pub room: Option,
      +    pub errors: Vec,
      +    /// What held, one line each.
      +    pub checked: Vec,
      +    /// What did not, one line each.
      +    pub failures: Vec,
      +}
      +
      +/// Check the app at `at`: a project directory, or a program file (its directory the project's
      +/// when a `wai.json` is beside it).
      +#[cfg(feature = "app")]
      +pub(crate) fn check(at: &std::path::Path) -> Result {
      +    use super::project;
      +    use wai_app::export::{dev_mode, APP};
      +    let (dir, program_path) = if at.is_dir() {
      +        let p = project::load(at)?;
      +        let app = p.as_ref().and_then(|p| p.app.clone()).unwrap_or_else(|| at.join(APP));
      +        (at.to_path_buf(), app)
      +    } else {
      +        (at.parent().map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d }).unwrap_or(std::path::Path::new(".")).to_path_buf(), at.to_path_buf())
      +    };
      +    let text = std::fs::read_to_string(&program_path).map_err(|e| format!("{}: {e}", program_path.display()))?;
      +    let c = wai_app::check(&text);
      +    let mut r = Report { program: program_path.display().to_string(), project: None, room: c.room.clone(), errors: c.errors.clone(), checked: Vec::new(), failures: Vec::new() };
      +    let bad: Vec<&wai_app::ErrorOut> = c.errors.iter().filter(|e| !e.warning).collect();
      +    for e in &bad {
      +        r.failures.push(format!("{} line {}: {}: {}", APP, e.line, e.code, e.message));
      +    }
      +    if bad.is_empty() {
      +        r.checked.push(format!("{APP}: every line accepted by the WAI registry, {} widget(s)", c.nodes.len()));
      +    }
      +    let Some(room) = &c.room else {
      +        r.failures.push(format!("{APP}: the app holds no room (a Room, a WatchTogether or a Show)"));
      +        return Ok(r);
      +    };
      +    let Some(p) = project::load(&dir)? else {
      +        return Ok(r);
      +    };
      +    r.project = Some(dir.join(project::FILE).display().to_string());
      +    // The room `wai dev` runs for the project is the app's.
      +    let want = dev_mode(&room.kind);
      +    match p.dev.mode.as_deref() {
      +        None => r.failures.push(format!("{}: names no room for the app (dev.mode \"{want}\")", project::FILE)),
      +        Some(m) if m != want => r.failures.push(format!("{}: dev.mode is \"{m}\", and the app's room is \"{want}\"", project::FILE)),
      +        Some(_) => r.checked.push(format!("{}: dev.mode \"{want}\", the app's room", project::FILE)),
      +    }
      +    if room.kind == "show" {
      +        match p.dev.audience {
      +            Some(a) if a == room.audience => r.checked.push(format!("{}: dev.audience {a}, the show's", project::FILE)),
      +            got => r.failures.push(format!("{}: dev.audience is {}, and the show's audience is {}", project::FILE, got.map_or("absent".into(), |a| a.to_string()), room.audience)),
      +        }
      +    }
      +    if room.kind == "watch" {
      +        let source = room.source.as_deref().unwrap_or_default();
      +        let want_path = dir.join("media").join(source);
      +        match &p.dev.watch {
      +            Some(w) if *w == want_path => {
      +                if w.is_file() {
      +                    r.checked.push(format!("{}: dev.watch media/{source}, present", project::FILE));
      +                } else {
      +                    r.failures.push(format!("media/{source} is not there: put the file the room watches at it"));
      +                }
      +            }
      +            got => r.failures.push(format!("{}: dev.watch is {}, and the app watches media/{source}", project::FILE, got.as_ref().map_or("absent".into(), |w| w.display().to_string()))),
      +        }
      +    }
      +    // The runtime under vendor/, file by file.
      +    let vendor = dir.join("vendor.json");
      +    match std::fs::read_to_string(&vendor).map_err(|e| e.to_string()).and_then(|t| serde_json::from_str::(&t).map_err(|e| e.to_string())) {
      +        Err(e) => r.failures.push(format!("vendor.json: {e}")),
      +        Ok(v) => {
      +            let files = v["files"].as_array().cloned().unwrap_or_default();
      +            let mut good = 0;
      +            for f in &files {
      +                let (Some(path), Some(sum)) = (f["path"].as_str(), f["sha256"].as_str()) else {
      +                    r.failures.push("vendor.json: a file without its path or its SHA-256".into());
      +                    continue;
      +                };
      +                if path.split('/').any(|s| s.is_empty() || s == "." || s == "..") {
      +                    r.failures.push(format!("vendor.json: the path {path} leaves vendor/"));
      +                    continue;
      +                }
      +                match std::fs::read(dir.join("vendor").join(path)) {
      +                    Err(_) => r.failures.push(format!("vendor/{path} is missing (vendor.json names it)")),
      +                    Ok(b) => {
      +                        let got = wai_app::runtime::sha256_hex(&b);
      +                        if got == sum {
      +                            good += 1;
      +                        } else {
      +                            r.failures.push(format!("vendor/{path}: its SHA-256 is {got}, vendor.json says {sum}"));
      +                        }
      +                    }
      +                }
      +            }
      +            if files.is_empty() {
      +                r.failures.push("vendor.json lists no runtime file".into());
      +            } else if good == files.len() {
      +                r.checked.push(format!("vendor/: {good} runtime file(s), each the one vendor.json names"));
      +            }
      +        }
      +    }
      +    Ok(r)
      +}
      diff --git a/wai/wai-rs/src/bin/wai_cli/dx/create.rs b/wai/wai-rs/src/bin/wai_cli/dx/create.rs
      index ee3b22f3d..93514c706 100644
      --- a/wai/wai-rs/src/bin/wai_cli/dx/create.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/dx/create.rs
      @@ -1,11 +1,19 @@
      -//! `wai create  --template `: a new project from one of the examples in
      -//! `wai/examples/`, whose files this binary holds ([`templates`]), so it needs neither the
      -//! repository nor a network.
      +//! `wai create` (catalogue `project.create`): a new project, in one of two ways.
       //!
      -//! It writes into a directory that does not exist or is empty, and never over a file. Each
      -//! example's `test/` directory is the repository's check that the example works, and is not
      -//! part of the project. Where a template names a generated file (a watch room's sample
      -//! sound), it is made here.
      +//! - `wai create  --template `: from one of the examples in `wai/examples/`, whose
      +//!   files this binary holds ([`templates`]), so it needs neither the repository nor a network.
      +//!   Each example's `test/` directory is the repository's check that the example works, and is
      +//!   not part of the project. Where a template names a generated file (a watch room's sample
      +//!   sound), it is made here.
      +//! - `wai create  --from app.oui` (catalogue `app.export`; a build with `app`): from a
      +//!   program made with Create. The app's files are `wai_app::export`'s, its runtime is copied
      +//!   in from a Create runtime (`--runtime DIR`, `$WAI_APP_RUNTIME`, or `share/wai/app` beside
      +//!   this binary) and checked file by file, and its project file names the program and the
      +//!   room `wai dev` runs for it.
      +//!
      +//! Either way the project file is written by [`project::render`], and every project carries
      +//! the licence its files are under, Apache-2.0, as `LICENSE`. It writes into a directory that
      +//! does not exist or is empty, and never over a file.
       
       #[path = "templates.rs"]
       mod templates;
      @@ -13,7 +21,10 @@ mod templates;
       use super::{flag, opt, positional, project};
       use std::path::Path;
       
      -pub const USAGE: &str = "wai create  --template  | wai create --list [--json]   a new project from one of the examples";
      +pub const USAGE: &str = "wai create  --template  | wai create  --from app.oui [--runtime DIR] | wai create --list [--json]\n            a new project from one of the examples, or from an app made with Create";
      +
      +/// The licence every project `wai create` writes is under, and the file that says so.
      +const LICENSE: (&str, &[u8]) = ("LICENSE", include_bytes!("../../../../../LICENSE"));
       
       /// A file of a template: its path in the project, and its bytes.
       pub type File = (&'static str, &'static [u8]);
      @@ -33,7 +44,7 @@ pub fn main(args: &[String]) -> Result {
               if flag(args, "--json") {
                   let v: Vec = templates::TEMPLATES
                       .iter()
      -                .map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).collect::>() }))
      +                .map(|t| serde_json::json!({ "template": t.name, "title": t.title, "files": t.files.iter().map(|f| f.0).chain(t.generated.iter().map(|g| g.0)).chain([LICENSE.0]).collect::>() }))
                       .collect();
                   println!("{}", serde_json::to_string_pretty(&v).expect("json"));
               } else {
      @@ -43,9 +54,24 @@ pub fn main(args: &[String]) -> Result {
               }
               return Ok(true);
           }
      -    if let Some(a) = args.iter().find(|a| a.starts_with("--") && *a != "--template") {
      +    if let Some(a) = args.iter().find(|a| a.starts_with("--") && !["--template", "--from", "--runtime"].contains(&a.as_str())) {
               return Err(format!("wai create: {a}: not an option ({USAGE})"));
           }
      +    if let Some(program) = opt(args, "--from") {
      +        let pos = positional(args, &["--from", "--runtime"]);
      +        let (Some(dir), 1, None) = (pos.first(), pos.len(), opt(args, "--template")) else {
      +            return Err(format!("wai create  --from app.oui [--runtime DIR]\n{USAGE}"));
      +        };
      +        return from_app(Path::new(program), Path::new(dir), opt(args, "--runtime").map(Path::new)).map(|written| {
      +            println!("wai create: an app made with Create, in {dir}");
      +            for f in written.iter().filter(|f| !f.starts_with("vendor/")) {
      +                println!("  {f}");
      +            }
      +            println!("  vendor/   ({} runtime files)", written.iter().filter(|f| f.starts_with("vendor/")).count());
      +            println!("Next:\n  cd {dir}\n  wai app check   the app, its room and its runtime\n  wai dev         then open the link it prints");
      +            true
      +        });
      +    }
           let pos = positional(args, &["--template"]);
           let (Some(dir), Some(name), 1) = (pos.first(), opt(args, "--template"), pos.len()) else {
               return Err(format!("{USAGE}\ntemplates: {}", names()));
      @@ -71,16 +97,25 @@ fn names() -> String {
       /// The template's files in `dir`, which must not exist or must be empty. A project file is
       /// written where the template carries none.
       fn write(t: &Template, dir: &Path) -> Result<(), String> {
      +    let mut files: Vec<(String, Vec)> = t.files.iter().map(|(p, b)| (p.to_string(), b.to_vec())).collect();
      +    files.extend(t.generated.iter().map(|(p, f)| (p.to_string(), f())));
      +    if !files.iter().any(|(p, _)| p == project::FILE) {
      +        files.push((project::FILE.into(), project::minimal(t.name).into_bytes()));
      +    }
      +    write_project(dir, files)
      +}
      +
      +/// A project's files in `dir`, which must not exist or must be empty, with the licence every
      +/// project carries; then its project file, read back. The one place `wai create` writes.
      +fn write_project(dir: &Path, mut files: Vec<(String, Vec)>) -> Result<(), String> {
           if dir.exists() && std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?.next().is_some() {
               return Err(format!("{}: not empty; name a new directory", dir.display()));
           }
      -    let mut files: Vec<(&str, Vec)> = t.files.iter().map(|(p, b)| (*p, b.to_vec())).collect();
      -    files.extend(t.generated.iter().map(|(p, f)| (*p, f())));
      -    if !files.iter().any(|(p, _)| *p == project::FILE) {
      -        files.push((project::FILE, project::minimal(t.name).into_bytes()));
      +    if !files.iter().any(|(p, _)| p == LICENSE.0) {
      +        files.push((LICENSE.0.into(), LICENSE.1.to_vec()));
           }
           for (rel, bytes) in &files {
      -        if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") {
      +        if rel.split('/').any(|s| s.is_empty() || s == "." || s == "..") || rel.starts_with('/') {
                   return Err(format!("{rel}: not a path a template may write"));
               }
               let p = dir.join(rel);
      @@ -90,11 +125,73 @@ fn write(t: &Template, dir: &Path) -> Result<(), String> {
               let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&p).map_err(|e| format!("{}: {e}", p.display()))?;
               std::io::Write::write_all(&mut f, bytes).map_err(|e| format!("{}: {e}", p.display()))?;
           }
      -    // The project file the template wrote must read: a template is checked as it is made.
      +    // The project file must read: a project is checked as it is made.
           project::load(dir)?.ok_or("the project file is missing")?;
           Ok(())
       }
       
      +/// The Create runtime: the flag, `$WAI_APP_RUNTIME`, or `share/wai/app` beside this binary's
      +/// directory (an installed archive's layout), checked for its listing.
      +#[cfg(feature = "app")]
      +pub(super) fn find_runtime(flag_value: Option<&Path>) -> Result {
      +    use std::path::PathBuf;
      +    let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
      +    let candidates: Vec = match flag_value.map(Path::to_path_buf).or_else(|| std::env::var_os("WAI_APP_RUNTIME").map(PathBuf::from)) {
      +        Some(p) => vec![p],
      +        None => exe_dir.map(|d| vec![d.join("../share/wai/app"), d.join("share/wai/app")]).unwrap_or_default(),
      +    };
      +    candidates.iter().find(|c| c.join(wai_app::runtime::LISTING).is_file()).cloned().ok_or_else(|| {
      +        format!(
      +            "the Create runtime is not found (looked for {} in {}): give --runtime DIR, a runtime laid out by wai/wai-app/web/runtime.mjs",
      +            wai_app::runtime::LISTING,
      +            if candidates.is_empty() { "nowhere".to_string() } else { candidates.iter().map(|c| c.display().to_string()).collect::>().join(", ") }
      +        )
      +    })
      +}
      +
      +/// The project for a program made with Create (`app.export`) in `dir`: the app's files and its
      +/// runtime, its project file, its licence. The files written, by path.
      +#[cfg(feature = "app")]
      +pub(super) fn export_app(program: &str, dir: &Path, runtime: Option<&Path>) -> Result, String> {
      +    use wai_app::export::Contents;
      +    let rt_dir = find_runtime(runtime)?;
      +    let rt = wai_app::runtime::read(&rt_dir)?;
      +    let listed: Vec = rt.iter().map(|(f, _)| f.clone()).collect();
      +    let e = wai_app::export::export(program, &listed).map_err(|e| e.to_string())?;
      +    let mut files: Vec<(String, Vec)> = Vec::new();
      +    for f in e.files {
      +        let bytes = match f.contents {
      +            Contents::Text(t) => t.into_bytes(),
      +            Contents::Runtime(from) => rt.iter().find(|(r, _)| r.path == from).map(|(_, b)| b.clone()).ok_or(format!("{from}: not in the runtime"))?,
      +        };
      +        files.push((f.path, bytes));
      +    }
      +    let p = &e.project;
      +    let mut dev: Vec<(&str, serde_json::Value)> = vec![("site", ".".into()), ("mode", p.mode.clone().into())];
      +    if let Some(w) = &p.watch {
      +        dev.push(("watch", w.clone().into()));
      +    }
      +    if let Some(a) = p.audience {
      +        dev.push(("audience", a.into()));
      +    }
      +    let top: Vec<(&str, serde_json::Value)> = vec![("title", p.title.clone().into()), ("summary", p.summary.clone().into()), ("app", wai_app::export::APP.into())];
      +    files.push((project::FILE.into(), project::render(&top, &dev).into_bytes()));
      +    let names: Vec = files.iter().map(|(p, _)| p.clone()).chain([LICENSE.0.to_string()]).collect();
      +    write_project(dir, files)?;
      +    Ok(names)
      +}
      +
      +#[cfg(feature = "app")]
      +fn from_app(program: &Path, dir: &Path, runtime: Option<&Path>) -> Result, String> {
      +    let text = std::fs::read_to_string(program).map_err(|e| format!("{}: {e}", program.display()))?;
      +    export_app(&text, dir, runtime)
      +}
      +
      +#[cfg(not(feature = "app"))]
      +fn from_app(_: &Path, _: &Path, _: Option<&Path>) -> Result, String> {
      +    Err("`wai create --from` needs a build with the feature `app` (cargo install --path wai/wai-rs --no-default-features --features app)".into())
      +}
      +
       /// A sample sound for a watch room: twenty seconds of a tone that steps in pitch each
       /// second, 8 kHz, 16-bit, mono, so any stretch of it is told from another by ear.
       pub fn tone_wav() -> Vec {
      @@ -140,6 +237,10 @@ mod tests {
                   let p = project::load(&d).unwrap().unwrap();
                   assert_eq!(p.template.as_deref(), Some(t.name), "{}: its project file names it", t.name);
                   assert!(d.join("README.md").is_file(), "{}: a README", t.name);
      +            // Every project states its licence: the file, and a line of its README.
      +            assert_eq!(std::fs::read(d.join("LICENSE")).unwrap(), LICENSE.1, "{}: LICENSE", t.name);
      +            assert!(std::str::from_utf8(LICENSE.1).unwrap().contains("Apache License"));
      +            assert!(std::fs::read_to_string(d.join("README.md")).unwrap().contains("Apache-2.0"), "{}: its README names its licence", t.name);
                   let _ = std::fs::remove_dir_all(&d);
               }
           }
      diff --git a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs
      index 1048930e5..32831c399 100644
      --- a/wai/wai-rs/src/bin/wai_cli/dx/dev.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/dx/dev.rs
      @@ -23,7 +23,7 @@ use std::process::{Child, Command, Stdio};
       use std::sync::Arc;
       use std::time::{Duration, Instant};
       
      -pub const USAGE: &str = "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]\n            [--messaging] [--e2ee] [--publishers N] [--check [--json]] [--for S]\n            a local relay, room service and room page on one origin (`wai dev --help`)";
      +pub const USAGE: &str = "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]\n            [--show [--audience N]] [--messaging] [--e2ee] [--publishers N] [--check [--json]] [--for S]\n            a local relay, room service and room page on one origin (`wai dev --help`)";
       
       const HELP: &str = "wai dev — a relay, a room service and the room page on one origin, on this machine
       
      @@ -35,6 +35,9 @@ const HELP: &str = "wai dev — a relay, a room service and the room page on one
                          .ogg, .mp3); --watch-ms MS its duration, read from a WAV file's header
         --call           the room is a call: /room/call.html places one, rings, answers
         --remote         the room is one host's shared screen; prints the host's invitation
      +  --show           the room is a show: hosts on camera, an audience that votes in polls
      +                   the room's clock opens and signs (co-performance); --audience N each
      +                   viewer's inputs to a poll (1)
         --messaging      a conversation beside the room, at /msg-api/: /room/messages.html
         --e2ee           every room end-to-end encrypted
         --check          start, check every piece through the origin, report, stop
      @@ -63,6 +66,8 @@ enum Mode {
           Watch { file: PathBuf, ms: u64 },
           Call,
           Remote,
      +    /// A room with co-performance: its clock leads polls the audience answers.
      +    Show { audience: u8 },
       }
       
       #[derive(Debug)]
      @@ -94,8 +99,8 @@ pub fn main(args: &[String]) -> Result {
       }
       
       fn options(args: &[String]) -> Result {
      -    let known_values = ["--port", "--dir", "--site", "--publishers", "--watch", "--watch-ms", "--for", "--web", "--relay", "--room"];
      -    let known_flags = ["--call", "--remote", "--messaging", "--e2ee", "--check", "--json"];
      +    let known_values = ["--port", "--dir", "--site", "--publishers", "--watch", "--watch-ms", "--for", "--web", "--relay", "--room", "--audience"];
      +    let known_flags = ["--call", "--remote", "--show", "--messaging", "--e2ee", "--check", "--json"];
           let mut i = 0;
           while i < args.len() {
               let a = args[i].as_str();
      @@ -129,6 +134,8 @@ fn options(args: &[String]) -> Result {
               "call"
           } else if flag(args, "--remote") {
               "remote"
      +    } else if flag(args, "--show") || opt(args, "--audience").is_some() {
      +        "show"
           } else if opt(args, "--watch").is_some() {
               "watch"
           } else {
      @@ -138,6 +145,13 @@ fn options(args: &[String]) -> Result {
               "room" => Mode::Room,
               "call" => Mode::Call,
               "remote" => Mode::Remote,
      +        "show" => {
      +            let audience = match num("--audience")? {
      +                Some(n) => u8::try_from(n).ok().filter(|n| *n >= 1).ok_or("--audience: 1 to 255")?,
      +                None => d.audience.unwrap_or(1),
      +            };
      +            Mode::Show { audience }
      +        }
               _ => {
                   let file = watch.ok_or("a watch room needs --watch FILE")?;
                   let file = std::fs::canonicalize(&file).map_err(|e| format!("--watch {}: {e}", file.display()))?;
      @@ -313,6 +327,12 @@ fn relay_toml(o: &Opts, ws: u16, lines: &str) -> String {
               Mode::Watch { .. } => &[],
               _ => &["audio", "video"],
           };
      +    // A show's relay holds the floor and the audience's inputs, honouring the room's clock
      +    // (co-performance §6): the clock's key is in the room service's relay lines.
      +    let floor = match (&o.mode, clock_of(lines)) {
      +        (Mode::Show { .. }, Some(clock)) => format!("[floor]\nroom_keys = [\"{clock}\"]\n\n"),
      +        _ => String::new(),
      +    };
           let mut t = format!(
               "# Written by `wai dev` on each start: the relay on loopback, its WebSocket binding alone.\nwebsocket_listen = \"127.0.0.1:{ws}\"\npath = \"{RELAY_PATH}\"\n\n[tls]\ndev_names = [\"localhost\", \"127.0.0.1\"]\n\n[receipts]\nreceipts_track_suffix = \"_receipts\"\n\n"
           );
      @@ -321,7 +341,12 @@ fn relay_toml(o: &Opts, ws: u16, lines: &str) -> String {
               t += &format!("[[receipts.door]]\nprefix = [\"*\", \"*\", \"{k}\"]\ntrust_field = 1\nhold_ms = 2000\n\n");
           }
           t += &format!("[summary]\nout_path = {}\n\n[auth]\nrequired = true\n\n", q(&o.dir.join("sessions.jsonl")));
      -    t + lines
      +    t + &floor + lines
      +}
      +
      +/// The room's clock key in a room service's relay lines (`# clock `).
      +fn clock_of(lines: &str) -> Option<&str> {
      +    lines.lines().find_map(|l| l.strip_prefix("# clock")).map(str::trim).filter(|k| k.len() == 64 && k.bytes().all(|b| b.is_ascii_hexdigit()))
       }
       
       fn room_toml(o: &Opts, port: u16, ws: u16) -> String {
      @@ -339,6 +364,10 @@ fn room_toml(o: &Opts, port: u16, ws: u16) -> String {
           match o.mode {
               Mode::Call => t += "\n[call]\nring_ms = 30000\n",
               Mode::Remote => t += "\n[remote]\nwidth = 1280\nheight = 720\nfps = 15\nallow = \"all\"\ncontrol = 3\n",
      +        // Each viewer's inputs to one poll; the first host to join leads (opens the polls).
      +        Mode::Show { audience } => {
      +            t += &format!("\n[co]\naudience_input = {audience}\nlatency_budget_ms = 0\nlead = \"first\"\nai = false\nfidelity_every_s = 0\ncommit_after_ms = 1500\nlead_ms = 300\n")
      +        }
               _ => {}
           }
           t
      @@ -491,6 +520,7 @@ fn announce(o: &Opts, base: &str) {
               }
               Mode::Watch { file, ms } => println!("  watch room  {base}/room/?service={ROOM_API}   {} ({} s), in sync for everyone in it", file.display(), ms / 1000),
               Mode::Room => println!("  the room    {base}/room/?service={ROOM_API}   open it in two tabs and press Join in each"),
      +        Mode::Show { audience } => println!("  the show    {base}/room/?service={ROOM_API}   hosts join, viewers watch; each viewer has {audience} input(s) to a poll"),
           }
           if o.messaging {
               println!("  messages    {base}/room/messages.html?service={MSG_API}");
      @@ -538,7 +568,7 @@ fn check(o: &Opts, origin: SocketAddr) -> Vec {
               let v: Value = serde_json::from_slice(&b).map_err(|_| format!("{s}: not JSON"))?;
               if s == 200 && v["descriptor"].is_string() { Ok("descriptor and grants".into()) } else { Err(format!("{s}")) }
           }));
      -    if matches!(o.mode, Mode::Room | Mode::Watch { .. }) && !o.e2ee {
      +    if matches!(o.mode, Mode::Room | Mode::Watch { .. } | Mode::Show { .. }) && !o.e2ee {
               let body = json!({ "principal": CHECK_PRINCIPAL, "role": "viewer" }).to_string();
               c("a viewer's grant", http(origin, "POST", &format!("{ROOM_API}/grant"), &[("content-type", "application/json")], body.as_bytes()).map_err(|e| e.to_string()).and_then(|(s, b)| {
                   let v: Value = serde_json::from_slice(&b).map_err(|_| format!("{s}: not JSON"))?;
      @@ -743,7 +773,8 @@ fn handle(mut c: TcpStream, f: &Front) -> std::io::Result<()> {
       }
       
       /// A URL path as a relative file path: percent-decoded, and refused when a segment is
      -/// empty past the first, `.` or `..`, or holds a backslash, a NUL or a control byte.
      +/// empty past the first, `.` or `..`, starts with a dot (the project's own `.wai/`, where the
      +/// room's keys are, is never served), or holds a backslash, a NUL or a control byte.
       fn clean(path: &str) -> Option {
           let b = path.as_bytes();
           let mut d = Vec::with_capacity(b.len());
      @@ -769,7 +800,7 @@ fn clean(path: &str) -> Option {
                   }
                   return None;
               }
      -        if *seg == "." || *seg == ".." || seg.bytes().any(|c| c == b'\\' || c < 0x20 || c == 0x7f) {
      +        if seg.starts_with('.') || seg.bytes().any(|c| c == b'\\' || c < 0x20 || c == 0x7f) {
                   return None;
               }
               out.push(seg);
      @@ -791,7 +822,7 @@ fn mime(p: &Path) -> &'static str {
               "mp4" | "m4s" => "video/mp4",
               "m3u8" => "application/vnd.apple.mpegurl",
               "mpd" => "application/dash+xml",
      -        "txt" | "md" => "text/plain; charset=utf-8",
      +        "txt" | "md" | "oui" => "text/plain; charset=utf-8",
               _ => "application/octet-stream",
           }
       }
      @@ -908,6 +939,10 @@ mod tests {
               std::fs::create_dir_all(web.join("room")).unwrap();
               std::fs::write(web.join("room/index.html"), "").unwrap();
               std::fs::write(dir.join("secret.txt"), "never served").unwrap();
      +        // A project served at / keeps the room's keys in .wai/: never served.
      +        std::fs::create_dir_all(web.join(".wai/dev")).unwrap();
      +        std::fs::write(web.join(".wai/dev/room-keys.toml"), "never served").unwrap();
      +        std::fs::write(web.join("room/.env"), "never served").unwrap();
               let f = Arc::new(Front { site: None, web, services: vec![(ROOM_API.into(), svc)], relay, home: "/room/?service=/room-api".into() });
               let l = TcpListener::bind("127.0.0.1:0").unwrap();
               let a = l.local_addr().unwrap();
      @@ -961,7 +996,7 @@ mod tests {
               assert_eq!((s, String::from_utf8_lossy(&b).contains("app.js")), (200, true));
               assert_eq!(get(a, "/room").unwrap().0, 301);
               assert_eq!(get(a, "/").unwrap().0, 302);
      -        for bad in ["/../secret.txt", "/room/%2e%2e/%2e%2e/secret.txt", "/room//index.html", "/room/%5c..%5csecret.txt"] {
      +        for bad in ["/../secret.txt", "/room/%2e%2e/%2e%2e/secret.txt", "/room//index.html", "/room/%5c..%5csecret.txt", "/.wai/dev/room-keys.toml", "/room/.env", "/%2ewai/dev/room-keys.toml"] {
                   let (s, b) = get(a, bad).unwrap();
                   assert!(s == 400 || s == 404, "{bad}: {s}");
                   assert!(!String::from_utf8_lossy(&b).contains("never served"), "{bad}");
      @@ -1003,5 +1038,13 @@ mod tests {
               assert!(r.contains("public_prefix = \"/room-api\"") && r.contains("relay_url = \"ws://127.0.0.1:9001/moq\"") && r.contains("max_publishers = 3"));
               let t = relay_toml(&o, 9001, "[[auth.authorities]]\n");
               assert!(t.contains("websocket_listen = \"127.0.0.1:9001\"") && !t.contains("quic_listen") && t.contains("required = true") && t.contains("\"video\""));
      +        assert!(!r.contains("[co]") && !t.contains("[floor]"));
      +        // A show: the room's co-performance, and the relay holding the floor to the room's clock.
      +        let clock = "ab".repeat(32);
      +        let show = Opts { mode: Mode::Show { audience: 2 }, ..o };
      +        let r = room_toml(&show, 9000, 9001);
      +        assert!(r.contains("\n[co]\naudience_input = 2\n") && r.contains("lead = \"first\""), "{r}");
      +        let t = relay_toml(&show, 9001, &format!("# authority {}\n# clock     {clock}\n[[auth.authorities]]\n", "cd".repeat(32)));
      +        assert!(t.contains(&format!("[floor]\nroom_keys = [\"{clock}\"]\n")) && t.contains("\"video\""), "{t}");
           }
       }
      diff --git a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs
      index 3d396b45a..3c0c778a7 100644
      --- a/wai/wai-rs/src/bin/wai_cli/dx/mod.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/dx/mod.rs
      @@ -1,9 +1,12 @@
       //! Every verb kept in a module of its own, one verb a module, every verb in [`VERBS`]:
       //!
       //! - `hello`: the four steps of WAI (name, dispatch, rebuild, receipt) on a bundled sample;
      -//! - `create`: a new project from one of the examples (`wai/examples/`);
      +//! - `create`: a new project from one of the examples (`wai/examples/`), or from an app made
      +//!   with Create (`--from app.oui`, a build with `app`);
       //! - `dev`: a relay, a room service and the room page on one origin, on this machine;
       //! - `verify`: the receipts under a directory, checked offline;
      +//! - `app` (`app.guide`, `app.check`; a build with `app`): an app made with Create, its guide
      +//!   and its check;
       //! - `mcp` (`agent.mcp`): WAI's operations as tools for a coding agent, a Model Context
       //!   Protocol server on standard input and output, in toolsets (`../mcp/`);
       //! - `setup-agent` (`agent.setup`): a project made ready for coding agents (`../setup_agent.rs`).
      @@ -13,6 +16,7 @@
       
       pub mod project;
       
      +mod app;
       mod create;
       mod dev;
       mod hello;
      @@ -50,11 +54,12 @@ pub const VERBS: &[Verb] = &[
           Verb { name: "create", usage: create::USAGE, run: Run::Checks(create::main) },
           Verb { name: "dev", usage: dev::USAGE, run: Run::Checks(dev::main) },
           Verb { name: "verify", usage: verify::USAGE, run: Run::Checks(verify::main) },
      +    Verb { name: "app", usage: app::USAGE, run: Run::Checks(app::main) },
           Verb { name: MCP, usage: MCP_USAGE, run: Run::Checks(mcp_main) },
           Verb { name: "setup-agent", usage: setup_agent::USAGE, run: Run::Status(setup_agent::main) },
       ];
       
      -const MCP_USAGE: &str = "wai mcp [--toolset core,dev] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n            WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n            input and output (a build with `mcp`)";
      +const MCP_USAGE: &str = "wai mcp [--toolset core,dev,app] [--read-only] [--project DIR] [--spec-root DIR] [--room-bin BIN]\n            WAI's operations as tools for a coding agent: a Model Context Protocol server on standard\n            input and output (a build with `mcp`)";
       
       #[cfg(feature = "mcp")]
       fn mcp_main(args: &[String]) -> Result {
      diff --git a/wai/wai-rs/src/bin/wai_cli/dx/project.rs b/wai/wai-rs/src/bin/wai_cli/dx/project.rs
      index 7246d14f6..742c4d62d 100644
      --- a/wai/wai-rs/src/bin/wai_cli/dx/project.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/dx/project.rs
      @@ -1,11 +1,14 @@
      -//! The project file, `wai.json`: what a template wrote, and what `wai dev` runs in the
      -//! project's directory. This module is the one place it is named, read and written.
      +//! The project file, `wai.json`: what a template wrote, or what `wai create --from app.oui`
      +//! wrote for an app made with Create, and what `wai dev` runs in the project's directory. This
      +//! module is the one place it is named, read and written ([`render`]).
       //!
       //! ```json
       //! { "template": "video-room",
       //!   "title": "A video room in one page",
      +//!   "app": "app.oui",
       //!   "dev": { "site": ".", "page": "/", "mode": "room", "publishers": 4,
      -//!            "watch": "tone.wav", "watch_ms": 20000, "messaging": false, "e2ee": false } }
      +//!            "watch": "tone.wav", "watch_ms": 20000, "messaging": false, "e2ee": false,
      +//!            "audience": 2 } }
       //! ```
       //!
       //! Every member is optional. `dev` gives `wai dev` its defaults; a flag on the command line
      @@ -26,7 +29,8 @@ pub struct Dev {
           /// The page `wai dev` names first when it is ready (a path on the front, `/` by default
           /// with a site, else the mode's page).
           pub page: Option,
      -    /// `room` (the default), `watch`, `call` or `remote`.
      +    /// `room` (the default), `watch`, `call`, `remote` or `show` (a room with co-performance:
      +    /// hosts on camera and an audience that votes).
           pub mode: Option,
           pub publishers: Option,
           /// A watch room's source, and its duration where it is not a WAV file.
      @@ -35,6 +39,8 @@ pub struct Dev {
           /// A conversation beside the room.
           pub messaging: Option,
           pub e2ee: Option,
      +    /// A show's inputs per viewer per poll, 1 to 255.
      +    pub audience: Option,
       }
       
       /// A project file as read.
      @@ -45,6 +51,8 @@ pub struct Project {
           pub template: Option,
           pub title: Option,
           pub summary: Option,
      +    /// A program made with Create (`app.oui`), which the project's page renders.
      +    pub app: Option,
           pub dev: Dev,
       }
       
      @@ -63,7 +71,7 @@ pub fn load(dir: &Path) -> Result, String> {
       pub fn parse(root: &Path, text: &str) -> Result {
           let v: Value = serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
           let top = v.as_object().ok_or("not a JSON object")?;
      -    only(top, &["template", "title", "summary", "dev"], "")?;
      +    only(top, &["template", "title", "summary", "app", "dev"], "")?;
           let s = |m: &Map, k: &str| -> Result, String> {
               match m.get(k) {
                   None => Ok(None),
      @@ -71,10 +79,17 @@ pub fn parse(root: &Path, text: &str) -> Result {
                   Some(_) => Err(format!("{k}: a string")),
               }
           };
      -    let mut p = Project { root: root.to_path_buf(), template: s(top, "template")?, title: s(top, "title")?, summary: s(top, "summary")?, dev: Dev::default() };
      +    let mut p = Project {
      +        root: root.to_path_buf(),
      +        template: s(top, "template")?,
      +        title: s(top, "title")?,
      +        summary: s(top, "summary")?,
      +        app: s(top, "app")?.map(|a| root.join(a)),
      +        dev: Dev::default(),
      +    };
           if let Some(d) = top.get("dev") {
               let d = d.as_object().ok_or("dev: an object")?;
      -        only(d, &["site", "page", "mode", "publishers", "watch", "watch_ms", "messaging", "e2ee"], "dev.")?;
      +        only(d, &["site", "page", "mode", "publishers", "watch", "watch_ms", "messaging", "e2ee", "audience"], "dev.")?;
               let path = |k: &str| -> Result, String> { Ok(s(d, k).map_err(|e| format!("dev.{e}"))?.map(|x| root.join(x))) };
               let num = |k: &str| -> Result, String> {
                   match d.get(k) {
      @@ -97,20 +112,39 @@ pub fn parse(root: &Path, text: &str) -> Result {
                   watch_ms: num("watch_ms")?,
                   messaging: boolean("messaging")?,
                   e2ee: boolean("e2ee")?,
      +            audience: num("audience")?.map(|n| u8::try_from(n).ok().filter(|n| *n >= 1).ok_or_else(|| "dev.audience: 1 to 255".to_string())).transpose()?,
               };
               if let Some(m) = &p.dev.mode
      -            && !["room", "watch", "call", "remote"].contains(&m.as_str())
      +            && !MODES.contains(&m.as_str())
               {
      -            return Err(format!("dev.mode: room, watch, call or remote, not {m}"));
      +            return Err(format!("dev.mode: room, watch, call, remote or show, not {m}"));
      +        }
      +        if p.dev.audience.is_some() && p.dev.mode.as_deref() != Some("show") {
      +            return Err("dev.audience: a show's alone (dev.mode \"show\")".into());
               }
           }
           Ok(p)
       }
       
      +/// The modes `wai dev` runs.
      +pub const MODES: &[&str] = &["room", "watch", "call", "remote", "show"];
      +
       /// The text of a project file naming `template`, for `wai create` to write when the
       /// template carries none of its own.
       pub fn minimal(template: &str) -> String {
      -    format!("{{\n  \"template\": {}\n}}\n", Value::from(template))
      +    render(&[("template", Value::from(template))], &[])
      +}
      +
      +/// A project file's text, in the layout every template's own has: its members in the order
      +/// given, one a line, and the `dev` block (when it has members) on one line, last. Every
      +/// project file `wai create` writes is this function's.
      +pub fn render(top: &[(&str, Value)], dev: &[(&str, Value)]) -> String {
      +    let mut lines: Vec = top.iter().map(|(k, v)| format!("  {}: {v}", Value::from(*k))).collect();
      +    if !dev.is_empty() {
      +        let inner: Vec = dev.iter().map(|(k, v)| format!("{}: {v}", Value::from(*k))).collect();
      +        lines.push(format!("  \"dev\": {{ {} }}", inner.join(", ")));
      +    }
      +    format!("{{\n{}\n}}\n", lines.join(",\n"))
       }
       
       fn only(m: &Map, known: &[&str], at: &str) -> Result<(), String> {
      @@ -146,4 +180,26 @@ mod tests {
           fn the_minimal_file_reads_back() {
               assert_eq!(parse(Path::new("/p"), &minimal("video-room")).unwrap().template.as_deref(), Some("video-room"));
           }
      +
      +    /// The writer lays a file out as the templates' own files are laid out, byte for byte.
      +    #[test]
      +    fn the_writer_writes_a_templates_file_as_it_is() {
      +        let ex = |t: &str| std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples").join(t).join(FILE)).unwrap();
      +        for t in ["watch-together", "messaging", "android-app"] {
      +            let p = parse(Path::new("/p"), &ex(t)).unwrap();
      +            let v: Value = serde_json::from_str(&ex(t)).unwrap();
      +            let top: Vec<(&str, Value)> = ["template", "title", "summary"].iter().filter_map(|k| v.get(*k).map(|x| (*k, x.clone()))).collect();
      +            let dev: Vec<(&str, Value)> = v.get("dev").and_then(Value::as_object).map(|d| d.iter().map(|(k, x)| (k.as_str(), x.clone())).collect()).unwrap_or_default();
      +            assert_eq!(render(&top, &dev), ex(t), "{t}");
      +            assert_eq!(p.template.as_deref(), Some(t));
      +        }
      +    }
      +
      +    #[test]
      +    fn an_apps_file_names_its_program_and_a_show_its_audience() {
      +        let p = parse(Path::new("/p"), &render(&[("title", "x".into()), ("app", "app.oui".into())], &[("site", ".".into()), ("mode", "show".into()), ("audience", 2.into())])).unwrap();
      +        assert_eq!((p.app.as_deref(), p.dev.mode.as_deref(), p.dev.audience), (Some(Path::new("/p/app.oui")), Some("show"), Some(2)));
      +        assert!(parse(Path::new("/p"), r#"{"dev":{"mode":"room","audience":2}}"#).unwrap_err().contains("show"));
      +        assert!(parse(Path::new("/p"), r#"{"dev":{"mode":"show","audience":0}}"#).unwrap_err().contains("1 to 255"));
      +    }
       }
      diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/app.rs b/wai/wai-rs/src/bin/wai_cli/mcp/app.rs
      new file mode 100644
      index 000000000..82f639c1a
      --- /dev/null
      +++ b/wai/wai-rs/src/bin/wai_cli/mcp/app.rs
      @@ -0,0 +1,64 @@
      +//! The `app` toolset: Create's operations (`app.guide`, `app.check`, `app.stream`,
      +//! `app.export`) through `wai-app`. A program streams into the person's open Create page
      +//! through the bridge this server runs on 127.0.0.1 (`wai_app::bridge`), which a page reads only
      +//! with the pairing code the bridge makes at start; an export is `wai create --from app.oui`'s,
      +//! inside the project directory.
      +
      +use super::tools::str_arg;
      +use super::Server;
      +use serde_json::{json, Value};
      +use std::path::Path;
      +
      +fn bridge_facts(server: &Server) -> Value {
      +    match &server.bridge {
      +        Some(b) => json!({"bridge": format!("http://127.0.0.1:{}", b.port), "code": b.code, "pages_connected": b.listening()}),
      +        None => json!({"bridge": null, "note": "this server runs no bridge (it was started read-only)"}),
      +    }
      +}
      +
      +pub(crate) fn guide(server: &mut Server, _: &Value) -> Result {
      +    let mut v = json!({"guide": wai_app::guide::guide()});
      +    if let (Some(o), Value::Object(b)) = (v.as_object_mut(), bridge_facts(server)) {
      +        o.extend(b);
      +    }
      +    if let Some(b) = &server.bridge {
      +        v["connect"] = json!(format!(
      +            "In Create, choose \"Your coding agent\" and enter the bridge http://127.0.0.1:{} and the code {}.",
      +            b.port, b.code
      +        ));
      +    }
      +    Ok(v)
      +}
      +
      +pub(crate) fn check(_: &mut Server, args: &Value) -> Result {
      +    let c = wai_app::check(str_arg(args, "program")?);
      +    let bad: Vec = c.errors.iter().filter(|e| !e.warning).map(|e| format!("line {}: {}: {}", e.line, e.code, e.message)).collect();
      +    let mut v = serde_json::to_value(&c).map_err(|e| e.to_string())?;
      +    v["ok"] = json!(bad.is_empty() && c.room.is_some());
      +    v["problems"] = json!(bad);
      +    Ok(v)
      +}
      +
      +pub(crate) fn stream(server: &mut Server, args: &Value) -> Result {
      +    let program = str_arg(args, "program")?;
      +    let b = server.bridge.as_ref().ok_or("this server runs no bridge (it was started read-only)")?;
      +    if b.listening() == 0 {
      +        return Err(format!(
      +            "No Create page is connected. Ask the person to open Create, choose \"Your coding agent\", and enter the bridge http://127.0.0.1:{} and the code {}.",
      +            b.port, b.code
      +        ));
      +    }
      +    let chunk = args.get("chunk").and_then(Value::as_u64).unwrap_or(24).clamp(1, 4096) as usize;
      +    let pace = args.get("pace_ms").and_then(Value::as_u64).unwrap_or(30).min(2000);
      +    let (chunks, delivered) = b.stream(program, chunk, pace);
      +    let c = wai_app::check(program);
      +    Ok(json!({"delivered": delivered, "chunks": chunks, "errors": c.errors}))
      +}
      +
      +pub(crate) fn export(server: &mut Server, args: &Value) -> Result {
      +    let program = str_arg(args, "program")?;
      +    let dir = server.inside_project(Path::new(str_arg(args, "dir")?))?;
      +    let runtime = args.get("runtime_dir").and_then(Value::as_str).map(Path::new);
      +    let files = crate::dx::create::export_app(program, &dir, runtime)?;
      +    Ok(json!({"dir": dir.display().to_string(), "files": files, "next": "wai app check, then wai dev, in that directory"}))
      +}
      diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
      index d28aeda75..aa64bde46 100644
      --- a/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/mcp/mod.rs
      @@ -13,7 +13,9 @@
       //!   conformance corpora, search the specification's text;
       //! - `dev`: a development room on loopback (`dev_room.rs`): start and stop it, mint grants for
       //!   test principals, join and publish as them, and read the session, each on the target
      -//!   `local` alone.
      +//!   `local` alone;
      +//! - `app` (a build with `app`; `app.rs`): Create's guide, a program checked, streamed into the
      +//!   person's open Create page through a bridge on 127.0.0.1, and exported as a project.
       //!
       //! Each answers with a structured result. Nothing leaves the machine, and the only tool that
       //! takes a service's address refuses every one that is not on this machine.
      @@ -36,6 +38,9 @@ use serde_json::{json, Value};
       use std::io::{BufRead, Write};
       use std::path::PathBuf;
       
      +#[cfg(feature = "app")]
      +#[path = "app.rs"]
      +mod app;
       #[path = "dev_room.rs"]
       mod dev_room;
       #[path = "tools.rs"]
      @@ -58,6 +63,11 @@ corpora through their independent verifiers; and search the specification's text
               s.push_str(" The development tools, each marked DEVELOPMENT ONLY, start a relay and a room service on loopback with keys \
       held in memory, mint grants for test principals, join and publish as them, and read the room's session for verification; they act \
       on the target local alone and refuse any service that is not on this machine.");
      +    }
      +    if toolsets.contains(&tools::Toolset::App) {
      +        s.push_str(" Create's tools give the line language and the WAI widgets a WAI app is written in (read app_guide first), \
      +check a program, stream it into the person's open Create page through a bridge on 127.0.0.1 that the page reads with a pairing \
      +code, and export it as a project.");
           }
           s.push_str(" No tool sends anything off the machine.");
           s
      @@ -75,6 +85,10 @@ pub(crate) struct Server {
           pub(crate) dev: Option,
           pub(crate) principals: std::collections::BTreeMap,
           pub(crate) client: Option,
      +    /// The bridge Create pages read programs from, while the `app` toolset is offered and the
      +    /// server is not read-only.
      +    #[cfg(feature = "app")]
      +    pub(crate) bridge: Option,
       }
       
       impl Server {
      @@ -90,7 +104,19 @@ impl Server {
                   },
               };
               let room_bin = room_bin.or_else(|| std::env::var_os("WAI_ROOM_BIN").map(PathBuf::from));
      -        Server { toolsets, read_only, project, spec_root, spec_root_from, room_bin, dev: None, principals: Default::default(), client: None }
      +        Server {
      +            toolsets,
      +            read_only,
      +            project,
      +            spec_root,
      +            spec_root_from,
      +            room_bin,
      +            dev: None,
      +            principals: Default::default(),
      +            client: None,
      +            #[cfg(feature = "app")]
      +            bridge: None,
      +        }
           }
       
           /// `p` (relative to the project directory) as a path to write, when it is inside the
      @@ -218,7 +244,14 @@ pub fn main(args: &[String]) -> Result {
               toolsets.push(tools::Toolset::Core);
           }
           let project = project.map_or_else(std::env::current_dir, Ok).and_then(|p| p.canonicalize()).map_err(|e| format!("the project directory: {e}"))?;
      +    #[cfg_attr(not(feature = "app"), allow(unused_mut))]
           let mut server = Server::new(toolsets, spec_root, room_bin, project, read_only);
      +    #[cfg(feature = "app")]
      +    if server.toolsets.contains(&tools::Toolset::App) && !server.read_only {
      +        let b = wai_app::bridge::Bridge::start(0).map_err(|e| format!("the bridge for Create pages: {e}"))?;
      +        eprintln!("wai mcp: the bridge for Create pages is http://127.0.0.1:{}, pairing code {}", b.port, b.code);
      +        server.bridge = Some(b);
      +    }
           let stdin = std::io::stdin();
           let mut out = std::io::stdout().lock();
           for line in stdin.lock().lines() {
      diff --git a/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
      index aefd37bfe..29c436b54 100644
      --- a/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/mcp/tools.rs
      @@ -3,6 +3,8 @@
       //! hints and the function that runs it. A tool's name is its operation's, with `_` for the dot
       //! (`wai::ops::mcp_tool_name`); nothing else names it.
       
      +#[cfg(feature = "app")]
      +use super::app;
       use super::dev_room;
       use super::Server;
       use serde_json::{json, Map, Value};
      @@ -16,15 +18,26 @@ pub(crate) enum Toolset {
           Core,
           /// The development room on loopback, on the target `local` alone.
           Dev,
      +    /// Create's: the guide, a program checked, streamed into the person's open Create page, and
      +    /// exported as a project (a build with `app`).
      +    #[cfg_attr(not(feature = "app"), allow(dead_code))]
      +    App,
       }
       
       impl Toolset {
      -    pub(crate) const ALL: &'static [Toolset] = &[Toolset::Core, Toolset::Dev];
      +    /// The toolsets this build serves.
      +    pub(crate) const ALL: &'static [Toolset] = &[
      +        Toolset::Core,
      +        Toolset::Dev,
      +        #[cfg(feature = "app")]
      +        Toolset::App,
      +    ];
       
           pub(crate) fn name(self) -> &'static str {
               match self {
                   Toolset::Core => "core",
                   Toolset::Dev => "dev",
      +            Toolset::App => "app",
               }
           }
       
      @@ -44,6 +57,9 @@ pub(crate) enum Effect {
           Writes,
           /// Starts or drives the development room on loopback.
           DevRoom,
      +    /// Sends to the person's open Create page through the bridge on loopback.
      +    #[cfg_attr(not(feature = "app"), allow(dead_code))]
      +    Bridge,
       }
       
       pub(crate) struct Tool {
      @@ -94,6 +110,18 @@ pub(crate) const TOOLS: &[Tool] = &[
               description: "DEVELOPMENT ONLY. On the target local: close a test principal's session in the development room and wait for the clock's leave." },
           Tool { op: "room.session", toolset: Toolset::Dev, title: "Read the development room's session", effect: Effect::DevRoom, schema: s_target, run: dev_room::session,
               description: "DEVELOPMENT ONLY. On the target local: the development room as a verifier is given it (descriptor, grants, key-log head and the clock's event chain), ready for receipt_verify." },
      +    #[cfg(feature = "app")]
      +    Tool { op: "app.guide", toolset: Toolset::App, title: "The guide to writing a WAI app", effect: Effect::Reads, schema: s_none, run: app::guide,
      +        description: "The line language and every WAI widget, as a model writing a WAI app is told them, and the bridge and pairing code the person's Create page connects to. Read it before writing a program." },
      +    #[cfg(feature = "app")]
      +    Tool { op: "app.check", toolset: Toolset::App, title: "Check a WAI app program", effect: Effect::Reads, schema: s_program, run: app::check,
      +        description: "Check a WAI app program against the WAI registry and WAI's rules: the widget tree it renders, every error with its line and reason code, and the room it needs. Fix each error at its line and check again." },
      +    #[cfg(feature = "app")]
      +    Tool { op: "app.stream", toolset: Toolset::App, title: "Stream a program into the person's Create page", effect: Effect::Bridge, schema: s_app_stream, run: app::stream,
      +        description: "Stream a program into the person's open Create page, connected to this server's bridge on 127.0.0.1, which renders the app as it arrives and lets them try its room." },
      +    #[cfg(feature = "app")]
      +    Tool { op: "app.export", toolset: Toolset::App, title: "Export an app as a project", effect: Effect::Writes, schema: s_app_export, run: app::export,
      +        description: "Write the app's starter project into a new or empty directory inside the project directory, as `wai create  --from app.oui` does: the program, wai.json naming its room, the page and its runtime, a README, an AGENTS.md and the licence." },
       ];
       
       /// The tools a server offers: those of its toolsets, or with `--read-only` those of them that
      @@ -266,6 +294,36 @@ fn s_room_join() -> Value {
       fn s_room_principal() -> Value {
           obj(json!({"target": target_prop(), "principal": {"type": "string", "description": "The test principal's name."}}), &["principal"])
       }
      +#[cfg(feature = "app")]
      +fn program_prop() -> Value {
      +    json!({"type": "string", "description": "The app as a program in joule-ui's line language: one widget a line, `root = App([...], \"Title\")` first (see app_guide)."})
      +}
      +#[cfg(feature = "app")]
      +fn s_program() -> Value {
      +    obj(json!({"program": program_prop()}), &["program"])
      +}
      +#[cfg(feature = "app")]
      +fn s_app_stream() -> Value {
      +    obj(
      +        json!({
      +            "program": program_prop(),
      +            "chunk": {"type": "integer", "minimum": 1, "maximum": 4096, "description": "Characters a chunk (24 by default)."},
      +            "pace_ms": {"type": "integer", "minimum": 0, "maximum": 2000, "description": "Milliseconds between chunks (30 by default)."}
      +        }),
      +        &["program"],
      +    )
      +}
      +#[cfg(feature = "app")]
      +fn s_app_export() -> Value {
      +    obj(
      +        json!({
      +            "program": program_prop(),
      +            "dir": {"type": "string", "description": "The new project's directory, inside the project directory: absent or empty."},
      +            "runtime_dir": {"type": "string", "description": "A Create runtime (laid out by wai/wai-app/web/runtime.mjs); default $WAI_APP_RUNTIME or share/wai/app beside wai."}
      +        }),
      +        &["program", "dir"],
      +    )
      +}
       
       // ------------------------------------------------------------------------------- helpers
       
      diff --git a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      index 4e87562fb..c56c01e9e 100644
      --- a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      @@ -34,7 +34,7 @@ use std::io::{BufRead, IsTerminal, Write};
       use std::path::{Path, PathBuf};
       
       /// The command's usage, for `wai`'s own.
      -pub const USAGE: &str = "wai setup-agent [--dir D] [--dry-run] [--confirm] [--json] [--all] [--only FILE,...] [--toolset core,dev]\n            write AGENTS.md (and an import of it in a CLAUDE.md already there) and register `wai mcp`\n            in the project's agent configuration";
      +pub const USAGE: &str = "wai setup-agent [--dir D] [--dry-run] [--confirm] [--json] [--all] [--only FILE,...] [--toolset core,dev,app]\n            write AGENTS.md (and an import of it in a CLAUDE.md already there) and register `wai mcp`\n            in the project's agent configuration";
       
       /// The operation, as the catalogue names it.
       const OP: &str = "agent.setup";
      @@ -107,8 +107,8 @@ fn usage() -> &'static str {
           "usage: wai setup-agent [--dir ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] [--toolset [,...]] [--command ]"
       }
       
      -/// The toolsets `wai mcp` serves, for `--toolset`.
      -const TOOLSETS: &[&str] = &["core", "dev"];
      +/// The toolsets `wai mcp` serves, for `--toolset` (`app` in a build with the feature `app`).
      +const TOOLSETS: &[&str] = &["core", "dev", "app"];
       
       fn parse(args: &[String]) -> Result {
           let mut o = Opts { dir: PathBuf::from("."), dry_run: false, confirm: false, json: false, all: false, only: None, toolsets: None, command: "wai".into() };
      @@ -174,7 +174,7 @@ receipts, claims and live-room records are signed so that a verifier can check t
       tools, each named by its operation (`receipt.verify` is the tool `receipt_verify`). The `core` toolset inspects envelopes \
       and packages, reads the capability registry, verifies receipts, claims, live-room sessions and audit bundles, packages \
       media, lists and runs conformance corpora, and searches the specification. The `dev` toolset (`{command} {mcp} --toolset core,dev`) \
      -runs a development room on loopback.\n\
      +runs a development room on loopback, and the `app` toolset writes, checks and exports apps made with Create.\n\
       - Verify with the tools. Do not judge a signature, a receipt or a session yourself: quote the verifier's verdict and its reason code.\n\
       - The specification's text is the authority. Search it, and cite a rule by its section (`SPEC.md` §N, or the extension and its section).\n\
       - The development tools are for development only. They run on 127.0.0.1 with keys held in memory and refuse any other \
      diff --git a/wai/wai-rs/tests/cli_app.rs b/wai/wai-rs/tests/cli_app.rs
      new file mode 100644
      index 000000000..00a882b20
      --- /dev/null
      +++ b/wai/wai-rs/tests/cli_app.rs
      @@ -0,0 +1,330 @@
      +//! An app made with Create, through `wai` built with the feature `app`:
      +//!
      +//! - `wai create  --from app.oui` writes an ordinary WAI project: the app's files and its
      +//!   runtime under `vendor/`, the project file laid out as every template's is and naming the
      +//!   program and the room `wai dev` runs for it, and the licence; a template's project and an
      +//!   exported one share their shape;
      +//! - `wai app check` passes on it, and refuses a project whose room is not its app's, whose
      +//!   runtime changed, whose program has a refused line, or that names no room;
      +//! - `wai create --from` refuses a program with errors, one with no room, and a directory with
      +//!   anything in it;
      +//! - `wai mcp --toolset app`: the tools are Create's operations (`app_guide`, `app_check`,
      +//!   `app_stream`, `app_export`); a program streams through the bridge to a page connected with
      +//!   the pairing code and to no page without it; an export is written inside the project
      +//!   directory alone; `--read-only` runs no bridge and offers no tool that streams or writes.
      +#![cfg(feature = "app")]
      +
      +use serde_json::{json, Value};
      +use sha2::{Digest, Sha256};
      +use std::io::{BufRead, BufReader, Read, Write};
      +use std::path::{Path, PathBuf};
      +use std::process::{Child, ChildStdin, ChildStdout, Command, Output, Stdio};
      +
      +const SHOW: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll, people], \"Live from the studio\", 2)\nplayer = Player()\nchat = Chat(\"Talk\")\npoll = Poll(\"Which song next?\", [\"The first one\", \"The quiet one\"])\npeople = Participants()\n";
      +
      +fn scratch(tag: &str) -> PathBuf {
      +    let d = std::env::temp_dir().join(format!("wai-cli-app-{tag}-{}", std::process::id()));
      +    let _ = std::fs::remove_dir_all(&d);
      +    std::fs::create_dir_all(&d).unwrap();
      +    d.canonicalize().unwrap()
      +}
      +
      +fn hex(b: &[u8]) -> String {
      +    b.iter().map(|x| format!("{x:02x}")).collect()
      +}
      +
      +/// A Create runtime as `web/runtime.mjs` lays one out: a few files and their listing.
      +fn runtime(d: &Path) -> PathBuf {
      +    let rt = d.join("runtime");
      +    let files = [("create/wai-app-app.js", "export {};\n"), ("create/pkg/wai_app_bg.wasm", "\0asm"), ("room/wai-app-room-client.js", "// the room client\n")];
      +    let mut listed = Vec::new();
      +    for (p, text) in files {
      +        let f = rt.join(p);
      +        std::fs::create_dir_all(f.parent().unwrap()).unwrap();
      +        std::fs::write(&f, text).unwrap();
      +        listed.push(json!({"path": p, "sha256": hex(&Sha256::digest(text.as_bytes()))}));
      +    }
      +    std::fs::write(rt.join("create/wai-app-runtime.json"), json!({"files": listed}).to_string()).unwrap();
      +    rt
      +}
      +
      +fn wai(dir: &Path, args: &[&str]) -> Output {
      +    Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).output().expect("run wai")
      +}
      +
      +fn text(o: &Output) -> String {
      +    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
      +}
      +
      +/// `wai app check` in `dir`: its exit status and its JSON.
      +fn app_check(dir: &Path) -> (i32, Value) {
      +    let o = wai(dir, &["app", "check", "--json"]);
      +    let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}", text(&o)));
      +    assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("app.check")), "{v}");
      +    (o.status.code().unwrap(), v)
      +}
      +
      +fn files_of(dir: &Path) -> Vec {
      +    fn walk(root: &Path, p: &Path, out: &mut Vec) {
      +        for e in std::fs::read_dir(p).unwrap().flatten() {
      +            let q = e.path();
      +            if q.is_dir() {
      +                walk(root, &q, out);
      +            } else {
      +                out.push(q.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"));
      +            }
      +        }
      +    }
      +    let mut out = Vec::new();
      +    walk(dir, dir, &mut out);
      +    out.sort();
      +    out
      +}
      +
      +#[test]
      +fn an_export_is_an_ordinary_project_and_its_check_holds() {
      +    let d = scratch("export");
      +    let rt = runtime(&d);
      +    std::fs::write(d.join("app.oui"), SHOW).unwrap();
      +    let o = wai(&d, &["create", "show", "--from", "app.oui", "--runtime", rt.to_str().unwrap()]);
      +    assert!(o.status.success(), "{}", text(&o));
      +    let p = d.join("show");
      +    let mut want: Vec = ["app.oui", "index.html", "README.md", "AGENTS.md", ".gitignore", "vendor.json", "wai.json", "LICENSE"].iter().map(|s| s.to_string()).collect();
      +    want.extend(["vendor/create/wai-app-app.js", "vendor/create/pkg/wai_app_bg.wasm", "vendor/room/wai-app-room-client.js"].iter().map(|s| s.to_string()));
      +    want.sort();
      +    assert_eq!(files_of(&p), want);
      +    // The project file: the template's layout, naming the program and the show's room.
      +    let wj = std::fs::read_to_string(p.join("wai.json")).unwrap();
      +    assert_eq!(
      +        wj,
      +        "{\n  \"title\": \"Friday night\",\n  \"summary\": \"A WAI app made with Create: a show, with hosts on camera and an audience that watches and votes.\",\n  \"app\": \"app.oui\",\n  \"dev\": { \"site\": \".\", \"mode\": \"show\", \"audience\": 2 }\n}\n"
      +    );
      +    // A template's project has the same shape: its project file read by the same reader and
      +    // laid out the same way, a README and the licence.
      +    let o = wai(&d, &["create", "room", "--template", "video-room"]);
      +    assert!(o.status.success(), "{}", text(&o));
      +    let t = std::fs::read_to_string(d.join("room/wai.json")).unwrap();
      +    for f in [&wj, &t] {
      +        let lines: Vec<&str> = f.lines().collect();
      +        assert!(lines.first() == Some(&"{") && lines.last() == Some(&"}") && lines[1..lines.len() - 1].iter().all(|l| l.starts_with("  \"")), "{f}");
      +    }
      +    assert_eq!(std::fs::read(d.join("room/LICENSE")).unwrap(), std::fs::read(p.join("LICENSE")).unwrap());
      +    for r in [p.join("README.md"), d.join("room/README.md")] {
      +        assert!(std::fs::read_to_string(&r).unwrap().contains("Apache-2.0"), "{}", r.display());
      +    }
      +    // The program in the project is the canonical form of the one given.
      +    assert!(std::fs::read_to_string(p.join("app.oui")).unwrap().starts_with("root = App("));
      +    let (code, v) = app_check(&p);
      +    assert_eq!(code, 0, "{v}");
      +    assert_eq!(v["result"]["room"]["kind"], "show");
      +    assert!(v["result"]["failures"].as_array().unwrap().is_empty(), "{v}");
      +    let _ = std::fs::remove_dir_all(&d);
      +}
      +
      +#[test]
      +fn the_check_refuses_a_project_that_is_not_whole() {
      +    let d = scratch("refused");
      +    let rt = runtime(&d);
      +    std::fs::write(d.join("app.oui"), SHOW).unwrap();
      +    assert!(wai(&d, &["create", "good", "--from", "app.oui", "--runtime", rt.to_str().unwrap()]).status.success());
      +    let good = d.join("good");
      +    let cases: [Case; 5] = [
      +        ("whose room is not its app's", |p| edit(&p.join("wai.json"), "\"mode\": \"show\", \"audience\": 2", "\"mode\": \"room\""), "dev.mode is \"room\""),
      +        ("whose show has another audience", |p| edit(&p.join("wai.json"), "\"audience\": 2", "\"audience\": 7"), "dev.audience is 7"),
      +        ("that names no room", |p| edit(&p.join("wai.json"), ", \"mode\": \"show\", \"audience\": 2", ""), "names no room"),
      +        ("with a runtime file changed", |p| std::fs::write(p.join("vendor/create/wai-app-app.js"), "export {}; // changed\n").unwrap(), "vendor/create/wai-app-app.js: its SHA-256"),
      +        ("with a line the registry refuses", |p| edit(&p.join("app.oui"), "Participants()", "Hologram()"), "unknown-component"),
      +    ];
      +    for (what, mutate, expect) in cases {
      +        let b = d.join("broken");
      +        let _ = std::fs::remove_dir_all(&b);
      +        let o = Command::new("cp").args(["-R"]).arg(&good).arg(&b).output().unwrap();
      +        assert!(o.status.success());
      +        mutate(&b);
      +        let (code, v) = app_check(&b);
      +        let failures: String = v["result"]["failures"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect::>().join("\n");
      +        assert!(code == 1 && failures.contains(expect), "a project {what}: {code} {v}");
      +    }
      +    let _ = std::fs::remove_dir_all(&d);
      +}
      +
      +/// A project broken one way: what is wrong with it, how to break it, and what the check says.
      +type Case = (&'static str, fn(&Path), &'static str);
      +
      +fn edit(p: &Path, from: &str, to: &str) {
      +    let t = std::fs::read_to_string(p).unwrap();
      +    assert_eq!(t.matches(from).count(), 1, "{from} in {}: {t}", p.display());
      +    std::fs::write(p, t.replace(from, to)).unwrap();
      +}
      +
      +#[test]
      +fn a_watch_room_names_its_file_and_its_check_wants_it() {
      +    let d = scratch("watch");
      +    let rt = runtime(&d);
      +    std::fs::write(d.join("w.oui"), "root = App([w], \"Film night\")\nw = WatchTogether(\"film.wav\", [Participants()])\n").unwrap();
      +    assert!(wai(&d, &["create", "w", "--from", "w.oui", "--runtime", rt.to_str().unwrap()]).status.success());
      +    let p = d.join("w");
      +    assert!(std::fs::read_to_string(p.join("wai.json")).unwrap().contains("\"dev\": { \"site\": \".\", \"mode\": \"watch\", \"watch\": \"media/film.wav\" }"));
      +    assert!(p.join("media/README.md").is_file());
      +    let (code, v) = app_check(&p);
      +    assert!(code == 1 && v["result"]["failures"].to_string().contains("media/film.wav is not there"), "{v}");
      +    std::fs::write(p.join("media/film.wav"), b"RIFF").unwrap();
      +    let (code, v) = app_check(&p);
      +    assert_eq!(code, 0, "{v}");
      +    let _ = std::fs::remove_dir_all(&d);
      +}
      +
      +#[test]
      +fn what_is_not_exported() {
      +    let d = scratch("not");
      +    let rt = runtime(&d);
      +    let rt = rt.to_str().unwrap();
      +    std::fs::write(d.join("bad.oui"), "root = App([r])\nr = Room([Poll(\"q\", [\"a\", \"b\"])])\n").unwrap();
      +    std::fs::write(d.join("noroom.oui"), "root = App([t])\nt = Text(\"no room\")\n").unwrap();
      +    std::fs::write(d.join("app.oui"), SHOW).unwrap();
      +    for (prog, why) in [("bad.oui", "error"), ("noroom.oui", "no room")] {
      +        let o = wai(&d, &["create", "x", "--from", prog, "--runtime", rt]);
      +        assert!(o.status.code() == Some(2) && text(&o).contains(why), "{prog}: {}", text(&o));
      +        assert!(!d.join("x").exists());
      +    }
      +    std::fs::create_dir(d.join("full")).unwrap();
      +    std::fs::write(d.join("full/mine.txt"), "mine").unwrap();
      +    let o = wai(&d, &["create", "full", "--from", "app.oui", "--runtime", rt]);
      +    assert!(o.status.code() == Some(2) && text(&o).contains("not empty"), "{}", text(&o));
      +    let o = wai(&d, &["create", "y", "--from", "app.oui", "--runtime", d.join("nowhere").to_str().unwrap()]);
      +    assert!(o.status.code() == Some(2) && text(&o).contains("runtime"), "{}", text(&o));
      +    let _ = std::fs::remove_dir_all(&d);
      +}
      +
      +// ------------------------------------------------------------------------------- the toolset
      +
      +struct Client {
      +    child: Child,
      +    stdin: ChildStdin,
      +    out: BufReader,
      +    next: i64,
      +    bridge: Option<(u16, String)>,
      +}
      +
      +impl Client {
      +    fn start(project: &Path, extra: &[&str]) -> Client {
      +        let mut child = Command::new(env!("CARGO_BIN_EXE_wai"))
      +            .args(["mcp", "--project"])
      +            .arg(project)
      +            .args(extra)
      +            .stdin(Stdio::piped())
      +            .stdout(Stdio::piped())
      +            .stderr(Stdio::piped())
      +            .spawn()
      +            .unwrap();
      +        let stdin = child.stdin.take().unwrap();
      +        let out = BufReader::new(child.stdout.take().unwrap());
      +        let mut c = Client { child, stdin, out, next: 1, bridge: None };
      +        let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_app test", "version": "1"}}));
      +        assert_eq!(init["result"]["serverInfo"]["name"], "wai", "{init}");
      +        c
      +    }
      +
      +    /// The bridge's address and pairing code, as the server says them at start.
      +    fn read_bridge(&mut self) {
      +        let mut err = BufReader::new(self.child.stderr.take().unwrap());
      +        let mut first = String::new();
      +        err.read_line(&mut first).unwrap();
      +        let port: u16 = first.split("http://127.0.0.1:").nth(1).and_then(|s| s.split(',').next()).and_then(|s| s.parse().ok()).expect(&first);
      +        let code = first.trim().rsplit(' ').next().unwrap().to_string();
      +        self.bridge = Some((port, code));
      +    }
      +
      +    fn request(&mut self, method: &str, params: Value) -> Value {
      +        let id = self.next;
      +        self.next += 1;
      +        writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
      +        self.stdin.flush().unwrap();
      +        let mut l = String::new();
      +        assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output");
      +        serde_json::from_str(&l).unwrap()
      +    }
      +
      +    fn call(&mut self, name: &str, args: Value) -> Result {
      +        let v = self.request("tools/call", json!({"name": name, "arguments": args}));
      +        let r = &v["result"];
      +        assert!(r.is_object(), "{name}: {v}");
      +        if r["isError"] == json!(true) {
      +            return Err(r["content"][0]["text"].as_str().unwrap_or("").to_string());
      +        }
      +        Ok(r["structuredContent"].clone())
      +    }
      +
      +    fn names(&mut self) -> Vec {
      +        let l = self.request("tools/list", json!({}));
      +        l["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
      +    }
      +}
      +
      +impl Drop for Client {
      +    fn drop(&mut self) {
      +        let _ = self.child.kill();
      +        let _ = self.child.wait();
      +    }
      +}
      +
      +#[test]
      +fn create_through_the_app_toolset() {
      +    let d = scratch("mcp");
      +    let rt = runtime(&d);
      +    let mut c = Client::start(&d, &["--toolset", "app"]);
      +    c.read_bridge();
      +    let (port, code) = c.bridge.clone().unwrap();
      +    assert_eq!(c.names(), ["app_guide", "app_check", "app_stream", "app_export"]);
      +    let g = c.call("app_guide", json!({})).unwrap();
      +    assert!(g["guide"].as_str().unwrap().contains("Poll(") && g["code"] == code.as_str(), "{g}");
      +    let ok = c.call("app_check", json!({"program": SHOW})).unwrap();
      +    assert_eq!((ok["ok"].as_bool(), ok["room"]["kind"].as_str()), (Some(true), Some("show")), "{ok}");
      +    let refused = c.call("app_check", json!({"program": "root = App([w])\nw = WatchTogether(\"f.mp4\", [Chat()])\n"})).unwrap();
      +    assert!(refused["ok"] == false && refused["problems"][0].as_str().unwrap().starts_with("line 2: disallowed-child"), "{refused}");
      +    // No page is connected: the stream says how to connect one.
      +    assert!(c.call("app_stream", json!({"program": SHOW})).unwrap_err().contains(&code));
      +    // A page without the code is refused; one with it reads the stream.
      +    let get = |path: &str| {
      +        let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
      +        write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap();
      +        s
      +    };
      +    let mut resp = String::new();
      +    get("/create/stream?code=wrong").read_to_string(&mut resp).unwrap();
      +    assert!(resp.starts_with("HTTP/1.1 403"), "{resp}");
      +    let mut page = BufReader::new(get(&format!("/create/stream?code={code}")));
      +    let mut head = String::new();
      +    page.read_line(&mut head).unwrap();
      +    assert!(head.starts_with("HTTP/1.1 200"), "{head}");
      +    let mut s = Err(String::new());
      +    for _ in 0..100 {
      +        s = c.call("app_stream", json!({"program": SHOW, "chunk": 40, "pace_ms": 0}));
      +        if s.is_ok() {
      +            break;
      +        }
      +        std::thread::sleep(std::time::Duration::from_millis(20));
      +    }
      +    let s = s.unwrap();
      +    assert_eq!(s["delivered"], 1, "{s}");
      +    let mut seen = String::new();
      +    while !seen.contains("event: end") {
      +        let mut l = String::new();
      +        page.read_line(&mut l).unwrap();
      +        seen.push_str(&l);
      +    }
      +    assert!(seen.contains("event: start") && seen.contains("Friday night"), "{seen}");
      +    // An export, inside the project directory alone.
      +    let e = c.call("app_export", json!({"program": SHOW, "dir": "show", "runtime_dir": rt})).unwrap();
      +    assert!(e["files"].as_array().unwrap().iter().any(|f| f == "wai.json"), "{e}");
      +    assert_eq!(app_check(&d.join("show")).0, 0);
      +    let outside = std::env::temp_dir().join(format!("wai-cli-app-outside-{}", std::process::id()));
      +    let err = c.call("app_export", json!({"program": SHOW, "dir": outside, "runtime_dir": rt})).unwrap_err();
      +    assert!(err.contains("outside the project directory") && !outside.exists(), "{err}");
      +    // Read-only: no bridge, and nothing that streams or writes.
      +    let mut ro = Client::start(&d, &["--toolset", "core,app", "--read-only"]);
      +    let names = ro.names();
      +    assert!(names.contains(&"app_check".to_string()) && !names.iter().any(|n| n == "app_stream" || n == "app_export"), "{names:?}");
      +    assert!(ro.call("app_guide", json!({})).unwrap()["bridge"].is_null());
      +    let _ = std::fs::remove_dir_all(&d);
      +}
      -- 
      2.54.0 (Apple Git-157)
      
      
      From bdd4e94442d771f4da3f2e26b6e5a9df362e12f1 Mon Sep 17 00:00:00 2001
      From: Transaction Science 
      Date: Tue, 27 Oct 2026 05:14:19 -0400
      Subject: [PATCH 12/14] CI, the browser end-to-end and docs: Create under
       wai-app, wai create --from and wai dev
      
      - .github/workflows/wai-ui.yml becomes wai-app.yml: the unit job runs wai-app's
        tests, its clippy (native and wasm32) and the line language's verifier; the e2e
        job runs wai/wai-app/e2e/run.sh, which now also reads wai/quickstart/build-web.sh.
      - wai-conformance runs tests/cli_app.rs with the feature `app`, and fires on
        wai/wai-app and the joule-ui crates it builds on.
      - The e2e (run.sh builds `wai` with `app` in a target directory of its own, and
        the room client `wai dev` serves): the Create page served by `wai dev --show`; the
        show recording renders as it streams; the host joins, chats and opens the poll;
        the sealed log verifies; Export hands over app.oui and the command; `wai create
        --from app.oui` writes the project; `wai app check` passes on it and refuses it
        with its room not its app's, another audience, no room named, a runtime file
        changed or a refused line; `wai dev` in the project runs its own room, its keys in
        .wai/ that the front never serves, where a host and a viewer answer a poll the
        room's clock signs; the refused lines never render; the visitor's own model gets
        their key alone; `wai mcp --toolset app` streams into the page through its bridge
        and app_export writes the same project.
      - CHANGELOG: Create as wai-app, `wai create --from app.oui`, `wai app`, the app
        toolset, LICENSE in every project, `wai dev --show`. wai/README.md: the app
        toolset and Create.
      
      Co-Authored-By: Claude Opus 5.5 
      ---
       .github/workflows/{wai-ui.yml => wai-app.yml} |  54 ++--
       .github/workflows/wai-conformance.yml         |  20 ++
       wai/CHANGELOG.md                              |  41 ++-
       wai/README.md                                 |  11 +-
       wai/{wai-ui => wai-app}/e2e/create.e2e.mjs    | 246 +++++++++---------
       wai/{wai-ui => wai-app}/e2e/package-lock.json |   4 +-
       wai/{wai-ui => wai-app}/e2e/package.json      |   2 +-
       wai/{wai-ui => wai-app}/e2e/run.sh            |  31 ++-
       8 files changed, 227 insertions(+), 182 deletions(-)
       rename .github/workflows/{wai-ui.yml => wai-app.yml} (71%)
       rename wai/{wai-ui => wai-app}/e2e/create.e2e.mjs (65%)
       rename wai/{wai-ui => wai-app}/e2e/package-lock.json (96%)
       rename wai/{wai-ui => wai-app}/e2e/package.json (91%)
       rename wai/{wai-ui => wai-app}/e2e/run.sh (71%)
      
      diff --git a/.github/workflows/wai-ui.yml b/.github/workflows/wai-app.yml
      similarity index 71%
      rename from .github/workflows/wai-ui.yml
      rename to .github/workflows/wai-app.yml
      index 14edf5409..fd1ab2da1 100644
      --- a/.github/workflows/wai-ui.yml
      +++ b/.github/workflows/wai-app.yml
      @@ -1,18 +1,22 @@
      -name: wai-ui and Create
      +name: wai-app and Create
       
      -# WAI's widgets for joule-ui and Create (wai/wai-ui), and the line language they take a
      +# WAI's widgets for joule-ui and Create (wai/wai-app), and the line language they take a
       # program in (joule-ui SPEC §11):
       #
      -#   unit   wai-ui's tests (its registry, its rules, the guide, the export writer, the MCP
      -#          server, and the binary driven over stdio by a client), clippy with warnings as
      -#          errors, its WebAssembly surface checked for wasm32; the line language's
      -#          independent verifier over the conformance pack, and its mutations refused; the
      -#          page modules' unit tests (the renderer, the ZIP writer, the model streams);
      -#   e2e    Create in the host's browser (wai/wai-ui/e2e/run.sh): a recorded stream renders
      -#          as it arrives, the room joins a real room service, a chat and a poll go through
      -#          it, the page's sealed log verifies, the export builds and runs against the same
      -#          room, the refused lines never render, and the export's own development setup
      -#          brings its relay and room service up.
      +#   unit   wai-app's tests (its registry, its rules, the guide, the export's files, the
      +#          runtime read by its listing, the bridge), clippy with warnings as errors, its
      +#          WebAssembly surface checked for wasm32; the line language's independent verifier
      +#          over the conformance pack, and its mutations refused; the page modules' unit tests
      +#          (the renderer, the model streams). `wai create --from app.oui`, `wai app check` and
      +#          `wai mcp --toolset app` are wai-rs's tests/cli_app.rs, in wai-conformance;
      +#   e2e    Create in the host's browser (wai/wai-app/e2e/run.sh), served by `wai dev --show`:
      +#          a recorded stream renders as it arrives, the room joins a real room service, a chat
      +#          and a poll go through it, the page's sealed log verifies, Export hands over app.oui,
      +#          `wai create --from app.oui` makes the project and `wai app check` passes on it and
      +#          refuses it broken, `wai dev` runs the project's own room where a host and a viewer
      +#          answer a poll the room's clock signs, the refused lines never render, the visitor's
      +#          own model is called with their key alone, and `wai mcp --toolset app` streams into
      +#          the page through its bridge.
       #
       # The line language's own tests and its pack test run in standards-build (joule-ui-rs).
       #
      @@ -26,7 +30,7 @@ on:
         push:
           branches: [main]
           paths:
      -      - 'wai/wai-ui/**'
      +      - 'wai/wai-app/**'
             - 'joule-ui/**'
             - 'wai/wai-live-web/**'
             - 'wai/wai-rs/**'
      @@ -37,11 +41,12 @@ on:
             - 'joule-code/joule-code-rs/crates/jwp/**'
             - 'joule-code/joule-code-rs/Cargo.toml'
             - 'joule-code/joule-code-rs/Cargo.lock'
      +      - 'wai/quickstart/build-web.sh'
             - 'rust-toolchain.toml'
      -      - '.github/workflows/wai-ui.yml'
      +      - '.github/workflows/wai-app.yml'
         pull_request:
           paths:
      -      - 'wai/wai-ui/**'
      +      - 'wai/wai-app/**'
             - 'joule-ui/**'
             - 'wai/wai-live-web/**'
             - 'wai/wai-rs/**'
      @@ -52,15 +57,16 @@ on:
             - 'joule-code/joule-code-rs/crates/jwp/**'
             - 'joule-code/joule-code-rs/Cargo.toml'
             - 'joule-code/joule-code-rs/Cargo.lock'
      +      - 'wai/quickstart/build-web.sh'
             - 'rust-toolchain.toml'
      -      - '.github/workflows/wai-ui.yml'
      +      - '.github/workflows/wai-app.yml'
       
       jobs:
         # Forgejo matches `paths:` against the push delta, so a rebase fires this workflow
         # whatever the pull request changes. This job reads the pull request's own diff against
         # the paths above (scripts/pr-touches-paths.py, which fails open).
         relevant:
      -    name: wai-ui · does this change touch it
      +    name: wai-app · does this change touch it
           runs-on: linux
           outputs:
             run: ${{ steps.gate.outputs.run }}
      @@ -71,16 +77,16 @@ jobs:
             - id: gate
               run: |
                 set -euo pipefail
      -          r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-ui.yml \
      +          r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-app.yml \
                       "${{ github.event.pull_request.base.sha }}" \
                       "${{ github.event.pull_request.head.sha }}")
      -          echo "this change touches wai-ui: $r"
      +          echo "this change touches wai-app: $r"
                 echo "run=$r" >> "$GITHUB_OUTPUT"
       
         # Nothing here needs macOS: it runs on the linux runner, leaving the macOS host to the
         # browser job.
         unit:
      -    name: wai-ui · tests, clippy, the line language's verifier (linux)
      +    name: wai-app · tests, clippy, the line language's verifier (linux)
           needs: relevant
           if: needs.relevant.outputs.run == 'true'
           runs-on: linux
      @@ -98,7 +104,7 @@ jobs:
                 echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
             - name: Tests and clippy
               shell: bash
      -        working-directory: wai/wai-ui
      +        working-directory: wai/wai-app
               run: |
                 set -euo pipefail
                 rustup target add wasm32-unknown-unknown
      @@ -114,10 +120,10 @@ jobs:
                 python3 -I joule-ui/conformance/lang_verify.py --mutations
             - name: The page modules
               shell: bash
      -        run: node --test wai/wai-ui/web/web.test.mjs
      +        run: node --test wai/wai-app/web/web.test.mjs
       
         e2e:
      -    name: wai-ui · Create, browser end-to-end (macos)
      +    name: wai-app · Create, browser end-to-end (macos)
           needs: relevant
           if: needs.relevant.outputs.run == 'true'
           runs-on: macos
      @@ -145,6 +151,6 @@ jobs:
                 test -x "$BROWSER_BIN" || { echo "::error::this runner has no browser at $BROWSER_BIN"; exit 1; }
                 mg=/Volumes/macos_4TB_external_1/wai-build/bin/memguard
                 if [ -x "$mg" ]; then export MEMGUARD="$mg"; fi
      -          nice -n 10 bash wai/wai-ui/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/create-e2e.log"
      +          nice -n 10 bash wai/wai-app/e2e/run.sh 2>&1 | tee "$RUNNER_TEMP/create-e2e.log"
                 grep -qE '^create e2e: [1-9][0-9]* passed, 0 failed$' "$RUNNER_TEMP/create-e2e.log" \
                   || { echo "::error::the Create end-to-end run did not pass"; exit 1; }
      diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml
      index f19903a71..b73db1b79 100644
      --- a/.github/workflows/wai-conformance.yml
      +++ b/.github/workflows/wai-conformance.yml
      @@ -43,6 +43,12 @@ on:
             - 'wai/wai-quantum/**'
             # The integer decoders that decode through the integer operator set depend on it.
             - 'wai/wai-ops/**'
      +      # `wai create --from app.oui`, `wai app` and `wai mcp --toolset app` (the feature `app`)
      +      # link Create's library and the joule-ui crates it builds on.
      +      - 'wai/wai-app/**'
      +      - 'joule-ui/joule-ui-rs/crates/joule-ui-core/**'
      +      - 'joule-ui/joule-ui-rs/crates/joule-ui-lang/**'
      +      - 'joule-ui/joule-ui-rs/crates/joule-ui-interaction/**'
             - 'wai/*-conformance/**'
             - 'wai/byte-exact-conformance/**'
             # The registry export and the two library tests that read files outside
      @@ -637,6 +643,20 @@ jobs:
               run: |
                 cargo test --no-default-features --features mcp --bin wai --test cli_mcp --test cli_setup_agent
                 cargo test --no-default-features --lib ops::
      +      # Apps made with Create, through `wai` built with the feature `app` (wai/wai-app linked
      +      # in). tests/cli_app.rs: `wai create --from app.oui` writes an ordinary project, its
      +      # wai.json laid out as a template's and naming the program and the room `wai dev` runs,
      +      # its runtime under vendor/ and LICENSE; `wai app check` passes on it and refuses it with
      +      # its room not its app's, another audience, no room named, a runtime file changed or a
      +      # refused line, and a watch room until its file is there; `wai create --from` refuses a
      +      # program with errors or no room, and a directory with anything in it; `wai mcp --toolset
      +      # app`: the tools app_guide, app_check, app_stream and app_export, a program streamed
      +      # through the bridge to a page with the pairing code and refused to one without, an export
      +      # inside the project directory alone, and read-only with no bridge. `--bin wai` runs the
      +      # binary's unit tests with the app toolset in its table, and the dev front's show mode.
      +      - name: Apps made with Create (wai create --from, wai app, wai mcp --toolset app)
      +        working-directory: wai/wai-rs
      +        run: cargo test --no-default-features --features app --bin wai --test cli_app
             # tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format
             # §4.1) with signed receipts: a chain for each track, a receipt held for its parent and
             # admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked
      diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md
      index 990354a5f..f995e7cd8 100644
      --- a/wai/CHANGELOG.md
      +++ b/wai/CHANGELOG.md
      @@ -6,27 +6,40 @@ wire format may change and such changes are recorded here.
       
       ## v0.4.0 — current
       
      -### `wai-ui` and Create: a WAI app described, built as a model writes it, exported
      +### Create: a WAI app described, built as a model writes it, and made a project by `wai create --from app.oui`
       
      -- **The widgets** ([`wai-ui`](wai-ui/)): a joule-ui registry of WAI widgets, `App`, `Room`,
      -  `WatchTogether`, `Show`, `Player`, `Chat`, `Poll`, `Participants`, `Receipts`,
      -  `EnergyMeter` and `Text`, each backed by the room client of `joule-moq-web` running in a
      -  frame of the page. Each kind of room accepts only the widgets its room service keeps: a
      -  watch room holds no `Chat` and no `Poll`, and a line that asks for one is refused
      +- **The widgets** ([`wai-app`](wai-app/), a library with no binary): a joule-ui registry of WAI
      +  widgets, `App`, `Room`, `WatchTogether`, `Show`, `Player`, `Chat`, `Poll`, `Participants`,
      +  `Receipts`, `EnergyMeter` and `Text`, each backed by the room client of `joule-moq-web`
      +  running in a frame of the page. Each kind of room accepts only the widgets its room service
      +  keeps: a watch room holds no `Chat` and no `Poll`, and a line that asks for one is refused
         `disallowed-child` at its line. An app holds one room; a poll has 2 to 32 options; a show's
         audience is 1 to 255; a watch room's source is a file within the app.
       - **Create**: a page where a person describes an app and a model writes it in joule-ui's line
         language ([joule-ui SPEC](../joule-ui/SPEC.md) §11), which the page parses as it streams and
         renders widget by widget, every widget validated against the registry. The page calls no
         model of its own and holds no key: the visitor's own model (its address and their key,
      -  called from the page alone), their coding agent (`wai-ui mcp`, an MCP server with a bridge
      -  on 127.0.0.1), or a recorded stream. Every action on the page is sealed into a hash-chained
      -  log the page signs with its own key and verifies (joule-ui-interaction; the figures are
      -  estimates, labelled `Estimator`: a page has no meter).
      -- **Export**: one writer (`wai_ui::export`) makes the starter project: the program in its
      -  canonical form, its tree, the room service's configuration for its kind of room, the relay's,
      -  the page and its runtime, a check that is the project's build, a development front, a
      -  README and an AGENTS.md. No key is in it.
      +  called from the page alone), their coding agent (`wai mcp --toolset app`, whose bridge on
      +  127.0.0.1 the page reads with a pairing code), or a recorded stream. Every action on the page
      +  is sealed into a hash-chained log the page signs with its own key and verifies
      +  (joule-ui-interaction; the figures are estimates, labelled `Estimator`: a page has no meter).
      +  Export hands over the program, `app.oui`, and the command that makes it a project.
      +- **`wai create  --from app.oui`** (`app.export`; the feature `app`): an ordinary WAI
      +  project, the same kind `wai create --template` writes: its `wai.json`, written by the one
      +  writer every project file has, names the program (`app`) and the room `wai dev` runs for it
      +  (`dev.mode` `room`, `watch` or `show`, a watch room's file, a show's `audience`); beside it
      +  the program in its canonical form, the page and its runtime under `vendor/` (each file
      +  checked against the runtime's listing), a README and an AGENTS.md. No key is in it.
      +  **`wai app guide`** and **`wai app check`** (`app.guide`, `app.check`): the guide a model
      +  writes against, and the app, the room its project file names and its runtime checked.
      +  **`wai mcp --toolset app`**: `app_guide`, `app_check`, `app_stream` (into the person's open
      +  Create page through the bridge) and `app_export`.
      +- **Every project `wai create` writes carries its licence**, Apache-2.0, as `LICENSE`, and every
      +  example's README says so.
      +- **`wai dev --show [--audience N]`** (or `"mode": "show"` in `wai.json`): a room with
      +  co-performance, its relay holding the floor to the room's clock. `wai dev`'s front never
      +  serves a path with a segment that starts with a dot, so a project's `.wai/`, where its room's
      +  keys are, stays off the origin.
       
       ### Coding agents: `wai mcp`, `wai setup-agent`, and the room service's API described
       
      diff --git a/wai/README.md b/wai/README.md
      index 908d338bf..2dbe8f5b3 100644
      --- a/wai/README.md
      +++ b/wai/README.md
      @@ -111,6 +111,7 @@ operation of the toolchain, named by it with `_` for the dot, and the tools come
       |---|---|
       | `core` (the default) | `envelope_inspect`, `registry_list`, `registry_show`, `receipt_verify`, `package_build`, `conformance_list`, `conformance_run`, `docs_search` |
       | `dev` | `dev_start`, `dev_stop`, `room_grant`, `room_join`, `room_leave`, `room_session` |
      +| `app` (a build with the feature `app`) | `app_guide`, `app_check`, `app_stream`, `app_export`: Create's, below |
       
       `receipt_verify` verifies a delivery receipt, a claim, a live-room session or a stream audit
       bundle, and answers with the verifier's verdict and reason code. The `dev` tools, marked
      @@ -126,8 +127,14 @@ cargo install --path . --no-default-features --features mcp   # `wai` with `wai
       cd /path/to/your/project && wai setup-agent --toolset core,dev
       ```
       
      -The quickstart's `wai` holds `wai mcp` too. The room service's HTTP API is described, OpenAPI
      -3.1, in [`joule-moq-room/openapi.json`](../joule-code/joule-code-rs/crates/joule-moq-room/openapi.json),
      +The quickstart's `wai` holds `wai mcp` too.
      +
      +**Create** ([`wai-app`](wai-app/)) builds a WAI app from a description: a model writes it in
      +joule-ui's line language and the page renders it as it streams. Its program, `app.oui`, becomes
      +a project with `wai create my-app --from app.oui` (a build with the feature `app`): an ordinary
      +WAI project that `wai app check` checks and `wai dev` runs.
      +
      +The room service's HTTP API is described, OpenAPI 3.1, in [`joule-moq-room/openapi.json`](../joule-code/joule-code-rs/crates/joule-moq-room/openapi.json),
       each operation's `operationId` its name in the toolchain's catalogue.
       
       ## SDK consumption
      diff --git a/wai/wai-ui/e2e/create.e2e.mjs b/wai/wai-app/e2e/create.e2e.mjs
      similarity index 65%
      rename from wai/wai-ui/e2e/create.e2e.mjs
      rename to wai/wai-app/e2e/create.e2e.mjs
      index e8b771973..3dd095327 100644
      --- a/wai/wai-ui/e2e/create.e2e.mjs
      +++ b/wai/wai-app/e2e/create.e2e.mjs
      @@ -1,33 +1,37 @@
       // Create, end to end, in the host's browser: a recorded model stream fed to the page, the
      -// app rendering as it arrives, its room joined on a real room service, a poll opened and
      -// answered, and the exported project built and run.
      +// app rendering as it arrives, its room joined on a real room service that `wai dev` runs, a
      +// poll opened and answered, the app taken away as app.oui and made a project by
      +// `wai create --from app.oui`, and that project checked and run with `wai dev`.
       //
       //   node create.e2e.mjs --rt  --relay  --room 
      -//                       --ui  --out  [--browser ] [--site ]
      +//                       --wai  --web  --out 
      +//                       [--browser ] [--site ]
       //
       // With --site, the WAI site's build is served instead of the runtime's directory, and its
      -// /create/ page is driven: the page as the site serves it, with its runtime under /wai-ui/.
      +// /create/ page is driven: the page as the site serves it, with its runtime under /wai-app/.
       //
       // Scenarios, in order:
      -//   create   the `show` recording replayed into Create: widgets appear while the stream is
      -//            still arriving and only grow in number; every snapshot's tree validates; the room
      -//            widget's frame loads the room client while later lines still stream; the host
      -//            joins (the room's clock records it), sends a chat, opens the poll; the page's
      -//            sealed log holds each action and verifies; the export downloads, its text files
      -//            the same bytes `wai-ui export` writes
      -//   built    the exported project: `node check.mjs` (its build) passes and writes dist/; its
      -//            own front (`node serve.mjs`) serves the app against the same room service; a
      -//            viewer there watches, sees the host's chat, votes, and both pages show the
      -//            result the room's clock signed
      +//   create   the `show` recording replayed into Create, served by `wai dev --show` (its relay,
      +//            its room service): widgets appear while the stream is still arriving and only grow
      +//            in number; every snapshot's tree validates; the room widget's frame loads the room
      +//            client while later lines still stream; the host joins (the room's clock records
      +//            it), sends a chat, opens the poll; the page's sealed log holds each action and
      +//            verifies; Export hands over app.oui, the program in its canonical form, and the
      +//            command that makes it a project; `wai create --from app.oui` writes the project
      +//   built    the exported project: `wai app check` passes, and refuses it with its room not
      +//            its app's, another audience, no room named, a runtime file changed, or a refused
      +//            line; `wai dev` in the project runs its own relay and room service from wai.json,
      +//            its keys in .wai/ (which .gitignore keeps out and the front never serves); there a
      +//            host joins, chats and opens the poll, a viewer watches, reads the chat and votes,
      +//            and both pages show the result the room's clock signed
       //   refused  the `watch-refused` recording: the Chat and Poll a watch room cannot keep never
       //            render, each refused `disallowed-child` at its line, and export stays off
       //   own      the visitor's own model: a stand-in chat/completions server on loopback streams
       //            the room recording; the page renders it, and the key it was given goes to that
       //            endpoint alone, in its Authorization header, and is stored nowhere
      -//   agent    the visitor's coding agent: `wai-ui mcp` driven over its stdio streams the show
      -//            into the page through its bridge, the page connected with the pairing code
      -//   up       the exported project's `node serve.mjs --up` starts its own relay and room
      -//            service from its configuration, keys made in .wai-dev/, and a page there watches
      +//   agent    the visitor's coding agent: `wai mcp --toolset app` driven over its stdio streams
      +//            the show into the page through its bridge, the page connected with the pairing
      +//            code; `app_export` writes the project `wai create --from` writes
       //
       // Every wait is bounded; nothing listens beyond loopback.
       import fs from "node:fs";
      @@ -43,16 +47,16 @@ const { serveSameOrigin, browserArgs, memguardedBrowser, killStrays, until, slee
       
       const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith("--") ? [...a, [x.slice(2), all[i + 1] && !all[i + 1].startsWith("--") ? all[i + 1] : "1"]] : a), []));
       const need = (k) => { if (!args[k]) { console.error(`create e2e: --${k} is required`); process.exit(2); } return path.resolve(args[k]); };
      -const RELAY = need("relay"), ROOM = need("room"), UI = need("ui");
      -// The runtime's directory (e2e/run.sh lays it out), served as the root with Create's
      +const RELAY = need("relay"), ROOM = need("room"), WAI = need("wai"), WEB = need("web");
      +// The runtime's directory (e2e/run.sh lays it out), served as the site with Create's
       // development page at /create/index.html; or, with --site, a WAI site build, whose /create/
      -// page loads the runtime from /wai-ui/.
      +// page loads the runtime from /wai-app/.
       const SITE = args.site ? path.resolve(args.site) : null;
      -const RT = SITE ? path.join(SITE, "wai-ui") : need("rt");
      +const RT = SITE ? path.join(SITE, "wai-app") : need("rt");
       const ROOT = SITE || RT;
       const CREATE = SITE ? "/create/" : "/create/index.html";
       const OUT = need("out");
      -const ONLY = (args.only || "create,built,refused,own,agent,up").split(",");
      +const ONLY = (args.only || "create,built,refused,own,agent").split(",");
       // Each run starts from no results: the room service's keygen refuses a key file a previous
       // run left.
       fs.rmSync(OUT, { recursive: true, force: true });
      @@ -80,31 +84,28 @@ function run(bin, argv, name, dir, env = {}) {
         return p;
       }
       const tail = (f) => { try { return fs.readFileSync(f, "utf8").split("\n").slice(-8).join(" | "); } catch { return ""; } };
      +// `wai` in `cwd`: its exit status and its output.
      +const wai = (argv, cwd) => {
      +  try {
      +    return { code: 0, out: execFileSync(WAI, argv, { cwd, stdio: ["ignore", "pipe", "pipe"] }).toString() };
      +  } catch (e) {
      +    return { code: e.status, out: `${e.stdout || ""}${e.stderr || ""}` };
      +  }
      +};
      +const envelope = (out) => JSON.parse(out.split("\n").find((l) => l.startsWith("{")) || "{}");
       
      -// The room service and the relay for a project's room.toml and relay.toml, on free ports,
      -// keys made in `dir`: as the project's own `serve.mjs --up` makes them, by hand here so the
      -// test holds the processes.
      -async function services(project, dir) {
      -  const keys = path.join(dir, "room-keys.toml");
      -  const lines = execFileSync(ROOM, ["keygen", keys], { stdio: ["ignore", "pipe", "pipe"] }).toString();
      -  const clock = lines.match(/^# clock\s+([0-9a-f]{64})/m)[1];
      -  const relayPort = await freePort();
      -  const roomPort = await freePort();
      -  const roomToml = fs.readFileSync(path.join(project, "room.toml"), "utf8");
      -  const show = /^\[co\][^[]*^audience_input\s*=\s*[1-9]/m.test(roomToml);
      -  let relay = fs.readFileSync(path.join(project, "relay.toml"), "utf8").replace(/^websocket_listen = .*$/m, `websocket_listen = "127.0.0.1:${relayPort}"`).replace(/^out_path = .*$/m, `out_path = ${JSON.stringify(path.join(dir, "summaries.jsonl"))}`);
      -  relay += `\n${lines}\n${show ? `\n[floor]\nroom_keys = ["${clock}"]\n` : ""}`;
      -  fs.writeFileSync(path.join(dir, "relay.toml"), relay);
      -  const room = roomToml.replace(/^listen = .*$/m, `listen = "127.0.0.1:${roomPort}"`).replace(/^keys = .*$/m, `keys = ${JSON.stringify(keys)}`).replace(/^relay_url = .*$/m, `relay_url = "ws://127.0.0.1:${relayPort}/moq"`);
      -  fs.writeFileSync(path.join(dir, "room.toml"), room);
      -  const r = run(RELAY, ["--config", path.join(dir, "relay.toml")], "relay", dir);
      -  await until("the relay listens", 30000, () => new Promise((ok, no) => { if (r.exitCode != null) return no(new Error(`the relay exited: ${tail(r.logFile)}`)); const s = net.connect(relayPort, "127.0.0.1", () => { s.end(); ok(true); }); s.on("error", () => ok(false)); }), 200);
      -  const s = run(ROOM, ["--config", path.join(dir, "room.toml")], "room", dir);
      -  await until("the room service's clock on the relay", 30000, async () => {
      -    if (s.exitCode != null) throw new Error(`the room service exited: ${tail(s.logFile)}`);
      -    try { const x = await fetch(`http://127.0.0.1:${roomPort}/health`); return x.ok && (await x.json()).relay === true; } catch { return false; }
      -  }, 200);
      -  return { relayPort, roomPort, url: `http://127.0.0.1:${roomPort}`, show };
      +// `wai dev` in `cwd` (a project, or with --site a directory of pages), on a free port: its
      +// relay and room service, with this host's binaries and room client. Resolves once it is ready.
      +async function waiDev(cwd, name, extra = []) {
      +  const port = await freePort();
      +  const dir = path.join(OUT, name);
      +  fs.mkdirSync(dir, { recursive: true });
      +  const p = run(WAI, ["dev", "--port", String(port), "--web", WEB, "--relay", RELAY, "--room", ROOM, ...extra], name, dir, { CWD: cwd });
      +  await until(`wai dev (${name}) is ready`, 90000, () => {
      +    if (p.exitCode != null) throw new Error(`wai dev exited: ${tail(p.logFile)}`);
      +    return /wai dev: ready on/.test(fs.readFileSync(p.logFile, "utf8"));
      +  }, 300);
      +  return { port, url: `http://127.0.0.1:${port}`, proc: p, dir };
       }
       
       let browser;
      @@ -129,23 +130,18 @@ async function page(url, name) {
       }
       const roomOf = (p) => p.evaluate(() => { const r = (window.createTest || window.appTest).ctx.room.current; return r ? { phase: r.phase(), me: r.me(), co: r.co(), chats: r.chats(), checked: r.checked(), present: r.present() } : null; });
       
      -const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `wai-ui-${n}.json`), "utf8"));
      +const recording = (n) => JSON.parse(fs.readFileSync(path.join(RT, "create/recorded", `wai-app-${n}.json`), "utf8"));
       const SHOW = recording("show");
       const SHOW_TEXT = SHOW.chunks.join("");
       
      -let shared = null; // the room service and relay of the show, the Create page, the export
      +let shared = null; // the Create page's `wai dev`, the page, the exported project
       async function scenarioCreate() {
         const dir = path.join(OUT, "create");
         fs.mkdirSync(dir, { recursive: true });
      -  // The project `wai-ui export` writes for the recording's program: its room.toml and relay.toml
      -  // configure the room service the page tries the app on.
      -  fs.writeFileSync(path.join(dir, "show.oui"), SHOW_TEXT);
      -  const cli = path.join(dir, "cli-export");
      -  fs.rmSync(cli, { recursive: true, force: true });
      -  execFileSync(UI, ["export", path.join(dir, "show.oui"), cli], { stdio: ["ignore", "pipe", "pipe"] });
      -  const svc = await services(cli, dir);
      -  check("create: the exported room.toml configures a room service that joins its relay", true, `room service :${svc.roomPort}, relay :${svc.relayPort}, show=${svc.show}`);
      -  const srv = await serveSameOrigin(ROOT, { service: svc.url, relayPort: svc.relayPort });
      +  // The Create page, served by `wai dev` with a show's room: the site is the runtime.
      +  const dev = await waiDev(ROOT, "create-dev", ["--site", ROOT, "--show", "--audience", "2", "--dir", path.join(OUT, "create-dev", "state")]);
      +  check("create: wai dev runs a show's relay and room service behind the Create page", true, dev.url);
      +  const srv = { url: dev.url, close: async () => {} };
         const p = await page(`${srv.url}${CREATE}`, "create-host");
         await until("Create loads its session", STEP, () => p.evaluate(() => !!window.createTest));
         // Watch the page while the stream arrives: when each widget appears, and how much of the
      @@ -205,82 +201,94 @@ async function scenarioCreate() {
         const cut = await p.evaluate(async () => {
           const raw = JSON.parse(window.createTest.session.logRaw());
           raw.splice(0, 1);
      -    const at = new URL("./wai-ui-session.js", new URL(document.getElementById("create").dataset.runtime, location.href));
      +    const at = new URL("./wai-app-session.js", new URL(document.getElementById("create").dataset.runtime, location.href));
           return (await import(at.href)).verifyLog(JSON.stringify(raw));
         });
         check("create: the log with its first link cut out does not verify", JSON.parse(cut).broken_at === 0, cut);
       
      -  // Export.
      +  // Export: the program, as app.oui, and the command that makes it a project.
         const dl = p.waitForEvent("download", { timeout: STEP });
         await p.click("#export");
         const d = await dl;
      -  const zipFile = path.join(dir, "export.zip");
      -  await d.saveAs(zipFile);
      -  const ex = path.join(dir, "exported");
      -  fs.rmSync(ex, { recursive: true, force: true });
      -  fs.mkdirSync(ex, { recursive: true });
      -  execFileSync("unzip", ["-q", zipFile, "-d", ex]);
      -  const top = fs.readdirSync(ex)[0];
      -  const project = path.join(ex, top);
      -  const same = ["app.oui", "app.json", "room.toml", "relay.toml", "README.md", "AGENTS.md", "check.mjs", "serve.mjs", "index.html", "package.json", ".gitignore"].filter((f) => fs.readFileSync(path.join(project, f), "utf8") !== fs.readFileSync(path.join(cli, f), "utf8"));
      -  check("create: the page's export holds the project files `wai-ui export` writes, byte for byte", same.length === 0, same.length ? `differ: ${same.join(", ")}` : `${top}/: ${fs.readdirSync(project).length} entries`);
      -  const vendor = JSON.parse(fs.readFileSync(path.join(project, "vendor.json"), "utf8"));
      -  check("create: the export carries the runtime under vendor/, its room configuration, a README and an AGENTS.md, and no key", vendor.files.length > 10 && fs.existsSync(path.join(project, "room.toml")) && fs.existsSync(path.join(project, "AGENTS.md")) && !/key\s*=\s*"[^.]/.test(fs.readFileSync(path.join(project, "room.toml"), "utf8")), `${vendor.files.length} runtime files`);
      -  shared = { svc, srv, p, project, window: w, dir };
      +  const oui = path.join(dir, "app.oui");
      +  await d.saveAs(oui);
      +  const canonical = await p.evaluate(() => window.createTest.session.canonical());
      +  const command = await p.evaluate(() => document.getElementById("export-command").textContent);
      +  check("create: Export hands over app.oui, the program in its canonical form, and the command that makes it a project", d.suggestedFilename() === "app.oui" && fs.readFileSync(oui, "utf8") === canonical && /^wai create [a-z0-9-]+ --from app\.oui$/.test(command), command);
      +  const project = path.join(dir, "exported");
      +  const made = wai(["create", "exported", "--from", "app.oui", "--runtime", RT], dir);
      +  const files = made.code === 0 ? fs.readdirSync(project).sort() : [];
      +  const wj = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "wai.json"), "utf8")) : { dev: {} };
      +  check("create: `wai create --from app.oui` writes the project: the program, wai.json naming its show, the page, its runtime, README, AGENTS.md and LICENSE",
      +    made.code === 0 && ["app.oui", "wai.json", "index.html", "README.md", "AGENTS.md", "LICENSE", "vendor.json", "vendor", ".gitignore"].every((f) => files.includes(f)) && wj.app === "app.oui" && wj.dev.mode === "show" && wj.dev.audience === 2 && fs.readFileSync(path.join(project, "app.oui"), "utf8") === canonical,
      +    made.code === 0 ? `${files.join(" ")}; dev ${JSON.stringify(wj.dev)}` : made.out);
      +  const vendor = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "vendor.json"), "utf8")) : { files: [] };
      +  check("create: the project carries the runtime under vendor/, and no key", vendor.files.length > 10 && !fs.readdirSync(project).some((f) => /key/i.test(f)), `${vendor.files.length} runtime files`);
      +  shared = { dev, srv, p, project, window: w, dir };
       }
       
       async function scenarioBuilt() {
         if (!shared) return check("built: needs the create scenario", false);
      -  const { svc, p, project, dir } = shared;
      -  const out = execFileSync("node", ["check.mjs"], { cwd: project, stdio: ["ignore", "pipe", "pipe"] }).toString();
      -  check("built: the exported project's build (node check.mjs) passes and writes dist/", fs.existsSync(path.join(project, "dist/index.html")) && fs.existsSync(path.join(project, "dist/vendor/create/pkg/wai_ui_bg.wasm")), out.trim());
      -  // Negative controls: the build refuses a project that is not whole.
      +  const { project, dir } = shared;
      +  const ok = wai(["app", "check", "--json"], project);
      +  const okv = envelope(ok.out);
      +  check("built: `wai app check` passes on the exported project", ok.code === 0 && okv.ok === true, ((okv.result && okv.result.checked) || []).join("; "));
      +  // Negative controls: the check refuses a project that is not whole.
         const broken = path.join(dir, "exported-broken");
      +  const edit = (f, from, to) => { const t = fs.readFileSync(f, "utf8"); if (!from.test(t)) throw new Error(`${f}: ${from}`); fs.writeFileSync(f, t.replace(from, to)); };
         for (const [what, mutate, expect] of [
      -    ["without its room configuration", (d) => fs.rmSync(path.join(d, "room.toml")), /room\.toml is missing/],
      -    ["whose room configuration is not its app's", (d) => fs.writeFileSync(path.join(d, "room.toml"), fs.readFileSync(path.join(d, "room.toml"), "utf8").replace(/^audience_input = \d+$/m, "audience_input = 7")), /audience_input is 7/],
      -    ["with a runtime file changed", (d) => fs.appendFileSync(path.join(d, "vendor/create/wai-ui-components.js"), "\n// changed\n"), /wai-ui-components\.js: its SHA-256/],
      -    ["with a line the registry refuses", (d) => fs.writeFileSync(path.join(d, "app.oui"), fs.readFileSync(path.join(d, "app.oui"), "utf8").replace(/^chat1 = Chat\(.*\)$/m, "chat1 = Hologram()")), /line \d+: unknown-component/],
      +    ["whose room is not its app's", (d) => edit(path.join(d, "wai.json"), /"mode": "show", "audience": 2/, '"mode": "room"'), /dev\.mode is "room"/],
      +    ["whose show has another audience", (d) => edit(path.join(d, "wai.json"), /"audience": 2/, '"audience": 7'), /dev\.audience is 7/],
      +    ["that names no room", (d) => edit(path.join(d, "wai.json"), /, "mode": "show", "audience": 2/, ""), /names no room/],
      +    ["with a runtime file changed", (d) => fs.appendFileSync(path.join(d, "vendor/create/wai-app-components.js"), "\n// changed\n"), /wai-app-components\.js: its SHA-256/],
      +    ["with a line the registry refuses", (d) => edit(path.join(d, "app.oui"), /^(\w+) = Chat\(.*\)$/m, "$1 = Hologram()"), /line \d+: unknown-component/],
         ]) {
           fs.rmSync(broken, { recursive: true, force: true });
           fs.cpSync(project, broken, { recursive: true });
      -    fs.rmSync(path.join(broken, "dist"), { recursive: true, force: true });
           mutate(broken);
      -    let code = 0, err = "";
      -    try { execFileSync("node", ["check.mjs"], { cwd: broken, stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { code = e.status; err = String(e.stderr); }
      -    check(`built: the build refuses a project ${what}`, code === 1 && expect.test(err) && !fs.existsSync(path.join(broken, "dist")), err.split("\n").filter(Boolean).slice(0, 2).join(" | "));
      +    const r = wai(["app", "check", "--json"], broken);
      +    const v = envelope(r.out);
      +    const failures = ((v.result && v.result.failures) || []).join(" | ");
      +    check(`built: \`wai app check\` refuses a project ${what}`, r.code === 1 && expect.test(failures), failures.slice(0, 200));
         }
      -  // Its own front, against the show's room service and relay.
      -  const port = await freePort();
      -  const front = run("node", [path.join(project, "serve.mjs")], "exported-front", dir, { PORT: String(port), ROOM_PORT: String(svc.roomPort), RELAY_PORT: String(svc.relayPort), CWD: project });
      -  await until("the exported app's front", 20000, async () => { if (front.exitCode != null) throw new Error(tail(front.logFile)); try { return (await fetch(`http://127.0.0.1:${port}/`)).ok; } catch { return false; } }, 200);
      -  const v = await page(`http://127.0.0.1:${port}/`, "exported-viewer");
      -  await until("the exported app renders", STEP, () => v.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8));
      +  // The project's own development setup: `wai dev` in it, from its wai.json.
      +  const dev = await waiDev(project, "exported-dev");
      +  const keys = path.join(project, ".wai/dev/room-keys.toml");
      +  const leaked = await fetch(`${dev.url}/.wai/dev/room-keys.toml`).then((r) => r.status).catch((e) => String(e));
      +  check("built: wai dev makes the room's keys in .wai/, which .gitignore keeps out and the front never serves", fs.existsSync(keys) && fs.readFileSync(path.join(project, ".gitignore"), "utf8").includes(".wai/") && leaked !== 200, `GET /.wai/dev/room-keys.toml: ${leaked}`);
      +  const h = await page(`${dev.url}/`, "exported-host");
      +  await until("the exported app renders", STEP, () => h.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8));
         check("built: the exported app renders its eight widgets from app.oui", true);
      +  await until("its room client is ready", STEP, () => h.evaluate(() => { const r = window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
      +  await h.click('[data-widget="Show"] [data-act="join"]');
      +  await until("the host is in the project's room, publishing", STEP, async () => { const r = await roomOf(h); return r && r.phase === "publishing" && r; }, 300);
      +  await h.fill('[data-widget="Chat"] input', "Welcome to the show");
      +  await h.click('[data-widget="Chat"] button[type="submit"]');
      +  await until("the host's chat is in the room's verified record", STEP, async () => (await roomOf(h)).chats.some((c) => c.text === "Welcome to the show"), 300);
      +  await until("the host leads the show", STEP, async () => { const r = await roomOf(h); return r.co && r.co.lead; }, 300);
      +  const v = await page(`${dev.url}/`, "exported-viewer");
      +  await until("the viewer's app renders", STEP, () => v.evaluate(() => window.appTest && document.querySelectorAll("#wai-app [data-widget]").length === 8));
         await until("its room client is ready", STEP, () => v.evaluate(() => { const r = window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
         await v.click('[data-widget="Show"] [data-act="watch"]');
         const viewer = await until("the viewer watches", STEP, async () => { const r = await roomOf(v); return r && r.phase === "watching" && r; }, 300);
      -  check("built: a viewer of the exported app enters the same room", !!viewer.me, viewer.me.slice(0, 16));
      +  check("built: in the project's own room, a host joins and a viewer enters", !!viewer.me, viewer.me.slice(0, 16));
         await until("the viewer reads the host's chat", STEP, async () => (await roomOf(v)).chats.some((c) => c.text === "Welcome to the show"), 300);
         check("built: the viewer reads the host's chat, verified", true);
      -  // The poll the host opened: open again if its window has closed meanwhile.
      -  let r = await roomOf(v);
      -  let open = r.co && r.co.windows.find((x) => x.question === "Which song next?" && x.close > r.co.now);
      -  if (!open) {
      -    await p.click('[data-widget="Poll"] button.wai-primary');
      -    open = await until("a window open now", STEP, async () => { const x = await roomOf(v); return x.co && x.co.windows.find((y) => y.question === "Which song next?" && y.close > x.co.now); }, 300);
      -  }
      +  await h.click('[data-widget="Poll"] button.wai-primary');
      +  await until("a window open now", STEP, async () => { const x = await roomOf(v); return x.co && x.co.windows.find((y) => y.question === "Which song next?" && y.close > x.co.now); }, 300);
         await until("the viewer's poll offers its options", STEP, () => v.evaluate(() => [...document.querySelectorAll('[data-widget="Poll"] button.wai-option')].some((b) => !b.disabled)), 300);
         await v.click('[data-widget="Poll"] button.wai-option[data-option="1"]');
         const chosen = await until("the room's clock selects and signs the result", STEP * 2, async () => {
           const t = await v.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent);
           return /chose “The loud one”, with 1 vote/.test(t) && t;
         }, 500);
      -  const hostSees = await until("the host's page shows the same result", STEP, async () => { const t = await p.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent); return /The loud one/.test(t) && t; }, 500);
      +  const hostSees = await until("the host's page shows the same result", STEP, async () => { const t = await h.evaluate(() => document.querySelector('[data-widget="Poll"] .wai-poll-result').textContent); return /The loud one/.test(t) && t; }, 500);
         check("built: the viewer's vote is selected by the room's clock, and both pages show it", !!chosen && !!hostSees, chosen);
         const vlog = await v.evaluate(() => ({ acts: window.appTest.session.log().map((e) => e.action), v: window.appTest.session.verify() }));
         check("built: the exported app sealed the viewer's actions too", vlog.acts.includes("room.watch") && vlog.acts.includes("poll.vote") && vlog.v.verified === vlog.acts.length, vlog.acts.join(", "));
      +  h.dump();
         v.dump();
      +  dev.proc.kill("SIGTERM");
       }
       
       async function scenarioRefused() {
      @@ -359,13 +367,13 @@ async function scenarioOwn() {
         if (!shared) await srv.close();
       }
       
      -// The visitor's coding agent: `wai-ui mcp` on this machine, driven over its stdio as an agent
      -// drives it; the page connects to its bridge with the pairing code, and the agent streams the
      -// show into it.
      +// The visitor's coding agent: `wai mcp --toolset app` on this machine, driven over its stdio as
      +// an agent drives it; the page connects to its bridge with the pairing code, and the agent
      +// streams the show into it, then exports it as `wai create --from` does.
       async function scenarioAgent() {
         const dir = path.join(OUT, "agent");
         fs.mkdirSync(dir, { recursive: true });
      -  const mcp = spawn(UI, ["mcp"], { stdio: ["pipe", "pipe", "pipe"] });
      +  const mcp = spawn(WAI, ["mcp", "--toolset", "app", "--project", dir], { stdio: ["pipe", "pipe", "pipe"] });
         procs.push(mcp);
         let errText = "";
         mcp.stderr.on("data", (d) => { errText += d; });
      @@ -394,7 +402,7 @@ async function scenarioAgent() {
         await p.fill("#agent-code", code);
         await p.click("#build");
         await until("the page is connected to the bridge", STEP, () => p.evaluate(() => /Connected/.test(document.getElementById("agent-state").textContent)), 200);
      -  const r = await rpc("tools/call", { name: "create_stream", arguments: { program: SHOW_TEXT, chunk: 12, pace_ms: 10 } });
      +  const r = await rpc("tools/call", { name: "app_stream", arguments: { program: SHOW_TEXT, chunk: 12, pace_ms: 10 } });
         check("agent: the agent's stream reaches the connected page", r.result && !r.result.isError && r.result.structuredContent.delivered === 1, JSON.stringify(r.result && r.result.structuredContent).slice(0, 120));
         await until("the page renders the agent's app", STEP, () => p.evaluate(() => window.createTest.done >= 1));
         const fin = await p.evaluate(() => window.createTest.last);
      @@ -402,34 +410,18 @@ async function scenarioAgent() {
         check("agent: the page renders the app as the agent streams it", fin.errors.length === 0 && fin.nodes.length === 8 && new Set(counts).size >= 5 && counts.every((c, i) => !i || c >= counts[i - 1]), `${counts.length} snapshots, ${fin.nodes.length} widgets`);
         const wrong = await p.evaluate(async (u) => (await fetch(u)).status, `http://127.0.0.1:${port}/create/stream?code=wrong`).catch((e) => String(e));
         check("agent: the bridge refuses a page without the pairing code", wrong === 403 || /Failed to fetch/.test(String(wrong)), String(wrong));
      +  const e = await rpc("tools/call", { name: "app_export", arguments: { program: SHOW_TEXT, dir: "agent-export", runtime_dir: RT } });
      +  const ex = path.join(dir, "agent-export");
      +  const checked = wai(["app", "check"], ex);
      +  check("agent: app_export writes the project `wai create --from` writes, and its check passes", e.result && !e.result.isError && fs.existsSync(path.join(ex, "wai.json")) && checked.code === 0, JSON.stringify(e.result && e.result.structuredContent).slice(0, 160));
         p.dump();
         mcp.stdin.end();
         if (!shared) await srv.close();
       }
       
      -async function scenarioUp() {
      -  if (!shared) return check("up: needs the create scenario", false);
      -  const dir = path.join(OUT, "up");
      -  fs.mkdirSync(dir, { recursive: true });
      -  const port = await freePort();
      -  const env = { PORT: String(port), ROOM_PORT: String(await freePort()), RELAY_PORT: String(await freePort()), JOULE_MOQ_RELAY: RELAY, JOULE_MOQ_ROOM: ROOM, CWD: shared.project };
      -  const up = run("node", [path.join(shared.project, "serve.mjs"), "--up"], "serve-up", dir, env);
      -  await until("serve.mjs --up brings the relay, the room service and the app up", 60000, async () => { if (up.exitCode != null) throw new Error(tail(up.logFile)); return /relay and room service up/.test(fs.readFileSync(up.logFile, "utf8")); }, 300);
      -  const keys = path.join(shared.project, ".wai-dev/room-keys.toml");
      -  const mode = (fs.statSync(keys).mode & 0o777).toString(8);
      -  check("up: the development keys are made in .wai-dev/, which .gitignore keeps out", fs.existsSync(keys) && fs.readFileSync(path.join(shared.project, ".gitignore"), "utf8").includes(".wai-dev/"), `room-keys.toml mode ${mode}`);
      -  const v = await page(`http://127.0.0.1:${port}/`, "up-viewer");
      -  await until("its room client is ready", STEP, () => v.evaluate(() => { const r = window.appTest && window.appTest.ctx.room.current; return r && r.phase() === "idle"; }), 300);
      -  await v.click('[data-widget="Show"] [data-act="watch"]');
      -  const r = await until("a page of the project's own setup watches", STEP, async () => { const x = await roomOf(v); return x && x.phase === "watching" && x; }, 300);
      -  check("up: a page of the project's own development setup enters its room", !!r.me, r.me.slice(0, 16));
      -  up.kill("SIGTERM");
      -  v.dump();
      -}
      -
       try {
         await launch();
      -  for (const [name, f] of [["create", scenarioCreate], ["built", scenarioBuilt], ["refused", scenarioRefused], ["own", scenarioOwn], ["agent", scenarioAgent], ["up", scenarioUp]]) {
      +  for (const [name, f] of [["create", scenarioCreate], ["built", scenarioBuilt], ["refused", scenarioRefused], ["own", scenarioOwn], ["agent", scenarioAgent]]) {
           if (!ONLY.includes(name)) continue;
           console.log(`== ${name}`);
           try { await f(); } catch (e) { check(`${name}: ran to the end`, false, (e && e.stack) || String(e)); }
      @@ -438,7 +430,7 @@ try {
       } finally {
         if (browser) await browser.close().catch(() => {});
         for (const p of procs) if (p.exitCode == null) p.kill("SIGTERM");
      -  if (shared) await shared.srv.close();
      +  for (const p of procs) if (p.exitCode == null) p.kill("SIGTERM");
         killStrays();
       }
       fs.writeFileSync(path.join(OUT, "results.json"), JSON.stringify({ results, failed }, null, 1));
      diff --git a/wai/wai-ui/e2e/package-lock.json b/wai/wai-app/e2e/package-lock.json
      similarity index 96%
      rename from wai/wai-ui/e2e/package-lock.json
      rename to wai/wai-app/e2e/package-lock.json
      index 8f560a736..87b58710f 100644
      --- a/wai/wai-ui/e2e/package-lock.json
      +++ b/wai/wai-app/e2e/package-lock.json
      @@ -1,10 +1,10 @@
       {
      -  "name": "wai-ui-e2e",
      +  "name": "wai-app-e2e",
         "lockfileVersion": 3,
         "requires": true,
         "packages": {
           "": {
      -      "name": "wai-ui-e2e",
      +      "name": "wai-app-e2e",
             "license": "Apache-2.0",
             "dependencies": {
               "playwright": "1.60.0"
      diff --git a/wai/wai-ui/e2e/package.json b/wai/wai-app/e2e/package.json
      similarity index 91%
      rename from wai/wai-ui/e2e/package.json
      rename to wai/wai-app/e2e/package.json
      index d8e417593..47ddb1b8c 100644
      --- a/wai/wai-ui/e2e/package.json
      +++ b/wai/wai-app/e2e/package.json
      @@ -1,5 +1,5 @@
       {
      -  "name": "wai-ui-e2e",
      +  "name": "wai-app-e2e",
         "private": true,
         "description": "Create, end to end: a browser automation package drives the host browser against a room service and a relay on loopback.",
         "license": "Apache-2.0",
      diff --git a/wai/wai-ui/e2e/run.sh b/wai/wai-app/e2e/run.sh
      similarity index 71%
      rename from wai/wai-ui/e2e/run.sh
      rename to wai/wai-app/e2e/run.sh
      index 5e230d499..08eed954e 100755
      --- a/wai/wai-ui/e2e/run.sh
      +++ b/wai/wai-app/e2e/run.sh
      @@ -1,13 +1,15 @@
       #!/bin/bash
       # Build what Create's end-to-end test needs, and run it.
       #
      -#   wai/wai-ui/e2e/run.sh [--only create,built,refused,up]
      +#   wai/wai-app/e2e/run.sh [--only create,built,refused,own,agent]
       #
      -# 1. joule-moq-relay and joule-moq-room (native, release), and wai-ui's command line;
      +# 1. joule-moq-relay and joule-moq-room (native, release), and `wai` with its feature `app`
      +#    (wai/wai-rs, in a target directory of its own: two workspaces build wai-rs differently);
       # 2. the WebAssembly packages, through wasm-bindgen's web target, each with its own --out-name,
       #    into the runtime directory: joule-moq-web (room/moq/pkg/wai_room_moq), wai-live-web
      -#    (room/live/pkg/wai_room_live) and wai-ui with its feature `web` (create/pkg/wai_ui);
      -# 3. the runtime laid out as the site serves it (web/runtime.mjs): create/ and room/;
      +#    (room/live/pkg/wai_room_live) and wai-app with its feature `web` (create/pkg/wai_app);
      +# 3. the runtime laid out as the site serves it (web/runtime.mjs): create/ and room/; and the
      +#    room client `wai dev` serves at /room/ (wai/quickstart/build-web.sh);
       # 4. the page modules' unit tests (node --test web/web.test.mjs);
       # 5. the browser-automation package from e2e/package-lock.json (`npm ci --ignore-scripts`),
       #    outside the tree unless PLAYWRIGHT_HOST names one installed;
      @@ -28,30 +30,34 @@ TARGET=${CARGO_TARGET_DIR:-$UI/target}
       export CARGO_TARGET_DIR="$TARGET"
       OUT=${CREATE_E2E_OUT:-${TMPDIR:-/tmp}/wai-create-e2e}
       RT="$OUT/runtime"
      -rm -rf "$OUT/results" "$RT"
      +rm -rf "$OUT/results" "$RT" "$OUT/web"
       mkdir -p "$OUT/results" "$RT"
       
       step() { echo "== $*"; }
       
      -step "native: joule-moq-relay, joule-moq-room, wai-ui"
      +step "native: joule-moq-relay, joule-moq-room, wai (feature app)"
       (cd "$JC" && cargo build $LOCKED --release -p joule-moq-relay --bin joule-moq-relay)
       (cd "$JC/crates/joule-moq-room" && cargo build $LOCKED --release --bin joule-moq-room)
      -(cd "$UI" && cargo build $LOCKED --release --bin wai-ui)
      +WAI_TARGET="$TARGET/wai-app-cli"
      +(cd "$ROOT/wai/wai-rs" && CARGO_TARGET_DIR="$WAI_TARGET" cargo build $LOCKED --release --no-default-features --features app --bin wai)
       
      -step "wasm: joule-moq-web, wai-live-web, wai-ui"
      +step "wasm: joule-moq-web, wai-live-web, wai-app"
       command -v wasm-bindgen >/dev/null || { echo "the wasm-bindgen CLI 0.2.127 is needed"; exit 2; }
       wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version the crates pin"; exit 2; }
       (cd "$JC" && cargo build $LOCKED --release -p joule-moq-web --lib --target wasm32-unknown-unknown)
      -wasm-bindgen --target web --no-typescript --out-dir "$RT/room/moq/pkg" --out-name wai_ui_room_moq "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm"
      +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/moq/pkg" --out-name wai_app_room_moq "$TARGET/wasm32-unknown-unknown/release/joule_moq_web.wasm"
       (cd "$WLW" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown)
      -wasm-bindgen --target web --no-typescript --out-dir "$RT/room/live/pkg" --out-name wai_ui_room_live "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm"
      +wasm-bindgen --target web --no-typescript --out-dir "$RT/room/live/pkg" --out-name wai_app_room_live "$TARGET/wasm32-unknown-unknown/release/wai_live_web.wasm"
       (cd "$UI" && cargo build $LOCKED --release --lib --target wasm32-unknown-unknown --no-default-features --features web)
      -wasm-bindgen --target web --no-typescript --out-dir "$RT/create/pkg" --out-name wai_ui "$TARGET/wasm32-unknown-unknown/release/wai_ui.wasm"
      +wasm-bindgen --target web --no-typescript --out-dir "$RT/create/pkg" --out-name wai_app "$TARGET/wasm32-unknown-unknown/release/wai_app.wasm"
       ls -l "$RT"/create/pkg/*.wasm "$RT"/room/*/pkg/*.wasm
       
       step "the runtime, as the site serves it"
       node "$UI/web/runtime.mjs" "$RT"
       
      +step "the room client wai dev serves"
      +bash "$ROOT/wai/quickstart/build-web.sh" "$OUT/web"
      +
       step "unit tests of the page's modules"
       node --test "$UI/web/web.test.mjs"
       
      @@ -71,7 +77,8 @@ node "$HERE/create.e2e.mjs" \
         --rt "$RT" \
         --relay "$TARGET/release/joule-moq-relay" \
         --room "$TARGET/release/joule-moq-room" \
      -  --ui "$TARGET/release/wai-ui" \
      +  --wai "$WAI_TARGET/release/wai" \
      +  --web "$OUT/web" \
         --out "$OUT/results" \
         ${browser[@]+"${browser[@]}"} \
         "$@"
      -- 
      2.54.0 (Apple Git-157)
      
      
      From 9cdff1cf90909161d121c27e3568edb0541676dc Mon Sep 17 00:00:00 2001
      From: Transaction Science 
      Date: Tue, 27 Oct 2026 05:19:08 -0400
      Subject: [PATCH 13/14] wai setup-agent: the app toolset named only by a build
       that serves it
      
      --toolset takes app, and AGENTS.md mentions the app toolset, only in a build with
      the feature `app`: a project set up from a build without it is never pointed at a
      toolset its wai cannot serve.
      
      Co-Authored-By: Claude Opus 5.5 
      ---
       wai/wai-rs/src/bin/wai_cli/setup_agent.rs | 12 +++++++++---
       1 file changed, 9 insertions(+), 3 deletions(-)
      
      diff --git a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      index c56c01e9e..1a4d346ba 100644
      --- a/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      +++ b/wai/wai-rs/src/bin/wai_cli/setup_agent.rs
      @@ -107,8 +107,13 @@ fn usage() -> &'static str {
           "usage: wai setup-agent [--dir ] [--dry-run] [--confirm] [--json] [--all] [--only [,...]] [--toolset [,...]] [--command ]"
       }
       
      -/// The toolsets `wai mcp` serves, for `--toolset` (`app` in a build with the feature `app`).
      -const TOOLSETS: &[&str] = &["core", "dev", "app"];
      +/// The toolsets this build's `wai mcp` serves, for `--toolset`.
      +const TOOLSETS: &[&str] = &[
      +    "core",
      +    "dev",
      +    #[cfg(feature = "app")]
      +    "app",
      +];
       
       fn parse(args: &[String]) -> Result {
           let mut o = Opts { dir: PathBuf::from("."), dry_run: false, confirm: false, json: false, all: false, only: None, toolsets: None, command: "wai".into() };
      @@ -174,7 +179,7 @@ receipts, claims and live-room records are signed so that a verifier can check t
       tools, each named by its operation (`receipt.verify` is the tool `receipt_verify`). The `core` toolset inspects envelopes \
       and packages, reads the capability registry, verifies receipts, claims, live-room sessions and audit bundles, packages \
       media, lists and runs conformance corpora, and searches the specification. The `dev` toolset (`{command} {mcp} --toolset core,dev`) \
      -runs a development room on loopback, and the `app` toolset writes, checks and exports apps made with Create.\n\
      +runs a development room on loopback{app}.\n\
       - Verify with the tools. Do not judge a signature, a receipt or a session yourself: quote the verifier's verdict and its reason code.\n\
       - The specification's text is the authority. Search it, and cite a rule by its section (`SPEC.md` §N, or the extension and its section).\n\
       - The development tools are for development only. They run on 127.0.0.1 with keys held in memory and refuse any other \
      @@ -184,6 +189,7 @@ service; never point a deployment at them.\n\
       `{command} verify`, `{command} dev`, `{command} registry export --check`.\n\
       {END}\n",
               mcp = super::MCP,
      +        app = if TOOLSETS.contains(&"app") { ", and the `app` toolset writes, checks and exports apps made with Create" } else { "" },
           )
       }
       
      -- 
      2.54.0 (Apple Git-157)
      
      
      From 3363058002dd150742bb3625a3a1f208da293c4f Mon Sep 17 00:00:00 2001
      From: Transaction Science 
      Date: Tue, 27 Oct 2026 05:20:18 -0400
      Subject: [PATCH 14/14] wai-app e2e: the show's audience is the recording's (1)
      
      Co-Authored-By: Claude Opus 5.5 
      ---
       wai/wai-app/e2e/create.e2e.mjs | 10 +++++-----
       1 file changed, 5 insertions(+), 5 deletions(-)
      
      diff --git a/wai/wai-app/e2e/create.e2e.mjs b/wai/wai-app/e2e/create.e2e.mjs
      index 3dd095327..8bd78aeb5 100644
      --- a/wai/wai-app/e2e/create.e2e.mjs
      +++ b/wai/wai-app/e2e/create.e2e.mjs
      @@ -139,7 +139,7 @@ async function scenarioCreate() {
         const dir = path.join(OUT, "create");
         fs.mkdirSync(dir, { recursive: true });
         // The Create page, served by `wai dev` with a show's room: the site is the runtime.
      -  const dev = await waiDev(ROOT, "create-dev", ["--site", ROOT, "--show", "--audience", "2", "--dir", path.join(OUT, "create-dev", "state")]);
      +  const dev = await waiDev(ROOT, "create-dev", ["--site", ROOT, "--show", "--audience", "1", "--dir", path.join(OUT, "create-dev", "state")]);
         check("create: wai dev runs a show's relay and room service behind the Create page", true, dev.url);
         const srv = { url: dev.url, close: async () => {} };
         const p = await page(`${srv.url}${CREATE}`, "create-host");
      @@ -220,7 +220,7 @@ async function scenarioCreate() {
         const files = made.code === 0 ? fs.readdirSync(project).sort() : [];
         const wj = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "wai.json"), "utf8")) : { dev: {} };
         check("create: `wai create --from app.oui` writes the project: the program, wai.json naming its show, the page, its runtime, README, AGENTS.md and LICENSE",
      -    made.code === 0 && ["app.oui", "wai.json", "index.html", "README.md", "AGENTS.md", "LICENSE", "vendor.json", "vendor", ".gitignore"].every((f) => files.includes(f)) && wj.app === "app.oui" && wj.dev.mode === "show" && wj.dev.audience === 2 && fs.readFileSync(path.join(project, "app.oui"), "utf8") === canonical,
      +    made.code === 0 && ["app.oui", "wai.json", "index.html", "README.md", "AGENTS.md", "LICENSE", "vendor.json", "vendor", ".gitignore"].every((f) => files.includes(f)) && wj.app === "app.oui" && wj.dev.mode === "show" && wj.dev.audience === 1 && fs.readFileSync(path.join(project, "app.oui"), "utf8") === canonical,
           made.code === 0 ? `${files.join(" ")}; dev ${JSON.stringify(wj.dev)}` : made.out);
         const vendor = made.code === 0 ? JSON.parse(fs.readFileSync(path.join(project, "vendor.json"), "utf8")) : { files: [] };
         check("create: the project carries the runtime under vendor/, and no key", vendor.files.length > 10 && !fs.readdirSync(project).some((f) => /key/i.test(f)), `${vendor.files.length} runtime files`);
      @@ -237,9 +237,9 @@ async function scenarioBuilt() {
         const broken = path.join(dir, "exported-broken");
         const edit = (f, from, to) => { const t = fs.readFileSync(f, "utf8"); if (!from.test(t)) throw new Error(`${f}: ${from}`); fs.writeFileSync(f, t.replace(from, to)); };
         for (const [what, mutate, expect] of [
      -    ["whose room is not its app's", (d) => edit(path.join(d, "wai.json"), /"mode": "show", "audience": 2/, '"mode": "room"'), /dev\.mode is "room"/],
      -    ["whose show has another audience", (d) => edit(path.join(d, "wai.json"), /"audience": 2/, '"audience": 7'), /dev\.audience is 7/],
      -    ["that names no room", (d) => edit(path.join(d, "wai.json"), /, "mode": "show", "audience": 2/, ""), /names no room/],
      +    ["whose room is not its app's", (d) => edit(path.join(d, "wai.json"), /"mode": "show", "audience": 1/, '"mode": "room"'), /dev\.mode is "room"/],
      +    ["whose show has another audience", (d) => edit(path.join(d, "wai.json"), /"audience": 1/, '"audience": 7'), /dev\.audience is 7/],
      +    ["that names no room", (d) => edit(path.join(d, "wai.json"), /, "mode": "show", "audience": 1/, ""), /names no room/],
           ["with a runtime file changed", (d) => fs.appendFileSync(path.join(d, "vendor/create/wai-app-components.js"), "\n// changed\n"), /wai-app-components\.js: its SHA-256/],
           ["with a line the registry refuses", (d) => edit(path.join(d, "app.oui"), /^(\w+) = Chat\(.*\)$/m, "$1 = Hologram()"), /line \d+: unknown-component/],
         ]) {
      -- 
      2.54.0 (Apple Git-157)