From 20971e04b48bc4be46e1760d66ee8008af786d64 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 19 Jun 2026 19:01:14 +0800 Subject: [PATCH] feat(sdk): add @zseven-w/op-web-sdk TS core (read-only web viewer) Plan 2 of the web embedding SDK: a self-contained, framework-agnostic TypeScript core wrapping the Plan-1 wasm Viewer. createViewer -> OpViewer with load, read-only snapshots (document/pages/pageCount/activePage), pan/zoom navigation (+ wheel), SVG export, load/viewportchange events, and destroy. Read-only: no .op editing/mutation. Self-contained: zero @zseven-w/pen-* deps (types vendored from jian-ops-schema ts-rs), tsup build, vitest+jsdom tests that mock the wasm seam. 10/10 tests; tsc clean; post-destroy use-after-free guarded. React+Vue adapters follow in Plan 3. --- packages/op-web-sdk/.gitignore | 2 + packages/op-web-sdk/README.md | 198 +++++++ packages/op-web-sdk/demo/index.html | 207 +++++++ packages/op-web-sdk/package.json | 21 + packages/op-web-sdk/scripts/sync-wasm.sh | 25 + packages/op-web-sdk/src/events.ts | 26 + packages/op-web-sdk/src/index.ts | 5 + packages/op-web-sdk/src/ops-types.ts | 526 ++++++++++++++++++ packages/op-web-sdk/src/types.ts | 15 + packages/op-web-sdk/src/viewer.ts | 141 +++++ packages/op-web-sdk/src/wasm-types.d.ts | 25 + packages/op-web-sdk/src/wasm.ts | 13 + packages/op-web-sdk/test/events.test.ts | 20 + .../op-web-sdk/test/export-destroy.test.ts | 36 ++ packages/op-web-sdk/test/mock-wasm.ts | 27 + packages/op-web-sdk/test/navigation.test.ts | 26 + packages/op-web-sdk/test/scaffold.test.ts | 8 + packages/op-web-sdk/test/viewer.test.ts | 22 + packages/op-web-sdk/test/wasm-stub.ts | 31 ++ packages/op-web-sdk/test/wasm.test.ts | 19 + packages/op-web-sdk/tsconfig.json | 11 + packages/op-web-sdk/tsup.config.ts | 25 + packages/op-web-sdk/vitest.config.ts | 33 ++ 23 files changed, 1462 insertions(+) create mode 100644 packages/op-web-sdk/.gitignore create mode 100644 packages/op-web-sdk/README.md create mode 100644 packages/op-web-sdk/demo/index.html create mode 100644 packages/op-web-sdk/package.json create mode 100755 packages/op-web-sdk/scripts/sync-wasm.sh create mode 100644 packages/op-web-sdk/src/events.ts create mode 100644 packages/op-web-sdk/src/index.ts create mode 100644 packages/op-web-sdk/src/ops-types.ts create mode 100644 packages/op-web-sdk/src/types.ts create mode 100644 packages/op-web-sdk/src/viewer.ts create mode 100644 packages/op-web-sdk/src/wasm-types.d.ts create mode 100644 packages/op-web-sdk/src/wasm.ts create mode 100644 packages/op-web-sdk/test/events.test.ts create mode 100644 packages/op-web-sdk/test/export-destroy.test.ts create mode 100644 packages/op-web-sdk/test/mock-wasm.ts create mode 100644 packages/op-web-sdk/test/navigation.test.ts create mode 100644 packages/op-web-sdk/test/scaffold.test.ts create mode 100644 packages/op-web-sdk/test/viewer.test.ts create mode 100644 packages/op-web-sdk/test/wasm-stub.ts create mode 100644 packages/op-web-sdk/test/wasm.test.ts create mode 100644 packages/op-web-sdk/tsconfig.json create mode 100644 packages/op-web-sdk/tsup.config.ts create mode 100644 packages/op-web-sdk/vitest.config.ts diff --git a/packages/op-web-sdk/.gitignore b/packages/op-web-sdk/.gitignore new file mode 100644 index 000000000..756e058f4 --- /dev/null +++ b/packages/op-web-sdk/.gitignore @@ -0,0 +1,2 @@ +/dist/ +/wasm/ diff --git a/packages/op-web-sdk/README.md b/packages/op-web-sdk/README.md new file mode 100644 index 000000000..5c818839e --- /dev/null +++ b/packages/op-web-sdk/README.md @@ -0,0 +1,198 @@ +# @zseven-w/op-web-sdk + +Read-only OpenPencil `.op` file viewer SDK for the web, backed by a Rust/wasm renderer. + +> **Editing is not supported.** This SDK provides a read-only view of `.op` documents. +> For full editing capability use the [OpenPencil app](https://openpencil.app). + +--- + +## Installation + +```bash +bun add @zseven-w/op-web-sdk +# or +npm install @zseven-w/op-web-sdk +``` + +After installing, copy the wasm assets into your project's public directory: + +```bash +cp -r node_modules/@zseven-w/op-web-sdk/wasm ./public/op-wasm +``` + +Or run the sync script from a monorepo checkout (requires the Rust wasm toolchain): + +```bash +bun run sync-wasm +``` + +--- + +## Quick start + +```ts +import { createViewer } from '@zseven-w/op-web-sdk'; + +const canvas = document.getElementById('my-canvas') as HTMLCanvasElement; + +// createViewer initialises the wasm module, binds it to the canvas, and returns +// a read-only OpViewer. Pass `doc` to load a document immediately. +const viewer = await createViewer({ + canvas, + // Optional: override the default wasm asset URL. + wasmUrl: '/op-wasm/op_web_sdk_bg.wasm', +}); + +// Load a .op document from a fetch response: +const resp = await fetch('/designs/my-design.op'); +const bytes = new Uint8Array(await resp.arrayBuffer()); +viewer.load(bytes); + +// Read document metadata: +const doc = viewer.document; // PenDocument +const pages = viewer.pages; // PenPage[] +console.log(doc.name, viewer.pageCount); + +// Control the viewport: +viewer.setZoom(1.5); +viewer.panTo(200, 100); +viewer.zoomToFit(canvas.clientWidth, canvas.clientHeight); + +// Export the current view to SVG: +const svgBytes = viewer.export({ format: 'svg' }); +const blob = new Blob([svgBytes], { type: 'image/svg+xml' }); + +// Listen to events: +const unsub = viewer.on('viewportchange', () => { + console.log('viewport:', viewer.viewport); +}); + +// Clean up: +viewer.destroy(); +``` + +--- + +## API reference + +### `createViewer(options): Promise` + +Initialises the wasm module and returns a bound `OpViewer`. + +| Option | Type | Description | +|---|---|---| +| `canvas` | `HTMLCanvasElement` | Canvas to render into (required). | +| `doc` | `string \| Uint8Array` | Initial document to load (optional). | +| `wasmUrl` | `string` | Override the `.wasm` asset URL (optional). | + +--- + +### `OpViewer` + +#### Document + +| Member | Signature | Description | +|---|---|---| +| `load` | `(src: string \| Uint8Array): void` | Load or reload a document from JSON string or binary blob. Fires the `'load'` event. | +| `document` | `PenDocument` | Snapshot of the full parsed document. | +| `pages` | `PenPage[]` | Snapshot of the pages array. | +| `pageCount` | `number` | Total number of pages in the document. | +| `activePage` | `number` | Zero-based index of the currently active page. | + +> `setActivePage` is not in the v1 read-only surface (single-page rendering). Multi-page +> navigation is deferred to a future release. + +#### Viewport + +| Member | Signature | Description | +|---|---|---| +| `viewport` | `Viewport` | Current pan + zoom state (`{ panX, panY, zoom }`). | +| `setViewport` | `(v: Viewport): void` | Set pan and zoom simultaneously. | +| `setZoom` | `(z: number): void` | Change zoom level, keeping current pan. | +| `panTo` | `(panX: number, panY: number): void` | Pan to a position, keeping current zoom. | +| `zoomToFit` | `(w: number, h: number): void` | Zoom to fit the given canvas dimensions. | + +#### Export + +| Member | Signature | Description | +|---|---|---| +| `export` | `(opts: { format: 'svg' }): Uint8Array` | Export the current document. Only `'svg'` is supported in v1. | + +#### Events + +| Member | Signature | Description | +|---|---|---| +| `on` | `(event: ViewerEvent, cb: () => void): () => void` | Subscribe to a viewer event. Returns an unsubscribe function. | +| `off` | `(event: ViewerEvent, cb: () => void): void` | Unsubscribe a specific callback. | + +**ViewerEvent values:** `'load'` | `'viewportchange'` + +#### Lifecycle + +| Member | Signature | Description | +|---|---|---| +| `destroy` | `(): void` | Remove event listeners, detach from the canvas, free the wasm instance. | + +--- + +## Types + +```ts +import type { PenDocument, PenPage, Viewport, ViewerEvent } from '@zseven-w/op-web-sdk'; +``` + +`PenDocument` and `PenPage` are generated from the canonical Rust schema +(`crates/op-web-sdk/bindings/ops.ts` via ts-rs). They are vendored into +`src/ops-types.ts` and re-exported from the package entry point. + +--- + +## Wasm assets note + +The package ships a `wasm/` directory containing the compiled wasm bundle. You must +make these files accessible at a URL your browser can fetch. The default URL is +resolved relative to the page; override it via the `wasmUrl` option in `createViewer`. + +To re-sync the wasm assets after a Rust build: + +```bash +# From packages/op-web-sdk/: +bun run sync-wasm +``` + +This rebuilds `crates/op-web-sdk` (requires the Rust wasm toolchain + wasm-bindgen), +copies the outputs to `wasm/`, and refreshes `src/ops-types.ts` from the latest +generated bindings. + +--- + +## Editing boundary + +This SDK is intentionally **read-only**. It provides: + +- Document parsing and typed access (`document`, `pages`) +- GPU-accelerated wasm rendering on a canvas +- Viewport control (pan, zoom, fit) +- SVG export +- Event subscriptions + +It does **not** support: + +- Node creation, deletion, or mutation +- Selection or drag interactions +- Layer panel, property panel, or toolbar UI +- AI / MCP integrations + +For the full editing experience, including AI-powered design generation and +the complete node editing surface, use the [OpenPencil app](https://openpencil.app) +or the `@zseven-w/pen-sdk` package (internal monorepo SDK). + +React and Vue adapter packages (`@zseven-w/op-web-sdk-react`, +`@zseven-w/op-web-sdk-vue`) are planned for a future release. + +--- + +## License + +MIT diff --git a/packages/op-web-sdk/demo/index.html b/packages/op-web-sdk/demo/index.html new file mode 100644 index 000000000..f53cdcb86 --- /dev/null +++ b/packages/op-web-sdk/demo/index.html @@ -0,0 +1,207 @@ + + + + + + + OpenPencil Web SDK Demo + + + +

