# dash-excalidraw — Excalidraw drawing canvas for Dash — full corpus

## Access policy

- Terms: these documents are free to fetch. A free account unlocks any gated document.
- Identity: agents may present a key by appending `?key=<value>` to any document URL. Get one: https://2plot.ai
- Rate: prefer ONE `/llms-full.txt` fetch over N per-page fetches. On 429, honour `Retry-After` and back off exponentially.
- Coordination: start at https://2plot.dev/llms.txt — one index enumerates every site; do not rediscover the network by crawling it.
- Crawler policy (mirrors /robots.txt): allowed: GPTBot, ClaudeBot, CCBot, Google-Extended, FacebookBot, Omgili, ByteSpider, Amazonbot, Applebot-Extended, meta-externalagent, AI2Bot, Diffbot, Timpibot, ImagesiftBot, ChatGPT-User, Claude-User, Claude-SearchBot, PerplexityBot, OAI-SearchBot, Perplexity-User, Googlebot, Bingbot, Slurp, DuckDuckBot, GoogleOther, Google-InspectionTool, Storebot-Google, AdsBot-Google.
- Accounting: every document read is logged with the requesting vendor (verified against published IP ranges where the operator publishes them). See https://2plot.dev/llms.txt

> dash-excalidraw — the Excalidraw whiteboard as a Dash component. JSON-safe props, imperative commands, event snapshots, AI scenes. By Pip Install Python.

Generated from 21 pages. The page index lives at https://excalidraw.2plot.dev/llms.txt and a compact briefing at https://excalidraw.2plot.dev/llms-small.txt; each page also serves its own document at `<page>/llms.txt`.

---

<!-- / — https://excalidraw.2plot.dev/llms.txt -->

# dash-excalidraw — Excalidraw drawing canvas for Dash

