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.
This commit is contained in:
Kayshen-X 2026-06-19 19:01:14 +08:00
parent 00526dec31
commit 20971e04b4
23 changed files with 1462 additions and 0 deletions

2
packages/op-web-sdk/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/dist/
/wasm/

View file

@ -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<OpViewer>`
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

View file

@ -0,0 +1,207 @@
<!DOCTYPE html>
<!--
OpenPencil Web SDK — browser demo.
Prerequisites (run from packages/op-web-sdk/):
1. bun install
2. bun run build (produces dist/index.js)
3. bun run sync-wasm (populates wasm/ and src/ops-types.ts; requires Rust wasm toolchain)
Then serve this directory with any static server, e.g.:
npx serve packages/op-web-sdk
# or
python3 -m http.server --directory packages/op-web-sdk 8080
Open http://localhost:8080/demo/index.html.
This demo is USER-RUN (deferred verification) — jsdom cannot execute wasm.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenPencil Web SDK Demo</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, sans-serif;
background: #0f0f0f;
color: #e4e4e7;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
min-height: 100dvh;
gap: 1.5rem;
}
h1 { font-size: 1.25rem; font-weight: 600; color: #a1a1aa; }
#canvas {
border: 1px solid #3f3f46;
border-radius: 8px;
background: #18181b;
width: min(900px, 100%);
height: 540px;
display: block;
}
.controls {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
button {
background: #27272a;
border: 1px solid #3f3f46;
color: #e4e4e7;
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.875rem;
transition: background 150ms;
}
button:hover { background: #3f3f46; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
#log {
width: min(900px, 100%);
background: #18181b;
border: 1px solid #3f3f46;
border-radius: 8px;
padding: 1rem;
font-family: monospace;
font-size: 0.8125rem;
max-height: 160px;
overflow-y: auto;
color: #a1a1aa;
}
.log-entry { line-height: 1.6; }
.log-entry.error { color: #f87171; }
</style>
</head>
<body>
<h1>OpenPencil Web SDK — read-only viewer demo</h1>
<canvas id="canvas" width="900" height="540"></canvas>
<div class="controls">
<button id="btn-load">Load fixture (.op)</button>
<button id="btn-zoom-in">Zoom in</button>
<button id="btn-zoom-out">Zoom out</button>
<button id="btn-zoom-fit">Zoom to fit</button>
<button id="btn-export">Export SVG</button>
<button id="btn-doc-info">Log document info</button>
</div>
<div id="log"><div class="log-entry">Ready — click "Load fixture" to begin.</div></div>
<!--
Import createViewer from the built ESM bundle.
Run `bun run build` from packages/op-web-sdk first.
-->
<script type="module">
import { createViewer } from '../dist/index.js';
const canvas = document.getElementById('canvas');
const log = document.getElementById('log');
function addLog(msg, isError = false) {
const el = document.createElement('div');
el.className = 'log-entry' + (isError ? ' error' : '');
el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
log.appendChild(el);
log.scrollTop = log.scrollHeight;
}
// Minimal fixture document — a single page with one rectangle.
const FIXTURE_DOC = JSON.stringify({
version: '1',
name: 'SDK Demo',
children: [
{
type: 'rectangle',
id: '1',
name: 'Demo rect',
x: 100,
y: 80,
width: 320,
height: 200,
fill: [{ type: 'solid', color: '#6366f1', opacity: 1, explain: null, blendMode: null }],
stroke: null,
effects: null,
},
],
});
let viewer = null;
async function initViewer() {
if (viewer) return viewer;
addLog('Initialising wasm viewer…');
viewer = await createViewer({ canvas });
viewer.on('load', () => addLog('Event: load fired'));
viewer.on('viewportchange', () => {
const { panX, panY, zoom } = viewer.viewport;
addLog(`Event: viewportchange panX=${panX.toFixed(1)} panY=${panY.toFixed(1)} zoom=${zoom.toFixed(3)}`);
});
addLog('Viewer ready.');
return viewer;
}
document.getElementById('btn-load').addEventListener('click', async () => {
try {
const v = await initViewer();
v.load(FIXTURE_DOC);
addLog(`Loaded document "${v.document.name ?? '(unnamed)'"} — ${v.pageCount} page(s).`);
} catch (err) {
addLog(String(err), true);
}
});
document.getElementById('btn-zoom-in').addEventListener('click', async () => {
try {
const v = await initViewer();
v.setZoom(v.viewport.zoom * 1.25);
} catch (err) { addLog(String(err), true); }
});
document.getElementById('btn-zoom-out').addEventListener('click', async () => {
try {
const v = await initViewer();
v.setZoom(v.viewport.zoom * 0.8);
} catch (err) { addLog(String(err), true); }
});
document.getElementById('btn-zoom-fit').addEventListener('click', async () => {
try {
const v = await initViewer();
v.zoomToFit(canvas.width, canvas.height);
addLog('zoomToFit called.');
} catch (err) { addLog(String(err), true); }
});
document.getElementById('btn-export').addEventListener('click', async () => {
try {
const v = await initViewer();
const bytes = v.export({ format: 'svg' });
const blob = new Blob([bytes], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.svg';
a.click();
URL.revokeObjectURL(url);
addLog(`Exported SVG (${bytes.byteLength} bytes).`);
} catch (err) { addLog(String(err), true); }
});
document.getElementById('btn-doc-info').addEventListener('click', async () => {
try {
const v = await initViewer();
const doc = v.document;
const pages = v.pages;
addLog(`document.name="${doc.name}" version="${doc.version}" pageCount=${v.pageCount} activePage=${v.activePage}`);
pages.forEach((p, i) => addLog(` page[${i}]: id="${p.id}" name="${p.name}" children=${p.children.length}`));
} catch (err) { addLog(String(err), true); }
});
</script>
</body>
</html>

View file

@ -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" }
}

View file

@ -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"

View file

@ -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<ViewerEvent, Set<() => 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(); }
}

View file

@ -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';

View file

@ -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: `{ "<action_name>": <body> }`.
*
* 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<Capability> | 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<string> | 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<Action> | null, onResume: Array<Action> | null, onBackground: Array<Action> | null, onTerminate: Array<Action> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<DesignMdColor> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<Action> | null, onDoubleTap: Array<Action> | null, onLongPress: Array<Action> | null, onPanStart: Array<Action> | null, onPanUpdate: Array<Action> | null, onPanEnd: Array<Action> | null, onScaleStart: Array<Action> | null, onScaleUpdate: Array<Action> | null, onScaleEnd: Array<Action> | null, onRotateStart: Array<Action> | null, onRotateUpdate: Array<Action> | null, onRotateEnd: Array<Action> | null, onHoverEnter: Array<Action> | null, onHoverLeave: Array<Action> | null, onChange: Array<Action> | null, onSubmit: Array<Action> | null, onFocus: Array<Action> | null, onBlur: Array<Action> | null, onKey: Array<Action> | null, onScroll: Array<Action> | null, onReachEnd: Array<Action> | 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<super::PenNode>` as children.
*/
export type FrameNode = { children: Array<PenNode> | null, imageSearchQuery: string | null, reusable: boolean | null, slot: Array<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, 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenNode> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, };
export type IconFontNode = { iconFontName: string, iconFontFamily: string | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array<PenFill> | 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<PenEffect> | 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<PenEffect> | 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<GradientStop>, 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<Capability> | 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<Action> | null, onUnmount: Array<Action> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<Action> | null, onLeave: Array<Action> | null, onForeground: Array<Action> | null, onBackground: Array<Action> | null, };
export type PathNode = { iconId: string | null, d: string | null, anchors: Array<PenPathAnchor> | null, closed: boolean | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<string> } | null, variables: { [key in string]?: VariableDefinition } | null, pages: Array<PenPage> | 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<PenNode>,
/**
* "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<LogicModuleRef> | 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<PenNode>, 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<number> | null, dashOffset: number | null, fill: Array<PenFill> | null, };
export type PolygonNode = { polygonCount: number, width: SizingBehavior | null, height: SizingBehavior | null, cornerRadius: number | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<GradientStop>, 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<SelectOption> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenNode> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | null, };
export type RefNode = { ref: string, descendants: { [key in string]?: JsonValue } | null, children: Array<PenNode> | 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<Action> | 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<SelectOption> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<Action>, };
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<SemanticAction> | null,
/**
* Author-stable override for the auto-derived AI action name.
* When set, the resulting action name is `<scope>.<aiName>`
* without the auto `_<hash4>` 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<string> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<StateType>, } | { 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<SelectOption> | null,
/**
* Currently active tab `value`.
*/
value: string | null,
/**
* Panel subtrees, one per tab (parallel to `tabs` by index).
*/
children: Array<PenNode> | null, fill: Array<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<StyledTextSegment>;
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<PenFill> | null, stroke: PenStroke | null, effects: Array<PenEffect> | 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<PenFill> | null, effects: Array<PenEffect> | 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<ThemedValue>;
/**
* Authored overrides for the four auto-derived interaction states.
*/
export type WidgetStates = { hover: StyleOverride | null, pressed: StyleOverride | null, focused: StyleOverride | null, disabled: StyleOverride | null, };

View file

@ -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';

View file

@ -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<typeof WasmViewer>) {}
/** 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<OpViewer> {
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;
}

25
packages/op-web-sdk/src/wasm-types.d.ts vendored Normal file
View file

@ -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<void>;
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<unknown>;
}

View file

@ -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<unknown> | null = null;
/** Initialise the wasm module exactly once. `url` overrides the .wasm asset URL. */
export async function ensureWasm(url?: string): Promise<void> {
if (!started) started = init(url ? { module_or_path: url } : undefined);
await started;
}
export { WasmViewer, _export };

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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]); // '<svg/>'
throw new Error('not available');
});

View file

@ -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();
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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<void> {}
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<Record<string, never>> {
return {};
}

View file

@ -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);
});
});

View file

@ -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"]
}

View file

@ -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'),
};
},
});

View file

@ -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 },
});