← Back to home

Playground

The playground is Loom running entirely in your browser: type a .ddd source, watch the multi-project tree regenerate, then bundle and boot the generated Hono backend on an in-process Postgres and click through the generated React app — no server, no install, no Docker. It is live at https://loom-harness.github.io/Loc/playground/ (landing page at https://loom-harness.github.io/Loc/) and lives in the web/ workspace.

The headline point: it runs the exact same toolchain the ddd CLI runs. There is no second, browser-flavoured compiler — web/ imports the parser, IR lowering, validators, and generators straight from ../src. What you see generated in the browser is byte-for-byte what ddd generate system produces on disk.

How it imports the toolchain

web/ is a separate Vite + React package, but it has no copy of the compiler. It imports ../src directly because the Loom toolchain is pure TypeScript with no Node-only APIs — the only Node-bound seams are src/cli/ and src/language/main.ts (plus src/mcp/), none of which the playground imports. Vite's bundler handles the .js-extension import specifiers used throughout src/.

One module needs a swap. The React generator's design-pack loader has a Node variant (src/generator/_packs/loader-fs.ts) that reads .hbs templates off disk via node:fs. A small Vite plugin (loomLoaderShim in web/vite.config.ts) resolves any import ending in /_packs/loader-fs.js to web/src/build/loader-vfs.ts instead — a VFS-backed loader that reads the pack templates from an in-memory virtual filesystem seeded at worker boot. The shim is registered both for the main app build and for the worker build (the build worker transitively pulls the generator in), so neither tries to bundle node:fs.

Heavy work runs in Web Workers, not on the UI thread:

The four capabilities

The landing page advertises "typed editor + visual system builder + live preview + in-browser test runner." All four exist in the code; here is what each actually is.

Typed editor

A Monaco editor wired to the Langium LSP worker over monaco-languageclient

Visual system builder

There are three graphical editors (the "Code" tab's sub-views), all backed by craft.js / React Flow, that edit the same .ddd source round-trip — they parse the current source, let you manipulate it visually, then splice the result back in (preserving everything else) and push it through the live Monaco model so the source tab and Problems panel update immediately:

These are genuine source-editing surfaces, not read-only diagrams. On mobile they render as the plain source editor until explicitly opened (they are heavy to mount).

Live preview

When the generated tree contains a Hono backend and a React frontend, the runtime worker bundles both (an in-browser npm install + esbuild-wasm bundle, src/engine/), boots the backend, and the React app renders in a sandboxed iframe. Iframe fetch() calls to http://localhost:* are intercepted by an in-iframe shim and routed via postMessage to the parent, which dispatches them through the runtime worker — so the generated React → Hono → Postgres round-trip runs end-to-end under one origin. In live mode the preview refreshes in place as you type (debounced ~5 s).

The runtime is Hono + React only. A system that declares only .NET, Phoenix LiveView, Java, or Python deployables — or a Vue/Svelte frontend — generates fully and is browsable in the Files pane, but the preview names those as "run outside the playground" rather than booting them. The playground surfaces this explicitly instead of failing silently.

In-browser test runner

The Postgres is PGlite — a WebAssembly Postgres — booted inside the runtime worker (src/runtime/runtime.worker.ts). Because Loom emits a Drizzle pg-core schema rather than ready SQL, the worker can't run Drizzle Kit in a browser; instead src/runtime/ddl.ts (synthDDL) walks the bundled schema's table/enum/index metadata and emits the minimal CREATE SCHEMA / CREATE TYPE / CREATE TABLE / CREATE INDEX SQL to bring a fresh PGlite up to the shape the generated repositories expect. It is idempotent (IF NOT EXISTS, enum-create wrapped in a duplicate-object catch) so it re-applies cleanly against a persistent OPFS-backed PGlite — the DB survives reloads, keyed by a hash of the source, with "Reset DB" to drop and rebuild.

On top of that live runtime, the playground runs the DSL-emitted test suites — the same files ddd generate system writes:

A Backend console (OpenAPI-driven endpoint picker) and a SQL console round out the runtime panel for poking the booted backend by hand.

Workspaces across tabs

Each workspace is one git repo over one IndexedDB (LightningFS + isomorphic-git), so two tabs on the same workspace are two writers on one filesystem. LightningFS's own mutex only covers 500 ms activation windows, which is far shorter than a git sequence (stageAll → commit → writeRef), so the playground coordinates a level up:

A browser without navigator.locks / BroadcastChannel degrades to the old single-tab assumption — every tab writable — rather than to a hard failure or a spurious read-only banner.

Runtime & evolution surfaces

The dock's Runtime tab has four sub-views once the backend is booted: API (the spec-driven endpoint console), Database (the SQL console + Reset), Tables (read-only — information_schema.tables across every generated schema, click a table for its first 50 rows; a Users strip beside it names the identities requests carry: the generated dev stub's built-in identity and the Auth tab's override) and Requests (every OpenAPI operation with the number of requests it served, matched from the runtime log's request_end lines by web/src/backend/route-match.ts; unmatched paths collect in a 404s list). Every raw runtime error — a boot failure, a 4xx/5xx body, a dispatch that produced no response — carries one line of interpretation and a See Output → Runtime logs link above the raw text.