> **`dash-excalidraw` — the [Excalidraw](https://excalidraw.com/) whiteboard as a first-class [Dash](https://dash.plotly.com/) component.** By [Pip Install Python](https://2plot.dev).

Every canvas on this site is a running Dash app. Draw on them.

```bash
pip install dash-excalidraw
```

---

## The problem this solves

Excalidraw is a React application with a large imperative API. Wrapping it for Dash
means answering one question honestly: **what can cross the Python/JavaScript bridge?**

Only JSON can. Functions, RegExps and class instances cannot. Most wrappers stop
there and hand you a `clientside_callback` for anything interesting — which means
writing JavaScript to use a Python component.

This one translates the entire surface into three JSON-safe patterns, so you write
ordinary `@callback`s and never touch JavaScript.

| Upstream shape | What Python sees | Why |
|---|---|---|
| Callbacks (`onPaste`, `onPointerUpdate`, …) | **Snapshot props** — `lastPaste`, `lastPointerMove`, … each with a `timestamp` | A callback can't be serialized; a record of it firing can. The timestamp is how you dedupe. |
| Imperative methods (`updateScene`, `exportToSvg`, …) | **`command`** — `{id, type, payload}`, dispatched once per unique `id` | One prop covers twelve methods, and a re-render can't re-fire a command. |
| Async results | **`lastExport`**, carrying the `id` you dispatched | Exports resolve out of order under load. The id is how you correlate. |
| `validateEmbeddable` RegExp | A **list of glob strings**, compiled to RegExps on the JS side | A RegExp has no JSON form. `"*.youtube.com"` does. |

---

## Thirty seconds to a canvas

```python
from dash import Dash, Input, Output, callback, html
from dash_excalidraw import DashExcalidraw

app = Dash(__name__)

app.layout = html.Div([
    DashExcalidraw(id="canvas", height="600px"),
    html.Pre(id="count"),
])


@callback(Output("count", "children"), Input("canvas", "elements"))
def show(elements):
    return f"{len(elements or [])} elements on the canvas"


if __name__ == "__main__":
    app.run(debug=True)
```

Nothing else is required. The Excalidraw bundle, its stylesheet and its UI font ship
inside the wheel as one self-contained JavaScript file — no CDN dependency at load
time, no `external_stylesheets` entry, no build step.

---

## Where to go next

**Start here** — [Basic usage](/basic) is the smallest useful app.
[initialData](/initial-data) seeds a scene at mount.

**Appearance** — [Theming](/theming), [View modes](/view-modes), [UIOptions](/ui-options).

**Reading the canvas** — [Events](/events) shows every callback as a snapshot prop.
[Persistence](/persistence) streams `serializedData` to a store and restores it.

**Driving the canvas** — [Command dispatch](/commands) is the imperative API from
Python. [Export](/export) is the async round-trip. [Library](/library) reads and
writes the shape library.

**At scale** — [File uploads](/file-uploads) keeps canvas JSON small by pushing
pasted images to storage and swapping the base64 for URLs.
[Collaboration](/collaboration) drives the collaborator UI and live cursors.

**AI** — [AI agent](/ai-agent) turns a natural-language prompt into a scene, and is
honest about what that costs. [Trace an image](/trace-image) redraws a picture as a
scene, and [Scene viewer](/scene-viewer) renders any scene JSON — a trace included —
with no model and no key.

---

## Excalidraw 0.18

Pinned exactly at 0.18.1, which brings elbow arrows, flowchart shortcuts, scene
search, image cropping, element linking and the command palette — and patches the
mermaid XSS advisory.

One upgrade note worth reading before you dispatch a scene from Python: 0.18 replaced
`commitToHistory` with `captureUpdate`, and **changed what the default means**. 0.17
left undo history untouched; 0.18 folds a programmatic push into the *next* captured
action, so a user's first Ctrl+Z after your `updateScene` would also roll back their
own previous edit. Nothing errors and nothing warns.

This wrapper defaults to `IMMEDIATELY`, so a Python-dispatched push is one discrete,
individually undoable step. [Command dispatch](/commands) covers the override.

---

## For agents

Append `/llms.txt` to any URL on this site for the machine-readable Markdown of that
page. The index is at [/llms.txt](/llms.txt), and every page document opens with a
navigation block back to the site and network indexes rather than being a dead end.

---

<!-- /ai-agent — https://excalidraw.2plot.dev/ai-agent/llms.txt -->

# AI agent

> Turn a natural-language prompt into an Excalidraw scene with Claude, ChatGPT or Gemini, and what it costs.

---



> **On this site these pages are documentation, not a hosted service.**
> excalidraw.2plot.dev carries no provider keys, so generation is disabled
> here and nothing below will spend anything. Everything on the page —
> streaming onto the canvas, the cost estimate, the Stop button, the daily
> spend ceiling — works when you run the repo locally with a `.env` holding a
> provider key. This is deliberate and not a fault.

### Overview

Turn a natural-language prompt into an Excalidraw scene. Pick a provider —
Claude, ChatGPT or Gemini — write a prompt, hit generate; the result is
dispatched to the canvas via `command: updateScene` (no component remount).
Run the same prompt twice with different providers to compare output.

Claude and ChatGPT runs **stream**: elements appear on the canvas as the
model closes each one, so you watch the scene being drawn rather than waiting
on a spinner and receiving it all at once. The status line counts shapes and
seconds as they land. Gemini has no streaming path in its SDK, so it still
returns the whole scene at the end — the page keeps that behaviour rather than
faking progress it cannot see.

Each provider is enabled only when its key is present, and the badges above
the controls say which name was looked for. The cost line under the button
prices the run **before** you click it: a typical figure and a ceiling, because
the max-token budget bounds the spend without determining it. Gemini has no
price on file here and says so rather than showing $0.00.

### Live demo


### Source


```python
# File: docs/ai-agent/ai_agent.py

"""AI agent: turn a prompt into an Excalidraw scene via Claude, ChatGPT or Gemini.

Uses the command dispatch pattern (`command: updateScene`) — no component
remount, no key-hack needed on the canvas. The same page compares Claude
Opus 4.7 / Sonnet 4.6 against GPT-6 Astra and Gemini 2.5 Flash / Pro.

Ship-readiness checklist for using this in your own production app:

1. Set env vars:
     ANTHROPIC_API_KEY=...
     CHATGPT_API_KEY=...      # OPENAI_API_KEY is accepted as a fallback
     GEMINI_API_KEY=...
   All three are optional — the page disables the corresponding provider
   if its key is missing, and says which name it looked for.

2. This page calls the LLM synchronously inside a Dash callback. For
   production-grade UX, wrap with a background task queue (Celery / RQ
   / Dramatiq) or Dash's `background=True` long-callback machinery. The
   code here prioritizes clarity over throughput.

3. The prompt template is a starting point — tune `SYSTEM_PROMPT` for
   your domain. Prompt caching is active on Claude calls because the
   system prompt is stable across requests.
"""

from __future__ import annotations

import json
import os
import re
import time
import traceback
import uuid
from typing import Any, Dict

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, dcc, html, no_update

from dash_excalidraw import DashExcalidraw
from lib import scene_library, scene_stream, spend
from lib.scene_ai import (  # shared with /benchmark — see lib/scene_ai.py
    CLAUDE_EFFORT,
    CLAUDE_MAX_TOKENS,
    CLAUDE_MODELS,
    MODEL_PRICING,
    EFFORT_CAPABLE,
    EFFORT_LEVELS,
    NO_KEYS_NOTICE,
    MODEL_EFFORT,
    MODEL_MAX_TOKENS,
    OPENAI_MODELS,
    any_provider_configured,
    available_models,
    call_model,
    estimate_cost,
    format_money,
    supported_efforts,
    GEMINI_MAX_TOKENS,
    GEMINI_MODELS,
    SYSTEM_PROMPT,
    _call_gemini,
    _cleanup_json,
    _coerce_types,
    _extract_json_block,
    _parse_and_normalize,
    _spend_allowed,
)
from docs._shared import canvas_frame, sync_canvas_theme

sync_canvas_theme("ai-canvas")

def offered_claude_models():
    """The models this deployment's key can actually call.

    CALLED FROM A CALLBACK, NEVER AT IMPORT. Page modules are imported while
    Dash registers pages, so a module-level call here put an outbound request
    to api.anthropic.com on the BOOT path — every start of the app blocked on
    a third party being reachable, and the answer was then frozen for the life
    of the process. It was visible in the boot log, one line under
    "Loading docs/ai-agent/ai-agent.md":

        INFO:httpx:HTTP Request: GET https://api.anthropic.com/v1/models...

    `available_models` caches, so calling it per callback costs one request
    for the process and no more. `verified` False means the check could not
    run — the full table is offered and the page SAYS so rather than implying
    it was checked.
    """
    return available_models(CLAUDE_MODELS)

# ---------------------------------------------------------------------------
# Prompt template (domain-specific instructions for producing Excalidraw JSON)
# ---------------------------------------------------------------------------

# Read once at import: on the deployed site this never changes, and locally a
# `.env` edit means a restart anyway.
ANY_KEY = any_provider_configured()

HAS_CLAUDE_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
# CHATGPT_API_KEY is this site's name; OPENAI_API_KEY is the SDK's own and is
# accepted so a machine that already exports one needs no second copy.
HAS_CHATGPT_KEY = bool(os.environ.get("CHATGPT_API_KEY")) or bool(
    os.environ.get("OPENAI_API_KEY")
)
HAS_GEMINI_KEY = bool(os.environ.get("GEMINI_API_KEY")) or bool(
    os.environ.get("GOOGLE_API_KEY")
)


# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------


def _benchmark_status(provider, model, element_count, elapsed, meta) -> str:
    """One line carrying everything a sweep needs to be compared.

    Elements alone say nothing about whether a setting was worth it — the
    interesting question is elements per second and per dollar, at a given
    effort and budget. So the line reports the settings ACTUALLY applied
    (which is not always what was asked: effort is silently dropped on models
    that reject it) alongside tokens, latency and an estimated cost.
    """
    plural = "s" if element_count != 1 else ""
    head = f"{model} — {element_count} element{plural} in {elapsed:.0f}s"
    if not meta:
        return f"Generated via {provider} / {head}."

    bits = [f"effort={meta['effort']}", f"max_tokens={meta['max_tokens']:,}"]
    if meta.get("effort_ignored"):
        bits.append("(effort ignored — model rejects it)")

    out_tok, in_tok = meta["output_tokens"], meta["input_tokens"]
    bits.append(f"{out_tok:,} out / {in_tok:,} in")
    if meta.get("cache_read"):
        bits.append(f"{meta['cache_read']:,} cached")

    # MODEL_PRICING, not CLAUDE_PRICING: a ChatGPT run priced from the Claude
    # table finds nothing and silently reports no cost at all, which reads as
    # "this was free".
    price = MODEL_PRICING.get(model)
    if price and (out_tok or in_tok):
        cost = (in_tok * price[0] + out_tok * price[1]) / 1_000_000
        # Estimate, not a bill — see CLAUDE_PRICING.
        bits.append(f"~${cost:.3f}")

    if element_count:
        bits.append(f"{elapsed / element_count:.1f}s/element")

    # Say truncation in words. `stop=max_tokens` is accurate and means nothing
    # to someone whose scene just stopped growing — and hitting the budget is
    # the single most common reason a drawing comes out shorter than asked
    # for, because the budget covers thinking AND output.
    stop = meta.get("stop_reason")
    if stop in ("max_tokens", "incomplete"):
        bits.append(
            f"TRUNCATED — hit the {meta['max_tokens']:,}-token budget; "
            f"raise it or lower effort for a longer scene"
        )
    elif stop and stop not in ("end_turn", "completed"):
        bits.append(f"stop={stop}")

    return f"{head}  ·  " + "  ·  ".join(bits)


def _format_parse_error(raw: str, exc: json.JSONDecodeError) -> str:
    """Show a window of text around the failure position so the Parsed tab
    actually helps you understand what broke."""
    pos = exc.pos or 0
    start = max(0, pos - 120)
    end = min(len(raw), pos + 120)
    pointer = " " * (pos - start) + "^"
    return (
        f"JSON parse error: {exc.msg}\n"
        f"  at line {exc.lineno}, column {exc.colno} (char {pos})\n\n"
        f"--- context (±120 chars) ---\n"
        f"{raw[start:end]}\n"
        f"{pointer}\n"
        f"--- end context ---\n\n"
        f"Length of model response: {len(raw)} chars.\n"
        f"Try switching to a different model, shortening your prompt, or "
        f"asking for a simpler diagram."
    )


# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------


def _provider_status():
    if not ANY_KEY:
        # ONE neutral notice, not three red badges. This is the permanent
        # state of the deployed site, so it has to read as "this is how the
        # page is here", not as a fault. Red would be a lie: nothing is
        # broken and nothing the reader can do would fix it.
        return dmc.Alert(
            NO_KEYS_NOTICE,
            title="AI generation is disabled on this site",
            color="blue",
            variant="light",
        )

    items = []
    items.append(
        dmc.Badge(
            "Claude: ready" if HAS_CLAUDE_KEY else "Claude: missing ANTHROPIC_API_KEY",
            color="green" if HAS_CLAUDE_KEY else "red",
            variant="light",
            size="sm",
        )
    )
    items.append(
        dmc.Badge(
            "ChatGPT: ready"
            if HAS_CHATGPT_KEY
            else "ChatGPT: missing CHATGPT_API_KEY",
            color="green" if HAS_CHATGPT_KEY else "red",
            variant="light",
            size="sm",
        )
    )
    items.append(
        dmc.Badge(
            "Gemini: ready"
            if HAS_GEMINI_KEY
            else "Gemini: missing GEMINI_API_KEY",
            color="green" if HAS_GEMINI_KEY else "red",
            variant="light",
            size="sm",
        )
    )
    # The "unverified" badge cannot be decided here: knowing whether the
    # model list was checked means asking the provider, and this function runs
    # at page-import time. The slot is filled by `_sync_models` on first
    # render instead.
    items.append(html.Div(id="ai-model-check"))
    return dmc.Group(items, gap="xs")


component = dmc.Stack(
    gap="md",
    children=[
        _provider_status(),
        dmc.Paper(
            withBorder=True,
            p="md",
            children=dmc.Stack(
                gap="sm",
                children=[
                    dmc.Grid(
                        gutter="md",
                        children=[
                            dmc.GridCol(
                                dmc.Select(
                                    id="ai-provider",
                                    label="Provider",
                                    data=[
                                        {"value": "claude", "label": "Claude"},
                                        {"value": "chatgpt", "label": "ChatGPT"},
                                        {"value": "gemini", "label": "Gemini"},
                                    ],
                                    # Land on a provider whose key is present,
                                    # so the first click can succeed. Falls
                                    # back to Claude, which then reports the
                                    # missing key by name.
                                    value=(
                                        "claude"
                                        if HAS_CLAUDE_KEY
                                        else "chatgpt"
                                        if HAS_CHATGPT_KEY
                                        else "gemini"
                                        if HAS_GEMINI_KEY
                                        else "claude"
                                    ),
                                ),
                                span={"base": 12, "sm": 3},
                            ),
                            dmc.GridCol(
                                dmc.Select(
                                    id="ai-model",
                                    label="Model",
                                    # Filled by `_sync_models` on first
                                    # render — see offered_claude_models().
                                    data=[],
                                    value=None,
                                ),
                                span={"base": 12, "sm": 4},
                            ),
                            dmc.GridCol(
                                dmc.Select(
                                    id="ai-effort",
                                    label="Effort",
                                    description="Thinking depth",
                                    data=EFFORT_LEVELS,
                                    value=CLAUDE_EFFORT.get(
                                        CLAUDE_MODELS[0]["value"]
                                    ) or "low",
                                ),
                                span={"base": 6, "sm": 2},
                            ),
                            dmc.GridCol(
                                dmc.NumberInput(
                                    id="ai-max-tokens",
                                    label="Max tokens",
                                    description="Caps thinking + output together",
                                    value=CLAUDE_MAX_TOKENS[
                                        CLAUDE_MODELS[0]["value"]
                                    ],
                                    min=1000,
                                    max=128000,
                                    step=4000,
                                ),
                                span={"base": 6, "sm": 2},
                            ),
                            dmc.GridCol(
                                dmc.NumberInput(
                                    id="ai-seed",
                                    label="Seed",
                                    description="Bump for a fresh id namespace",
                                    value=1,
                                    min=1,
                                ),
                                span={"base": 12, "sm": 1},
                            ),
                        ],
                    ),
                    # What this run will cost, BEFORE it is triggered. The
                    # three controls above are all cost levers and none of
                    # them says so on its own — model sets the rate, max
                    # tokens sets the ceiling, effort sets how much of that
                    # ceiling gets used.
                    dmc.Alert(
                        id="ai-estimate",
                        color="gray",
                        variant="light",
                        p="xs",
                    ),
                    dmc.Textarea(
                        id="ai-prompt",
                        label="Prompt",
                        placeholder="E.g. 'a flowchart for onboarding a new engineer: signup → security training → first PR → mentorship pairing'",
                        autosize=True,
                        minRows=3,
                        maxRows=6,
                    ),
                    dmc.Group(
                        [
                            dmc.Button(
                                "Generate",
                                id="ai-generate-btn",
                                leftSection="✨",
                                color="indigo",
                                loaderProps={"type": "dots"},
                                # Nothing to generate with, so the control is
                                # off rather than offering a click that can
                                # only produce an error.
                                disabled=not ANY_KEY,
                            ),
                            # THE BRAKES. Enabled only while a run is in
                            # flight, and filled (not subtle) because the
                            # moment you want it is the moment you should not
                            # have to hunt for it.
                            dmc.Button(
                                "Stop",
                                id="ai-stop-btn",
                                leftSection="■",
                                color="red",
                                disabled=True,
                            ),
                            dmc.Button(
                                "Clear canvas",
                                id="ai-clear-btn",
                                variant="subtle",
                                color="red",
                            ),
                            # The scene library: LOCAL-ONLY, enabled on the same
                            # condition as generation itself (provider keys
                            # present). See lib/scene_library.py.
                            dmc.Button(
                                "Save scene",
                                id="ai-save-btn",
                                leftSection="💾",
                                variant="light",
                                color="indigo",
                                disabled=not ANY_KEY,
                            ),
                            dmc.Select(
                                id="ai-library",
                                placeholder=(
                                    "Saved scenes (local)" if ANY_KEY
                                    else "Scene library is local-only"
                                ),
                                data=[],
                                searchable=True,
                                clearable=True,
                                w=380,
                                disabled=not ANY_KEY,
                            ),
                        ]
                    ),
                ],
            ),
        ),
        # Processing banner — driven by the callback's `running=` clause so
        # feedback appears the instant the click registers, not once the
        # model has returned. `running` collides with normal Outputs, so
        # this element is only *shown*/*hidden* from running; its inner
        # copy is static.
        html.Div(
            id="ai-processing-banner",
            style={"display": "none"},
            children=dmc.Alert(
                color="blue",
                variant="light",
                children=dmc.Group(
                    gap="sm",
                    children=[
                        dmc.Loader(size="sm", color="blue", type="bars"),
                        dmc.Stack(
                            gap=0,
                            children=[
                                dmc.Text(
                                    "Generating scene…",
                                    fw=600,
                                    size="sm",
                                ),
                                dmc.Text(
                                    "Sending prompt to the model and parsing "
                                    "the response — typically 2–20 s. Opus / "
                                    "Gemini Pro are slower than Sonnet / Flash.",
                                    size="xs",
                                    c="dimmed",
                                ),
                            ],
                        ),
                    ],
                ),
            ),
        ),
        dmc.Alert(
            id="ai-status",
            children="Ready.",
            color="gray",
            variant="light",
            title="Status",
        ),
        dcc.Store(id="ai-last-raw", data=""),
        # The drawing in ARRIVAL ORDER, captured from the canvas command stream
        # (see the timeline section at the bottom). The timeline replays
        # `elements[:i]`.
        dcc.Store(id="ai-history", data={"elements": []}),
        # Mirror of this page's controls for window.DeckBridge (assets/
        # deck_bridge.js) — how a Stream Deck drives this page. Holds only a
        # timestamp; the state itself lives on window.
        dcc.Store(id="ai-deck-mirror"),
        dcc.Store(id="ai-library-mount", data=0),
        # The streaming pair. `ai-run` holds the id of the generation in
        # flight plus how many elements the canvas has already been shown;
        # `ai-stream-tick` collects whatever arrived since. 400ms is fast
        # enough that shapes appear to land as they are drawn, and slow
        # enough that a long generation is a few dozen polls rather than a
        # few thousand.
        dcc.Store(id="ai-run", data=None),
        dcc.Interval(id="ai-stream-tick", interval=400, disabled=True),
        dmc.Tabs(
            value="canvas",
            children=[
                dmc.TabsList(
                    [
                        dmc.TabsTab("Canvas", value="canvas"),
                        dmc.TabsTab("Raw response", value="raw"),
                        dmc.TabsTab("Parsed envelope", value="parsed"),
                    ]
                ),
                dmc.TabsPanel(
                    value="canvas",
                    pt="sm",
                    children=dmc.Box(
                        style={"position": "relative"},
                        children=[
                            dmc.LoadingOverlay(
                                id="ai-canvas-overlay",
                                visible=False,
                                zIndex=100,
                                overlayProps={
                                    "radius": "md",
                                    "blur": 2,
                                    "backgroundOpacity": 0.55,
                                },
                                loaderProps={
                                    "color": "indigo",
                                    "type": "bars",
                                    "size": "lg",
                                },
                            ),
                            canvas_frame(
                                DashExcalidraw(
                                    id="ai-canvas",
                                    height="640px",
                                    UIOptions={
                                        "welcomeScreen": False,
                                        "canvasActions": {
                                            "clearCanvas": True,
                                            "export": False,
                                            "saveAsImage": True,
                                        },
                                    },
                                ),
                                min_height=640,
                            ),
                            # THE TIMELINE. Every scene arrives one element at a
                            # time, so the draw order is a timeline for free:
                            # scrub it to step back through the drawing, or
                            # replay it. Works on saved scenes too.
                            dmc.Paper(
                                withBorder=True,
                                p="sm",
                                mt="xs",
                                children=dmc.Stack(
                                    gap=6,
                                    children=[
                                        dmc.Group(
                                            [
                                                dmc.Button(
                                                    "Replay",
                                                    id="ai-timeline-play",
                                                    leftSection="▶",
                                                    size="xs",
                                                    variant="light",
                                                    color="indigo",
                                                ),
                                                dmc.Text("Timeline", size="sm", fw=600),
                                                dmc.Text(
                                                    "No scene yet.",
                                                    id="ai-timeline-label",
                                                    size="xs",
                                                    c="dimmed",
                                                ),
                                            ],
                                            gap="sm",
                                        ),
                                        dmc.Slider(
                                            id="ai-timeline",
                                            min=0,
                                            max=1,
                                            value=0,
                                            step=1,
                                            disabled=True,
                                            color="indigo",
                                        ),
                                    ],
                                ),
                            ),
                        ],
                    ),
                ),
                dmc.TabsPanel(
                    value="raw",
                    pt="sm",
                    children=dcc.Loading(
                        id="ai-raw-loading",
                        type="default",
                        delay_show=200,
                        delay_hide=300,
                        custom_spinner=dmc.Stack(
                            gap="xs",
                            p="sm",
                            children=[
                                dmc.Skeleton(height=18, width="40%", radius="sm"),
                                dmc.Skeleton(height=14, radius="sm"),
                                dmc.Skeleton(height=14, width="95%", radius="sm"),
                                dmc.Skeleton(height=14, width="80%", radius="sm"),
                                dmc.Skeleton(height=14, width="90%", radius="sm"),
                                dmc.Skeleton(height=14, width="70%", radius="sm"),
                                dmc.Skeleton(height=14, radius="sm"),
                                dmc.Skeleton(height=14, width="60%", radius="sm"),
                            ],
                        ),
                        children=dmc.ScrollArea(
                            style={"height": 520},
                            children=dmc.Code(
                                id="ai-raw",
                                block=True,
                                style={
                                    "whiteSpace": "pre-wrap",
                                    "wordBreak": "break-word",
                                    "fontSize": 11,
                                },
                            ),
                        ),
                    ),
                ),
                dmc.TabsPanel(
                    value="parsed",
                    pt="sm",
                    children=dcc.Loading(
                        id="ai-parsed-loading",
                        type="default",
                        delay_show=200,
                        delay_hide=300,
                        custom_spinner=dmc.Stack(
                            gap="xs",
                            p="sm",
                            children=[
                                dmc.Skeleton(height=18, width="35%", radius="sm"),
                                dmc.Skeleton(height=14, radius="sm"),
                                dmc.Skeleton(height=14, width="88%", radius="sm"),
                                dmc.Skeleton(height=14, width="92%", radius="sm"),
                                dmc.Skeleton(height=14, width="75%", radius="sm"),
                                dmc.Skeleton(height=14, radius="sm"),
                                dmc.Skeleton(height=14, width="65%", radius="sm"),
                            ],
                        ),
                        children=dmc.ScrollArea(
                            style={"height": 520},
                            children=dmc.Code(
                                id="ai-parsed",
                                block=True,
                                style={
                                    "whiteSpace": "pre-wrap",
                                    "wordBreak": "break-word",
                                    "fontSize": 11,
                                },
                            ),
                        ),
                    ),
                ),
            ],
        ),
    ],
)


# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------


@callback(
    Output("ai-estimate", "children"),
    Output("ai-estimate", "color"),
    Input("ai-provider", "value"),
    Input("ai-model", "value"),
    Input("ai-effort", "value"),
    Input("ai-max-tokens", "value"),
)
def _show_estimate(provider, model, effort, max_tokens):
    """Price this run before it happens.

    Two numbers, not one, and the reason is in `estimate_cost`: `max_tokens`
    is a ceiling, so a single figure has to choose between being pessimistic
    (quote the ceiling, and every real run looks like a bargain) or optimistic
    (quote the typical, and the bill can exceed the quote). Showing both makes
    the spread itself the information — it is exactly what effort controls.
    """
    if not ANY_KEY:
        return (
            "No provider keys on this site — nothing is spent here. "
            "The estimate works locally with a `.env`.",
            "blue",
        )

    if not model:
        # First paint: the model control ships empty and `_sync_models` fills
        # it. Without this the estimate would flash "no price on file for
        # this model", which reads as a broken model rather than an unfinished
        # render.
        return "Checking which models this key can call…", "gray"

    if provider == "gemini":
        # Gemini has no entry in MODEL_PRICING and its billing is not ours to
        # quote. Saying so beats rendering $0.00, which reads as "free".
        return "No cost estimate for Gemini here — see Google's pricing.", "gray"

    est = estimate_cost(model, effort, max_tokens)
    if not est["priced"]:
        if est["reason"] == "budget":
            # Mid-edit the NumberInput hands over whatever is in the box, so
            # this is a normal transient state, not an error worth shouting
            # about. It used to raise here and 500 the callback.
            return "Set a max-token budget to see the cost.", "gray"
        return "No price on file for this model — cost unknown.", "gray"

    # The budget AS PRICED, not the raw control value — they differ whenever
    # the box holds something like "64.000" or a number outside the range.
    budget = est["budget"]
    sent = est["effort"]
    effort_phrase = (
        f"effort {sent}" if sent else "no effort set (the model's own default)"
    )

    budget_state = spend.summary()
    parts = [
        dmc.Text(
            [
                "About ",
                dmc.Text(format_money(est["typical"]), fw=700, span=True),
                f" for this run · at most {format_money(est['ceiling'])} "
                f"if it uses the whole budget.",
            ],
            size="sm",
        ),
        dmc.Text(
            f"{budget:,} max tokens · {effort_phrase} · "
            f"typical run uses ~{est['fraction']:.0%} of the budget.",
            size="xs",
            c="dimmed",
        ),
    ]

    # What is left today. Shown always rather than only when low: a number
    # that appears for the first time when you are nearly out is a number
    # nobody has learned to read.
    parts.append(
        dmc.Text(
            f"Daily budget: {format_money(budget_state['spent'])} of "
            f"{format_money(budget_state['ceiling'])} used · "
            f"{format_money(budget_state['remaining'])} left (resets midnight UTC).",
            size="xs",
            c="orange" if budget_state["fraction"] > 0.8 else "dimmed",
        )
    )

    # Say it out loud when the level in the selector is not the level that
    # will be sent — otherwise the estimate looks wrong rather than the
    # control looking inert.
    if effort and effort != "none" and sent is None:
        parts.append(
            dmc.Text(
                f"This model ignores the effort parameter, so “{effort}” "
                f"costs the same as any other level here.",
                size="xs",
                c="dimmed",
                fs="italic",
            )
        )

    return dmc.Stack(parts, gap=2), "gray"


# Clear stays SYNCHRONOUS and is its own callback. It is instant, and routing
# it through a job queue would add a round trip to a button whose whole value
# is that it responds immediately. Splitting it also keeps `ctx.triggered_id`
# out of the background worker, where callback context is a different animal.
#
# It writes the same Output as the generator, so one of the two must declare
# allow_duplicate — this one, because it is the secondary writer.
@callback(
    Output("ai-model", "data"),
    Output("ai-model", "value"),
    Output("ai-model-check", "children"),
    Input("ai-provider", "value"),
    prevent_initial_call=False,
)
def _sync_models(provider):
    """Fill the model list, and say whether it was verified.

    `prevent_initial_call=False` is load-bearing: this fires on first render,
    which is what lets the control ship empty and the availability check stay
    off the import path.
    """
    if provider == "chatgpt":
        return OPENAI_MODELS, OPENAI_MODELS[0]["value"], None
    if provider == "gemini":
        return GEMINI_MODELS, GEMINI_MODELS[0]["value"], None

    data, verified = offered_claude_models()
    # With no key at all, "unverified" is noise: of course it could not be
    # checked, and the notice above already says why. The badge is for the
    # case where a key EXISTS and the check still could not run.
    badge = (
        None
        if verified or not ANY_KEY
        else dmc.Badge(
            "model list unverified", color="yellow", variant="light", size="sm"
        )
    )
    return data, data[0]["value"], badge


@callback(
    Output("ai-effort", "value"),
    Output("ai-effort", "data"),
    Output("ai-max-tokens", "value"),
    Output("ai-effort", "disabled", allow_duplicate=True),
    Input("ai-model", "value"),
    prevent_initial_call=True,
)
def _sync_model_defaults(model):
    """Move effort and budget to this model's defaults when the model changes.

    Without this, switching models silently carries the previous model's
    settings over — which quietly invalidates a comparison, because you would
    be reading a difference between models that is partly a difference in
    configuration. The effort control is also disabled outright on models that
    reject the parameter, so the UI cannot offer a choice the API will 400 on.
    """
    capable = model in EFFORT_CAPABLE
    allowed = set(supported_efforts(model))
    data = [e for e in EFFORT_LEVELS if e["value"] in allowed]
    # MODEL_* rather than CLAUDE_*: switching to a ChatGPT model has to pick
    # up ITS defaults, or the run would carry the previous provider's budget
    # and the comparison would be measuring configuration, not models.
    effort = (MODEL_EFFORT.get(model) or "none") if capable else "none"
    if effort not in allowed:
        effort = "none"
    return effort, data, MODEL_MAX_TOKENS.get(model, 32000), not capable


# Clear stays SYNCHRONOUS and is its own callback. It is instant, and routing
# it through a job queue would add a round trip to a button whose whole value
# is that it responds immediately. Splitting it also keeps `ctx.triggered_id`
# out of the background worker, where callback context is a different animal.
#
# It writes the same Output as the generator, so one of the two must declare
# allow_duplicate — this one, because it is the secondary writer.
@callback(
    Output("ai-canvas", "command", allow_duplicate=True),
    Output("ai-status", "children", allow_duplicate=True),
    Output("ai-status", "color", allow_duplicate=True),
    Output("ai-raw", "children", allow_duplicate=True),
    Output("ai-parsed", "children", allow_duplicate=True),
    Output("ai-last-raw", "data", allow_duplicate=True),
    Input("ai-clear-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _clear(_clicks):
    cmd = {
        "id": f"clear-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {"elements": []},
    }
    return cmd, "Canvas cleared.", "gray", "", "", ""


@callback(
    Output("ai-canvas", "command"),
    Output("ai-status", "children"),
    Output("ai-status", "color"),
    Output("ai-raw", "children"),
    Output("ai-parsed", "children"),
    Output("ai-last-raw", "data"),
    Output("ai-run", "data"),
    Output("ai-stream-tick", "disabled"),
    Input("ai-generate-btn", "n_clicks"),
    State("ai-provider", "value"),
    State("ai-model", "value"),
    State("ai-effort", "value"),
    State("ai-max-tokens", "value"),
    State("ai-prompt", "value"),
    running=[
        # ONLY the banner. The canvas overlay used to be here and is gone on
        # purpose: it covered the canvas for the whole call, which is the
        # exact thing streaming exists to remove. Everything else that needs
        # locking is handled by `_lock_controls`, which keys off the ticker
        # and therefore stays on for the whole DRAWING, not just this
        # callback — which now returns in milliseconds.
        (
            Output("ai-processing-banner", "style"),
            {"display": "block", "marginTop": 4, "marginBottom": 4},
            {"display": "none"},
        ),
    ],
    prevent_initial_call=True,
)
def _generate(_gen_clicks, provider, model, effort, max_tokens, prompt):
    """START a generation. Returns immediately; the canvas fills in as it draws.

    This used to block for the whole call and hand back a finished scene, which
    is why the page had a loading overlay: there was nothing to show until
    there was everything to show. Claude and ChatGPT runs now go to a worker
    thread that parses elements out of the token stream as each one closes,
    and `ai-stream-tick` collects them — first shape on the canvas in a few
    seconds instead of a minute of spinner.

    `background=` is gone with the blocking call. It existed so a 100-second
    generation could not hold a request worker and take /healthz down with it;
    the generation no longer happens in a callback at all, so the risk it
    guarded against is gone with it. The producer is a plain thread — see
    lib/scene_stream.start for why not a background worker.

    Gemini still runs synchronously: `_call_gemini` has no streaming path and
    returns a whole string, so there is nothing to stream. It keeps the old
    behaviour rather than pretending otherwise.
    """
    idle = (no_update,) * 6 + (no_update, True)

    # The button is disabled, so this is only reachable by a crafted request.
    # Answered in the same words and the same colour as the notice above,
    # because a caller who gets here has not done anything wrong either.
    if not ANY_KEY:
        return (no_update, NO_KEYS_NOTICE, "blue") + idle[3:]

    if not prompt or not prompt.strip():
        return (no_update, "Write a prompt first.", "yellow") + idle[3:]

    # ---- THE SPEND GATE -------------------------------------------------
    # This check has to live HERE, not on the page's `tier: auth`, and the
    # reason is worth stating because the frontmatter looks like it covers it.
    #
    #  1. `lib/page_tiers.degraded_tier` makes every tier except `hidden` fail
    #     OPEN when Clerk is not configured. That is the right trade for
    #     reading documentation and exactly the wrong one for a page that
    #     spends money, which must fail CLOSED.
    #  2. Page tiers are path-based, and every Dash callback posts to the one
    #     shared `/_dash-update-component` route. No path-based gate can tell
    #     this callback from any other.
    #
    # So the page tier governs who can READ the page; this governs who can
    # make it BILL.
    if not _spend_allowed():
        return (
            no_update,
            "Sign in to generate — this page spends real API credits, so "
            "generation is limited to signed-in visitors.",
            "yellow",
        ) + idle[3:]

    missing = {
        "claude": (not HAS_CLAUDE_KEY, "ANTHROPIC_API_KEY is not set in the environment."),
        "chatgpt": (
            not HAS_CHATGPT_KEY,
            "CHATGPT_API_KEY / OPENAI_API_KEY is not set in the environment.",
        ),
        "gemini": (
            not HAS_GEMINI_KEY,
            "GEMINI_API_KEY / GOOGLE_API_KEY is not set in the environment.",
        ),
    }.get(provider, (False, ""))
    if missing[0]:
        return (no_update, missing[1], "red") + idle[3:]

    # The ceiling, checked HERE as well as inside the call. `stream_model`
    # admits once per run, but it is a generator whose body does not execute
    # until the worker thread advances it — so without this the refusal would
    # arrive as a red error on a run that had already been created. Checking
    # up front makes a blown budget a clean, yellow "not now".
    try:
        spend.check(estimate_cost(model, effort, max_tokens)["typical"])
    except spend.CeilingReached as exc:
        return (no_update, str(exc), "yellow") + idle[3:]

    # ---- Gemini: no stream available, so keep the one-shot path ------------
    if provider == "gemini":
        started = time.monotonic()
        try:
            raw = _call_gemini(model, prompt.strip())
        except Exception as exc:  # noqa: BLE001 - surface any provider error
            traceback.print_exc()
            return (no_update, f"{provider} call failed: {exc}", "red",
                    str(exc), "", "", no_update, True)
        try:
            parsed = _parse_and_normalize(raw)
        except json.JSONDecodeError as exc:
            return (
                no_update,
                f"Parse error at char {exc.pos}: {exc.msg}. See Parsed tab for context.",
                "red", raw, _format_parse_error(raw, exc), raw, no_update, True,
            )
        except ValueError as exc:
            return (no_update, f"Parse error: {exc}", "red", raw, str(exc), raw,
                    no_update, True)

        elements = parsed.get("elements", [])
        cmd = {
            "id": f"ai-{uuid.uuid4()}",
            "type": "updateScene",
            "payload": {
                "elements": elements,
                "appState": parsed.get("appState", {}),
                "files": parsed.get("files", {}),
            },
        }
        status = _benchmark_status(
            provider, model, len(elements), time.monotonic() - started, None
        )
        return (cmd, status, "green", raw, json.dumps(parsed, indent=2), raw,
                no_update, True)

    # ---- Claude / ChatGPT: stream it --------------------------------------
    try:
        run_id = scene_stream.start(
            model=model,
            user_prompt=prompt.strip(),
            max_tokens=max_tokens,
            effort=effort,
        )
    except Exception as exc:  # noqa: BLE001 - a bad model id, a missing key
        traceback.print_exc()
        return (no_update, f"{type(exc).__name__}: {exc}", "red") + idle[3:]

    # Blank the canvas so the drawing starts from nothing and each shape's
    # arrival is visible, rather than accumulating over the previous scene.
    return (
        {"id": f"ai-reset-{run_id[:8]}", "type": "resetScene", "payload": {}},
        "Drawing…",
        "gray",
        no_update,
        no_update,
        no_update,
        {"id": run_id, "cursor": 0, "provider": provider, "model": model},
        False,
    )


@callback(
    Output("ai-canvas", "command", allow_duplicate=True),
    Output("ai-status", "children", allow_duplicate=True),
    Output("ai-status", "color", allow_duplicate=True),
    Output("ai-raw", "children", allow_duplicate=True),
    Output("ai-parsed", "children", allow_duplicate=True),
    Output("ai-last-raw", "data", allow_duplicate=True),
    Output("ai-run", "data", allow_duplicate=True),
    Output("ai-stream-tick", "disabled", allow_duplicate=True),
    Input("ai-stream-tick", "n_intervals"),
    State("ai-run", "data"),
    prevent_initial_call=True,
)
def _stream_tick(_n, run):
    """Collect whatever the worker has parsed since the last poll."""
    stop = (no_update,) * 7 + (True,)
    if not run or not run.get("id"):
        return stop

    state = scene_stream.take(run["id"], run.get("cursor", 0))
    if not state["found"]:
        # Expired, forgotten, or an instance restart took the store with it.
        # Stop polling rather than ask forever about a run that cannot answer.
        return stop

    elements = state["all"]
    run_next = {**run, "cursor": state["cursor"]}

    if state["error"]:
        scene_stream.forget(run["id"])
        return (no_update, state["error"], "red", no_update, no_update,
                no_update, None, True)

    # updateScene REPLACES the scene, so every tick sends everything so far.
    # captureUpdate NEVER while drawing: without it each tick becomes its own
    # undo step, and a 40-element scene would take 40 Ctrl+Z to undo.
    cmd = no_update
    if state["elements"]:
        cmd = {
            "id": f"ai-tick-{run['id'][:8]}-{state['cursor']}",
            "type": "updateScene",
            "payload": {"elements": elements, "captureUpdate": "NEVER"},
        }

    # A gap means elements the producer wrote can no longer be read back, so
    # the canvas is missing shapes it was already shown. Saying nothing is how
    # this last went unnoticed for a whole session — it just looked like the
    # model drawing less.
    lost_note = f" · {state['lost']} lost from the buffer" if state.get("lost") else ""

    if not state["done"]:
        plural = "s" if len(elements) != 1 else ""
        return (
            cmd,
            f"Drawing… {len(elements)} element{plural} so far "
            f"({state['elapsed']:.0f}s){lost_note}",
            "orange" if state.get("lost") else "gray",
            no_update, no_update, no_update, run_next, False,
        )

    scene_stream.forget(run["id"])
    if not elements:
        return (
            no_update,
            "The model returned no elements. Try a different model or a "
            "larger budget.",
            "yellow",
            no_update, no_update, no_update, None, True,
        )

    # One last updateScene, this time as a single undoable step.
    final_cmd = {
        "id": f"ai-final-{run['id'][:8]}",
        "type": "updateScene",
        "payload": {"elements": elements, "captureUpdate": "IMMEDIATELY"},
    }
    # The Raw tab shows the scene REBUILT from the streamed elements rather
    # than the exact bytes off the wire: the parser consumes the deltas as
    # they arrive, and keeping a second full copy of every response in memory
    # to populate a tab is not worth the memory.
    rebuilt = json.dumps({"elements": elements}, indent=2)
    status = _benchmark_status(
        run.get("provider"), run.get("model"), len(elements),
        state["elapsed"], state["meta"],
    )
    if state.get("lost"):
        status = (
            f"{status}  ·  WARNING: {state['lost']} element(s) were generated "
            f"but could not be read back, so this scene is incomplete."
        )
        return final_cmd, status, "orange", rebuilt, rebuilt, rebuilt, None, True
    return final_cmd, status, "green", rebuilt, rebuilt, rebuilt, None, True


@callback(
    Output("ai-status", "children", allow_duplicate=True),
    Output("ai-status", "color", allow_duplicate=True),
    Output("ai-run", "data", allow_duplicate=True),
    Output("ai-stream-tick", "disabled", allow_duplicate=True),
    Input("ai-stop-btn", "n_clicks"),
    State("ai-run", "data"),
    prevent_initial_call=True,
)
def _stop(_clicks, run):
    """Press the brakes: stop the generation, keep what it has drawn.

    Two things have to happen and only one of them is obvious. Disabling the
    ticker stops the PAGE from polling — but the worker thread would carry on
    pulling tokens to the full budget with nobody reading them, which is
    exactly the cost this button exists to prevent. `scene_stream.cancel`
    closes the provider stream, so billing stops at the tokens already
    produced.

    What is on the canvas stays there. A stop is not an undo: the shapes drawn
    so far are usually the reason you are stopping.
    """
    if not run or not run.get("id"):
        return no_update, no_update, no_update, True

    scene_stream.cancel(run["id"])
    drawn = len(scene_stream.take(run["id"], 0)["all"])
    plural = "s" if drawn != 1 else ""
    return (
        f"Stopped. {drawn} element{plural} kept; no further tokens are being "
        f"generated.",
        "yellow",
        None,
        True,
    )


@callback(
    Output("ai-generate-btn", "loading"),
    Output("ai-generate-btn", "disabled"),
    Output("ai-stop-btn", "disabled"),
    Output("ai-clear-btn", "disabled"),
    Output("ai-prompt", "disabled"),
    Output("ai-provider", "disabled"),
    Output("ai-model", "disabled"),
    Output("ai-max-tokens", "disabled"),
    Input("ai-stream-tick", "disabled"),
)
def _lock_controls(tick_disabled):
    """Lock the controls while a drawing is in flight.

    Keyed on the TICKER, not on the generate callback's lifetime: that
    callback now returns in milliseconds, so a `running=` lock would release
    while the canvas was still filling in. The ticker being enabled is the
    definition of "a run is in progress".
    """
    drawing = not tick_disabled
    # `or not ANY_KEY`: with no provider configured these stay off for good.
    # Without it this callback would re-enable the button on first render and
    # hand the reader a click whose only outcome is a red error.
    off = drawing or not ANY_KEY
    # Stop is the one control that is enabled precisely when a run is going.
    return (drawing, off, not drawing) + (off,) * 5


# ---------------------------------------------------------------------------
# Timeline, scene library, and the Stream Deck bridge
# ---------------------------------------------------------------------------
#
# The timeline is CLIENTSIDE end to end. The history is captured from the
# canvas `command` prop itself — every updateScene the stream sends carries
# all elements so far, in arrival order — so none of the streaming callbacks
# above had to change. Scrubbing writes the canvas through
# `dash_clientside.set_props` rather than an Output: an Output on
# `ai-canvas.command` from a callback that (indirectly) listens to it would be
# a dependency cycle, and set_props is outside the static graph. Commands the
# timeline itself sends carry a `tl-` id prefix and are ignored by the capture.

clientside_callback(
    """
    function (cmd) {
        var nu = window.dash_clientside.no_update;
        if (!cmd || !cmd.type) return nu;
        if (String(cmd.id || "").indexOf("tl-") === 0) return nu;
        if (cmd.type === "resetScene") return {elements: [], t: Date.now()};
        if (cmd.type === "updateScene" && cmd.payload && Array.isArray(cmd.payload.elements)) {
            return {elements: cmd.payload.elements, t: Date.now()};
        }
        return nu;
    }
    """,
    Output("ai-history", "data"),
    Input("ai-canvas", "command"),
    prevent_initial_call=True,
)

clientside_callback(
    """
    function (h) {
        var n = ((h && h.elements) || []).length;
        window.__aiTlShown = n;  // the canvas already shows the whole scene
        return [Math.max(n, 1), n, n === 0,
                n ? (n + " / " + n + " elements") : "No scene yet."];
    }
    """,
    Output("ai-timeline", "max"),
    Output("ai-timeline", "value"),
    Output("ai-timeline", "disabled"),
    Output("ai-timeline-label", "children"),
    Input("ai-history", "data"),
    prevent_initial_call=True,
)

clientside_callback(
    """
    function (v, h, tickDisabled) {
        var nu = window.dash_clientside.no_update;
        var els = (h && h.elements) || [];
        if (v === null || v === undefined || !els.length) return nu;
        var label = v + " / " + els.length + " elements";
        if (v === window.__aiTlShown) return label;
        if (tickDisabled === false) return label + " — drawing; scrub when it finishes";
        window.__aiTlShown = v;
        window.dash_clientside.set_props("ai-canvas", {command: {
            id: "tl-" + Date.now() + "-" + v,
            type: "updateScene",
            payload: {elements: els.slice(0, v), captureUpdate: "NEVER"}
        }});
        return label;
    }
    """,
    Output("ai-timeline-label", "children", allow_duplicate=True),
    Input("ai-timeline", "value"),
    State("ai-history", "data"),
    State("ai-stream-tick", "disabled"),
    prevent_initial_call=True,
)

clientside_callback(
    """
    function (n, h) {
        var nu = window.dash_clientside.no_update;
        var N = ((h && h.elements) || []).length;
        if (!n || !N) return nu;
        if (window.__aiTlTimer) clearInterval(window.__aiTlTimer);
        var i = 0, ms = Math.max(40, Math.min(250, 6000 / N));
        window.dash_clientside.set_props("ai-timeline", {value: 0});
        window.__aiTlTimer = setInterval(function () {
            i += 1;
            window.dash_clientside.set_props("ai-timeline", {value: i});
            if (i >= N) { clearInterval(window.__aiTlTimer); window.__aiTlTimer = null; }
        }, ms);
        return nu;
    }
    """,
    Output("ai-timeline-play", "loading"),
    Input("ai-timeline-play", "n_clicks"),
    State("ai-history", "data"),
    prevent_initial_call=True,
)

# The deck mirror. Publishes this page's controls to window.DeckBridge on every
# change; the deck reads them back with one JS call and writes through
# set_props, so provider → model → defaults chains run exactly as by hand.
# Page actions (generate, preset, …) live in assets/deck_bridge_ai_agent.js.
clientside_callback(
    """
    function (prov, mdata, mval, edata, effort, edis, mt, seed, tl, hist, ldata, lval,
              tickDisabled, status, pdata) {
        if (!window.DeckBridge) return window.dash_clientside.no_update;
        var drawing = tickDisabled === false;
        var n = ((hist && hist.elements) || []).length;
        return window.DeckBridge.update(location.pathname, {
            provider: {type: "select", id: "ai-provider", value: prov, options: pdata, wrap: true, disabled: drawing},
            model: {type: "select", id: "ai-model", value: mval, options: mdata, wrap: true, disabled: drawing},
            effort: {type: "select", id: "ai-effort", value: effort, options: edata, disabled: !!edis || drawing},
            max_tokens: {type: "number", id: "ai-max-tokens", value: mt, min: 1000, max: 128000, step: 4000,
                         label: (mt || 0).toLocaleString(), disabled: drawing},
            seed: {type: "number", id: "ai-seed", value: seed, min: 1, max: 9999, step: 1},
            timeline: {type: "number", id: "ai-timeline", value: tl, min: 0, max: n, step: 1,
                       label: (tl || 0) + "/" + n, disabled: !n || drawing},
            scene: {type: "select", id: "ai-library", value: lval, options: ldata,
                    disabled: !(ldata && ldata.length)}
        }, (typeof status === "string" ? status : "") + (drawing ? " [drawing]" : ""));
    }
    """,
    Output("ai-deck-mirror", "data"),
    Input("ai-provider", "value"),
    Input("ai-model", "data"),
    Input("ai-model", "value"),
    Input("ai-effort", "data"),
    Input("ai-effort", "value"),
    Input("ai-effort", "disabled"),
    Input("ai-max-tokens", "value"),
    Input("ai-seed", "value"),
    Input("ai-timeline", "value"),
    Input("ai-history", "data"),
    Input("ai-library", "data"),
    Input("ai-library", "value"),
    Input("ai-stream-tick", "disabled"),
    Input("ai-status", "children"),
    State("ai-provider", "data"),
    prevent_initial_call=False,
)


@callback(
    Output("ai-library", "data"),
    Input("ai-library-mount", "data"),
    prevent_initial_call=False,
)
def _list_scenes(_mount):
    """Fill the library on first render — never on the public site, where the
    library does not exist (and must not be created)."""
    if not ANY_KEY:
        return []
    try:
        return scene_library.options()
    except Exception:  # noqa: BLE001 - a broken DB must not break the page
        traceback.print_exc()
        return []


@callback(
    Output("ai-library", "data", allow_duplicate=True),
    Output("ai-status", "children", allow_duplicate=True),
    Output("ai-status", "color", allow_duplicate=True),
    Input("ai-save-btn", "n_clicks"),
    State("ai-history", "data"),
    State("ai-prompt", "value"),
    State("ai-provider", "value"),
    State("ai-model", "value"),
    State("ai-effort", "value"),
    State("ai-max-tokens", "value"),
    State("ai-seed", "value"),
    prevent_initial_call=True,
)
def _save_scene(n, hist, prompt, provider, model, effort, max_tokens, seed):
    """Save the generated scene, in draw order, with the settings that made it."""
    if not n:
        return no_update, no_update, no_update
    if not ANY_KEY:
        return no_update, "The scene library is local-only.", "blue"
    elements = (hist or {}).get("elements") or []
    try:
        sid = scene_library.save(elements, prompt=prompt or "", provider=provider, model=model,
                                 effort=effort, max_tokens=max_tokens, seed=seed)
    except ValueError as exc:
        return no_update, str(exc), "yellow"
    return (scene_library.options(),
            f"Saved scene #{sid} — {len(elements)} elements, in draw order, "
            f"to {scene_library.db_path().name}.", "green")


@callback(
    Output("ai-canvas", "command", allow_duplicate=True),
    Output("ai-status", "children", allow_duplicate=True),
    Output("ai-status", "color", allow_duplicate=True),
    Output("ai-prompt", "value"),
    Input("ai-library", "value"),
    prevent_initial_call=True,
)
def _load_scene(scene_id):
    """Put a saved scene back on the canvas (and its prompt back in the box,
    ready to iterate). The timeline picks it up like any other scene."""
    if not scene_id or not ANY_KEY:
        return no_update, no_update, no_update, no_update
    row = scene_library.get(scene_id)
    if not row:
        return no_update, f"Scene #{scene_id} is not in the library.", "yellow", no_update
    cmd = {
        "id": f"lib-{scene_id}-{uuid.uuid4().hex[:8]}",
        "type": "updateScene",
        "payload": {"elements": row["elements"], "captureUpdate": "IMMEDIATELY"},
    }
    status = (f"Loaded #{row['id']}: {row['title']} — {row['model'] or '?'} · "
              f"{row['n_elements']} elements. Scrub the timeline to replay it.")
    return cmd, status, "gray", row["prompt"]
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /ai-agent*

---

<!-- /api — https://excalidraw.2plot.dev/api/llms.txt -->

# API reference

## dash_excalidraw

### DashExcalidraw

| prop | type | default | description |
|---|---|---|---|
| `id` | string |  | Unique ID to identify this component in Dash callbacks. |
| `UIOptions` | dict |  | Subset of Excalidraw `UIOptions` that is JSON-serializable. Use this to toggle individual canvas actions, show/hide the welcome screen, etc. |
| `appState` | objectOf |  | Full serializable app state (view background, zoom, scroll, grid mode, zen mode, theme, active tool, …). Read-only from Python. |
| `autoFocus` | bool |  | Focus the canvas on mount. @,default,true |
| `command` | dict |  | Write to this prop from a Python callback to dispatch an imperative action into Excalidraw. Shape:  ```python {"id": "unique-string", "type": "updateScene", "payload": {...}} ```  Supported `type` values:  - `updateScene` / `resetScene` / `addFiles`  - `scrollToContent` / `setActiveTool` / `setToast` / `toggleSidebar`  - `updateLibrary`  - `exportToSvg` / `exportToBlob` / `exportToCanvas`  Each dispatch is de-duplicated by `id`, and the component clears the prop (sets it to `None`) once the action completes so React re-renders do not re-fire. |
| `detectScroll` | bool |  | Whether Excalidraw listens to wheel-scroll events on the canvas. @,default,true |
| `docsLinkLabel` | string |  | Visible label for the `docsLinkUrl` item. Re-point the URL and you should relabel: a menu entry that names one destination and opens another is the failure this pair exists to avoid. @,default,"dash-excalidraw docs" |
| `docsLinkUrl` | string |  | URL for the wrapper's own documentation item in the main menu. Rendered as its own group, independently of `hideExcalidrawLinks`. Set to `""` or `None` to render no item at all. @,default,"https://excalidraw.2plot.dev" |
| `elements` | list of objectOf |  | Current Excalidraw element array. Written via setProps on every scene change — read-only from Python callbacks. |
| `externalizedSerializedData` | string |  | Same envelope as `serializedData`, but every `files[*].dataURL` that still holds an inline `data:` URI is replaced with `null`. External URLs (those dispatched via the `replaceFiles` command) are retained as-is. Persist this variant to avoid paying the base64 cost; inline bytes can be rehydrated later via `dash_excalidraw.helpers.restore_inline_files`. |
| `files` | objectOf |  | Map of binary file entries (image id -> {dataURL, mimeType, ...}). Read-only from Python. |
| `gridModeEnabled` | bool |  | Snap to grid and draw the grid background. @,default,false |
| `handleKeyboardGlobally` | bool |  | When true, keyboard shortcuts work even when the canvas is not focused. Turn off if your Dash app has other inputs that might conflict. @,default,true |
| `height` | string |  | CSS height of the canvas container. Excalidraw fills its parent, so this is the number to change when the canvas looks too short. @,default,"600px" |
| `hideExcalidrawLinks` | bool |  | When `true` (default), the vendor's "Excalidraw links" group (GitHub / Follow us / Discord) is not rendered. Set `false` to show it.  This is a render condition on THIS canvas's menu, so it works in both directions and each canvas on a page decides for itself. @,default,true |
| `initialData` | dict |  | Initial scene contents passed to Excalidraw on mount. Shape: `{elements, appState, files, libraryItems, scrollToContent}`. Updating this prop after mount has no effect — use `command` with `type="updateScene"` to change the scene imperatively. |
| `interceptLinkOpens` | bool |  | When `true`, the wrapper calls `event.preventDefault()` on every `lastLinkOpen` event so Python can handle the click itself (typically by opening a `dmc.Drawer`). Default `false` — links open in a new tab normally. |
| `isCollaborating` | bool |  | Renders the "currently-editing" collaborator UI. You'll also need to feed `appState.collaborators`; the wrapper does not bundle a transport layer. @,default,false |
| `langCode` | string |  | UI language code (e.g. `en`, `fr-FR`, `zh-CN`). @,default,"en" |
| `lastExport` | objectOf |  | Result of the most recent export command: `{timestamp, id, type, result, error?}`. Match `id` against the command you dispatched to correlate responses. |
| `lastExternalDrop` | objectOf |  | Fires when the wrapper's drop handler intercepts a drop that Excalidraw itself doesn't accept (non-image files, or any multi- file drop). Payload:    {     timestamp,     files: [{name, mimeType, dataURL, size}, ...],     dropPoint: {x, y},                  # scene coords     placeholderIds: [elemId, ...],      # one id per non-image file,                                         # pointing at the rectangle we                                         # placed on the canvas so you                                         # can update its `link` after                                         # upload.   } |
| `lastFileAdded` | objectOf |  | Fires when one or more new file ids appear in `files` with an inline `data:` dataURL. Payload:    {     timestamp,     fileId, mimeType, dataURL, size,   # first new file (back-compat)     files: [       {fileId, mimeType, dataURL, size},       ...                              # every new file in this change     ],   }  Single-file callbacks can keep reading `event['fileId']`; batch callbacks iterate `event['files']`. `size` is decoded byte count. |
| `lastLibraryChange` | objectOf |  | Snapshot of the last library change `{timestamp, items}`. |
| `lastLinkOpen` | objectOf |  | Snapshot of the last link-open event `{timestamp, elementId, url}` — fired when a user Cmd/Ctrl-clicks an element with a hyperlink. |
| `lastPaste` | objectOf |  | Snapshot of the last clipboard paste `{timestamp, data}`. The wrapper cannot cancel the paste from Python; if you need to intercept, clean up in a follow-up callback that modifies scene state afterward. |
| `lastPointerDown` | objectOf |  | Snapshot of the last pointer-down event: `{timestamp, activeTool, pointer: {x, y}}`. |
| `lastPointerMove` | objectOf |  | Throttled pointer-move snapshot `{timestamp, pointer, button, pointersMap}`. Throttled by `pointerMoveThrottleMs` (default 50 ms). |
| `lastPointerUp` | objectOf |  | Snapshot of the last pointer-up event: `{timestamp, activeTool, pointer: {x, y}}`. |
| `lastScrollChange` | objectOf |  | Throttled scroll/zoom snapshot `{timestamp, scrollX, scrollY}`. |
| `libraryReturnUrl` | string |  | Optional URL appended to the "Browse Library" button in the sidebar. When unset Excalidraw uses its own default. |
| `name` | string |  | Drawing name — appears in the top bar and in serialized export filenames. |
| `pointerMoveThrottleMs` | number |  | Debounce interval for `lastPointerMove` writes (milliseconds). @,default,50 |
| `sceneVersion` | number |  | Monotonic scene version, from the package's `getSceneVersion(elements)` export. Useful for change detection without diffing element arrays.  It tracks ELEMENTS only. Registering a file, panning or zooming leaves it unchanged, so a callback that must see those should take `files` or `appState` as its Input rather than this. |
| `scrollThrottleMs` | number |  | Debounce interval for `lastScrollChange` writes (milliseconds). @,default,100 |
| `serializedData` | string |  | JSON string of the canonical Excalidraw serialized envelope `{type, version, source, elements, appState, files}`. Suitable to pass to `dash.dcc.Store` and later restore via `initialData`. |
| `theme` | one of 'light', 'dark' |  | Canvas color theme. @,default,"light" |
| `validateEmbeddable` | bool \| list of string |  | Controls which URLs may be embedded inside Excalidraw frames. Pass `true` to allow all, `false` to deny all, or a list of domain-glob strings (e.g. `["*.youtube.com", "excalidraw.com"]`) which the wrapper compiles to case-insensitive RegExps. |
| `viewModeEnabled` | bool |  | View-only mode: disables drawing tools; pan/zoom still available. @,default,false |
| `welcomeScene` | objectOf |  | A scene to open with when `initialData` is not given: `{elements, appState, files}`, the same shape `initialData` and `externalizedSerializedData` use, so a scene produced anywhere in this library can be pasted straight in.  MOUNT-ONLY, like `initialData` — Excalidraw owns the scene afterwards. Dispatch `updateScene` to change it later. |
| `welcomeScreen` | bool |  | Show Excalidraw's welcome overlay on an empty canvas.  REACTIVE, unlike `UIOptions.welcomeScreen`. The vendor reads that key once while the canvas is mounting, so a switch wired to it appears to do nothing — which is exactly how it read.  This works by composing Excalidraw's `<WelcomeScreen>` as a CHILD, which React mounts and unmounts with the prop. MEASURED, because the obvious alternative looks right and is not: `appState.showWelcomeScreen` exists, but `updateScene({appState: {showWelcomeScreen: false}})` leaves it `true` — the vendor filters that key out of the merge, silently. Do not "fix" this back to an appState push. |
| `welcomeScreenContent` | dict |  | Words for the welcome overlay: `{title, subtitle}`. Either may be omitted. Supplying neither keeps Excalidraw's own wording.  Only the TEXT is yours — the overlay's menu hints stay the vendor's, so they cannot drift out of step with the menu they describe. |
| `width` | string |  | CSS width of the canvas container. @,default,"100%" |
| `zenModeEnabled` | bool |  | Zen mode hides most of the chrome for a distraction-free canvas. @,default,false |

---

<!-- /basic — https://excalidraw.2plot.dev/basic/llms.txt -->

# Basic usage

> The minimum viable DashExcalidraw — one component, default props, a working canvas.

---



### Overview

The smallest useful app is one component with default props. Draw on the canvas
below — zoom, pan, undo, the shape tools, the library and the context menu all
work with no configuration at all.

That is the point of this page: **nothing on it is set up.** Everything you can
do to the canvas is Excalidraw's own behaviour, reaching Dash unmodified.

### What you get for free

Mounting `DashExcalidraw` with nothing but an `id` gives you the full editor:

- **Every drawing tool** — rectangle, diamond, ellipse, arrow, line, freedraw,
  text, image, eraser and the frame tool, with keyboard shortcuts (`1`–`9`).
- **The shape library**, including anything the user has saved locally.
- **Undo and redo**, scoped to this canvas.
- **Zoom and pan**, including scroll-to-zoom and space-drag.
- **The context menu**, with copy/paste styles, grouping, layering and locking.
- **Excalidraw 0.18's additions** — elbow arrows, flowchart shortcuts
  (`Cmd`/`Ctrl` + arrow key), scene search, image cropping, element linking and
  the command palette.

None of that costs you a callback.

### The minimum app

```python
from dash import Dash
from dash_excalidraw import DashExcalidraw

app = Dash(__name__)
app.layout = DashExcalidraw(id="canvas", height="600px")

if __name__ == "__main__":
    app.run(debug=True)
```

`height` is the prop you will actually change. Excalidraw fills its parent, so a
canvas that looks too short is almost always a container-height problem rather
than a component one.

### Live demo


### Reading what the user drew

The component writes its state back through ordinary props, so a callback reads
the canvas the same way it reads a dropdown:

```python
from dash import Input, Output, callback

@callback(Output("count", "children"), Input("canvas", "elements"))
def show(elements):
    return f"{len(elements or [])} elements"
```

`elements` updates on every scene change. If you only need to know *that*
something changed — not what — read `sceneVersion` instead; it is a single
integer and far cheaper to compare than diffing the element array.

### Four switches you may need in a real app

The defaults suit a canvas that owns its page. An app with other inputs, or a
canvas embedded in a longer scrolling document, usually wants one or two of
these changed.

| Prop | Default | Turn it off when |
|:-----|:--------|:-----------------|
| `handleKeyboardGlobally` | `True` | Your page has other inputs. Shortcuts otherwise fire while the user types elsewhere. |
| `autoFocus` | `True` | The canvas is below the fold, or another field should hold focus on load. |
| `detectScroll` | `True` | The canvas sits inside a scrolling container and swallowing wheel events feels like a trap. |
| `langCode` | `"en"` | You want another UI language — `"fr-FR"`, `"zh-CN"`, and so on. |

```python
DashExcalidraw(
    id="canvas",
    handleKeyboardGlobally=False,  # a form on the same page owns the keyboard
    autoFocus=False,
    detectScroll=False,            # the page scrolls; the canvas should not eat it
    langCode="fr-FR",
)
```

`handleKeyboardGlobally` is the one worth thinking about first. It moves
Excalidraw's key handling from the canvas to the document, which is what
makes single-key shortcuts work without clicking the canvas — and also what
puts it in the same room as every other keyboard consumer on your page. If
you have your own shortcuts, a modal, or a form that shares the layout, turn
it off and let the canvas take focus before it takes keys.

### Where to go next

- [initialData](/initial-data) — seed the canvas at mount.
- [Events](/events) — every Excalidraw callback, as a snapshot prop.
- [Command dispatch](/commands) — drive the canvas from Python.
- [Persistence](/persistence) — save a scene and restore it later.

### Source


```python
# File: docs/basic-usage/basic.py

"""Basic usage: the minimum viable DashExcalidraw."""

import dash_mantine_components as dmc

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("basic-canvas")

CODE = """
from dash import Dash
from dash_excalidraw import DashExcalidraw

app = Dash(__name__)
app.layout = DashExcalidraw(id='canvas', height='600px')
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        canvas_frame(DashExcalidraw(id="basic-canvas", height="600px")),
    ],
)
```

    :defaultExpanded: false
    :withExpandedButton: true

### Component reference

Every prop on the component, generated from the TypeScript source.



---

*Source: /basic*

---

<!-- /benchmark — https://excalidraw.2plot.dev/benchmark/llms.txt -->

# Benchmark

> Run one prompt across several efforts, token budgets or models — Claude against ChatGPT — and compare the drawings side by side.

---



> **On this site these pages are documentation, not a hosted service.**
> excalidraw.2plot.dev carries no provider keys, so generation is disabled
> here and nothing below will spend anything. Everything on the page —
> streaming onto the canvas, the cost estimate, the Stop button, the daily
> spend ceiling — works when you run the repo locally with a `.env` holding a
> provider key. This is deliberate and not a fault.

### Overview

[AI agent](/ai-agent) answers *can it draw this?* This page answers the question
you have immediately afterwards: **what do I give up by turning the knob down?**

It sends one prompt to several settings at once and puts the drawings next to
each other. Element counts and token totals rank the runs; looking at them tells
you whether the extra tokens actually bought anything — which is not the same
question, and is the reason every cell renders a real canvas rather than a row
in a table.

### What the three axes mean

**Effort** is thinking depth. On Claude Opus 5 it is largely a cost-and-latency
control for this task: in a measured sweep, `high` cost 2.3× `low` and took 44%
longer to produce one extra element, and `medium` produced *fewer* elements than
`low`. Worth confirming on your own prompts — that is what the page is for.

**Max tokens** is the lever that moves output. On Opus 5 it bounds thinking
**and** response text together, so it is not a safety net but the main quality
dial: tripling it took the same prompt from 24 elements to 31.

**Model** is the third axis, and the only one that crosses providers: it puts
a Claude cell and a ChatGPT cell in the same sweep, on the same prompt, at the
same budget and the same effort. That last part is what makes it a comparison
rather than two runs — matched settings, and a real price on each, where the
published rates differ by up to 40× between the models offered here.

Gemini is deliberately absent from this axis. It is called through a different
signature with neither control and has no published price in this app, so a
Gemini cell could show a drawing but never an honest cost or a matched setting.
It remains available on [AI agent](/ai-agent).

Every cell **streams**: the six drawings appear together, shape by shape, and
each panel refits itself as its scene grows. A **Stop all** button is enabled
for as long as a sweep is running — it closes every variant's connection to its
provider, so generation stops and billing stops at the tokens already produced.
What has been drawn stays on the canvases; a half-finished sweep is still a
comparison, and usually the reason for stopping.

Only one axis varies per run, and the page shows only the controls that axis
reads — the others are hidden rather than left on screen looking live. Two
moving variables make a comparison unreadable, and a full grid is a
combinatorial bill.

### Live demo


### Reading the results

Each panel shows the settings, the element count, wall time, token counts and an
estimated cost. The status line reports total wall time alongside what the same
run would have cost serially — the variants run **concurrently**, so a six-cell
matrix takes roughly one generation's time rather than six.

Three cautions before drawing conclusions:

- **Element count is a crude proxy for quality.** A busier diagram is not a
  better one. The canvases are there to be looked at.
- **These are single samples.** Model output varies run to run; repeat before
  trusting a small difference.
- **Cost figures are local estimates**, not a bill — they ignore cache-write
  premiums and any discount.

### Source


```python
# File: docs/benchmark/benchmark.py

"""Run one prompt across several settings at once and compare the results.

The /ai-agent page answers "can it draw this?". This page answers the question
you actually have afterwards: "what do I give up by turning this knob down?"
— which needs the same prompt rendered several ways, side by side, with the
cost of each.

THREE THINGS THAT SHAPE THE DESIGN
1. Variants run CONCURRENTLY. They are network-bound, so a six-cell matrix
   costs roughly one generation's wall time instead of six. Run serially, a
   sweep takes long enough that nobody runs a second one.
2. The cost is shown BEFORE you press the button, not after. A six-cell Opus 5
   matrix is real money, and an estimate that arrives with the results is an
   apology rather than a decision.
3. Every cell renders its own canvas in view mode. Element counts and token
   totals rank the runs; only looking at them tells you whether the extra
   tokens bought anything.
"""

from __future__ import annotations

import dash_mantine_components as dmc
from dash import ALL, Input, Output, State, callback, dcc, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame
from lib import scene_stream, spend
from lib.scene_ai import (
    CLAUDE_EFFORT,
    CLAUDE_MAX_TOKENS,
    CLAUDE_MODELS,
    COMPARABLE_MODELS,
    EFFORT_CAPABLE,
    MODEL_LABEL,
    MODEL_PRICING,
    NO_KEYS_NOTICE,
    _spend_allowed,
    any_provider_configured,
    coerce_budget,
    estimate_cost,
    format_money,
)

# Kept small on purpose. Every extra cell is another paid call, and the point
# of the page is comparison, not exhaustiveness — beyond about six panels you
# stop being able to see them at a glance, which is the whole feature.
MAX_VARIANTS = 6

ANY_KEY = any_provider_configured()

EFFORT_CHOICES = ["none", "low", "medium", "high", "xhigh", "max"]
BUDGET_CHOICES = ["4000", "8000", "16000", "24000", "48000", "64000"]


def _variants(
    model, axis, efforts, budgets, models, fixed_budget, fixed_effort
) -> list[tuple]:
    """The exact `(model, effort, budget)` list a run will execute.

    Shared by the estimate and the runner on purpose. The old estimate priced
    `n` variants at the LARGEST selected budget, which over-quoted every
    budget sweep — a 4K+8K+16K comparison was billed as three times 16K. It
    also ignored effort entirely, so the control the page exists to explore
    made no difference to the number above the button. Building the real list
    once fixes both, and means the quote cannot drift from the run.

    MODEL IS PER-VARIANT, not a single value alongside, because the third axis
    compares models across providers — a Claude cell and a ChatGPT cell in one
    sweep. The other two axes just repeat the one selected model.

    coerce_budget, not int(): `bm-fixed-budget` is a NumberInput and hands over
    whatever is in the box mid-edit ("64.000" crashed the estimate on
    /ai-agent). The chips are our own strings and always parse, but they go
    through the same door so there is one rule.
    """
    if axis == "effort":
        budget = coerce_budget(fixed_budget, 24000)
        return [(model, e, budget) for e in (efforts or [])[:MAX_VARIANTS]]
    if axis == "model":
        budget = coerce_budget(fixed_budget, 24000)
        effort = fixed_effort or "low"
        return [(m, effort, budget) for m in (models or [])[:MAX_VARIANTS]]
    return [
        (model, fixed_effort or "low", coerce_budget(b, 24000))
        for b in (budgets or [])[:MAX_VARIANTS]
    ]


def _variant_label(axis: str, model: str, effort: str, budget: int) -> str:
    """What a panel calls itself. On the model axis the model IS the finding,
    so it leads; elsewhere it is constant across cells and only clutters."""
    if axis == "model":
        return f"{MODEL_LABEL.get(model, model)} · effort={effort}"
    return f"effort={effort} · {budget:,} tok"


def _slot(index: int):
    """One cell's skeleton, built ONCE at import and then filled by callbacks.

    Panels used to be constructed from finished results, which is why the page
    could only show anything after every variant had returned. They are static
    now, with pattern-matching ids, so a callback can write into six canvases
    while the models are still drawing. Unused slots are hidden rather than
    absent — a component that does not exist cannot be an Output.
    """
    return dmc.GridCol(
        id={"type": "bm-slot", "index": index},
        style={"display": "none"},
        span={"base": 12, "md": 6},
        children=dmc.Paper(
            withBorder=True,
            p="sm",
            children=dmc.Stack(
                gap="xs",
                children=[
                    dmc.Box(id={"type": "bm-head", "index": index}),
                    canvas_frame(
                        DashExcalidraw(
                            id={"type": "bm-canvas", "index": index},
                            height="340px",
                            # View mode: these are specimens to compare, not
                            # canvases to edit. It also stops a stray click in
                            # one panel from changing what you are comparing.
                            viewModeEnabled=True,
                        ),
                        min_height=340,
                    ),
                ],
            ),
        ),
    )


def _cell_cost(model: str, meta: dict | None) -> float:
    if not meta:
        return 0.0
    price = MODEL_PRICING.get(model, (0, 0))
    return (
        meta["input_tokens"] * price[0] + meta["output_tokens"] * price[1]
    ) / 1_000_000


def _head(cell: dict, state: dict):
    """The badges above one cell's canvas, from whatever is known so far."""
    if state.get("error"):
        return dmc.Stack(
            gap=4,
            children=[
                dmc.Badge(cell["label"], color="red", variant="light"),
                dmc.Text(state["error"], size="xs", c="red"),
            ],
        )

    drawn = len(state.get("all", []))
    badges = [dmc.Badge(cell["label"], variant="light")]
    badges.append(
        dmc.Badge(
            f"{drawn} element{'s' if drawn != 1 else ''}",
            color="violet",
            variant="light",
        )
    )
    badges.append(
        dmc.Badge(f"{state.get('elapsed', 0.0):.0f}s", color="gray", variant="light")
    )

    meta = state.get("meta")
    if state.get("cancelled"):
        badges.append(dmc.Badge("stopped", color="orange", variant="filled"))
    elif not state.get("done"):
        badges.append(dmc.Badge("drawing…", color="blue", variant="dot"))

    if meta:
        badges.append(
            dmc.Badge(
                f"~${_cell_cost(cell['model'], meta):.3f}",
                color="teal",
                variant="light",
            )
        )

    detail = ""
    if meta:
        detail = f"{meta['output_tokens']:,} out / {meta['input_tokens']:,} in"
        stop = meta.get("stop_reason")
        if stop in ("max_tokens", "incomplete"):
            detail += f"  ·  TRUNCATED at {meta['max_tokens']:,} tokens"
        elif stop and stop not in ("end_turn", "completed"):
            detail += f"  ·  stop={stop}"
    if state.get("lost"):
        detail += f"  ·  {state['lost']} lost from the buffer"

    return dmc.Stack(
        gap=4,
        children=[
            dmc.Group(gap="xs", children=badges),
            dmc.Text(detail, size="xs", c="dimmed"),
        ],
    )


component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            NO_KEYS_NOTICE,
            title="AI generation is disabled on this site",
            color="blue",
            variant="light",
            style={} if not ANY_KEY else {"display": "none"},
        ),
        dmc.Alert(
            title="This page spends real API credits",
            color="yellow",
            style={"display": "none"} if not ANY_KEY else {},
            children=(
                "Each variant is a separate paid model call. The estimate below "
                "updates as you change the matrix — check it before running."
            ),
        ),
        dmc.Paper(
            withBorder=True,
            p="md",
            children=dmc.Stack(
                gap="sm",
                children=[
                    dmc.Grid(
                        gutter="md",
                        children=[
                            dmc.GridCol(
                                dmc.Select(
                                    id="bm-model",
                                    label="Model",
                                    data=COMPARABLE_MODELS,
                                    value=COMPARABLE_MODELS[0]["value"],
                                ),
                                id="bm-model-col",
                                span={"base": 12, "sm": 4},
                            ),
                            dmc.GridCol(
                                dmc.SegmentedControl(
                                    id="bm-axis",
                                    data=[
                                        {"value": "effort", "label": "Vary effort"},
                                        {"value": "budget", "label": "Vary max tokens"},
                                        {"value": "model", "label": "Compare models"},
                                    ],
                                    value="effort",
                                    fullWidth=True,
                                ),
                                span={"base": 12, "sm": 8},
                            ),
                        ],
                    ),
                    # Only one axis varies at a time. A grid over both is a
                    # combinatorial bill, and two variables moving at once is
                    # exactly what makes a comparison unreadable.
                    #
                    # ONLY THE ACTIVE AXIS IS SHOWN. Both lists used to render
                    # together, always enabled, while the segmented control
                    # above decided which one the run actually read — so
                    # ticking "Max-token budgets to compare" while the axis sat
                    # on its "Vary effort" default changed nothing, including
                    # the price. The estimate was right and the page was
                    # lying: it offered an input it then ignored. Hiding the
                    # inactive pair is the fix, not making the price react to
                    # a control the run does not read.
                    dmc.CheckboxGroup(
                        id="bm-efforts",
                        label="Effort levels to compare",
                        value=["low", "high"],
                        children=dmc.Group(
                            [dmc.Checkbox(label=e, value=e) for e in EFFORT_CHOICES],
                            gap="md",
                        ),
                    ),
                    dmc.CheckboxGroup(
                        id="bm-budgets",
                        label="Max-token budgets to compare",
                        value=["8000", "24000"],
                        style={"display": "none"},  # axis defaults to "effort"
                        children=dmc.Group(
                            [
                                dmc.Checkbox(label=f"{int(b):,}", value=b)
                                for b in BUDGET_CHOICES
                            ],
                            gap="md",
                        ),
                    ),
                    # The cross-provider axis. Every entry here accepts a
                    # budget and an effort and has a published price, so a
                    # Claude cell and a ChatGPT cell are genuinely comparable:
                    # same prompt, same budget, same effort, and a cost on
                    # each. Gemini is deliberately absent — see
                    # COMPARABLE_MODELS in lib/scene_ai.py.
                    dmc.MultiSelect(
                        id="bm-models",
                        label="Models to compare",
                        description=(
                            "Mix providers freely — the same prompt, budget and "
                            "effort go to each"
                        ),
                        data=COMPARABLE_MODELS,
                        value=["claude-opus-5", "gpt-6-astra"],
                        maxValues=MAX_VARIANTS,
                        clearable=True,
                        style={"display": "none"},  # axis defaults to "effort"
                    ),
                    dmc.Grid(
                        gutter="md",
                        children=[
                            dmc.GridCol(
                                dmc.NumberInput(
                                    id="bm-fixed-budget",
                                    label="Max tokens (held constant)",
                                    description="Every effort variant runs at this budget",
                                    value=24000,
                                    min=1000,
                                    max=128000,
                                    step=4000,
                                ),
                                id="bm-fixed-budget-col",
                                span={"base": 12, "sm": 6},
                            ),
                            dmc.GridCol(
                                dmc.Select(
                                    id="bm-fixed-effort",
                                    label="Effort (held constant)",
                                    description="Every budget variant runs at this effort",
                                    data=[
                                        {"value": e, "label": e} for e in EFFORT_CHOICES
                                    ],
                                    value="low",
                                ),
                                id="bm-fixed-effort-col",
                                span={"base": 12, "sm": 6},
                                style={"display": "none"},  # axis defaults to "effort"
                            ),
                        ],
                    ),
                    dmc.Textarea(
                        id="bm-prompt",
                        label="Prompt",
                        description="The same prompt is sent to every variant — that is what makes them comparable",
                        placeholder="E.g. 'an architecture diagram of a payments service: api, ledger, queue, two databases, with retry paths'",
                        autosize=True,
                        minRows=3,
                    ),
                    dmc.Group(
                        [
                            dmc.Button(
                                "Run benchmark",
                                id="bm-run",
                                disabled=not ANY_KEY,
                            ),
                            # THE BRAKES. This page is the reason they matter
                            # most: one click is up to six paid calls running
                            # at once, so a stop here is worth six times what
                            # it is worth on /ai-agent.
                            dmc.Button(
                                "Stop all",
                                id="bm-stop",
                                leftSection="■",
                                color="red",
                                disabled=True,
                            ),
                            dmc.Text(id="bm-estimate", size="sm", c="dimmed"),
                        ],
                        justify="space-between",
                    ),
                ],
            ),
        ),
        dmc.Alert(
            id="bm-status",
            title="Status",
            color="gray",
            children="Ready.",
        ),
        # Six fixed slots, hidden until used. Previously this was an empty
        # Div filled with finished panels, which is why nothing could appear
        # until everything had.
        dmc.Grid(gutter="md", children=[_slot(i) for i in range(MAX_VARIANTS)]),
        # One ticker for the whole sweep, not one per cell: six Intervals
        # would mean six callbacks a second arguing over the same Store.
        dcc.Store(id="bm-runs", data=None),
        dcc.Interval(id="bm-tick", interval=500, disabled=True),
    ],
)


@callback(
    Output("bm-efforts", "style"),
    Output("bm-budgets", "style"),
    Output("bm-models", "style"),
    Output("bm-model-col", "style"),
    Output("bm-fixed-budget-col", "style"),
    Output("bm-fixed-effort-col", "style"),
    Input("bm-axis", "value"),
)
def _show_active_axis(axis):
    """Show only the controls the run will actually read.

    The segmented control decides which list becomes the variants and which
    values are held constant; everything else is inert. Leaving it all on
    screen made the inert controls look live — tick three budgets while the
    axis says "Vary effort" and nothing happens, price included.

    Note the single Model select disappears on the model axis: there, the
    model is the thing being varied, so one fixed model would be a control
    that contradicts the axis.
    """
    shown: dict = {}
    hidden = {"display": "none"}

    def vis(active: bool):
        return shown if active else hidden

    return (
        vis(axis == "effort"),  # effort levels to compare
        vis(axis == "budget"),  # max-token budgets to compare
        vis(axis == "model"),  # models to compare (cross-provider)
        vis(axis != "model"),  # the single model select
        vis(axis != "budget"),  # the budget held constant
        vis(axis != "effort"),  # the effort held constant
    )


@callback(
    Output("bm-estimate", "children"),
    Input("bm-model", "value"),
    Input("bm-axis", "value"),
    Input("bm-efforts", "value"),
    Input("bm-budgets", "value"),
    Input("bm-models", "value"),
    Input("bm-fixed-budget", "value"),
    Input("bm-fixed-effort", "value"),
)
def _estimate_cost(model, axis, efforts, budgets, models, fixed_budget, fixed_effort):
    """Price the matrix BEFORE it runs, per variant. See the module docstring.

    One click here is up to six paid calls, so this number is the last thing
    between a curious click and a real bill. It prices each variant at its own
    effort and its own budget — the two things the page lets you vary — and
    shows the ceiling beside the typical figure, because `max_tokens` bounds
    the spend but does not determine it.
    """
    if not ANY_KEY:
        return "No provider keys on this site — nothing is spent here."

    variants = _variants(
        model, axis, efforts, budgets, models, fixed_budget, fixed_effort
    )
    if not variants:
        return "Select at least one variant."

    ests = [(m, estimate_cost(m, e, b)) for m, e, b in variants]
    unpriced = sorted({m for m, x in ests if not x["priced"]})
    if unpriced:
        return f"{len(variants)} variants · no price on file for {', '.join(unpriced)}."

    typical = sum(x["typical"] for _, x in ests)
    ceiling = sum(x["ceiling"] for _, x in ests)
    n = len(variants)

    # Name the axis in the number. Three controls can each produce "3
    # variants", and when the price surprises someone the first useful fact is
    # which of them the run is actually reading.
    sweeping = {"effort": "effort", "budget": "max tokens", "model": "models"}[axis]
    head = (
        f"{n} variant{'s' if n != 1 else ''} varying {sweeping} · about "
        f"{format_money(typical)} for the sweep, at most "
        f"{format_money(ceiling)} if every one runs its budget out."
    )

    # Where the cells differ in price, the spread is the interesting part: a
    # single total hides which one is doing the spending. That is most acute
    # on the model axis, where rates differ by up to 40x between providers.
    if n > 1 and axis in ("budget", "model"):
        lo = min(ests, key=lambda pair: pair[1]["typical"])
        hi = max(ests, key=lambda pair: pair[1]["typical"])
        if axis == "model":
            head += (
                f" Cheapest {MODEL_LABEL.get(lo[0], lo[0])} "
                f"~{format_money(lo[1]['typical'])}, dearest "
                f"{MODEL_LABEL.get(hi[0], hi[0])} "
                f"~{format_money(hi[1]['typical'])}."
            )
        else:
            head += (
                f" Cheapest cell ~{format_money(lo[1]['typical'])}, "
                f"dearest ~{format_money(hi[1]['typical'])}."
            )

    # Only meaningful when one model spans the sweep; on the model axis the
    # per-model note would be ambiguous, and the estimate already prices each
    # cell correctly either way.
    if axis != "model" and model not in EFFORT_CAPABLE:
        head += " (This model ignores effort, so every cell costs the same.)"

    return head


BLANK = {"display": "none"}
SHOWN: dict = {}


@callback(
    Output("bm-runs", "data"),
    Output("bm-tick", "disabled"),
    Output("bm-status", "children"),
    Output("bm-status", "color"),
    Output({"type": "bm-slot", "index": ALL}, "style"),
    Output({"type": "bm-head", "index": ALL}, "children"),
    Output({"type": "bm-canvas", "index": ALL}, "command"),
    Input("bm-run", "n_clicks"),
    State("bm-model", "value"),
    State("bm-axis", "value"),
    State("bm-efforts", "value"),
    State("bm-budgets", "value"),
    State("bm-models", "value"),
    State("bm-fixed-budget", "value"),
    State("bm-fixed-effort", "value"),
    State("bm-prompt", "value"),
    prevent_initial_call=True,
)
def _run(
    _clicks, model, axis, efforts, budgets, models, fixed_budget, fixed_effort, prompt
):
    """START every variant, then return. The cells fill in as they draw.

    This used to block until all six calls had finished and then build the
    panels from their results, which meant a minute or more of spinner and —
    more to the point — no way to stop once the money was committed. Each
    variant is now a streamed run in the shared buffer, so the page can show
    six drawings appearing at once AND cancel them.
    """
    blank = [BLANK] * MAX_VARIANTS
    nothing = [no_update] * MAX_VARIANTS

    def refuse(message, color="yellow"):
        return None, True, message, color, blank, nothing, nothing

    # THIS PAGE HAD NO KEY CHECK AT ALL — measured 2026-09-12 against a
    # keyless process: it started every variant, enabled the ticker, and each
    # cell failed separately from its worker thread. A spinner followed by six
    # red panels, for a site that is never going to have keys. Refused here
    # instead, in the same words and colour as the notice on the page.
    if not ANY_KEY:
        return refuse(NO_KEYS_NOTICE, "blue")

    if not prompt or not prompt.strip():
        return refuse("Write a prompt first.")

    # Same gate as /ai-agent, and it matters more here: one click is up to six
    # paid calls. See lib/scene_ai._spend_allowed.
    if not _spend_allowed():
        return refuse(
            "Sign in to run a benchmark — this page spends real API credits."
        )

    # Same builder the estimate uses, so the run cannot execute a different
    # matrix from the one that was priced above the button.
    variants = _variants(
        model, axis, efforts, budgets, models, fixed_budget, fixed_effort
    )
    if not variants:
        return refuse("Select at least one variant to compare.")

    if axis == "effort" and model not in EFFORT_CAPABLE:
        # Varying effort on a model that rejects the parameter would run N
        # identical calls and present them as a comparison — worse than an
        # error, because the output looks like a result.
        if len({e for _, e, _ in variants}) > 1:
            return refuse(
                f"{model} does not accept the effort parameter, so every variant "
                "would be identical. Switch to varying max tokens, or pick "
                "another model.",
                "red",
            )

    # The model axis has its own version of the same trap: one model selected
    # is not a comparison, it is a single call wearing a comparison's UI.
    if axis == "model" and len(variants) < 2:
        return refuse("Pick at least two models to compare — one is just a single run.")

    # Priced as a SWEEP, not per cell: six calls admitted one at a time would
    # each pass a check the six together fail, which is precisely the runaway
    # this page is most able to cause.
    try:
        spend.check(
            sum(
                estimate_cost(m, e, b)["typical"]
                for m, e, b in variants
            )
        )
    except spend.CeilingReached as exc:
        return refuse(str(exc))

    cells = []
    styles = list(blank)
    heads = list(nothing)
    commands = list(nothing)
    failed = []

    for index, (variant_model, effort, budget) in enumerate(variants):
        label = _variant_label(axis, variant_model, effort, budget)
        try:
            run_id = scene_stream.start(
                model=variant_model,
                user_prompt=prompt.strip(),
                max_tokens=budget,
                effort=effort,
            )
        except Exception as exc:  # noqa: BLE001 - one bad variant, not the sweep
            # A cell that cannot start must not take the others down with it;
            # that is the whole reason the sweep is worth running.
            failed.append(f"{label}: {type(exc).__name__}: {exc}")
            continue

        cell = {
            "id": run_id,
            "label": label,
            "model": variant_model,
            "cursor": 0,
            "done": False,
        }
        cells.append(cell)
        styles[index] = SHOWN
        heads[index] = _head(cell, {"all": [], "elapsed": 0.0, "done": False})
        # Blank the cell so the drawing starts from nothing rather than
        # appearing over the previous sweep's scene.
        commands[index] = {
            "id": f"bm-reset-{run_id[:8]}",
            "type": "resetScene",
            "payload": {},
        }

    if not cells:
        return refuse("No variant could be started. " + " · ".join(failed), "red")

    status = f"Running {len(cells)} variant{'s' if len(cells) != 1 else ''}…"
    if failed:
        status += f" ({len(failed)} could not start: {'; '.join(failed)})"
    return cells, False, status, "gray", styles, heads, commands


@callback(
    Output("bm-status", "children", allow_duplicate=True),
    Output("bm-status", "color", allow_duplicate=True),
    Output("bm-runs", "data", allow_duplicate=True),
    Output("bm-tick", "disabled", allow_duplicate=True),
    Output({"type": "bm-head", "index": ALL}, "children", allow_duplicate=True),
    Output({"type": "bm-canvas", "index": ALL}, "command", allow_duplicate=True),
    Input("bm-tick", "n_intervals"),
    State("bm-runs", "data"),
    prevent_initial_call=True,
)
def _tick(_n, cells):
    """Collect every cell's new elements in ONE poll.

    One ticker for the whole sweep rather than one per cell: six Intervals
    would be six callbacks a second contending for the same Store, and Dash
    gives no ordering guarantee between them.
    """
    nothing = [no_update] * MAX_VARIANTS
    if not cells:
        return no_update, no_update, no_update, True, nothing, nothing

    heads = list(nothing)
    commands = list(nothing)
    next_cells = []
    finished = 0
    total_cost = 0.0
    total_elements = 0
    slowest = 0.0

    for index, cell in enumerate(cells):
        state = scene_stream.take(cell["id"], cell.get("cursor", 0))
        if not state["found"]:
            # Expired, forgotten, or an instance restart took the store. Treat
            # it as finished rather than polling forever.
            next_cells.append({**cell, "done": True})
            finished += 1
            continue

        heads[index] = _head(cell, state)
        if state["elements"]:
            commands[index] = {
                "id": f"bm-{cell['id'][:8]}-{state['cursor']}",
                "type": "updateScene",
                "payload": {
                    "elements": state["all"],
                    # NEVER while drawing, or each poll is its own undo step.
                    "captureUpdate": "NEVER",
                    # These cells are 340px showing scenes laid out for
                    # 1200x800. Without refitting you watch a zoomed-in corner
                    # of a drawing rather than the drawing.
                    "scrollToContent": True,
                },
            }

        total_elements += len(state["all"])
        slowest = max(slowest, state["elapsed"])
        if state["done"]:
            finished += 1
            total_cost += _cell_cost(cell["model"], state.get("meta"))
            if not cell.get("done"):
                # Last poll for this cell: commit it as one undo step, then
                # drop it from the buffer.
                commands[index] = {
                    "id": f"bm-final-{cell['id'][:8]}",
                    "type": "updateScene",
                    "payload": {
                        "elements": state["all"],
                        "captureUpdate": "IMMEDIATELY",
                        "scrollToContent": True,
                    },
                }
                scene_stream.forget(cell["id"])
            next_cells.append({**cell, "cursor": state["cursor"], "done": True})
        else:
            next_cells.append({**cell, "cursor": state["cursor"]})

    all_done = finished == len(cells)
    if not all_done:
        status = (
            f"Drawing… {finished}/{len(cells)} variants finished · "
            f"{total_elements} elements so far ({slowest:.0f}s)"
        )
        return status, "gray", next_cells, False, heads, commands

    cancelled = sum(1 for c in cells if c.get("cancelled"))
    status = (
        f"{len(cells)} variant{'s' if len(cells) != 1 else ''} in {slowest:.0f}s "
        f"(wall clock — they ran together) · {total_elements} elements · "
        f"~${total_cost:.3f} total"
    )
    return status, "yellow" if cancelled else "green", next_cells, True, heads, commands


@callback(
    Output("bm-status", "children", allow_duplicate=True),
    Output("bm-status", "color", allow_duplicate=True),
    Output("bm-runs", "data", allow_duplicate=True),
    Output("bm-tick", "disabled", allow_duplicate=True),
    Input("bm-stop", "n_clicks"),
    State("bm-runs", "data"),
    prevent_initial_call=True,
)
def _stop(_clicks, cells):
    """Press the brakes on every variant at once.

    Six calls are six bills, so this cancels them all rather than asking which
    one. Each `cancel` closes that run's provider stream, so generation stops
    and billing stops at the tokens already produced — ending the poll alone
    would leave six models running to their full budgets unobserved.

    What is drawn stays drawn: a half-finished sweep is still a comparison,
    and it is usually the reason for stopping.
    """
    if not cells:
        return no_update, no_update, no_update, True

    stopped = sum(1 for cell in cells if scene_stream.cancel(cell["id"]))
    return (
        f"Stopped {stopped} variant{'s' if stopped != 1 else ''}. What was drawn "
        f"is kept; no further tokens are being generated.",
        "yellow",
        [{**cell, "cancelled": True} for cell in cells],
        True,
    )


@callback(
    Output("bm-run", "loading"),
    Output("bm-run", "disabled"),
    Output("bm-stop", "disabled"),
    Output("bm-prompt", "disabled"),
    Output("bm-axis", "disabled"),
    Input("bm-tick", "disabled"),
)
def _lock_controls(tick_disabled):
    """Keyed on the ticker, not on `_run`'s lifetime — that now returns in
    milliseconds, so a `running=` lock would release while six models were
    still drawing."""
    sweeping = not tick_disabled
    # `or not ANY_KEY` keeps Run off for good on a keyless deployment; without
    # it this callback re-enables it on first render.
    off = sweeping or not ANY_KEY
    # Stop is enabled precisely when a sweep is running.
    return sweeping, off, not sweeping, off, off
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /benchmark*

---

<!-- /changelog — https://excalidraw.2plot.dev/changelog/llms.txt -->

# Changelog

> Version history of dash-excalidraw. The timeline on this page is rendered from `CHANGELOG.md`, reproduced below.

---

All notable changes to `dash-excalidraw` are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

#### Added

- **`welcomeScreen`, `welcomeScreenContent` and `welcomeScene` props.**
  `UIOptions.welcomeScreen` is read once while the canvas mounts, so a control
  bound to it does nothing after the first paint. The overlay's real state is
  `appState.showWelcomeScreen`, so `welcomeScreen` drives that instead and
  toggles live. `welcomeScreenContent={"title": …, "subtitle": …}` puts your
  own words on it. `welcomeScene` sets the scene the canvas opens with when
  `initialData` is absent — the same `{elements, appState, files}` shape, so a
  scene from anywhere in this library can be pasted in.

- **`/trace-image` hands the trace back as copyable scene JSON**, taken from
  `externalizedSerializedData` so the inline reference image is stripped out.
  Draw or trace a scene, copy it, paste it into `welcomeScene` or
  `initialData`.

- **`/scene-viewer`**: paste or upload raw scene JSON and render it on a
  live canvas. It is the other half of `/trace-image`. It accepts a full
  `.excalidraw` envelope, anything shaped like `initialData`, a bare element
  array, a model's fenced reply, and `serializedData` quoted once too often.
  Before rendering, it reports what Excalidraw's loader would otherwise drop
  without a word: unknown types, invisibly small elements, duplicate ids, and
  images whose bytes were externalized to `null`. No model is called, so it
  works on this site. It also documents a reusable pattern: a render is four
  commands fed one at a time through a `dcc.Store` queue, advanced each time
  the canvas clears `command`.

- **Claude Opus 5.5 on `/ai-agent`, `/benchmark` and `/trace-image`**
  (`claude-opus-5-5`, $4 / $20 per 1M tokens). Its image input, all five
  effort levels and its 1M / 128K limits were read from `GET /v1/models` on
  2026-09-22. Its API default effort is `medium`, not `high`, so the
  selector starts at an explicit `low` and the "none" estimate is priced as
  medium. Claude Opus 5 stays the default model.

#### Changed

- **The sidebar reads Getting started → Appearance → Data flow → Advanced.**
  Appearance moved ahead of Data flow. Inside Advanced, Coverage moved to the
  end: it shared `order: 6` with Trace an image and sorted between Benchmark
  and Trace by name alone.

#### Fixed

- **The mobile drawer closes when you pick a page.** Drawer links route
  client-side, so the drawer stayed open with its overlay over the page you
  had just chosen until a second tap on the hamburger. Recorded as
  DIVERGENCES.md 18. It is template-class and should be filed upward.

- **Documented: an embed always draws above every ordinary element**, whatever
  the scene z-order says, so "bring to front" on a shape does nothing against a
  GIF. Measured in the DOM — the static canvas holding every shape, image and
  text is one bitmap at z-index 1, and the iframe container is a DOM layer at
  z-index 2. It is upstream Excalidraw behaviour, identical for a YouTube
  embed on excalidraw.com, and there is no setting that fixes it.

- **The toolbar's "insert image" now takes GIFs down the same path as a drop.**
  Dropping worked and picking did not, which was not a second bug but a second
  door: Excalidraw opens its picker with `browser-fs-access`, which uses
  `window.showOpenFilePicker` where it exists (Chrome) and a hidden
  `<input type="file">` otherwise — neither reaches the drop handler. Both are
  intercepted, and a picked GIF is re-dispatched as a synthetic drop so it
  reuses the proven path rather than a second copy of it. Non-GIF picks pass
  through untouched.

- **The upload activity table scrolls sideways** instead of running past its
  card and clipping the status column — the one that says whether the upload
  worked.

- **A GIF is identified by its bytes, not by the drag's MIME type.** A drag can
  arrive with `type` empty — measured, such a GIF was stored as `.bin` with
  mime `application/octet-stream`, so the page could not tell it was a GIF and
  never built the embed. The component sniffs the GIF magic, rewrites the
  dataURL prefix, and reads the size from the 10-byte header instead of
  decoding the file (which was reintroducing the very main-thread stall the
  interception exists to avoid).

- **An upload over the per-file cap is answered, not crashed.** `FileTooLarge`
  used to leave the callback: HTTP 500, a placeholder on the canvas that never
  resolved, and nothing saying why. The upload table now shows `too large` in
  red with the limit and the env var that raises it.

- **Animated GIFs survive the drop, and no longer freeze the tab.** Excalidraw
  rasterises a dropped GIF to a single still frame before anything downstream
  sees it — measured, a 123,069-byte 12-frame GIF89a arrived as a 2,820-byte
  PNG with none. The component now intercepts GIF drops and sends the original
  bytes down the `lastExternalDrop` path, exactly like a `.pdf`; Excalidraw
  never decodes them. `/file-uploads` stores those bytes and puts an
  `embeddable` where the placeholder was, so the browser animates the real
  file. Verified end to end: 123,069 bytes stored, `GIF89a`, 12 frames, iframe
  live on the canvas. The rasterisation was also what froze the tab on a large
  GIF — a 5.17 MB one left the renderer unable to answer a debugger evaluation
  at all — so not decoding it fixes both symptoms.

  The trade: a GIF is an embed rather than an image element, so it is not
  croppable or styleable on the canvas.

- **The storage backend is a seam** (`lib/file_backends.py`): `put`, `get`,
  `url_for`. `EXCALIDRAW_FILE_BACKEND` selects `memory` (default) or `disk`
  (`EXCALIDRAW_FILE_DIR`), and an unknown name raises at boot rather than
  falling back — a store that is silently not the one you configured is worse
  than a failure to start. Implement those three methods and `register_backend`
  to point uploads at Cloudflare R2, S3 or Postgres; nothing above storage
  changes, because the canvas only ever needed a URL back. `/file-uploads`
  explains the whole path and shows which backend is live, and
  `scripts/stress_file_uploads.py` re-measures the caps, eviction and TTL.

- **`replaceFiles` never replaced anything.** It was built on `api.addFiles`
  overwriting an entry whose id already exists — measured in a browser, that
  is a silent no-op, and there is no `removeFiles` and no `updateScene({files})`
  either. So every `/file-uploads` drop uploaded correctly and the canvas kept
  its base64, which is why the GIF auto-embed never produced an iframe: that
  page waits for a file whose `dataURL` has become a URL and there never was
  one. The command now stores the new bytes under a fresh id and repoints the
  elements that referenced the old one, and reports the file store so callbacks
  watching `files` actually fire.

- **`resetScene` silently discarded every mode the props had asked for.** It
  restores Excalidraw's default appState, so a canvas mounted with
  `viewModeEnabled` (or zen/grid mode, a `name`, a `theme`) quietly came back
  editable — measured as 1 toolbar button before the reset and 17 after — and
  then snapped back on the next React re-render, stranding anything drawn in
  between. `/trace-image` resets the scene at the start of every trace, so this
  was the ordinary path. The command now re-asserts what the props say.

- **`/trace-image`'s canvas is editable.** It was view-only, on the theory that
  a specimen being compared should not change under a stray click. Now that the
  scene JSON below it is copyable, tidying the trace before taking it away is
  the point.

- **`sceneVersion` was never computed.** `getSceneVersion` is a standalone
  export of `@excalidraw/excalidraw`, not a method on the imperative API; the
  component called it as a method behind a `typeof === "function"` guard, so it
  was always `undefined` and the guard made that silent. The prop never
  reached Python and any callback holding it as an Input never fired — which
  is why `/coverage`'s read-only panel sat at null however much you drew.

- **`addFiles` registered files nothing could observe.** It touches no element,
  so Excalidraw fires no change event and the `files` prop never mentioned the
  file just added. The command now reports the file store itself.

- **`toggleSidebar` was being given a tab name as a sidebar name.** Excalidraw's
  built-in sidebar is `"default"`; `"library"` and `"search"` are tabs within
  it, so `{name: "library"}` addressed nothing and returned false. `/library`'s
  and `/commands`' buttons now pass `{name: "default", tab: "library"}`, and the
  component warns when a `toggleSidebar` finds no such sidebar rather than
  letting the command evaporate.

- **`/ui-options`' two link-state canvases were side by side** in a column too
  narrow for the hamburger menu they exist to demonstrate, so the menu covered
  its own canvas. Stacked.

#### Note for readers of the deployed site

**The AI pages on <https://excalidraw.2plot.dev> are documentation, not a
hosted service.** That deployment carries no provider keys, so `/ai-agent`,
`/benchmark` and `/trace-image` show a notice and their run controls are
disabled. Nothing there spends anything, and nothing is broken — the owner
declined to spend tokens on demos for anonymous visitors, which is a
deliberate decision and not a misconfiguration.

Everything described below works when you run the repo locally with a `.env`
holding a provider key: streaming onto the canvas, the cost estimate, the Stop
button and the daily spend ceiling included.

#### Added

- **ChatGPT joins Claude and Gemini on `/ai-agent`, and `/benchmark` can now
  compare the two head to head.** GPT-6 Astra, GPT-5.6 Sol, Terra and Luna,
  called through the Responses API — `instructions`, `input`,
  `max_output_tokens` and `reasoning.effort` map onto the page's existing
  three controls with no translation. `/benchmark` gains a third axis,
  "Compare models", which puts a Claude cell and a ChatGPT cell in one sweep
  at the same prompt, budget and effort; matched settings and a real price on
  each are what make it a comparison rather than two runs. Set
  `CHATGPT_API_KEY` (`OPENAI_API_KEY` is accepted as a fallback).

  Gemini is deliberately excluded from the compare axis: it takes neither
  control and has no published price in this app, so a cell for it could show
  a drawing but never an honest cost. It stays available on `/ai-agent`.

- **`/ai-agent` draws the scene as it arrives instead of showing a loading
  overlay.** Claude and ChatGPT runs stream: elements are parsed out of the
  token stream as each one's braces close and dispatched to the canvas by a
  400ms poll, so the first shape lands in a few seconds rather than after the
  whole generation. The status line counts elements and elapsed seconds while
  it draws. Intermediate updates use `captureUpdate: NEVER` and the final one
  `IMMEDIATELY`, so the whole drawing is a single undo step rather than one
  per poll. Gemini keeps the one-shot path — its SDK has no streaming call.

- **A daily spend ceiling across every AI page and provider.** The sign-in
  gate stops anonymous spending; it does nothing about a signed-in visitor
  running `/benchmark` thirty times, or a model deciding a prompt deserves its
  whole 128K budget. `lib/spend.py` holds one constant — `DAILY_CEILING_USD`,
  the site owner's only dial — and refuses a call that would take the day past
  it, with a message that says what the limit is, what has gone, and that it
  resets at midnight UTC. `/ai-agent`'s cost label shows what is left.

  Admission uses the typical cost estimate and settlement records the actual
  one, so the day can overshoot by at most one run's difference between the
  two — bounded, not zero, and stated that way in the module rather than
  implied to be a hard cap. The ledger is diskcache-backed for the same reason
  the scene buffer is: a counter in a module global would give each of the two
  gunicorn workers its own budget, making the real ceiling a function of
  `WEB_CONCURRENCY`.

  The ledger lives under the system temp directory, which on Render is inside
  the container — shared by both workers, as the ceiling requires, and reset
  on every deploy, so a redeploy starts the day over. Point `AI_SPEND_DIR` at
  a persistent disk if a day should survive deploys.

- **`/trace-image`** — upload a reference image and have the model redraw it
  with Excalidraw's own primitives, streaming onto the canvas beside the
  original. Same controls as `/ai-agent` (model, effort, max tokens) with the
  cost estimate accounting for the image's input tokens, which a prompt-only
  page does not have to. The system prompt is spliced from the shared one, so
  the JSON contract and the parser are identical and only the task changes; it
  tells the model to match layout, structure, text and colour in that order,
  and explicitly NOT to chase photographic detail with hundreds of freedraw
  strokes, which exhausts the budget before the layout is down. Models are
  refused an image unless they are in `VISION_MODELS` — a model that silently
  ignores one would trace from the prompt alone and return a confident drawing
  of nothing in particular.

- **`/benchmark` streams every cell, and has a Stop all button.** The six
  drawings now appear together as they are made, each panel refitting itself
  as its scene grows, instead of a spinner followed by six finished panels.
  The button stops every variant at once — one click here is up to six paid
  calls, so it is worth six times what it is worth on `/ai-agent`.

- **A Stop button on `/ai-agent` and `/trace-image`.** Presses the brakes on a generation in
  flight: it closes the provider stream, so the model stops generating and
  billing stops at the tokens already produced. Ending the poll alone would
  have left the run going to its full budget with nobody reading it. The
  cancel flag lives in the shared store, so it works when the click is
  handled by a different worker from the one running the generation. What is
  already drawn stays on the canvas — a stop is not an undo.

#### Changed

- **The AI provider SDKs are now installed on deployed commits, which makes
  `/ai-agent` and `/benchmark` able to call anything there for the first
  time.** `anthropic`, `openai` and `google-genai` were an optional
  `pyproject` extra that the Dockerfile never installs, so on every deploy to
  date the pages rendered and every click died on an `ImportError`. They are
  now pinned in `requirements.txt`. **This is a behaviour change to the
  deployed site, not a dependency bump** — those pages can spend real API
  credits from the moment a key is present in the environment. Both remain
  behind `tier: auth` and the signed-in spend gate.

  Pinned with `==` rather than a floor, to the versions this app's calls have
  actually gone through (`anthropic==0.96.0`, `openai==3.11.0`,
  `google-genai==1.73.1`). `anthropic` 1.4.0 and `google-genai` 2.22.0 both
  exist and are each a major version ahead; the commit that first makes the
  lane live is the wrong place to discover what changed in them.

#### Fixed

- **A generation lasting longer than five minutes ate its own beginning.**
  In-flight elements were stored with the same 300-second expiry used to
  retire an idle run, so on a long generation the earliest elements expired
  while the model was still drawing: the canvas lost roughly one shape for
  every shape it gained and the count plateaued. Measured on a real 358-second
  run that finished with 145 elements. Elements now carry an expiry that
  outlives the longest possible run, and `take()` COUNTS elements it cannot
  read back instead of quietly returning a shorter list — the silence is what
  made this look like a model that had stopped drawing.

- **Streamed elements were not normalised, so long scenes stopped growing.**
  Observed on `/ai-agent` with `gpt-6-astra`: past roughly 150 elements the
  canvas appeared to delete one shape for every new one. `updateScene`
  reconciles by element id, so an element carrying an id the scene already
  holds REPLACES it — a model that repeats ids therefore caps the scene at
  the number of distinct ids it emits, with no error anywhere. Streamed
  elements now go through the same `_coerce_types` normalisation the blocking
  path always used (which also fills in `version`, `versionNonce`, `seed` and
  `isDeleted`), and a repeated id is renamed rather than allowed to overwrite.

- **`/ai-agent`'s status line priced every run from the Claude table**, so a
  ChatGPT run reported no cost at all — which reads as "this was free".

- **A budget-truncated scene said `stop=max_tokens` and nothing else.** It now
  says it was truncated and names the budget, since hitting it is the most
  common reason a drawing comes out shorter than asked for. A truncated
  ChatGPT stream is also no longer raised as an error: the elements that
  arrived are real shapes already on the canvas, and presenting a partial
  success as a failure is wrong.

- **In-flight generations are stored in a shared cache rather than a
  per-process dict.** The production image runs `gunicorn --workers 2`, so a
  poll balanced to the worker that is not running the generation would have
  found no such run and reported that it vanished — roughly half of them, at
  random, with nothing in any log. Impossible to reproduce locally, where
  `python run.py` is a single process. The buffer is now backed by diskcache
  (already a dependency), and a test spawns a second interpreter to prove a
  separate process can read a run.

- `_call_claude` carried two consecutive docstrings, so `help()` showed only
  the first and the streaming rationale was invisible. Merged. Pre-existing;
  harmless at runtime, invisible to flake8 and pytest.

## [1.0.0] — unreleased

Ground-up TypeScript rebuild of the component, and the documentation site
that ships with it. See `REBUILD.md` for the rebuild's design and
`/migration` for what changes for a 0.0.x user.

#### Known

- **Excalidraw's font-subsetting worker runs on the main thread, and an SVG
  export of text logs one console error.** The worker's URL is resolved from
  `import.meta.url` at build time, so the bundle carries a `file://` path of
  the machine that built it. A browser refuses it — `new Worker(…)` from the
  page raises `SecurityError: Script at 'file:///…/subset-worker.chunk.js'
  cannot be accessed from origin 'https://…'` — and Excalidraw catches that
  and falls back, logging `Failed to use workers for subsetting, falling back
  to the main thread.` once.

  **Exports are correct**; only the console is noisy and the subsetting is
  slower than it would be off-thread. Measured on the deployed site, not
  inferred from the source. Pre-existing since the first build of this
  rebuild, not new in 1.0.0. The fix is to have webpack emit those chunks
  under a URL Dash can serve, and it belongs in the next release rather than
  in a change made under a tag.

### Site — internal traffic is dropped from the read table too (2026-09-01)

Consumes `sync/SYNC-1.6.43.md` items 1–3 at template 2b1edd5. Item 1 first,
as the drop ordered: its consequence was accruing.

#### Fixed

- **`record_read` drops internal traffic before it reads any field.**
  `track_visit` has honoured the network's internal-traffic contract since
  it existed; the `on_document_read` hook added at the 2.8.0 floor did not.
  So the hub's health sweep, this site's own link audit and every
  post-deploy battery were landing in the `reads` table and had become the
  busiest "vendor" on the board. "Counted nowhere" includes the read table
  — a host that drops the token from one table and not the other holds half
  its own contract. Keyed on `ua`, which is what `EVENT_FIELDS` calls it;
  keyed on `user_agent` it would have been a silent no-op, so the field name
  is pinned against the resolved package, not against the spec text.
  Measured both directions: internal-token probe → 0 read rows, real crawler
  probe → exactly 1, counts printed beside the result.
- **The test-client UA pin reads the CODE, not the file.** Its file-scoped
  form flagged `tests/test_internal_traffic.py`, which drives no client at
  all — it mentions `.test_client()` in a comment about greps matching
  comments. Now AST-based, with a non-vacuity guard: the sweep must match at
  least three files, because a sweep that found nothing and a sweep that
  swept nothing produce the same green. It sweeps 6 and goes red under
  mutation.

#### Added

- **`/healthz` reports `llms_version`.** The payload named `dash_version`
  and `python` but not the package every floor in this fleet is about, and
  item 1's acceptance asks each host to compare `EVENT_FIELDS` across its
  CI-vs-production pair — a comparison this host could not make from
  outside. Additive and omitted on failure. Adopted by the fleet the same
  day under this name: it ships as 1.6.44 item 1's eighth `/healthz` key,
  so this is early adoption rather than a divergence (DIVERGENCES 16). The
  name was settled before either side shipped, because the template drop
  was carrying the field as `dimll` and a fleet key with two names is the
  failure `/healthz`'s shape contract exists to prevent.
- Three kit traps in `.claude/CLAUDE.md`, all earned in the item-18 round:
  which branch Render builds can be measured on a green push by timing (as
  evidence, not proof); verify the artifact the claim is about and say which
  one; and assert the corpus is non-empty before trusting any negative.

Tests 468 → 470.

### Site — the 1.6.41 remainder, and the caller for the cargo (2026-08-31)

Consumes `sync/SYNC-1.6.22-1.6.42.md` item 18 at template 4ac02e0, with the
a33ecfe amendment to contract highlight (7). This is the round that closes
the seam this fork reported from inside: it had `slim_generated_on()` and
nothing called it.

#### Added

- **`/api` carries a `lastmod`** — the committed extract's `generated` stamp,
  reaching the sitemap through `api_reference.slim_generated_on()`. `/api`
  was the one page in a 14-entry sitemap with no lastmod while the extract
  sat on disk. `/api` and `/changelog` now register the FULL machine record
  (visibility, tier, lastmod), not just a module `LLMS_DOC`.
- **A row-parity pin for `/api`, with a mutation check.** Four mechanisms
  produce an empty `/api` at 200; a heading pin passes on all four. This one
  asserts ROWS and row CONTENT across the machine lane, the received HTML,
  the prerender block inside it, and the component tree React mounts — and a
  second test breaks the loader to prove the pin can go red.
- **A skip link** as the first tab stop, and **short sidebar labels**
  (`nav:` frontmatter) available per page.
- **`tests/test_layout_nesting.py`** — `parse()` returns a LIST, and
  `children=[hero, parse(...)]` renders the page EMPTY with a green suite
  (React #31; the prerender reads the markdown, so every machine-lane check
  stays green). Walk + non-vacuity fixture + registry-derived positive
  control + source pin.
- **`smoke_live.py` gains three checks**: `HEAD /healthz answers what GET
  answers`, `GITHUB_URL resolves`, and a `/` dedup so a broken home page
  stops printing three identical failures.

#### Fixed

- **The battery was checking the wrong hidden paths.**
  `network_smoke.HIDDEN_DOC_PATHS` still held the template's canary
  (`/admin/llms.txt`, `/analytics/llms.txt`) — neither is a page here — so it
  probed two paths that 404 because nothing serves them, a vacuous pass,
  while never checking the two admin pages this site actually has. Now the
  real ones, pinned against the registry.
- **`scripts/audit_links.py` drove a bare in-process test client.** A bare
  client sends `Werkzeug/x.y`, which is the crawler lane at the 2.8 floor, so
  the audit fetched the crawler document for every page and would report this
  site's own `mark_hidden` admin pages as broken links. Its `AUDIT_UA` was
  crawler-shaped too. External requests keep that identity; in-process ones
  now name the browser lane.
- The header's identity (`WORDMARK`, `LOGO_ASSET`, `LOGO_STYLE`,
  `WORDMARK_COLOR`, `WORDMARK_VISIBLE_FROM`) moves to `lib/constants.py`, so
  `components/header.py` holds no fork content and is byte-identical to the
  template.
- The changelog parser accepts every heading shape the fleet writes — em
  dash, en dash, no brackets, prose-first, and words where the date goes.
  This repo's own `## [0.1.0] — unreleased` now reads as a note rather than a
  date, and `/changelog` omits its lastmod honestly instead of inventing one.

Tests 446 → 468.

### Site — the wall comes down, and navigation comes from one registry (2026-08-30)

Consumes dash-documentation-boilerplate's `sync/SYNC-1.6.22-1.6.38.md` items
15 and 16 at template 1.6.39 (`519d496`), against the owner's brief
`DESIGN-navigation-uniformity-2026-08-30`. Nothing here touches the
component half. **Not deployed** — the owner holds the push.

#### Changed

- **Training crawlers are ALLOWED by default.** `block_ai_training` is off:
  robots.txt emits no training stanza at all (GPTBot, ClaudeBot and CCBot
  fall under `User-agent: *` / Allow), and the package's middleware stops
  answering 403 on the browser document and `/healthz` for them. The wall
  decided by vendor CLASS what nobody could account for; since the 2.8.0
  ledger round every corpus read is a row that can be priced, so the tool
  is now per-vendor policy — `vendor_policy={"<key>": "block" | "meter"}`
  for ONE vendor whose rows justify it. Measured on this host's wire
  2026-08-30 BEFORE the flip: ClaudeBot and GPTBot both 403 on `/`, 200 on
  `/llms.txt`, 403 on `/healthz`. In-process AFTER: 200/200/200 for both,
  and zero `Disallow: /` in robots.txt. Every one of those 403s was the
  app's own middleware — there is no edge wall in front of this host.
- **The sidebar is built from frontmatter, not from a hand-written list.**
  `components/navbar.py`'s `page_order` and `excluded_links` are DELETED.
  Sections now come from each page's `category:` ordered by
  `lib.constants.CATEGORY_ORDER`, and pages within a section by a new
  `order:` frontmatter field. The reading order the old list encoded is
  preserved exactly, promoted from page names to four categories: Getting
  started · Data flow · Appearance · Advanced.
- **The network is listed ONCE**, as the top bar's *Other Apps* hover menu,
  from `lib.network_directory.PRIMARY` — 2plot.ai, 2plot.dev, 2plot.media,
  piratesbargain.com, ai-agent.buzz, this host removed, labelled by domain.
  The sidebar's "Pip Components" and "Other Apps I've built" sections are
  gone, and with them the duplicate 2plot.dev entry that appeared in both
  that list and Resources.
- **Resources is third-party only**: `dmc` and Excalidraw, the upstream
  project this component wraps. `community.plotly.com` and the duplicate
  2plot.dev link are removed.
- **The top bar's GitHub icon and JSON-LD `sameAs` share one constant**
  (`GITHUB_URL`), and the icon points at this repository rather than the
  profile. The footer's GitHub link is the profile — two different links,
  each in the right place.
- **`/admin/*` is hidden, not blocked.** The startup sidebar tree carries no
  `/admin/` href at all; a callback fills an Admin section only for
  `is_admin_user()`. Admin pages never enter search either.
- **`/admin/traffic`'s day picker is `dmc.DatePickerInput`**, bounded by the
  ledger's first and last day with Today / Yesterday / Last 7 days presets —
  the fleet's no-`dcc`-where-DMC-has-it rule. `pages/home.py` moves off
  `dcc.Markdown` to the same markdown2dash renderer the docs use.

#### Added

- **`/changelog`** — this file as a DMC Timeline, and its own `LLMS_DOC`.
  Linked once, from the sidebar.
- **`/api`** — one prop table per component, generated from the installed
  package's `metadata.json`. `API_PACKAGES = ["dash_excalidraw"]`, so this
  site documents `DashExcalidraw`'s 38 props without hand-maintaining a
  table. The header gains a version badge reading the same package.
- **A footer** — © {year} Pip Install Python LLC · GitHub profile · Discord ·
  YouTube, every icon named.
- **A People section on `/admin/traffic`**, above the crawler tables, with
  the day's human hits / visitors / sessions / median session and the line
  that answers the owner's question directly: humans never enter the read
  ledger, so "(unidentified)" below is the crawler lane with no vendor
  match, never a person.
- **Accessibility**: the sidebar Burger, the search inputs, the ledger's day
  picker and every footer icon carry names; `CodeHighlightTabs` sets
  `copyLabel` / `copiedLabel`.
- **Auth-tier pages carry a lock in the sidebar** — `/ai-agent` and
  `/benchmark`. A divergence from template 1.6.38, recorded and filed
  upward: the template has no auth-tier docs page, so listing one
  indistinguishably from a public page was never its problem.
- **Mobile fit**: code blocks inside a List item, Blockquote or Timeline can
  no longer widen the document at phone width, and the mobile drawer is
  `keepMounted` so the hamburger never opens onto an empty panel.
- Tests 418 → 445. New: `tests/test_nav_contract.py` (25 pins),
  `tests/test_excluded_links_hidden.py`, `tests/fixtures/fake_dash_pkg`, and
  the battery's lane pin.

#### Fixed

- **The live battery's default User-Agent names the browser lane**
  (`sync/SYNC-1.6.22-1.6.40.md` item 17, found by muischeduler). At the
  2.8.0 floor a User-Agent with no browser engine token is crawler-lane, and
  `scripts/network_smoke.py`'s default was the bare internal token plus
  `" network-smoke"` — so every default-UA check was reading the prerendered
  crawler document, and any browser-shape check would have gone red in CD's
  verify job the moment it was added. The Chrome/AppleWebKit token now comes
  FIRST and the internal token after it; `INTERNAL_UA_TOKEN` is a substring
  match, so the analytics exclusion still holds and the run is still counted
  nowhere. `CRAWLER_UA` is the other lane and is untouched.
  `scripts/smoke_live.py` was audited and already had this shape.
- **`scripts/smoke_test.py` names the browser lane too** — the same defect by
  a second road (found on leaflet's first CD run). A backend test client
  sends NO User-Agent, which at the 2.8.0 floor is the crawler lane, so the
  harness's "every registered page returns 200" loop was asking the crawler
  lane about `/admin/control-board` and `/admin/traffic` — pages that are
  `mark_hidden` and therefore CORRECTLY 404 there. Reproduced on this tree:
  `python scripts/smoke_test.py` exited 1 with those two as the only
  failures, which would have gone red in CI's `compat-matrix` job and taken
  the deploy with it. Every request the harness makes now carries a
  browser-lane UA, and the crawler lane is asserted explicitly rather than
  assumed — admin paths must 404 to a crawler — so naming the browser lane
  cannot mask the inverse regression of a hidden page becoming public.
  56/56 checks, exit 0.
- **`/api` no longer depends on a gitignored build artifact.**
  `dash_excalidraw/metadata.json` is a `dash-generate-components` byproduct:
  gitignored on purpose, and `scripts/check_release.py` asserts it is absent
  from the built wheel. So it exists only on a machine that has run
  `npm run build` — never in a CI checkout, never in the production image.
  Reading it alone made `/api` document 38 props on the author's laptop and
  nothing anywhere else. `lib/api_reference.py` now prefers `metadata.json`
  when present and falls back to the generated component class's docstring,
  which is tracked, ships in the wheel, and carries the same catalogue in
  Dash's standard "Keyword arguments:" format. Same 38 props either way.
  Superseded within the day by template 1.6.41's three-source loader, taken
  as cargo: `metadata.json` → the committed `api_metadata.json` extract
  (`scripts/build_api_metadata.py`, 11 KB, tracked) → the docstrings. All
  three roads produce the same 38 props here.

### Site — the ledger row, and `release` becomes the deploy branch (2026-08-29)

Consumes dash-documentation-boilerplate's `sync/SYNC-1.6.22-1.6.35.md`
items 12 and 13 at template 1.6.35 (`4c63992`). Nothing here touches the
component half.

#### Changed

- **dimll floor 2.7.1 → 2.8.0**, in all four encodings the number lives in:
  `requirements.txt` (the flask line plus the three commented backend
  lines), `run.py`'s `LLMS_PKG_FLOOR` and its boot diagnosis, and
  `ci.yml`'s two asserts. Editing the requirements line IS the Docker cache
  bust; a `>=` floor can never pull a new release through a cache hit.
- **There is ONE classifier.** `lib/analytics_tracker.py` carried its own
  User-Agent lists for a year. They filed ClaudeBot — Anthropic's
  *training* crawler — under "search", still named the retired
  `anthropic-ai` / `claude-web` tokens, and counted every UA-less or
  library client (`httpx`, `Go-http-client`) as a person. The lists are
  gone: `is_bot` and `detect_bot_type` keep their names and signatures for
  callers and delegate to `dash_improve_my_llms.classify()`, which is the
  same registry `robots.txt` is rendered from — so what this site SAYS
  about a vendor and what it COUNTS now agree. `track_visit` classifies
  exactly once, *after* the real client IP is resolved, so `verified` is
  computed against the client rather than the proxy. Crawler rows gain
  `vendor_key`, `vendor_class`, `verified` and `lane`; human rows are
  byte-identical to before.
- **REPORTING CONSEQUENCE, expected and not a regression:** `human_hits`
  DROPS and `bot_hits` RISES on adoption day, because UA-less and library
  clients move from the human lane to the crawler lane. That is the number
  becoming true. The hub's day-over-day view will show the step.
- **Render deploys `release`, and only CD writes `release`.**
  `.github/workflows/cd.yml`'s `deploy` job no longer POSTs a Render deploy
  hook; it fast-forward-pushes the run's own sha to `refs/heads/release`
  after the CI matrix is green, under a job-level `contents: write` grant
  (the workflow stays `contents: read`). The checkout is `fetch-depth: 0` —
  a shallow HEAD pushed onto an EXISTING `release` is rejected as
  non-fast-forward, which a fork does not discover until its SECOND
  promote. `render.yaml` declares `branch: release`. A push to `main` is
  now a candidate, not a deploy. **The repository's deploy-hook secret is
  inert and safe to delete.**
- **`verify` gains a `/healthz build == github.sha` assertion of its own**,
  and its `if:` is now the template's verbatim `needs.deploy.result ==
  'success'` (was `always() && needs.deploy.result == 'success'` — the two
  are equivalent). This fork's divergence 5 is **retired by convergence**:
  template 1.6.35 adopted the stricter gate after run 33262495272 showed
  the permissive form reporting GREEN against the previous build on a
  failed promote — the same failure this fork measured on 2026-08-21.

#### Added

- **The read ledger.** `run.py` registers `on_document_read(
  tracker.record_read)` once, next to `add_llms_routes`. Every corpus
  document dash-improve-my-llms serves now lands as a row in a `reads`
  table in the SAME analytics file — same buffer, lock, flush cadence and
  retention as `visits` — carrying tier, lane, vendor, verified, policy,
  verdict, status and bytes. `client_ip` is dropped unless
  `ANALYTICS_KEEP_CLIENT_IP=1`. `reads` is a second table joined by the
  rollup, never summed into `human_hits` / `bot_hits` / `pages`.
- **Rollup v4, additive.** `lib/traffic_rollup.py` gains `load_reads` and
  `vendor_rows`; `daily_rollup` emits `vendors[]` (one row per
  `(key, verified, policy)`, null key kept, sorted by hits desc, capped at
  40, all seven tier keys always) and `reads` — but ONLY on a day that has
  reads, so a host below the floor and a quiet day are indistinguishable by
  absence, which is the truthful state of both. Every v3 key is
  byte-identical.
- **`/admin/traffic`** (`pages/traffic.py`) — this host's own ledger behind
  the control board's exact gate, failing CLOSED without Clerk and
  `mark_hidden` at import. Vendor × day, vendor → tier for the picked day,
  top paths per vendor, and the v3 headline numbers for the same day. Plain
  tables, no charts, no interval callback; the day dropdown is the only
  control. The page says in words what `verified` means: `n/a` is a vendor
  publishing no IP ranges — Anthropic publishes none, so ClaudeBot is
  ALWAYS `n/a` here, and that is a property of the vendor, not a defect on
  this host.
- **`DIVERGENCES.md` gains a `posture` fence** declaring
  `deploy: release-branch`. Only that key: the rest of the 1.6.30 posture
  item is not adopted here, and declaring `ai_bots` / `healthz` from the
  tree rather than from the wire would be the aging copy the fence exists
  to kill.
- Tests 380 → 418. New: `tests/test_analytics_classifier.py` and
  `tests/test_traffic_rollup_v4.py` (the spec's block cargo, byte-verbatim),
  `tests/test_read_ledger.py`, `tests/test_traffic_page.py` and
  `tests/test_cd_promotes_release.py`.

#### Fixed

- **`tests/test_proxy_scheme.py` names the browser lane.** Its end-to-end
  `twitter:url` probe sent no User-Agent, and at the 2.8.0 floor an absent
  UA is the crawler lane — the crawler document has no `twitter:url` at
  all, so the test failed on "no tag" without saying anything about the
  scheme. It sends `BROWSER_UA` now. This is a "satellites copy this
  verbatim" file and the red hit every Flask fork in the fleet; either lane
  can be the one you did not mean to test.

### Site — one fleet Python, and the live tools grow their contracts (2026-08-26)

Consumes dash-documentation-boilerplate's `sync/SYNC-1.6.22-1.6.29.md`
items 5 and 6 at template 1.6.29 (`5589318`), plus the fork drop's two
addenda. Nothing here touches the component half.

#### Changed

- **The image, the workflows and `/healthz` now name ONE Python: 3.14.**
  The production image was `python:3.12-slim`; the site-lane CI matrix said
  3.12; nothing on the wire could contradict either, because `/healthz`
  carried no `python` field at all. It does now
  (`platform.python_version()`, one builder for both backends), and
  `scripts/network_smoke.py` gained the `python_matches_declared` check that
  holds the SERVING interpreter to the Dockerfile's `FROM` minor. A missing
  field is NOT-ADOPTED rather than not-applicable: emojimart's image moved
  to 3.14 through dependabot alone, so the cheap half of the detect passed
  while the expensive half failed invisibly.
- **The site's compat window is 3.14 / 3.13 / 3.12** (was 3.12 / 3.13 with a
  3.10 floor leg). The package's own 3.9-3.13 claim is untouched — it is
  measured by `package-python-range`, against the built wheel, which is
  where `requires-python` is actually a promise. `render.yaml` needed no
  change and is now pinned that way: on a `runtime: docker` service
  `PYTHON_VERSION` must be ABSENT, because nothing reads it there and a
  string that looks like the platform's setting and can never be true is the
  same defect class arriving through its own fix.
- **`scripts/smoke_live.py` is byte-current with template 1.6.29**, which
  brings two behaviours this fork lacked: the auth-wiring probe (`POST
  /api/auth/{session,signout}` must not answer 0/404/405 when the served
  shell carries the Clerk bootstrap — the flexlayout defect, where
  `register()` runs without `configure_app(app)` and every server render
  reads signed-out while the UI looks signed in), and a `wake()` that
  tolerates a legacy `fetch` stub instead of taking a fork's whole suite
  down. Measured against production before shipping: the shell carries the
  bootstrap, `/api/auth/session` answers 401 and `/api/auth/signout` 200, so
  the probe is armed here and green.

#### Added

- **`tests/test_python_version.py`** — the encodings-agreement pins, adapted
  for a repo that carries two Pythons legitimately. Scoped BY JOB NAME
  rather than by file, because the template's reference greps a whole
  workflow and this is the first repo in the fleet with a lane that file
  explicitly disclaims; `test_every_job_declaring_a_python_is_classified` is
  the guard on the guard, so a job nobody classified fails loudly instead of
  going quietly unread. Workflows are hand-parsed with comments stripped —
  not via PyYAML, which this site never declares (it arrives only as a
  transitive of `python-frontmatter`, and a pin that dies of ImportError the
  day an upstream swaps YAML libraries has stopped pinning).
- **An SSL context on `scripts/network_smoke.py`'s `urlopen`**, with a source
  pin beside `smoke_live.py`'s. This makes the battery's trust store a
  declared dependency (`certifi`, already required) rather than whatever CA
  bundle the running interpreter was built against. Stated plainly because
  the drop's rationale was stronger: the "reads a healthy host as down from a
  Mac" symptom did NOT reproduce on this seat — measured both ways against
  production, 10/10 either way — so this ships as parity with
  `smoke_live.py`, not as a fix for an observed outage. The template's copy
  has the same gap; filed upward with the correction attached.
- `tests/test_smoke_live.py` gains the legacy-stub guard, and its
  foreign-canonical stub now DERIVES the rewrite host from `BASE_URL`
  instead of spelling it — a literal that happened to be right on this fork
  is luck, not a property, and the vacuous-pass mode it invites is what the
  template's 1.6.8 hardening exists for.

#### Divergences recorded

- **8** — `ci.yml`'s two Pythons: the site lane is the fleet's, the package
  lane is `pyproject.toml`'s, and a sync must not "restore" the latter.
- **9** — `network_smoke.py`'s SSL context, which is ahead of the template
  rather than divergent from it; the entry retires when the template adopts
  it.

### Site — .claude kit adoption (2026-08-25)

Consumes dash-documentation-boilerplate's `sync/SYNC-1.6.10-1.6.16.md` and
`sync/SYNC-1.6.17-1.6.21.md` at template 1.6.23. Most of the first spec was
already satisfied by the floor round; what is new is the shipped development
kit, the machine-readable divergence fence, and four smaller ports.

#### Added

- **`.claude/` is shipped, not ignored.** This repo blanket-ignored the whole
  folder from its first commit, so the network's behavioral contract, its
  skills and its settings stayed local to one machine and propagated to
  nobody who cloned it. `.gitignore` becomes an allow-list (`.claude/*` plus
  `!CLAUDE.md`, `!settings.json`, `!skills/`); everything else — `agents/`,
  `rules/`, `scripts/`, `tasks/`, session scratch, `settings.local.json` —
  stays local. `.claude/CLAUDE.md` keeps this repo's own guide (rewritten:
  it still described an `app.py` demo and a `pages/` full of showcase files,
  neither of which has existed since the docs site landed) and gains the
  template's contract and verification-traps sections byte-verbatim.
- **`DIVERGENCES.md`**, with seven recorded deliberate differences and the
  `byte-owned` machine fence the fleet's fan-out honours. The fence is
  **empty by decision**: this repo carries all four `sync-verbatim` paths
  byte-verbatim and intends to keep receiving them mechanically.
- **`tests/test_claude_kit.py`**, byte-verbatim from the template — pins the
  kit shippable, case-correct, and pointed at *this* host rather than the
  template's.
- **CI asserts Docker's own health verdict** (`docker inspect
  .State.Health.Status`), failing on `none`. The external curl proves the app
  answers; it says nothing about the HEALTHCHECK instruction, which is what
  an orchestrator actually reads — emojimart shipped a broken probe silently
  while every external check stayed green.
- Source pins for two absences no rendered output can show: every `urlopen`
  in `scripts/smoke_live.py` carries `context=SSL_CONTEXT`, and
  `pages/home.py` runs `substitute_versions` over `home.md`.

#### Changed

- **`X402-SYNC-REPORT.md` is untracked** (kept on disk). Session working
  documents are local by convention network-wide; two public fleet repos
  were caught tracking theirs. `DEPLOY-READINESS.md` deliberately stays
  tracked — it is an owner deliverable and names no values (DIVERGENCES §4).
- **The auth-gate teaser demo points at this site.** `lib/auth_demos.py`
  still carried the template's `/examples/visualization` ->
  `docs.data-visualization.basic_chart` entry: no such page, no such module.
  `build_demo` swallows import errors by design and the endpoint was never a
  page here, so not even its warning ever fired — every gate card on the site
  quietly rendered the demo-less variant. Rekeyed to `/ai-agent` (one of the
  two pages this site hard-gates in frontmatter) showing the basic canvas.
  Deliberately **not** the AI agent module: that page's buttons call paid
  thinking models, and a live model call inside an unauthenticated sign-in
  card is an open invoice.
- **The Dockerfile defaults the port at the point of use** —
  `${PORT:-8050}` in both `CMD` and `HEALTHCHECK`. The `ENV` default covers
  *unset*, not *set-empty*; a bare `${PORT}` collapses the bind to
  `"0.0.0.0:"` and points the probe at `http://localhost:/healthz`.
- **CD is sized for the worst build**: the build-match wait goes 60 → 100
  iterations and the job timeout 20 → 30 minutes, because a floor bump busts
  the pip cache by design and this pipeline's most important deploy is
  therefore Render's slowest. A missing deploy hook now emits `::warning`
  rather than `::notice` — the quiet notice is why "nothing deployed at all"
  took a whole run to see on dash-email.
- **`pages/markdown.py` and `lib/page_visibility.py` regain byte-identity
  with the template**, which adds `published_name()`: the site brand is what
  a root page publishes to agents, so the llms preamble and the injected
  prerender header agree and 2.7.0's H1 dedup can fire. A no-op here (no
  docs page registers `/`) and taken anyway, to remove the drift point.
- **`lib/gate_layouts.py` keeps "and the AI assistant"**, against the
  template's 1.6.16 retirement. That fix's premise — no fork wires one — is
  false here: `docs/ai-agent` is a real page at `tier: auth`. Ported as the
  item's contract (*the gate card promises only what ships*) with a test that
  goes red if the page's tier ever opens.

### Site — floor round (2026-08-23)

Moves to `dash-improve-my-llms >= 2.7.1` and syncs four template fixes from
dash-documentation-boilerplate 1.6.9-1.6.13.

#### Changed

- **dimll floor 2.6.1 → 2.7.1**, in all six places it is encoded:
  requirements.txt (the pin and the three commented backend extras), run.py's
  `LLMS_PKG_FLOOR` and its boot message, and ci.yml's extras install plus both
  version asserts. 2.7.0 dedups the prerender H1 and the home footer's doubled
  `/llms.txt` link, and hardens the idempotency probe so a page that merely
  MENTIONS the marker keeps its prerender; 2.7.1 adds the llms.txt v2
  discovery relations and Link headers, the `Accept: text/plain` ramp, and the
  representation digest. **Editing the requirements line is the Docker cache
  bust** — that layer re-runs only on a byte change, so a `>=` floor can never
  pull a newer release through a cache hit.
- **`/healthz` is built per request, from one payload, on every backend.** It
  was a snapshot closed over at registration — harmless while every field was
  static and wrong the moment one is not. FastAPI built its own payload
  *without* `build`, which is the exact field cd.yml's build-match wait polls
  for, so a FastAPI deploy would have verified whichever release happened to
  be serving. The payload gains `app` (`SATELLITE_APP_KEY`, else `"unknown"`)
  and, on dimll >= 2.7.0, `geo` `{configured, denied, resolved}` — counts and
  flags only, never the denylist's country codes, and omitted rather than
  error-flagged on older packages.
- **`_expand_source_directives` is fence-aware.** A `.. source::` inside a
  fenced block is documentation showing the syntax, not a directive; expanding
  it injected a fence inside the open fence, closed it early, and rendered the
  inlined Python as markdown — every `# comment` becoming an `<h1>`. Latent
  here (no doc teaches the directive yet) and fixed before it could bite.
- **Dependabot pip version-updates restricted** to `dash*` / `plotly*` /
  `markdown2dash`. Without the allow-list it proposes floor-raises for every
  requirement, and those floors encode minimum-compatibility knowledge — the
  gunicorn floor *is* the CVE fact. Security updates are unaffected; they
  arrive through a separate channel.

#### Fixed

- **The noscript block carried an `<h1>`, so every page served two.** Crawlers
  run no JavaScript but do parse `noscript`, making it a second site-wide h1
  competing with each page's own. Found by the every-page structure pin the
  moment it was ported. The block now starts at h2.

#### Added

- The every-page structure pin: every non-admin page serves exactly one `<h1>`
  to a generic client (comments stripped first), no duplicate llms.txt links
  in the prerender footer, and home carries the root link exactly once.
- Four healthz contract tests, the fence-awareness test, and the noscript pin.
- The Dockerfile's cache-semantics block, plus `curl` and a `$PORT`-aware
  HEALTHCHECK.

### Site — round-2 pass (2026-08-22)

Follow-ups from the gate-wave review, an outside SEO audit, and the
dash-improve-my-llms 2.6.1 pickup.

#### Fixed

- **The home link had no accessible name on phones.** Below 576px the
  wordmark is `display: none` (that is what `visibleFrom` compiles to), and a
  display:none subtree is removed from the accessibility tree entirely — so
  with a decorative logo beside it the branded header link announced as just
  "link", with nothing saying where it goes. The gate-wave report claimed
  `visibleFrom` "stays in the accessibility tree"; **that was wrong**, and the
  report has been corrected. The anchor now carries a permanent `aria-label`
  and the logo is explicitly decorative (`alt=""`) so the two do not compete
  for the accessible name — template 1.6.6's pattern.
- **The meta description was 270 characters.** Google truncates around
  155-160 and, for a description it judges unrepresentative, rewrites the
  snippet from page text — so an over-long one forfeits the snippet rather
  than shortening it. Now 153, in `lib/constants.SITE_DESCRIPTION`, with
  index.html's JSON-LD copy kept in step.
- **The meta keywords still described the template.** The tag shipped naming
  Dash Mantine Components, markdown docs and developer tools — the one tag
  that states outright what a site is about never once mentioned Excalidraw.
  Replaced with terms for the component.

#### Changed

- **`lib/network_directory.py` re-copied verbatim from the boilerplate.**
  Template 1.6.5 added `excalidraw.2plot.dev` and `modelviewer.2plot.dev` to
  the canonical list now that both are live. `test_this_host_is_queued_for_registration`
  fired on the re-copy exactly as it was designed to, and is replaced by two
  tests that pin the steady state: the self-filter removes exactly one row
  (which proves the spelling), and this host's row exists at the source
  (which a healthy self-filter would otherwise hide).
- **dimll floor 2.6.0 → 2.6.1**, in requirements (including the commented
  backend extras), `run.py`'s boot floor and its message, and CI's two
  version fingerprints. 2.6.1 makes the universal prerender visible to non-JS
  consumers: below it the injected block carries a literal `hidden`
  attribute, so every visibility-respecting reader saw "Loading..." and
  nothing else. A `>=2.6.0` build resolves 2.6.1 today, but that is
  resolution luck rather than a guarantee — the floor is stated where the
  guarantee is. Verified by downgrading: on 2.6.0 the new generic-lane test
  fails on the `hidden` attribute and the boot floor refuses to start.

### Site — gate-wave pass (2026-08-21)

The 2plot network's gate/reporter/SEO sync, from
dash-documentation-boilerplate 1.6.4, plus the deployment groundwork this
host had never had. **The documentation site is not affected in anything a
visitor can see: it ships DARK** (`PAGE_DEFAULT_TIER=public`), which means
the gate plumbing goes live and changes nothing. Everything here is the site
under `run.py`; the `dash_excalidraw` package is untouched apart from one
metadata fix.

#### Fixed — mobile

- **The home page scrolled sideways on phones.** The prop-mapping table is
  min-content sized, so with `overflow: visible` it burst out of its column
  and dragged the document with it: measured at 414px, the table rendered
  471px inside a 318px container and the page scrolled 105px horizontally, so
  every paragraph could be swiped off-screen. Markdown tables now scroll
  inside their own box (`display:block` + `width:max-content` +
  `max-width:100%` + `overflow-x:auto`) — a no-op above the container width,
  so desktop is unchanged. `dmc.Table` (which /file-uploads renders, and
  which has its own scroll container) is explicitly excluded.
- **/ui-options scrolled sideways too**, by 15px: a Switch labelled
  `canvasActions.changeViewBackgroundColor` — a dotted identifier with no
  break opportunity — rendered 309px wide in a 250px box. Control labels in
  the docs bodies now use `overflow-wrap: anywhere`.
- **All fifteen pages now measure zero horizontal overflow at 414px.**
- **Three stylesheet rules targeted Mantine's internal hashed class names**
  (`.m_46b77525`, `.m_5caae85b`, `.m_9cdde9a`). Those hashes are private,
  version-unstable build artifacts. Audited against DMC 2.8: `.m_5caae85b`
  had already gone dead (zero occurrences in the bundle), and `.m_9cdde9a`
  was putting a stray `margin-top: 15px` on the AppShell aside while
  re-declaring width, transform and z-index that Mantine already sets. The
  header-Select rule was rewritten against a stable selector; the other two
  are gone. This is the failure 2plot_leaflet recorded after a `.m_b8a05bbd`
  rule — the Drawer content — silently overrode its drawer's docking styles
  and left that host's mobile navigation floating. A test now fails on any
  `.m_*` selector in any stylesheet.


- **The header wordmark overflowed the row on phones.** `dash-excalidraw` is
  a long mark to sit beside a burger, a 36px logo, a search control and the
  theme toggle. It is now `visibleFrom="xs"` — hidden below 576px (36em),
  which covers every phone in portrait. Measured at the boundary: hidden at
  360/414/575px, shown from 576px. `visibleFrom` renders a CSS class rather
  than dropping the component, so the element stays in the DOM: the site's
  name is still in the accessibility tree via the logo's `alt`, and
  `assets/text_animation.js` still finds `#dash-docs-title` to type into.

#### Fixed — the first CI run (round 2)

The first run of the rewritten workflows lost nine checks. Every one was in
the CI configuration; none was in the site. The four pytest legs, lint, and
the Docker build-boot-battery job all passed first time.

- **`scripts/smoke_test.py` did not exist** — six `docs-compat` legs called
  it. Same class as `check_release.py`: the job and its script were both
  leaflet's. Ported; it node-syntax-checks all 23 inline clientside callbacks
  and all four asset JS files, which no pytest can reach.
- **The wheel's packaging-leak check was fooled by its own working
  directory.** `importlib.util.find_spec("lib")` ran in a heredoc whose cwd
  was the repo root, and Python puts the cwd on `sys.path` for a stdin
  script — so it found the repo's own directories and failed on a clean
  wheel. Now a `zipfile` scan of the artifact, plus a clean-venv import run
  from `/tmp`.
- **The docs-compat matrix tested Dash 4.1.0, which this site cannot run
  on.** `run.py` passes `backend=` to the Dash constructor and 4.1.0 rejects
  it during construction. Measured: 4.1.0 cannot boot; 4.2.0, 4.3.0 and
  4.4.1 each pass 48/48. The floor is 4.2.0 and the matrix now says so.
- **`requirements.txt` never carried the `# COMPAT-MATRIX: dash` marker** the
  matrix strips, so the pin was silently reinstated on every leg — a green
  matrix testing one Dash version three times.
- **A failed CI run produced a misleading production failure.** With `deploy`
  skipped, `verify the live site` still ran and failed against production.
  Now gated on the deploy having actually succeeded.

`tests/test_config.py` gains three tests for the class rather than the
instances: every script a workflow runs must exist, no workflow may name
another repo's artifacts in live YAML, and the dash pin must keep its
matrix marker.

#### Fixed — things that would have broken the first deploy

- **The site was on a branch Render does not deploy.** `main` held the
  initial component commit and nothing else; the entire documentation site
  lived on `feat/excalidraw-0.18.1`. `main` is now fast-forwarded onto it —
  no commit rewritten, squashed or dropped.
- **`.github/workflows/cd.yml` deployed and smoke-tested
  `leaflet.2plot.dev`.** It was a copy of that repo's file and had never
  run. Rewritten from the template, which also carries the fix for CD
  verifying the *previous* release whenever the deploy hook is unset — the
  fleet's default configuration.
- **`.github/workflows/ci.yml` and `release.yml` were also leaflet's**:
  package jobs importing `dash_leaflet2`, an image tagged
  `dash-leaflet2-docs`, flake8 pointed at a `usage.py` this repo lacks, a
  missing `scripts/check_release.py`, and `dash-leaflet2` as the PyPI
  trusted-publishing target.
- **The Dockerfile bound a hardcoded port 8550**, which Render (which
  assigns `$PORT`) cannot health-check, and installed nodejs + npm to build
  a bundle that is committed. Rewritten to the fleet shape: python 3.12-slim,
  binds `$PORT`, `WEB_CONCURRENCY`, a 120s timeout for the model calls, no
  Node.
- **`.env.example` documented `CLERK_SATELLITE_DOMAIN=excalidraw.2plot.dev`.**
  Every `*.2plot.dev` docs host is an allowed *subdomain* of the one
  `2plot.dev` satellite; the per-host spelling resolves to NXDOMAIN and hangs
  sign-in with no error.

#### Fixed — SEO and identity regressions

- `twitter:card` had been deleted from `templates/index.html`. Dash declares
  it with `property=` and Twitter's parser reads only `name=`, so no scraper
  could see a card type at all. Restored.
- Three icon links (96/192/512) had been dropped from the head, so it could
  not agree with the crawler head. Restored.
- `msapplication-TileColor` still carried the template's teal against a
  violet `theme-color`.
- The noscript block advertised `/getting-started/llms.txt`, a template page
  this site does not have.

#### Added

- The interactive gate, **dark**: `lib/gate_layouts.py`, `lib/access.py`'s
  two-axis resolution, `lib/page_visibility.py` and the `/admin/control-board`
  page, `lib/agent_key.py` (`/api/agent-key`), `assets/auth_gate.*`.
- `configure_seo` with this site's own seven-icon set, social card and
  publisher/`sameAs`; `SoftwareApplication` on the home page; and truthful
  `lastmod` on all fourteen docs, taken from each file's real last-commit
  date via `git log` — never invented, never from a file mtime.
- `lib/versions.py`, so prose can write `{{VERSION:<dist>}}` instead of a
  number that goes stale.
- `scripts/check_release.py` and `scripts/make_favicons.py`.
- `DEPLOY-READINESS.md` (the owner-side deploy checklist) and
  `X402-SYNC-REPORT.md` (what this pass did, measured).

#### Changed

- **Vendored `dash-clerk-auth` 1.0.0 → 1.0.5**, from the hook repo's `dist/`
  and admitted only on sha256
  `a2f9062e…b74f3`. 1.0.5 is the release that reconciles the *return trip*:
  landing back on a gated page after signing in on the primary used to show
  the gate card until a manual refresh.
- **Security floors**: `clerk-backend-api>=7.0.0,<8` and
  `cryptography>=50.0.0`. The old SDK cap held `cryptography` below the fixes
  for GHSA-537c-gmf6-5ccf and PYSEC-2026-3552/3553/3554; all four now audit
  clean.
- Floors raised: `dash-improve-my-llms[flask]>=2.6.0` (honest sitemap
  `lastmod`, icon autodiscovery), `dash-mantine-components>=2.8.0` (below it
  the mobile drawer renders as a floating card), `plotly>=6.9.0`.
- `lib/satellite_reporter.py` is now **byte-identical** to the template's —
  which is why `run.py` claims `SATELLITE_APP_KEY=excalidraw` via
  `os.environ.setdefault` before any hub-facing import. Without that, an
  unset variable would file this site's traffic under the template's row.
- `/healthz` reports `build` (the running instance's commit) alongside the
  existing `app`, so CD can verify the artifact it shipped.
- `render.yaml` fully authored for Docker + the 1 GB `/var/data` disk, and
  deliberately declares **no** env-group-owned variable — a service-level
  copy would shadow the group and mask future edits.
- `dash_excalidraw/package-info.json` advertised
  `@excalidraw/excalidraw ^0.17.6` while the bundle is built from 0.18.1.


### Security

- **Repository history starts clean.** The project had never been committed to
  any git repository. Before the first commit, the live `ANTHROPIC_API_KEY` and
  `GEMINI_API_KEY` that lived in the untracked `.env` were rotated at their
  providers, `.env.example` was authored with names only, and the first commit
  was gated on three checks: `.env` absent from the staged set, a secret-pattern
  scan over every staged file, and a review of the staged tree for build
  directories. No key has ever entered this history.

- **`dash_excalidraw/dash_excalidraw.js` contains a `AIzaSy…` string, and that
  is expected.** Secret scanners pattern-match it as a Google API key. It starts
  `AIzaSyAd15pYlMci_` (truncated here on purpose, so this file does not itself
  become a second scanner finding) and appears three times inside the
  `VITE_APP_FIREBASE_CONFIG` literal that upstream Excalidraw bakes into its own
  published `dist/` (`authDomain: excalidraw-room-persistence.firebaseapp.com`).
  It belongs to the Excalidraw project, not to this one, and a Firebase **Web**
  API key is a public client-side identifier by design — access is enforced by
  Firebase Security Rules, not by keeping the key secret. It is bundled verbatim
  by webpack from `node_modules/@excalidraw/excalidraw/dist/`, so any finding
  against it is **dismissed deliberately** as a third-party public identifier.
  **Do not re-litigate this**, and do not "fix" it by stripping the string —
  that would fork upstream's bundle. Note that upstream's *development* builds
  carry a second such key (`AIzaSyCMkxA60XIW8Kbq…`); webpack's production build
  does not include it.

  Measured on the first push (2026-08-08): GitHub push protection did **not**
  block it. Treat that as unverified rather than as proof the string is
  ignorable — enable Secret scanning + Push protection under Settings →
  Advanced Security, dismiss the resulting alert as a false positive, and expect
  a future bundle-touching push to need an explicit bypass.

### Changed

- `.gitignore`: `.claude/` and `.idea/` are now ignored wholesale; the built bundle
  `dash_excalidraw/dash_excalidraw.js` is now **tracked** rather than ignored
  (the release gate reads its commit timestamp, and tracking keeps
  `pip install git+…` working without npm); `dash_excalidraw/metadata.json`,
  a `dash-generate-components` byproduct that must not ship in the wheel, is
  now ignored.

### Added

- `.env.example` documenting `ANTHROPIC_API_KEY`, `GEMINI_API_KEY` and the
  `GOOGLE_API_KEY` fallback, read out of `pages/ai_agent.py` rather than out of
  `.env`. Nothing in the showcase requires a key; every AI provider degrades to
  a disabled state when its key is absent.

---

<!-- /collaboration — https://excalidraw.2plot.dev/collaboration/llms.txt -->

# Collaboration

> Drive the collaborator UI and live cursors from Python — the wrapper exposes the knobs, you bring the transport.

---



### Overview

The wrapper does not ship a transport layer, but it exposes the knobs:
`isCollaborating` toggles the collaborator UI, and `appState.collaborators`
can be driven via the updateScene command.

### Live demo


### Source


```python
# File: docs/collaboration/collaboration.py

"""Collaboration: isCollaborating + appState.collaborators scaffolding."""

import uuid

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("collaboration-canvas")

CODE = """
DashExcalidraw(
    id='canvas',
    isCollaborating=True,
)

# Feed collaborators via an updateScene command:
{'id': ..., 'type': 'updateScene',
 'payload': {
     'collaborators': {
         'user-1': {
             'username': 'Alice',
             'color': {'background': '#ff6b6b', 'stroke': '#c92a2a'},
             'pointer': {'x': 300, 'y': 240, 'tool': 'laser'},
         },
     },
 }}
"""


component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="blue",
            variant="light",
            children="This example only simulates a single fake cursor from a timer — "
            "hook it up to a real backend (Socket.IO, Firestore, Liveblocks, ...) "
            "to make it collaborative.",
        ),
        code_block(CODE),
        dmc.Group(
            [
                dmc.Switch(
                    id="collab-switch",
                    checked=True,
                    label="isCollaborating",
                    size="md",
                ),
                dmc.Button(
                    "Simulate Alice's cursor",
                    id="collab-simulate-btn",
                    variant="light",
                ),
            ]
        ),
        canvas_frame(
            DashExcalidraw(
                id="collaboration-canvas",
                height="560px",
                isCollaborating=True,
            )
        ),
    ],
)


@callback(
    Output("collaboration-canvas", "isCollaborating"),
    Input("collab-switch", "checked"),
)
def _toggle(checked):
    return bool(checked)


@callback(
    Output("collaboration-canvas", "command"),
    Input("collab-simulate-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _simulate(n):
    x = 200 + (n or 0) * 40
    return {
        "id": f"collab-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {
            "appState": {
                "collaborators": {
                    "user-alice": {
                        "username": "Alice",
                        "color": {
                            "background": "#ff6b6b",
                            "stroke": "#c92a2a",
                        },
                        "pointer": {"x": x, "y": 260, "tool": "laser"},
                    }
                }
            }
        },
    }
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /collaboration*

---

<!-- /commands — https://excalidraw.2plot.dev/commands/llms.txt -->

# Command dispatch

> Call Excalidraw's imperative API from Python through a JSON-safe command prop dispatched once per id.

---



### Overview

Every imperative API the upstream Excalidraw exposes is callable from Python
as a `command` prop: a dict with `id`, `type`, and `payload`. The component
dispatches once per unique id, then clears the prop so React re-renders don't
re-fire.

### The twelve command types

| `type` | Payload | Notes |
|:-------|:--------|:------|
| `updateScene` | `{elements?, appState?, collaborators?, captureUpdate?}` | See history, below |
| `addFiles` | A **list** of `{id, mimeType, dataURL, created}` | Raw passthrough |
| `replaceFiles` | A **map** `{fileId: {dataURL, mimeType?}}` | Stores the new bytes under a fresh id and repoints the elements |
| `resetScene` | `{}` | |
| `scrollToContent` | `{target?, opts?}` | |
| `setActiveTool` | `{type: "selection"}`, `{type: "rectangle"}`, … | Defaults to `selection` |
| `setToast` | `{message, duration?}`, or `None` to clear | |
| `toggleSidebar` | `{name, tab?, force?}` | `name` is the sidebar — Excalidraw's built-in one is `"default"`; `"library"` and `"search"` are `tab`s inside it. Naming a tab as the sidebar does nothing at all. |
| `updateLibrary` | `{libraryItems, merge?}` | |
| `exportToSvg` | Export options | Replies on `lastExport` |
| `exportToBlob` | Export options, `{mimeType?}` | Replies on `lastExport` |
| `exportToCanvas` | Export options, `{mimeType?}` | Replies on `lastExport` |

`addFiles` and `replaceFiles` both reach Excalidraw's `addFiles`, but they
are not interchangeable. Use `replaceFiles` to swap an existing file's bytes
— it takes the id-keyed map, fills `created` and `mimeType` for you, and
skips malformed entries; that is the shape [File uploads](/file-uploads)
uses to trade base64 for URLs.

**It does not overwrite, because Excalidraw cannot.** `api.addFiles` with an
id the store already holds is a silent no-op — measured in a browser: add `x`
inline, add `x` again with a URL, read `getFiles()`, and it is still the
inline one. There is no `removeFiles`, and `updateScene({files})` is ignored.
So `replaceFiles` stores the new bytes under a **new** id and repoints every
element that referenced the old one. The orphaned entry cannot be deleted, but
nothing references it and `externalizedSerializedData` strips it anyway.

Until 2026-09-12 this command claimed the overwrite and silently did nothing,
which is why /file-uploads' GIF auto-embed never fired: that page waits for a
file whose `dataURL` has become a URL, and there never was one. Use `addFiles` only when you need the raw
Excalidraw `BinaryFileData` list, including fields `replaceFiles` would
drop. It is a straight passthrough, so the list you send is the list
Excalidraw receives: **you supply `created` and `mimeType` yourself**, and an
entry missing either is not an error — Excalidraw simply ignores it, so a
malformed `addFiles` payload is a silent no-op rather than a visible failure.

```python
# replaceFiles — id-keyed map; created/mimeType filled for you
{"id": cmd_id, "type": "replaceFiles",
 "payload": {file_id: {"dataURL": "https://cdn.example.com/a.png"}}}

# addFiles — raw BinaryFileData list; every field is yours to supply
{"id": cmd_id, "type": "addFiles",
 "payload": [{"id": file_id, "mimeType": "image/png",
              "dataURL": "data:image/png;base64,…",
              "created": 1757376000000}]}
```

### Scene pushes and undo history

Excalidraw `0.18` replaced `commitToHistory` with `captureUpdate`, **and
changed what the default means**. `0.17` left undo history untouched when no
flag was given; `0.18` defaults to folding a programmatic push into the
*next* captured action, so a user's first Ctrl+Z after your `updateScene`
would also roll back their own previous edit. Nothing errors and nothing
warns.

This wrapper therefore defaults `captureUpdate` to `IMMEDIATELY`: a
dispatched scene push is a deliberate edit and undoes as one discrete step.
Override it per command:

```python
{
    "id": str(uuid.uuid4()),
    "type": "updateScene",
    "payload": {
        "elements": elements,
        "captureUpdate": "NEVER",   # or "IMMEDIATELY" (default), "EVENTUALLY"
    },
}
```

| `captureUpdate` | Effect on undo history |
|:----------------|:-----------------------|
| `IMMEDIATELY` | *(default)* The push is its own undo step |
| `NEVER` | The push is not recorded; Ctrl+Z skips past it |
| `EVENTUALLY` | Folded into the next captured action — Excalidraw's own `0.18` default |

Two guardrails worth knowing. Passing the removed `commitToHistory` still
works: it is translated (`True` → `IMMEDIATELY`, `False` → `NEVER`) and logs
a one-time console warning telling you to switch. And an unrecognised
`captureUpdate` value is **not** accepted silently — it warns and falls back
to `IMMEDIATELY`, because a typo that looked like it took effect is the
worse failure.

### Live demo


### Source


```python
# File: docs/commands/commands.py

"""Command dispatch: drive Excalidraw imperatively from Python."""

from __future__ import annotations

import time
import uuid

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("commands-canvas")

CODE = """
@callback(
    Output('canvas', 'command'),
    Input('btn-rect', 'n_clicks'),
    prevent_initial_call=True,
)
def pick_rectangle(_):
    return {
        'id': str(uuid.uuid4()),
        'type': 'setActiveTool',
        'payload': {'type': 'rectangle'},
    }
"""


def _btn(btn_id: str, label: str, color: str = "indigo") -> dmc.Button:
    return dmc.Button(label, id=btn_id, color=color, variant="light", size="sm")


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dmc.Paper(
            withBorder=True,
            p="md",
            radius="md",
            children=dmc.Stack(
                gap="sm",
                children=[
                    dmc.Text("Scene", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-update", "updateScene (seed)"),
                            _btn("cmd-reset", "resetScene", color="red"),
                            _btn("cmd-scrollto", "scrollToContent"),
                        ]
                    ),
                    dmc.Text("Tools", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-tool-rect", "setActiveTool: rectangle"),
                            _btn("cmd-tool-arrow", "setActiveTool: arrow"),
                            _btn("cmd-tool-select", "setActiveTool: selection"),
                        ]
                    ),
                    dmc.Text("UI", size="sm", fw=600, c="dimmed"),
                    dmc.Group(
                        [
                            _btn("cmd-toast", "setToast"),
                            _btn("cmd-sidebar", "toggleSidebar"),
                        ]
                    ),
                ],
            ),
        ),
        canvas_frame(
            DashExcalidraw(
                id="commands-canvas",
                height="600px",
            )
        ),
    ],
)


