diff --git a/README.md b/README.md index 39cd3ac..8854468 100644 --- a/README.md +++ b/README.md @@ -96,10 +96,18 @@ Landed since: DOM"). The pure `render(state) -> Frame` projection is unit-tested headlessly; the hardware path is proven by `cargo run -p positron-wgpu --example counter_gpu` (missing GPU is a loud `GpuError::NoAdapter`, never a software fallback). - -Next (see `docs/ARCHITECTURE.md` § roadmap O4b–O6): -- `positron-lit` *(optional)* — Lit DOM renderer for a11y / text-reflow (O4b) -- `ContinuumHost` (in continuum) — session ↔ Commands/Events, first real `ViewState` (O5) +- `@positron/lit` — the Lit DOM renderer (O4b): `LitRenderer` + (`Renderer`) + `LitHost`, the TS/DOM sibling of the wgpu + backend. This is also where the contract first **crosses the language boundary** + — the four Rust traits re-expressed as hand-authored TS interfaces in + `@positron/core` (typechecked and CI-gated by a `web` job), the same contract in + two languages beside the ts-rs-generated wire *data*. Same pure/impure seam as + the Rust outliers: `render(state) -> TemplateResult` is asserted headlessly on + the template's `strings`/`values`; the one DOM-touching call (`domCommit`) is + quarantined in `dom.ts`. Run it: `npm test` (workspace root). + +Next (see `docs/ARCHITECTURE.md` § roadmap O5–O6): +- `ContinuumHost` (in continuum) — session ↔ Commands/Events, first real `ViewState` (O5). positron's portable contract and continuum's `sdk/typescript` **complement** (decided at O4b); they meet here, neither subsumes the other. - persona `Observer` → RAG/tool bridge (O6) - Theme pack (Loki / Matrix / Fallout / Tron) ported from the cyberpunk-cli experiment diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 148fb48..a1ddef1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -167,11 +167,20 @@ the view from `ViewState` alone, the state type is incomplete — not the render geometry, a theme *token* is arguably semantic and could ride in state, so this is the recommended default rather than a primitive-enforced necessity; DESIGN.md leans the same way. -- **`@positron/core` (npm) + `positron-lit` vs continuum `sdk/typescript`: decided - at O4b, not before.** Both generate types from Rust via ts-rs; whether the positron - DOM stack *subsumes* or *complements* the continuum SDK is resolved when the Lit - renderer actually lands. (O4 is `positron-wgpu`, the GPU surface — it has no bearing - on the npm/SDK reconcile, which is the DOM stack's concern.) +- **`@positron/core` + `@positron/lit` vs continuum `sdk/typescript`: they + COMPLEMENT — decided at O4b when the Lit renderer landed.** positron owns the + *portable* half: the widget contract (`ViewState`/`Renderer`/`Host`/`Observer`) + and the transport-neutral session frames (`ClientMessage`/`ServerMessage`), + generated/hand-authored once and reusable by any substrate. continuum's SDK owns + the *continuum-specific* half: its `Commands`/`Events`, connection/session + machinery, and domain payload types. A continuum web client composes both — it + renders `ViewState`s through `@positron/lit` and carries actions through the + continuum SDK. The two meet at `ContinuumHost` (O5), which lowers positron + session frames onto continuum `Commands`/`Events`; neither subsumes the other, + so there is no merge and no duplicated source of truth. (O4 `positron-wgpu`, the + GPU surface, had no bearing on this — the reconcile was always the DOM stack's + concern, and the answer is "different layers, composed," not "one replaces the + other.") ## Organizational task roadmap (one PR per unit) @@ -181,11 +190,11 @@ the view from `ViewState` alone, the state type is incomplete — not the render | **O2** | `examples/counter-cli` | renderer-agnostic **and** AI-perceives-same-state, in-process, zero transport (one `Counter` `ViewState`, ≥2 `Renderer`s, 1 `Observer`) | O1 | | **O3** | `positron-ratatui` | terminal `Renderer` reference + `Host` event loop (**outlier A**: CPU cells, real stateful surface) | O2 | | **O4** | `positron-wgpu` | the run-everywhere GPU `Renderer` (**outlier B**: native Metal/Vulkan/DX12 + web WebGPU/WASM from one codebase) — proves `Renderer` carries no CPU-tree assumption; the load-bearing web+native surface | O2 | -| **O4b** | `positron-lit` *(optional)* | Lit DOM `Renderer` + regenerate `@positron/core`; a11y / text-reflow surface where the GPU path isn't ideal; reconcile with continuum `sdk/typescript` (subsume vs complement) | O4 | +| **O4b** | `@positron/lit` | Lit DOM `Renderer` (`Output = TemplateResult`) + `LitHost`, the TS/DOM outlier of the same contract wgpu implements for the GPU; the language-boundary proof (Rust traits → hand-authored TS interfaces in `@positron/core`) and the "web ≠ DOM as a peer paradigm" surface; reconcile with continuum `sdk/typescript` → **complement** | O4 | | **O5** | `ContinuumHost` (in continuum) | positron session ↔ Commands/Events; first real `ViewState` (`ChatViewState`) flows to a positron renderer; reconciles positron's frame output with continuum's existing `RenderBackend`/`RgbaFrame` GPU seam; resolves the two-wire merge question | O3 or O4 | | **O6** | persona `Observer` → RAG/tool bridge (in continuum) | perception into cognition + action as `CommandEnvelope` — closes "AI persona rag/tool integration" | O5 | -O1–O4 have landed: the boundary is pinned; `examples/counter-cli` proves "one +O1–O4b have landed: the boundary is pinned; `examples/counter-cli` proves "one `ViewState`, many renderers, plus an observer perceiving the same state" in a single process; `positron-ratatui` proves the first real stateful `Renderer` + `Host` event loop against a genuinely different `type Output` (terminal cells, @@ -200,7 +209,23 @@ while the hardware path (`Gpu::rasterize`) is proven by the `counter_gpu` example (any real machine) and kept off the CI test surface (runners have no adapter; a missing GPU is a loud `GpuError::NoAdapter`, never a software fallback). `RgbaFrame` is deliberately continuum's avatar-frame shape. + +O4b then crosses the contract into TypeScript. It is a **guaranteed-middle** +surface (the outlier pair already closed at O4), chosen deliberately for two +things a Rust renderer can't prove: the **language boundary** — the four Rust +traits re-expressed as hand-authored TS interfaces in `@positron/core` (a trait +has no wire form for ts-rs to project, so the contract lives twice, in sync by +hand), typechecked and CI-gated by a `web` job — and **"web ≠ DOM as a peer +paradigm"**: `@positron/lit` is a `Renderer` (`LitRenderer`) + +`LitHost`, the DOM sibling of the wgpu backend. It splits along the *same* +pure/impure seam as the two Rust outliers — the `render(state) -> TemplateResult` +projection is asserted headlessly on the template's `strings`/`values` (the DOM +analogue of wgpu's quad assertions), while the one DOM-touching call (`domCommit`, +Lit's `render` into a container) is quarantined in `dom.ts` and kept off the +headless test path. + **O5 is the next unit** — `ContinuumHost`: the first real `ViewState` (`ChatViewState`) flowing to a positron renderer, the session↔Commands/Events -lowering, and the reconcile of positron's `RgbaFrame` with continuum's existing -`RenderBackend`/`RgbaFrame` GPU seam. +lowering (where positron's portable contract and continuum's SDK *complement*, +per the decision above), and the reconcile of positron's `RgbaFrame` with +continuum's existing `RenderBackend`/`RgbaFrame` GPU seam. diff --git a/npm/lit/package.json b/npm/lit/package.json new file mode 100644 index 0000000..a2de4e6 --- /dev/null +++ b/npm/lit/package.json @@ -0,0 +1,33 @@ +{ + "name": "@positron/lit", + "version": "0.1.1", + "description": "Positron Lit DOM renderer — LitRenderer (Renderer) + LitHost over @positron/core. The web/DOM outlier of the same contract the wgpu backend implements for the GPU.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/CambrianTech/positron.git", + "directory": "npm/lit" + }, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test \"src/**/*.test.ts\"" + }, + "files": [ + "src" + ], + "dependencies": { + "@positron/core": "^0.1.1", + "lit": "^3.2.0" + }, + "keywords": [ + "positron", + "lit", + "dom", + "renderer", + "widgets", + "continuum" + ] +} diff --git a/npm/lit/src/dom.ts b/npm/lit/src/dom.ts new file mode 100644 index 0000000..d8fb026 --- /dev/null +++ b/npm/lit/src/dom.ts @@ -0,0 +1,37 @@ +/** + * The one DOM-touching seam in `@positron/lit` — the analogue of the + * Rust wgpu outlier's `gpu.rs` (the file that talks to the device), + * kept separate from the pure `renderer.ts`/`host.ts` projection so the + * rest of the package stays headlessly testable. + * + * `domCommit` builds a {@link CommitSink} that commits a rendered + * `TemplateResult` into a live DOM container via Lit's `render`. This is + * the sink you pass to a {@link "./host".LitHost} in a browser; tests + * pass a capturing sink instead and never import this file. + * + * Importing this module is safe in Node (Lit's `render` reaches for + * `document` only when CALLED, not on import), but calling the returned + * sink requires a real DOM — which is exactly why it lives behind the + * seam and out of the headless test path. + */ +import { render } from "lit"; +import type { TemplateResult } from "lit"; + +import type { CommitSink } from "./host"; + +/** + * A DOM-backed {@link CommitSink}: each committed template is rendered + * into `container`. Lit tracks the root part on the container itself, so + * repeat commits diff against the previous render — no manual part + * bookkeeping. + * + * @param container the live DOM node the widget owns — an `HTMLElement`, + * shadow root, or `DocumentFragment`. + */ +export function domCommit( + container: HTMLElement | DocumentFragment, +): CommitSink { + return (result: TemplateResult): void => { + render(result, container); + }; +} diff --git a/npm/lit/src/host.ts b/npm/lit/src/host.ts new file mode 100644 index 0000000..707522d --- /dev/null +++ b/npm/lit/src/host.ts @@ -0,0 +1,97 @@ +/** + * `LitHost` — the DOM surface's {@link Host} implementation, the glue + * between the positron substrate (producing {@link ViewState} updates and + * consuming commands) and a {@link LitRenderer}. + * + * State flows down: `onState` renders the state to a `TemplateResult` and + * hands it to a **commit sink** — the seam that actually touches the DOM. + * Events flow up: `onEvent` maps a surface event to an optional command + * via an injected mapper (the app owns which DOM interaction means which + * command; the host stays app-agnostic). + * + * ## Why the commit sink is injected + * + * Committing a `TemplateResult` into live DOM is Lit's `render(result, + * container)` — it needs a real `Element`. Keeping that call BEHIND an + * injected sink is the same pure/impure split as the Rust wgpu outlier + * (`frame.rs` pure, `gpu.rs` touches the device): `LitHost` itself is + * DOM-free and headlessly testable (a test injects a capturing sink), + * while the real DOM commit lives in `./dom` (`domCommit`). The host + * never imports a DOM value — only the `TemplateResult` type. + * + * This mirrors positron-ratatui's Host, which owns the render→backend + * commit and the event→command translation for the terminal surface; + * `LitHost` is the DOM analogue. + */ +import type { Host, ViewState } from "@positron/core"; +import type { TemplateResult } from "lit"; + +import type { LitRenderer } from "./renderer"; + +/** + * Commits a rendered `TemplateResult` to a surface. The production sink + * ({@link "./dom".domCommit}) calls Lit's `render` into a container; a + * test sink captures the result. This is the one seam that touches (or + * stands in for) the DOM. + */ +export type CommitSink = (result: TemplateResult) => void; + +/** + * Translates a surface event into an optional command. `undefined` (the + * Rust `Option::None`) means "this event produces no command". The app + * supplies this; the host does not hardcode DOM-event semantics. + */ +export type EventToCommand = ( + event: Event, +) => Command | undefined; + +/** Construction inputs for a {@link LitHost}. */ +export interface LitHostOptions { + /** The renderer this host drives (one per widget kind). */ + readonly renderer: LitRenderer; + /** Where rendered templates go — DOM in production, capture in tests. */ + readonly commit: CommitSink; + /** How surface events become commands for the substrate. */ + readonly toCommand: EventToCommand; +} + +/** + * The DOM {@link Host}: renders {@link ViewState} to Lit templates and + * commits them through an injected sink; maps surface events to commands + * through an injected mapper. + */ +export class LitHost + implements Host +{ + readonly #renderer: LitRenderer; + readonly #commit: CommitSink; + readonly #toCommand: EventToCommand; + #lastRendered: TemplateResult | undefined; + + constructor(options: LitHostOptions) { + this.#renderer = options.renderer; + this.#commit = options.commit; + this.#toCommand = options.toCommand; + } + + /** New state from the substrate — render and commit to the surface. */ + onState(state: S): void { + const tree = this.#renderer.render(state); + this.#lastRendered = tree; + this.#commit(tree); + } + + /** Surface interaction — map to a command for the substrate, or none. */ + onEvent(event: Event): Command | undefined { + return this.#toCommand(event); + } + + /** + * The most recently committed template, or `undefined` before the + * first `onState`. Lets a caller (or a test) inspect what was last + * rendered without re-running the renderer. + */ + get lastRendered(): TemplateResult | undefined { + return this.#lastRendered; + } +} diff --git a/npm/lit/src/index.ts b/npm/lit/src/index.ts new file mode 100644 index 0000000..297d322 --- /dev/null +++ b/npm/lit/src/index.ts @@ -0,0 +1,29 @@ +/** + * @positron/lit — the Lit DOM renderer for positron. + * + * The "web ≠ DOM as a peer paradigm" surface: a {@link LitRenderer} + * projects a `@positron/core` `ViewState` to a Lit `TemplateResult`, and + * a {@link LitHost} drives that renderer and commits the result into + * live DOM. It is the TS/DOM outlier of the same `Renderer`/`Host` + * contract the Rust wgpu backend implements for the GPU — one contract, + * many surfaces. + * + * Two halves, split by whether they touch the DOM (the same pure/impure + * split as the Rust wgpu outlier's `frame.rs` vs `gpu.rs`): + * + * - **Pure, headlessly testable:** `renderer.ts` (`LitRenderer` — the + * `Renderer` specialization) and `host.ts` + * (`LitHost` — glue with an injected commit sink and event→command + * mapper). A `TemplateResult` is a plain value; tests assert on its + * `strings`/`values` with no live DOM. + * - **DOM-touching seam:** `dom.ts` (`domCommit` — the sink that calls + * Lit's `render` into a container). Only browser code imports it. + */ +export type { LitRenderer } from "./renderer"; +export type { + CommitSink, + EventToCommand, + LitHostOptions, +} from "./host"; +export { LitHost } from "./host"; +export { domCommit } from "./dom"; diff --git a/npm/lit/src/lit.test.ts b/npm/lit/src/lit.test.ts new file mode 100644 index 0000000..f8caead --- /dev/null +++ b/npm/lit/src/lit.test.ts @@ -0,0 +1,133 @@ +/** + * Smoke tests for `@positron/lit` — the DOM outlier of the positron + * `Renderer`/`Host` contract, mirroring `@positron/core`'s single + * `contract.test.ts` and the Rust wgpu outlier's headless `Frame` + * assertions. + * + * The projection is proven WITHOUT a live DOM: a `LitRenderer` returns a + * Lit `TemplateResult`, a plain value whose `strings` (static parts) and + * `values` (dynamic interpolations) we assert on directly — exactly how + * the wgpu outlier asserts on `Frame` quads instead of pixels. `LitHost` + * is driven with a capturing commit sink, so the whole file runs under + * `node --test` with no jsdom. The DOM-touching `domCommit` seam is + * deliberately not exercised here (it needs a real container). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import type { ViewState } from "@positron/core"; +import { html, type TemplateResult } from "lit"; + +import type { LitRenderer } from "./renderer"; +import { LitHost } from "./host"; + +/** The same tiny fixture the core smoke tests use. */ +class Counter implements ViewState { + readonly kind = "counter"; + constructor( + readonly value: number, + readonly revision: number, + ) {} +} + +/** Commands this widget can emit up to the substrate. */ +interface IncrementCommand { + readonly kind: "increment"; +} + +/** Surface events the host maps to commands. */ +type CounterEvent = { readonly type: "click" } | { readonly type: "hover" }; + +/** + * The DOM renderer: state → a `
` with a sign class, the value as an + * attribute, and one `` per unit of magnitude. Written + * so the interpolations land in the template's `values` (sign, value, + * pips) rather than being baked into the static `strings`. + */ +class CounterLitRenderer implements LitRenderer { + render(state: Counter): TemplateResult { + const sign = state.value >= 0 ? "positive" : "negative"; + const pips = Array.from( + { length: Math.abs(state.value) }, + (_unused, i) => html``, + ); + return html`
${pips}
`; + } +} + +// what this catches: the DOM projection is a pure function of state — +// sign, value, and per-unit pips land in the template's dynamic `values` +// (not hardcoded into the static markup), and the surrounding structure +// is in `strings`. If a renderer starts baking state into the static +// parts (defeating Lit's diffing) this is where it shows. +test("renderer projects state into template values", () => { + const r = new CounterLitRenderer(); + const result = r.render(new Counter(3, 1)); + + // Dynamic interpolations, in source order: sign, value, pips array. + assert.equal(result.values[0], "positive"); + assert.equal(result.values[1], 3); + assert.equal((result.values[2] as readonly unknown[]).length, 3); + + // Static structure is stable and carries no state. + // Lit 3: `TemplateResult` is the uncompiled form, so `.strings` is + // public. If we ever bump to Lit 4 (`TemplateResult` becomes possibly- + // compiled, which drops `.strings`), narrow to `UncompiledTemplateResult` + // here — this line is the one that breaks. + const markup = result.strings.join(""); + assert.match(markup, /class="counter /); + assert.match(markup, /data-value=/); + assert.match(markup, /<\/div>/); +}); + +// what this catches: the sign branch flips and the pip count tracks +// |value| — proves the projection is deterministic across the value +// domain, not just the positive path. +test("renderer reflects sign and magnitude", () => { + const r = new CounterLitRenderer(); + + const negative = r.render(new Counter(-2, 4)); + assert.equal(negative.values[0], "negative"); + assert.equal(negative.values[1], -2); + assert.equal((negative.values[2] as readonly unknown[]).length, 2); + + const zero = r.render(new Counter(0, 0)); + assert.equal(zero.values[0], "positive"); + assert.equal((zero.values[2] as readonly unknown[]).length, 0); +}); + +// what this catches: state flows DOWN through the host — onState renders +// and hands the template to the commit sink exactly once, and +// lastRendered exposes what was committed without re-running the +// renderer. The injected sink is why this needs no DOM. +test("host commits rendered state through the sink", () => { + const committed: TemplateResult[] = []; + const host = new LitHost({ + renderer: new CounterLitRenderer(), + commit: (result) => committed.push(result), + toCommand: (event) => + event.type === "click" ? { kind: "increment" } : undefined, + }); + + assert.equal(host.lastRendered, undefined); + host.onState(new Counter(2, 1)); + assert.equal(committed.length, 1); + assert.equal(host.lastRendered, committed[0]); + assert.equal(committed[0]?.values[1], 2); +}); + +// what this catches: events flow UP as optional commands — the mapped +// event yields a command, the unmapped event yields undefined (the Rust +// Option::None path). If the None branch ever collapses to a bogus +// command this fails. +test("host maps events up to optional commands", () => { + const host = new LitHost({ + renderer: new CounterLitRenderer(), + commit: () => {}, + toCommand: (event) => + event.type === "click" ? { kind: "increment" } : undefined, + }); + + assert.equal(host.onEvent({ type: "click" })?.kind, "increment"); + assert.equal(host.onEvent({ type: "hover" }), undefined); +}); diff --git a/npm/lit/src/renderer.ts b/npm/lit/src/renderer.ts new file mode 100644 index 0000000..7e1169b --- /dev/null +++ b/npm/lit/src/renderer.ts @@ -0,0 +1,29 @@ +/** + * The Lit DOM specialization of the positron {@link Renderer} contract. + * + * A {@link LitRenderer} is exactly a `Renderer` — the + * `Output` associated type from `@positron/core` pinned to Lit's + * `TemplateResult`. This is the "web ≠ DOM as a peer paradigm" surface: + * the SAME `ViewState` a wgpu backend renders to a GPU `Frame`, a + * terminal renders to cells, this one renders to a DOM template. + * + * It carries no new methods — a Lit renderer is just a positron renderer + * whose output happens to be a `TemplateResult`. Keeping it a type alias + * (not a fresh interface) is the compression rule: the render contract + * lives once, in `@positron/core`; this file only names the DOM output. + * + * Like the Rust wgpu outlier, the projection is PURE and headlessly + * testable: `render(state)` returns a `TemplateResult` value whose + * `strings`/`values` can be asserted without a live DOM (see + * `renderer.test.ts`), the way the wgpu outlier asserts on `Frame` + * quads. The DOM-touching commit is a separate seam (`./dom`). + */ +import type { Renderer, ViewState } from "@positron/core"; +import type { TemplateResult } from "lit"; + +/** + * A positron renderer that projects a {@link ViewState} to a Lit + * `TemplateResult`. Compose one per widget kind; the {@link LitHost} + * drives it and commits the result into a real DOM node. + */ +export type LitRenderer = Renderer; diff --git a/npm/lit/tsconfig.json b/npm/lit/tsconfig.json new file mode 100644 index 0000000..bf5a36d --- /dev/null +++ b/npm/lit/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +}