---
name: dither-ui-studio
description: Compose dither-ui screens, components and charts as Studio documents, offline or against a live Studio tab. Use when asked to build UI with dither-ui, dither-kit, or the dither-ui Studio.
---

# dither-ui Studio

dither-ui is a Vue 3 toolkit (plus a Svelte 5 port) rendered on one ordered-dither
canvas engine: charts, 55+ components, generative backgrounds, text and motion
effects, all seed-deterministic. The **Studio** at https://dither-ui.com/studio is
an infinite-canvas editor: every frame ("artboard") is a chart, a widget, one
registry component, or a **screen** composed of rows of registry components.
Studio exports every frame as a Vue single-file component.

You build for the Studio by producing **documents**. A document is JSON. Studio
validates everything it loads: unknown components are dropped, props are
clamped to their specs, enums fall back to defaults. You cannot break the
editor with a bad document, but you can waste a turn, so follow the registry.

## 1. Read the registry first

`https://dither-ui.com/agent/registry.json` lists every placeable component:

```json
{ "is": "DitherSlider", "label": "Slider", "group": "inputs",
  "frame": { "w": 280, "h": 90 },
  "props": [ { "key": "color", "kind": "color", "def": "blue" },
             { "key": "min", "kind": "number", "def": 0 },
             { "key": "max", "kind": "number", "def": 100 },
             { "key": "disabled", "kind": "boolean", "def": false } ],
  "slotText": null, "vmodel": { "default": 40 } }
```

Rules the validator enforces:

- `is` must be a registry name exactly (`DitherTabs`, not `Tabs`).
- Prop kinds: `text` (string), `url` (an http(s), mailto, raster `data:` image
  or relative address; any other scheme falls back to the default),
  `boolean`, `number` (clamped to `min`/`max`), `select` (one of `options`),
  `color` (`green | blue | purple | pink | orange | red | grey` or `#rrggbb`),
  `list` (array of strings).
- `slotText` is the component's visible text where it has a slot (buttons,
  badges, checkboxes).
- Screens have ONE nesting level: rows of cells. No nested containers.

## 2. The document shape

```json
{
  "artboards": [
    {
      "name": "Sign in",
      "x": 0, "y": 0, "w": 380, "h": 420,
      "chart": { "type": "area" },
      "widget": {
        "kind": "screen", "gap": 16, "padding": 20,
        "rows": [
          { "cells": [ { "is": "DitherInput", "props": { "placeholder": "Email" }, "grow": true } ],
            "align": "center", "justify": "start", "gap": 12 },
          { "cells": [ { "is": "DitherBadge", "slotText": "Continue", "props": { "color": "blue" } } ],
            "justify": "end" }
        ]
      }
    },
    {
      "name": "Signups",
      "x": 440, "y": 0, "w": 520, "h": 360,
      "chart": {
        "type": "bar",
        "rows": [ { "month": "Q1", "web": 10, "app": 5 }, { "month": "Q2", "web": 20, "app": 9 } ],
        "series": [ { "key": "web", "label": "Web", "color": "blue" }, { "key": "app", "label": "App", "color": "pink" } ],
        "seed": 512, "bloom": "low", "stackType": "default", "cell": 2
      }
    }
  ]
}
```

- Every artboard carries a `chart` (a stub `{ "type": "area" }` is fine for
  widget frames) and optionally a `widget`.
- Widget kinds: `avatar`, `button`, `gradient`, `image` (bespoke models),
  `component` (`{ "kind": "component", "is", "props", "slotText" }`), `screen`.
- Chart rows use the family's label key: cartesian (`area`, `line`, `bar`)
  → `month`; `pie` → `{ "name", "value" }`; `radar` → `axis`. Series keys must
  match the row keys. `seed` is any integer; the same seed replays the same
  texture forever.
- Omit `id`; Studio assigns one. Omit anything else and defaults fill in.

## 3. Deliver it

**Offline (any harness, no browser access):** write the document to a
`.json` file. The user loads it in Studio through the project menu → *Open
file*, or by dropping the file onto the canvas. Save to file round-trips the
same shape, so you can read a user's project, edit it, and hand it back.

**Live through the ACP bridge (you are Claude Code, Codex, Gemini CLI, Qwen
Code, oh-my-pi, Goose, OpenCode, Auggie or any Agent Client Protocol
agent):** the user runs `node bridge/dither-bridge.mjs` from a dither-ui
checkout, picks you in the Studio's Agent panel and sends prompts from
there. Your session then
has an MCP server named `dither-studio` whose tools are the commands below
(`add_screen`, `add_component`, `add_chart`, `add_widget`, `update_artboard`,
`remove_artboard`, `evolve`, `select`, `get_registry`, `get_document`,
`get_code`, `list_artboards`), each applied to the live canvas and undoable.
Prefer them over writing files; the user is watching the canvas.

**Live (harness has a browser tool, e.g. the sitegeist bridge):** a Studio
tab speaks a DOM protocol that works from any script world. Dispatch on
`document`, always with a JSON **string** detail:

```js
const id = crypto.randomUUID()
document.addEventListener("dither-studio:result", (e) => {
  const r = JSON.parse(e.detail)   // { id, ok, data } or { id, ok: false, error }
  if (r.id === id) console.log(r)
}, { once: true })
document.dispatchEvent(new CustomEvent("dither-studio:command", {
  detail: JSON.stringify({ id, command: { type: "component.add", is: "DitherTabs",
    props: { tabs: ["One", "Two"], variant: "segmented" } } })
}))
```

The live document is also mirrored in
`document.getElementById("dither-studio-document").textContent` (JSON),
refreshed within half a second of any edit. Main-world code can call
`window.ditherStudio.run(command)` directly.

Commands (`type` and fields):

| type | fields |
| --- | --- |
| `document.get` | — |
| `document.set` | `document` (validated like a file import; undoable) |
| `artboard.list` | — |
| `artboard.select` | `id` or `ids` |
| `artboard.remove` | `id` |
| `artboard.update` | `id`, `patch` (name/x/y/w/h/hidden/locked, `chart` fields, `widget.props`, `widget.rows` or `widget.clips`) |
| `chart.add` | `chart`, `name?`, `data?: { labels, series: [{ key, label?, color?, values }] }`, `frame?` |
| `widget.add` | `widget` (avatar/button/gradient/image), `name?`, `props?` |
| `component.add` | `is`, `name?`, `props?`, `slotText?`, `frame?` |
| `screen.add` | `name?`, `rows`, `gap?`, `padding?`, `frame?` |
| `reel.add` | `name?`, `clips` (ids, or `{ id, seconds?, transition?: { kind, seconds?, cell?, seed? }, camera?, callouts?, chapter? }` — kinds `cut`, `dissolve`, `wipe-right`, `wipe-left`, `wipe-down`, `wipe-up`; 3s clips in over a 0.6s dissolve by default), `score?` (`true` or `{ seed }`), `frame?` — frames played in order with ordered-dither transitions; a film with camera moves, callouts, chapters and a score (see below) |
| `evolve` | `id?`, `count?` (1–12), `seed?`, `strength?` (0–1) — seeded variants placed as a row |
| `code.get` | `id`, `styling?` — the frame as a Vue SFC; `tailwind` (default) keeps the utility classes, `css` (a scoped stylesheet), `modules` (CSS Modules) or `stylex` write the same look in that system while the kit components keep their own styles |
| `registry.get` | `is?` — the registry, or one component |
| `video.export` | `id?`, `seconds?` (1–600; a reel's default is its length), `fps?` (24/30/60), `theme?` — the frame as a HyperFrames composition: `index` (HTML referencing `./player.js` + `./player.css`) and the `assets` URLs to fetch beside it; a reel with a score adds `score: { path: "score.wav", base64 }` for the index's audio track |
| `clock.seek` | `seconds?` — hold every animation on the canvas at that moment, so a screenshot is stable from any driver in any browser; without `seconds` time runs again (`clock.release` does the same) |

## 4. Work like the Studio does

- One idea per frame. A screen is a mockup of one view; a chart is one dataset.
- Prefer `screen.add` for anything with more than one control; prefer
  `component.add` to show a single control's states.
- When the user wants options, add one good frame and call `evolve` on it.
  Variation belongs to the engine (seeds), structure belongs to you.
- Ask Studio for `registry.get` with `is` before guessing a prop name.
- You can write 3D and shaders directly: `DitherWorld` takes a model as
  `props.source` (VRML97 / VRML 1.0 `.wrl`, X3D XML, glTF JSON, OBJ, ASCII
  STL / PLY or OFF text — VRML `TimeSensor` + interpolator + `ROUTE`
  animations play on the clock) or a URL as `props.src` (also `.glb`), and
  `DitherShader` takes GLSL as `props.source` (Shadertoy-style `mainImage`
  with `iTime`/`iResolution`/`iMouse`/`iColor`, or a raw `main`). Both are
  ordinary `component.add` targets; `props.colors` gives either a palette
  ramp. A world also takes `props.shader`: a GLSL material
  (`mainMaterial(out vec4, in vec2)` reading `dk_shade`, `dk_depth`,
  `dk_covered`, `dk_color` per cell) over its finished render. In code
  (not through the protocol) a world's `channels` bind other kit surfaces
  or images as its skin through the file's texture coordinates, and a
  material samples them with `dk_texture(p)` / `dk_uv(p)`.
- Layered motion is a `DitherStage`: a size query container with one clock
  whose `DitherLayer` children run keyframes in container units (x, y in
  `cqw`/`cqh`, rotate, scale, opacity), so a scene scales exactly with its
  frame and seeks for video. Both are `component.add` targets (the Studio
  composes a sample scene); the layers' keyframes themselves are code.
- A sequence is a reel: `reel.add` cuts existing frames into one frame that
  plays them in order, each clip coming in through an ordered-dither
  dissolve or wipe (the kit's `DitherReveal` masks). Build the frames first,
  then the reel; a reel has no code of its own, it is a video.
- A reel becomes a film (a launch video of the frames) with four things,
  all optional and all sanitized:
  - `camera` per clip: keys `{ t, x, y, zoom, tilt }` eased between. `t` is
    0–1 of the clip, `x`/`y` the point of the frame the camera looks at
    (0–1), `zoom` 1 is the frame fitted, `tilt` a lean in degrees (±30). Two
    keys make a move: `{ t: 0, zoom: 1 }` to `{ t: 1, zoom: 1.3 }` pushes in.
  - `callouts` per clip: `{ x, y, label, note?, side?, from?, to? }`, a
    point of the frame with a label on a leader; the camera carries it.
    Keep labels short facts ("glTF, OBJ, STL") and notes shorter.
  - `chapter` per clip: `{ line, spec?: [[figure, words]] }`, the band
    under the stage from that clip on: one line, then up to four rows with
    the measured figure first (`["264", "components"]`).
  - `score` on the reel: `true` or `{ seed }`. A soundtrack synthesized
    from the cut (a chord per clip, a riser and an impact on every cut, a
    tick per callout, a blip per chapter); the video export carries it.
- Screenshots: call `clock.seek` (the `seek_clock` tool) with a moment first,
  so every canvas holds still, then `clock.seek` with no seconds to let time
  run. The Studio is plain web platform — the same commands work through CDP
  (`Runtime.evaluate` on `window.ditherStudio.run(...)`), WebDriver, Playwright
  or a DOM event, in Chromium, WebKit and Firefox alike.
- Finish with `code.get` when the user wants code, and quote the SFC — in
  their styling system (`styling: "css" | "modules" | "stylex"`) when their
  project has one.

## 5. Render a frame to video (HyperFrames)

Every frame is also a video: the kit's animations are seek-deterministic, so
a frame exported as a [HyperFrames](https://github.com/heygen-com/hyperframes)
composition renders to an MP4 whose every pixel follows from the frame's seeds
and the requested time (Node 22 + FFmpeg: `npx hyperframes render`).

- Through the bridge: call the `export_video` tool (`id?`, `seconds?`,
  `fps?`, `theme?`). The Studio hands the bridge a self-contained composition
  and the bridge writes it as `video/<name>/index.html` under the project;
  render it with `npx hyperframes render video/<name> -o <name>.mp4`.
- Through the DOM protocol: `video.export` returns `index` plus two asset
  URLs; write `index.html`, `player.js` and `player.css` side by side and
  render the directory. The exported file carries the frame's document, the
  player and the root contract (`data-width/height`, `data-duration`,
  `data-fps`, `data-no-timeline`), so nothing is fetched at render time.
- A reel exports as one video of its whole cut: its clips travel in the
  composition's document and `seconds` defaults to the reel's length. A
  reel with a score carries it as the composition's audio track (inline in
  the bridge's file; `score.wav` beside `index.html` through the protocol).
- Length and frame rate are the file's `data-duration` / `data-fps`; the
  frame's size is the video's size. For 1080p, make the frame 960×540 (or
  480×270) and render with `--resolution landscape`: HyperFrames captures at
  a higher device pixel ratio, the kit's canvases follow it, and the dither
  gets finer while the design stays the same.
- Simulation backgrounds (particles, fluids) advance frame by frame, so
  render those with `--workers 1`. `npx hyperframes lint` reports the inert
  free-running paths inside the bundled player (`requestAnimationFrame`,
  `Math.random`, wall clocks); they do not run while the renderer seeks.
- The composition is a plain HyperFrames file: the HyperFrames skills apply
  if the user wants it cut into a longer piece (sub-composition, captions,
  music).
- Any browser engine: `npm run frames -- video/<name>/index.html --browser
  webkit --mp4 <name>.mp4` (in the dither-ui checkout; `chromium` and
  `firefox` too) seeks the composition frame by frame through the same
  `hf-seek` DOM event and screenshots it with Playwright, then assembles the
  MP4 with ffmpeg — no CDP needed. The player also exposes
  `window.ditherClock.seek(seconds)` / `.release()` for any `evaluate` call.