def _cmd(type_: str, payload: dict | None = None) -> dict:
    return {"id": f"{type_}-{uuid.uuid4()}", "type": type_, "payload": payload or {}}


@callback(
    Output("commands-canvas", "command"),
    Input("cmd-update", "n_clicks"),
    Input("cmd-reset", "n_clicks"),
    Input("cmd-scrollto", "n_clicks"),
    Input("cmd-tool-rect", "n_clicks"),
    Input("cmd-tool-arrow", "n_clicks"),
    Input("cmd-tool-select", "n_clicks"),
    Input("cmd-toast", "n_clicks"),
    Input("cmd-sidebar", "n_clicks"),
    prevent_initial_call=True,
)
def _dispatch(update_, reset_, scrollto_, rect_, arrow_, sel_, toast_, sidebar_):
    trigger = dash.ctx.triggered_id
    if trigger == "cmd-update":
        return _cmd(
            "updateScene",
            {
                "elements": [
                    {
                        "id": f"seeded-{int(time.time())}",
                        "type": "ellipse",
                        "x": 200,
                        "y": 200,
                        "width": 300,
                        "height": 180,
                        "angle": 0,
                        "strokeColor": "#e67700",
                        "backgroundColor": "#fff4e6",
                        "fillStyle": "solid",
                        "strokeWidth": 2,
                        "roughness": 1,
                        "opacity": 100,
                        "seed": 42,
                        "version": 1,
                        "versionNonce": 42,
                        "isDeleted": False,
                        "groupIds": [],
                        "frameId": None,
                        "boundElements": [],
                        "updated": 1,
                        "link": None,
                        "locked": False,
                    }
                ]
            },
        )
    if trigger == "cmd-reset":
        return _cmd("resetScene")
    if trigger == "cmd-scrollto":
        return _cmd("scrollToContent", {"opts": {"fitToViewport": True}})
    if trigger == "cmd-tool-rect":
        return _cmd("setActiveTool", {"type": "rectangle"})
    if trigger == "cmd-tool-arrow":
        return _cmd("setActiveTool", {"type": "arrow"})
    if trigger == "cmd-tool-select":
        return _cmd("setActiveTool", {"type": "selection"})
    if trigger == "cmd-toast":
        return _cmd(
            "setToast",
            {"message": "Toast dispatched from Python!", "duration": 2500},
        )
    if trigger == "cmd-sidebar":
        # "default" is the sidebar; "library" is a tab within it. Naming
        # the tab as the sidebar is a silent no-op — see docs/library.
        return _cmd("toggleSidebar", {"name": "default", "tab": "library"})
    return no_update
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /commands*

---

<!-- /coverage — https://excalidraw.2plot.dev/coverage/llms.txt -->

# Coverage

> The three commands and ten props no other example reaches — the leftovers of the prop surface, in one runnable place.

---



### Why this page exists

Every other page here demonstrates a feature. This one covers the leftovers.

The component ships 38 props and 12 command types. Sweeping every example
module for the commands it dispatches — both the `{"type": "X"}` dict form and
the `_cmd("X", …)` helper form — and for every prop reached either as a
constructor keyword or through an `Input`/`Output`/`State`, three commands and
ten props came back untouched. A reader could find them in the component
reference and nowhere else.

That is the gap this page closes. It is not a tutorial; it is the part of the
surface that would otherwise be documented only as a table row.

### The three commands

| `type` | What it does here | How you see it |
|:-------|:------------------|:---------------|
| `addFiles` | Registers one 1×1 PNG as raw `BinaryFileData` | The readout's *registered file ids* grows |
| `exportToCanvas` | Exports the scene through a canvas element | The preview appears, tagged with the dispatched `id` |
| `updateLibrary` | Merges a two-rectangle item into the library | The library panel opens with the item in it |

`addFiles` registers **bytes**, not a visible element. Excalidraw draws an
image only when a scene element references the file id, so the file appearing
in the readout while the canvas stays empty is correct, not a failure — see
[File uploads](/file-uploads) for the flow that pairs the two.

`exportToCanvas` returns a `data:` URL, where `exportToSvg` returns markup.
Both land on `lastExport` carrying the id you dispatched, which is what lets
you tell two in-flight exports apart. [Export round-trip](/export) covers the
correlation.

### The ten props

| Prop | Default | What it does | Live? |
|:-----|:--------|:-------------|:------|
| `langCode` | `"en"` | Excalidraw's UI language | Yes |
| `name` | *(unset)* | Scene name, used by the export dialog | Yes |
| `width` | `"100%"` | CSS width of the container | Yes |
| `detectScroll` | `True` | Canvas handles wheel events | Yes |
| `handleKeyboardGlobally` | `True` | Key handling on `document`, not the canvas | Yes |
| `hideExcalidrawLinks` | `True` | Hides the vendor's links group | Yes |
| `docsLinkUrl` | `"https://excalidraw.2plot.dev"` | Our own menu item's URL; `""` renders none | Yes |
| `docsLinkLabel` | `"dash-excalidraw docs"` | That item's visible label | Yes |
| `autoFocus` | `True` | Focus the canvas on mount | Mount only |
| `libraryReturnUrl` | *(unset)* | Where the Browse Library trip returns to | Mount only, in effect |
| `appState` | — | Full serializable app state | Read-only |
| `sceneVersion` | — | Monotonic scene counter | Read-only |

`autoFocus` is read by Excalidraw when the canvas mounts. A switch for it
would be theatre — this page sets it to `False` and that takes effect on page
load, nowhere else. Reload to change it.

`appState` and `sceneVersion` are written *out* by the component. Setting
them from Python does nothing; the readout on the right is what they are for.
`sceneVersion` is a single integer and far cheaper to compare than diffing
`elements`, which is the reason it exists.

### The main menu is composed here, not by Excalidraw

The wrapper renders its own `MainMenu` rather than letting Excalidraw render
the default one. It mirrors the vendor's `DefaultMainMenu` item for item and
in the same order — Load scene, Save to file, Export, Save as image, Find,
Help, Reset canvas, then the links, then Theme and Canvas background.

It does that because there is no other supported way to control the links
group: the vendor's `Socials` item is composed at a single site inside
Excalidraw and no `UIOptions` key reaches it. Two consequences worth knowing:

- **Each canvas decides for itself.** `hideExcalidrawLinks` is a render
  condition on this canvas's own menu, so it works in both directions and two
  canvases on one page can disagree. It was one-way until R5 — a shared
  stylesheet that went in and never came out — and this page said so.
- **Two switches are ours to keep in step.** Most default items decide their
  own visibility, but `Export` and `Save as image` are gated by Excalidraw
  where the menu is composed. Composing our own means replicating exactly
  those two, so `UIOptions.canvasActions.export` and `.saveAsImage` still
  work. A test in the JS harness reads the vendor's own composition and fails
  if a version bump changes the item set, rather than letting our copy quietly
  become a copy of an older menu.

| State | Vendor links group | Our docs item |
|:------|:-------------------|:--------------|
| Default | hidden | shown |
| `hideExcalidrawLinks=False` | shown | shown |
| `docsLinkUrl=""` | hidden | none |

`docsLinkLabel` exists so that re-pointing the URL does not leave a menu entry
naming one destination and opening another. If you send `docsLinkUrl` to your
own site, relabel it.

### What still points at Excalidraw

Being honest about the residue: the menu is not the only place the vendor
links out, and the rest is deliberately left alone.

- **The Help dialog** links to `docs.excalidraw.com`, `plus.excalidraw.com/blog`,
  the Excalidraw GitHub issues tracker and their YouTube channel.
- **Two error dialogs** link to the vendor's FAQ and to "open an issue".

That is Excalidraw's manual for Excalidraw's canvas, and a wrapper that
rewrote it would be sending people to the wrong project for help with the
thing that is actually failing. Out of scope on purpose.

### Live demo


### Source