The Migrations tab compares the live source with a baseline (Last save by default, or any commit pinned from History) and draws the schema as a table diagram tinted by the pending migration — added green, changed amber, removed red, untouched dimmed (web/src/layout/migration-tint.ts over the evolution worker's tables + per-step table) — beside the migration SQL. A destructive change renders as the gate ddd migrate add raises: the --allow-destructive flag and the data it would drop. The comparison runs when the tab opens; Refresh re-runs it.

The workspace holds the committed migration chain in the same format the CLI keeps on disk: migrations/*.json beside the entry .ddd (/workspace/migrations/ for main.ddd, see migrations.md § The migration chain). When the chain exists, generate renders it whole, like ddd generate system. A chain that does not read as one list, or a model with schema changes the chain does not record, fails the generate with a diagnostic. The Migrations tab then uses the chain's head as the schema baseline, so the SQL it shows is exactly what ddd migrate add would record. The baseline source still drives the wire-contract diff. Every diff passes the model's migration { … } intents, so a declared rename shows as a RENAME COLUMN, not a drop and add. A status line under the baseline picker gives the chain's entry count, its head, and the unrecorded steps. On two heads it names both files and says to run ddd migrate rebase locally. Record migration takes a kebab-case name and is the playground's ddd migrate add, numbered from the browser's UTC clock. A destructive change also needs its --allow-destructive box ticked. Recording writes the new entries into the workspace's working tree like any other edit, then re-runs the diff and the generate. With no chain yet, the first recording starts one with each module's Initial. Like ddd migrate add, the diff and the record run the IR validator on the live source first: a model it refuses (an index: naming a removed field, a missing dataSources: binding) shows its diagnostics instead of a migration, because the SQL derived from it would not apply. A baseline pinned from History only has to load. The diff core is evolveSystem (src/system/migration-diff.ts), which the toolkit's loom_diff shares.

The Tests tab's requirement verdicts read Verified / Failing / Untested / Unverified with a one-line legend; a discovery failure (in a sandbox with no registry: Failed to resolve module specifier "uuidv7") shows one line of interpretation with the raw text folded behind Show details, and discovery reports per-suite progress with a Cancel.

The .loom/ bundle as views, and source ↔ output correspondence

generate system emits a documentation bundle beside the code (loom-artifacts.md). The Explorer switcher renders it rather than only listing it: Diagrams opens the .loom/*.mmd files in the mermaid viewer, Traceability renders the .loom/*.md reports, and API lists every HTTP operation grouped by aggregate plus the system's channels. The API view is derived from the IR (deriveContextOperations — the same derivation all five backend route builders render from), not parsed out of generated source, so it describes a .NET / Java / Phoenix / Python deployable's surface just as accurately, with nothing booted. The files stay browsable under Generated.

The generated tree also marks what changed since the last generate (the build worker returns a per-file content hash), and each History commit carries a What changed in the output section grouped by deployable.

Source ↔ output correspondence is the Compiler-Explorer feature over .loom/sourcemap.json, which the build worker now records on every system generate (the artifact itself is still only written into the tree under --sourcemap, so the emitted output is unchanged). Hovering a .ddd declaration names it, marks the generated files it produced and decorates their lines when one is open; hovering generated code flashes the .ddd span it came from; a Colour map toggle tints every declaration and every region it owns with one hue on both sides. Because the map is construct-granular rather than language-specific, one hover lights up files across every deployable at once. Which lines get painted follows the map: a region whose origin begins on the hovered line is a declaration you are pointing at (its whole file lights up), one that merely covers the line is a declaration you are inside (only that construct's lines light up). The mapping logic is pure in web/src/build/correspondence.ts.

Finally, a Select toggle in the preview footer arms the running app: the next click is swallowed and its element's data-testid is resolved — through the generated page that emits it, then the sourcemap — to the .ddd declaration, which is revealed in the editor with Open in Builder and Ask the agent beside it.

Crash reporting & diagnostics

The playground is a static GitHub Pages site: there is no telemetry and no beacon, and nothing is ever transmitted automatically. A crash is therefore only reportable if the app can hand you a self-sufficient artifact — which is what this layer does.

On device. Every crash-class event lands in a 12-entry ring persisted at localStorage["loom.diag"], so it survives the reload a crash causes. Read it either in Output → Diagnostics (newest first, with the message and stack inline) or from the console:

__loomDiag()          // the whole ring, oldest-first

Five reasons are error classes — react-error (the root boundary), react-error-pane (a pane boundary; carries the pane name), window-error, unhandledrejection, and worker-error (the build worker died — previously invisible). Everything else (hidden, pagehide) is a pressure breadcrumb: heap and storage readings captured before a crash. Each entry carries the build SHA of the bundle that produced it; the deployed bundle ships minified with no sourcemap, so the SHA is the key that makes a stack resolvable (rebuild that commit).

Reporting. Copy crash report and Report on GitHub sit on both crash fallbacks, on the "crashed last session" notice, and in the Diagnostics stream. Copy puts the complete markdown report on the clipboard and renders it on screen so you can read exactly what you are about to share. Report on GitHub opens the crash-report issue form with the report prefilled (abridged to fit the URL — the clipboard copy is always complete); it is an ordinary link, nothing is sent until you submit.

A report contains the build SHA and build time; the crash class, pane, message, stack and React component stack; the user-agent, viewport and page URL (path only); heap/storage pressure; and a workspace fingerprint — one row per file with its path, byte length and a truncated SHA-256.

A report never contains .ddd or generated source text (files appear as the fingerprint only), the URL query or hash (the share hash encodes your whole model), any credential shape found in free text (sk-…, ghp_…, bearer tokens, JWTs, key=/token= parameters — all replaced with [redacted]), or your BYOK provider key. That last one is structural, not a filter: the assembler (web/src/util/crash-report.ts) performs no ambient storage read at all, and a unit test pins that it never names the settings entry the key lives in.

Self-test. ?crash=app forces a throw inside the root boundary and ?crash=pane inside a pane boundary — the way to check that reporting works on your device without waiting for a real crash (and how the e2e spec covers the boundaries, which run in the production bundle).

How it's built and deployed

npm --prefix web run build runs tsc -b + vite build; the output is a fully static bundle (relative base, so it runs from any sub-path). The pages.yml workflow typechecks, smoke-tests (a Node-side end-to-end of the pipeline), prebuilds the same-origin npm-mirror/ so the deployed runtime installs without registry round-trips, builds, and stages web/dist/ under docs/_site/playground/ for GitHub Pages — deployed on every push to main that touches web/**. The Playwright e2e specs (web/e2e/) gate the production-built playground; see web/e2e/README.md.

Conventions for changing the playground

One source of truth

The value of the playground is that it is not a demo of Loom — it is Loom, minus the filesystem. Generate, validate, the design-pack rendering, the wire shape, the emitted tests: every one of those is the code under src/ that the CLI and CI run. A .ddd that compiles, boots, and passes its tests in the browser does so because the real toolchain made it, which is exactly why the playground doubles as a fast feedback loop while developing the toolchain itself.