OpenPencil Web SDK — read-only viewer demo

+ + + +
+ + + + + + +
+ +
Ready — click "Load fixture" to begin.
+ + + + + diff --git a/packages/op-web-sdk/package.json b/packages/op-web-sdk/package.json new file mode 100644 index 000000000..98dd608b3 --- /dev/null +++ b/packages/op-web-sdk/package.json @@ -0,0 +1,21 @@ +{ + "name": "@zseven-w/op-web-sdk", + "version": "0.8.0", + "description": "Read-only OpenPencil .op viewer SDK for the web (wasm-backed)", + "license": "MIT", + "type": "module", + "files": ["dist", "wasm"], + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "sync-wasm": "bash scripts/sync-wasm.sh" + }, + "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } +} diff --git a/packages/op-web-sdk/scripts/sync-wasm.sh b/packages/op-web-sdk/scripts/sync-wasm.sh new file mode 100755 index 000000000..2f6a9a5da --- /dev/null +++ b/packages/op-web-sdk/scripts/sync-wasm.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Build the Plan-1 wasm bundle and copy its output into this package's wasm/. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +bash "${ROOT}/crates/op-web-sdk/tools/build-wasm.sh" +DST="$(cd "$(dirname "$0")/.." && pwd)/wasm" +mkdir -p "${DST}" +cp -R "${ROOT}/crates/op-web-sdk/pkg/." "${DST}/" +echo "synced wasm bundle → ${DST}" +# Vendor the ts-rs generated types into the package so it is self-contained. +# Post-process: remove the dangling serde_json import and inline JsonValue so +# the vendored file compiles without the missing ./serde_json/JsonValue module. +PKGROOT="$(cd "$(dirname "$0")/.." && pwd)" +cp "${ROOT}/crates/op-web-sdk/bindings/ops.ts" "${PKGROOT}/src/ops-types.ts" +perl -i -0pe ' + # Remove the ts-rs generated import for the missing serde_json helper. + s/import type \{ JsonValue \} from "\.\/serde_json\/JsonValue";\n//g; + # Inject the GENERATED header comment and the inline JsonValue definition + # right after the first comment block (the ts-rs file header line), but only + # when the inline block is not already present (idempotent). + unless (/\/\/ JsonValue inlined/) { + s|(// This file was generated.*?\n)|\1\n// JsonValue inlined from the ts-rs serde_json helper (the original imports from\n// \.\/serde_json\/JsonValue which is not part of this vendored package).\ntype JsonValue = null \| boolean \| number \| string \| JsonValue[] \| \{ \[key: string\]: JsonValue \};\n|s; + } +' "${PKGROOT}/src/ops-types.ts" +echo "synced ops-types → ${PKGROOT}/src/ops-types.ts" diff --git a/packages/op-web-sdk/src/events.ts b/packages/op-web-sdk/src/events.ts new file mode 100644 index 000000000..951d76f34 --- /dev/null +++ b/packages/op-web-sdk/src/events.ts @@ -0,0 +1,26 @@ +// Typed event emitter for OpViewer lifecycle and viewport change events. + +/** Events emitted by OpViewer. */ +export type ViewerEvent = 'load' | 'viewportchange'; + +/** Minimal typed pub/sub emitter used internally by OpViewer. */ +export class Emitter { + private map = new Map void>>(); + + /** Subscribe to an event. Returns an unsubscribe function. */ + on(e: ViewerEvent, cb: () => void): () => void { + let set = this.map.get(e); + if (!set) { set = new Set(); this.map.set(e, set); } + set.add(cb); + return () => this.off(e, cb); + } + + /** Unsubscribe a specific callback from an event. */ + off(e: ViewerEvent, cb: () => void): void { this.map.get(e)?.delete(cb); } + + /** Fire all listeners registered for the given event. */ + emit(e: ViewerEvent): void { this.map.get(e)?.forEach((cb) => cb()); } + + /** Remove all listeners for all events. */ + clear(): void { this.map.clear(); } +} diff --git a/packages/op-web-sdk/src/index.ts b/packages/op-web-sdk/src/index.ts new file mode 100644 index 000000000..5b47652ff --- /dev/null +++ b/packages/op-web-sdk/src/index.ts @@ -0,0 +1,5 @@ +// Public entry for the OpenPencil read-only web SDK core. +export const VERSION = '0.8.0'; +export { createViewer, OpViewer } from './viewer.js'; +export type { Viewport, CreateViewerOptions, PenDocument, PenPage } from './types.js'; +export type { ViewerEvent } from './events.js'; diff --git a/packages/op-web-sdk/src/ops-types.ts b/packages/op-web-sdk/src/ops-types.ts new file mode 100644 index 000000000..26dbb60c9 --- /dev/null +++ b/packages/op-web-sdk/src/ops-types.ts @@ -0,0 +1,526 @@ +// GENERATED by jian-ops-schema ts-rs (crates/op-web-sdk/bindings/ops.ts). Do not edit by hand; re-sync via scripts/sync-wasm.sh. + +// JsonValue inlined from the ts-rs serde_json helper (the original imports from +// ./serde_json/JsonValue which is not part of this vendored package). +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +/** + * A single Action is a 1-key object: `{ "": }`. + * + * Examples (all are valid JSON `Action`s): + * - `{ "set": { "$state.count": "$state.count + 1" } }` + * - `{ "fetch": { "url": "/api/x", "into": "$state.u" } }` + * - `{ "push": "/detail/42" }` + * + * The body shape per action is NOT validated here — see `jian-core::action`. + */ +export type Action = { [key in string]?: JsonValue }; + +export type AlignItems = "start" | "center" | "end" | "stretch"; + +export type AppConfig = { name: string, version: string, id: string, entry: string | null, capabilities: Array | null, theme: string | null, orientation: Orientation | null, backgroundColor: string | null, icon: string | null, +/** + * C19 splash-frame config. + */ +splash: SplashConfig | null, +/** + * C18 ASP web handshake postMessage origin allowlist. + * Only consulted by the web host; strict match; no wildcards. + */ +aspAllowedOrigins: Array | null, +/** + * Auto-update backend descriptor — same idea as `app.icon`: + * the schema declares the source of truth (which release feed + * to consult), the host crate translates it into a concrete + * `Updater` impl. `None` (the default) means the host's own + * fallback wins (typically `NullUpdater`). Only consulted by + * hosts that compile with their respective updater feature + * (`jian-host-desktop/updater`). + * + * TS-side, the type widens to a generic discriminated record so + * third-party hosts can declare their own kinds without forking + * `ops.ts`; the Rust side is the typed `UpdaterConfig` struct + * below. + */ +updater: { kind: string; [key: string]: unknown } | null, }; + +export type AppLifecycleHooks = { onLaunch: Array | null, onResume: Array | null, onBackground: Array | null, onTerminate: Array | null, }; + +export type BlendMode = "normal" | "darken" | "multiply" | "screen" | "overlay" | "lighten" | "difference" | "hue" | "saturation" | "color" | "luminosity"; + +export type BlurBody = { radius: number, }; + +/** + * Boolean that may also be an expression. + */ +export type BoolOrExpression = boolean | string; + +export type Capability = "storage" | "network" | "camera" | "microphone" | "location" | "notifications" | "clipboard" | "biometric" | "file_system" | "haptic"; + +/** + * Checkbox with an optional adjacent `label`. `checked` two-way binds + * via `bindings.bind:value`. + */ +export type CheckboxNode = { width: SizingBehavior | null, height: SizingBehavior | null, checked: BoolOrExpression | null, label: string | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type CornerRadius = number | [number, number, number, number]; + +/** + * One named colour from the design-md colour palette. + */ +export type DesignMdColor = { +/** + * Human label, e.g. "Primary". + */ +name: string, +/** + * `#RRGGBB` hex value. + */ +hex: string, +/** + * How the colour is used, e.g. "buttons and links". + */ +role: string, }; + +/** + * A structured design-system brief attached to a [`PenDocument`]. + * + * [`PenDocument`]: crate::document::PenDocument + */ +export type DesignMdSpec = { +/** + * Original markdown source — kept verbatim for round-trip fidelity. + */ +raw: string, projectName: string | null, visualTheme: string | null, colorPalette: Array | null, typography: DesignMdTypography | null, componentStyles: string | null, layoutPrinciples: string | null, generationNotes: string | null, }; + +/** + * Typography guidance from the design-md typography section. + */ +export type DesignMdTypography = { fontFamily: string | null, headings: string | null, body: string | null, +/** + * Free-form scale description — usually the whole section text. + */ +scale: string | null, }; + +export type EllipseNode = { width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: number | null, innerRadius: number | null, startAngle: number | null, sweepAngle: number | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +/** + * All supported event hook keys. Note: input events (`onChange`, `onSubmit`, `onFocus`, + * `onBlur`) apply only to input-kind nodes. `on_key` is keyboard, `on_reach_end` + * is list-scroll-end, etc. + */ +export type EventHandlers = { onTap: Array | null, onDoubleTap: Array | null, onLongPress: Array | null, onPanStart: Array | null, onPanUpdate: Array | null, onPanEnd: Array | null, onScaleStart: Array | null, onScaleUpdate: Array | null, onScaleEnd: Array | null, onRotateStart: Array | null, onRotateUpdate: Array | null, onRotateEnd: Array | null, onHoverEnter: Array | null, onHoverLeave: Array | null, onChange: Array | null, onSubmit: Array | null, onFocus: Array | null, onBlur: Array | null, onKey: Array | null, onScroll: Array | null, onReachEnd: Array | null, }; + +/** + * A Tier 1 expression source — represented as a raw string. + * + * Parsing and validation are the responsibility of `jian-core::expression::parser` + * (Plan 2). The schema crate only guarantees the string is present; content-level + * correctness is deferred. + */ +export type Expression = string; + +export type FontStyleKind = "normal" | "italic"; + +export type FontWeight = number | string; + +/** + * Forward declaration of PenNode union — defined in `node/mod.rs`. + * We accept `Vec` as children. + */ +export type FrameNode = { children: Array | null, imageSearchQuery: string | null, reusable: boolean | null, slot: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, }; + +export type GestureOverrides = { +/** + * When true, this node and its subtree bypass the Arena and receive raw pointer events. + */ +rawPointer: boolean | null, disabled: Expression | null, scrollBehavior: ScrollBehavior | null, +/** + * Override drag threshold in logical pixels (default 8). + */ +dragThreshold: number | null, +/** + * Override long-press duration in ms (default 500). + */ +longPressDuration: number | null, +/** + * Author-explicit Tab-traversal opt-in. + * + * `Some(true)` — node enters the focus chain regardless of its + * semantic role. + * `Some(false)` — node is excluded even if its `semantics.role` + * would otherwise auto-include it (e.g. a decorative `Input`). + * `None` — falls back to the role heuristic (`Button` / `Link` + * / `Input` are auto-included; everything else is opt-in). + */ +focusable: boolean | null, }; + +export type GradientStop = { offset: number, color: string, }; + +export type GroupNode = { children: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, }; + +export type IconFontNode = { iconFontName: string, iconFontFamily: string | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array | null, stroke: PenStroke | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type ImageFillBody = { url: string, mode: ImageFillMode | null, originalSize: ImageOriginalSize | null, transform: ImageTransform | null, explain: string | null, opacity: number | null, exposure: number | null, contrast: number | null, saturation: number | null, temperature: number | null, tint: number | null, highlights: number | null, shadows: number | null, }; + +export type ImageFillMode = "fill" | "fit" | "crop" | "tile" | "stretch"; + +export type ImageFitMode = "fill" | "fit" | "crop" | "tile"; + +export type ImageNode = { src: string, objectFit: ImageFitMode | null, width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: CornerRadius | null, effects: Array | null, exposure: number | null, contrast: number | null, saturation: number | null, temperature: number | null, tint: number | null, highlights: number | null, shadows: number | null, imagePrompt: string | null, imageSearchQuery: string | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type ImageOriginalSize = { width: number, height: number, }; + +export type ImageTransform = { m00: number, m01: number, m02: number, m10: number, m11: number, m12: number, }; + +export type JustifyContent = "start" | "center" | "end" | "space_between" | "space_around"; + +export type LayoutMode = "none" | "vertical" | "horizontal"; + +export type LineNode = { x2: number | null, y2: number | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type LinearGradientBody = { angle: number | null, stops: Array, explain: string | null, opacity: number | null, blendMode: BlendMode | null, }; + +export type LiveRegion = "off" | "polite" | "assertive"; + +/** + * An ABI version string for the Tier 3 WASM module. The only recognised + * value today is `jian.wasm.v1`; Jian rejects unknown ABI at load time. + */ +export type LogicAbi = string; + +export type LogicModuleRef = { id: string, source: string, integrity: string | null, abi: LogicAbi, capabilities: Array | null, }; + +/** + * Declarative per-node navigation: clicking the node pushes/replaces/pops a route. + * Equivalent to `events.on_tap = [{"push": "..."}]` but more editor-discoverable. + */ +export type NavigationRoute = { "push": string } | { "replace": string } | { "pop": null }; + +export type NodeLifecycleHooks = { onMount: Array | null, onUnmount: Array | null, }; + +/** + * Numeric input with optional +/- steppers. Precise complement to + * `slider`; `value` two-way binds via `bindings.bind:value`. When + * omitted, `min`/`max`/`step` default to none/none/1 at runtime. + */ +export type NumberInputNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Placeholder shown when `value` is empty. + */ +placeholder: string | null, value: NumberOrExpression | null, +/** + * Lucide glyph drawn at the left content edge. See `TextInputNode`. + */ +leadingIcon: string | null, +/** + * Lucide glyph drawn at the right content edge. See `TextInputNode`. + */ +trailingIcon: string | null, min: number | null, max: number | null, step: number | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +/** + * Opacity can be a number or a `$variable` reference string. + */ +export type NumberOrExpression = number | string; + +export type Orientation = "portrait" | "landscape" | "auto"; + +export type Padding = number | [number, number] | [number, number, number, number] | string; + +export type PageLifecycleHooks = { onEnter: Array | null, onLeave: Array | null, onForeground: Array | null, onBackground: Array | null, }; + +export type PathNode = { iconId: string | null, d: string | null, anchors: Array | null, closed: boolean | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type PenDocument = { +/** + * Document format version stored in files since v0.x. Always present. + */ +version: string, name: string | null, +/** + * Wire shape: axis-name → ordered theme names. Frozen from v0.x. + */ +themes: { [key in string]?: Array } | null, variables: { [key in string]?: VariableDefinition } | null, pages: Array | null, +/** + * Default-on-deserialize so a multi-page document that carries only + * `pages` (no top-level `children`) still loads — the TS web app's + * whole-document sync (`document.post.ts`) accepts `{version, pages}` + * without a `children` array. Always serialized (even empty `[]`). + */ +children: Array, +/** + * "1.0" when any v1 extension is present; undefined ⇒ legacy v0.x. + */ +formatVersion: string | null, +/** + * App id (reverse-DNS). Required when `app` is set; otherwise optional. + */ +id: string | null, app: AppConfig | null, routes: RoutesConfig | null, state: { [key in string]?: StateEntry } | null, lifecycle: AppLifecycleHooks | null, logicModules: Array | null, +/** + * Per-document design-system brief (the "design.md"). Optional — + * absent on documents that never authored one. + */ +designMd: DesignMdSpec | null, }; + +export type PenEffect = { "type": "blur" } & BlurBody | { "type": "background_blur" } & BlurBody | { "type": "shadow" } & ShadowBody; + +export type PenFill = { "type": "solid" } & SolidFillBody | { "type": "linear_gradient" } & LinearGradientBody | { "type": "radial_gradient" } & RadialGradientBody | { "type": "image" } & ImageFillBody; + +/** + * Union of all concrete node types. + * Tag is the JSON `"type"` field. + */ +export type PenNode = { "type": "frame" } & FrameNode | { "type": "group" } & GroupNode | { "type": "rectangle" } & RectangleNode | { "type": "ellipse" } & EllipseNode | { "type": "line" } & LineNode | { "type": "polygon" } & PolygonNode | { "type": "path" } & PathNode | { "type": "text" } & TextNode | { "type": "text_input" } & TextInputNode | { "type": "image" } & ImageNode | { "type": "icon_font" } & IconFontNode | { "type": "text_area" } & TextAreaNode | { "type": "select" } & SelectNode | { "type": "switch" } & SwitchNode | { "type": "checkbox" } & CheckboxNode | { "type": "slider" } & SliderNode | { "type": "radio_group" } & RadioGroupNode | { "type": "number_input" } & NumberInputNode | { "type": "progress" } & ProgressNode | { "type": "tabs" } & TabsNode | { "type": "ref" } & RefNode; + +export type PenPage = { id: string, name: string, children: Array, state: { [key in string]?: StateEntry } | null, lifecycle: PageLifecycleHooks | null, }; + +export type PenPathAnchor = { x: number, y: number, handleIn: PenPathHandle | null, handleOut: PenPathHandle | null, pointType: PenPathPointType | null, }; + +export type PenPathHandle = { x: number, y: number, }; + +export type PenPathPointType = "corner" | "mirrored" | "independent"; + +export type PenStroke = { thickness: StrokeThickness, align: StrokeAlign | null, join: StrokeJoin | null, cap: StrokeCap | null, dashPattern: Array | null, dashOffset: number | null, fill: Array | null, }; + +export type PolygonNode = { polygonCount: number, width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: number | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type PrimitiveType = "int" | "float" | "number" | "string" | "bool" | "array" | "object" | "date"; + +/** + * Progress indicator. Display-only (not focusable/keyboard-driven): + * `value` is read from the state graph via `bindings.value`. `max` + * defaults to 100; `indeterminate` shows an animated unknown-progress + * state and ignores `value`. + */ +export type ProgressNode = { width: SizingBehavior | null, height: SizingBehavior | null, value: NumberOrExpression | null, max: number | null, indeterminate: boolean | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type RadialGradientBody = { cx: number | null, cy: number | null, radius: number | null, stops: Array, explain: string | null, opacity: number | null, blendMode: BlendMode | null, }; + +/** + * Single-choice radio group. Renders one radio per `option`; the + * selected option `value` two-way binds via `bindings.bind:value`. + */ +export type RadioGroupNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Currently selected option `value`. + */ +value: string | null, options: Array | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type RectangleNode = { children: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, }; + +export type RefNode = { ref: string, descendants: { [key in string]?: JsonValue } | null, children: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type RouteSpec = { pageId: string, preload: boolean | null, guards: Array | null, +/** + * Path-parameter type declarations (v1.0 additive — 2026-04-24). + * Keys correspond to `:param` placeholders in the route path + * (e.g. path `/detail/:id` → key `id`). The AI Action Surface + * uses these types when synthesising the JsonSchema for the + * derived `open_*(p)` action; runtime does strict type-checking + * on incoming values rather than silent coercion. + */ +params: { [key in string]?: StateType } | null, }; + +export type RoutesConfig = { entry: string, routes: { [key in string]?: RouteSpec }, transitions: { [key in string]?: Transition } | null, }; + +export type ScrollBehavior = "auto" | "contain" | "none"; + +/** + * Dropdown select. The runtime pops an option list; the selected + * option `value` two-way binds via `bindings.bind:value`. + */ +export type SelectNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Shown when no option is selected. + */ +placeholder: string | null, +/** + * Currently selected option `value`. + */ +value: string | null, options: Array | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +/** + * A single dropdown option: the persisted `value` and its display `label`. + */ +export type SelectOption = { value: string, label: string, }; + +export type SemanticAction = { name: string, label: string, handler: Array, }; + +export type SemanticRole = "button" | "link" | "image" | "text" | "heading" | "input" | "list" | "list_item" | "header" | "nav" | "main" | "dialog" | "alert"; + +export type SemanticsMeta = { role: SemanticRole | null, label: string | null, hint: string | null, liveRegion: LiveRegion | null, disabled: Expression | null, actions: Array | null, +/** + * Author-stable override for the auto-derived AI action name. + * When set, the resulting action name is `.` + * without the auto `_` suffix and survives slug recomputes + * across builds. See `2026-04-24-ai-action-surface.md` §3.3-3.4. + */ +aiName: string | null, +/** + * Tool description shown to external AI agents. Overrides the + * auto-generated default; lets authors steer what a model "sees" + * without changing visible UI text. + */ +aiDescription: string | null, +/** + * `true` permanently hides the node's derived action from the AI + * surface (StaticHidden). Defaults to `false`. ConfirmGated / + * StateGated availability are decided dynamically and do **not** + * require this flag — see ai-action-surface.md §4. + */ +aiHidden: boolean | null, +/** + * Historical `aiName` values still accepted by `execute_action` + * for transparent migration after a rename. Aliases are honoured + * at execute time (with `audit reason_code: "alias_used"`) but + * not surfaced by `list_available_actions`. See §9. + */ +aiAliases: Array | null, }; + +export type ShadowBody = { inner: boolean | null, offsetX: number, offsetY: number, blur: number, spread: number, color: string, }; + +export type SidedThickness = { top: number | null, right: number | null, bottom: number | null, left: number | null, }; + +/** + * Sizing value: a number, a fixed keyword, or an arbitrary string (typically `$variable` ref). + */ +export type SizingBehavior = number | SizingKeyword | string; + +export type SizingKeyword = "fit_content" | "fill_container"; + +/** + * Range slider. `value` two-way binds via `bindings.bind:value`; + * `min`/`max`/`step` default to 0/100/1 at runtime when omitted. + */ +export type SliderNode = { width: SizingBehavior | null, height: SizingBehavior | null, min: number | null, max: number | null, step: number | null, value: NumberOrExpression | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type SolidFillBody = { color: string, explain: string | null, opacity: number | null, blendMode: BlendMode | null, }; + +export type SplashConfig = { background: string | null, image: string | null, text: string | null, minDurationMs: number | null, }; + +export type StateEntry = { type: StateType, default: JsonValue | null, description: string | null, persist: boolean | null, }; + +/** + * Recursive state type description. + */ +export type StateType = PrimitiveType | { oneOf: Array, } | { array: StateType, } | { object: { [key in string]?: StateType }, }; + +export type StrokeAlign = "inside" | "center" | "outside"; + +export type StrokeCap = "none" | "round" | "square"; + +export type StrokeJoin = "miter" | "bevel" | "round"; + +export type StrokeThickness = number | [number, number, number, number] | SidedThickness; + +export type StyleOverride = { fill: Array | null, stroke: PenStroke | null, effects: Array | null, opacity: number | null, }; + +export type StyledTextSegment = { text: string, fontFamily: string | null, fontSize: number | null, fontWeight: number | null, fontStyle: FontStyleKind | null, fill: string | null, underline: boolean | null, strikethrough: boolean | null, href: string | null, }; + +/** + * On/off toggle. `checked` two-way binds via `bindings.bind:value`. + */ +export type SwitchNode = { width: SizingBehavior | null, height: SizingBehavior | null, checked: BoolOrExpression | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +/** + * Tabbed panel switcher. Unlike the leaf widgets this is a CONTAINER: + * `children[i]` is the panel for `tabs[i]`. The active tab `value` + * two-way binds via `bindings.bind:value`; only the active panel is + * painted at runtime. + */ +export type TabsNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Tab bar entries; `value` keys the active tab, `label` is shown. + */ +tabs: Array | null, +/** + * Currently active tab `value`. + */ +value: string | null, +/** + * Panel subtrees, one per tab (parallel to `tabs` by index). + */ +children: Array | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type TextAlign = "left" | "center" | "right" | "justify"; + +export type TextAlignVertical = "top" | "middle" | "bottom"; + +/** + * Multi-line writable text input. Like `text_input` but wraps and + * scrolls vertically; two-way binds via `bindings.bind:value`. + */ +export type TextAreaNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Placeholder shown when `value` is empty. + */ +placeholder: string | null, +/** + * Initial value. Two-way binding lives on `bindings.bind:value`. + */ +value: string | null, +/** + * Lucide glyph drawn at the left content edge. See `TextInputNode`. + */ +leadingIcon: string | null, +/** + * Lucide glyph drawn at the right content edge. See `TextInputNode`. + */ +trailingIcon: string | null, +/** + * Visible-line window before the content scrolls (chat-style). + */ +maxVisibleLines: number | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type TextContent = string | Array; + +export type TextGrowth = "auto" | "fixed-width" | "fixed-width-height"; + +/** + * Single-line text input. Forms / counters need a writable input + * surface that two-way binds via `bindings.bind:value`. The walker + * renders a styled rectangle + caret placeholder; full IME and + * selection-painter wiring lands in the desktop host (Plan 8) once + * the gesture arena gains `Focus` recognizers. + */ +export type TextInputNode = { width: SizingBehavior | null, height: SizingBehavior | null, +/** + * Placeholder shown when `value` is empty. Static text — author + * `bindings.placeholder` if it needs to react to state. + */ +placeholder: string | null, +/** + * Initial value. Two-way binding lives on `bindings.bind:value`, + * which derive lifts into a `set_*` action and the runtime keeps + * in sync with the state graph. + */ +value: string | null, +/** + * Lucide glyph drawn at the left content edge (e.g. `mail`, `lock`). + * The painter insets the text/caret past it so the whole box stays + * one interactive node. `None` = no leading icon. + */ +leadingIcon: string | null, +/** + * Lucide glyph drawn at the right content edge (e.g. `eye` for a + * password reveal). Decorative in Phase 1 (no toggle behaviour). + */ +trailingIcon: string | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, cornerRadius: CornerRadius | null, states: WidgetStates | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type TextNode = { width: SizingBehavior | null, height: SizingBehavior | null, content: TextContent, fontFamily: string | null, fontSize: number | null, fontWeight: FontWeight | null, fontStyle: FontStyleKind | null, letterSpacing: number | null, lineHeight: number | null, textAlign: TextAlign | null, textAlignVertical: TextAlignVertical | null, textGrowth: TextGrowth | null, underline: boolean | null, strikethrough: boolean | null, fill: Array | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; + +export type ThemedValue = { value: VariableScalar, theme: { [key in string]?: string } | null, }; + +export type Transition = "push" | "fade" | "modal" | "none"; + +export type VariableDefinition = { type: VariableKind, value: VariableValue, }; + +export type VariableKind = "color" | "number" | "boolean" | "string"; + +export type VariableScalar = boolean | number | string; + +export type VariableValue = VariableScalar | Array; + +/** + * Authored overrides for the four auto-derived interaction states. + */ +export type WidgetStates = { hover: StyleOverride | null, pressed: StyleOverride | null, focused: StyleOverride | null, disabled: StyleOverride | null, }; diff --git a/packages/op-web-sdk/src/types.ts b/packages/op-web-sdk/src/types.ts new file mode 100644 index 000000000..bf9b95f0a --- /dev/null +++ b/packages/op-web-sdk/src/types.ts @@ -0,0 +1,15 @@ +// Public types for the OpenPencil read-only web SDK. +export interface Viewport { + panX: number; + panY: number; + zoom: number; +} + +export interface CreateViewerOptions { + canvas: HTMLCanvasElement; + doc?: string | Uint8Array; + wasmUrl?: string; +} + +// Re-export generated document types from the vendored ops schema. +export type { PenDocument, PenPage } from './ops-types.js'; diff --git a/packages/op-web-sdk/src/viewer.ts b/packages/op-web-sdk/src/viewer.ts new file mode 100644 index 000000000..6a535de8f --- /dev/null +++ b/packages/op-web-sdk/src/viewer.ts @@ -0,0 +1,141 @@ +// OpViewer wrapper around the wasm Viewer class, providing read-only document snapshots. +import { ensureWasm, WasmViewer, _export } from './wasm.js'; +import type { CreateViewerOptions, Viewport } from './types.js'; +import type { PenDocument, PenPage } from './ops-types.js'; +import { Emitter } from './events.js'; +import type { ViewerEvent } from './events.js'; + +let canvasSeq = 0; + +/** Ensure the canvas element has an id, assigning one if absent. */ +function ensureCanvasId(canvas: HTMLCanvasElement): string { + if (!canvas.id) canvas.id = `op-web-sdk-canvas-${++canvasSeq}`; + return canvas.id; +} + +/** Convert a string-or-binary source to UTF-8 text for the wasm load_str call. */ +function toText(src: string | Uint8Array): string { + return typeof src === 'string' ? src : new TextDecoder().decode(src); +} + +/** Read-only wrapper around the wasm Viewer. Returns typed PenDocument / PenPage snapshots. */ +export class OpViewer { + /** @internal */ wheelHandler?: (e: WheelEvent) => void; + /** @internal */ canvas?: HTMLCanvasElement; + private readonly emitter = new Emitter(); + private destroyed = false; + + /** @internal */ constructor(private readonly inner: InstanceType) {} + + /** Throw if the viewer has already been destroyed. */ + private assertLive(): void { + if (this.destroyed) throw new Error('op-web-sdk: viewer has been destroyed'); + } + + /** Subscribe to a viewer event. Returns an unsubscribe function. */ + on(event: ViewerEvent, cb: () => void): () => void { return this.emitter.on(event, cb); } + + /** Unsubscribe a specific callback from a viewer event. */ + off(event: ViewerEvent, cb: () => void): void { this.emitter.off(event, cb); } + + /** @internal Fire an event — used by createViewer for wheel-driven viewport changes. */ + emit(event: ViewerEvent): void { this.emitter.emit(event); } + + /** Load (or reload) a document from a JSON string or binary blob. */ + load(src: string | Uint8Array): void { + this.assertLive(); + this.inner.load_str(toText(src)); + this.inner.push_scene(); + this.emitter.emit('load'); + } + + /** Parsed document object. Returns a typed PenDocument snapshot. */ + get document(): PenDocument { + this.assertLive(); + return JSON.parse(this.inner.document_json()) as PenDocument; + } + + /** Parsed pages array. Returns a typed PenPage[] snapshot. */ + get pages(): PenPage[] { + this.assertLive(); + return JSON.parse(this.inner.pages_json()) as PenPage[]; + } + + /** Total number of pages in the loaded document. */ + get pageCount(): number { + this.assertLive(); + return this.inner.page_count(); + } + + /** Zero-based index of the currently active page. */ + get activePage(): number { + this.assertLive(); + return this.inner.active_page_index(); + } + + /** Current viewport state parsed from wasm (snake_case → camelCase). */ + get viewport(): Viewport { + this.assertLive(); + const v = JSON.parse(this.inner.viewport_json()) as { pan_x: number; pan_y: number; zoom: number }; + return { panX: v.pan_x, panY: v.pan_y, zoom: v.zoom }; + } + + /** Set viewport pan and zoom simultaneously. */ + setViewport(v: Viewport): void { this.assertLive(); this.inner.set_viewport(v.panX, v.panY, v.zoom); this.emitter.emit('viewportchange'); } + + /** Change zoom level while keeping current pan position. */ + setZoom(z: number): void { this.assertLive(); const c = this.viewport; this.inner.set_viewport(c.panX, c.panY, z); this.emitter.emit('viewportchange'); } + + /** Pan to a new position while keeping current zoom level. */ + panTo(panX: number, panY: number): void { this.assertLive(); const c = this.viewport; this.inner.set_viewport(panX, panY, c.zoom); this.emitter.emit('viewportchange'); } + + /** Zoom to fit the given width/height into the canvas viewport. */ + zoomToFit(w: number, h: number): void { this.assertLive(); this.inner.zoom_to_fit(w, h); this.emitter.emit('viewportchange'); } + + /** Export the current document to SVG bytes. + * Only 'svg' is supported in v1; any other format throws immediately. */ + export(opts: { format: 'svg' }): Uint8Array { + this.assertLive(); + if (opts.format !== 'svg') throw new Error(`op-web-sdk: format "${opts.format}" not supported in v1 (use 'svg')`); + return _export(this.inner, 'svg'); + } + + /** Tear down the viewer: remove the wheel listener, detach from the canvas, + * free the wasm instance, and clear all event subscriptions. + * Idempotent — safe to call more than once. */ + destroy(): void { + if (this.destroyed) return; + this.destroyed = true; + if (this.canvas && this.wheelHandler) this.canvas.removeEventListener('wheel', this.wheelHandler); + this.inner.detach(); + this.inner.free(); + this.emitter.clear(); + } + + /** @internal Expose the underlying wasm instance for sub-class access. */ + get _inner() { + return this.inner; + } +} + +/** Initialise the wasm module, create a Viewer bound to the given canvas, + * optionally load an initial document, and return an OpViewer. */ +export async function createViewer(opts: CreateViewerOptions): Promise { + await ensureWasm(opts.wasmUrl); + const inner = new WasmViewer(); + const viewer = new OpViewer(inner); + if (opts.doc !== undefined) viewer.load(opts.doc); + const id = ensureCanvasId(opts.canvas); + await inner.attach_canvas(id); + // Store canvas reference and attach a non-passive wheel listener to forward + // scroll/pinch events to the wasm renderer with cursor-relative coordinates. + viewer.canvas = opts.canvas; + viewer.wheelHandler = (e: WheelEvent) => { + e.preventDefault(); + const r = opts.canvas.getBoundingClientRect(); + inner.forward_wheel(e.deltaX, e.deltaY, e.ctrlKey || e.metaKey, e.clientX - r.left, e.clientY - r.top); + viewer.emit('viewportchange'); + }; + opts.canvas.addEventListener('wheel', viewer.wheelHandler, { passive: false }); + return viewer; +} diff --git a/packages/op-web-sdk/src/wasm-types.d.ts b/packages/op-web-sdk/src/wasm-types.d.ts new file mode 100644 index 000000000..fa7c27df4 --- /dev/null +++ b/packages/op-web-sdk/src/wasm-types.d.ts @@ -0,0 +1,25 @@ +// Type declarations for the virtual wasm glue module. +// The virtual specifier 'virtual:op_web_sdk_wasm' is resolved by the Vite plugin +// in vitest.config.ts (to the test stub) and in tsup.config.ts (to the real bundle). +// Using a virtual specifier avoids requiring a physical wasm/ file for tsc or Vitest. +declare module 'virtual:op_web_sdk_wasm' { + export class Viewer { + constructor(); + free(): void; + load_str(src: string): void; + page_count(): number; + active_page_index(): number; + attach_canvas(canvas_id: string): Promise; + detach(): void; + mark_dirty(): void; + push_scene(): void; + set_viewport(pan_x: number, pan_y: number, zoom: number): void; + zoom_to_fit(w: number, h: number): void; + forward_wheel(dx: number, dy: number, ctrl_or_meta: boolean, cursor_x: number, cursor_y: number): void; + document_json(): string; + pages_json(): string; + viewport_json(): string; + } + export function _export(viewer: Viewer, format: string): Uint8Array; + export default function init(module_or_path?: unknown): Promise; +} diff --git a/packages/op-web-sdk/src/wasm.ts b/packages/op-web-sdk/src/wasm.ts new file mode 100644 index 000000000..ed7d01424 --- /dev/null +++ b/packages/op-web-sdk/src/wasm.ts @@ -0,0 +1,13 @@ +// Single seam to the Plan-1 wasm bundle. All SDK code imports the wasm +// Viewer through here so unit tests can mock this module. +import init, { Viewer as WasmViewer, _export } from 'virtual:op_web_sdk_wasm'; + +let started: Promise | null = null; + +/** Initialise the wasm module exactly once. `url` overrides the .wasm asset URL. */ +export async function ensureWasm(url?: string): Promise { + if (!started) started = init(url ? { module_or_path: url } : undefined); + await started; +} + +export { WasmViewer, _export }; diff --git a/packages/op-web-sdk/test/events.test.ts b/packages/op-web-sdk/test/events.test.ts new file mode 100644 index 000000000..653d7c879 --- /dev/null +++ b/packages/op-web-sdk/test/events.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MockViewer } from './mock-wasm.js'; +let current: MockViewer; +vi.mock('../src/wasm.js', () => ({ + ensureWasm: vi.fn(async () => {}), WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), _export: vi.fn(), +})); +import { createViewer } from '../src/index.js'; + +describe('events', () => { + beforeEach(() => vi.clearAllMocks()); + it('fires viewportchange on setZoom and stops after off', async () => { + const v = await createViewer({ canvas: document.createElement('canvas') }); + const cb = vi.fn(); + const off = v.on('viewportchange', cb); + v.setZoom(2); + expect(cb).toHaveBeenCalledTimes(1); + off(); v.setZoom(3); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/op-web-sdk/test/export-destroy.test.ts b/packages/op-web-sdk/test/export-destroy.test.ts new file mode 100644 index 000000000..dd50dca16 --- /dev/null +++ b/packages/op-web-sdk/test/export-destroy.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MockViewer, mockExport } from './mock-wasm.js'; +let current: MockViewer; +vi.mock('../src/wasm.js', () => ({ + ensureWasm: vi.fn(async () => {}), WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), _export: mockExport, +})); +import { createViewer } from '../src/index.js'; + +describe('export + destroy', () => { + beforeEach(() => vi.clearAllMocks()); + it('exports svg bytes and rejects png', async () => { + const v = await createViewer({ canvas: document.createElement('canvas') }); + expect(v.export({ format: 'svg' })).toBeInstanceOf(Uint8Array); + expect(() => v.export({ format: 'png' as 'svg' })).toThrow(); + }); + it('destroy detaches, frees, and removes the wheel listener', async () => { + const canvas = document.createElement('canvas'); + const v = await createViewer({ canvas }); + v.destroy(); + expect(current.detach).toHaveBeenCalled(); + expect(current.free).toHaveBeenCalled(); + canvas.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + expect(current.forward_wheel).not.toHaveBeenCalled(); + }); + it('calling a method after destroy throws the use-after-free error', async () => { + const v = await createViewer({ canvas: document.createElement('canvas') }); + v.destroy(); + expect(() => v.setZoom(2)).toThrow('op-web-sdk: viewer has been destroyed'); + }); + it('double-destroy is a no-op and does not call free twice', async () => { + const v = await createViewer({ canvas: document.createElement('canvas') }); + v.destroy(); + expect(() => v.destroy()).not.toThrow(); + expect(current.free).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/op-web-sdk/test/mock-wasm.ts b/packages/op-web-sdk/test/mock-wasm.ts new file mode 100644 index 000000000..fc399dec3 --- /dev/null +++ b/packages/op-web-sdk/test/mock-wasm.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest'; +// In-memory stand-in for the wasm Viewer so unit tests run in jsdom. +export class MockViewer { + private docJson = '{}'; + private pagesJson = '[]'; + private vp = { pan_x: 0, pan_y: 0, zoom: 1 }; + free = vi.fn(); + load_str = vi.fn((src: string) => { this.docJson = src; this.pagesJson = '[{"id":"p","name":"P","children":[]}]'; }); + page_count = vi.fn(() => 1); + active_page_index = vi.fn(() => 0); + attach_canvas = vi.fn(async () => {}); + detach = vi.fn(); + mark_dirty = vi.fn(); + push_scene = vi.fn(); + set_viewport = vi.fn((x: number, y: number, z: number) => { this.vp = { pan_x: x, pan_y: y, zoom: z }; }); + zoom_to_fit = vi.fn(() => { this.vp = { pan_x: 0, pan_y: 0, zoom: 2 }; }); + forward_wheel = vi.fn(); + document_json = vi.fn(() => this.docJson); + pages_json = vi.fn(() => this.pagesJson); + viewport_json = vi.fn(() => JSON.stringify(this.vp)); +} +export const mockExport = vi.fn((_v: unknown, format: string) => { + // Use a typed-array literal instead of TextEncoder to avoid jsdom cross-realm + // Uint8Array instanceof failures (TextEncoder in jsdom returns a Node-realm Uint8Array). + if (format === 'svg') return new Uint8Array([60, 115, 118, 103, 47, 62]); // '' + throw new Error('not available'); +}); diff --git a/packages/op-web-sdk/test/navigation.test.ts b/packages/op-web-sdk/test/navigation.test.ts new file mode 100644 index 000000000..b7822c19b --- /dev/null +++ b/packages/op-web-sdk/test/navigation.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MockViewer } from './mock-wasm.js'; +let current: MockViewer; +vi.mock('../src/wasm.js', () => ({ + ensureWasm: vi.fn(async () => {}), + WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), + _export: vi.fn(), +})); +import { createViewer } from '../src/index.js'; + +describe('navigation', () => { + beforeEach(() => vi.clearAllMocks()); + it('setZoom keeps pan and changes zoom; viewport reflects it', async () => { + const v = await createViewer({ canvas: document.createElement('canvas') }); + v.setViewport({ panX: 3, panY: 4, zoom: 1 }); + v.setZoom(2); + expect(current.set_viewport).toHaveBeenLastCalledWith(3, 4, 2); + expect(v.viewport).toEqual({ panX: 3, panY: 4, zoom: 2 }); + }); + it('forwards wheel events to the wasm viewer', async () => { + const canvas = document.createElement('canvas'); + await createViewer({ canvas }); + canvas.dispatchEvent(new WheelEvent('wheel', { deltaX: 5, deltaY: -10, ctrlKey: true, bubbles: true })); + expect(current.forward_wheel).toHaveBeenCalled(); + }); +}); diff --git a/packages/op-web-sdk/test/scaffold.test.ts b/packages/op-web-sdk/test/scaffold.test.ts new file mode 100644 index 000000000..b89efbbe4 --- /dev/null +++ b/packages/op-web-sdk/test/scaffold.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { VERSION } from '../src/index.js'; + +describe('package scaffold', () => { + it('exposes a VERSION string', () => { + expect(typeof VERSION).toBe('string'); + }); +}); diff --git a/packages/op-web-sdk/test/viewer.test.ts b/packages/op-web-sdk/test/viewer.test.ts new file mode 100644 index 000000000..e66769a9d --- /dev/null +++ b/packages/op-web-sdk/test/viewer.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MockViewer } from './mock-wasm.js'; +let current: MockViewer; +vi.mock('../src/wasm.js', () => ({ + ensureWasm: vi.fn(async () => {}), + WasmViewer: vi.fn(() => { current = new MockViewer(); return current; }), + _export: vi.fn(), +})); +import { createViewer } from '../src/index.js'; + +function makeCanvas() { return document.createElement('canvas'); } + +describe('createViewer + snapshots', () => { + beforeEach(() => vi.clearAllMocks()); + it('loads the doc and exposes page count', async () => { + const v = await createViewer({ canvas: makeCanvas(), doc: '{"version":"1.0","pages":[{"id":"p","name":"P","children":[]}]}' }); + expect(current.load_str).toHaveBeenCalled(); + expect(v.pageCount).toBe(1); + expect(v.activePage).toBe(0); + expect(Array.isArray(v.pages)).toBe(true); + }); +}); diff --git a/packages/op-web-sdk/test/wasm-stub.ts b/packages/op-web-sdk/test/wasm-stub.ts new file mode 100644 index 000000000..64f883f73 --- /dev/null +++ b/packages/op-web-sdk/test/wasm-stub.ts @@ -0,0 +1,31 @@ +// Committed test stub for the wasm glue module. +// Vitest resolves '../wasm/op_web_sdk.js' to this file via the alias in +// vitest.config.ts so tests pass on a fresh clone with no wasm/ dir present. + +/** Minimal stand-in for the wasm-bindgen Viewer class. */ +export class Viewer { + free(): void {} + load_str(_src: string): void {} + page_count(): number { return 0; } + active_page_index(): number { return 0; } + async attach_canvas(_canvas_id: string): Promise {} + detach(): void {} + mark_dirty(): void {} + push_scene(): void {} + set_viewport(_pan_x: number, _pan_y: number, _zoom: number): void {} + zoom_to_fit(_w: number, _h: number): void {} + forward_wheel(_dx: number, _dy: number, _ctrl_or_meta: boolean, _cursor_x: number, _cursor_y: number): void {} + document_json(): string { return '{}'; } + pages_json(): string { return '[]'; } + viewport_json(): string { return '{}'; } +} + +/** Minimal stand-in for the wasm _export function. */ +export function _export(_viewer: Viewer, _format: string): Uint8Array { + return new Uint8Array(); +} + +/** Async no-op standing in for the wasm-bindgen init default export. */ +export default async function init(_module_or_path?: unknown): Promise> { + return {}; +} diff --git a/packages/op-web-sdk/test/wasm.test.ts b/packages/op-web-sdk/test/wasm.test.ts new file mode 100644 index 000000000..fdac4426b --- /dev/null +++ b/packages/op-web-sdk/test/wasm.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock the virtual wasm module (resolved to test/wasm-stub.ts by vitest.config.ts). +// vi.mock hoists before imports, so the spy is in place when ensureWasm loads. +vi.mock('virtual:op_web_sdk_wasm', () => { + const init = vi.fn(async () => ({})); + return { default: init, Viewer: class {}, _export: vi.fn(() => new Uint8Array()) }; +}); + +import { ensureWasm } from '../src/wasm.js'; +import initGlue from 'virtual:op_web_sdk_wasm'; + +describe('ensureWasm', () => { + it('calls the wasm init exactly once across two calls', async () => { + await ensureWasm(); + await ensureWasm(); + expect(initGlue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/op-web-sdk/tsconfig.json b/packages/op-web-sdk/tsconfig.json new file mode 100644 index 000000000..4b0ecf7b4 --- /dev/null +++ b/packages/op-web-sdk/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", + "strict": true, "declaration": true, "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vitest/globals"], "noEmit": true, + "paths": { + "virtual:op_web_sdk_wasm": ["./test/wasm-stub.ts"] + } + }, + "include": ["src", "test"] +} diff --git a/packages/op-web-sdk/tsup.config.ts b/packages/op-web-sdk/tsup.config.ts new file mode 100644 index 000000000..aec0ab1c5 --- /dev/null +++ b/packages/op-web-sdk/tsup.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'tsup'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: true, + // The wasm glue is imported via the virtual specifier `virtual:op_web_sdk_wasm` + // so vitest (resolveId plugin) and tsc (tsconfig paths) resolve it to a stub + // without the real bundle present. For the production build, alias it to the + // real wasm-bindgen glue at `wasm/op_web_sdk.js`, which `sync-wasm.sh` + // populates before `bun run build`. (Build is deferred-verification: it needs + // tsup installed and the synced bundle on disk.) + esbuildOptions(options) { + options.alias = { + ...options.alias, + 'virtual:op_web_sdk_wasm': resolve(here, 'wasm/op_web_sdk.js'), + }; + }, +}); diff --git a/packages/op-web-sdk/vitest.config.ts b/packages/op-web-sdk/vitest.config.ts new file mode 100644 index 000000000..7980f74ac --- /dev/null +++ b/packages/op-web-sdk/vitest.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, type Plugin } from 'vitest/config'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// Absolute path to the committed test stub. +const wasmStubPath = path.resolve(__dirname, 'test/wasm-stub.ts'); + +// The virtual module specifier used in src/wasm.ts and test files. +const VIRTUAL_WASM_ID = 'virtual:op_web_sdk_wasm'; + +/** + * Vite plugin that resolves the virtual wasm specifier to the committed test + * stub during tests, so import-analysis succeeds on a fresh clone with no + * wasm/ dir present. The real bundle is resolved differently at build time + * (by tsup/externals or by sync-wasm.sh populating wasm/). + */ +function wasmStubPlugin(): Plugin { + return { + name: 'wasm-stub', + enforce: 'pre', + resolveId(id) { + if (id === VIRTUAL_WASM_ID) return wasmStubPath; + return null; + }, + }; +} + +export default defineConfig({ + plugins: [wasmStubPlugin()], + test: { environment: 'jsdom', globals: true }, +});