```python
# File: docs/coverage/coverage.py

"""Coverage: the three commands and ten props no other example page reaches.

Measured, not guessed — an AST sweep of every `docs/*/*.py` for command types
(both the `{"type": "X"}` dict literal and the `_cmd("X", ...)` helper form)
and for props reached via a constructor kwarg or an Input/Output/State
dependency. What was left over is what this page exists to exercise.
"""

import time
import uuid

import dash_mantine_components as dmc
from dash import Input, Output, State, callback, html, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, json_panel, sync_canvas_theme, two_column

sync_canvas_theme("coverage-canvas")

# A real 1x1 PNG. Small enough to read inline, valid enough that Excalidraw
# accepts it as BinaryFileData rather than silently dropping the entry.
PNG_1PX = (
    "data:image/png;base64,"
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8"
    "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)

CODE = """
# The three commands no other page dispatches.

# addFiles — RAW BinaryFileData list. You supply `created` and `mimeType`;
# an entry missing either is ignored, so a bad payload is a silent no-op.
{'id': cmd_id, 'type': 'addFiles',
 'payload': [{'id': file_id, 'mimeType': 'image/png',
              'dataURL': PNG_1PX, 'created': int(time.time() * 1000)}]}

# exportToCanvas — async; the result arrives on `lastExport`, carrying the
# id you dispatched. A data: URL, not an SVG string.
{'id': cmd_id, 'type': 'exportToCanvas', 'payload': {'mimeType': 'image/png'}}

# updateLibrary — merge items into the user's library.
{'id': cmd_id, 'type': 'updateLibrary',
 'payload': {'libraryItems': [item], 'merge': True, 'openLibraryMenu': True}}
"""


def _rect(x: int, y: int, colour: str, bg: str) -> dict:
    now = int(time.time())
    return {
        "id": f"cov-{uuid.uuid4()}",
        "type": "rectangle",
        "x": x,
        "y": y,
        "width": 120,
        "height": 80,
        "angle": 0,
        "strokeColor": colour,
        "backgroundColor": bg,
        "fillStyle": "solid",
        "strokeWidth": 2,
        "roughness": 1,
        "opacity": 100,
        "seed": now % 100000,
        "version": 1,
        "versionNonce": now % 100000,
        "isDeleted": False,
        "groupIds": [],
        "frameId": None,
        "boundElements": [],
        "updated": 1,
        "link": None,
        "locked": False,
    }


def _btn(btn_id: str, label: str, colour: str) -> dmc.Button:
    return dmc.Button(label, id=btn_id, color=colour, size="sm", variant="light")


LANGS = ["en", "fr-FR", "de-DE", "zh-CN", "es-ES"]

controls = dmc.Paper(
    withBorder=True,
    p="md",
    radius="md",
    children=dmc.Stack(
        gap="sm",
        children=[
            dmc.Text("Props no other page sets", size="sm", fw=600, c="dimmed"),
            dmc.SimpleGrid(
                cols={"base": 1, "sm": 2, "md": 3},
                spacing="sm",
                children=[
                    dmc.Select(
                        id="cov-lang",
                        label="langCode",
                        description="Excalidraw UI language",
                        data=LANGS,
                        value="en",
                        size="xs",
                    ),
                    dmc.Select(
                        id="cov-name",
                        label="name",
                        description="Scene name in the export dialog",
                        data=["coverage-scene", "renamed-scene"],
                        value="coverage-scene",
                        size="xs",
                    ),
                    dmc.Select(
                        id="cov-width",
                        label="width",
                        description="CSS width of the container",
                        data=["100%", "75%", "50%"],
                        value="100%",
                        size="xs",
                    ),
                    dmc.Switch(
                        id="cov-detectscroll",
                        label="detectScroll",
                        description="Canvas handles wheel events",
                        checked=True,
                        size="sm",
                    ),
                    dmc.Switch(
                        id="cov-keyboard",
                        label="handleKeyboardGlobally",
                        description="Key handling on document, not canvas",
                        checked=True,
                        size="sm",
                    ),
                    dmc.Switch(
                        id="cov-hidelinks",
                        label="hideExcalidrawLinks",
                        description="Hides the GitHub/Discord/X menu group",
                        checked=True,
                        size="sm",
                    ),
                ],
            ),
        ],
    ),
)

component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="blue",
            variant="light",
            title="What this page is for",
            children=(
                "Every other example page demonstrates a feature. This one "
                "exists to cover the leftovers: the three command types and "
                "ten props that no other page reaches. If a prop is listed "
                "in the component reference but you cannot find it exercised "
                "anywhere, it is here."
            ),
        ),
        code_block(CODE),
        dmc.Group(
            gap="sm",
            children=[
                _btn("cov-addfiles", "addFiles", "grape"),
                _btn("cov-exportcanvas", "exportToCanvas", "teal"),
                _btn("cov-updatelibrary", "updateLibrary", "indigo"),
            ],
        ),
        controls,
        two_column(
            canvas_frame(
                DashExcalidraw(
                    id="coverage-canvas",
                    height="520px",
                    width="100%",
                    name="coverage-scene",
                    langCode="en",
                    detectScroll=True,
                    handleKeyboardGlobally=True,
                    hideExcalidrawLinks=True,
                    # autoFocus is read by Excalidraw at MOUNT only, so a
                    # switch for it would be theatre — it is set here and
                    # takes effect on page load, nowhere else.
                    autoFocus=False,
                    # Only observable by taking the Browse Library round trip;
                    # it is where that trip returns to.
                    libraryReturnUrl="https://excalidraw.2plot.dev/coverage",
                )
            ),
            dmc.Stack(
                gap="sm",
                children=[
                    html.Div(id="cov-readout"),
                    html.Div(id="cov-export-out"),
                ],
            ),
        ),
    ],
)


@callback(
    Output("coverage-canvas", "langCode"),
    Output("coverage-canvas", "name"),
    Output("coverage-canvas", "width"),
    Output("coverage-canvas", "detectScroll"),
    Output("coverage-canvas", "handleKeyboardGlobally"),
    Output("coverage-canvas", "hideExcalidrawLinks"),
    Input("cov-lang", "value"),
    Input("cov-name", "value"),
    Input("cov-width", "value"),
    Input("cov-detectscroll", "checked"),
    Input("cov-keyboard", "checked"),
    Input("cov-hidelinks", "checked"),
)
def _drive_props(lang, name, width, detect, keyboard, hidelinks):
    return lang, name, width, bool(detect), bool(keyboard), bool(hidelinks)


@callback(
    Output("coverage-canvas", "command"),
    Input("cov-addfiles", "n_clicks"),
    Input("cov-exportcanvas", "n_clicks"),
    Input("cov-updatelibrary", "n_clicks"),
    prevent_initial_call=True,
)
def _dispatch(_add, _export, _library):
    import dash

    trigger = dash.ctx.triggered_id
    cmd_id = str(uuid.uuid4())

    if trigger == "cov-addfiles":
        return {
            "id": cmd_id,
            "type": "addFiles",
            "payload": [
                {
                    "id": f"cov-file-{cmd_id[:8]}",
                    "mimeType": "image/png",
                    "dataURL": PNG_1PX,
                    "created": int(time.time() * 1000),
                }
            ],
        }

    if trigger == "cov-exportcanvas":
        return {
            "id": cmd_id,
            "type": "exportToCanvas",
            "payload": {"mimeType": "image/png"},
        }

    if trigger == "cov-updatelibrary":
        return {
            "id": cmd_id,
            "type": "updateLibrary",
            "payload": {
                "libraryItems": [
                    {
                        "id": f"cov-lib-{cmd_id[:8]}",
                        "status": "unpublished",
                        "created": int(time.time() * 1000),
                        "elements": [
                            _rect(100, 100, "#5f3dc4", "#d0bfff"),
                            _rect(240, 100, "#0b7285", "#99e9f2"),
                        ],
                    }
                ],
                "merge": True,
                "openLibraryMenu": True,
            },
        }

    return no_update


# Three INPUTS, not one input and two states, and the distinction is the whole
# reason this panel looked dead. `sceneVersion` tracks ELEMENTS: registering a
# file does not change it, and neither does panning or zooming. So with it as
# the only Input, clicking addFiles updated nothing — the command had worked
# and the panel simply never re-ran — and appState's zoom and scroll could
# never appear at all, because a State is only read when some Input fires.
@callback(
    Output("cov-readout", "children"),
    Input("coverage-canvas", "sceneVersion"),
    Input("coverage-canvas", "files"),
    Input("coverage-canvas", "appState"),
)
def _readout(scene_version, files, app_state):
    state = app_state or {}
    return json_panel(
        "sceneVersion + appState (both read-only)",
        {
            "sceneVersion": scene_version,
            "registered file ids": sorted((files or {}).keys()),
            "appState.zoom": (state.get("zoom") or {}).get("value"),
            "appState.scrollX": state.get("scrollX"),
            "appState.scrollY": state.get("scrollY"),
            "appState.viewBackgroundColor": state.get("viewBackgroundColor"),
            "appState.activeTool": (state.get("activeTool") or {}).get("type"),
            # The `name` prop lands here. Without this row the page offered a
            # control for `name` and no way to observe it — its only other
            # surface is the export dialog's filename, which you have to open
            # a modal to see. A demo you cannot watch is indistinguishable
            # from one that does not work.
            "appState.name (the `name` prop)": state.get("name"),
        },
        height=200,
    )


@callback(
    Output("cov-export-out", "children"),
    Input("coverage-canvas", "lastExport"),
    prevent_initial_call=True,
)
def _show_export(export):
    if not export:
        return no_update
    if export.get("error"):
        return dmc.Alert(
            f"{export['type']} (id {export['id']}) failed: {export['error']}",
            color="red",
            variant="light",
        )
    result = export.get("result") or ""
    return dmc.Stack(
        gap="xs",
        children=[
            dmc.Text(
                f"lastExport carries id {export.get('id')} for {export.get('type')}",
                size="sm",
                fw=600,
            ),
            dmc.Image(src=result, h=120, fit="contain")
            if result.startswith("data:image")
            else dmc.Code(result[:200], block=True),
        ],
    )
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /coverage*

---

<!-- /events — https://excalidraw.2plot.dev/events/llms.txt -->

# Events

> Every Excalidraw callback surfaced as a timestamped snapshot prop you can read from a Dash callback.

---



### Overview

Every Excalidraw callback is surfaced as a setProps-written snapshot prop on
the Python side. Interact with the canvas and watch the panels update.

### Live demo


### Source


```python
# File: docs/events/events.py

"""Events: observe pointer/scroll/paste/library snapshots."""

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import (
    canvas_frame,
    code_block,
    json_panel,
    sync_canvas_theme,
    two_column,
)

sync_canvas_theme("events-canvas")

CODE = """
@callback(
    Output('pointer-out', 'children'),
    Input('canvas', 'lastPointerMove'),
)
def show_pointer(snapshot):
    return json.dumps(snapshot) if snapshot else '(waiting)'
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        two_column(
            canvas_frame(
                DashExcalidraw(
                    id="events-canvas",
                    height="560px",
                    pointerMoveThrottleMs=75,
                    scrollThrottleMs=150,
                ),
                min_height=560,
            ),
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Tabs(
                        value="pointer",
                        children=[
                            dmc.TabsList(
                                [
                                    dmc.TabsTab("Pointer", value="pointer"),
                                    dmc.TabsTab("Scroll", value="scroll"),
                                    dmc.TabsTab("Paste", value="paste"),
                                    dmc.TabsTab("Links", value="links"),
                                ]
                            ),
                            dmc.TabsPanel(
                                value="pointer",
                                pt="sm",
                                children=dmc.Stack(
                                    gap="xs",
                                    children=[
                                        dmc.Text(
                                            "lastPointerDown / Up / Move",
                                            size="sm",
                                            fw=600,
                                        ),
                                        dmc.Paper(id="events-pointer", withBorder=True, p="xs"),
                                    ],
                                ),
                            ),
                            dmc.TabsPanel(
                                value="scroll",
                                pt="sm",
                                children=dmc.Paper(id="events-scroll", withBorder=True, p="xs"),
                            ),
                            dmc.TabsPanel(
                                value="paste",
                                pt="sm",
                                children=dmc.Paper(id="events-paste", withBorder=True, p="xs"),
                            ),
                            dmc.TabsPanel(
                                value="links",
                                pt="sm",
                                children=dmc.Paper(id="events-link", withBorder=True, p="xs"),
                            ),
                        ],
                    ),
                ],
            ),
        ),
    ],
)


@callback(
    Output("events-pointer", "children"),
    Input("events-canvas", "lastPointerDown"),
    Input("events-canvas", "lastPointerUp"),
    Input("events-canvas", "lastPointerMove"),
)
def _render_pointer(down, up, move):
    return dmc.Stack(
        gap="xs",
        children=[
            json_panel("lastPointerDown", down, height=120),
            json_panel("lastPointerUp", up, height=120),
            json_panel("lastPointerMove", move, height=120),
        ],
    )


@callback(
    Output("events-scroll", "children"),
    Input("events-canvas", "lastScrollChange"),
)
def _render_scroll(scroll):
    return json_panel("lastScrollChange", scroll, height=220)


@callback(
    Output("events-paste", "children"),
    Input("events-canvas", "lastPaste"),
)
def _render_paste(paste):
    return json_panel("lastPaste", paste, height=220)


@callback(
    Output("events-link", "children"),
    Input("events-canvas", "lastLinkOpen"),
)
def _render_link(link):
    return json_panel("lastLinkOpen", link, height=220)
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /events*

---

<!-- /export — https://excalidraw.2plot.dev/export/llms.txt -->

# Export round-trip

> Export to SVG, PNG blob or canvas through an async command/lastExport round-trip correlated by id.

---



### Overview

Excalidraw's export utilities are async. The wrapper models the round-trip as
(1) Python writes `command`, (2) JS awaits the export and writes `lastExport`
with the same id, (3) Python reads the result.

### Live demo


### Source


```python
# File: docs/export/export.py

"""Export: drive exportToSvg / exportToBlob / exportToCanvas via command."""

import uuid

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, html, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import (
    canvas_frame,
    code_block,
    sync_canvas_theme,
    two_column,
)

sync_canvas_theme("export-canvas")

CODE = """
# Python -> component: dispatch an export command
return {'id': str(uuid.uuid4()),
        'type': 'exportToSvg',
        'payload': {'exportPadding': 20}}

# component -> Python: observe lastExport and match ids
@callback(Output('preview', 'children'),
          Input('canvas', 'lastExport'))
def preview(result):
    if result and result['type'] == 'exportToSvg':
        return html.Iframe(srcDoc=result['result'])
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        two_column(
            canvas_frame(
                DashExcalidraw(
                    id="export-canvas",
                    height="520px",
                    initialData={
                        "elements": [
                            {
                                "id": "s1",
                                "type": "rectangle",
                                "x": 150,
                                "y": 150,
                                "width": 220,
                                "height": 140,
                                "angle": 0,
                                "strokeColor": "#2f9e44",
                                "backgroundColor": "#d3f9d8",
                                "fillStyle": "solid",
                                "strokeWidth": 2,
                                "roughness": 1,
                                "opacity": 100,
                                "seed": 7,
                                "version": 1,
                                "versionNonce": 7,
                                "isDeleted": False,
                                "groupIds": [],
                                "frameId": None,
                                "boundElements": [],
                                "updated": 1,
                                "link": None,
                                "locked": False,
                            }
                        ],
                        "scrollToContent": True,
                    },
                ),
                min_height=520,
            ),
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Paper(
                        withBorder=True,
                        p="md",
                        radius="md",
                        children=dmc.Stack(
                            gap="sm",
                            children=[
                                dmc.Text("Dispatch", size="sm", fw=600, c="dimmed"),
                                dmc.Group(
                                    [
                                        dmc.Button(
                                            "Export SVG",
                                            id="export-svg-btn",
                                            variant="light",
                                        ),
                                        dmc.Button(
                                            "Export PNG",
                                            id="export-png-btn",
                                            variant="light",
                                            color="teal",
                                        ),
                                    ]
                                ),
                            ],
                        ),
                    ),
                    dmc.Paper(
                        withBorder=True,
                        p="md",
                        radius="md",
                        children=dmc.Stack(
                            gap="sm",
                            children=[
                                dmc.Text("Preview", size="sm", fw=600, c="dimmed"),
                                dmc.Box(id="export-preview", style={"minHeight": 180}),
                            ],
                        ),
                    ),
                ],
            ),
        ),
    ],
)


@callback(
    Output("export-canvas", "command"),
    Input("export-svg-btn", "n_clicks"),
    Input("export-png-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _dispatch_export(_svg, _png):
    trigger = dash.ctx.triggered_id
    if trigger == "export-svg-btn":
        return {
            "id": f"svg-{uuid.uuid4()}",
            "type": "exportToSvg",
            "payload": {"exportPadding": 20},
        }
    if trigger == "export-png-btn":
        return {
            "id": f"png-{uuid.uuid4()}",
            "type": "exportToBlob",
            "payload": {"mimeType": "image/png", "exportPadding": 20},
        }
    return no_update


@callback(
    Output("export-preview", "children"),
    Input("export-canvas", "lastExport"),
)
def _render_preview(result):
    if not result or "result" not in result:
        return dmc.Text("(dispatch an export to preview)", c="dimmed", size="sm")
    export_type = result.get("type")
    payload = result.get("result")
    if not payload:
        err = result.get("error") or "empty result"
        return dmc.Alert(color="red", variant="light", children=str(err))
    if export_type == "exportToSvg":
        return html.Iframe(
            srcDoc=payload,
            style={
                "width": "100%",
                "height": "260px",
                "border": "0",
                "background": "white",
            },
        )
    if export_type in ("exportToBlob", "exportToCanvas"):
        return html.Img(
            src=payload,
            style={"maxWidth": "100%", "maxHeight": "260px", "display": "block"},
        )
    return dmc.Text(f"Unhandled export type: {export_type}", c="dimmed")
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /export*

---

<!-- /file-uploads — https://excalidraw.2plot.dev/file-uploads/llms.txt -->

# File uploads

> Keep canvas JSON small: push pasted images to external storage and swap the base64 for URLs.

---



### Where the bytes go, and how to point that at a CDN

A dropped file arrives as **base64** — that is how the browser hands it over,
and it is the wrong place for it to stay. Inline in the scene, a 120 KB image
is ~160 KB of base64 inside every `serializedData` you persist, every callback
payload that carries the scene, and every undo step. The job of this page is
getting it out.

The path is four steps, and only the third is deployment-specific:

1. **The canvas emits the bytes.** `lastFileAdded` for an image Excalidraw
   placed; `lastExternalDrop` for anything else — including GIFs, for the
   reason below. Both carry a `dataURL`.
2. **Python decodes and stores it.** `decode_data_url` → the storage backend's
   `put(key, mime, data)`, which returns a **URL**.
3. **The bytes live wherever the backend puts them.** In-process by default.
4. **The canvas is pointed at the URL.** `replaceFiles` for images; for other
   files the placeholder gets a `link`, and a GIF's placeholder is replaced by
   an `embeddable` whose iframe loads the stored file. Either way
   `externalizedSerializedData` comes back with every `data:` URI stripped —
   that is the payload you persist.

#### The storage backend is a seam

`lib/file_backends.py` defines it, and it is three methods:

```python
class FileBackend:
    def put(self, key, mime_type, data) -> str:  ...   # store; return a URL
    def get(self, key) -> tuple[str, bytes] | None: ...  # None if this app
                                                         # does not serve them
    def url_for(self, key) -> str: ...
```

`EXCALIDRAW_FILE_BACKEND` chooses one: `memory` (default) or `disk`
(`EXCALIDRAW_FILE_DIR`). An unknown name **raises at boot** rather than
falling back — a store that is silently not the one you configured is worse
than a failure to start.

For Cloudflare R2, S3 or a Postgres blob table, implement those three and
register it:

```python
from lib.file_backends import FileBackend, register_backend

class R2Backend(FileBackend):
    name = "r2"
    def put(self, key, mime_type, data):
        bucket.put_object(Key=key, Body=data, ContentType=mime_type)
        return self.url_for(key)
    def get(self, key):
        return None              # the CDN serves them; this app never does
    def url_for(self, key):
        return f"https://cdn.example.com/{key}"

register_backend("r2", R2Backend)
```

Nothing above storage changes. The canvas only ever needed a URL back.

**Two things change once the URL is not this app's origin**, and both are the
deployment's to solve rather than the component's. **CORS**: Excalidraw fetches
image bytes, so the bucket must allow your site's origin — a CDN that serves
`<img>` happily can still refuse a cross-origin fetch. And **lifetime**: the
default backend's caps exist because it holds bytes in a web process on a
512 MB container; a bucket has none of those constraints and should not
inherit limits sized for somebody else's.

#### Why GIFs take a different route

Excalidraw **rasterises a dropped GIF to a single still frame** before anything
downstream sees it. Measured here: a 123,069-byte GIF89a of 12 frames arrives
as a 2,820-byte PNG with none. So uploading what the canvas hands you stores a
still, and framing that in an iframe frames a still — the animation was lost at
the drop, not at the embed.

The rasterisation is also what freezes the tab on a large one. Dropping a
5.17 MB 600×600×20 GIF left the renderer unable to answer a debugger
evaluation at all — two 45-second timeouts — while the server sat idle at 32
callbacks and logged no error. It is decode work on the main thread, not I/O,
and no storage change touches it.

(Timer-based "scheduling lag" is **not** a usable measure here: a backgrounded
tab is throttled to ~1 s, so an idle page with no GIF reports the same figure
as a wedged one. An earlier draft of this note quoted those numbers before
that was checked against a control.)

So the component intercepts a GIF drop and sends the **original bytes** down
the `lastExternalDrop` path, exactly like a `.pdf` or a `.txt`. Excalidraw
never decodes it. This page then stores those bytes and puts an `embeddable`
where the placeholder was, so the browser animates the real file.

The trade is that a GIF is an embed rather than an image element: it is not
croppable or styleable on the canvas, and it needs the storage URL to be
reachable from the browser. That is the price of it moving at all.

#### An embed always draws above the canvas, whatever the z-order says

**Bring to front will not lift a shape above a GIF**, and that is not a bug in
this page — it is how Excalidraw renders. Measured in the DOM:

| layer | what is in it | z-index |
|---|---|---|
| `.excalidraw__canvas.static` | every rectangle, image, arrow and text — painted as pixels into **one** bitmap | 1 |
| `.excalidraw__embeddable-container` | every embed, as real `<iframe>` elements | 2 |

Scene order decides the painting order *within* that single canvas. It cannot
lift part of a bitmap above a DOM sibling that sits in a higher layer, so an
embed is in front of every ordinary element no matter where it sits in the
scene. The same is true of a YouTube embed on excalidraw.com; nothing about
GIFs or this page causes it.

There is no setting that fixes it. Raising the canvas above the embed layer
would only invert the problem — every embed would hide behind everything and
stop being clickable — and true interleaving would mean one canvas per z-band,
which is an upstream renderer change.

What works in practice: don't overlap them. If layering matters more than
motion for a particular image, keep it as an ordinary image element and accept
a still frame.

### Overview

Keep canvas JSON small: uploads to external storage, base64 swapped for URLs.
Drop multiple images, or any non-image file, or a GIF — each takes the right
path automatically.

### Live demo


### Source


```python
# File: docs/file-uploads/file_uploads.py

"""File uploads: externalize inline base64, place non-image placeholders,
auto-embed GIFs as animated iframes, view via dmc.Drawer.

Demonstrates the production pattern for keeping Excalidraw's serialized
JSON small and scalable, AND shows how the wrapper's extended drop
handler feeds multi-file and non-image cases through the same rails.

Flow per drop type:

  Image (non-GIF)
    wrapper adds as `image` element → lastFileAdded → Python uploads →
    replaceFiles swaps dataURL → JSON is clean.

  GIF
    wrapper adds as `image` element (static initially) → lastFileAdded
    → Python uploads → replaceFiles swaps dataURL → follow-up callback
    (triggered by `files`) spots the mime=image/gif with an external URL
    and replaces the image element with an `embeddable` so the GIF
    animates inside an iframe.

  Non-image (pdf, doc, csv, json, …)
    wrapper places a rectangle+text placeholder at the drop point →
    lastExternalDrop fires with {placeholderIds, files[]} → Python
    uploads → dispatches updateScene to set the placeholder's `link` to
    the served URL. Cmd/Ctrl+click opens the drawer.
"""

from __future__ import annotations

import json
import mimetypes
import uuid

import dash_mantine_components as dmc
from dash import Input, Output, State, callback, ctx, dcc, html, no_update

from dash_excalidraw import DashExcalidraw, decode_data_url
from lib import file_backends as _file_backends
from lib import file_store as _file_store
from docs._shared import (
    canvas_frame,
    code_block,
    sync_canvas_theme,
    two_column,
)

sync_canvas_theme("fu-canvas")

try:
    from dash import clientside_callback, ClientsideFunction  # noqa: F401
except ImportError:  # pragma: no cover
    clientside_callback = None
    ClientsideFunction = None

# ---------------------------------------------------------------------------
# Showcase code snippet (trimmed for display)
# ---------------------------------------------------------------------------

FLOW_CODE = """
@callback(
    Output('canvas', 'command'),
    Output('uploads', 'data'),
    Input('canvas', 'lastFileAdded'),
    State('uploads', 'data'),
    prevent_initial_call=True,
)
def upload_and_swap(event, uploads):
    mappings = {}
    uploads = dict(uploads or {})
    for f in event.get('files') or [event]:
        mime, raw = decode_data_url(f['dataURL'])
        url = upload(raw, mime)     # ← your storage layer here
        uploads[f['fileId']] = {'url': url, 'mimeType': mime, ...}
        mappings[f['fileId']] = {'dataURL': url, 'mimeType': mime}
    return ({'id': str(uuid.uuid4()),
             'type': 'replaceFiles',
             'payload': mappings},
            uploads)
"""

# ---------------------------------------------------------------------------
# UI helpers
# ---------------------------------------------------------------------------


def _human_bytes(n):
    if n is None:
        return "—"
    step = 1024.0
    n = float(n)
    for unit in ("B", "KiB", "MiB", "GiB"):
        if n < step:
            return f"{n:.1f} {unit}" if unit != "B" else f"{int(n)} {unit}"
        n /= step
    return f"{n:.1f} TiB"


def _uploads_table(uploads):
    if not uploads:
        return dmc.Text(
            "Drop an image or any other file on the canvas to start.",
            size="sm",
            c="dimmed",
        )
    rows = []
    for fid, info in uploads.items():
        status = info.get("status", "uploaded")
        kind = info.get("kind", "image")
        rows.append(
            dmc.TableTr(
                [
                    dmc.TableTd(
                        dmc.Badge(
                            kind,
                            size="sm",
                            color={"gif": "grape", "file": "blue"}.get(kind, "teal"),
                            variant="light",
                        ),
                    ),
                    dmc.TableTd(
                        dmc.Code(
                            (info.get("name") or fid)[:22]
                            + ("…" if len(info.get("name") or fid) > 22 else "")
                        )
                    ),
                    dmc.TableTd(_human_bytes(info.get("size", 0))),
                    dmc.TableTd(
                        dmc.Anchor(
                            info.get("url", ""),
                            href=info.get("url", ""),
                            target="_blank",
                            size="xs",
                        )
                    ),
                    dmc.TableTd(
                        dmc.Tooltip(
                            label=info.get("detail") or status,
                            disabled=not info.get("detail"),
                            multiline=True,
                            w=320,
                            children=dmc.Badge(
                                status,
                                # Red for a refusal, not yellow: nothing was
                                # stored and the URL column is empty, so this
                                # is a failure rather than a slow step.
                                color={
                                    "uploaded": "green",
                                    "too large": "red",
                                }.get(status, "yellow"),
                                variant="light",
                                size="sm",
                            ),
                        )
                    ),
                ]
            )
        )
    # SCROLLS SIDEWAYS. The url column holds a full storage path, and a
    # backend whose URLs are a CDN's makes it longer still — the table was
    # running past the card and clipping the status column, which is the one
    # that says whether the upload actually worked.
    return dmc.TableScrollContainer(
        minWidth=560,
        children=dmc.Table(
        highlightOnHover=True,
        withTableBorder=True,
        children=[
            dmc.TableThead(
                dmc.TableTr(
                    [
                        dmc.TableTh("kind"),
                        dmc.TableTh("name"),
                        dmc.TableTh("size"),
                        dmc.TableTh("url"),
                        dmc.TableTh("status"),
                    ]
                )
            ),
            dmc.TableTbody(rows),
        ],
        ),
    )


def _size_panel(serialized, externalized):
    raw = len(serialized or "")
    ext = len(externalized or "")
    ratio = f"{raw / ext:.1f}×" if ext else "—"
    return dmc.Stack(
        gap="xs",
        children=[
            dmc.Group(
                justify="space-between",
                children=[
                    dmc.Text("serializedData", size="sm", fw=500),
                    dmc.Badge(_human_bytes(raw), color="red", variant="light"),
                ],
            ),
            dmc.Group(
                justify="space-between",
                children=[
                    dmc.Text("externalizedSerializedData", size="sm", fw=500),
                    dmc.Badge(_human_bytes(ext), color="green", variant="light"),
                ],
            ),
            dmc.Group(
                justify="space-between",
                children=[
                    dmc.Text("reduction", size="sm", c="dimmed"),
                    dmc.Text(ratio, size="sm", fw=600),
                ],
            ),
        ],
    )


def _emoji_for_mime(mime: str, name: str) -> str:
    """Python mirror of the TSX icon picker for the drawer header."""
    m = (mime or "").lower()
    n = (name or "").lower()
    if "pdf" in m or n.endswith(".pdf"):
        return "📕"
    if "csv" in m or n.endswith(".csv"):
        return "📊"
    if "spreadsheet" in m or "excel" in m or n.endswith((".xls", ".xlsx")):
        return "📊"
    if "presentation" in m or "powerpoint" in m or n.endswith((".ppt", ".pptx")):
        return "🎞️"
    if "msword" in m or "officedocument" in m or n.endswith((".doc", ".docx")):
        return "📘"
    if "json" in m or "yaml" in m or n.endswith((".json", ".yaml", ".yml", ".toml")):
        return "🗂️"
    if m.startswith("audio/"):
        return "🎵"
    if m.startswith("video/"):
        return "🎬"
    if m.startswith("image/"):
        return "🖼️"
    if "zip" in m or "tar" in m or "gzip" in m or "compressed" in m:
        return "🗜️"
    if m.startswith("text/"):
        return "📝"
    return "📎"


def _preview_for(file_id: str, mime: str, url: str):
    """Return a type-appropriate preview widget for the drawer body."""
    if mime.startswith("image/"):
        return html.Img(
            src=url,
            style={
                "maxWidth": "100%",
                "maxHeight": "55vh",
                "display": "block",
                "margin": "0 auto",
                "borderRadius": 8,
                "border": "1px solid var(--mantine-color-gray-3)",
            },
        )
    if mime == "application/pdf":
        return html.Iframe(
            src=url,
            style={
                "width": "100%",
                "height": "55vh",
                "border": "1px solid var(--mantine-color-gray-3)",
                "borderRadius": 8,
            },
        )
    if mime.startswith("text/") or mime in (
        "application/json",
        "application/xml",
        "application/javascript",
        "application/yaml",
        "application/x-yaml",
        "application/toml",
    ):
        entry = _file_backends.backend().get(file_id)
        text = ""
        try:
            text = entry[1].decode("utf-8", errors="replace") if entry else ""
        except Exception:
            text = "(cannot decode as UTF-8)"
        language = {
            "application/json": "json",
            "application/xml": "xml",
            "application/javascript": "javascript",
            "application/yaml": "yaml",
            "application/x-yaml": "yaml",
            "application/toml": "toml",
            "text/markdown": "markdown",
            "text/x-python": "python",
        }.get(mime, "text")
        try:
            return dmc.CodeHighlight(
                code=text[:20000],
                language=language,
                withCopyButton=True,
            )
        except Exception:
            return dmc.Code(text[:20000], block=True)
    return dmc.Alert(
        color="gray",
        variant="light",
        children=[
            dmc.Text(
                f"No inline preview available for {mime or 'this file type'}.",
                size="sm",
            ),
            dmc.Text(
                "Use the Download / Open buttons above to inspect the file.",
                size="xs",
                c="dimmed",
            ),
        ],
    )


def _drawer_body(file_id: str, name: str, mime: str, url: str):
    """Polished drawer layout: big icon header, metadata, actions, preview."""
    if not file_id:
        return dmc.Text("(no file selected)", c="dimmed")

    entry = _file_backends.backend().get(file_id)
    size_bytes = len(entry[1]) if entry else 0
    icon = _emoji_for_mime(mime, name)
    short_mime = (mime or "application/octet-stream").split("/")[-1].upper()

    header = dmc.Paper(
        withBorder=True,
        p="md",
        radius="md",
        children=dmc.Group(
            gap="md",
            wrap="nowrap",
            children=[
                dmc.Text(icon, style={"fontSize": 56, "lineHeight": 1}),
                dmc.Stack(
                    gap=4,
                    style={"flex": 1, "minWidth": 0},
                    children=[
                        dmc.Text(
                            name,
                            fw=700,
                            size="md",
                            style={
                                "overflow": "hidden",
                                "textOverflow": "ellipsis",
                                "whiteSpace": "nowrap",
                            },
                        ),
                        dmc.Group(
                            gap="xs",
                            children=[
                                dmc.Badge(
                                    short_mime,
                                    color="indigo",
                                    variant="light",
                                    size="sm",
                                ),
                                dmc.Text(
                                    _human_bytes(size_bytes),
                                    size="xs",
                                    c="dimmed",
                                ),
                                dmc.Text(
                                    f"id: {file_id[:14]}…"
                                    if len(file_id) > 14
                                    else f"id: {file_id}",
                                    size="xs",
                                    c="dimmed",
                                ),
                            ],
                        ),
                    ],
                ),
            ],
        ),
    )

    served_row = dmc.Paper(
        withBorder=True,
        p="sm",
        radius="md",
        children=dmc.Stack(
            gap=4,
            children=[
                dmc.Text("Served URL", size="xs", c="dimmed", fw=500),
                dmc.Group(
                    gap="xs",
                    wrap="nowrap",
                    children=[
                        dmc.Code(
                            url,
                            style={
                                "flex": 1,
                                "whiteSpace": "nowrap",
                                "overflow": "hidden",
                                "textOverflow": "ellipsis",
                            },
                        ),
                    ],
                ),
            ],
        ),
    )

    actions = dmc.Group(
        gap="xs",
        children=[
            html.A(
                dmc.Button(
                    "Download",
                    leftSection="⬇",
                    variant="filled",
                    color="indigo",
                ),
                href=url,
                download=name,
                style={"textDecoration": "none"},
            ),
            html.A(
                dmc.Button(
                    "Open in new tab",
                    leftSection="↗",
                    variant="light",
                ),
                href=url,
                target="_blank",
                rel="noreferrer",
                style={"textDecoration": "none"},
            ),
            dmc.Button(
                "Copy URL",
                id="fu-drawer-copy-btn",
                leftSection="⧉",
                variant="subtle",
                color="gray",
            ),
            dmc.Text(
                id="fu-drawer-copy-status",
                size="xs",
                c="green",
                style={"alignSelf": "center"},
            ),
        ],
    )

    preview_block = dmc.Paper(
        withBorder=True,
        p="md",
        radius="md",
        children=dmc.Stack(
            gap="xs",
            children=[
                dmc.Text("Preview", fw=600, size="sm"),
                _preview_for(file_id, mime, url),
            ],
        ),
    )

    return dmc.Stack(
        gap="md",
        children=[header, actions, served_row, preview_block],
    )


# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------

component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="indigo",
            variant="light",
            title="Drop anything",
            children=dmc.List(
                size="sm",
                children=[
                    dmc.ListItem(
                        "Single image → Excalidraw's native placement."
                    ),
                    dmc.ListItem(
                        "Multiple images → wrapper places them side-by-side and "
                        "`lastFileAdded` carries the full batch in `event['files']`."
                    ),
                    dmc.ListItem(
                        "GIF → uploaded, then the static image element is "
                        "upgraded to an `embeddable` iframe so the animation plays."
                    ),
                    dmc.ListItem(
                        "Any other file → rectangle placeholder on the canvas; "
                        "Cmd/Ctrl-click opens a drawer with its contents."
                    ),
                ],
            ),
        ),
        code_block(FLOW_CODE),
        dmc.Box(id="fu-backend-card"),
        dcc.Store(id="fu-uploads-store", data={}),
        dcc.Store(id="fu-drawer-store", data=None),
        two_column(
            canvas_frame(
                DashExcalidraw(
                    id="fu-canvas",
                    height="560px",
                    interceptLinkOpens=True,
                    validateEmbeddable=True,
                    UIOptions={
                        "welcomeScreen": False,
                        "tools": {"image": True},
                        "canvasActions": {"export": False, "saveAsImage": True},
                    },
                ),
                min_height=560,
            ),
            dmc.Stack(
                gap="md",
                children=[
                    dmc.Paper(
                        withBorder=True,
                        p="md",
                        children=dmc.Stack(
                            gap="xs",
                            children=[
                                dmc.Text("Upload activity", fw=600),
                                dmc.Box(id="fu-table"),
                            ],
                        ),
                    ),
                    dmc.Paper(
                        withBorder=True,
                        p="md",
                        children=dmc.Stack(
                            gap="xs",
                            children=[
                                dmc.Text("Payload size", fw=600),
                                dmc.Box(id="fu-size"),
                            ],
                        ),
                    ),
                    dmc.Paper(
                        withBorder=True,
                        p="md",
                        children=dmc.Stack(
                            gap="xs",
                            children=[
                                dmc.Group(
                                    justify="space-between",
                                    children=[
                                        dmc.Text(
                                            "externalizedSerializedData",
                                            fw=600,
                                        ),
                                        dmc.Button(
                                            "Clear storage",
                                            id="fu-clear-btn",
                                            variant="subtle",
                                            color="red",
                                            size="compact-xs",
                                        ),
                                    ],
                                ),
                                dmc.ScrollArea(
                                    style={"height": 220},
                                    children=dmc.Code(
                                        id="fu-json",
                                        block=True,
                                        style={
                                            "whiteSpace": "pre-wrap",
                                            "wordBreak": "break-word",
                                            "fontSize": 11,
                                        },
                                    ),
                                ),
                            ],
                        ),
                    ),
                ],
            ),
        ),
        dmc.Drawer(
            id="fu-drawer",
            opened=False,
            position="right",
            size="lg",
            padding="md",
            title=dmc.Group(
                [
                    dmc.Text(id="fu-drawer-title", fw=600),
                    dmc.Badge(id="fu-drawer-badge", size="sm", variant="light"),
                ]
            ),
            children=dmc.Box(id="fu-drawer-body"),
        ),
        dmc.Divider(label="Edge cases", labelPosition="center", my="lg"),
        dmc.SimpleGrid(
            cols={"base": 1, "md": 3},
            spacing="md",
            children=[
                dmc.Card(
                    withBorder=True,
                    p="md",
                    children=dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Text("Multi-file drag-drop", fw=600),
                            dmc.Text(
                                "Drop several files (any mix of types). The "
                                "wrapper places them all side-by-side and "
                                "`lastFileAdded` carries the full batch in "
                                "`event['files']`.",
                                size="sm",
                                c="dimmed",
                            ),
                        ],
                    ),
                ),
                dmc.Card(
                    withBorder=True,
                    p="md",
                    children=dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Text("GIF auto-embed", fw=600),
                            dmc.Text(
                                "Excalidraw rasterizes a dropped GIF to one "
                                "still frame — measured, a 12-frame 123 KB "
                                "GIF89a arrives as a 2.8 KB PNG. So the "
                                "component intercepts GIF drops, keeps the "
                                "original bytes, and this page puts an "
                                "embeddable over them. The iframe animates the "
                                "real file; Excalidraw never decodes it.",
                                size="sm",
                                c="dimmed",
                            ),
                        ],
                    ),
                ),
                dmc.Card(
                    withBorder=True,
                    p="md",
                    children=dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Text("Non-image files", fw=600),
                            dmc.Text(
                                "PDF, JSON, CSV, anything — placed on the "
                                "canvas as a card. Cmd/Ctrl-click opens the "
                                "drawer with a type-appropriate view (iframe, "
                                "syntax highlight, or download).",
                                size="sm",
                                c="dimmed",
                            ),
                        ],
                    ),
                ),
            ],
        ),
    ],
)


# ---------------------------------------------------------------------------
# Callbacks
# ---------------------------------------------------------------------------


@callback(
    Output("fu-canvas", "command"),
    Output("fu-uploads-store", "data"),
    Input("fu-canvas", "lastFileAdded"),
    State("fu-uploads-store", "data"),
    prevent_initial_call=True,
)
def _upload_and_swap(event, uploads):
    """Upload every new file in the batch; dispatch replaceFiles once."""
    if not event:
        return no_update, no_update
    files_in_event = event.get("files") or [
        {k: event.get(k) for k in ("fileId", "mimeType", "dataURL", "size")}
    ]
    uploads = dict(uploads or {})
    mappings = {}
    for f in files_in_event:
        try:
            mime, raw = decode_data_url(f["dataURL"])
        except (ValueError, KeyError):
            continue
        try:
            url = _file_backends.backend().put(f["fileId"], mime, raw)
        except _file_store.FileTooLarge as exc:
            # The store refuses a blob over its per-file cap, and that refusal
            # used to leave the callback: HTTP 500, a placeholder that never
            # resolved, and nothing on the page saying why. MEASURED with a
            # 5.17 MB GIF against the 5 MB default. A cap is a policy, so it
            # gets an answer, not a traceback.
            uploads[f["fileId"]] = {
                "url": "",
                "size": f.get("size", len(raw)),
                "mimeType": mime,
                "name": f.get("fileId"),
                "kind": "gif" if mime == "image/gif" else "image",
                "status": "too large",
                "detail": str(exc),
            }
            continue
        uploads[f["fileId"]] = {
            "url": url,
            "size": f.get("size", len(raw)),
            "mimeType": mime,
            "name": f.get("fileId"),
            "kind": "gif" if mime == "image/gif" else "image",
            "status": "uploaded",
        }
        mappings[f["fileId"]] = {"dataURL": url, "mimeType": mime}
    if not mappings:
        return no_update, no_update
    return (
        {
            "id": f"replace-{uuid.uuid4()}",
            "type": "replaceFiles",
            "payload": mappings,
        },
        uploads,
    )


@callback(
    Output("fu-canvas", "command", allow_duplicate=True),
    Input("fu-canvas", "files"),
    State("fu-canvas", "elements"),
    prevent_initial_call=True,
)
def _upgrade_gifs_to_embeddable(files, elements):
    """Once a GIF has an external URL, swap its image element for an iframe."""
    if not files or not elements:
        return no_update
    gif_targets = []
    for el in elements:
        if el.get("type") != "image":
            continue
        fid = el.get("fileId")
        if not fid:
            continue
        entry = files.get(fid) if isinstance(files, dict) else None
        if not isinstance(entry, dict):
            continue
        if entry.get("mimeType") != "image/gif":
            continue
        url = entry.get("dataURL")
        if not isinstance(url, str) or url.startswith("data:"):
            continue
        gif_targets.append(
            {
                "id": el["id"],
                "x": el.get("x", 0),
                "y": el.get("y", 0),
                "width": el.get("width", 320),
                "height": el.get("height", 240),
                "url": url,
            }
        )
    if not gif_targets:
        return no_update

    target_ids = {g["id"] for g in gif_targets}
    new_elements = [el for el in elements if el["id"] not in target_ids]
    now_ms = int(uuid.uuid4().int % (10**12))
    for g in gif_targets:
        # Use the /viewer HTML wrapper as the embeddable src. Pointing the
        # iframe directly at the raw GIF URL trips Chromium's same-URL frame
        # safety check ("Unsafe attempt to load URL ... from frame with URL ...");
        # loading HTML that contains an <img> sidesteps that and lets the GIF
        # animate natively inside the iframe.
        viewer_href = g["url"].rstrip("/") + "/viewer"
        new_elements.append(
            {
                "id": f"gif-embed-{g['id']}",
                "type": "embeddable",
                "x": g["x"],
                "y": g["y"],
                "width": g["width"],
                "height": g["height"],
                "angle": 0,
                "strokeColor": "transparent",
                "backgroundColor": "transparent",
                "fillStyle": "solid",
                "strokeWidth": 1,
                "strokeStyle": "solid",
                "roughness": 0,
                "opacity": 100,
                "seed": now_ms % 1_000_000,
                "version": 1,
                "versionNonce": now_ms % 1_000_000,
                "isDeleted": False,
                "groupIds": [],
                "frameId": None,
                "boundElements": [],
                "updated": now_ms,
                "link": viewer_href,
                "locked": False,
                "roundness": None,
            }
        )
    return {
        "id": f"gif-upgrade-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {"elements": new_elements},
    }


@callback(
    Output("fu-canvas", "command", allow_duplicate=True),
    Output("fu-uploads-store", "data", allow_duplicate=True),
    Input("fu-canvas", "lastExternalDrop"),
    State("fu-canvas", "elements"),
    State("fu-uploads-store", "data"),
    prevent_initial_call=True,
)
def _handle_external_drop(event, elements, uploads):
    """Non-image drops: upload each file and set the placeholder's link.

    GIFs arrive here too, and that is the point. The component routes them
    down this path instead of letting Excalidraw place them, because
    Excalidraw rasterises a GIF to one still frame on drop — measured, a
    123,069-byte 12-frame GIF89a became a 2,820-byte PNG. What reaches this
    callback is the ORIGINAL file, so the bytes in storage are the animation.

    A GIF's placeholder is then replaced by an `embeddable` rather than given
    a link: the iframe loads the stored GIF and the browser animates it, which
    a canvas image element can never do.
    """
    if not event or not event.get("files"):
        return no_update, no_update
    placeholder_ids = event.get("placeholderIds") or []
    uploads = dict(uploads or {})
    updates = {}  # elementId -> {link, mimeType, fileId, name}
    for idx, f in enumerate(event["files"]):
        try:
            mime, raw = decode_data_url(f["dataURL"])
        except (ValueError, KeyError):
            continue
        # Use a single store_key for everything: file_store, uploads dict,
        # and customData.external.fileId. Keeps the drawer lookup honest.
        guessed_ext = mimetypes.guess_extension(mime) or ""
        store_key = f"ext-{uuid.uuid4().hex[:12]}{guessed_ext}"
        try:
            url = _file_backends.backend().put(store_key, mime, raw)
        except _file_store.FileTooLarge as exc:
            uploads[store_key] = {
                "url": "",
                "size": f.get("size", len(raw)),
                "mimeType": mime,
                "name": f.get("name") or store_key,
                "kind": "file",
                "status": "too large",
                "detail": str(exc),
            }
            continue
        uploads[store_key] = {
            "url": url,
            "size": f.get("size", len(raw)),
            "mimeType": mime,
            "name": f.get("name") or store_key,
            "kind": "file",
            "status": "uploaded",
        }
        if idx < len(placeholder_ids):
            updates[placeholder_ids[idx]] = {
                "link": url,
                "fileId": store_key,
                "mimeType": mime,
                "name": f.get("name") or store_key,
                # Carried from the drop so the embed matches the GIF rather
                # than the placeholder rectangle it replaces.
                "naturalSize": f.get("naturalSize"),
            }
    if not updates:
        return no_update, uploads

    # A placeholder is a GROUP — rectangle, icon, name, size, badge — so a GIF
    # replacing one has to remove all five, not just repaint the rectangle, or
    # the label floats on top of the animation.
    gif_groups = set()
    for el in elements or []:
        meta = updates.get(el.get("id"))
        if meta and meta["mimeType"] == "image/gif":
            gif_groups.update(el.get("groupIds") or [])

    new_elements = []
    for el in elements or []:
        meta = updates.get(el.get("id"))
        if meta and meta["mimeType"] == "image/gif" and el.get("type") == "rectangle":
            # A GIF becomes an iframe over the stored file. `/viewer` rather
            # than the raw URL: framing an image directly trips Chromium's
            # same-URL frame check, and a one-line HTML document holding an
            # <img> does not.
            size = meta.get("naturalSize") or {}
            new_elements.append({
                **el,
                "type": "embeddable",
                "width": size.get("width") or el.get("width", 320),
                "height": size.get("height") or el.get("height", 240),
                "link": meta["link"].rstrip("/") + "/viewer",
                "strokeColor": "transparent",
                "backgroundColor": "transparent",
                "groupIds": [],
                "customData": {
                    **(el.get("customData") or {}),
                    "external": meta,
                },
            })
        elif gif_groups.intersection(el.get("groupIds") or []):
            # The rest of that placeholder group: dropped.
            continue
        elif meta and el.get("type") == "rectangle":
            patched = dict(el)
            patched["link"] = meta["link"]
            # Stash metadata in customData so the drawer callback can look up
            # mime/name/fileId by element id without re-parsing the link.
            patched["customData"] = {
                **(el.get("customData") or {}),
                "external": meta,
            }
            new_elements.append(patched)
        else:
            new_elements.append(el)

    return (
        {
            "id": f"extdrop-{uuid.uuid4()}",
            "type": "updateScene",
            "payload": {"elements": new_elements},
        },
        uploads,
    )


@callback(
    Output("fu-drawer", "opened"),
    Output("fu-drawer-title", "children"),
    Output("fu-drawer-badge", "children"),
    Output("fu-drawer-body", "children"),
    Output("fu-drawer-store", "data"),
    Input("fu-canvas", "lastLinkOpen"),
    State("fu-canvas", "elements"),
    prevent_initial_call=True,
)
def _open_drawer(link_event, elements):
    """Open the drawer when a placeholder link is clicked."""
    if not link_event or not link_event.get("elementId"):
        return no_update, no_update, no_update, no_update, no_update
    eid = link_event["elementId"]
    url = link_event.get("url") or ""
    # Only handle links served from our Flask endpoint
    if not url.startswith(_file_store.FILE_URL_PREFIX):
        return no_update, no_update, no_update, no_update, no_update
    target = next((el for el in (elements or []) if el.get("id") == eid), None)
    meta = (target or {}).get("customData", {}).get("external", {})
    name = meta.get("name") or url.rsplit("/", 1)[-1]
    mime = meta.get("mimeType") or "application/octet-stream"
    file_id = meta.get("fileId") or url.rsplit("/", 1)[-1]
    body = _drawer_body(file_id, name, mime, url)
    return True, name, mime, body, {"url": url, "name": name, "mime": mime}


@callback(
    Output("fu-table", "children"),
    Input("fu-uploads-store", "data"),
)
def _render_table(uploads):
    return _uploads_table(uploads)


@callback(
    Output("fu-size", "children"),
    Output("fu-json", "children"),
    Input("fu-canvas", "serializedData"),
    Input("fu-canvas", "externalizedSerializedData"),
)
def _render_size(serialized, externalized):
    size_panel = _size_panel(serialized, externalized)
    if not externalized:
        json_blob = "(draw or drop something to see the envelope)"
    else:
        try:
            json_blob = json.dumps(json.loads(externalized), indent=2)
        except (TypeError, ValueError):
            json_blob = externalized
    return size_panel, json_blob


@callback(
    Output("fu-uploads-store", "data", allow_duplicate=True),
    Input("fu-clear-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _clear_store(_clicks):
    _file_store.clear()
    return {}


if clientside_callback is not None:
    clientside_callback(
        """
        function(n, data) {
            if (!n || !data || !data.url) { return window.dash_clientside.no_update; }
            try {
                const href = new URL(data.url, window.location.origin).href;
                navigator.clipboard.writeText(href);
                return "copied ✓";
            } catch (err) {
                return "copy failed";
            }
        }
        """,
        Output("fu-drawer-copy-status", "children"),
        Input("fu-drawer-copy-btn", "n_clicks"),
        State("fu-drawer-store", "data"),
        prevent_initial_call=True,
    )


@callback(
    Output("fu-backend-card", "children"),
    Input("fu-canvas", "id"),
)
def _describe_backend(_id):
    """Name the storage backend that is actually serving, at page load.

    Prose in the markdown can go stale the moment somebody sets an env var;
    this reads the live object, so what the page claims about where bytes go
    is what is happening. It also makes the seam visible — the whole point is
    that this line can say "r2" without anything else on the page changing.
    """
    info = _file_backends.backend().describe()
    rows = [
        dmc.Group(
            gap="xs",
            children=[
                dmc.Text("Storage backend", fw=600, size="sm"),
                dmc.Badge(info.get("name", "?"), variant="light"),
                dmc.Badge(
                    "serves bytes here"
                    if info.get("serves_bytes")
                    else "bytes served elsewhere",
                    color="teal" if info.get("serves_bytes") else "grape",
                    variant="light",
                    size="sm",
                ),
            ],
        )
    ]

    facts = []
    if info.get("max_entry_bytes"):
        facts.append(f"{info['max_entry_bytes'] // (1024 * 1024)} MB per file")
    if info.get("max_total_bytes"):
        facts.append(f"{info['max_total_bytes'] // (1024 * 1024)} MB total")
    if info.get("ttl_seconds"):
        facts.append(f"{info['ttl_seconds'] // 60} min TTL")
    if info.get("root"):
        facts.append(info["root"])
    # State the two properties that decide whether this is deployable, rather
    # than leaving someone to infer them from the backend's name.
    facts.append(
        "survives a restart"
        if info.get("survives_restart")
        else "lost on restart"
    )
    facts.append(
        "shared between workers"
        if info.get("shared_between_workers")
        else "per-worker"
    )
    rows.append(dmc.Text(" · ".join(facts), size="xs", c="dimmed"))
    rows.append(
        dmc.Text(
            "EXCALIDRAW_FILE_BACKEND selects it (memory | disk). Implement "
            "three methods to point it at R2, S3 or Postgres — see the section "
            "above and lib/file_backends.py.",
            size="xs",
            c="dimmed",
        )
    )
    return dmc.Alert(
        dmc.Stack(rows, gap=4),
        color="blue",
        variant="light",
    )
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /file-uploads*

---

<!-- /initial-data — https://excalidraw.2plot.dev/initial-data/llms.txt -->

# initialData

> Pre-populate the canvas on mount with elements, appState overrides and library items.

---



### Overview

Pre-populate the canvas with elements, appState overrides, and optionally
library items. The prop is consumed once on mount — after that, drive changes
through the command dispatch.

### Live demo


### Source


```python
# File: docs/initial-data/initial_data.py

"""initialData: seed the scene on mount."""

import dash_mantine_components as dmc

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("initialdata-canvas")

SEED_ELEMENTS = [
    {
        "id": "rect-1",
        "type": "rectangle",
        "x": 120,
        "y": 120,
        "width": 260,
        "height": 140,
        "angle": 0,
        "strokeColor": "#1971c2",
        "backgroundColor": "#d0ebff",
        "fillStyle": "solid",
        "strokeWidth": 2,
        "roughness": 1,
        "opacity": 100,
        "seed": 1,
        "version": 1,
        "versionNonce": 1,
        "isDeleted": False,
        "groupIds": [],
        "frameId": None,
        "boundElements": [],
        "updated": 1,
        "link": None,
        "locked": False,
    },
    {
        "id": "text-1",
        "type": "text",
        "x": 150,
        "y": 170,
        "width": 210,
        "height": 40,
        "angle": 0,
        "strokeColor": "#1864ab",
        "backgroundColor": "transparent",
        "fillStyle": "solid",
        "strokeWidth": 1,
        "roughness": 0,
        "opacity": 100,
        "seed": 2,
        "version": 1,
        "versionNonce": 2,
        "isDeleted": False,
        "groupIds": [],
        "frameId": None,
        "boundElements": [],
        "updated": 1,
        "link": None,
        "locked": False,
        "fontSize": 28,
        "fontFamily": 1,
        "text": "Hello from Dash!",
        "textAlign": "center",
        "verticalAlign": "middle",
        "containerId": None,
        "originalText": "Hello from Dash!",
        "lineHeight": 1.25,
    },
]

INITIAL_DATA = {
    "elements": SEED_ELEMENTS,
    "appState": {
        "viewBackgroundColor": "#f8f9fa",
        "gridSize": None,
    },
    "scrollToContent": True,
}

CODE = """
SEED = {
    'elements': [
        {'type': 'rectangle', 'x': 120, 'y': 120,
         'width': 260, 'height': 140, ...},
        {'type': 'text', 'x': 150, 'y': 170,
         'text': 'Hello from Dash!', ...},
    ],
    'appState': {'viewBackgroundColor': '#f8f9fa'},
    'scrollToContent': True,
}

DashExcalidraw(id='canvas', initialData=SEED)
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dmc.Alert(
            color="yellow",
            variant="light",
            title="Why not just update this prop later?",
            children="Excalidraw owns the scene state after mount. Overwriting "
            "initialData after render would fight the editor's internal state and "
            "history stack. Use `command` with type='updateScene' or 'resetScene' "
            "for post-mount changes.",
        ),
        canvas_frame(
            DashExcalidraw(
                id="initialdata-canvas",
                height="600px",
                initialData=INITIAL_DATA,
            )
        ),
    ],
)
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /initial-data*

---

<!-- /library — https://excalidraw.2plot.dev/library/llms.txt -->

# Library

> Read and write the Excalidraw shape library from Python with lastLibraryChange and updateLibrary.

---



### Overview

The library menu holds reusable shapes. `lastLibraryChange` fires whenever the
user adds/removes an item; `command: updateLibrary` lets you push items in
from Python.

### Coming back from the public library

The sidebar's **Browse Library** button sends the user to Excalidraw's
public library site. `libraryReturnUrl` is the URL that site sends them back
to once they pick a shape:

```python
DashExcalidraw(
    id="canvas",
    libraryReturnUrl="https://your-app.example.com/drawing",
)
```

It has no default of its own. Left unset, the return trip is whatever
Excalidraw decides, which is not a URL your app chose — so set it to the
page hosting the canvas whenever the library button is reachable, and the
user comes back to you rather than wherever the fallback points.

### Live demo


### Source


```python
# File: docs/library/library.py

"""Library: observe library items and drive updateLibrary."""

import uuid

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import (
    canvas_frame,
    code_block,
    json_panel,
    sync_canvas_theme,
    two_column,
)

sync_canvas_theme("library-canvas")

CODE = """
# observe
@callback(Output('out', 'children'),
          Input('canvas', 'lastLibraryChange'))
def show(snapshot): ...

# drive (merge items from Python)
@callback(Output('canvas', 'command'),
          Input('btn', 'n_clicks'),
          prevent_initial_call=True)
def add_items(_):
    return {'id': str(uuid.uuid4()),
            'type': 'updateLibrary',
            'payload': {
                'libraryItems': [...],
                'merge': True,
                'openLibraryMenu': True,
            }}
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        two_column(
            canvas_frame(
                DashExcalidraw(
                    id="library-canvas",
                    height="560px",
                ),
                min_height=560,
            ),
            dmc.Stack(
                gap="sm",
                children=[
                    dmc.Button(
                        "Open library menu",
                        id="library-open-btn",
                        variant="light",
                    ),
                    dmc.Paper(id="library-panel", withBorder=True, p="xs"),
                ],
            ),
        ),
    ],
)


@callback(
    Output("library-panel", "children"),
    Input("library-canvas", "lastLibraryChange"),
)
def _render_library(snapshot):
    if not snapshot:
        return dmc.Text("(no library changes yet)", c="dimmed", size="sm")
    items = snapshot.get("items") or []
    return dmc.Stack(
        gap="xs",
        children=[
            dmc.Group(
                [
                    dmc.Text("Library items", size="sm", fw=600),
                    dmc.Badge(len(items), variant="light"),
                ]
            ),
            json_panel("Raw payload", snapshot, height=320),
        ],
    )


@callback(
    Output("library-canvas", "command"),
    Input("library-open-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _open_library(_clicks):
    return {
        "id": f"libopen-{uuid.uuid4()}",
        "type": "toggleSidebar",
        # `name` is the SIDEBAR, `tab` is the panel inside it. Excalidraw's
        # built-in sidebar is called "default" and "library" is one of its
        # tabs — so `{"name": "library"}` addresses a sidebar that does not
        # exist, and `toggleSidebar` answers False and does nothing at all.
        # That is what made this button look dead while the canvas's own
        # library button opened the very same panel.
        "payload": {"name": "default", "tab": "library", "force": True},
    }
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /library*

---

<!-- /migration — https://excalidraw.2plot.dev/migration/llms.txt -->

# Migrating from 0.0.x

> What changed between the PyPI 0.0.x releases and the current build — no prop was removed or renamed, but three defaults did change.

---



### The short version

This page covers the upgrade from the `0.0.x` series to dash-excalidraw
1.0.0, the build this site runs on.

**No prop was removed. No prop was renamed.** All 21 props that `0.0.4`
exposed are still present and still spelled the same way — the current
surface is a strict superset of the released one. Read against the shipped
`_prop_names` of every published wheel (`0.0.1`, `0.0.2`, `0.0.3`, `0.0.4`),
not against release notes.

**Three defaults changed.** That is the entire breaking surface, and it is
the part a compatibility shim could not have helped with anyway. If your app
sets these three props explicitly, the upgrade is a version bump and nothing
else.

### The three defaults

| Prop | `0.0.x` default | Current default | Keep the old behaviour with |
|:-----|:----------------|:----------------|:----------------------------|
| `isCollaborating` | `True` | `False` | `isCollaborating=True` |
| `height` | `"400px"` | `"600px"` | `height="400px"` |
| `langCode` | *(unset)* | `"en"` | `langCode=None` |

`isCollaborating` is the one most likely to be visible: it drives the
collaborator UI, so a canvas that used to show it now does not. `height` is
the one most likely to be *invisible* in a review and obvious in production
— every canvas grows by 200px unless a stylesheet was already overriding it.

For the avoidance of doubt, `gridModeEnabled` did **not** change. It
defaulted to `False` in `0.0.4` and it defaults to `False` now. Some
migration notes claim otherwise.

```python
# The upgrade, stated as a diff, for an app that relied on 0.0.x defaults.
DashExcalidraw(
    id="canvas",
    isCollaborating=True,   # was the default; now opt in
    height="400px",         # was the default; now 600px
    langCode=None,          # was unset; now "en"
)
```

### What was added

Seventeen props are new since `0.0.4`. Nothing you already wrote needs to
change to accommodate them — they are additive.

- **Command dispatch.** `command` accepts `{id, type, payload}` and covers
  twelve imperative Excalidraw methods. See [Command dispatch](/commands).
- **Event snapshots.** `lastPointerDown`, `lastPointerUp`, `lastPointerMove`,
  `lastScrollChange`, `lastPaste`, `lastLibraryChange`, `lastLinkOpen`,
  `lastExport`, `lastFileAdded` and `lastExternalDrop` — every Excalidraw
  callback surfaced as a timestamped prop. See [Events](/events).
- **Persistence.** `externalizedSerializedData` and `sceneVersion`. See
  [Persistence](/persistence) and [File uploads](/file-uploads).
- **Behaviour switches.** `interceptLinkOpens`, `hideExcalidrawLinks`,
  `pointerMoveThrottleMs`, `scrollThrottleMs`.

### Props that never existed

A migration table circulating elsewhere lists `onPaste`, `onPointerUpdate`,
`onScrollChange`, `onLibraryChange`, `onLinkOpen`, `excalidrawAPI`,
`renderTopRightUI`, `renderCustomStats`, `renderEmbeddable` and
`generateIdForFile` as things to migrate away from.

**None of them was ever a Dash prop in any published release.** They are
names from Excalidraw's underlying React API. What the wheels actually
exposed was `0.0.1` with six props, and `0.0.2` through `0.0.4` with the
same twenty-one. If your `0.0.x` code passes any of those names, Dash was
already ignoring it, and removing it changes nothing.

This matters because it changes what you look for. There is no callback to
port and no `excalidrawAPI` handle to replace — those patterns were never
available from Python. What you are adopting is new capability, not a
replacement for something you lost.

### There is no compatibility shim

Deliberately, and not for lack of effort.

A shim exists to keep old names working. Since no name changed, there is
nothing for one to translate. The only thing a shim could do here is pin the
three old defaults — which would mean shipping a component whose behaviour
depends on which import you used, and freezing the defaults the rebuild
exists to correct.

Set the three props explicitly instead. It is three lines, it is greppable,
and it leaves your app saying what it means.

### Version pinning

`0.0.x` and the current release are not API-compatible in the defaults
described above, so pin deliberately rather than by accident. Pin at or
above the version named at the top of this page — that number is read from
the installed distribution, so it is the one this documentation was built
against.

If you need to stay on the old series while you migrate, pin
`dash-excalidraw==0.0.4` and upgrade once the three lines above are in
place.


---

*Source: /migration*

---

<!-- /persistence — https://excalidraw.2plot.dev/persistence/llms.txt -->

# Persistence

> Stream serializedData to a Store or a database and restore a scene later via updateScene.

---



### Overview

`serializedData` gives you the canonical Excalidraw envelope on every change —
stream it to a dcc.Store, write it back to a DB, restore it via `command` with
type='updateScene'.

### Live demo


### Source


```python
# File: docs/persistence/persistence.py

"""Persistence: save to dcc.Store and restore on reload."""

import json
import uuid

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, dcc, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("persistence-canvas")

CODE = """
# 1. observe serializedData
@callback(Output('store', 'data'),
          Input('canvas', 'serializedData'),
          prevent_initial_call=True)
def save(snapshot):
    return snapshot

# 2. on reload, hand the stored snapshot back as a command
@callback(Output('canvas', 'command'),
          Input('btn-restore', 'n_clicks'),
          State('store', 'data'),
          prevent_initial_call=True)
def restore(_, snapshot):
    data = json.loads(snapshot)
    return {'id': str(uuid.uuid4()),
            'type': 'updateScene',
            'payload': {'elements': data['elements'],
                        'appState': data.get('appState', {})}}
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dcc.Store(id="persistence-store", storage_type="session"),
        dmc.Group(
            [
                dmc.Badge(
                    id="persistence-indicator",
                    children="Waiting for changes",
                    color="gray",
                    variant="light",
                ),
                dmc.Button(
                    "Restore from store",
                    id="persistence-restore-btn",
                    variant="light",
                    color="teal",
                ),
                dmc.Button(
                    "Clear store",
                    id="persistence-clear-btn",
                    variant="subtle",
                    color="red",
                ),
            ],
            justify="flex-start",
        ),
        canvas_frame(
            DashExcalidraw(
                id="persistence-canvas",
                height="600px",
            )
        ),
    ],
)


@callback(
    Output("persistence-store", "data"),
    Output("persistence-indicator", "children"),
    Output("persistence-indicator", "color"),
    Input("persistence-canvas", "serializedData"),
    Input("persistence-clear-btn", "n_clicks"),
    prevent_initial_call=True,
)
def _save(serialized, _clear_clicks):
    trigger = dash.ctx.triggered_id
    if trigger == "persistence-clear-btn":
        return None, "Cleared", "red"
    if not serialized:
        return no_update, "No data yet", "gray"
    # Excalidraw fires onChange once on mount with an empty scene.
    # Without this guard, that first emit would clobber whatever the
    # store had from a previous session the moment the page loaded —
    # then clicking Restore would hand back an empty canvas.
    try:
        parsed = json.loads(serialized)
    except (TypeError, ValueError):
        return no_update, "Invalid snapshot", "yellow"
    if not parsed.get("elements"):
        return no_update, "Waiting for content…", "gray"
    return serialized, "Saved", "green"


@callback(
    Output("persistence-canvas", "command"),
    Input("persistence-restore-btn", "n_clicks"),
    State("persistence-store", "data"),
    prevent_initial_call=True,
)
def _restore(_clicks, snapshot):
    if not snapshot:
        return no_update
    try:
        parsed = json.loads(snapshot)
    except (TypeError, ValueError):
        return no_update
    return {
        "id": f"restore-{uuid.uuid4()}",
        "type": "updateScene",
        "payload": {
            "elements": parsed.get("elements", []),
            "appState": parsed.get("appState", {}),
        },
    }
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /persistence*

---

<!-- /scene-viewer — https://excalidraw.2plot.dev/scene-viewer/llms.txt -->

# Scene viewer

> Paste or upload raw Excalidraw scene JSON — a trace, a .excalidraw file, a model's reply — and render it on a live canvas.

---



### Overview

[Trace an image](/trace-image) hands its drawing back as **scene JSON**. This
page is the other half of that loop: paste scene JSON in, press **Render**, and
the canvas draws it. Edit the drawing, press **Canvas → JSON**, and the box
holds the edited scene, ready to copy out again.

No model is called, so nothing here spends anything, and the page works on
this site even though the AI pages cannot run here. A trace made on a local
checkout renders here the same way.

### What it accepts

| Paste this | Where it comes from |
|:-----------|:--------------------|
| `{"type": "excalidraw", "elements": [...], "appState": {...}, "files": {...}}` | `/trace-image`'s Scene JSON panel, `serializedData`, a `.excalidraw` file from excalidraw.com |
| `{"elements": [...], "appState": {...}}` | Anything shaped like [`initialData`](/initial-data) or `welcomeScene` |
| `[{...}, {...}]` | A bare element array |
| A model's raw reply | [AI agent](/ai-agent) output: a markdown code fence, a sentence before the object and trailing commas are all tolerated |
| `"{\"elements\": ...}"` | `serializedData` copied out of a `dcc.Store` or a log, which is a JSON *string* and arrives quoted once more than it should |

**Upload file** takes a `.json` or `.excalidraw` file and does the same thing.
A `.excalidrawlib` library file is refused with a pointer to
[Library](/library): it holds library items, not a scene.

### What Excalidraw would drop without saying so

Excalidraw's loader is forgiving, and the price is that it throws things away
without saying anything. An element of an unknown type, a `selection` element,
a zero-sized shape and a line with fewer than two points all vanish on load.
The drawing just comes out shorter. The page does the same checks in Python
first and says what it found, so a 30-element scene that draws 26 comes with
the reason for the missing four:

- **Skipped types.** The list of types that survive was read from the pinned
  Excalidraw 0.18 bundle's `restoreElement`, not remembered.
- **Invisibly small elements.** These mirror `isInvisiblySmallElement`.
- **Deleted elements.** `isDeleted: true` elements stay in the scene but are
  not drawn.
- **Duplicate ids.** Excalidraw gives each repeat a fresh random id, so an
  arrow bound to that id can end up attached to the other copy.
- **Images with no bytes.** A trace's JSON comes from
  `externalizedSerializedData`, which strips every inline `data:` URI to
  `null`. An image with no bytes draws as an empty placeholder. Serve the file
  and point the scene at a URL; [File uploads](/file-uploads) is that pattern.

Only `viewBackgroundColor`, `gridSize` and `gridStep` are applied from a pasted
`appState`. Theme, view mode, zen mode and grid mode are this component's
props, and a scene that set them would fight the props. Scroll and zoom are
worked out again when the page fits the scene to the canvas.

### How a render works: a command queue

`initialData` is **mount-only**. It gives the canvas its first scene (the
sample, here), and changing it afterwards does nothing. Every render after that
goes through [command dispatch](/commands), and one render takes four commands:

1. `resetScene` clears the elements, the undo history and the previous scene's
   background.
2. `updateScene` loads the scene with `captureUpdate: "NEVER"`, so the first
   Ctrl+Z does not undo the whole load and leave an empty canvas.
3. `replaceFiles` sends the image bytes, if there are any. It is not
   `addFiles`, because `addFiles` does nothing when the canvas already holds
   that id, and the second render of the same scene would keep the old image.
4. `scrollToContent` with `fitToContent` fits the drawing to the view. It
   zooms out to fit and never zooms in past 100%.

The canvas holds **one** `command` at a time. It runs the command and then sets
`command` back to `None`. You cannot return all four from one callback, because
each would overwrite the one before it ran. So the render callback sends the
first command and parks the rest in a `dcc.Store`. A second callback, fired by
`command` going back to `None`, sends the next one:

```python
@callback(Output("canvas", "command"),
          Output("queue", "data", allow_duplicate=True),
          Input("canvas", "command"),
          State("queue", "data"),
          prevent_initial_call=True)
def next_command(current, queue):
    if current is not None or not queue:  # busy, or nothing left
        return no_update, no_update
    head, *rest = queue
    return head, rest
```

You can reuse this for any sequence of commands whose order matters.

### Live demo


### Source


```python
# File: docs/scene-viewer/scene_viewer.py

"""Scene viewer: paste or upload raw scene JSON and render it on a live canvas.

The other half of /trace-image. That page hands a trace back as scene JSON;
this one takes scene JSON — a trace, a `.excalidraw` file, a model's reply, a
bare element array — and draws it. No model is called, so nothing here spends
anything and the page works on a deployment with no provider keys.

The parsing and the "what will Excalidraw silently drop" checks live in
lib/scene_json.py so they can be tested without a browser. What is left here
is the one thing a Dash page has to get right: the canvas takes ONE command at
a time, and a render is four of them.
"""

from __future__ import annotations

import base64
import binascii
import json
import uuid

import dash
import dash_mantine_components as dmc
from dash import Input, Output, State, callback, clientside_callback, dcc, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, sync_canvas_theme
from lib.scene_json import MAX_SCENE_BYTES, SceneError, build_render, parse_scene_text

sync_canvas_theme("scene-viewer-canvas")


# ---------------------------------------------------------------------------
#  The sample — hand-written, in the shape /trace-image hands back
# ---------------------------------------------------------------------------
#
# NOT a real trace, and labelled as such on the page: it is a small scene
# written to the same envelope `externalizedSerializedData` produces, so the
# textarea opens on something a reader can edit and re-render straight away.

def _base(el_id, kind, x, y, width, height, stroke="#1e1e1e", bg="transparent",
          seed=1, **extra):
    return {
        "id": el_id, "type": kind, "x": x, "y": y,
        "width": width, "height": height, "angle": 0,
        "strokeColor": stroke, "backgroundColor": bg, "fillStyle": "solid",
        "strokeWidth": 2, "strokeStyle": "solid", "roughness": 1,
        "opacity": 100, "groupIds": [], "frameId": None,
        "roundness": {"type": 3} if kind in ("rectangle", "diamond") else (
            {"type": 2} if kind == "ellipse" else None),
        "seed": seed, "version": 1, "versionNonce": seed, "isDeleted": False,
        "boundElements": [], "updated": 1, "link": None, "locked": False,
        **extra,
    }


def _text(el_id, text, cx, cy, size=16, color="#1e1e1e", container=None, seed=1):
    lines = text.split("\n")
    width = round(max(len(line) for line in lines) * size * 0.6)
    height = round(size * 1.25 * len(lines))
    return _base(
        el_id, "text", round(cx - width / 2), round(cy - height / 2), width, height,
        stroke=color, seed=seed, text=text, originalText=text, fontSize=size,
        fontFamily=2, textAlign="center",
        verticalAlign="middle" if container else "top",
        containerId=container, lineHeight=1.25, autoResize=True,
    )


def _box(el_id, kind, label, x, y, stroke, bg, seed):
    """A shape with its label bound inside it, both halves of the binding set."""
    w, h = 200, 80
    shape = _base(el_id, kind, x, y, w, h, stroke=stroke, bg=bg, seed=seed,
                  boundElements=[{"id": f"{el_id}-label", "type": "text"}])
    label_el = _text(f"{el_id}-label", label, x + w / 2, y + h / 2,
                     color=stroke, container=el_id, seed=seed + 100)
    return shape, label_el


def _arrow(el_id, start, end, x, y, dx, dy, seed):
    return _base(
        el_id, "arrow", x, y, abs(dx), abs(dy), seed=seed,
        points=[[0, 0], [dx, dy]], lastCommittedPoint=None,
        startBinding={"elementId": start, "focus": 0, "gap": 4},
        endBinding={"elementId": end, "focus": 0, "gap": 4},
        startArrowhead=None, endArrowhead="arrow", elbowed=False,
    )


def _sample_scene() -> dict:
    ref, ref_label = _box("ref", "rectangle", "Reference image",
                          60, 140, "#4263eb", "#dbe4ff", 11)
    model, model_label = _box("model", "ellipse", "Vision model",
                              380, 140, "#0ca678", "#c3fae8", 21)
    scene_json, scene_label = _box("json", "rectangle", "Scene JSON",
                                   700, 140, "#e67700", "#ffe8cc", 31)
    viewer, viewer_label = _box("viewer", "rectangle", "This canvas",
                                700, 320, "#ae3ec9", "#eebefa", 41)
    arrows = [
        _arrow("a1", "ref", "model", 264, 180, 112, 0, 51),
        _arrow("a2", "model", "json", 584, 180, 112, 0, 52),
        _arrow("a3", "json", "viewer", 800, 224, 0, 92, 53),
    ]
    # Each arrow is listed on both shapes it binds, or dragging a shape
    # leaves the arrow behind.
    for arrow in arrows:
        for shape in (ref, model, scene_json, viewer):
            if shape["id"] in (arrow["startBinding"]["elementId"],
                               arrow["endBinding"]["elementId"]):
                shape["boundElements"].append({"id": arrow["id"], "type": "arrow"})

    title = _text("title", "Trace → scene JSON → canvas", 480, 60, size=28,
                  color="#1e3a8a", seed=61)
    caption = _text(
        "caption",
        "A hand-written sample in the shape /trace-image hands back.\n"
        "Edit the JSON, or replace it with your own, and press Render.",
        300, 380, size=14, color="#6b7280", seed=62,
    )
    caption["textAlign"] = "left"
    return {
        "type": "excalidraw",
        "version": 2,
        "source": "https://excalidraw.2plot.dev/scene-viewer",
        "elements": [title, ref, ref_label, model, model_label, scene_json,
                     scene_label, viewer, viewer_label, *arrows, caption],
        "appState": {"viewBackgroundColor": "#ffffff", "gridSize": 20},
        "files": {},
    }


SAMPLE_SCENE = _sample_scene()
SAMPLE_TEXT = json.dumps(SAMPLE_SCENE, indent=2)


# ---------------------------------------------------------------------------
#  Layout
# ---------------------------------------------------------------------------

component = dmc.Stack(
    gap="sm",
    children=[
        # STACKED, not side by side. The canvas IS the output, and in this
        # site's content column (sidebar left, table of contents right) a
        # 7/12 column left it about 300px wide — too narrow to read a trace.
        dmc.Textarea(
            id="scene-viewer-input",
            label="Scene JSON",
            description=(
                "A trace from /trace-image, a .excalidraw file, a model's reply, "
                "or a bare element array"
            ),
            value=SAMPLE_TEXT,
            autosize=False,
            # DMC 2.8 rejects `spellcheck=` as a kwarg; Mantine's `attributes`
            # reaches the <textarea>. Without it every key in the JSON is
            # underlined red.
            attributes={"input": {"spellCheck": "false"}},
            styles={
                "input": {
                    "fontFamily": "var(--mantine-font-family-monospace)",
                    "fontSize": 12,
                    "height": 260,
                }
            },
            **{"aria-label": "Scene JSON to render"},
        ),
        dmc.Group(
            gap="xs",
            children=[
                dmc.Button("Render", id="scene-viewer-render", color="indigo"),
                dcc.Upload(
                    id="scene-viewer-upload",
                    multiple=False,
                    accept=".json,.excalidraw,application/json",
                    max_size=MAX_SCENE_BYTES,
                    children=dmc.Button("Upload file", variant="light", color="indigo"),
                ),
                dmc.Button(
                    "Canvas → JSON", id="scene-viewer-pull", variant="light", color="gray"
                ),
                dmc.Button("Sample", id="scene-viewer-sample", variant="subtle", color="gray"),
                dmc.Button("Clear", id="scene-viewer-clear", variant="subtle", color="gray"),
            ],
        ),
        dmc.Alert(
            id="scene-viewer-status",
            color="gray",
            variant="light",
            children=(
                "Showing the sample. Paste a scene over it, or upload a .json / "
                ".excalidraw file."
            ),
        ),
        canvas_frame(
            DashExcalidraw(
                id="scene-viewer-canvas",
                height="560px",
                # The FIRST scene only. initialData is mount-only; every render
                # after this one is the command queue below.
                initialData=SAMPLE_SCENE,
            ),
            min_height=560,
        ),
        # The commands still to send, in order. See `_next_command`.
        dcc.Store(id="scene-viewer-queue", data=[]),
        # Whether the opening scene has been fitted yet. See `_fit_once`.
        dcc.Store(id="scene-viewer-fitted", data=False),
    ],
)


# ---------------------------------------------------------------------------
#  Callbacks
# ---------------------------------------------------------------------------


def _decode_upload(contents: str, filename: str | None) -> str:
    """A dcc.Upload data URL -> the file's text."""
    if not contents or "," not in contents:
        raise SceneError("The upload arrived empty.")
    try:
        raw = base64.b64decode(contents.split(",", 1)[1], validate=True)
    except (binascii.Error, ValueError):
        raise SceneError(f"{filename or 'That file'} could not be read.") from None
    try:
        return raw.decode("utf-8-sig")
    except UnicodeDecodeError:
        raise SceneError(
            f"{filename or 'That file'} is not text. A scene is a .json or "
            f".excalidraw file; a PNG with an embedded scene is not supported here."
        ) from None


def _rich(text: str) -> list:
    """`backticked` spans as inline code. The notes are written for a reader,
    in lib/scene_json.py, with backticks round the names they quote."""
    parts = str(text).split("`")
    return [dmc.Code(part) if i % 2 else part for i, part in enumerate(parts) if part]


def _summary(render: dict) -> str:
    drawn = render["drawn"]
    parts = [f"{drawn} element{'' if drawn == 1 else 's'} drawn"]
    if render["images"]:
        parts.append(f"{render['images']} image{'' if render['images'] == 1 else 's'}")
    background = render["app_state"].get("viewBackgroundColor")
    if background:
        parts.append(f"background {background}")
    return " · ".join(parts)


@callback(
    Output("scene-viewer-canvas", "command", allow_duplicate=True),
    Output("scene-viewer-queue", "data"),
    Output("scene-viewer-input", "value"),
    Output("scene-viewer-status", "children"),
    Output("scene-viewer-status", "color"),
    Output("scene-viewer-status", "title"),
    Input("scene-viewer-render", "n_clicks"),
    Input("scene-viewer-upload", "contents"),
    Input("scene-viewer-sample", "n_clicks"),
    Input("scene-viewer-clear", "n_clicks"),
    State("scene-viewer-upload", "filename"),
    State("scene-viewer-input", "value"),
    prevent_initial_call=True,
)
def _render(_render, upload, _sample, _clear, filename, text):
    """Parse whatever the reader supplied and start the command queue.

    Sends the FIRST command and parks the rest in the Store; `_next_command`
    feeds them to the canvas one at a time.
    """
    trigger = dash.ctx.triggered_id
    if trigger == "scene-viewer-clear":
        return (
            {"id": f"scene-clear-{uuid.uuid4().hex[:8]}",
             "type": "resetScene", "payload": {}},
            [], "", "Canvas cleared.", "gray", None,
        )

    new_text = no_update
    source = "the pasted JSON"
    try:
        if trigger == "scene-viewer-upload":
            text = new_text = _decode_upload(upload, filename)
            source = filename or "the uploaded file"
        elif trigger == "scene-viewer-sample":
            text = new_text = SAMPLE_TEXT
            source = "the sample"
        render = build_render(parse_scene_text(text))
    except SceneError as exc:
        return (no_update, no_update, new_text, dmc.Text(_rich(exc), size="sm"),
                "red", f"Could not render {source}")

    first, *rest = render["commands"]
    notes = render["notes"]
    body = dmc.Stack(gap=4, children=[dmc.Text(_summary(render), size="sm")] + [
        dmc.Text(["• ", *_rich(note)], size="sm") for note in notes
    ])
    return (
        first, rest, new_text, body,
        "yellow" if notes else "green",
        f"Rendered {source}" + (" — with notes" if notes else ""),
    )


@callback(
    Output("scene-viewer-canvas", "command"),
    Output("scene-viewer-queue", "data", allow_duplicate=True),
    Input("scene-viewer-canvas", "command"),
    State("scene-viewer-queue", "data"),
    prevent_initial_call=True,
)
def _next_command(current, queue):
    """Feed the canvas the next queued command once it has finished the last.

    THE CANVAS HOLDS ONE COMMAND. It runs it, then sets `command` back to None
    — and that write is this callback's trigger. Returning four commands from
    one callback is not an option (each would overwrite the last before it
    ran), so a render is a queue: `_render` sends the head, and every time the
    canvas reports itself idle this sends the next. When the queue is empty
    it does nothing, which is how the chain ends.

    `current` is not None when the trigger was a callback SETTING a command
    rather than the canvas clearing one; that is not our turn.
    """
    if current is not None or not queue:
        return no_update, no_update
    head, *rest = queue
    return head, rest


@callback(
    Output("scene-viewer-input", "value", allow_duplicate=True),
    Output("scene-viewer-status", "children", allow_duplicate=True),
    Output("scene-viewer-status", "color", allow_duplicate=True),
    Output("scene-viewer-status", "title", allow_duplicate=True),
    Input("scene-viewer-pull", "n_clicks"),
    State("scene-viewer-canvas", "serializedData"),
    prevent_initial_call=True,
)
def _pull(_clicks, serialized):
    """The canvas as it is now — tidied, edited, whatever — back into the box.

    `serializedData`, not `externalizedSerializedData`: this is a round trip,
    and the externalized form nulls every inline image, so pulling a scene
    with pictures in it and rendering it again would lose them.
    """
    if not serialized:
        return no_update, "The canvas has not reported a scene yet — draw something first.", "gray", None
    try:
        scene = json.loads(serialized)
    except (TypeError, ValueError):
        return no_update, "The canvas produced no readable scene.", "red", None
    count = len([e for e in scene.get("elements") or [] if not e.get("isDeleted")])
    return (
        json.dumps(scene, indent=2),
        f"Copied the canvas into the box — {count} element{'' if count == 1 else 's'}.",
        "gray",
        None,
    )


# THE OPENING SCENE, FITTED ONCE. `initialData.scrollToContent` only centres
# the scene, it does not zoom, and a fit command cannot go at page load: the
# API the command waits for exists before Excalidraw has finished loading
# initialData, so it would frame an empty scene. The first `elements` write
# with content IS the "scene loaded" signal.
#
# Even that can be too early on its own. The app shell applies the table of
# contents' offset AFTER first paint — a callback sets the aside, and main's
# padding-right then transitions 32px -> 312px over 200ms — so the canvas
# narrows under a fit already sent. MEASURED on a 1728px viewport: 1316px wide
# before the offset, 1036px after, with the fit going out at 1036 once this
# wait was in. Excalidraw keeps its scroll through a resize, so a fit framed
# for the wider canvas leaves the scene off-centre with its right edge cut
# off. The fit waits until the canvas has held one width for 300ms (capped at
# 2s).
# Clientside, because it is one command, and deciding "already done" on the
# server would cost a round trip per canvas change.
clientside_callback(
    """
    async function(elements, fitted) {
        const skip = window.dash_clientside.no_update;
        if (fitted || !elements || !elements.length) { return [skip, skip]; }
        const box = document.getElementById("scene-viewer-canvas");
        const began = performance.now();
        let width = -1, steadySince = began;
        while (box && performance.now() - began < 2000) {
            const now = box.getBoundingClientRect().width;
            if (now !== width) { width = now; steadySince = performance.now(); }
            else if (performance.now() - steadySince > 300) { break; }
            await new Promise((done) => setTimeout(done, 50));
        }
        return [
            {id: "scene-opening-fit", type: "scrollToContent",
             payload: {opts: {fitToContent: true}}},
            true,
        ];
    }
    """,
    Output("scene-viewer-canvas", "command", allow_duplicate=True),
    Output("scene-viewer-fitted", "data"),
    Input("scene-viewer-canvas", "elements"),
    State("scene-viewer-fitted", "data"),
    prevent_initial_call=True,
)
```

    :defaultExpanded: false
    :withExpandedButton: true


```python
# File: lib/scene_json.py

"""Raw scene JSON -> the commands that put it on a live canvas (/scene-viewer).

The page takes whatever a reader is holding — the scene JSON /trace-image
hands back, a `.excalidraw` file saved from excalidraw.com, a model's raw reply
on /ai-agent, a bare element array — and renders it. Everything that can be
decided without a browser is decided here, in pure Python, so the tests can
reach it without one:

* whether the text is a scene at all, and if not, a message saying why;
* what Excalidraw would throw away WITHOUT A WORD, said out loud first;
* the ordered commands that replace the canvas with the scene.

Why commands and not `initialData`: initialData is mount-only, which is
Excalidraw's rule before it is this component's. A second paste is a
post-mount change like any other, so it goes through the command dispatch.

Nothing here is part of the published dash-excalidraw package; it belongs to
the documentation site.
"""

from __future__ import annotations

import json
import uuid
from collections import Counter

from lib.scene_ai import _cleanup_json, _extract_json_block

# A `.excalidraw` file with a few inline photos is a few MB of base64; ten
# times that is a paste nobody meant to make, and it would travel through
# every command below.
MAX_SCENE_BYTES = 8 * 1024 * 1024

# The element types Excalidraw 0.18's `restoreElements` keeps. READ FROM THE
# PINNED BUNDLE, not recalled: its `restoreElement` switch handles exactly
# these, returns null for anything else, and `restoreElements` skips
# `selection` before the switch. "draw" is the legacy name it migrates to
# `line`. An element of any other type vanishes with nothing logged.
KNOWN_TYPES = frozenset({
    "rectangle", "ellipse", "diamond", "text", "arrow", "line", "draw",
    "freedraw", "image", "frame", "magicframe", "embeddable", "iframe",
})
LINEAR_TYPES = frozenset({"arrow", "line", "draw", "freedraw"})

# The appState a pasted scene may set. Everything else in a saved appState is
# either this component's to control (theme, viewModeEnabled, zen and grid
# mode are props, and a scene that set them would fight the props) or view
# state that `scrollToContent` recomputes anyway (scroll, zoom).
APPSTATE_KEYS = ("viewBackgroundColor", "gridSize", "gridStep")

# What an image file's `dataURL` may be for the canvas to load it: inline
# image bytes, or a URL (what `replaceFiles` leaves behind on /file-uploads).
# Not `data:text/html`, not `javascript:` — this string becomes an <img src>.
_LOADABLE_PREFIXES = ("data:image/", "https://", "http://", "/")


class SceneError(ValueError):
    """The text is not a scene this page can render. The message is for the
    reader, so it names the problem in their terms, not the parser's."""


def _strip_fences(text: str) -> str:
    """```json ... ``` -> the inside. Arrays as well as objects, which is why
    this is not `_extract_json_block` — that one looks for the first `{` and
    would return the first ELEMENT of a fenced array as if it were the scene."""
    t = text.strip()
    if t.startswith("```"):
        newline = t.find("\n")
        t = t[newline + 1:] if newline > 0 else ""
        t = t.rstrip()
        if t.endswith("```"):
            t = t[:-3]
    return t.strip()


def _loads(text: str):
    """json.loads, then the forgiving readings a model's reply needs."""
    try:
        return json.loads(text)
    except ValueError as exc:
        first = exc
    fenced = _strip_fences(text)
    for candidate in (
        fenced,
        _cleanup_json(fenced),
        # Prose around the object ("Here is your diagram: {...}").
        _cleanup_json(_extract_json_block(text)),
    ):
        try:
            return json.loads(candidate)
        except ValueError:
            continue
    if isinstance(first, json.JSONDecodeError):
        raise SceneError(
            f"Not valid JSON: {first.msg} at line {first.lineno}, "
            f"column {first.colno}."
        ) from None
    raise SceneError("Not valid JSON.") from None


def parse_scene_text(text) -> dict:
    """Decode pasted or uploaded text into a scene dict with an `elements` list.

    Raises SceneError with a reader-facing message for anything else.
    """
    if text is None or not str(text).strip():
        raise SceneError("Nothing to render — paste scene JSON or upload a file.")
    text = str(text)
    size = len(text.encode("utf-8"))
    if size > MAX_SCENE_BYTES:
        raise SceneError(
            f"That is {size / 1024 / 1024:.1f} MB of JSON; the limit here is "
            f"{MAX_SCENE_BYTES // 1024 // 1024} MB. A scene that size is almost "
            f"always inline images — externalize them first (see File uploads)."
        )

    data = _loads(text)
    # `serializedData` is a JSON STRING, so a scene copied out of a dcc.Store,
    # a log line or a JSON dump arrives quoted once more than it should be.
    if isinstance(data, str):
        data = _loads(data)
    if isinstance(data, list):
        data = {"elements": data}
    if not isinstance(data, dict):
        raise SceneError(
            f"Expected a scene object or an array of elements; this JSON is "
            f"a {type(data).__name__}."
        )
    if data.get("type") == "excalidrawlib" or (
        "libraryItems" in data and "elements" not in data
    ):
        raise SceneError(
            "That is a library file (.excalidrawlib), not a scene. Libraries "
            "load through `updateLibrary` — see the Library page."
        )
    if "elements" not in data:
        raise SceneError(
            "This is JSON, but not an Excalidraw scene: there is no "
            "`elements` key."
        )
    if not isinstance(data["elements"], list):
        raise SceneError("`elements` has to be a list of element objects.")
    return data


def _invisibly_small(el: dict) -> bool:
    """Mirror of Excalidraw's `isInvisiblySmallElement`, which
    `restoreElements` uses to drop an element before it is ever drawn."""
    if el.get("type") in LINEAR_TYPES:
        points = el.get("points")
        return not isinstance(points, list) or len(points) < 2
    return el.get("width") == 0 and el.get("height") == 0


def _plural(n: int, word: str) -> str:
    return f"{n} {word}{'' if n == 1 else 's'}"


def build_render(scene: dict, token: str | None = None) -> dict:
    """The commands that replace a canvas with `scene`, and what to tell the
    reader about it.

    Returns ``{"commands": [...], "drawn": int, "images": int, "notes": [...]}``.
    `commands` are in dispatch order and must go ONE AT A TIME — the canvas
    holds a single `command` and clears it when done, so a caller queues the
    tail and sends the next when it sees `command` go back to None.
    """
    token = token or uuid.uuid4().hex[:8]
    notes: list[str] = []

    kept: list[dict] = []
    not_objects = 0
    unknown: Counter = Counter()
    too_small = 0
    deleted = 0
    for el in scene.get("elements") or []:
        if not isinstance(el, dict):
            not_objects += 1
            continue
        kind = el.get("type")
        if kind not in KNOWN_TYPES:
            unknown[str(kind)] += 1
            continue
        if _invisibly_small(el):
            too_small += 1
            continue
        if el.get("isDeleted"):
            deleted += 1
        kept.append(el)

    ids = Counter(el.get("id") for el in kept if el.get("id"))
    duplicated = sum(n - 1 for n in ids.values() if n > 1)
    drawn = len(kept) - deleted

    # ---- files: which images can actually be shown -----------------------
    files = scene.get("files")
    if files is not None and not isinstance(files, dict):
        notes.append("`files` is not an object keyed by file id, so it was ignored.")
        files = None
    loadable: dict[str, dict] = {}
    for file_id, entry in (files or {}).items():
        if not isinstance(entry, dict):
            continue
        url = entry.get("dataURL")
        if isinstance(url, str) and url.startswith(_LOADABLE_PREFIXES):
            mime = entry.get("mimeType")
            if not mime and url.startswith("data:"):
                mime = url[5:].split(";", 1)[0].split(",", 1)[0]
            loadable[str(file_id)] = {"dataURL": url, "mimeType": mime or "image/png"}
    images = [el for el in kept if el.get("type") == "image" and not el.get("isDeleted")]
    no_bytes = sum(1 for el in images if el.get("fileId") not in loadable)

    # ---- what to say -----------------------------------------------------
    if not_objects:
        notes.append(f"{_plural(not_objects, 'entry')} in `elements` "
                     f"{'is' if not_objects == 1 else 'are'} not an object — skipped.")
    for kind, n in sorted(unknown.items()):
        label = "`selection`" if kind == "selection" else f"type `{kind}`"
        notes.append(f"{_plural(n, 'element')} of {label} skipped — Excalidraw "
                     f"drops that type on load without saying so.")
    if too_small:
        notes.append(f"{_plural(too_small, 'element')} skipped as invisibly small "
                     f"(zero width and height, or a line with fewer than two "
                     f"points) — Excalidraw would drop them too.")
    if deleted:
        notes.append(f"{_plural(deleted, 'element')} marked `isDeleted` "
                     f"{'is' if deleted == 1 else 'are'} kept in the scene but not drawn.")
    if duplicated:
        notes.append(f"{_plural(duplicated, 'duplicate id')} — Excalidraw gives "
                     f"each repeat a fresh random id, so an arrow bound to that "
                     f"id may attach to the other copy.")
    if no_bytes:
        notes.append(f"{_plural(no_bytes, 'image')} {'has' if no_bytes == 1 else 'have'} "
                     f"no loadable bytes (a `null` dataURL is what "
                     f"`externalizedSerializedData` leaves), so "
                     f"{'it draws' if no_bytes == 1 else 'they draw'} as an "
                     f"empty placeholder. Serve the file and point the scene at it — "
                     f"see File uploads.")
    if not drawn:
        notes.append("Nothing visible to draw — the canvas is cleared.")

    # ---- the commands, in order -----------------------------------------
    app_state = {}
    raw_state = scene.get("appState")
    if isinstance(raw_state, dict):
        app_state = {k: raw_state[k] for k in APPSTATE_KEYS if k in raw_state}

    commands = [
        # 1. A clean slate: elements, history and the previous scene's
        #    background. The component re-asserts its own mode props after.
        {"id": f"scene-{token}-reset", "type": "resetScene", "payload": {}},
        # 2. The scene. NEVER, so the first Ctrl+Z after loading does not
        #    unwind the whole load back to an empty canvas.
        {
            "id": f"scene-{token}-load",
            "type": "updateScene",
            "payload": {
                "elements": kept,
                "appState": app_state,
                "captureUpdate": "NEVER",
            },
        },
    ]
    if loadable:
        # 3. The image bytes. `replaceFiles`, not `addFiles`, on purpose:
        #    `addFiles` with an id the canvas already holds is a silent no-op
        #    (measured — see the component's replaceFiles comment), so the
        #    SECOND render of a scene, or a scene reusing an id with new bytes,
        #    would keep the old image. replaceFiles stores under a fresh id and
        #    repoints the image elements, which works every time.
        commands.append({
            "id": f"scene-{token}-files",
            "type": "replaceFiles",
            "payload": loadable,
        })
    if drawn:
        # 4. Frame it. fitToContent zooms OUT to fit and never in past 100%,
        #    so a tiny scene is not blown up to fill the canvas.
        commands.append({
            "id": f"scene-{token}-fit",
            "type": "scrollToContent",
            "payload": {"opts": {"fitToContent": True}},
        })

    return {
        "commands": commands,
        "drawn": drawn,
        "images": len(images),
        "notes": notes,
        "app_state": app_state,
    }
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /scene-viewer*

---

<!-- /theming — https://excalidraw.2plot.dev/theming/llms.txt -->

# Theming

> Light and dark canvas themes, and how to keep them in step with your Mantine colour scheme.

---



### Overview

Set `theme='light'` or `'dark'` — Excalidraw repaints the canvas, toolbar, and
library accordingly. Pair with dmc.MantineProvider color-scheme for a
consistent look.

### Live demo


### Source


```python
# File: docs/theming/theming.py

"""Theming: light/dark mode wired to a segmented control."""

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block

CODE = """
from dash import Input, Output, callback

@callback(
    Output('canvas', 'theme'),
    Input('theme-picker', 'value'),
)
def set_theme(theme):
    return theme
"""


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dmc.Group(
            justify="flex-start",
            children=[
                dmc.SegmentedControl(
                    id="theme-picker",
                    value="light",
                    data=[
                        {"label": "Light", "value": "light"},
                        {"label": "Dark", "value": "dark"},
                    ],
                ),
            ],
        ),
        canvas_frame(
            DashExcalidraw(
                id="theming-canvas",
                height="600px",
                theme="light",
            )
        ),
    ],
)


@callback(
    Output("theming-canvas", "theme"),
    Input("theme-picker", "value"),
)
def _set_theme(value):
    return value or "light"
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /theming*

---

<!-- /trace-image — https://excalidraw.2plot.dev/trace-image/llms.txt -->

# Trace an image

> Upload a reference image and have a vision model redraw it as an Excalidraw scene, then compare the two side by side.

---



> **On this site these pages are documentation, not a hosted service.**
> excalidraw.2plot.dev carries no provider keys, so generation is disabled
> here and nothing below will spend anything. Everything on the page —
> streaming onto the canvas, the cost estimate, the Stop button, the daily
> spend ceiling — works when you run the repo locally with a `.env` holding a
> provider key. This is deliberate and not a fault.

### Taking the trace away

Under the traced canvas is its **scene JSON** with a copy button. It is
`externalizedSerializedData`, not `serializedData` — every inline `data:` URI
is stripped to null, so what you copy is the drawing rather than a megabyte of
base64 for the reference image you fed in.

That object is the shape [`initialData`](/initial-data) takes, and the shape
the component's `welcomeScene` prop takes. So the loop is: upload a reference,
trace it, copy the JSON, and paste it in as the scene your own canvas opens
with. To look at a copied trace again later, or at one someone sent you, paste
it into [Scene viewer](/scene-viewer). It renders the JSON without calling a
model.

### Overview

[AI agent](/ai-agent) starts from a sentence. This page starts from a picture:
upload one, and a vision model is asked to rebuild it out of Excalidraw's
primitives. The reference sits on the left, the drawing on the right, so the
comparison is the output.

The interesting part is the constraint. Excalidraw has six element types —
rectangle, ellipse, diamond, text, arrow/line, freedraw — so a photograph
cannot be reproduced. The model has to decide what the image **is** and rebuild
that. A screenshot of a flowchart comes back as a flowchart. A photo of a cat
comes back as a disappointment, and that is a more useful thing to see than a
curated success.

### What traces well

| Reference | Result |
|:----------|:-------|
| Flowcharts, box-and-arrow diagrams | Usually close — the primitives match the source |
| Wireframes, UI mockups | Good structure, approximate text |
| Charts with clear axes and bars | Layout holds; exact values rarely do |
| Logos, simple icons | Recognisable when geometric, poor when organic |
| Photographs | Blocks of colour at best; this is the honest limit |

The prompt tells the model to prefer six clean shapes that capture the
arrangement over six hundred freedraw strokes that capture noise — the second
looks like effort and exhausts the token budget without improving the drawing.

### The controls

The same three levers as [AI agent](/ai-agent), because they matter here for
the same reasons:

- **Model** — only models that accept image input are listed, which was
  established by reading each provider's capability data rather than assuming.
  Claude and ChatGPT models are both offered.
- **Effort** — thinking depth. Tracing rewards more of it than diagram
  generation does: the model is reasoning about spatial relationships rather
  than emitting a familiar shape. Only levels a model actually accepts appear.
- **Max tokens** — covers thinking *and* output together. A dense reference
  needs a larger budget than a sentence-prompted diagram.

### Cost

The estimate above the button includes the image's own input tokens, which are
not negligible for a large upload — roughly `width x height / 750`. It is
shown before the run, not after, for the same reason as
[Benchmark](/benchmark): an estimate that arrives with the result is an apology
rather than a decision.

### Live demo


### Source


```python
# File: docs/trace-image/trace_image.py

"""Upload a reference image and ask a model to redraw it in Excalidraw.

The /ai-agent page starts from a sentence. This one starts from a picture, and
the interesting part is the constraint: Excalidraw has six primitives, so the
model cannot reproduce a photograph — it has to decide what the image IS and
rebuild that. A screenshot of a flowchart comes back as a flowchart; a photo of
a cat comes back as a disappointment. That gap is the point of the example.

DELIBERATELY LIMITED
- One image, one model, one drawing. Cross-model tracing belongs on
  /benchmark's model axis if it is ever wanted; here a second variant would
  double the bill for a page whose job is to show the mechanism.
- Synchronous, like /benchmark. The generate path on /ai-agent runs through a
  background job queue; that machinery earns its place there and would be
  noise here.
- Claude and ChatGPT only. Both take an image through the same `call_model`
  signature and report the same meta; Gemini's client takes neither a budget
  nor an effort, so a cost and a matched setting could not be shown.
"""

from __future__ import annotations

import base64
import binascii
import io
import json
import os
import time

import dash_mantine_components as dmc
from dash import Input, Output, State, callback, dcc, html, no_update

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, sync_canvas_theme
from lib import scene_stream, spend
from lib.scene_ai import (
    COMPARABLE_MODELS,
    EFFORT_LEVELS,
    MODEL_EFFORT,
    MODEL_LABEL,
    MODEL_MAX_TOKENS,
    MODEL_PRICING,
    NO_KEYS_NOTICE,
    PROVIDER_OF,
    TRACE_SYSTEM_PROMPT,
    VISION_MODELS,
    _spend_allowed,
    any_provider_configured,
    estimate_cost,
    format_money,
    image_input_tokens,
    supported_efforts,
)

sync_canvas_theme("trace-canvas")

# Only models that can actually see the image. VISION_MODELS was measured
# rather than assumed — see lib/scene_ai.py.
TRACE_MODELS = [m for m in COMPARABLE_MODELS if m["value"] in VISION_MODELS]

# Anthropic rejects images over 5 MB and OpenAI has its own ceiling; browsers
# will happily hand over a 12 MB phone photo. Refusing early beats a provider
# error several frames away, and the number is in bytes of DECODED image.
MAX_IMAGE_BYTES = 4 * 1024 * 1024

ACCEPTED = {"image/png", "image/jpeg", "image/gif", "image/webp"}

ANY_KEY = any_provider_configured()

HAS_CLAUDE_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
HAS_CHATGPT_KEY = bool(os.environ.get("CHATGPT_API_KEY")) or bool(
    os.environ.get("OPENAI_API_KEY")
)


def _decode_upload(contents: str) -> tuple[str, str, int, int, int]:
    """`(media_type, b64, byte_size, width, height)` from a dcc.Upload value.

    dcc.Upload hands over a data URL. Everything downstream needs the media
    type and the payload separately, and the dimensions are what make the
    image's share of the bill estimable instead of invisible.
    """
    if not contents or "," not in contents:
        raise ValueError("No image data in the upload.")
    header, payload = contents.split(",", 1)
    media_type = header.split(";")[0].removeprefix("data:") or ""
    if media_type not in ACCEPTED:
        raise ValueError(
            f"{media_type or 'that file'} is not an image the APIs accept. "
            f"Use PNG, JPEG, GIF or WebP."
        )
    try:
        raw = base64.b64decode(payload, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("That upload is not valid base64 image data.") from exc
    if len(raw) > MAX_IMAGE_BYTES:
        raise ValueError(
            f"Image is {len(raw) / 1024 / 1024:.1f} MB; the limit here is "
            f"{MAX_IMAGE_BYTES // 1024 // 1024} MB. Scale it down and retry."
        )
    try:
        from PIL import Image

        with Image.open(io.BytesIO(raw)) as im:
            width, height = im.size
    except Exception:  # noqa: BLE001 - dimensions are a nicety, not a gate
        width = height = 0
    return media_type, payload, len(raw), width, height


component = dmc.Stack(
    gap="md",
    children=[
        # Exactly one of these two shows. Which one depends on whether this
        # deployment can call anything at all — a spend warning on a site that
        # cannot spend is as misleading as a missing one on a site that can.
        dmc.Alert(
            NO_KEYS_NOTICE,
            title="AI generation is disabled on this site",
            color="blue",
            variant="light",
            style={} if not ANY_KEY else {"display": "none"},
        ),
        dmc.Alert(
            title="This page spends real API credits",
            color="yellow",
            style={"display": "none"} if not ANY_KEY else {},
            children=(
                "Tracing sends your image and a long instruction to a paid "
                "model. The estimate below includes the image's own input "
                "tokens — check it before running."
            ),
        ),
        dmc.Paper(
            withBorder=True,
            p="md",
            children=dmc.Stack(
                gap="sm",
                children=[
                    dcc.Upload(
                        id="trace-upload",
                        multiple=False,
                        accept="image/*",
                        children=dmc.Paper(
                            withBorder=True,
                            p="xl",
                            radius="md",
                            style={
                                "borderStyle": "dashed",
                                "cursor": "pointer",
                                "textAlign": "center",
                            },
                            children=dmc.Stack(
                                gap=4,
                                align="center",
                                children=[
                                    dmc.Text(
                                        "Drop a reference image here, or click "
                                        "to choose one",
                                        fw=600,
                                    ),
                                    dmc.Text(
                                        "PNG, JPEG, GIF or WebP · up to 4 MB · "
                                        "diagrams and screenshots trace far "
                                        "better than photographs",
                                        size="xs",
                                        c="dimmed",
                                    ),
                                ],
                            ),
                        ),
                    ),
                    html.Div(id="trace-preview"),
                    dmc.Grid(
                        gutter="md",
                        children=[
                            dmc.GridCol(
                                dmc.Select(
                                    id="trace-model",
                                    label="Model",
                                    description="Only models that accept images",
                                    data=TRACE_MODELS,
                                    value=TRACE_MODELS[0]["value"],
                                ),
                                span={"base": 12, "sm": 5},
                            ),
                            dmc.GridCol(
                                dmc.Select(
                                    id="trace-effort",
                                    label="Effort",
                                    description="Thinking depth",
                                    data=EFFORT_LEVELS,
                                    value=MODEL_EFFORT.get(TRACE_MODELS[0]["value"])
                                    or "low",
                                ),
                                span={"base": 6, "sm": 3},
                            ),
                            dmc.GridCol(
                                dmc.NumberInput(
                                    id="trace-max-tokens",
                                    label="Max tokens",
                                    description="Covers thinking + output",
                                    value=MODEL_MAX_TOKENS[TRACE_MODELS[0]["value"]],
                                    min=1000,
                                    max=128000,
                                    step=4000,
                                ),
                                span={"base": 6, "sm": 4},
                            ),
                        ],
                    ),
                    dmc.Textarea(
                        id="trace-note",
                        label="Extra instruction (optional)",
                        description=(
                            "Anything the image cannot say for itself — "
                            "'ignore the background', 'keep it monochrome'"
                        ),
                        autosize=True,
                        minRows=2,
                    ),
                    dmc.Alert(id="trace-estimate", color="gray", variant="light", p="xs"),
                    dmc.Group(
                        children=[
                            dmc.Button(
                                "Trace this image",
                                id="trace-run-btn",
                                color="indigo",
                                disabled=not ANY_KEY,
                            ),
                            # THE BRAKES. A trace sends an image on every
                            # run, so its input cost is higher than a prompt-
                            # only call and stopping early saves more.
                            dmc.Button(
                                "Stop",
                                id="trace-stop",
                                leftSection="■",
                                color="red",
                                disabled=True,
                            ),
                            dmc.Button(
                                "Clear",
                                id="trace-clear",
                                variant="subtle",
                                color="gray",
                            ),
                        ]
                    ),
                ],
            ),
        ),
        dmc.Alert(id="trace-status", color="gray", variant="light"),
        dmc.Grid(
            gutter="md",
            children=[
                dmc.GridCol(
                    dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Text("Reference", size="sm", fw=600, c="dimmed"),
                            html.Div(id="trace-reference"),
                        ],
                    ),
                    span={"base": 12, "md": 6},
                ),
                dmc.GridCol(
                    dmc.Stack(
                        gap="xs",
                        children=[
                            dmc.Text("Traced", size="sm", fw=600, c="dimmed"),
                            canvas_frame(
                                DashExcalidraw(
                                    id="trace-canvas",
                                    height="520px",
                                    # EDITABLE, and this is a reversal. It was
                                    # view-only on the theory that a specimen
                                    # you are comparing should not change under
                                    # a stray click. In practice the first
                                    # thing anyone does with a trace is tidy it
                                    # — and now that the scene JSON below is
                                    # copyable, the tidying is the point: fix
                                    # the trace up, then take it away as a
                                    # `welcomeScene` or `initialData`.
                                    #
                                    # It also could not honestly stay view-only
                                    # by accident. `resetScene` used to discard
                                    # the mode, so every trace silently made
                                    # this canvas editable and the next render
                                    # snapped it back, stranding whatever had
                                    # been drawn. That is fixed in the
                                    # component; this is the deliberate choice
                                    # the fix forced someone to make.
                                    viewModeEnabled=False,
                                ),
                                min_height=520,
                            ),
                            # THE TRACE, AS A SCENE YOU CAN TAKE AWAY.
                            # `externalizedSerializedData` rather than
                            # `serializedData`: the externalized form has every
                            # inline `data:` URI stripped to null, so what you
                            # copy is the drawing rather than a megabyte of
                            # base64 for the reference image you fed in. It is
                            # the same shape `initialData` and the component's
                            # `welcomeScene` prop take, so a trace can be
                            # pasted straight into either.
                            dmc.Group(
                                justify="space-between",
                                children=[
                                    dmc.Text(
                                        "Scene JSON", size="sm", fw=600, c="dimmed"
                                    ),
                                    dcc.Clipboard(
                                        id="trace-copy-scene",
                                        title="Copy scene JSON",
                                        style={"cursor": "pointer"},
                                    ),
                                ],
                            ),
                            dmc.Code(
                                id="trace-scene-json",
                                block=True,
                                children="(trace something to get a scene)",
                                style={
                                    "maxHeight": 220,
                                    "overflow": "auto",
                                    "fontSize": 11,
                                },
                            ),
                        ],
                    ),
                    span={"base": 12, "md": 6},
                ),
            ],
        ),
        dcc.Store(id="trace-image-store"),
        # Streaming pair, same shape as /ai-agent: the run id plus how many
        # elements the canvas has already seen, and a ticker that collects
        # whatever arrived since.
        dcc.Store(id="trace-run", data=None),
        dcc.Interval(id="trace-tick", interval=400, disabled=True),
    ],
)


@callback(
    Output("trace-image-store", "data"),
    Output("trace-preview", "children"),
    Input("trace-upload", "contents"),
    State("trace-upload", "filename"),
    prevent_initial_call=True,
)
def _accept_upload(contents, filename):
    """Validate and describe the upload before a single credit is spent."""
    if not contents:
        return None, None
    try:
        media_type, b64, size, width, height = _decode_upload(contents)
    except ValueError as exc:
        return None, dmc.Alert(str(exc), color="red", variant="light")

    tokens = image_input_tokens(width, height)
    dims = f"{width}x{height}" if width else "dimensions unknown"
    return (
        {
            "media_type": media_type,
            "b64": b64,
            "width": width,
            "height": height,
            "tokens": tokens,
            "data_url": contents,
        },
        dmc.Group(
            gap="xs",
            children=[
                dmc.Badge(filename or "image", variant="light"),
                dmc.Badge(f"{size / 1024:.0f} KB", color="gray", variant="light"),
                dmc.Badge(dims, color="gray", variant="light"),
                dmc.Badge(f"~{tokens:,} input tokens", color="teal", variant="light"),
            ],
        ),
    )


@callback(
    Output("trace-effort", "value"),
    Output("trace-effort", "data"),
    Output("trace-max-tokens", "value"),
    Input("trace-model", "value"),
    prevent_initial_call=True,
)
def _sync_model_defaults(model):
    """Move effort and budget to the chosen model's defaults, and offer only
    the levels it accepts — Sonnet 4.6 rejects `xhigh`, and a selector that
    lists it is a 400 waiting to happen."""
    allowed = set(supported_efforts(model))
    data = [e for e in EFFORT_LEVELS if e["value"] in allowed]
    effort = MODEL_EFFORT.get(model) or "none"
    if effort not in allowed:
        effort = "none"
    return effort, data, MODEL_MAX_TOKENS.get(model, 24000)


@callback(
    Output("trace-estimate", "children"),
    Input("trace-model", "value"),
    Input("trace-effort", "value"),
    Input("trace-max-tokens", "value"),
    Input("trace-image-store", "data"),
)
def _show_estimate(model, effort, max_tokens, image):
    """Price the trace before it runs, image included."""
    image_tokens = (image or {}).get("tokens", 0)
    est = estimate_cost(model, effort, max_tokens, extra_input_tokens=image_tokens)
    if not est["priced"]:
        if est["reason"] == "budget":
            return "Set a max-token budget to see the cost."
        return "No price on file for this model — cost unknown."

    tail = (
        f"{est['budget']:,} max tokens · effort {est['effort'] or 'model default'}"
    )
    if image_tokens:
        tail += f" · image ~{image_tokens:,} input tokens"
    else:
        tail += " · no image yet, so this is the text-only cost"

    return dmc.Stack(
        gap=2,
        children=[
            dmc.Text(
                [
                    "About ",
                    dmc.Text(format_money(est["typical"]), fw=700, span=True),
                    f" · at most {format_money(est['ceiling'])} if it uses the "
                    f"whole budget.",
                ],
                size="sm",
            ),
            dmc.Text(tail, size="xs", c="dimmed"),
        ],
    )


@callback(
    Output("trace-canvas", "command", allow_duplicate=True),
    Output("trace-reference", "children", allow_duplicate=True),
    Output("trace-status", "children", allow_duplicate=True),
    Output("trace-status", "color", allow_duplicate=True),
    Input("trace-clear", "n_clicks"),
    prevent_initial_call=True,
)
def _clear(_clicks):
    """Synchronous and separate, for the same reason /ai-agent keeps its clear
    button out of the generate path: it is instant, and it must stay instant."""
    return (
        {"id": f"trace-clear-{time.time()}", "type": "resetScene", "payload": {}},
        None,
        "Cleared.",
        "gray",
    )


@callback(
    Output("trace-canvas", "command"),
    Output("trace-reference", "children"),
    Output("trace-status", "children"),
    Output("trace-status", "color"),
    Output("trace-run", "data"),
    Output("trace-tick", "disabled"),
    Input("trace-run-btn", "n_clicks"),
    State("trace-model", "value"),
    State("trace-effort", "value"),
    State("trace-max-tokens", "value"),
    State("trace-note", "value"),
    State("trace-image-store", "data"),
    prevent_initial_call=True,
)
def _trace(_clicks, model, effort, max_tokens, note, image):
    """START a trace. The canvas fills in as the model draws.

    Same shape as /ai-agent: this returns in milliseconds and `trace-tick`
    does the collecting, so the reference appears immediately and the drawing
    builds up beside it instead of arriving whole after a minute of spinner.
    """
    if not image:
        return no_update, no_update, "Upload a reference image first.", "yellow", None, True

    # The button is disabled without a key, so this is only reachable by a
    # crafted request. Answered in the same words and colour as the page's
    # own notice — a caller here has not done anything wrong.
    if not ANY_KEY:
        return no_update, no_update, NO_KEYS_NOTICE, "blue", None, True

    if not _spend_allowed():
        return (
            no_update, no_update,
            "Sign in to trace an image — this page spends real API credits.",
            "yellow", None, True,
        )

    provider = PROVIDER_OF.get(model)
    if provider == "claude" and not HAS_CLAUDE_KEY:
        return no_update, no_update, "ANTHROPIC_API_KEY is not set.", "red", None, True
    if provider == "chatgpt" and not HAS_CHATGPT_KEY:
        return no_update, no_update, "CHATGPT_API_KEY is not set.", "red", None, True

    prompt = (
        "Trace the attached reference image as an Excalidraw scene."
        + (f"\n\nAdditional instruction: {note.strip()}" if note and note.strip() else "")
    )

    # The ceiling, up front. `stream_model` admits once per run too, but it is
    # a generator: its body does not run until the worker advances it, so
    # without this the refusal would arrive as a red error on a run that had
    # already been created. A trace also carries the image's input tokens, so
    # it is priced with them rather than as a bare prompt.
    try:
        spend.check(
            estimate_cost(
                model, effort, max_tokens,
                extra_input_tokens=image_input_tokens(
                    image.get("width") or 0, image.get("height") or 0
                ),
            )["typical"]
        )
    except spend.CeilingReached as exc:
        return no_update, no_update, str(exc), "yellow", None, True

    try:
        run_id = scene_stream.start(
            model=model,
            user_prompt=prompt,
            max_tokens=max_tokens,
            effort=effort,
            image=(image["media_type"], image["b64"]),
            system=TRACE_SYSTEM_PROMPT,
        )
    except Exception as exc:  # noqa: BLE001
        return no_update, no_update, f"{type(exc).__name__}: {exc}", "red", None, True

    # Show the reference straight away — the comparison is the whole point, and
    # having it on screen while the trace builds is better than after.
    reference = dmc.Image(
        src=image["data_url"],
        radius="md",
        fit="contain",
        style={
            "maxHeight": "520px",
            "border": "1px solid var(--mantine-color-gray-3)",
        },
    )
    return (
        {"id": f"trace-reset-{run_id[:8]}", "type": "resetScene", "payload": {}},
        reference,
        "Tracing…",
        "gray",
        {"id": run_id, "model": model, "cursor": 0},
        False,
    )


@callback(
    Output("trace-canvas", "command", allow_duplicate=True),
    Output("trace-status", "children", allow_duplicate=True),
    Output("trace-status", "color", allow_duplicate=True),
    Output("trace-run", "data", allow_duplicate=True),
    Output("trace-tick", "disabled", allow_duplicate=True),
    Input("trace-tick", "n_intervals"),
    State("trace-run", "data"),
    prevent_initial_call=True,
)
def _trace_tick(_n, run):
    """Collect whatever the worker parsed since the last poll."""
    if not run or not run.get("id"):
        return no_update, no_update, no_update, no_update, True

    state = scene_stream.take(run["id"], run.get("cursor", 0))
    if not state["found"]:
        return no_update, no_update, no_update, None, True

    elements = state["all"]
    if state["error"]:
        scene_stream.forget(run["id"])
        return no_update, state["error"], "red", None, True

    # updateScene replaces the scene, so send everything so far. NEVER while
    # drawing keeps a 30-element trace from becoming 30 undo steps.
    cmd = no_update
    if state["elements"]:
        cmd = {
            "id": f"trace-tick-{run['id'][:8]}-{state['cursor']}",
            "type": "updateScene",
            "payload": {"elements": elements, "captureUpdate": "NEVER"},
        }
    run_next = {**run, "cursor": state["cursor"]}

    # Elements the producer wrote that cannot be read back. Reported rather
    # than skipped — a silently shorter list reads as a model that traced
    # less, which is how the same bug hid on /ai-agent.
    lost_note = f" · {state['lost']} lost from the buffer" if state.get("lost") else ""

    if not state["done"]:
        n = len(elements)
        return (
            cmd,
            f"Tracing… {n} element{'s' if n != 1 else ''} so far "
            f"({state['elapsed']:.0f}s){lost_note}",
            "orange" if state.get("lost") else "gray",
            run_next,
            False,
        )

    scene_stream.forget(run["id"])
    if not elements:
        return (
            no_update,
            "The model returned no elements. Try a clearer image, a higher "
            "budget, or a different model.",
            "yellow",
            None,
            True,
        )

    meta = state["meta"] or {}
    price = MODEL_PRICING.get(run.get("model"), (0, 0))
    cost = (
        meta.get("input_tokens", 0) * price[0]
        + meta.get("output_tokens", 0) * price[1]
    ) / 1_000_000
    status = (
        f"{MODEL_LABEL.get(run.get('model'), run.get('model'))} · "
        f"{len(elements)} elements in {state['elapsed']:.0f}s · "
        f"{meta.get('output_tokens', 0):,} out / "
        f"{meta.get('input_tokens', 0):,} in · ~${cost:.3f}"
    )
    return (
        {
            "id": f"trace-final-{run['id'][:8]}",
            "type": "updateScene",
            "payload": {"elements": elements, "captureUpdate": "IMMEDIATELY"},
        },
        status,
        "green",
        None,
        True,
    )


@callback(
    Output("trace-status", "children", allow_duplicate=True),
    Output("trace-status", "color", allow_duplicate=True),
    Output("trace-run", "data", allow_duplicate=True),
    Output("trace-tick", "disabled", allow_duplicate=True),
    Input("trace-stop", "n_clicks"),
    State("trace-run", "data"),
    prevent_initial_call=True,
)
def _stop(_clicks, run):
    """Press the brakes: stop the trace, keep what it has drawn.

    Disabling the ticker would only stop the PAGE watching; the worker would
    keep pulling tokens to the full budget with nobody reading them.
    `scene_stream.cancel` closes the provider stream, so billing stops at the
    tokens already produced. See lib/scene_stream.cancel.
    """
    if not run or not run.get("id"):
        return no_update, no_update, no_update, True

    scene_stream.cancel(run["id"])
    drawn = len(scene_stream.take(run["id"], 0)["all"])
    return (
        f"Stopped. {drawn} element{'s' if drawn != 1 else ''} kept; no further "
        f"tokens are being generated.",
        "yellow",
        None,
        True,
    )


@callback(
    Output("trace-run-btn", "loading"),
    Output("trace-run-btn", "disabled"),
    Output("trace-stop", "disabled"),
    Output("trace-upload", "disabled"),
    Output("trace-model", "disabled"),
    Output("trace-max-tokens", "disabled"),
    Input("trace-tick", "disabled"),
)
def _lock_controls(tick_disabled):
    """The ticker being enabled IS "a trace is in progress"."""
    drawing = not tick_disabled
    # `or not ANY_KEY` keeps the run button off for good on a keyless site.
    off = drawing or not ANY_KEY
    # Stop is enabled precisely when a trace is running.
    return drawing, off, not drawing, off, off, off


@callback(
    Output("trace-scene-json", "children"),
    Output("trace-copy-scene", "content"),
    Input("trace-canvas", "externalizedSerializedData"),
)
def _offer_the_scene(externalized):
    """Hand the finished trace back as a pasteable scene.

    Why this page and not a generic export: a trace is the one scene here you
    did not draw yourself, so it is the one you are most likely to want to
    keep — as a `welcomeScene`, as `initialData`, or as a fixture. Re-drawing
    it by hand is not an option, and reading it out of the network tab is not
    a workflow.

    Pretty-printed for the panel and copied verbatim, because the thing you
    paste into Python should look like the thing you were shown.
    """
    if not externalized:
        return "(trace something to get a scene)", ""
    try:
        scene = json.loads(externalized)
    except (TypeError, ValueError):
        # Never hand back something that will not `json.loads` on the other
        # side; an unparseable blob in a copy button is worse than no button.
        return "(the canvas produced no readable scene)", ""

    elements = scene.get("elements") or []
    if not elements:
        return "(empty scene — nothing to copy yet)", ""

    pretty = json.dumps(scene, indent=2)
    return pretty, pretty
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /trace-image*

---

<!-- /ui-options — https://excalidraw.2plot.dev/ui-options/llms.txt -->

# UIOptions

> The JSON-safe subset of Excalidraw's UIOptions: hide toolbar actions, tools and the welcome screen.

---



### The welcome screen is a prop, and it is reactive

`UIOptions.welcomeScreen` is read **once**, while the canvas mounts. A control
bound to it appears to do nothing, because by the time you click it Excalidraw
has stopped looking. The overlay's real state is `appState.showWelcomeScreen`,
so the component takes a top-level prop and pushes it there:

```python
DashExcalidraw(
    id="canvas",
    welcomeScreen=True,
    welcomeScreenContent={
        "title": "dash-excalidraw",
        "subtitle": "Draw something, or load a scene from Python.",
    },
)
```

`welcomeScreen` toggles live — it brings the overlay back after you have drawn
and dismissed it. `welcomeScreenContent` replaces the wording; the menu hints
stay Excalidraw's own, so they cannot fall out of step with the menu they
describe.

To open on a scene rather than a blank canvas, pass `welcomeScene` — any
`{elements, appState, files}` object, which is the shape `initialData` and
`externalizedSerializedData` both use. [/trace-image](/trace-image) will hand
you one as copyable JSON. It is mount-only, like `initialData`: Excalidraw
owns the scene once it has one.

### Overview

The JSON-safe subset of Excalidraw's UIOptions prop. Toggle the switches below
and watch the canvas chrome change.

### The Excalidraw links menu

One piece of chrome is not part of `UIOptions` and has its own prop.
Excalidraw's hamburger menu carries a link group pointing at its own GitHub,
Discord and X accounts. In a product embedding the canvas those send users
off your app, so this wrapper hides them by default:

```python
DashExcalidraw(id="canvas", hideExcalidrawLinks=False)  # show them again
```

`hideExcalidrawLinks` defaults to `True` and works in **both directions** —
set it to `False` and the links come back, no reload. Each canvas decides for
itself, so two on one page may disagree.

It is a render condition, not a stylesheet: the wrapper composes its own main
menu, mirroring Excalidraw's default item for item, and simply omits the
vendor's links group when you ask it to. That is the only supported way to
reach that group — no `UIOptions` key does.

Alongside it, `docsLinkUrl` puts one item of the wrapper's own in the menu,
independently of the switch above:

```python
DashExcalidraw(
    id="canvas",
    hideExcalidrawLinks=False,          # show Excalidraw's GitHub / X / Discord
    docsLinkUrl="https://my-app.example.com/help",
    docsLinkLabel="Drawing help",       # relabel whenever you re-point the URL
)
```

| State | Vendor links group | Our docs item |
|:------|:-------------------|:--------------|
| Default | hidden | shown |
| `hideExcalidrawLinks=False` | shown | shown |
| `docsLinkUrl=""` | hidden | none |

Set `docsLinkUrl=""` to render no item of ours at all. And relabel when you
re-point: a menu entry that names one destination and opens another is the
reason this is a pair of props rather than one. [Coverage](/coverage) covers
the composition, the two `UIOptions` gates it replicates, and what still
points at Excalidraw (the Help dialog and two error dialogs — deliberately).

The three canvases below are the three states, side by side. Open the
hamburger menu on each.

### Live demo


### Source


```python
# File: docs/ui-options/ui_options.py

"""UIOptions: toggle individual canvas actions and the welcome screen."""

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("ui-options-canvas")
sync_canvas_theme("ui-links-vendor-canvas")
sync_canvas_theme("ui-links-none-canvas")

CODE = """
DashExcalidraw(
    id='canvas',
    UIOptions={
        'welcomeScreen': False,
        'canvasActions': {
            'changeViewBackgroundColor': True,
            'clearCanvas': True,
            'export': True,
            'loadScene': True,
            'saveAsImage': True,
            'toggleTheme': True,
        },
        'tools': {'image': True},
    },
)
"""

ACTION_SWITCHES = [
    ("welcome", "welcomeScreen", "Welcome overlay on empty canvas"),
    ("clearCanvas", "canvasActions.clearCanvas", "Clear-canvas action in menu"),
    ("export", "canvasActions.export", "Export action in menu"),
    ("loadScene", "canvasActions.loadScene", "Load-scene action"),
    ("saveAsImage", "canvasActions.saveAsImage", "Save-as-image action"),
    ("toggleTheme", "canvasActions.toggleTheme", "Theme-toggle button"),
    ("changeBg", "canvasActions.changeViewBackgroundColor", "Bg color picker"),
    ("image", "tools.image", "Image tool"),
]


def _switch(key: str, label: str, desc: str, default: bool) -> dmc.Switch:
    return dmc.Switch(
        id=f"ui-{key}",
        label=label,
        description=desc,
        checked=default,
        size="sm",
    )


component = dmc.Stack(
    gap="md",
    children=[
        dmc.Alert(
            color="blue",
            variant="light",
            title="welcomeScreen is a prop now, and it is reactive",
            children=(
                "`UIOptions.welcomeScreen` is read once while the canvas "
                "mounts, so a switch bound to it does nothing after the first "
                "paint. The overlay's real home is `appState.showWelcomeScreen`, "
                "so the component takes a top-level `welcomeScreen` prop and "
                "pushes it there — the switch below brings the overlay back "
                "after you have drawn on the canvas and dismissed it. "
                "`welcomeScreenContent={'title': …, 'subtitle': …}` puts your "
                "own words on it."
            ),
        ),
        code_block(CODE),
        dmc.Paper(
            withBorder=True,
            p="md",
            radius="md",
            children=dmc.SimpleGrid(
                cols={"base": 1, "sm": 2, "md": 4},
                spacing="sm",
                children=[
                    _switch(key, label, desc, default=(key != "welcome"))
                    for key, label, desc in ACTION_SWITCHES
                ],
            ),
        ),
        canvas_frame(
            DashExcalidraw(
                id="ui-options-canvas",
                height="600px",
                # The overlay is driven by the top-level `welcomeScreen`
                # prop below, not by this key — see the note above.
                welcomeScreen=False,
                welcomeScreenContent={
                    "title": "dash-excalidraw",
                    "subtitle": "Draw something, or load a scene from Python.",
                },
                UIOptions={
                    "welcomeScreen": False,
                    "canvasActions": {
                        "clearCanvas": True,
                        "export": True,
                        "loadScene": True,
                        "saveAsImage": True,
                        "toggleTheme": True,
                        "changeViewBackgroundColor": True,
                    },
                    "tools": {"image": True},
                },
            )
        ),
        dmc.Divider(
            my="lg",
            label="The other two link states — open the hamburger menu on each",
            labelPosition="center",
        ),
        # STACKED, not side by side. The point of these two is that you open
        # the hamburger menu on each, and that menu is ~300px wide with a tall
        # item list. Two of them in a column that is itself only ~860px left
        # each canvas around 420px, so the menu covered most of its own canvas
        # and the two examples read as broken rather than as different. A
        # comparison you cannot see is not a comparison.
        dmc.SimpleGrid(
            cols=1,
            spacing="md",
            children=[
                dmc.Stack(
                    gap="xs",
                    children=[
                        dmc.Text(
                            "hideExcalidrawLinks=False — both groups",
                            size="sm",
                            fw=600,
                        ),
                        canvas_frame(
                            DashExcalidraw(
                                id="ui-links-vendor-canvas",
                                height="360px",
                                hideExcalidrawLinks=False,
                            ),
                            min_height=360,
                        ),
                    ],
                ),
                dmc.Stack(
                    gap="xs",
                    children=[
                        dmc.Text(
                            'docsLinkUrl="" — neither group',
                            size="sm",
                            fw=600,
                        ),
                        canvas_frame(
                            DashExcalidraw(
                                id="ui-links-none-canvas",
                                height="360px",
                                docsLinkUrl="",
                            ),
                            min_height=360,
                        ),
                    ],
                ),
            ],
        ),
    ],
)


@callback(
    Output("ui-options-canvas", "UIOptions"),
    # The overlay rides its own prop now. Sending it inside UIOptions as well
    # would be writing the mount-time key that never took effect — the very
    # thing that made this switch look dead.
    Output("ui-options-canvas", "welcomeScreen"),
    Input("ui-welcome", "checked"),
    Input("ui-clearCanvas", "checked"),
    Input("ui-export", "checked"),
    Input("ui-loadScene", "checked"),
    Input("ui-saveAsImage", "checked"),
    Input("ui-toggleTheme", "checked"),
    Input("ui-changeBg", "checked"),
    Input("ui-image", "checked"),
)
def _compose_ui_options(welcome, clear_, export, load, save_img, tog_theme, change_bg, image):
    return {
        "welcomeScreen": False,
        "canvasActions": {
            "clearCanvas": bool(clear_),
            "export": bool(export),
            "loadScene": bool(load),
            "saveAsImage": bool(save_img),
            "toggleTheme": bool(tog_theme),
            "changeViewBackgroundColor": bool(change_bg),
        },
        "tools": {"image": bool(image)},
    }, bool(welcome)
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /ui-options*

---

<!-- /view-modes — https://excalidraw.2plot.dev/view-modes/llms.txt -->

# View modes

> View mode, zen mode and grid mode — three declarative booleans that flip the editor's behaviour.

---



### Overview

Three boolean props flip the editor's mode. They are purely declarative — set
them from any callback.

### Live demo


### Source


```python
# File: docs/view-modes/view_modes.py

"""View modes: view/zen/grid toggles driven from Python."""

import dash_mantine_components as dmc
from dash import Input, Output, callback

from dash_excalidraw import DashExcalidraw
from docs._shared import canvas_frame, code_block, sync_canvas_theme

sync_canvas_theme("viewmodes-canvas")

CODE = """
DashExcalidraw(
    id='canvas',
    viewModeEnabled=False,   # hides drawing tools when True
    zenModeEnabled=False,    # hides most chrome
    gridModeEnabled=False,   # grid + snap
)
"""


def _mode_switch(switch_id: str, label: str, description: str) -> dmc.Switch:
    return dmc.Switch(
        id=switch_id,
        label=label,
        description=description,
        checked=False,
        size="md",
    )


component = dmc.Stack(
    gap="md",
    children=[
        code_block(CODE),
        dmc.Group(
            children=[
                _mode_switch(
                    "view-mode-switch",
                    "viewModeEnabled",
                    "Disables drawing; pan & zoom remain.",
                ),
                _mode_switch(
                    "zen-mode-switch",
                    "zenModeEnabled",
                    "Hides library, menus, and footer.",
                ),
                _mode_switch(
                    "grid-mode-switch",
                    "gridModeEnabled",
                    "Snap-to-grid with a visible grid.",
                ),
            ],
        ),
        canvas_frame(
            DashExcalidraw(
                id="viewmodes-canvas",
                height="600px",
            )
        ),
    ],
)


@callback(
    Output("viewmodes-canvas", "viewModeEnabled"),
    Output("viewmodes-canvas", "zenModeEnabled"),
    Output("viewmodes-canvas", "gridModeEnabled"),
    Input("view-mode-switch", "checked"),
    Input("zen-mode-switch", "checked"),
    Input("grid-mode-switch", "checked"),
)
def _apply_modes(view, zen, grid):
    return bool(view), bool(zen), bool(grid)
```

    :defaultExpanded: false
    :withExpandedButton: true


---

*Source: /view-modes*
