makersclaw 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
@@ -5,7 +5,7 @@
5
5
  "title": "SketchCanvas",
6
6
  "description": "A freeform drawing canvas (Excalidraw) in the workspace theme: a Miro-style tool rail (select, pan, draw, shapes, arrows, text, image, eraser, frames), a floating toolbar over the selection with colours from the theme's tokens, zoom and undo controls, a friendly empty state, and stamp tools that place ready-made drawings (UI wireframe pieces built in). Optional locked background image for annotating a screenshot. The sketch is plain JSON; helpers read its words for an agent, and the handle exports a PNG.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@excalidraw/excalidraw@0.18.1"
@@ -21,7 +21,7 @@
21
21
  "path": "registry/blocks/sketch-canvas/sketch-canvas.jsx",
22
22
  "type": "registry:component",
23
23
  "target": "@components/makersclaw/blocks/sketch-canvas/sketch-canvas.jsx",
24
- "content": "\"use client\";\n\n// SketchCanvas — Excalidraw, in the workspace theme, behind one set of props.\n//\n// Client only, one static bundle. Excalidraw needs the DOM, and the app's\n// check refuses import() (so no next/dynamic): the modules load with the\n// page (on the server too — see excalidraw.js and ssr-shim.js for how that\n// is made safe), and the canvas itself mounts only after the first effect\n// (the mount guard). Until then — on the server and in the first client\n// paint — the same frame shows, with the sketch's words for a reader.\n//\n// What is ours and what is Excalidraw's: Excalidraw draws, selects, edits\n// text, keeps history and answers its own shortcuts. The tool rail, the\n// context toolbar (colours from the theme), the zoom and undo buttons, the\n// menu, the save state and the empty state are ours, in the kit; theme.js\n// hides the Excalidraw UI they replace. Where ours drives an Excalidraw\n// action that has no API (undo, zoom, layer order, duplicate, delete,\n// font size) it presses that action's own shortcut on the canvas, so the\n// behaviour is Excalidraw's exactly.\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Button, Confirm, Icon, IconButton, Kbd, Menu, Popover, Separator, Toggle, Tooltip, cx } from \"../../ui\";\nimport {\n CaptureUpdateAction,\n Excalidraw,\n convertToExcalidrawElements,\n exportToBlob,\n getCommonBounds,\n getSceneVersion,\n newElementWith,\n restoreElements,\n sceneCoordsToViewportCoords,\n viewportCoordsToSceneCoords,\n} from \"./excalidraw\";\nimport { FILLS, STROKES, elementTokens, paletteKey, repaint, resolveColours, resolvePalette, tagTokens } from \"./palette\";\nimport { BACKGROUND_ROLE, SKETCH_VERSION, SYSTEM_FONT, filesFor, isBackground, pickAppState, sketchToText, toSketch } from \"./sketch\";\nimport { ROOT_VARS, THEME, TRIM_CSS } from \"./theme\";\n\n/** A ref for the canvas's handle: `const sketch = useSketchCanvas()`, then\n * `<SketchCanvas canvasRef={sketch} />` and `await sketch.current?.exportImage()`. */\nexport function useSketchCanvas() {\n return useRef(null);\n}\n\n/** The built-in tools, in rail order. `tools` picks a subset by id. */\nexport const SKETCH_TOOLS = [\n { id: \"select\", tool: \"selection\", label: \"Select\", icon: \"mouse-pointer\", key: \"V\" },\n { id: \"hand\", tool: \"hand\", label: \"Pan\", icon: \"hand\", key: \"H\" },\n { id: \"draw\", tool: \"freedraw\", label: \"Draw\", icon: \"pencil\", key: \"P\" },\n { id: \"rectangle\", tool: \"rectangle\", label: \"Rectangle\", icon: \"square\", key: \"R\" },\n { id: \"ellipse\", tool: \"ellipse\", label: \"Ellipse\", icon: \"circle\", key: \"O\" },\n { id: \"diamond\", tool: \"diamond\", label: \"Diamond\", icon: \"diamond\", key: \"D\" },\n { id: \"arrow\", tool: \"arrow\", label: \"Arrow\", icon: \"arrow-right\", key: \"A\" },\n { id: \"line\", tool: \"line\", label: \"Line\", icon: \"minus\", key: \"L\" },\n { id: \"text\", tool: \"text\", label: \"Text\", icon: \"type\", key: \"T\" },\n { id: \"image\", tool: \"image\", label: \"Image\", icon: \"image\", key: null },\n { id: \"eraser\", tool: \"eraser\", label: \"Eraser\", icon: \"eraser\", key: \"E\" },\n { id: \"frame\", tool: \"frame\", label: \"Frame\", icon: \"frame\", key: \"F\" },\n];\n\n// Excalidraw's own single-key tool shortcuts; a tool left out of `tools`\n// has its keys swallowed so it cannot be reached by keyboard either.\nconst TOOL_KEYS = {\n selection: [\"v\", \"1\"],\n rectangle: [\"r\", \"2\"],\n diamond: [\"d\", \"3\"],\n ellipse: [\"o\", \"4\"],\n arrow: [\"a\", \"5\"],\n line: [\"l\", \"6\"],\n freedraw: [\"p\", \"x\", \"7\"],\n text: [\"t\", \"8\"],\n image: [\"9\"],\n eraser: [\"e\", \"0\"],\n hand: [\"h\"],\n frame: [\"f\"],\n laser: [\"k\"],\n};\n\n// Tailwind needs whole class names in the source: one per token.\nconst SWATCH = {\n foreground: \"bg-foreground\",\n \"muted-foreground\": \"bg-muted-foreground\",\n primary: \"bg-primary\",\n destructive: \"bg-destructive\",\n warning: \"bg-warning\",\n success: \"bg-success\",\n info: \"bg-info\",\n \"chart-2\": \"bg-chart-2\",\n \"chart-3\": \"bg-chart-3\",\n \"chart-4\": \"bg-chart-4\",\n \"chart-5\": \"bg-chart-5\",\n none: \"bg-background\",\n muted: \"bg-muted\",\n \"primary:soft\": \"bg-primary/25\",\n \"destructive:soft\": \"bg-destructive/25\",\n \"warning:soft\": \"bg-warning/25\",\n \"success:soft\": \"bg-success/25\",\n \"info:soft\": \"bg-info/25\",\n \"chart-2:soft\": \"bg-chart-2/25\",\n \"chart-3:soft\": \"bg-chart-3/25\",\n \"chart-4:soft\": \"bg-chart-4/25\",\n \"chart-5:soft\": \"bg-chart-5/25\",\n};\n\nconst WIDTHS = [\n { value: 1, label: \"Thin\" },\n { value: 2, label: \"Medium\" },\n { value: 4, label: \"Bold\" },\n];\n\nconst BG_ID = \"sketch-background\";\nconst SHAPES = new Set([\"rectangle\", \"ellipse\", \"diamond\"]);\nconst LINES = new Set([\"arrow\", \"line\", \"freedraw\"]);\nconst isDarwin = () => typeof navigator !== \"undefined\" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);\nconst nonEmpty = (v) => (v && (Array.isArray(v) ? v.length : Array.isArray(v.elements) ? v.elements.length : false) ? v : null);\n\n/** A short stable hash (for a background file id). */\nfunction hash(s) {\n let h = 5381;\n for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;\n return h.toString(36);\n}\n\nfunction mimeOf(src) {\n const m = /^data:([^;,]+)/.exec(src);\n if (m) return m[1];\n const ext = /\\.(png|jpe?g|webp|gif|svg)(?:[?#]|$)/i.exec(src)?.[1]?.toLowerCase();\n return { png: \"image/png\", jpg: \"image/jpeg\", jpeg: \"image/jpeg\", webp: \"image/webp\", gif: \"image/gif\", svg: \"image/svg+xml\" }[ext] ?? \"image/png\";\n}\n\n/** The workspace's light or dark where the canvas sits. */\nfunction schemeOf(root) {\n const scoped = root?.closest?.(\"[data-theme]\")?.getAttribute(\"data-theme\");\n if (scoped === \"light\" || scoped === \"dark\") return scoped;\n const cs = root ? getComputedStyle(root).colorScheme || \"\" : \"\";\n if (/\\bdark\\b/.test(cs) && !/\\blight\\b/.test(cs)) return \"dark\";\n if (/\\blight\\b/.test(cs) && !/\\bdark\\b/.test(cs)) return \"light\";\n return typeof matchMedia !== \"undefined\" && matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n}\n\n/** What is saved of the scene: live elements with their tokens, the\n * files they show, the appState a sketch keeps. */\nfunction sceneValue(api, palette) {\n return valueOf(api.getSceneElements(), api.getFiles(), api.getAppState(), palette);\n}\n\nfunction valueOf(elements, files, appState, palette) {\n const live = tagTokens(\n elements.filter((el) => !el.isDeleted && !isBackground(el)),\n palette,\n );\n return { type: \"sketch\", version: SKETCH_VERSION, elements: live, files: filesFor(live, files), appState: pickAppState(appState) };\n}\n\n/** A cheap fingerprint of a value's elements and files. */\nfunction signature(value) {\n const s = toSketch(value);\n return `${s.elements.map((e) => `${e.id}:${e.version}`).join(\",\")}|${Object.keys(s.files).sort().join(\",\")}`;\n}\n\n/** A box never shorter than the text bound inside it. Excalidraw re-wraps\n * and re-measures text as a scene loads (the system font, a narrower box)\n * but grows a container only while someone types, so text that wrapped to\n * an extra line hung out of its box (\"Annual billing toggle\" on the\n * whiteboard demo, seen in the pane 2026-09-24). Grows the box and centres\n * the text again; ellipses and diamonds need more room for the same text. */\nconst BOUND_PAD = 5;\nfunction fitContainers(elements) {\n const byId = new Map(elements.map((el) => [el.id, el]));\n const grown = new Map();\n for (const t of elements) {\n if (t?.type !== \"text\" || !t.containerId || t.isDeleted) continue;\n const box = grown.get(t.containerId) ?? byId.get(t.containerId);\n if (!box || box.type === \"arrow\" || box.type === \"line\") continue;\n // A rectangle needs the text plus its padding; an ellipse or diamond\n // fits text of about 1/√2 of its height (a numbered pin: 18px of text\n // in a 28px circle is fine, and must stay a circle).\n const round = box.type === \"ellipse\" || box.type === \"diamond\";\n const need = Math.ceil(round ? t.height * Math.SQRT2 : t.height + BOUND_PAD * 2);\n if (box.height + 1 >= need) continue;\n grown.set(box.id, { ...box, height: need, version: (box.version ?? 1) + 1 });\n }\n if (!grown.size) return elements;\n return elements.map((el) => {\n if (grown.has(el.id)) return grown.get(el.id);\n if (el?.type === \"text\" && grown.has(el.containerId)) {\n const box = grown.get(el.containerId);\n return { ...el, y: box.y + (box.height - el.height) / 2, version: (el.version ?? 1) + 1 };\n }\n return el;\n });\n}\n\n/** Elements as they should enter the scene: every text in the system\n * font (re-measured), tokened colours resolved in this theme. */\nfunction prepareElements(elements, palette) {\n const fonted = (elements ?? []).map((el) => (el?.type === \"text\" && el.fontFamily !== SYSTEM_FONT ? { ...el, fontFamily: SYSTEM_FONT } : el));\n const restored = fitContainers(restoreElements(fonted, null, { refreshDimensions: true, repairBindings: true }));\n const patches = new Map(repaint(restored, palette, null).map((p) => [p.id, p.patch]));\n return restored.map((el) => (patches.has(el.id) ? { ...el, ...patches.get(el.id) } : el));\n}\n\n/** A skeleton (convertToExcalidrawElements' input) ready to convert:\n * token colours resolved, texts in the system font. */\nfunction prepareSkeleton(sk, palette) {\n const out = resolveColours(sk, palette);\n if (out.type === \"text\" && !out.fontFamily) out.fontFamily = SYSTEM_FONT;\n if (out.label && !out.label.fontFamily) out.label = { ...out.label, fontFamily: SYSTEM_FONT };\n if (out.type !== \"text\" && !out.strokeColor && palette.foreground) out.strokeColor = palette.foreground;\n if (out.type === \"text\" && !out.strokeColor && palette.foreground) out.strokeColor = palette.foreground;\n return out;\n}\n\nfunction boundsOfSkeletons(sks) {\n const boxes = sks.map((s) => {\n const w = s.width ?? (s.type === \"text\" ? String(s.text ?? \"\").length * (s.fontSize ?? 20) * 0.55 : 0);\n const h = s.height ?? (s.type === \"text\" ? (s.fontSize ?? 20) * 1.25 : 0);\n return { x1: s.x ?? 0, y1: s.y ?? 0, x2: (s.x ?? 0) + w, y2: (s.y ?? 0) + h };\n });\n const x1 = Math.min(...boxes.map((b) => b.x1));\n const y1 = Math.min(...boxes.map((b) => b.y1));\n return { x: x1, y: y1, width: Math.max(...boxes.map((b) => b.x2)) - x1, height: Math.max(...boxes.map((b) => b.y2)) - y1 };\n}\n\nexport function SketchCanvas(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return <Frame {...props} mounted={mounted} />;\n}\n\n/** The frame, the same before and after the canvas mounts: server HTML\n * already has its size, border and text alternative; nothing jumps. */\nfunction Frame({ mounted, className, label = \"Sketch\", ...props }) {\n const rootRef = useRef(null);\n const descId = useId();\n const [text, setText] = useState(() => sketchToText(nonEmpty(props.value) ?? nonEmpty(props.defaultValue)));\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n return (\n <div\n ref={rootRef}\n data-sketch-canvas=\"\"\n role=\"group\"\n aria-label={label}\n aria-describedby={descId}\n className={cx(\n \"relative isolate overflow-hidden rounded-lg border border-border bg-background text-foreground\",\n !sized && \"h-[32rem]\",\n ROOT_VARS,\n THEME,\n className,\n )}\n >\n <style>{TRIM_CSS}</style>\n <p id={descId} className=\"sr-only\">\n {text ? `The sketch says: ${text.replace(/\\n+/g, \". \")}` : \"The sketch is empty.\"}\n </p>\n {mounted ? (\n <LiveSketch {...props} rootRef={rootRef} onText={setText} />\n ) : (\n <div className=\"grid h-full place-items-center text-sm text-muted-foreground\" aria-busy=\"true\">\n Loading the canvas…\n </div>\n )}\n </div>\n );\n}\n\nfunction LiveSketch({\n rootRef,\n onText,\n value,\n defaultValue,\n onChange,\n onSave,\n changeDelay = 600,\n readOnly = false,\n background,\n tools,\n stamps,\n grid = true,\n empty,\n actions,\n canvasRef,\n onReady,\n}) {\n const apiRef = useRef(null);\n const [api, setApi] = useState(null);\n // Excalidraw hands over its API before it has loaded initialData, then\n // loads it asynchronously and replaces the scene. Anything laid before\n // that is wiped: annotate's screenshot vanished that way (its file stayed,\n // its image element went; seen in the pane 2026-09-24). `ready` turns true\n // once the first load is done, and the effects that write the scene wait\n // for it.\n const [ready, setReady] = useState(false);\n useEffect(() => {\n if (!api) return;\n if (!api.getAppState().isLoading) return setReady(true);\n const off = api.onChange((_els, appState) => {\n if (!appState.isLoading) setReady(true);\n });\n return () => off?.();\n }, [api]);\n const [palette, setPalette] = useState(() => resolvePalette(rootRef.current));\n const paletteRef = useRef(palette);\n const [scheme, setScheme] = useState(() => schemeOf(rootRef.current));\n const [ui, setUi] = useState({ tool: \"selection\", zoom: 1, selected: [], box: null, count: 0, grid: false });\n const uiKey = useRef(\"\");\n const [stamp, setStamp] = useState(null);\n const [save, setSave] = useState({ state: \"idle\" });\n const [clearing, setClearing] = useState(false);\n const current = useRef({ strokeColor: \"foreground\", backgroundColor: \"none\" });\n\n // What was last sent out / taken in, so a controlled `value` that is\n // only our own echo never reloads the scene.\n const initial = useRef(null);\n if (!initial.current) initial.current = toSketch(nonEmpty(value) ?? nonEmpty(defaultValue));\n const lastSig = useRef(null);\n if (lastSig.current === null) lastSig.current = signature(initial.current);\n const lastVersion = useRef(-1);\n const timer = useRef(0);\n const pending = useRef(false);\n const latest = useRef(null);\n const saveSeq = useRef(0);\n const lastValue = useRef(initial.current);\n const cbs = useRef({});\n cbs.current = { onChange, onSave, onText, onReady };\n\n const activeTools = useMemo(() => {\n const want = Array.isArray(tools) && tools.length ? new Set(tools) : null;\n return SKETCH_TOOLS.filter((t) => !want || want.has(t.id));\n }, [tools]);\n const blockedKeys = useMemo(() => {\n const on = new Set(activeTools.map((t) => t.tool));\n const keys = new Set();\n for (const [tool, ks] of Object.entries(TOOL_KEYS)) if (!on.has(tool)) ks.forEach((k) => keys.add(k));\n return keys;\n }, [activeTools]);\n const stampList = useMemo(() => (Array.isArray(stamps) ? stamps.filter((s) => s && s.id && typeof s.create === \"function\") : []), [stamps]);\n\n const initialData = useMemo(() => {\n const els = prepareElements(initial.current.elements, palette);\n return {\n elements: els,\n files: initial.current.files,\n appState: {\n viewBackgroundColor: \"transparent\",\n currentItemFontFamily: SYSTEM_FONT,\n currentItemStrokeColor: palette.foreground,\n currentItemBackgroundColor: \"transparent\",\n currentItemFillStyle: \"solid\",\n currentItemRoundness: \"round\",\n gridModeEnabled: !!initial.current.appState.gridModeEnabled,\n },\n scrollToContent: true,\n };\n // Excalidraw reads initialData once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ---- output -------------------------------------------------------------\n\n const runSave = useCallback(async (v) => {\n const fn = cbs.current.onSave;\n if (!fn) return;\n const seq = ++saveSeq.current;\n setSave({ state: \"saving\" });\n let res;\n try {\n res = await fn(v);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (seq !== saveSeq.current) return;\n setSave(res?.ok ? { state: \"saved\" } : { state: \"error\", error: res?.error });\n }, []);\n\n const emit = useCallback(() => {\n clearTimeout(timer.current);\n pending.current = false;\n // From what the last onChange handed us (so a flush while unmounting\n // never reads a scene Excalidraw is tearing down), else the API.\n const seen = latest.current;\n const a = apiRef.current;\n if (!seen && !a) return;\n const v = seen ? valueOf(seen.elements, seen.files, seen.appState, paletteRef.current) : sceneValue(a, paletteRef.current);\n lastValue.current = v;\n lastSig.current = signature(v);\n cbs.current.onText?.(sketchToText(v));\n cbs.current.onChange?.(v);\n runSave(v);\n }, [runSave]);\n\n const schedule = useCallback(() => {\n clearTimeout(timer.current);\n pending.current = true;\n timer.current = setTimeout(emit, Math.max(0, changeDelay));\n }, [emit, changeDelay]);\n\n // Leaving with an edit still in its debounce: send it now, not never.\n const flush = useRef(emit);\n flush.current = emit;\n useEffect(\n () => () => {\n clearTimeout(timer.current);\n if (!pending.current) return;\n try {\n flush.current();\n } catch {\n // the canvas is already gone; nothing left to read\n }\n },\n [],\n );\n\n // ---- the scene ----------------------------------------------------------\n\n const quietUpdate = useCallback((elements, appState) => {\n const a = apiRef.current;\n if (!a) return;\n a.updateScene({ elements, appState, captureUpdate: CaptureUpdateAction.NEVER });\n lastVersion.current = getSceneVersion(a.getSceneElementsIncludingDeleted());\n }, []);\n\n /** Press one of Excalidraw's own shortcuts on the canvas. */\n const press = useCallback((key, code, mods = {}) => {\n const target = rootRef.current?.querySelector(\".excalidraw\");\n if (!target) return;\n const mac = isDarwin();\n target.dispatchEvent(\n new KeyboardEvent(\"keydown\", {\n key,\n code,\n bubbles: true,\n cancelable: true,\n metaKey: !!mods.cmd && mac,\n ctrlKey: !!mods.cmd && !mac,\n shiftKey: !!mods.shift,\n altKey: !!mods.alt,\n }),\n );\n }, [rootRef]);\n\n const layer = useCallback(\n (front) => {\n const mac = isDarwin();\n press(front ? \"]\" : \"[\", front ? \"BracketRight\" : \"BracketLeft\", mac ? { cmd: true, alt: true } : { cmd: true, shift: true });\n },\n [press],\n );\n\n // The locked background image, laid from the prop at the back.\n const bgKey = background?.src ? `${background.src.length}:${hash(background.src)}:${background.width ?? \"\"}x${background.height ?? \"\"}` : \"\";\n useEffect(() => {\n const a = api;\n if (!a || !ready) return;\n const others = () => a.getSceneElementsIncludingDeleted().filter((el) => !isBackground(el));\n if (!background?.src) {\n if (a.getSceneElementsIncludingDeleted().some(isBackground)) quietUpdate(others());\n return;\n }\n let cancelled = false;\n const place = (width, height) => {\n if (cancelled || !width || !height) return;\n const fileId = `bg-${hash(background.src)}`;\n a.addFiles([{ id: fileId, dataURL: background.src, mimeType: mimeOf(background.src), created: Date.now() }]);\n const [bg] = convertToExcalidrawElements(\n [{ type: \"image\", id: BG_ID, fileId, x: 0, y: 0, width, height, locked: true, status: \"saved\", customData: { role: BACKGROUND_ROLE } }],\n { regenerateIds: false },\n );\n const rest = others();\n quietUpdate([bg, ...rest]);\n if (!rest.some((el) => !el.isDeleted)) a.scrollToContent(undefined, { fitToContent: true });\n };\n if (background.width && background.height) place(background.width, background.height);\n else {\n const img = new window.Image();\n img.onload = () => place(img.naturalWidth, img.naturalHeight);\n img.src = background.src;\n }\n return () => {\n cancelled = true;\n };\n // bgKey stands for background's fields.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [api, ready, bgKey, quietUpdate]);\n\n // Controlled: a `value` that is not what we last sent replaces the scene.\n useEffect(() => {\n const a = api;\n if (!a || !ready || value === undefined) return;\n const sig = signature(value);\n if (sig === lastSig.current) return;\n lastSig.current = sig;\n const s = toSketch(value);\n lastValue.current = s;\n const bg = a.getSceneElementsIncludingDeleted().filter(isBackground);\n const files = Object.values(s.files);\n if (files.length) a.addFiles(files);\n quietUpdate([...bg, ...prepareElements(s.elements, paletteRef.current)], { gridModeEnabled: !!s.appState.gridModeEnabled });\n a.history.clear();\n cbs.current.onText?.(sketchToText(s));\n }, [api, ready, value, quietUpdate]);\n\n // Theme and scheme: re-read the tokens when anything that sets them\n // changes, and repaint what was drawn in them.\n const refresh = useCallback(() => {\n const root = rootRef.current;\n if (!root) return;\n setScheme(schemeOf(root));\n const next = resolvePalette(root);\n const prev = paletteRef.current;\n if (paletteKey(next) === paletteKey(prev)) return;\n paletteRef.current = next;\n setPalette(next);\n const a = apiRef.current;\n if (!a) return;\n const all = a.getSceneElementsIncludingDeleted();\n const patches = new Map(repaint(all, next, prev).map((p) => [p.id, p.patch]));\n quietUpdate(\n patches.size ? all.map((el) => (patches.has(el.id) ? newElementWith(el, patches.get(el.id)) : el)) : undefined,\n {\n currentItemStrokeColor: next[current.current.strokeColor] ?? next.foreground,\n currentItemBackgroundColor: next[current.current.backgroundColor] ?? \"transparent\",\n },\n );\n }, [rootRef, quietUpdate]);\n\n useEffect(() => {\n let t = 0;\n const soon = () => {\n clearTimeout(t);\n t = setTimeout(refresh, 30);\n };\n const obs = new MutationObserver((records) => {\n for (const r of records) for (const n of r.addedNodes ?? []) if (n.tagName === \"LINK\") n.addEventListener(\"load\", soon, { once: true });\n soon();\n });\n obs.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n obs.observe(document.head, { childList: true });\n const scoped = rootRef.current?.closest(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) obs.observe(scoped, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const mq = matchMedia(\"(prefers-color-scheme: dark)\");\n mq.addEventListener(\"change\", soon);\n soon();\n return () => {\n clearTimeout(t);\n obs.disconnect();\n mq.removeEventListener(\"change\", soon);\n };\n }, [refresh, rootRef]);\n\n // ---- Excalidraw's events -----------------------------------------------\n\n const fontFix = useRef(0);\n const handleChange = useCallback(\n (elements, appState, files) => {\n latest.current = { elements, appState, files };\n // What our overlays show.\n const selected = elements.filter((el) => appState.selectedElementIds[el.id] && !el.isDeleted);\n const busy =\n appState.newElement || appState.selectionElement || appState.selectedElementsAreBeingDragged || appState.resizingElement || appState.isResizing || appState.isRotating || appState.editingTextElement || appState.multiElement || appState.cursorButton === \"down\";\n let box = null;\n if (selected.length && !busy) {\n const [x1, y1, x2, y2] = getCommonBounds(selected);\n const a = sceneCoordsToViewportCoords({ sceneX: x1, sceneY: y1 }, appState);\n const b = sceneCoordsToViewportCoords({ sceneX: x2, sceneY: y2 }, appState);\n box = { x: a.x - appState.offsetLeft, y: a.y - appState.offsetTop, w: b.x - a.x, h: b.y - a.y, vw: appState.width, vh: appState.height };\n }\n const count = elements.reduce((n, el) => n + (!el.isDeleted && !isBackground(el) ? 1 : 0), 0);\n const next = { tool: appState.activeTool.type, zoom: appState.zoom.value, selected: selected.map((el) => el.id), box, count, grid: !!appState.gridModeEnabled };\n const key = JSON.stringify(next);\n if (key !== uiKey.current) {\n uiKey.current = key;\n setUi(next);\n }\n // Output: only when the drawing changed, not on pan, zoom or select.\n const v = getSceneVersion(elements);\n if (lastVersion.current === -1) lastVersion.current = v;\n else if (v !== lastVersion.current) {\n lastVersion.current = v;\n schedule();\n }\n // Text that arrived in another font (a paste, a dropped file): into\n // the system font, once nothing is being edited.\n if (!busy && elements.some((el) => el.type === \"text\" && !el.isDeleted && el.fontFamily !== SYSTEM_FONT)) {\n clearTimeout(fontFix.current);\n fontFix.current = setTimeout(() => {\n const a = apiRef.current;\n if (!a) return;\n // The whole scene through restoreElements, as Excalidraw does on\n // load: it re-measures text in its container (a subset would lose\n // the bindings it cannot see).\n const all = a.getSceneElementsIncludingDeleted();\n const need = new Set(all.filter((el) => el.type === \"text\" && el.fontFamily !== SYSTEM_FONT).map((el) => el.id));\n if (!need.size) return;\n const fixed = restoreElements(\n all.map((el) => (need.has(el.id) ? { ...el, fontFamily: SYSTEM_FONT } : el)),\n null,\n { refreshDimensions: true, repairBindings: true },\n ).map((el) => (need.has(el.id) ? { ...el, version: el.version + 1, versionNonce: Math.floor(Math.random() * 2 ** 31) } : el));\n a.updateScene({ elements: fixed, captureUpdate: CaptureUpdateAction.NEVER });\n }, 0);\n }\n },\n [schedule],\n );\n\n const handleScroll = useCallback(\n (scrollX, scrollY, zoom) => {\n const root = rootRef.current;\n if (!root) return;\n let gap = 24 * zoom.value;\n while (gap < 10) gap *= 4;\n root.style.setProperty(\"--sketch-dot-gap\", `${gap}px`);\n root.style.setProperty(\"--sketch-dot-x\", `${(scrollX * zoom.value) % gap}px`);\n root.style.setProperty(\"--sketch-dot-y\", `${(scrollY * zoom.value) % gap}px`);\n },\n [rootRef],\n );\n\n // Pasted Excalidraw content keeps its own fonts: into the system font\n // before it lands (no font is ever fetched from a CDN).\n const handlePaste = useCallback((data) => {\n for (const el of data?.elements ?? []) if (el?.type === \"text\") el.fontFamily = SYSTEM_FONT;\n return true;\n }, []);\n\n // ---- the handle ---------------------------------------------------------\n\n const viewCentre = useCallback(() => {\n const st = apiRef.current.getAppState();\n return viewportCoordsToSceneCoords({ clientX: st.offsetLeft + st.width / 2, clientY: st.offsetTop + st.height / 2 }, st);\n }, []);\n\n const insertElements = useCallback(\n (skeletons, opts = {}) => {\n const a = apiRef.current;\n if (!a || !Array.isArray(skeletons) || !skeletons.length) return [];\n const pal = paletteRef.current;\n let sks = skeletons.map((sk) => prepareSkeleton(sk, pal));\n if (opts.at !== false) {\n const b = boundsOfSkeletons(sks);\n const at = opts.at && typeof opts.at === \"object\" ? opts.at : null;\n const c = at ? { x: at.x, y: at.y } : (() => { const v = viewCentre(); return { x: v.x, y: v.y }; })();\n const dx = (opts.anchor === \"top-left\" && at ? c.x : c.x - b.width / 2) - b.x;\n const dy = (opts.anchor === \"top-left\" && at ? c.y : c.y - b.height / 2) - b.y;\n sks = sks.map((sk) => ({ ...sk, x: (sk.x ?? 0) + dx, y: (sk.y ?? 0) + dy }));\n }\n let els = convertToExcalidrawElements(sks, { regenerateIds: true });\n const gid = opts.group || opts.stamp ? `g${Math.random().toString(36).slice(2, 10)}` : null;\n els = els.map((el) => ({\n ...el,\n groupIds: gid ? [...(el.groupIds ?? []), gid] : el.groupIds,\n customData: { ...(el.customData ?? {}), ...(opts.customData ?? {}), ...(opts.stamp ? { stamp: opts.stamp, stampId: gid } : {}) },\n }));\n if (opts.files?.length) a.addFiles(opts.files);\n const select = opts.select !== false;\n a.updateScene({\n elements: [...a.getSceneElementsIncludingDeleted(), ...els],\n appState: select ? { selectedElementIds: Object.fromEntries(els.filter((e) => !e.containerId).map((e) => [e.id, true])), selectedGroupIds: gid ? { [gid]: true } : {} } : undefined,\n captureUpdate: CaptureUpdateAction.IMMEDIATELY,\n });\n return els.map((e) => e.id);\n },\n [viewCentre],\n );\n\n const exportImage = useCallback(async (opts = {}) => {\n const a = apiRef.current;\n if (!a) throw new Error(\"The canvas is not ready yet.\");\n const st = a.getAppState();\n const all = a.getSceneElements();\n const els = opts.onlySelected ? all.filter((el) => st.selectedElementIds[el.id]) : opts.withBackground === false ? all.filter((el) => !isBackground(el)) : all;\n const scale = opts.scale ?? 2;\n return exportToBlob({\n elements: els,\n files: a.getFiles(),\n mimeType: \"image/png\",\n exportPadding: opts.padding ?? 16,\n appState: { ...st, exportBackground: opts.transparent !== true, viewBackgroundColor: paletteRef.current.background ?? \"transparent\", exportWithDarkMode: false, exportScale: scale },\n getDimensions: (w, h) => ({ width: w * scale, height: h * scale, scale }),\n });\n }, []);\n\n const handle = useMemo(() => {\n if (!api) return null;\n return {\n api,\n getScene: (opts = {}) => {\n const v = sceneValue(api, paletteRef.current);\n if (!opts.withBackground) return v;\n const bg = api.getSceneElements().filter(isBackground);\n return { ...v, elements: [...bg, ...v.elements], files: { ...v.files, ...filesFor(bg, api.getFiles()) } };\n },\n setScene: (v) => {\n const s = toSketch(v);\n const bg = api.getSceneElementsIncludingDeleted().filter(isBackground);\n if (Object.keys(s.files).length) api.addFiles(Object.values(s.files));\n api.updateScene({ elements: [...bg, ...prepareElements(s.elements, paletteRef.current)], captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n },\n insertElements,\n exportImage,\n zoomToFit: (opts = {}) => api.scrollToContent(undefined, { fitToContent: true, animate: opts.animate !== false }),\n setTool: (id) => {\n const s = stampList.find((x) => x.id === id);\n if (s) return setStamp(s);\n const t = SKETCH_TOOLS.find((x) => x.id === id || x.tool === id);\n setStamp(null);\n if (t) api.setActiveTool({ type: t.tool });\n },\n clear: () => {\n const bg = api.getSceneElementsIncludingDeleted().filter(isBackground);\n api.updateScene({ elements: bg, captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n },\n undo: () => press(\"z\", \"KeyZ\", { cmd: true }),\n redo: () => press(\"z\", \"KeyZ\", { cmd: true, shift: true }),\n focus: () => rootRef.current?.querySelector(\".excalidraw\")?.focus(),\n palette: () => paletteRef.current,\n repaint: refresh,\n save: emit,\n };\n }, [api, insertElements, exportImage, stampList, press, rootRef, refresh, emit]);\n\n useEffect(() => {\n if (!canvasRef || !handle) return;\n canvasRef.current = handle;\n return () => {\n if (canvasRef.current === handle) canvasRef.current = null;\n };\n }, [canvasRef, handle]);\n useEffect(() => {\n if (handle) cbs.current.onReady?.(handle);\n }, [handle]);\n\n // Open fitted: initialData's scrollToContent only centres the scene, so\n // a board larger than the frame opened on its top-left corner at 100%.\n // Fit once the canvas has its size; never zoom in past 100%.\n useEffect(() => {\n if (!api || !ready) return;\n const t = setTimeout(() => {\n const live = api.getSceneElements().filter((el) => !el.isDeleted);\n if (!live.length) return;\n api.scrollToContent(live, { fitToViewport: true, viewportZoomFactor: 0.9, maxZoom: 1, animate: false });\n }, 150);\n return () => clearTimeout(t);\n }, [api, ready]);\n\n // ---- our controls -------------------------------------------------------\n\n const pickTool = (t) => {\n setStamp(null);\n apiRef.current?.setActiveTool({ type: t.tool });\n };\n\n const placeStamp = (s, at) => {\n insertElements(s.create(), { stamp: s.id, at });\n setStamp(null);\n apiRef.current?.setActiveTool({ type: \"selection\" });\n };\n\n const selectedEls = () => {\n const a = apiRef.current;\n if (!a) return [];\n const ids = a.getAppState().selectedElementIds;\n return a.getSceneElements().filter((el) => ids[el.id]);\n };\n\n const setColour = (prop, token) => {\n const a = apiRef.current;\n const pal = paletteRef.current;\n if (!a || !(token in pal)) return;\n const sel = selectedEls();\n const ids = new Set(sel.map((el) => el.id));\n if (prop === \"strokeColor\") for (const el of sel) for (const b of el.boundElements ?? []) if (b.type === \"text\") ids.add(b.id);\n const all = a.getSceneElementsIncludingDeleted();\n a.updateScene({\n elements: all.map((el) =>\n ids.has(el.id) ? newElementWith(el, { [prop]: pal[token], customData: { ...(el.customData ?? {}), tokens: { ...(el.customData?.tokens ?? {}), [prop]: token } } }) : el,\n ),\n appState: prop === \"strokeColor\" ? { currentItemStrokeColor: pal[token] } : { currentItemBackgroundColor: pal[token] },\n captureUpdate: CaptureUpdateAction.IMMEDIATELY,\n });\n current.current = { ...current.current, [prop]: token };\n };\n\n const setProp = (patch, only) => {\n const a = apiRef.current;\n if (!a) return;\n const ids = new Set(selectedEls().filter(only ?? (() => true)).map((el) => el.id));\n a.updateScene({ elements: a.getSceneElementsIncludingDeleted().map((el) => (ids.has(el.id) ? newElementWith(el, patch) : el)), captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n };\n\n const download = async () => {\n try {\n const blob = await exportImage();\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"sketch.png\";\n link.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n } catch {\n setSave((s) => ({ ...s, exportError: true }));\n }\n };\n\n // Keys on their way into the canvas: tools left out, the stamp being\n // placed, Cmd/Ctrl+S.\n const keyDown = (e) => {\n const t = e.target;\n if (t?.tagName === \"TEXTAREA\" || t?.tagName === \"INPUT\" || t?.isContentEditable) return;\n const k = e.key?.toLowerCase();\n if ((e.metaKey || e.ctrlKey) && k === \"s\") {\n e.preventDefault();\n e.stopPropagation();\n if (!readOnly) emit();\n return;\n }\n if ((e.metaKey || e.ctrlKey) && k === \"f\") {\n e.stopPropagation(); // the browser's find, not Excalidraw's (hidden) search\n return;\n }\n if (stamp) {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n setStamp(null);\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n e.stopPropagation();\n placeStamp(stamp);\n }\n return;\n }\n if (!e.metaKey && !e.ctrlKey && !e.altKey && blockedKeys.has(k)) {\n e.preventDefault();\n e.stopPropagation();\n }\n };\n\n const selected = ui.selected.length ? selectedEls() : [];\n const kinds = new Set(selected.map((el) => el.type));\n const hasShape = [...kinds].some((k) => SHAPES.has(k));\n const hasLine = [...kinds].some((k) => SHAPES.has(k) || LINES.has(k));\n const hasText = kinds.has(\"text\") || selected.some((el) => (el.boundElements ?? []).some((b) => b.type === \"text\"));\n const hasStroke = [...kinds].some((k) => k !== \"image\" && k !== \"frame\" && k !== \"magicframe\");\n const first = selected[0];\n const tokens = first ? elementTokens(first, palette) : {};\n const roughness = first?.roughness ?? 1;\n const width = first?.strokeWidth ?? 2;\n const empty_ = ui.count === 0;\n const showEmpty = empty_ && !readOnly && empty !== false && !stamp && (ui.tool === \"selection\" || ui.tool === \"hand\");\n\n return (\n // raw: a plain box that hears keys on their way into Excalidraw (capture),\n // to swallow the shortcuts of tools left out; no control of its own.\n <div\n className=\"absolute inset-0\"\n onKeyDownCapture={keyDown}\n style={{ \"--sketch-selection\": palette.primary ?? \"currentColor\" }}\n >\n {grid ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 [background-image:radial-gradient(var(--color-border)_1.2px,transparent_1.3px)] [background-size:var(--sketch-dot-gap,24px)_var(--sketch-dot-gap,24px)] [background-position:var(--sketch-dot-x,0)_var(--sketch-dot-y,0)]\"\n />\n ) : null}\n <div className=\"absolute inset-0\">\n <Excalidraw\n excalidrawAPI={(a) => {\n apiRef.current = a;\n setApi(a);\n }}\n initialData={initialData}\n onChange={handleChange}\n onScrollChange={handleScroll}\n onPaste={handlePaste}\n theme={scheme}\n viewModeEnabled={readOnly}\n aiEnabled={false}\n detectScroll\n handleKeyboardGlobally={false}\n UIOptions={{\n canvasActions: {\n changeViewBackgroundColor: false,\n clearCanvas: false,\n export: false,\n loadScene: false,\n saveToActiveFile: false,\n toggleTheme: false,\n saveAsImage: false,\n },\n tools: { image: activeTools.some((t) => t.id === \"image\") },\n }}\n renderTopRightUI={() => null}\n />\n </div>\n\n {showEmpty ? (\n <div className=\"pointer-events-none absolute inset-0 z-10 grid place-items-center p-6\">\n {empty ?? (\n <div className=\"flex max-w-xs flex-col items-center gap-2 text-center\">\n <span className=\"grid size-12 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name=\"pencil\" size={22} />\n </span>\n <p className=\"text-base font-medium\">Start sketching</p>\n <p className=\"text-sm text-muted-foreground\">\n Pick a tool, or press <Kbd>P</Kbd> to draw, <Kbd>R</Kbd> for a box, <Kbd>T</Kbd> for text.\n </p>\n </div>\n )}\n </div>\n ) : null}\n\n {stamp ? (\n <>\n {/* raw: a click target laid over the canvas while a stamp waits to\n land — the pointer picks the spot; Enter drops it in the middle. */}\n <div\n aria-hidden=\"true\"\n className=\"absolute inset-0 z-10 cursor-crosshair\"\n onPointerDown={(e) => {\n e.preventDefault();\n const st = apiRef.current?.getAppState();\n if (!st) return;\n placeStamp(stamp, viewportCoordsToSceneCoords({ clientX: e.clientX, clientY: e.clientY }, st));\n }}\n />\n <div className=\"pointer-events-none absolute inset-x-0 top-3 z-20 flex justify-center px-3\">\n <p role=\"status\" className=\"rounded-full border border-border bg-popover px-3 py-1 text-xs text-popover-foreground shadow-sm\">\n Click to place the {stamp.label.toLowerCase()} · Enter for the middle · Esc to cancel\n </p>\n </div>\n </>\n ) : null}\n\n {!readOnly ? (\n <Rail tools={activeTools} stamps={stampList} tool={ui.tool} stamp={stamp} onTool={pickTool} onStamp={(s) => setStamp((cur) => (cur?.id === s.id ? null : s))} />\n ) : null}\n\n {!readOnly && ui.box && selected.length ? (\n <ContextBar\n box={ui.box}\n tokens={tokens}\n hasShape={hasShape}\n hasLine={hasLine}\n hasText={hasText}\n hasStroke={hasStroke}\n roughness={roughness}\n width={width}\n onColour={setColour}\n onWidth={(w) => setProp({ strokeWidth: w }, (el) => el.type !== \"text\")}\n onRough={(r) => setProp({ roughness: r }, (el) => el.type !== \"text\")}\n onFont={(bigger) => press(bigger ? \">\" : \"<\", bigger ? \"Period\" : \"Comma\", { cmd: true, shift: true })}\n onLayer={layer}\n onDuplicate={() => press(\"d\", \"KeyD\", { cmd: true })}\n onDelete={() => press(\"Delete\", \"Delete\")}\n />\n ) : null}\n\n {/* top right: the host's actions, the save state, the menu */}\n <div className=\"absolute right-3 top-3 z-20 flex items-center gap-2\">\n {onSave && !readOnly ? <SaveState save={save} onRetry={() => runSave(lastValue.current)} /> : null}\n {actions}\n <div className=\"rounded-lg border border-border bg-popover shadow-sm\">\n <Menu\n label=\"Canvas menu\"\n size=\"sm\"\n items={[\n { label: \"Download PNG\", icon: \"download\", onSelect: download },\n !readOnly && { type: \"checkbox\", label: \"Snap to grid\", checked: ui.grid, onCheckedChange: (on) => apiRef.current?.updateScene({ appState: { gridModeEnabled: on } }) },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => handle?.zoomToFit() },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => apiRef.current?.updateScene({ appState: { openDialog: { name: \"help\" } } }) },\n !readOnly && { type: \"separator\" },\n !readOnly && { label: \"Clear canvas\", icon: \"trash\", tone: \"danger\", disabled: empty_, onSelect: () => setClearing(true) },\n ]}\n />\n </div>\n </div>\n\n {/* bottom left: undo, redo */}\n {!readOnly ? (\n <div className=\"absolute bottom-3 left-3 z-20 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-sm max-md:bottom-[4.25rem]\">\n <Tooltip label={`Undo — ${isDarwin() ? \"⌘Z\" : \"Ctrl+Z\"}`}>\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" onClick={() => handle?.undo()} />\n </Tooltip>\n <Tooltip label={`Redo — ${isDarwin() ? \"⇧⌘Z\" : \"Ctrl+Shift+Z\"}`}>\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" onClick={() => handle?.redo()} />\n </Tooltip>\n </div>\n ) : null}\n\n {/* bottom right: zoom */}\n <div className=\"absolute bottom-3 right-3 z-20 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-sm max-md:bottom-[4.25rem]\">\n <Tooltip label=\"Zoom out\">\n <IconButton icon=\"minus\" label=\"Zoom out\" size=\"sm\" onClick={() => press(\"-\", \"Minus\", { cmd: true })} />\n </Tooltip>\n <Tooltip label=\"Reset to 100%\">\n <Button variant=\"quiet\" size=\"sm\" className=\"min-w-14 tabular-nums\" onClick={() => press(\"0\", \"Digit0\", { cmd: true })} aria-label={`Zoom ${Math.round(ui.zoom * 100)}%, reset to 100%`}>\n {Math.round(ui.zoom * 100)}%\n </Button>\n </Tooltip>\n <Tooltip label=\"Zoom in\">\n <IconButton icon=\"plus\" label=\"Zoom in\" size=\"sm\" onClick={() => press(\"=\", \"Equal\", { cmd: true })} />\n </Tooltip>\n <Tooltip label=\"Fit to screen — ⇧1\">\n <IconButton icon=\"maximize\" label=\"Fit to screen\" size=\"sm\" onClick={() => handle?.zoomToFit()} />\n </Tooltip>\n </div>\n\n <Confirm\n open={clearing}\n onClose={() => setClearing(false)}\n onConfirm={() => {\n handle?.clear();\n setClearing(false);\n }}\n title=\"Clear the canvas?\"\n body=\"Everything drawn here is removed. Undo brings it back.\"\n confirmLabel=\"Clear\"\n tone=\"danger\"\n />\n </div>\n );\n}\n\n/** The tool rail: a vertical pill on the left; a bottom bar on a phone. */\nfunction Rail({ tools, stamps, tool, stamp, onTool, onStamp }) {\n return (\n <div\n role=\"toolbar\"\n aria-label=\"Tools\"\n className={cx(\n \"absolute z-20 flex gap-0.5 rounded-xl border border-border bg-popover p-1 shadow-lg\",\n \"bottom-3 left-1/2 max-w-[calc(100%-1.5rem)] -translate-x-1/2 flex-row overflow-x-auto\",\n \"md:bottom-auto md:left-3 md:top-1/2 md:max-h-[calc(100%-1.5rem)] md:max-w-none md:-translate-x-0 md:-translate-y-1/2 md:flex-col md:overflow-y-auto md:overflow-x-visible\",\n )}\n >\n {tools.map((t) => (\n <Tooltip key={t.id} label={t.key ? `${t.label} — ${t.key}` : t.label} side=\"right\">\n <Toggle variant=\"quiet\" size=\"sm\" icon={t.icon} label={t.label} pressed={!stamp && tool === t.tool} onPressedChange={() => onTool(t)} aria-keyshortcuts={t.key ?? undefined} />\n </Tooltip>\n ))}\n {stamps.length ? (\n <>\n <span aria-hidden=\"true\" className=\"mx-0.5 w-px shrink-0 self-stretch bg-border md:mx-0.5 md:my-0.5 md:h-px md:w-auto\" />\n {stamps.map((s) => (\n <Tooltip key={s.id} label={`${s.label} — click the canvas to place`} side=\"right\">\n <Toggle variant=\"quiet\" size=\"sm\" icon={s.icon ?? \"plus\"} label={s.label} pressed={stamp?.id === s.id} onPressedChange={() => onStamp(s)} />\n </Tooltip>\n ))}\n </>\n ) : null}\n </div>\n );\n}\n\n/** The floating toolbar above a selection: colours from the theme, stroke,\n * text size, order, duplicate, delete. */\nfunction ContextBar({ box, tokens, hasShape, hasLine, hasText, hasStroke, roughness, width, onColour, onWidth, onRough, onFont, onLayer, onDuplicate, onDelete }) {\n const above = box.y > 64;\n const left = Math.max(8, Math.min(box.vw - 8, box.x + box.w / 2));\n const top = above ? box.y - 10 : Math.min(box.vh - 56, box.y + box.h + 10);\n return (\n <div\n role=\"toolbar\"\n aria-label=\"Selection\"\n className={cx(\n \"absolute z-20 flex max-w-[calc(100%-1rem)] items-center gap-0.5 overflow-x-auto rounded-lg border border-border bg-popover p-0.5 text-popover-foreground shadow-lg\",\n \"-translate-x-1/2 transition-opacity duration-150 motion-reduce:transition-none\",\n above && \"-translate-y-full\",\n )}\n style={{ left, top }}\n >\n {hasStroke ? <Swatches label=\"Colour\" prop=\"strokeColor\" list={STROKES} value={tokens.strokeColor} ring onPick={onColour} /> : null}\n {hasShape ? <Swatches label=\"Fill\" prop=\"backgroundColor\" list={FILLS} value={tokens.backgroundColor} onPick={onColour} /> : null}\n {hasLine ? (\n <>\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Popover\n label=\"Stroke\"\n variant=\"quiet\"\n size=\"sm\"\n width=\"auto\"\n className=\"p-2\"\n trigger={\n <span className=\"inline-flex items-center gap-1\" aria-label=\"Stroke\">\n <Icon name=\"minus\" size={16} />\n </span>\n }\n >\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex gap-1\" role=\"group\" aria-label=\"Stroke width\">\n {WIDTHS.map((w) => (\n <Toggle key={w.value} size=\"sm\" pressed={width === w.value} onPressedChange={() => onWidth(w.value)}>\n {w.label}\n </Toggle>\n ))}\n </div>\n <div className=\"flex gap-1\" role=\"group\" aria-label=\"Line style\">\n <Toggle size=\"sm\" pressed={roughness === 0} onPressedChange={() => onRough(0)}>\n Clean\n </Toggle>\n <Toggle size=\"sm\" pressed={roughness > 0} onPressedChange={() => onRough(1)}>\n Sketchy\n </Toggle>\n </div>\n </div>\n </Popover>\n </>\n ) : null}\n {hasText ? (\n <>\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Tooltip label=\"Smaller text\">\n <IconButton icon=\"minus\" label=\"Smaller text\" size=\"sm\" onClick={() => onFont(false)} />\n </Tooltip>\n <Icon name=\"type\" size={16} className=\"text-muted-foreground\" />\n <Tooltip label=\"Larger text\">\n <IconButton icon=\"plus\" label=\"Larger text\" size=\"sm\" onClick={() => onFont(true)} />\n </Tooltip>\n </>\n ) : null}\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Tooltip label=\"Bring to front\">\n <IconButton icon=\"bring-front\" label=\"Bring to front\" size=\"sm\" onClick={() => onLayer(true)} />\n </Tooltip>\n <Tooltip label=\"Send to back\">\n <IconButton icon=\"send-back\" label=\"Send to back\" size=\"sm\" onClick={() => onLayer(false)} />\n </Tooltip>\n <Tooltip label=\"Duplicate\">\n <IconButton icon=\"copy\" label=\"Duplicate\" size=\"sm\" onClick={onDuplicate} />\n </Tooltip>\n <Tooltip label=\"Delete\">\n <IconButton icon=\"trash\" label=\"Delete\" size=\"sm\" onClick={onDelete} />\n </Tooltip>\n </div>\n );\n}\n\nfunction Swatches({ label, prop, list, value, ring, onPick }) {\n const cur = list.find((s) => s.token === value);\n return (\n <Popover\n label={label}\n variant=\"quiet\"\n size=\"sm\"\n width=\"auto\"\n className=\"p-2\"\n trigger={\n <span className=\"inline-flex items-center\" aria-label={`${label}: ${cur?.label ?? \"custom\"}`}>\n <span className={cx(\"inline-block size-5 rounded-full border border-border\", ring && \"ring-2 ring-inset ring-background\", SWATCH[value] ?? \"bg-muted\")} />\n </span>\n }\n >\n <div className=\"grid grid-cols-6 gap-1\" role=\"group\" aria-label={label}>\n {list.map((s) => (\n <Tooltip key={s.token} label={s.label}>\n <Button variant=\"quiet\" size=\"sm\" iconOnly aria-label={s.label} aria-pressed={value === s.token} className=\"aria-pressed:bg-accent\" onClick={() => onPick(prop, s.token)}>\n <span className={cx(\"grid size-6 place-items-center rounded-full border border-border\", SWATCH[s.token])}>\n {s.token === \"none\" ? <Icon name=\"x\" size={14} className=\"text-muted-foreground\" /> : null}\n </span>\n </Button>\n </Tooltip>\n ))}\n </div>\n </Popover>\n );\n}\n\nfunction SaveState({ save, onRetry }) {\n if (save.state === \"idle\") return null;\n if (save.state === \"error\") {\n return (\n <div role=\"alert\" className=\"flex items-center gap-1 rounded-lg border border-border bg-popover py-0.5 pl-2.5 pr-0.5 text-xs shadow-sm\">\n <Icon name=\"warning\" size={14} className=\"text-destructive\" />\n <span>{save.error || \"Couldn't save\"}</span>\n <Button variant=\"quiet\" size=\"sm\" onClick={onRetry}>\n Retry\n </Button>\n </div>\n );\n }\n return (\n <p role=\"status\" className=\"rounded-lg border border-border bg-popover px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm\">\n {save.state === \"saving\" ? \"Saving…\" : \"Saved\"}\n </p>\n );\n}\n"
24
+ "content": "\"use client\";\n\n// SketchCanvas — Excalidraw, in the workspace theme, behind one set of props.\n//\n// Client only, one bundle. Excalidraw needs the DOM, and the app's\n// check refuses import() (so no next/dynamic): the modules load with the\n// page (on the server too — see excalidraw.js and ssr-shim.js for how that\n// is made safe), and the canvas itself mounts only after the first effect\n// (the mount guard). Until then — on the server and in the first client\n// paint — the same frame shows, with the sketch's words for a reader.\n//\n// What is ours and what is Excalidraw's: Excalidraw draws, selects, edits\n// text, keeps history and answers its own shortcuts. The tool rail, the\n// context toolbar (colours from the theme), the zoom and undo buttons, the\n// menu, the save state and the empty state are ours, in the kit; theme.js\n// hides the Excalidraw UI they replace. Where ours drives an Excalidraw\n// action that has no API (undo, zoom, layer order, duplicate, delete,\n// font size) it presses that action's own shortcut on the canvas, so the\n// behaviour is Excalidraw's exactly.\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Button, Confirm, Icon, IconButton, Kbd, Menu, Popover, Separator, Toggle, Tooltip, cx } from \"../../ui\";\nimport {\n CaptureUpdateAction,\n Excalidraw,\n convertToExcalidrawElements,\n exportToBlob,\n getCommonBounds,\n getSceneVersion,\n newElementWith,\n restoreElements,\n sceneCoordsToViewportCoords,\n viewportCoordsToSceneCoords,\n} from \"./excalidraw\";\nimport { FILLS, STROKES, elementTokens, paletteKey, repaint, resolveColours, resolvePalette, tagTokens } from \"./palette\";\nimport { BACKGROUND_ROLE, SKETCH_VERSION, SYSTEM_FONT, filesFor, isBackground, pickAppState, sketchToText, toSketch } from \"./sketch\";\nimport { ROOT_VARS, THEME, TRIM_CSS } from \"./theme\";\n\n/** A ref for the canvas's handle: `const sketch = useSketchCanvas()`, then\n * `<SketchCanvas canvasRef={sketch} />` and `await sketch.current?.exportImage()`. */\nexport function useSketchCanvas() {\n return useRef(null);\n}\n\n/** The built-in tools, in rail order. `tools` picks a subset by id. */\nexport const SKETCH_TOOLS = [\n { id: \"select\", tool: \"selection\", label: \"Select\", icon: \"mouse-pointer\", key: \"V\" },\n { id: \"hand\", tool: \"hand\", label: \"Pan\", icon: \"hand\", key: \"H\" },\n { id: \"draw\", tool: \"freedraw\", label: \"Draw\", icon: \"pencil\", key: \"P\" },\n { id: \"rectangle\", tool: \"rectangle\", label: \"Rectangle\", icon: \"square\", key: \"R\" },\n { id: \"ellipse\", tool: \"ellipse\", label: \"Ellipse\", icon: \"circle\", key: \"O\" },\n { id: \"diamond\", tool: \"diamond\", label: \"Diamond\", icon: \"diamond\", key: \"D\" },\n { id: \"arrow\", tool: \"arrow\", label: \"Arrow\", icon: \"arrow-right\", key: \"A\" },\n { id: \"line\", tool: \"line\", label: \"Line\", icon: \"minus\", key: \"L\" },\n { id: \"text\", tool: \"text\", label: \"Text\", icon: \"type\", key: \"T\" },\n { id: \"image\", tool: \"image\", label: \"Image\", icon: \"image\", key: null },\n { id: \"eraser\", tool: \"eraser\", label: \"Eraser\", icon: \"eraser\", key: \"E\" },\n { id: \"frame\", tool: \"frame\", label: \"Frame\", icon: \"frame\", key: \"F\" },\n];\n\n// Excalidraw's own single-key tool shortcuts; a tool left out of `tools`\n// has its keys swallowed so it cannot be reached by keyboard either.\nconst TOOL_KEYS = {\n selection: [\"v\", \"1\"],\n rectangle: [\"r\", \"2\"],\n diamond: [\"d\", \"3\"],\n ellipse: [\"o\", \"4\"],\n arrow: [\"a\", \"5\"],\n line: [\"l\", \"6\"],\n freedraw: [\"p\", \"x\", \"7\"],\n text: [\"t\", \"8\"],\n image: [\"9\"],\n eraser: [\"e\", \"0\"],\n hand: [\"h\"],\n frame: [\"f\"],\n laser: [\"k\"],\n};\n\n// Tailwind needs whole class names in the source: one per token.\nconst SWATCH = {\n foreground: \"bg-foreground\",\n \"muted-foreground\": \"bg-muted-foreground\",\n primary: \"bg-primary\",\n destructive: \"bg-destructive\",\n warning: \"bg-warning\",\n success: \"bg-success\",\n info: \"bg-info\",\n \"chart-2\": \"bg-chart-2\",\n \"chart-3\": \"bg-chart-3\",\n \"chart-4\": \"bg-chart-4\",\n \"chart-5\": \"bg-chart-5\",\n none: \"bg-background\",\n muted: \"bg-muted\",\n \"primary:soft\": \"bg-primary/25\",\n \"destructive:soft\": \"bg-destructive/25\",\n \"warning:soft\": \"bg-warning/25\",\n \"success:soft\": \"bg-success/25\",\n \"info:soft\": \"bg-info/25\",\n \"chart-2:soft\": \"bg-chart-2/25\",\n \"chart-3:soft\": \"bg-chart-3/25\",\n \"chart-4:soft\": \"bg-chart-4/25\",\n \"chart-5:soft\": \"bg-chart-5/25\",\n};\n\nconst WIDTHS = [\n { value: 1, label: \"Thin\" },\n { value: 2, label: \"Medium\" },\n { value: 4, label: \"Bold\" },\n];\n\nconst BG_ID = \"sketch-background\";\nconst SHAPES = new Set([\"rectangle\", \"ellipse\", \"diamond\"]);\nconst LINES = new Set([\"arrow\", \"line\", \"freedraw\"]);\nconst isDarwin = () => typeof navigator !== \"undefined\" && /Mac|iPod|iPhone|iPad/.test(navigator.platform);\nconst nonEmpty = (v) => (v && (Array.isArray(v) ? v.length : Array.isArray(v.elements) ? v.elements.length : false) ? v : null);\n\n/** A short stable hash (for a background file id). */\nfunction hash(s) {\n let h = 5381;\n for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;\n return h.toString(36);\n}\n\nfunction mimeOf(src) {\n const m = /^data:([^;,]+)/.exec(src);\n if (m) return m[1];\n const ext = /\\.(png|jpe?g|webp|gif|svg)(?:[?#]|$)/i.exec(src)?.[1]?.toLowerCase();\n return { png: \"image/png\", jpg: \"image/jpeg\", jpeg: \"image/jpeg\", webp: \"image/webp\", gif: \"image/gif\", svg: \"image/svg+xml\" }[ext] ?? \"image/png\";\n}\n\n/** The workspace's light or dark where the canvas sits. */\nfunction schemeOf(root) {\n const scoped = root?.closest?.(\"[data-theme]\")?.getAttribute(\"data-theme\");\n if (scoped === \"light\" || scoped === \"dark\") return scoped;\n const cs = root ? getComputedStyle(root).colorScheme || \"\" : \"\";\n if (/\\bdark\\b/.test(cs) && !/\\blight\\b/.test(cs)) return \"dark\";\n if (/\\blight\\b/.test(cs) && !/\\bdark\\b/.test(cs)) return \"light\";\n return typeof matchMedia !== \"undefined\" && matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n}\n\n/** What is saved of the scene: live elements with their tokens, the\n * files they show, the appState a sketch keeps. */\nfunction sceneValue(api, palette) {\n return valueOf(api.getSceneElements(), api.getFiles(), api.getAppState(), palette);\n}\n\nfunction valueOf(elements, files, appState, palette) {\n const live = tagTokens(\n elements.filter((el) => !el.isDeleted && !isBackground(el)),\n palette,\n );\n return { type: \"sketch\", version: SKETCH_VERSION, elements: live, files: filesFor(live, files), appState: pickAppState(appState) };\n}\n\n/** A cheap fingerprint of a value's elements and files. */\nfunction signature(value) {\n const s = toSketch(value);\n return `${s.elements.map((e) => `${e.id}:${e.version}`).join(\",\")}|${Object.keys(s.files).sort().join(\",\")}`;\n}\n\n/** A box never shorter than the text bound inside it. Excalidraw re-wraps\n * and re-measures text as a scene loads (the system font, a narrower box)\n * but grows a container only while someone types, so text that wrapped to\n * an extra line hung out of its box (\"Annual billing toggle\" on the\n * whiteboard demo, seen in the pane 2026-09-24). Grows the box and centres\n * the text again; ellipses and diamonds need more room for the same text. */\nconst BOUND_PAD = 5;\nfunction fitContainers(elements) {\n const byId = new Map(elements.map((el) => [el.id, el]));\n const grown = new Map();\n for (const t of elements) {\n if (t?.type !== \"text\" || !t.containerId || t.isDeleted) continue;\n const box = grown.get(t.containerId) ?? byId.get(t.containerId);\n if (!box || box.type === \"arrow\" || box.type === \"line\") continue;\n // A rectangle needs the text plus its padding; an ellipse or diamond\n // fits text of about 1/√2 of its height (a numbered pin: 18px of text\n // in a 28px circle is fine, and must stay a circle).\n const round = box.type === \"ellipse\" || box.type === \"diamond\";\n const need = Math.ceil(round ? t.height * Math.SQRT2 : t.height + BOUND_PAD * 2);\n if (box.height + 1 >= need) continue;\n grown.set(box.id, { ...box, height: need, version: (box.version ?? 1) + 1 });\n }\n if (!grown.size) return elements;\n return elements.map((el) => {\n if (grown.has(el.id)) return grown.get(el.id);\n if (el?.type === \"text\" && grown.has(el.containerId)) {\n const box = grown.get(el.containerId);\n return { ...el, y: box.y + (box.height - el.height) / 2, version: (el.version ?? 1) + 1 };\n }\n return el;\n });\n}\n\n/** Elements as they should enter the scene: every text in the system\n * font (re-measured), tokened colours resolved in this theme. */\nfunction prepareElements(elements, palette) {\n const fonted = (elements ?? []).map((el) => (el?.type === \"text\" && el.fontFamily !== SYSTEM_FONT ? { ...el, fontFamily: SYSTEM_FONT } : el));\n const restored = fitContainers(restoreElements(fonted, null, { refreshDimensions: true, repairBindings: true }));\n const patches = new Map(repaint(restored, palette, null).map((p) => [p.id, p.patch]));\n return restored.map((el) => (patches.has(el.id) ? { ...el, ...patches.get(el.id) } : el));\n}\n\n/** A skeleton (convertToExcalidrawElements' input) ready to convert:\n * token colours resolved, texts in the system font. */\nfunction prepareSkeleton(sk, palette) {\n const out = resolveColours(sk, palette);\n if (out.type === \"text\" && !out.fontFamily) out.fontFamily = SYSTEM_FONT;\n if (out.label && !out.label.fontFamily) out.label = { ...out.label, fontFamily: SYSTEM_FONT };\n if (out.type !== \"text\" && !out.strokeColor && palette.foreground) out.strokeColor = palette.foreground;\n if (out.type === \"text\" && !out.strokeColor && palette.foreground) out.strokeColor = palette.foreground;\n return out;\n}\n\nfunction boundsOfSkeletons(sks) {\n const boxes = sks.map((s) => {\n const w = s.width ?? (s.type === \"text\" ? String(s.text ?? \"\").length * (s.fontSize ?? 20) * 0.55 : 0);\n const h = s.height ?? (s.type === \"text\" ? (s.fontSize ?? 20) * 1.25 : 0);\n return { x1: s.x ?? 0, y1: s.y ?? 0, x2: (s.x ?? 0) + w, y2: (s.y ?? 0) + h };\n });\n const x1 = Math.min(...boxes.map((b) => b.x1));\n const y1 = Math.min(...boxes.map((b) => b.y1));\n return { x: x1, y: y1, width: Math.max(...boxes.map((b) => b.x2)) - x1, height: Math.max(...boxes.map((b) => b.y2)) - y1 };\n}\n\nexport function SketchCanvas(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return <Frame {...props} mounted={mounted} />;\n}\n\n/** The frame, the same before and after the canvas mounts: server HTML\n * already has its size, border and text alternative; nothing jumps. */\nfunction Frame({ mounted, className, label = \"Sketch\", ...props }) {\n const rootRef = useRef(null);\n const descId = useId();\n const [text, setText] = useState(() => sketchToText(nonEmpty(props.value) ?? nonEmpty(props.defaultValue)));\n const sized = /(^|\\s)(h-|min-h-|size-|flex-1|grow)/.test(className ?? \"\");\n return (\n <div\n ref={rootRef}\n data-sketch-canvas=\"\"\n role=\"group\"\n aria-label={label}\n aria-describedby={descId}\n className={cx(\n \"relative isolate overflow-hidden rounded-lg border border-border bg-background text-foreground\",\n !sized && \"h-[32rem]\",\n ROOT_VARS,\n THEME,\n className,\n )}\n >\n <style>{TRIM_CSS}</style>\n <p id={descId} className=\"sr-only\">\n {text ? `The sketch says: ${text.replace(/\\n+/g, \". \")}` : \"The sketch is empty.\"}\n </p>\n {mounted ? (\n <LiveSketch {...props} rootRef={rootRef} onText={setText} />\n ) : (\n <div className=\"grid h-full place-items-center text-sm text-muted-foreground\" aria-busy=\"true\">\n Loading the canvas…\n </div>\n )}\n </div>\n );\n}\n\nfunction LiveSketch({\n rootRef,\n onText,\n value,\n defaultValue,\n onChange,\n onSave,\n changeDelay = 600,\n readOnly = false,\n background,\n tools,\n stamps,\n grid = true,\n empty,\n actions,\n canvasRef,\n onReady,\n}) {\n const apiRef = useRef(null);\n const [api, setApi] = useState(null);\n // Excalidraw hands over its API before it has loaded initialData, then\n // loads it asynchronously and replaces the scene. Anything laid before\n // that is wiped: annotate's screenshot vanished that way (its file stayed,\n // its image element went; seen in the pane 2026-09-24). `ready` turns true\n // once the first load is done, and the effects that write the scene wait\n // for it.\n const [ready, setReady] = useState(false);\n useEffect(() => {\n if (!api) return;\n if (!api.getAppState().isLoading) return setReady(true);\n const off = api.onChange((_els, appState) => {\n if (!appState.isLoading) setReady(true);\n });\n return () => off?.();\n }, [api]);\n const [palette, setPalette] = useState(() => resolvePalette(rootRef.current));\n const paletteRef = useRef(palette);\n const [scheme, setScheme] = useState(() => schemeOf(rootRef.current));\n const [ui, setUi] = useState({ tool: \"selection\", zoom: 1, selected: [], box: null, count: 0, grid: false });\n const uiKey = useRef(\"\");\n const [stamp, setStamp] = useState(null);\n const [save, setSave] = useState({ state: \"idle\" });\n const [clearing, setClearing] = useState(false);\n const current = useRef({ strokeColor: \"foreground\", backgroundColor: \"none\" });\n\n // What was last sent out / taken in, so a controlled `value` that is\n // only our own echo never reloads the scene.\n const initial = useRef(null);\n if (!initial.current) initial.current = toSketch(nonEmpty(value) ?? nonEmpty(defaultValue));\n const lastSig = useRef(null);\n if (lastSig.current === null) lastSig.current = signature(initial.current);\n const lastVersion = useRef(-1);\n const timer = useRef(0);\n const pending = useRef(false);\n const latest = useRef(null);\n const saveSeq = useRef(0);\n const lastValue = useRef(initial.current);\n const cbs = useRef({});\n cbs.current = { onChange, onSave, onText, onReady };\n\n const activeTools = useMemo(() => {\n const want = Array.isArray(tools) && tools.length ? new Set(tools) : null;\n return SKETCH_TOOLS.filter((t) => !want || want.has(t.id));\n }, [tools]);\n const blockedKeys = useMemo(() => {\n const on = new Set(activeTools.map((t) => t.tool));\n const keys = new Set();\n for (const [tool, ks] of Object.entries(TOOL_KEYS)) if (!on.has(tool)) ks.forEach((k) => keys.add(k));\n return keys;\n }, [activeTools]);\n const stampList = useMemo(() => (Array.isArray(stamps) ? stamps.filter((s) => s && s.id && typeof s.create === \"function\") : []), [stamps]);\n\n const initialData = useMemo(() => {\n const els = prepareElements(initial.current.elements, palette);\n return {\n elements: els,\n files: initial.current.files,\n appState: {\n viewBackgroundColor: \"transparent\",\n currentItemFontFamily: SYSTEM_FONT,\n currentItemStrokeColor: palette.foreground,\n currentItemBackgroundColor: \"transparent\",\n currentItemFillStyle: \"solid\",\n currentItemRoundness: \"round\",\n gridModeEnabled: !!initial.current.appState.gridModeEnabled,\n },\n scrollToContent: true,\n };\n // Excalidraw reads initialData once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ---- output -------------------------------------------------------------\n\n const runSave = useCallback(async (v) => {\n const fn = cbs.current.onSave;\n if (!fn) return;\n const seq = ++saveSeq.current;\n setSave({ state: \"saving\" });\n let res;\n try {\n res = await fn(v);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (seq !== saveSeq.current) return;\n setSave(res?.ok ? { state: \"saved\" } : { state: \"error\", error: res?.error });\n }, []);\n\n const emit = useCallback(() => {\n clearTimeout(timer.current);\n pending.current = false;\n // From what the last onChange handed us (so a flush while unmounting\n // never reads a scene Excalidraw is tearing down), else the API.\n const seen = latest.current;\n const a = apiRef.current;\n if (!seen && !a) return;\n const v = seen ? valueOf(seen.elements, seen.files, seen.appState, paletteRef.current) : sceneValue(a, paletteRef.current);\n lastValue.current = v;\n lastSig.current = signature(v);\n cbs.current.onText?.(sketchToText(v));\n cbs.current.onChange?.(v);\n runSave(v);\n }, [runSave]);\n\n const schedule = useCallback(() => {\n clearTimeout(timer.current);\n pending.current = true;\n timer.current = setTimeout(emit, Math.max(0, changeDelay));\n }, [emit, changeDelay]);\n\n // Leaving with an edit still in its debounce: send it now, not never.\n const flush = useRef(emit);\n flush.current = emit;\n useEffect(\n () => () => {\n clearTimeout(timer.current);\n if (!pending.current) return;\n try {\n flush.current();\n } catch {\n // the canvas is already gone; nothing left to read\n }\n },\n [],\n );\n\n // ---- the scene ----------------------------------------------------------\n\n const quietUpdate = useCallback((elements, appState) => {\n const a = apiRef.current;\n if (!a) return;\n a.updateScene({ elements, appState, captureUpdate: CaptureUpdateAction.NEVER });\n lastVersion.current = getSceneVersion(a.getSceneElementsIncludingDeleted());\n }, []);\n\n /** Press one of Excalidraw's own shortcuts on the canvas. */\n const press = useCallback((key, code, mods = {}) => {\n const target = rootRef.current?.querySelector(\".excalidraw\");\n if (!target) return;\n const mac = isDarwin();\n target.dispatchEvent(\n new KeyboardEvent(\"keydown\", {\n key,\n code,\n bubbles: true,\n cancelable: true,\n metaKey: !!mods.cmd && mac,\n ctrlKey: !!mods.cmd && !mac,\n shiftKey: !!mods.shift,\n altKey: !!mods.alt,\n }),\n );\n }, [rootRef]);\n\n const layer = useCallback(\n (front) => {\n const mac = isDarwin();\n press(front ? \"]\" : \"[\", front ? \"BracketRight\" : \"BracketLeft\", mac ? { cmd: true, alt: true } : { cmd: true, shift: true });\n },\n [press],\n );\n\n // The locked background image, laid from the prop at the back.\n const bgKey = background?.src ? `${background.src.length}:${hash(background.src)}:${background.width ?? \"\"}x${background.height ?? \"\"}` : \"\";\n useEffect(() => {\n const a = api;\n if (!a || !ready) return;\n const others = () => a.getSceneElementsIncludingDeleted().filter((el) => !isBackground(el));\n if (!background?.src) {\n if (a.getSceneElementsIncludingDeleted().some(isBackground)) quietUpdate(others());\n return;\n }\n let cancelled = false;\n const place = (width, height) => {\n if (cancelled || !width || !height) return;\n const fileId = `bg-${hash(background.src)}`;\n a.addFiles([{ id: fileId, dataURL: background.src, mimeType: mimeOf(background.src), created: Date.now() }]);\n const [bg] = convertToExcalidrawElements(\n [{ type: \"image\", id: BG_ID, fileId, x: 0, y: 0, width, height, locked: true, status: \"saved\", customData: { role: BACKGROUND_ROLE } }],\n { regenerateIds: false },\n );\n const rest = others();\n quietUpdate([bg, ...rest]);\n if (!rest.some((el) => !el.isDeleted)) a.scrollToContent(undefined, { fitToContent: true });\n };\n if (background.width && background.height) place(background.width, background.height);\n else {\n const img = new window.Image();\n img.onload = () => place(img.naturalWidth, img.naturalHeight);\n img.src = background.src;\n }\n return () => {\n cancelled = true;\n };\n // bgKey stands for background's fields.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [api, ready, bgKey, quietUpdate]);\n\n // Controlled: a `value` that is not what we last sent replaces the scene.\n useEffect(() => {\n const a = api;\n if (!a || !ready || value === undefined) return;\n const sig = signature(value);\n if (sig === lastSig.current) return;\n lastSig.current = sig;\n const s = toSketch(value);\n lastValue.current = s;\n const bg = a.getSceneElementsIncludingDeleted().filter(isBackground);\n const files = Object.values(s.files);\n if (files.length) a.addFiles(files);\n quietUpdate([...bg, ...prepareElements(s.elements, paletteRef.current)], { gridModeEnabled: !!s.appState.gridModeEnabled });\n a.history.clear();\n cbs.current.onText?.(sketchToText(s));\n }, [api, ready, value, quietUpdate]);\n\n // Theme and scheme: re-read the tokens when anything that sets them\n // changes, and repaint what was drawn in them.\n const refresh = useCallback(() => {\n const root = rootRef.current;\n if (!root) return;\n setScheme(schemeOf(root));\n const next = resolvePalette(root);\n const prev = paletteRef.current;\n if (paletteKey(next) === paletteKey(prev)) return;\n paletteRef.current = next;\n setPalette(next);\n const a = apiRef.current;\n if (!a) return;\n const all = a.getSceneElementsIncludingDeleted();\n const patches = new Map(repaint(all, next, prev).map((p) => [p.id, p.patch]));\n quietUpdate(\n patches.size ? all.map((el) => (patches.has(el.id) ? newElementWith(el, patches.get(el.id)) : el)) : undefined,\n {\n currentItemStrokeColor: next[current.current.strokeColor] ?? next.foreground,\n currentItemBackgroundColor: next[current.current.backgroundColor] ?? \"transparent\",\n },\n );\n }, [rootRef, quietUpdate]);\n\n useEffect(() => {\n let t = 0;\n const soon = () => {\n clearTimeout(t);\n t = setTimeout(refresh, 30);\n };\n const obs = new MutationObserver((records) => {\n for (const r of records) for (const n of r.addedNodes ?? []) if (n.tagName === \"LINK\") n.addEventListener(\"load\", soon, { once: true });\n soon();\n });\n obs.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n obs.observe(document.head, { childList: true });\n const scoped = rootRef.current?.closest(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) obs.observe(scoped, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const mq = matchMedia(\"(prefers-color-scheme: dark)\");\n mq.addEventListener(\"change\", soon);\n soon();\n return () => {\n clearTimeout(t);\n obs.disconnect();\n mq.removeEventListener(\"change\", soon);\n };\n }, [refresh, rootRef]);\n\n // ---- Excalidraw's events -----------------------------------------------\n\n const fontFix = useRef(0);\n const handleChange = useCallback(\n (elements, appState, files) => {\n latest.current = { elements, appState, files };\n // What our overlays show.\n const selected = elements.filter((el) => appState.selectedElementIds[el.id] && !el.isDeleted);\n const busy =\n appState.newElement || appState.selectionElement || appState.selectedElementsAreBeingDragged || appState.resizingElement || appState.isResizing || appState.isRotating || appState.editingTextElement || appState.multiElement || appState.cursorButton === \"down\";\n let box = null;\n if (selected.length && !busy) {\n const [x1, y1, x2, y2] = getCommonBounds(selected);\n const a = sceneCoordsToViewportCoords({ sceneX: x1, sceneY: y1 }, appState);\n const b = sceneCoordsToViewportCoords({ sceneX: x2, sceneY: y2 }, appState);\n box = { x: a.x - appState.offsetLeft, y: a.y - appState.offsetTop, w: b.x - a.x, h: b.y - a.y, vw: appState.width, vh: appState.height };\n }\n const count = elements.reduce((n, el) => n + (!el.isDeleted && !isBackground(el) ? 1 : 0), 0);\n const next = { tool: appState.activeTool.type, zoom: appState.zoom.value, selected: selected.map((el) => el.id), box, count, grid: !!appState.gridModeEnabled };\n const key = JSON.stringify(next);\n if (key !== uiKey.current) {\n uiKey.current = key;\n setUi(next);\n }\n // Output: only when the drawing changed, not on pan, zoom or select.\n const v = getSceneVersion(elements);\n if (lastVersion.current === -1) lastVersion.current = v;\n else if (v !== lastVersion.current) {\n lastVersion.current = v;\n schedule();\n }\n // Text that arrived in another font (a paste, a dropped file): into\n // the system font, once nothing is being edited.\n if (!busy && elements.some((el) => el.type === \"text\" && !el.isDeleted && el.fontFamily !== SYSTEM_FONT)) {\n clearTimeout(fontFix.current);\n fontFix.current = setTimeout(() => {\n const a = apiRef.current;\n if (!a) return;\n // The whole scene through restoreElements, as Excalidraw does on\n // load: it re-measures text in its container (a subset would lose\n // the bindings it cannot see).\n const all = a.getSceneElementsIncludingDeleted();\n const need = new Set(all.filter((el) => el.type === \"text\" && el.fontFamily !== SYSTEM_FONT).map((el) => el.id));\n if (!need.size) return;\n const fixed = restoreElements(\n all.map((el) => (need.has(el.id) ? { ...el, fontFamily: SYSTEM_FONT } : el)),\n null,\n { refreshDimensions: true, repairBindings: true },\n ).map((el) => (need.has(el.id) ? { ...el, version: el.version + 1, versionNonce: Math.floor(Math.random() * 2 ** 31) } : el));\n a.updateScene({ elements: fixed, captureUpdate: CaptureUpdateAction.NEVER });\n }, 0);\n }\n },\n [schedule],\n );\n\n const handleScroll = useCallback(\n (scrollX, scrollY, zoom) => {\n const root = rootRef.current;\n if (!root) return;\n let gap = 24 * zoom.value;\n while (gap < 10) gap *= 4;\n root.style.setProperty(\"--sketch-dot-gap\", `${gap}px`);\n root.style.setProperty(\"--sketch-dot-x\", `${(scrollX * zoom.value) % gap}px`);\n root.style.setProperty(\"--sketch-dot-y\", `${(scrollY * zoom.value) % gap}px`);\n },\n [rootRef],\n );\n\n // Pasted Excalidraw content keeps its own fonts: into the system font\n // before it lands (no font is ever fetched from a CDN).\n const handlePaste = useCallback((data) => {\n for (const el of data?.elements ?? []) if (el?.type === \"text\") el.fontFamily = SYSTEM_FONT;\n return true;\n }, []);\n\n // ---- the handle ---------------------------------------------------------\n\n const viewCentre = useCallback(() => {\n const st = apiRef.current.getAppState();\n return viewportCoordsToSceneCoords({ clientX: st.offsetLeft + st.width / 2, clientY: st.offsetTop + st.height / 2 }, st);\n }, []);\n\n const insertElements = useCallback(\n (skeletons, opts = {}) => {\n const a = apiRef.current;\n if (!a || !Array.isArray(skeletons) || !skeletons.length) return [];\n const pal = paletteRef.current;\n let sks = skeletons.map((sk) => prepareSkeleton(sk, pal));\n if (opts.at !== false) {\n const b = boundsOfSkeletons(sks);\n const at = opts.at && typeof opts.at === \"object\" ? opts.at : null;\n const c = at ? { x: at.x, y: at.y } : (() => { const v = viewCentre(); return { x: v.x, y: v.y }; })();\n const dx = (opts.anchor === \"top-left\" && at ? c.x : c.x - b.width / 2) - b.x;\n const dy = (opts.anchor === \"top-left\" && at ? c.y : c.y - b.height / 2) - b.y;\n sks = sks.map((sk) => ({ ...sk, x: (sk.x ?? 0) + dx, y: (sk.y ?? 0) + dy }));\n }\n let els = convertToExcalidrawElements(sks, { regenerateIds: true });\n const gid = opts.group || opts.stamp ? `g${Math.random().toString(36).slice(2, 10)}` : null;\n els = els.map((el) => ({\n ...el,\n groupIds: gid ? [...(el.groupIds ?? []), gid] : el.groupIds,\n customData: { ...(el.customData ?? {}), ...(opts.customData ?? {}), ...(opts.stamp ? { stamp: opts.stamp, stampId: gid } : {}) },\n }));\n if (opts.files?.length) a.addFiles(opts.files);\n const select = opts.select !== false;\n a.updateScene({\n elements: [...a.getSceneElementsIncludingDeleted(), ...els],\n appState: select ? { selectedElementIds: Object.fromEntries(els.filter((e) => !e.containerId).map((e) => [e.id, true])), selectedGroupIds: gid ? { [gid]: true } : {} } : undefined,\n captureUpdate: CaptureUpdateAction.IMMEDIATELY,\n });\n return els.map((e) => e.id);\n },\n [viewCentre],\n );\n\n const exportImage = useCallback(async (opts = {}) => {\n const a = apiRef.current;\n if (!a) throw new Error(\"The canvas is not ready yet.\");\n const st = a.getAppState();\n const all = a.getSceneElements();\n const els = opts.onlySelected ? all.filter((el) => st.selectedElementIds[el.id]) : opts.withBackground === false ? all.filter((el) => !isBackground(el)) : all;\n const scale = opts.scale ?? 2;\n return exportToBlob({\n elements: els,\n files: a.getFiles(),\n mimeType: \"image/png\",\n exportPadding: opts.padding ?? 16,\n appState: { ...st, exportBackground: opts.transparent !== true, viewBackgroundColor: paletteRef.current.background ?? \"transparent\", exportWithDarkMode: false, exportScale: scale },\n getDimensions: (w, h) => ({ width: w * scale, height: h * scale, scale }),\n });\n }, []);\n\n const handle = useMemo(() => {\n if (!api) return null;\n return {\n api,\n getScene: (opts = {}) => {\n const v = sceneValue(api, paletteRef.current);\n if (!opts.withBackground) return v;\n const bg = api.getSceneElements().filter(isBackground);\n return { ...v, elements: [...bg, ...v.elements], files: { ...v.files, ...filesFor(bg, api.getFiles()) } };\n },\n setScene: (v) => {\n const s = toSketch(v);\n const bg = api.getSceneElementsIncludingDeleted().filter(isBackground);\n if (Object.keys(s.files).length) api.addFiles(Object.values(s.files));\n api.updateScene({ elements: [...bg, ...prepareElements(s.elements, paletteRef.current)], captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n },\n insertElements,\n exportImage,\n zoomToFit: (opts = {}) => api.scrollToContent(undefined, { fitToContent: true, animate: opts.animate !== false }),\n setTool: (id) => {\n const s = stampList.find((x) => x.id === id);\n if (s) return setStamp(s);\n const t = SKETCH_TOOLS.find((x) => x.id === id || x.tool === id);\n setStamp(null);\n if (t) api.setActiveTool({ type: t.tool });\n },\n clear: () => {\n const bg = api.getSceneElementsIncludingDeleted().filter(isBackground);\n api.updateScene({ elements: bg, captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n },\n undo: () => press(\"z\", \"KeyZ\", { cmd: true }),\n redo: () => press(\"z\", \"KeyZ\", { cmd: true, shift: true }),\n focus: () => rootRef.current?.querySelector(\".excalidraw\")?.focus(),\n palette: () => paletteRef.current,\n repaint: refresh,\n save: emit,\n };\n }, [api, insertElements, exportImage, stampList, press, rootRef, refresh, emit]);\n\n useEffect(() => {\n if (!canvasRef || !handle) return;\n canvasRef.current = handle;\n return () => {\n if (canvasRef.current === handle) canvasRef.current = null;\n };\n }, [canvasRef, handle]);\n useEffect(() => {\n if (handle) cbs.current.onReady?.(handle);\n }, [handle]);\n\n // Open fitted: initialData's scrollToContent only centres the scene, so\n // a board larger than the frame opened on its top-left corner at 100%.\n // Fit once the canvas has its size; never zoom in past 100%.\n useEffect(() => {\n if (!api || !ready) return;\n const t = setTimeout(() => {\n const live = api.getSceneElements().filter((el) => !el.isDeleted);\n if (!live.length) return;\n api.scrollToContent(live, { fitToViewport: true, viewportZoomFactor: 0.9, maxZoom: 1, animate: false });\n }, 150);\n return () => clearTimeout(t);\n }, [api, ready]);\n\n // ---- our controls -------------------------------------------------------\n\n const pickTool = (t) => {\n setStamp(null);\n apiRef.current?.setActiveTool({ type: t.tool });\n };\n\n const placeStamp = (s, at) => {\n insertElements(s.create(), { stamp: s.id, at });\n setStamp(null);\n apiRef.current?.setActiveTool({ type: \"selection\" });\n };\n\n const selectedEls = () => {\n const a = apiRef.current;\n if (!a) return [];\n const ids = a.getAppState().selectedElementIds;\n return a.getSceneElements().filter((el) => ids[el.id]);\n };\n\n const setColour = (prop, token) => {\n const a = apiRef.current;\n const pal = paletteRef.current;\n if (!a || !(token in pal)) return;\n const sel = selectedEls();\n const ids = new Set(sel.map((el) => el.id));\n if (prop === \"strokeColor\") for (const el of sel) for (const b of el.boundElements ?? []) if (b.type === \"text\") ids.add(b.id);\n const all = a.getSceneElementsIncludingDeleted();\n a.updateScene({\n elements: all.map((el) =>\n ids.has(el.id) ? newElementWith(el, { [prop]: pal[token], customData: { ...(el.customData ?? {}), tokens: { ...(el.customData?.tokens ?? {}), [prop]: token } } }) : el,\n ),\n appState: prop === \"strokeColor\" ? { currentItemStrokeColor: pal[token] } : { currentItemBackgroundColor: pal[token] },\n captureUpdate: CaptureUpdateAction.IMMEDIATELY,\n });\n current.current = { ...current.current, [prop]: token };\n };\n\n const setProp = (patch, only) => {\n const a = apiRef.current;\n if (!a) return;\n const ids = new Set(selectedEls().filter(only ?? (() => true)).map((el) => el.id));\n a.updateScene({ elements: a.getSceneElementsIncludingDeleted().map((el) => (ids.has(el.id) ? newElementWith(el, patch) : el)), captureUpdate: CaptureUpdateAction.IMMEDIATELY });\n };\n\n const download = async () => {\n try {\n const blob = await exportImage();\n const url = URL.createObjectURL(blob);\n const link = document.createElement(\"a\");\n link.href = url;\n link.download = \"sketch.png\";\n link.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n } catch {\n setSave((s) => ({ ...s, exportError: true }));\n }\n };\n\n // Keys on their way into the canvas: tools left out, the stamp being\n // placed, Cmd/Ctrl+S.\n const keyDown = (e) => {\n const t = e.target;\n if (t?.tagName === \"TEXTAREA\" || t?.tagName === \"INPUT\" || t?.isContentEditable) return;\n const k = e.key?.toLowerCase();\n if ((e.metaKey || e.ctrlKey) && k === \"s\") {\n e.preventDefault();\n e.stopPropagation();\n if (!readOnly) emit();\n return;\n }\n if ((e.metaKey || e.ctrlKey) && k === \"f\") {\n e.stopPropagation(); // the browser's find, not Excalidraw's (hidden) search\n return;\n }\n if (stamp) {\n if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n setStamp(null);\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n e.stopPropagation();\n placeStamp(stamp);\n }\n return;\n }\n if (!e.metaKey && !e.ctrlKey && !e.altKey && blockedKeys.has(k)) {\n e.preventDefault();\n e.stopPropagation();\n }\n };\n\n const selected = ui.selected.length ? selectedEls() : [];\n const kinds = new Set(selected.map((el) => el.type));\n const hasShape = [...kinds].some((k) => SHAPES.has(k));\n const hasLine = [...kinds].some((k) => SHAPES.has(k) || LINES.has(k));\n const hasText = kinds.has(\"text\") || selected.some((el) => (el.boundElements ?? []).some((b) => b.type === \"text\"));\n const hasStroke = [...kinds].some((k) => k !== \"image\" && k !== \"frame\" && k !== \"magicframe\");\n const first = selected[0];\n const tokens = first ? elementTokens(first, palette) : {};\n const roughness = first?.roughness ?? 1;\n const width = first?.strokeWidth ?? 2;\n const empty_ = ui.count === 0;\n const showEmpty = empty_ && !readOnly && empty !== false && !stamp && (ui.tool === \"selection\" || ui.tool === \"hand\");\n\n return (\n // raw: a plain box that hears keys on their way into Excalidraw (capture),\n // to swallow the shortcuts of tools left out; no control of its own.\n <div\n className=\"absolute inset-0\"\n onKeyDownCapture={keyDown}\n style={{ \"--sketch-selection\": palette.primary ?? \"currentColor\" }}\n >\n {grid ? (\n <div\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute inset-0 [background-image:radial-gradient(var(--color-border)_1.2px,transparent_1.3px)] [background-size:var(--sketch-dot-gap,24px)_var(--sketch-dot-gap,24px)] [background-position:var(--sketch-dot-x,0)_var(--sketch-dot-y,0)]\"\n />\n ) : null}\n <div className=\"absolute inset-0\">\n <Excalidraw\n excalidrawAPI={(a) => {\n apiRef.current = a;\n setApi(a);\n }}\n initialData={initialData}\n onChange={handleChange}\n onScrollChange={handleScroll}\n onPaste={handlePaste}\n theme={scheme}\n viewModeEnabled={readOnly}\n aiEnabled={false}\n detectScroll\n handleKeyboardGlobally={false}\n UIOptions={{\n canvasActions: {\n changeViewBackgroundColor: false,\n clearCanvas: false,\n export: false,\n loadScene: false,\n saveToActiveFile: false,\n toggleTheme: false,\n saveAsImage: false,\n },\n tools: { image: activeTools.some((t) => t.id === \"image\") },\n }}\n renderTopRightUI={() => null}\n />\n </div>\n\n {showEmpty ? (\n <div className=\"pointer-events-none absolute inset-0 z-10 grid place-items-center p-6\">\n {empty ?? (\n <div className=\"flex max-w-xs flex-col items-center gap-2 text-center\">\n <span className=\"grid size-12 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name=\"pencil\" size={22} />\n </span>\n <p className=\"text-base font-medium\">Start sketching</p>\n <p className=\"text-sm text-muted-foreground\">\n Pick a tool, or press <Kbd>P</Kbd> to draw, <Kbd>R</Kbd> for a box, <Kbd>T</Kbd> for text.\n </p>\n </div>\n )}\n </div>\n ) : null}\n\n {stamp ? (\n <>\n {/* raw: a click target laid over the canvas while a stamp waits to\n land — the pointer picks the spot; Enter drops it in the middle. */}\n <div\n aria-hidden=\"true\"\n className=\"absolute inset-0 z-10 cursor-crosshair\"\n onPointerDown={(e) => {\n e.preventDefault();\n const st = apiRef.current?.getAppState();\n if (!st) return;\n placeStamp(stamp, viewportCoordsToSceneCoords({ clientX: e.clientX, clientY: e.clientY }, st));\n }}\n />\n <div className=\"pointer-events-none absolute inset-x-0 top-3 z-20 flex justify-center px-3\">\n <p role=\"status\" className=\"rounded-full border border-border bg-popover px-3 py-1 text-xs text-popover-foreground shadow-sm\">\n Click to place the {stamp.label.toLowerCase()} · Enter for the middle · Esc to cancel\n </p>\n </div>\n </>\n ) : null}\n\n {!readOnly ? (\n <Rail tools={activeTools} stamps={stampList} tool={ui.tool} stamp={stamp} onTool={pickTool} onStamp={(s) => setStamp((cur) => (cur?.id === s.id ? null : s))} />\n ) : null}\n\n {!readOnly && ui.box && selected.length ? (\n <ContextBar\n box={ui.box}\n tokens={tokens}\n hasShape={hasShape}\n hasLine={hasLine}\n hasText={hasText}\n hasStroke={hasStroke}\n roughness={roughness}\n width={width}\n onColour={setColour}\n onWidth={(w) => setProp({ strokeWidth: w }, (el) => el.type !== \"text\")}\n onRough={(r) => setProp({ roughness: r }, (el) => el.type !== \"text\")}\n onFont={(bigger) => press(bigger ? \">\" : \"<\", bigger ? \"Period\" : \"Comma\", { cmd: true, shift: true })}\n onLayer={layer}\n onDuplicate={() => press(\"d\", \"KeyD\", { cmd: true })}\n onDelete={() => press(\"Delete\", \"Delete\")}\n />\n ) : null}\n\n {/* top right: the host's actions, the save state, the menu */}\n <div className=\"absolute right-3 top-3 z-20 flex items-center gap-2\">\n {onSave && !readOnly ? <SaveState save={save} onRetry={() => runSave(lastValue.current)} /> : null}\n {actions}\n <div className=\"rounded-lg border border-border bg-popover shadow-sm\">\n <Menu\n label=\"Canvas menu\"\n size=\"sm\"\n items={[\n { label: \"Download PNG\", icon: \"download\", onSelect: download },\n !readOnly && { type: \"checkbox\", label: \"Snap to grid\", checked: ui.grid, onCheckedChange: (on) => apiRef.current?.updateScene({ appState: { gridModeEnabled: on } }) },\n { label: \"Fit to screen\", icon: \"maximize\", shortcut: \"⇧1\", onSelect: () => handle?.zoomToFit() },\n { label: \"Keyboard shortcuts\", icon: \"keyboard\", shortcut: \"?\", onSelect: () => apiRef.current?.updateScene({ appState: { openDialog: { name: \"help\" } } }) },\n !readOnly && { type: \"separator\" },\n !readOnly && { label: \"Clear canvas\", icon: \"trash\", tone: \"danger\", disabled: empty_, onSelect: () => setClearing(true) },\n ]}\n />\n </div>\n </div>\n\n {/* bottom left: undo, redo */}\n {!readOnly ? (\n <div className=\"absolute bottom-3 left-3 z-20 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-sm max-md:bottom-[4.25rem]\">\n <Tooltip label={`Undo — ${isDarwin() ? \"⌘Z\" : \"Ctrl+Z\"}`}>\n <IconButton icon=\"undo\" label=\"Undo\" size=\"sm\" onClick={() => handle?.undo()} />\n </Tooltip>\n <Tooltip label={`Redo — ${isDarwin() ? \"⇧⌘Z\" : \"Ctrl+Shift+Z\"}`}>\n <IconButton icon=\"redo\" label=\"Redo\" size=\"sm\" onClick={() => handle?.redo()} />\n </Tooltip>\n </div>\n ) : null}\n\n {/* bottom right: zoom */}\n <div className=\"absolute bottom-3 right-3 z-20 flex items-center gap-0.5 rounded-lg border border-border bg-popover p-0.5 shadow-sm max-md:bottom-[4.25rem]\">\n <Tooltip label=\"Zoom out\">\n <IconButton icon=\"minus\" label=\"Zoom out\" size=\"sm\" onClick={() => press(\"-\", \"Minus\", { cmd: true })} />\n </Tooltip>\n <Tooltip label=\"Reset to 100%\">\n <Button variant=\"quiet\" size=\"sm\" className=\"min-w-14 tabular-nums\" onClick={() => press(\"0\", \"Digit0\", { cmd: true })} aria-label={`Zoom ${Math.round(ui.zoom * 100)}%, reset to 100%`}>\n {Math.round(ui.zoom * 100)}%\n </Button>\n </Tooltip>\n <Tooltip label=\"Zoom in\">\n <IconButton icon=\"plus\" label=\"Zoom in\" size=\"sm\" onClick={() => press(\"=\", \"Equal\", { cmd: true })} />\n </Tooltip>\n <Tooltip label=\"Fit to screen — ⇧1\">\n <IconButton icon=\"maximize\" label=\"Fit to screen\" size=\"sm\" onClick={() => handle?.zoomToFit()} />\n </Tooltip>\n </div>\n\n <Confirm\n open={clearing}\n onClose={() => setClearing(false)}\n onConfirm={() => {\n handle?.clear();\n setClearing(false);\n }}\n title=\"Clear the canvas?\"\n body=\"Everything drawn here is removed. Undo brings it back.\"\n confirmLabel=\"Clear\"\n tone=\"danger\"\n />\n </div>\n );\n}\n\n/** The tool rail: a vertical pill on the left; a bottom bar on a phone. */\nfunction Rail({ tools, stamps, tool, stamp, onTool, onStamp }) {\n return (\n <div\n role=\"toolbar\"\n aria-label=\"Tools\"\n className={cx(\n \"absolute z-20 flex gap-0.5 rounded-xl border border-border bg-popover p-1 shadow-lg\",\n \"bottom-3 left-1/2 max-w-[calc(100%-1.5rem)] -translate-x-1/2 flex-row overflow-x-auto\",\n \"md:bottom-auto md:left-3 md:top-1/2 md:max-h-[calc(100%-1.5rem)] md:max-w-none md:-translate-x-0 md:-translate-y-1/2 md:flex-col md:overflow-y-auto md:overflow-x-visible\",\n )}\n >\n {tools.map((t) => (\n <Tooltip key={t.id} label={t.key ? `${t.label} — ${t.key}` : t.label} side=\"right\">\n <Toggle variant=\"quiet\" size=\"sm\" icon={t.icon} label={t.label} pressed={!stamp && tool === t.tool} onPressedChange={() => onTool(t)} aria-keyshortcuts={t.key ?? undefined} />\n </Tooltip>\n ))}\n {stamps.length ? (\n <>\n <span aria-hidden=\"true\" className=\"mx-0.5 w-px shrink-0 self-stretch bg-border md:mx-0.5 md:my-0.5 md:h-px md:w-auto\" />\n {stamps.map((s) => (\n <Tooltip key={s.id} label={`${s.label} — click the canvas to place`} side=\"right\">\n <Toggle variant=\"quiet\" size=\"sm\" icon={s.icon ?? \"plus\"} label={s.label} pressed={stamp?.id === s.id} onPressedChange={() => onStamp(s)} />\n </Tooltip>\n ))}\n </>\n ) : null}\n </div>\n );\n}\n\n/** The floating toolbar above a selection: colours from the theme, stroke,\n * text size, order, duplicate, delete. */\nfunction ContextBar({ box, tokens, hasShape, hasLine, hasText, hasStroke, roughness, width, onColour, onWidth, onRough, onFont, onLayer, onDuplicate, onDelete }) {\n const above = box.y > 64;\n const left = Math.max(8, Math.min(box.vw - 8, box.x + box.w / 2));\n const top = above ? box.y - 10 : Math.min(box.vh - 56, box.y + box.h + 10);\n return (\n <div\n role=\"toolbar\"\n aria-label=\"Selection\"\n className={cx(\n \"absolute z-20 flex max-w-[calc(100%-1rem)] items-center gap-0.5 overflow-x-auto rounded-lg border border-border bg-popover p-0.5 text-popover-foreground shadow-lg\",\n \"-translate-x-1/2 transition-opacity duration-150 motion-reduce:transition-none\",\n above && \"-translate-y-full\",\n )}\n style={{ left, top }}\n >\n {hasStroke ? <Swatches label=\"Colour\" prop=\"strokeColor\" list={STROKES} value={tokens.strokeColor} ring onPick={onColour} /> : null}\n {hasShape ? <Swatches label=\"Fill\" prop=\"backgroundColor\" list={FILLS} value={tokens.backgroundColor} onPick={onColour} /> : null}\n {hasLine ? (\n <>\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Popover\n label=\"Stroke\"\n variant=\"quiet\"\n size=\"sm\"\n width=\"auto\"\n className=\"p-2\"\n trigger={\n <span className=\"inline-flex items-center gap-1\" aria-label=\"Stroke\">\n <Icon name=\"minus\" size={16} />\n </span>\n }\n >\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex gap-1\" role=\"group\" aria-label=\"Stroke width\">\n {WIDTHS.map((w) => (\n <Toggle key={w.value} size=\"sm\" pressed={width === w.value} onPressedChange={() => onWidth(w.value)}>\n {w.label}\n </Toggle>\n ))}\n </div>\n <div className=\"flex gap-1\" role=\"group\" aria-label=\"Line style\">\n <Toggle size=\"sm\" pressed={roughness === 0} onPressedChange={() => onRough(0)}>\n Clean\n </Toggle>\n <Toggle size=\"sm\" pressed={roughness > 0} onPressedChange={() => onRough(1)}>\n Sketchy\n </Toggle>\n </div>\n </div>\n </Popover>\n </>\n ) : null}\n {hasText ? (\n <>\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Tooltip label=\"Smaller text\">\n <IconButton icon=\"minus\" label=\"Smaller text\" size=\"sm\" onClick={() => onFont(false)} />\n </Tooltip>\n <Icon name=\"type\" size={16} className=\"text-muted-foreground\" />\n <Tooltip label=\"Larger text\">\n <IconButton icon=\"plus\" label=\"Larger text\" size=\"sm\" onClick={() => onFont(true)} />\n </Tooltip>\n </>\n ) : null}\n <Separator orientation=\"vertical\" className=\"my-1\" />\n <Tooltip label=\"Bring to front\">\n <IconButton icon=\"bring-front\" label=\"Bring to front\" size=\"sm\" onClick={() => onLayer(true)} />\n </Tooltip>\n <Tooltip label=\"Send to back\">\n <IconButton icon=\"send-back\" label=\"Send to back\" size=\"sm\" onClick={() => onLayer(false)} />\n </Tooltip>\n <Tooltip label=\"Duplicate\">\n <IconButton icon=\"copy\" label=\"Duplicate\" size=\"sm\" onClick={onDuplicate} />\n </Tooltip>\n <Tooltip label=\"Delete\">\n <IconButton icon=\"trash\" label=\"Delete\" size=\"sm\" onClick={onDelete} />\n </Tooltip>\n </div>\n );\n}\n\nfunction Swatches({ label, prop, list, value, ring, onPick }) {\n const cur = list.find((s) => s.token === value);\n return (\n <Popover\n label={label}\n variant=\"quiet\"\n size=\"sm\"\n width=\"auto\"\n className=\"p-2\"\n trigger={\n <span className=\"inline-flex items-center\" aria-label={`${label}: ${cur?.label ?? \"custom\"}`}>\n <span className={cx(\"inline-block size-5 rounded-full border border-border\", ring && \"ring-2 ring-inset ring-background\", SWATCH[value] ?? \"bg-muted\")} />\n </span>\n }\n >\n <div className=\"grid grid-cols-6 gap-1\" role=\"group\" aria-label={label}>\n {list.map((s) => (\n <Tooltip key={s.token} label={s.label}>\n <Button variant=\"quiet\" size=\"sm\" iconOnly aria-label={s.label} aria-pressed={value === s.token} className=\"aria-pressed:bg-accent\" onClick={() => onPick(prop, s.token)}>\n <span className={cx(\"grid size-6 place-items-center rounded-full border border-border\", SWATCH[s.token])}>\n {s.token === \"none\" ? <Icon name=\"x\" size={14} className=\"text-muted-foreground\" /> : null}\n </span>\n </Button>\n </Tooltip>\n ))}\n </div>\n </Popover>\n );\n}\n\nfunction SaveState({ save, onRetry }) {\n if (save.state === \"idle\") return null;\n if (save.state === \"error\") {\n return (\n <div role=\"alert\" className=\"flex items-center gap-1 rounded-lg border border-border bg-popover py-0.5 pl-2.5 pr-0.5 text-xs shadow-sm\">\n <Icon name=\"warning\" size={14} className=\"text-destructive\" />\n <span>{save.error || \"Couldn't save\"}</span>\n <Button variant=\"quiet\" size=\"sm\" onClick={onRetry}>\n Retry\n </Button>\n </div>\n );\n }\n return (\n <p role=\"status\" className=\"rounded-lg border border-border bg-popover px-2.5 py-1.5 text-xs text-muted-foreground shadow-sm\">\n {save.state === \"saving\" ? \"Saving…\" : \"Saved\"}\n </p>\n );\n}\n"
25
25
  },
26
26
  {
27
27
  "path": "registry/blocks/sketch-canvas/excalidraw.js",
@@ -33,7 +33,7 @@
33
33
  "path": "registry/blocks/sketch-canvas/ssr-shim.js",
34
34
  "type": "registry:component",
35
35
  "target": "@components/makersclaw/blocks/sketch-canvas/ssr-shim.js",
36
- "content": "// Server only: a stand-in for the few browser globals Excalidraw's modules\n// read WHILE THEY LOAD. See excalidraw.js for why this exists.\n//\n// Excalidraw 0.18 runs browser code at module level (window.location,\n// devicePixelRatio, document.createElement(\"img\"), an Element.prototype\n// polyfill), so evaluating it on the server throws \"window is not defined\"\n// and the page fails to render — even though the component itself only\n// ever renders in the browser (the mount guard). The app is one static\n// bundle (no import(), no next/dynamic), so the module cannot be kept off\n// the server; instead it is let load against these stand-ins.\n//\n// The stand-ins live only for that load: excalidraw.js imports this file,\n// then @excalidraw/excalidraw, then ssr-shim-end.js, which removes them —\n// module evaluation is synchronous, so no other code runs in between. If\n// the load throws, the microtask below removes them anyway, so a global\n// `window` can never outlive it and confuse the rest of the server. In\n// the browser `window` exists and this file does nothing.\nconst G = globalThis;\nconst SET = [];\n\nfunction stand(name, value) {\n if (typeof G[name] !== \"undefined\") return;\n G[name] = value;\n SET.push(name);\n}\n\nif (typeof window === \"undefined\") {\n const element = () => ({ style: {}, dataset: {}, setAttribute() {}, appendChild() {}, getContext: () => ({}) });\n stand(\"window\", { location: { origin: \"\", href: \"\" }, devicePixelRatio: 1 });\n stand(\"document\", { createElement: element, documentElement: element(), head: element(), body: element() });\n stand(\"devicePixelRatio\", 1);\n stand(\"Element\", function Element() {});\n stand(\"navigator\", { platform: \"\", userAgent: \"\" });\n G.__sketchCanvasShim = SET;\n queueMicrotask(() => {\n if (G.__sketchCanvasShim === SET) removeShim();\n });\n}\n\nexport function removeShim() {\n const set = G.__sketchCanvasShim;\n if (!set) return;\n for (const name of set) delete G[name];\n delete G.__sketchCanvasShim;\n}\n"
36
+ "content": "// Server only: a stand-in for the few browser globals Excalidraw's modules\n// read WHILE THEY LOAD. See excalidraw.js for why this exists.\n//\n// Excalidraw 0.18 runs browser code at module level (window.location,\n// devicePixelRatio, document.createElement(\"img\"), an Element.prototype\n// polyfill), so evaluating it on the server throws \"window is not defined\"\n// and the page fails to render — even though the component itself only\n// ever renders in the browser (the mount guard). The app is one bundle\n// (no import(), no next/dynamic), so the module cannot be kept off the\n// server; instead it is let load against these stand-ins.\n//\n// The stand-ins live only for that load: excalidraw.js imports this file,\n// then @excalidraw/excalidraw, then ssr-shim-end.js, which removes them —\n// module evaluation is synchronous, so no other code runs in between. If\n// the load throws, the microtask below removes them anyway, so a global\n// `window` can never outlive it and confuse the rest of the server. In\n// the browser `window` exists and this file does nothing.\nconst G = globalThis;\nconst SET = [];\n\nfunction stand(name, value) {\n if (typeof G[name] !== \"undefined\") return;\n G[name] = value;\n SET.push(name);\n}\n\nif (typeof window === \"undefined\") {\n const element = () => ({ style: {}, dataset: {}, setAttribute() {}, appendChild() {}, getContext: () => ({}) });\n stand(\"window\", { location: { origin: \"\", href: \"\" }, devicePixelRatio: 1 });\n stand(\"document\", { createElement: element, documentElement: element(), head: element(), body: element() });\n stand(\"devicePixelRatio\", 1);\n stand(\"Element\", function Element() {});\n stand(\"navigator\", { platform: \"\", userAgent: \"\" });\n G.__sketchCanvasShim = SET;\n queueMicrotask(() => {\n if (G.__sketchCanvasShim === SET) removeShim();\n });\n}\n\nexport function removeShim() {\n const set = G.__sketchCanvasShim;\n if (!set) return;\n for (const name of set) delete G[name];\n delete G.__sketchCanvasShim;\n}\n"
37
37
  },
38
38
  {
39
39
  "path": "registry/blocks/sketch-canvas/ssr-shim-end.js",
@@ -69,7 +69,7 @@
69
69
  "path": "registry/blocks/sketch-canvas/README.md",
70
70
  "type": "registry:file",
71
71
  "target": "@components/makersclaw/blocks/sketch-canvas/README.md",
72
- "content": "# SketchCanvas\n\nA freeform drawing canvas in the workspace theme, friendly like Miro. A\ntool rail on the left (a bar along the bottom on a phone): select, pan,\ndraw, rectangle, ellipse, diamond, arrow, line, text, image, eraser, frame\n— each with its one-key shortcut. Select something and a small toolbar\nfloats above it: colour and fill from the theme's palette, stroke, text\nsize, bring to front / send to back, duplicate, delete. Undo and redo sit\nbottom left, zoom and fit-to-screen bottom right, a menu top right\n(download PNG, snap to grid, shortcuts, clear). An empty canvas says how\nto start. Stamp tools place ready-made drawings with one click — the six\nUI wireframe pieces come built in.\n\nIt is [Excalidraw](https://github.com/excalidraw/excalidraw) (MIT)\nunderneath: its canvas, selection, text editing, history and shortcuts;\nour rail, toolbars, palette and empty state, in the kit. Excalidraw's own\ntool bar, properties panel, main menu, library, share / collaboration\nbuttons, welcome screen and sidebars are hidden. The blocks built on this\none — `whiteboard`, `annotate`, `sketch-to-app` — import it, never\n`@excalidraw/excalidraw`.\n\n```jsx\nimport { SketchCanvas, sketchToText } from \"../components/blocks/sketch-canvas\"; // a Makersclaw app (\"./blocks/sketch-canvas\" from components/)\nimport { SketchCanvas, sketchToText } from \"@/components/makersclaw/blocks/sketch-canvas\"; // a shadcn project\n```\n\n`SketchCanvas` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`sketchToText`, `sketchItems`, `emptySketch`,\n`isSketchEmpty`, `toSketch`) are plain functions and work on the server.\n\n`makersclaw add sketch-canvas` adds `@excalidraw/excalidraw` (pinned,\nexact) to package.json: run `npm install` after. It loads with the page\n(no `import()`); the canvas mounts after the first render, and until then\n— on the server — the frame shows \"Loading the canvas…\", with the\nsketch's words for a screen reader.\n\n## The data\n\nA sketch is plain JSON. Store it as it comes out of `onChange` / `onSave`\n(a `jsonb` column).\n\n```js\n{\n type: \"sketch\",\n version: 1,\n elements: [ /* Excalidraw elements, non-deleted, in paint order (first = back) */ ],\n files: { \"<fileId>\": { id, mimeType: \"image/png\", dataURL: \"data:image/png;base64,…\", created } },\n appState: { gridModeEnabled: false },\n}\n```\n\n- `elements` are Excalidraw's own element objects: `type` is `rectangle`,\n `ellipse`, `diamond`, `arrow`, `line`, `freedraw`, `text`, `image` or\n `frame`; each has `id`, `x`, `y`, `width`, `height`, `strokeColor`,\n `backgroundColor`, `strokeWidth`, `roughness`, `groupIds`, `frameId`…\n A `text` has `text`, `fontSize`, and `containerId` when it is the label\n of a shape; a shape lists its label and arrows in `boundElements`.\n- **Colours are tokens.** Every colour picked in the canvas is a theme\n token, and the element keeps it in `customData.tokens`:\n `{ strokeColor: \"primary\", backgroundColor: \"chart-2:soft\" }`. The\n concrete colour beside it (`strokeColor`) is what the token resolved to\n when it was saved; on load, and whenever the theme or light/dark\n changes, the canvas resolves every token again and repaints. A colour\n with no token (pasted from elsewhere) stays as it is.\n Tokens: `foreground`, `muted-foreground`, `primary`, `destructive`,\n `warning`, `success`, `info`, `chart-2` … `chart-5` (ink), and for\n fills also `none`, `muted` and `<slot>:soft` (the slot mixed into the\n page background — a sticky-note tint). `STROKES` and `FILLS` list them\n with labels.\n- **Stamps** mark what they place: every element of one stamp shares a\n group and carries `customData: { stamp: \"button\", stampId }`.\n- `files` holds the images the elements show (only those). Images are\n data URLs inside the JSON — keep them small, or store the file\n elsewhere and keep the sketch light.\n- The `background` image is never in the value; it comes from the prop.\n- Written by hand (a starter template, a seed), an element may leave out\n what Excalidraw can default (`version`, `seed`, `strokeWidth`,\n `roughness`…) and give its colours only as tokens:\n `{ id: \"a\", type: \"rectangle\", x: 0, y: 0, width: 160, height: 72, customData: { tokens: { strokeColor: \"foreground\", backgroundColor: \"chart-2:soft\" } } }`.\n A text needs `text`, `fontSize` and a rough `width`/`height` (it is\n re-measured). Or skip hand-written elements: insert skeletons through\n the handle (below).\n- `sketchToText(sketch)` reads the words out for an agent; see Helpers.\n\n## Props\n\n- `value` — the sketch, controlled. A new `value` that differs from what\n the canvas last sent replaces the drawing (loading another record) and\n clears undo. Pass back exactly what `onChange` gave you and nothing\n resets. Also takes a bare element array or Excalidraw's own export\n (`{ elements, files, appState }`).\n- `defaultValue` — the starting sketch, uncontrolled. With neither, it\n starts empty.\n- `onChange(sketch)` — the whole sketch, after an edit settles\n (debounced by `changeDelay`). Not called for pan, zoom or selection.\n- `onSave(sketch)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called with the same sketch, right after\n `onChange`, and on Cmd/Ctrl+S. The canvas shows \"Saving…\", \"Saved\",\n or the error with a Retry. A throw counts as a failure.\n- `changeDelay` — ms of quiet before `onChange` / `onSave` (default 600).\n- `readOnly` — `false` (default). `true` shows the sketch to look at: pan\n and zoom, no rail, no toolbar, no edits.\n- `background` — `{ src, width?, height? }`: an image laid at the back,\n locked (it cannot be selected, moved, erased or deleted), top-left at\n (0, 0). For marking up a screenshot (annotate). `src` is a data URL, or\n a same-origin URL (a cross-origin one draws, but spoils `exportImage`).\n Without `width`/`height` the image's own size is used. When it first\n lands on an empty sketch, the view fits it.\n- `tools` — ids of the built-in tools to offer, in rail order:\n `[\"select\", \"hand\", \"draw\", \"rectangle\", \"ellipse\", \"diamond\", \"arrow\",\n \"line\", \"text\", \"image\", \"eraser\", \"frame\"]` (all, the default). A tool\n left out is also unreachable by its shortcut. `SKETCH_TOOLS` lists them\n with labels, icons and keys.\n- `stamps` — stamp tools after the built-ins:\n `[{ id, label, icon, create() }]`. `create()` returns element\n skeletons (below) laid out from (0, 0); a click on the canvas places\n them centred on the pointer (Enter: the middle of the view; Esc\n cancels), grouped, selected. `WIREFRAME_STAMPS` are button, input, card,\n nav bar, table and image placeholder.\n- `grid` — `true` (default): a dotted background that pans and zooms with\n the drawing. `false`: plain.\n- `empty` — what an empty, editable canvas shows in its middle. Omit for\n the built-in hint (\"Start sketching…\"); `false` for nothing; or a node\n (starter templates: give its buttons `pointer-events-auto`, the layer\n lets clicks through to the canvas). Shown while nothing is drawn and the\n select or pan tool is active.\n- `actions` — nodes in the top-right corner, before the menu (a Share\n button, a title chip).\n- `canvasRef` — a ref (`useSketchCanvas()`) that receives the handle.\n- `onReady(handle)` — once, when the canvas has mounted.\n- `label` — the accessible name of the canvas region (default \"Sketch\").\n- `className` — layout: width, margin, height. The default height is\n `h-[32rem]`; a height class of yours (`h-dvh`, `h-full`, `min-h-…`,\n `flex-1`) replaces it. Never colour.\n\n### The handle\n\n`canvasRef.current` (and `onReady`'s argument):\n\n- `getScene({ withBackground? })` — the sketch now (what `onChange`\n sends), without waiting for the debounce.\n- `setScene(sketch)` — replace the drawing (undoable).\n- `insertElements(skeletons, opts?)` → the new element ids. Skeletons are\n Excalidraw's `convertToExcalidrawElements` input:\n `{ type: \"rectangle\" | \"ellipse\" | \"diamond\", x, y, width, height, label?: { text, fontSize?, textAlign? } }`,\n `{ type: \"text\", x, y, text, fontSize? }`,\n `{ type: \"arrow\" | \"line\", x, y, points: [[0, 0], [dx, dy]], start?: { id }, end?: { id } }`,\n `{ type: \"frame\", children: [ids], name }`, `{ type: \"image\", fileId, x, y, width, height }`.\n Colours may be token names (`strokeColor: \"primary\"`,\n `backgroundColor: \"warning:soft\"`); a shape with no stroke gets\n `foreground`; text is the system font. Options:\n - `at` — `{ x, y }` in canvas coordinates; the skeletons' box is\n centred there (or its top-left, with `anchor: \"top-left\"`). Default:\n the middle of the view. `false`: keep the skeletons' own x/y.\n - `group` — group them (move as one). `stamp: \"<kind>\"` groups too and\n marks them for `sketchToText`.\n - `customData` — merged into every new element's customData (annotate:\n `{ note: 3 }`).\n - `files` — `[{ id, dataURL, mimeType, created }]` to add first, for\n image skeletons.\n - `select` — `true` (default): select what was inserted.\n- `exportImage(opts?)` → `Promise<Blob>`, a PNG made by Excalidraw's own\n export, in the theme's colours now, on the theme's background. Options:\n `scale` (default 2), `padding` (px, default 16), `transparent: true`\n (no background), `withBackground: false` (leave the background image\n out), `onlySelected: true`.\n- `zoomToFit({ animate? })` — fit everything in view (Shift+1).\n- `setTool(id)` — a built-in tool id or a stamp id.\n- `undo()`, `redo()`, `clear()`, `focus()`.\n- `save()` — send `onChange` / `onSave` now.\n- `palette()` — token → the concrete colour it resolves to now.\n- `repaint()` — re-read the theme and repaint (the canvas already does\n this when `data-theme` changes or a theme stylesheet loads).\n- `api` — Excalidraw's own imperative API, for anything else. Reaching for\n it ties you to Excalidraw 0.18; prefer the methods above.\n\n### Helpers\n\nPlain functions; they take a sketch (or an element array) and never throw\non a partial one:\n\n- `emptySketch()` — `{ type: \"sketch\", version: 1, elements: [], files: {}, appState: {} }`.\n- `isSketchEmpty(sketch)` — nothing drawn (deleted elements and the\n background image do not count).\n- `toSketch(value)` — any accepted input → a sketch.\n- `sketchToText(sketch, { stamps = true })` — the words, for an agent or a\n reader: one line per text or stamp in reading order (top to bottom,\n then left to right), grouped under `# <frame name>` per frame (then\n `# Outside frames`); a shape's label reads as its text; a stamp reads\n `[button] Sign up`, an image `[image]`. `stamps: false` drops the\n brackets. An empty sketch gives `\"\"`.\n- `sketchItems(sketch)` — the same, structured: `[{ kind: \"frame\" | \"stamp\" | \"text\" | \"image\", id, text?, name?, stamp?, container?, frame, x, y, width, height }]`.\n- `STROKES`, `FILLS` — `[{ token, label }]`, the palette the toolbar offers.\n- `SKETCH_TOOLS`, `WIREFRAME_STAMPS` — as above.\n\n## Theming, colours and fonts\n\n- **Chrome.** Excalidraw's own CSS variables (its menus, dialogs, context\n menu, hints) point at the workspace tokens (`theme.js`). Light or dark\n follows the nearest `data-theme` (else `color-scheme`, else the OS).\n- **Canvas colours.** Excalidraw needs concrete colours; they are read at\n run time from the tokens' CSS variables and never written in source.\n Excalidraw's dark mode (which inverts the whole canvas with a filter) is\n switched off: the tokens are already the dark scheme's.\n- **Fonts.** Every text is drawn in the device's own sans-serif\n (Excalidraw's \"Helvetica\" family — Helvetica, Arial…), so no font is ever\n fetched: Excalidraw's hand-drawn fonts would otherwise load from a\n third-party CDN. Text pasted from another Excalidraw is moved into it.\n The strokes keep their hand-drawn look (Sketchy / Clean in the toolbar).\n- **Stylesheet.** The block imports `@excalidraw/excalidraw/index.css`\n from the package; the app's bundler ships it like any CSS.\n\n## Example\n\nA whiteboard that saves itself:\n\n```jsx\n\"use client\";\nimport { SketchCanvas } from \"./blocks/sketch-canvas\";\nimport { updateRow } from \"../lib/actions\";\n\nexport default function Board({ board }) {\n return (\n <SketchCanvas\n label={board.title}\n defaultValue={board.sketch} // jsonb, may be null\n onSave={(sketch) => updateRow({ source: \"public.boards\", id: board.id, patch: { sketch } })}\n className=\"h-dvh rounded-none border-0\"\n />\n );\n}\n```\n\nMarking up a screenshot, with numbered notes:\n\n```jsx\nconst canvas = useSketchCanvas();\nconst addNote = (n) =>\n canvas.current?.insertElements(\n [{ type: \"ellipse\", x: 0, y: 0, width: 32, height: 32, strokeColor: \"destructive\", backgroundColor: \"destructive:soft\", label: { text: String(n) } }],\n { group: true, customData: { note: n } },\n );\n\n<SketchCanvas canvasRef={canvas} background={{ src: screenshotDataUrl }} tools={[\"select\", \"hand\", \"draw\", \"rectangle\", \"arrow\", \"text\", \"eraser\"]} onChange={setSketch} />\nconst image = await canvas.current.exportImage(); // the screenshot with the marks, as a PNG\n```\n\nA wireframe for an agent:\n\n```jsx\nimport { SketchCanvas, WIREFRAME_STAMPS, sketchToText, useSketchCanvas } from \"./blocks/sketch-canvas\";\n\nconst canvas = useSketchCanvas();\n<SketchCanvas canvasRef={canvas} stamps={WIREFRAME_STAMPS} tools={[\"select\", \"hand\", \"draw\", \"rectangle\", \"arrow\", \"text\", \"eraser\", \"frame\"]} onChange={setSketch} />\n\nconst build = async () => onBuild({ image: await canvas.current.exportImage(), sketch, text: sketchToText(sketch), prompt });\n// sketchToText → \"# Login\\nWelcome back\\n[input] Email\\n[button] Sign in\"\n```\n\n## What it will not do\n\n- Collaborate live (several cursors): Excalidraw's collaboration is a\n server of its own, not wired here. Saves are last-write-wins.\n- Offer Excalidraw's library, share links, \"text to diagram\" / AI, or its\n hand-drawn fonts. Its command palette (Cmd+/) and help dialog (?) still\n open, themed; the palette may name actions that do nothing here.\n- Show a minimap.\n- Keep images out of the JSON: an image lives as a data URL in `files`.\n- Render on the server: the server shows the frame and the sketch's\n words; the drawing appears when the page runs.\n- Pick an arbitrary colour: only the theme's tokens.\n\nA canvas it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add sketch-canvas` restores them.\n\n## Credits\n\nExcalidraw (https://github.com/excalidraw/excalidraw), MIT — installed from\nnpm as a dependency, not copied here. See THIRD-PARTY-NOTICES.md.\n"
72
+ "content": "# SketchCanvas\n\nA freeform drawing canvas in the workspace theme, friendly like Miro. A\ntool rail on the left (a bar along the bottom on a phone): select, pan,\ndraw, rectangle, ellipse, diamond, arrow, line, text, image, eraser, frame\n— each with its one-key shortcut. Select something and a small toolbar\nfloats above it: colour and fill from the theme's palette, stroke, text\nsize, bring to front / send to back, duplicate, delete. Undo and redo sit\nbottom left, zoom and fit-to-screen bottom right, a menu top right\n(download PNG, snap to grid, shortcuts, clear). An empty canvas says how\nto start. Stamp tools place ready-made drawings with one click — the six\nUI wireframe pieces come built in.\n\nIt is [Excalidraw](https://github.com/excalidraw/excalidraw) (MIT)\nunderneath: its canvas, selection, text editing, history and shortcuts;\nour rail, toolbars, palette and empty state, in the kit. Excalidraw's own\ntool bar, properties panel, main menu, library, share / collaboration\nbuttons, welcome screen and sidebars are hidden. The blocks built on this\none — `whiteboard`, `annotate`, `sketch-to-app` — import it, never\n`@excalidraw/excalidraw`.\n\n```jsx\nimport { SketchCanvas, sketchToText } from \"../components/blocks/sketch-canvas\"; // a Makersclaw app (\"./blocks/sketch-canvas\" from components/)\nimport { SketchCanvas, sketchToText } from \"@/components/makersclaw/blocks/sketch-canvas\"; // a shadcn project\n```\n\n`SketchCanvas` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`sketchToText`, `sketchItems`, `emptySketch`,\n`isSketchEmpty`, `toSketch`) are plain functions and work on the server.\n\n`makersclaw add sketch-canvas` adds `@excalidraw/excalidraw` (pinned,\nexact) to package.json: run `npm install` after. It loads with the page\n(no `import()`); the canvas mounts after the first render, and until then\n— on the server — the frame shows \"Loading the canvas…\", with the\nsketch's words for a screen reader.\n\n## The data\n\nA sketch is plain JSON. Store it as it comes out of `onChange` / `onSave`\n(a `jsonb` column).\n\n```js\n{\n type: \"sketch\",\n version: 1,\n elements: [ /* Excalidraw elements, non-deleted, in paint order (first = back) */ ],\n files: { \"<fileId>\": { id, mimeType: \"image/png\", dataURL: \"data:image/png;base64,…\", created } },\n appState: { gridModeEnabled: false },\n}\n```\n\n- `elements` are Excalidraw's own element objects: `type` is `rectangle`,\n `ellipse`, `diamond`, `arrow`, `line`, `freedraw`, `text`, `image` or\n `frame`; each has `id`, `x`, `y`, `width`, `height`, `strokeColor`,\n `backgroundColor`, `strokeWidth`, `roughness`, `groupIds`, `frameId`…\n A `text` has `text`, `fontSize`, and `containerId` when it is the label\n of a shape; a shape lists its label and arrows in `boundElements`.\n- **Colours are tokens.** Every colour picked in the canvas is a theme\n token, and the element keeps it in `customData.tokens`:\n `{ strokeColor: \"primary\", backgroundColor: \"chart-2:soft\" }`. The\n concrete colour beside it (`strokeColor`) is what the token resolved to\n when it was saved; on load, and whenever the theme or light/dark\n changes, the canvas resolves every token again and repaints. A colour\n with no token (pasted from elsewhere) stays as it is.\n Tokens: `foreground`, `muted-foreground`, `primary`, `destructive`,\n `warning`, `success`, `info`, `chart-2` … `chart-5` (ink), and for\n fills also `none`, `muted` and `<slot>:soft` (the slot mixed into the\n page background — a sticky-note tint). `STROKES` and `FILLS` list them\n with labels.\n- **Stamps** mark what they place: every element of one stamp shares a\n group and carries `customData: { stamp: \"button\", stampId }`.\n- `files` holds the images the elements show (only those). Images are\n data URLs inside the JSON — keep them small, or store the file\n elsewhere and keep the sketch light.\n- The `background` image is never in the value; it comes from the prop.\n- Written by hand (a starter template, a seed), an element may leave out\n what Excalidraw can default (`version`, `seed`, `strokeWidth`,\n `roughness`…) and give its colours only as tokens:\n `{ id: \"a\", type: \"rectangle\", x: 0, y: 0, width: 160, height: 72, customData: { tokens: { strokeColor: \"foreground\", backgroundColor: \"chart-2:soft\" } } }`.\n A text needs `text`, `fontSize` and a rough `width`/`height` (it is\n re-measured). Or skip hand-written elements: insert skeletons through\n the handle (below).\n- `sketchToText(sketch)` reads the words out for an agent; see Helpers.\n\n## Props\n\n- `value` — the sketch, controlled. A new `value` that differs from what\n the canvas last sent replaces the drawing (loading another record) and\n clears undo. Pass back exactly what `onChange` gave you and nothing\n resets. Also takes a bare element array or Excalidraw's own export\n (`{ elements, files, appState }`).\n- `defaultValue` — the starting sketch, uncontrolled. With neither, it\n starts empty.\n- `onChange(sketch)` — the whole sketch, after an edit settles\n (debounced by `changeDelay`). Not called for pan, zoom or selection.\n- `onSave(sketch)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called with the same sketch, right after\n `onChange`, and on Cmd/Ctrl+S. The canvas shows \"Saving…\", \"Saved\",\n or the error with a Retry. A throw counts as a failure.\n- `changeDelay` — ms of quiet before `onChange` / `onSave` (default 600).\n- `readOnly` — `false` (default). `true` shows the sketch to look at: pan\n and zoom, no rail, no toolbar, no edits.\n- `background` — `{ src, width?, height? }`: an image laid at the back,\n locked (it cannot be selected, moved, erased or deleted), top-left at\n (0, 0). For marking up a screenshot (annotate). `src` is a data URL, or\n a same-origin URL (a cross-origin one draws, but spoils `exportImage`).\n Without `width`/`height` the image's own size is used. When it first\n lands on an empty sketch, the view fits it.\n- `tools` — ids of the built-in tools to offer, in rail order:\n `[\"select\", \"hand\", \"draw\", \"rectangle\", \"ellipse\", \"diamond\", \"arrow\",\n \"line\", \"text\", \"image\", \"eraser\", \"frame\"]` (all, the default). A tool\n left out is also unreachable by its shortcut. `SKETCH_TOOLS` lists them\n with labels, icons and keys.\n- `stamps` — stamp tools after the built-ins:\n `[{ id, label, icon, create() }]`. `create()` returns element\n skeletons (below) laid out from (0, 0); a click on the canvas places\n them centred on the pointer (Enter: the middle of the view; Esc\n cancels), grouped, selected. `WIREFRAME_STAMPS` are button, input, card,\n nav bar, table and image placeholder.\n- `grid` — `true` (default): a dotted background that pans and zooms with\n the drawing. `false`: plain.\n- `empty` — what an empty, editable canvas shows in its middle. Omit for\n the built-in hint (\"Start sketching…\"); `false` for nothing; or a node\n (starter templates: give its buttons `pointer-events-auto`, the layer\n lets clicks through to the canvas). Shown while nothing is drawn and the\n select or pan tool is active.\n- `actions` — nodes in the top-right corner, before the menu (a Share\n button, a title chip).\n- `canvasRef` — a ref (`useSketchCanvas()`) that receives the handle.\n- `onReady(handle)` — once, when the canvas has mounted.\n- `label` — the accessible name of the canvas region (default \"Sketch\").\n- `className` — layout: width, margin, height. The default height is\n `h-[32rem]`; a height class of yours (`h-dvh`, `h-full`, `min-h-…`,\n `flex-1`) replaces it. Never colour.\n\n### The handle\n\n`canvasRef.current` (and `onReady`'s argument):\n\n- `getScene({ withBackground? })` — the sketch now (what `onChange`\n sends), without waiting for the debounce.\n- `setScene(sketch)` — replace the drawing (undoable).\n- `insertElements(skeletons, opts?)` → the new element ids. Skeletons are\n Excalidraw's `convertToExcalidrawElements` input:\n `{ type: \"rectangle\" | \"ellipse\" | \"diamond\", x, y, width, height, label?: { text, fontSize?, textAlign? } }`,\n `{ type: \"text\", x, y, text, fontSize? }`,\n `{ type: \"arrow\" | \"line\", x, y, points: [[0, 0], [dx, dy]], start?: { id }, end?: { id } }`,\n `{ type: \"frame\", children: [ids], name }`, `{ type: \"image\", fileId, x, y, width, height }`.\n Colours may be token names (`strokeColor: \"primary\"`,\n `backgroundColor: \"warning:soft\"`); a shape with no stroke gets\n `foreground`; text is the system font. Options:\n - `at` — `{ x, y }` in canvas coordinates; the skeletons' box is\n centred there (or its top-left, with `anchor: \"top-left\"`). Default:\n the middle of the view. `false`: keep the skeletons' own x/y.\n - `group` — group them (move as one). `stamp: \"<kind>\"` groups too and\n marks them for `sketchToText`.\n - `customData` — merged into every new element's customData (annotate:\n `{ note: 3 }`).\n - `files` — `[{ id, dataURL, mimeType, created }]` to add first, for\n image skeletons.\n - `select` — `true` (default): select what was inserted.\n- `exportImage(opts?)` → `Promise<Blob>`, a PNG made by Excalidraw's own\n export, in the theme's colours now, on the theme's background. Options:\n `scale` (default 2), `padding` (px, default 16), `transparent: true`\n (no background), `withBackground: false` (leave the background image\n out), `onlySelected: true`.\n- `zoomToFit({ animate? })` — fit everything in view (Shift+1).\n- `setTool(id)` — a built-in tool id or a stamp id.\n- `undo()`, `redo()`, `clear()`, `focus()`.\n- `save()` — send `onChange` / `onSave` now.\n- `palette()` — token → the concrete colour it resolves to now.\n- `repaint()` — re-read the theme and repaint (the canvas already does\n this when `data-theme` changes or a theme stylesheet loads).\n- `api` — Excalidraw's own imperative API, for anything else. Reaching for\n it ties you to Excalidraw 0.18; prefer the methods above.\n\n### Helpers\n\nPlain functions; they take a sketch (or an element array) and never throw\non a partial one:\n\n- `emptySketch()` — `{ type: \"sketch\", version: 1, elements: [], files: {}, appState: {} }`.\n- `isSketchEmpty(sketch)` — nothing drawn (deleted elements and the\n background image do not count).\n- `toSketch(value)` — any accepted input → a sketch.\n- `sketchToText(sketch, { stamps = true })` — the words, for an agent or a\n reader: one line per text or stamp in reading order (top to bottom,\n then left to right), grouped under `# <frame name>` per frame (then\n `# Outside frames`); a shape's label reads as its text; a stamp reads\n `[button] Sign up`, an image `[image]`. `stamps: false` drops the\n brackets. An empty sketch gives `\"\"`.\n- `sketchItems(sketch)` — the same, structured: `[{ kind: \"frame\" | \"stamp\" | \"text\" | \"image\", id, text?, name?, stamp?, container?, frame, x, y, width, height }]`.\n- `STROKES`, `FILLS` — `[{ token, label }]`, the palette the toolbar offers.\n- `SKETCH_TOOLS`, `WIREFRAME_STAMPS` — as above.\n\n## Theming, colours and fonts\n\n- **Chrome.** Excalidraw's own CSS variables (its menus, dialogs, context\n menu, hints) point at the workspace tokens (`theme.js`). Light or dark\n follows the nearest `data-theme` (else `color-scheme`, else the OS).\n- **Canvas colours.** Excalidraw needs concrete colours; they are read at\n run time from the tokens' CSS variables and never written in source.\n Excalidraw's dark mode (which inverts the whole canvas with a filter) is\n switched off: the tokens are already the dark scheme's.\n- **Fonts.** Every text is drawn in the device's own sans-serif\n (Excalidraw's \"Helvetica\" family — Helvetica, Arial…), so no font is ever\n fetched: Excalidraw's hand-drawn fonts would otherwise load from a\n third-party CDN. Text pasted from another Excalidraw is moved into it.\n The strokes keep their hand-drawn look (Sketchy / Clean in the toolbar).\n- **Stylesheet.** The block imports `@excalidraw/excalidraw/index.css`\n from the package; the app's bundler ships it like any CSS.\n\n## Example\n\nA whiteboard that saves itself:\n\n```jsx\n// surfaces/sketch.jsx — client: one board, saved as it is drawn\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { SketchCanvas } from \"../components/blocks/sketch-canvas\";\n\nexport default function Board() {\n const id = useSearchParams().get(\"row\");\n const { row: board, loading } = useRow({ source: \"boards\", id });\n if (loading || !board) return null;\n return (\n <SketchCanvas\n key={board.id}\n label={board.title}\n defaultValue={board.sketch} // jsonb, may be null; read once per board\n onSave={(sketch) => updateRow({ source: \"boards\", id: board.id, patch: { sketch } })}\n className=\"h-dvh rounded-none border-0\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nMarking up a screenshot, with numbered notes:\n\n```jsx\nconst canvas = useSketchCanvas();\nconst addNote = (n) =>\n canvas.current?.insertElements(\n [{ type: \"ellipse\", x: 0, y: 0, width: 32, height: 32, strokeColor: \"destructive\", backgroundColor: \"destructive:soft\", label: { text: String(n) } }],\n { group: true, customData: { note: n } },\n );\n\n<SketchCanvas canvasRef={canvas} background={{ src: screenshotDataUrl }} tools={[\"select\", \"hand\", \"draw\", \"rectangle\", \"arrow\", \"text\", \"eraser\"]} onChange={setSketch} />\nconst image = await canvas.current.exportImage(); // the screenshot with the marks, as a PNG\n```\n\nA wireframe for an agent:\n\n```jsx\nimport { SketchCanvas, WIREFRAME_STAMPS, sketchToText, useSketchCanvas } from \"./blocks/sketch-canvas\";\n\nconst canvas = useSketchCanvas();\n<SketchCanvas canvasRef={canvas} stamps={WIREFRAME_STAMPS} tools={[\"select\", \"hand\", \"draw\", \"rectangle\", \"arrow\", \"text\", \"eraser\", \"frame\"]} onChange={setSketch} />\n\nconst build = async () => onBuild({ image: await canvas.current.exportImage(), sketch, text: sketchToText(sketch), prompt });\n// sketchToText → \"# Login\\nWelcome back\\n[input] Email\\n[button] Sign in\"\n```\n\n## What it will not do\n\n- Collaborate live (several cursors): Excalidraw's collaboration is a\n server of its own, not wired here. Saves are last-write-wins.\n- Offer Excalidraw's library, share links, \"text to diagram\" / AI, or its\n hand-drawn fonts. Its command palette (Cmd+/) and help dialog (?) still\n open, themed; the palette may name actions that do nothing here.\n- Show a minimap.\n- Keep images out of the JSON: an image lives as a data URL in `files`.\n- Render on the server: the server shows the frame and the sketch's\n words; the drawing appears when the page runs.\n- Pick an arbitrary colour: only the theme's tokens.\n\nA canvas it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add sketch-canvas` restores them.\n\n## Credits\n\nExcalidraw (https://github.com/excalidraw/excalidraw), MIT — installed from\nnpm as a dependency, not copied here. See THIRD-PARTY-NOTICES.md.\n"
73
73
  }
74
74
  ],
75
75
  "categories": [
@@ -92,7 +92,7 @@
92
92
  "docs": "Installed to components/makersclaw/blocks/sketch-canvas. Import: import { SketchCanvas, useSketchCanvas, SKETCH_TOOLS, WIREFRAME_STAMPS, STROKES, FILLS, emptySketch, isSketchEmpty, sketchToText, sketchItems, toSketch } from \"@/components/makersclaw/blocks/sketch-canvas\". Props and an example: components/makersclaw/blocks/sketch-canvas/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
93
93
  "meta": {
94
94
  "makersclaw": {
95
- "version": "0.5.2",
95
+ "version": "0.7.0",
96
96
  "useWhen": "A screen needs freehand drawing: a whiteboard, marking up a screenshot, sketching a UI wireframe, a diagram drawn by hand. The whiteboard, annotate and sketch-to-app blocks are built on it. Not for structured boards of cards and connectors (flow-canvas), charts (chart) or a kanban (board)."
97
97
  }
98
98
  }
@@ -5,7 +5,7 @@
5
5
  "title": "Spreadsheet",
6
6
  "description": "A table you edit like Google Sheets or Airtable, virtualised for 10,000+ rows: typed columns (text, number, currency, date, single and multiple select, person, checkbox, link, rating) each with its own in-cell editor, a cell cursor and range selection with the mouse and arrow keys, typing to edit, copy and paste ranges as TSV (to and from Sheets and Excel), drag-to-fill and ⌘D fill down, undo/redo, column resize, drag to reorder, hide, freeze, rename, change type and add fields, row insert and delete with Undo, sort and filter panels, search, a sum/average of the selection, validation marks, and a phone layout. Edits go out as async callbacks answering { ok }, applied at once and rolled back on failure. A column-type registry and an imperative handle for blocks built on it.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@tanstack/react-virtual@3.14.13"
@@ -51,7 +51,7 @@
51
51
  "path": "registry/blocks/spreadsheet/README.md",
52
52
  "type": "registry:file",
53
53
  "target": "@components/makersclaw/blocks/spreadsheet/README.md",
54
- "content": "# Spreadsheet\n\nA table you edit like Google Sheets or Airtable. Click a cell and type;\nEnter edits, Tab and the arrows move, Shift extends a range, ⌘C / ⌘V copy\nand paste ranges to and from Sheets and Excel, drag the small square at a\nrange's corner to fill down, ⌘Z undoes. Every column has a type — text,\nnumber, currency, date, single select, multiple select, person, checkbox,\nlink, rating — and each type draws its own cell and opens its own editor\n(a calendar for a date, a searchable list for a select or a person). Drag\na header to reorder, its edge to resize; its menu sorts, filters, renames,\nchanges the type, inserts, freezes, hides and deletes. The toolbar has\nsearch, a filter panel, a sort panel, show/hide fields and \"Add row\"; the\nline under the grid counts rows and sums the selected numbers.\n\nIt is virtualised in both directions (`@tanstack/react-virtual`): only the\ncells in the window are drawn, so 10,000 rows scroll like ten. On a phone\nthe rows grow to 44px, the frozen column narrows, a tap selects and a\nsecond tap edits; long-press opens the row menu.\n\n```jsx\nimport { Spreadsheet } from \"../components/blocks/spreadsheet\"; // a Makersclaw app (\"./blocks/spreadsheet\" from components/)\nimport { Spreadsheet } from \"@/components/makersclaw/blocks/spreadsheet\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4). `makersclaw add\nspreadsheet` adds `@tanstack/react-virtual` (pinned, exact) to\npackage.json: run `npm install` after. The helpers (the type registry,\n`processRows`, TSV) are plain functions and run on the server too.\nBlocks built on this one (base-view, csv-import) import it, never the\nvirtualiser.\n\n## The data\n\n`rows` — plain objects with a unique `id`; each column reads one key.\n`columns` — what each key is:\n\n```js\nconst COLUMNS = [\n { key: \"company\", name: \"Company\", type: \"text\", width: 200, required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\",\n options: [{ value: \"lead\", label: \"Lead\", hue: 5 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"value\", name: \"Deal value\", type: \"currency\", currency: \"USD\", precision: 0 },\n { key: \"owner\", name: \"Owner\", type: \"person\" }, // ids from the `people` prop\n { key: \"close\", name: \"Close date\", type: \"date\" }, // \"YYYY-MM-DD\"\n { key: \"segments\", name: \"Segments\", type: \"multi-select\", options: [...] },\n { key: \"fit\", name: \"Fit\", type: \"rating\", max: 5 },\n { key: \"contacted\", name: \"Contacted\", type: \"checkbox\" },\n { key: \"website\", name: \"Website\", type: \"url\" },\n];\n```\n\nA column: `key`, `name`, `type` (default `\"text\"`), and any of `width`\n(px), `readOnly`, `required` (an empty cell is marked as a problem),\n`options` (select types: `{ value, label, hue: 1–5 }` — the hue is a\nchart token, never a colour), `allowNew: false` (a select that refuses\nnew options), `currency` + `precision` (currency, number), `max`\n(rating, default 5), `multiple: true` (person: several people),\n`dateOrder: \"dmy\"` (how `3/4/2026` is read; default month first). Any\nother key is kept and ignored, so a block on top can carry its own.\n\nWhat each type stores:\n\n| type | value |\n| --- | --- |\n| `text`, `url` | a string |\n| `number`, `currency` | a number, or `null` |\n| `date` | `\"YYYY-MM-DD\"`, or `null` — a day, never a `Date` |\n| `select` | an option's `value`, or `null` |\n| `multi-select` | an array of option values |\n| `person` | a person's `id` (an array with `multiple`) |\n| `checkbox` | `true` / `false` |\n| `rating` | a whole number, `0` = none |\n\n## Props\n\nRequired first; the rest have the defaults shown.\n\n- `rows` — the records, as above.\n- `columns` — the fields, as above.\n\nSaving. Every callback is async and answers `{ ok: true }` or\n`{ ok: false, error }` (a throw counts as a failure; nothing returned\ncounts as ok). The grid applies the change at once and, on a failure,\nputs it back and says so in a toast.\n\n- `onChange(changes)` — cell edits, always a batch: one typed value, a\n paste, a fill, a clear, an undo. `changes` is\n `[{ rowId, key, value, prev }]`. Without it the grid still edits (in\n memory).\n- `onAddRows(rows, { index, restore })` — new rows (from \"Add row\", the\n row menu's insert, a paste that runs past the end, or an Undo of a\n delete when `restore` is true). `index` is where in `rows` they go\n (absent: the end). A row's `id` is made here (`createRow` or a random\n one); answer `{ ok: true, rows: [{ id }] }` to swap in the server's ids.\n Without it there is no Add row.\n- `onDeleteRows(ids)` — rows deleted from the row menu or the selection\n line; a toast offers Undo (which calls `onAddRows` with `restore`).\n Without it rows cannot be deleted.\n- `onColumnsChange(columns, { reason })` — the whole new column list after\n a rename, a type change, an added, deleted or restored field, or a\n select option created while typing; `reason` is `\"rename\"`, `\"type\"`,\n `\"add\"`, `\"delete\"`, `\"restore\"` or `\"options\"`. A type change also sends\n the converted values through `onChange`. Without it the header menu\n cannot change fields (options typed into a select are kept in memory).\n- `createRow()` — a new row's starting values (`{ id, stage: \"lead\" }`).\n Give each call a unique `id`.\n\nThe view — which fields show, in what order and width, what is frozen,\nsorted and filtered. Plain JSON; store it per view (base-view keeps one\nper tab):\n\n```js\n{ order: [\"company\", \"stage\", …], hidden: [\"website\"], widths: { company: 240 },\n frozen: 1, sort: [{ key: \"value\", dir: \"desc\" }],\n filters: [{ id: \"f1\", key: \"stage\", op: \"any\", value: [\"won\", \"negotiation\"] }] }\n```\n\n- `view` + `onViewChange(view)` — controlled; or `defaultView` and the\n grid keeps it (still reporting through `onViewChange`). Keep it in state:\n a new `sort` or `filters` array each render re-sorts each render.\n- A filter's `op` is one of its type's conditions (`COLUMN_TYPES[type]\n .filters[].id`, plus `empty` / `not-empty`); a blank `value` means \"not\n set yet\" and passes every row.\n- Edits do not re-sort: an edited row stays where it is (as in Airtable)\n and the line under the grid offers \"Sort again\". New data from the\n props re-runs the view.\n\nBehaviour and look:\n\n- `people` — `[{ id, name, email?, src? }]`, who a `person` cell can hold.\n- `types` — your own column types, or overrides of the built-ins (see\n below). Keep the object stable (a module constant).\n- `validate(row, column)` — a message when a cell is wrong, else null. A\n wrong cell gets a red corner and its message on hover and under the\n grid; the line under the grid counts problems and jumps to the next.\n- `readOnly` — nothing edits (select, copy, sort, filter still work).\n Per column: `column.readOnly`.\n- `lockFields` — the header menu cannot rename, retype, add or delete\n fields, though `onColumnsChange` is given; select options typed into a\n cell still arrive through it (reason `\"options\"`). csv-import's review\n uses this.\n- `onExpandRow(row)` — adds an open button to each row's number (and\n Space / ⌘↵ / the row menu); draw the record yourself (a Sheet).\n- `onSelectionChange({ active: { rowId, key }, rowIds, keys } | null)`.\n- `loading` — skeleton rows.\n- `label` — what a screen reader calls the grid (`\"Table\"`).\n- `noun` — what a row is (`\"deal\"`): \"Add deal\", \"10,000 deals\". Plural\n is noun + \"s\". Default `\"row\"`.\n- `height` — the grid's height (`560`, or any CSS length); it scrolls\n inside it.\n- `toolbar` — `false` hides the toolbar (a parent draws its own and\n drives the grid through the handle). `toolbarStart`, `toolbarEnd` —\n nodes at the toolbar's ends (an \"Import CSV\" button).\n- `empty` — a node for the empty table, instead of \"No rows yet\".\n- `locale`, `currency` — for numbers, amounts and dates (the reader's\n locale, `\"USD\"`). `today` — `\"YYYY-MM-DD\"` for the date picker (a demo\n that must not change day to day).\n- `ref` — the handle, below.\n\n### The handle\n\n`<Spreadsheet ref={grid} …>` then `grid.current`:\n\n- `focus()` · `clearSelection()`\n- `select({ rowId, key, toRowId?, toKey? })` → true when found; a range\n when `to…` is given\n- `getSelection()` → `{ active, rowIds, keys }` or null\n- `scrollToRow(rowId)` · `edit(rowId, key)` — opens that cell's editor\n- `addRow(values?)` — adds, selects and edits a row (through `onAddRows`)\n- `deleteRows(ids)` · `undo()` · `redo()`\n- `copy()` → the selection as TSV · `paste(text)` — TSV at the selection\n- `visibleRows()` · `visibleColumns()` — after filter and sort, in order\n- `resort()` — the \"Sort again\" button\n\n### The column-type registry\n\n`COLUMN_TYPES` holds the built-ins; `typeOf(column, types)` resolves one\n(your `types` first, then the built-ins, unknown names read as text). A\ntype is plain data and functions — the logic runs on the server too:\n\n```js\n{\n label: \"Currency\", icon: \"dollar\", // a kit glyph for the header\n align: \"end\", width: 130, // default width, px\n editor: \"number\", // the in-cell editor: text · number · date ·\n // select · person · toggle · rating · url · none\n multiple: false, // select editor: pick several\n empty: (v) => v == null, // default: null, \"\" or []\n parse: (text, column, ctx) => ({ value }) | ({ error }), // typed or pasted text in;\n // a select may add `create: [{ value, label, hue }]`\n toText: (v, column, ctx) => \"1250.5\", // what a copy puts on the clipboard, and an edit starts from\n format: (v, column, ctx) => \"$1,250.50\",// what the cell shows (and search matches)\n compare: (a, b, column, ctx) => a - b, // sort order; empties always sort last\n filters: [{ id: \"gt\", label: \">\", arg: \"number\", test: (v, arg, column, ctx) => v > arg }],\n // arg: text · number · date · options · person · none\n render: (v, column, ctx) => <node/>, // optional: draw the cell yourself\n Editor: MyEditor, // optional: your own editor component\n}\n```\n\n`ctx` is `{ people, locale, currency }`. A custom type may be small —\nanything it leaves out comes from `text`:\n\n```js\nconst TYPES = {\n percent: {\n label: \"Percent\", icon: \"hash\", align: \"end\", width: 100, editor: \"number\",\n parse: (t) => { const n = parseNumberText(String(t).replace(\"%\", \"\")); return Number.isNaN(n) ? { error: \"Not a percent\" } : { value: n == null ? null : n / 100 }; },\n toText: (v) => (v == null ? \"\" : String(v * 100)),\n format: (v) => (v == null ? \"\" : `${Math.round(v * 100)}%`),\n compare: (a, b) => a - b,\n },\n};\n```\n\nA custom `Editor` gets `{ value, column, type, ctx, initial, today,\ncanCreate, onCommit(value, move, { create }?), onCancel() }` and draws\ninside the cell (absolutely positioned over it). `initial` is the key\nthat opened it (a typed letter) or null; `move` is `\"down\"`, `\"up\"`,\n`\"right\"`, `\"left\"` or null (stay). Float a panel beside the cell with\nthe exported `CellPanel` (`anchorRef`, `onDismiss`, `width`, `label`).\n\nOther exports, for blocks on top: `CellValue` (a cell's display),\n`OptionBadge`, `editorFor(type)`, `parseCell`, `convertValue(v, from,\nto)`, `guessType(texts)` (csv-import's guess), `parseNumberText`,\n`parseDateText`, `optionValue(label, taken)`, `emptyView`,\n`normalizeView`, `orderedColumns`, `visibleColumns`, `processRows(rows,\ncolumns, view, { search, ctx, custom })`, `passesFilters`, `parseTSV`,\n`toTSV`, `pasteChanges`, `fillChanges`, `clearChanges`, `applyChanges`,\n`invertChanges`, `TYPE_ORDER`, `sortWords(type)`.\n\n## Keys\n\n↑ ↓ ← → move (⌘ to the edge; Shift extends) · Tab / Shift+Tab · Home /\nEnd · Page Up / Down · Enter or F2 edits · typing replaces · Esc cancels ·\nDelete clears · ⌘C ⌘X ⌘V · ⌘D fills down · ⌘Z / ⇧⌘Z · ⌘A all · Shift+Space\nthe row · ⌘Space the column · Space ticks a box or opens the row ·\nShift+F10 the row menu.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow, insertRow, deleteRow } from \"../lib/actions\";\nimport { Spreadsheet } from \"./blocks/spreadsheet\";\n\nconst COLUMNS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\", options: [{ value: \"lead\", label: \"Lead\", hue: 2 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"amount\", name: \"Amount\", type: \"currency\", precision: 0 },\n { key: \"close\", name: \"Close date\", type: \"date\" },\n];\n\nexport default function Deals({ deals, team }) {\n const [view, setView] = useState({ sort: [{ key: \"amount\", dir: \"desc\" }] });\n return (\n <Spreadsheet\n label=\"Deals\"\n noun=\"deal\"\n rows={deals}\n columns={COLUMNS}\n people={team}\n view={view}\n onViewChange={setView}\n onChange={async (changes) => {\n for (const c of changes) {\n const r = await updateRow({ source: \"public.deals\", id: c.rowId, patch: { [c.key]: c.value } });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n onAddRows={async (rows) => {\n const made = [];\n for (const { id, ...values } of rows) {\n const r = await insertRow({ source: \"public.deals\", values: { stage: \"lead\", ...values } });\n if (!r.ok) return r;\n made.push({ id: r.id }); // the database's id replaces the one the grid made up\n }\n return { ok: true, rows: made };\n }}\n onDeleteRows={async (ids) => {\n for (const id of ids) {\n const r = await deleteRow({ source: \"public.deals\", id });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- No formulas. A value is what was typed; a derived column is computed by\n you and passed in (mark it `readOnly`).\n- No merged cells, no rich text in a cell, no row heights of their own:\n every row is one height (that is what keeps 10,000 rows smooth).\n- No grouping of rows under headings — that is a view on top (base-view).\n- Search and filters run in the browser over `rows`. For a table too big\n to send, filter on the server and pass the page.\n- Sort and filter follow the view, not the edits: an edited row stays put\n until \"Sort again\" (or new props).\n- Paste reads plain text (TSV). Pasted images and HTML tables are read as\n their text.\n"
54
+ "content": "# Spreadsheet\n\nA table you edit like Google Sheets or Airtable. Click a cell and type;\nEnter edits, Tab and the arrows move, Shift extends a range, ⌘C / ⌘V copy\nand paste ranges to and from Sheets and Excel, drag the small square at a\nrange's corner to fill down, ⌘Z undoes. Every column has a type — text,\nnumber, currency, date, single select, multiple select, person, checkbox,\nlink, rating — and each type draws its own cell and opens its own editor\n(a calendar for a date, a searchable list for a select or a person). Drag\na header to reorder, its edge to resize; its menu sorts, filters, renames,\nchanges the type, inserts, freezes, hides and deletes. The toolbar has\nsearch, a filter panel, a sort panel, show/hide fields and \"Add row\"; the\nline under the grid counts rows and sums the selected numbers.\n\nIt is virtualised in both directions (`@tanstack/react-virtual`): only the\ncells in the window are drawn, so 10,000 rows scroll like ten. On a phone\nthe rows grow to 44px, the frozen column narrows, a tap selects and a\nsecond tap edits; long-press opens the row menu.\n\n```jsx\nimport { Spreadsheet } from \"../components/blocks/spreadsheet\"; // a Makersclaw app (\"./blocks/spreadsheet\" from components/)\nimport { Spreadsheet } from \"@/components/makersclaw/blocks/spreadsheet\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4). `makersclaw add\nspreadsheet` adds `@tanstack/react-virtual` (pinned, exact) to\npackage.json: run `npm install` after. The helpers (the type registry,\n`processRows`, TSV) are plain functions and run on the server too.\nBlocks built on this one (base-view, csv-import) import it, never the\nvirtualiser.\n\n## The data\n\n`rows` — plain objects with a unique `id`; each column reads one key.\n`columns` — what each key is:\n\n```js\nconst COLUMNS = [\n { key: \"company\", name: \"Company\", type: \"text\", width: 200, required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\",\n options: [{ value: \"lead\", label: \"Lead\", hue: 5 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"value\", name: \"Deal value\", type: \"currency\", currency: \"USD\", precision: 0 },\n { key: \"owner\", name: \"Owner\", type: \"person\" }, // ids from the `people` prop\n { key: \"close\", name: \"Close date\", type: \"date\" }, // \"YYYY-MM-DD\"\n { key: \"segments\", name: \"Segments\", type: \"multi-select\", options: [...] },\n { key: \"fit\", name: \"Fit\", type: \"rating\", max: 5 },\n { key: \"contacted\", name: \"Contacted\", type: \"checkbox\" },\n { key: \"website\", name: \"Website\", type: \"url\" },\n];\n```\n\nA column: `key`, `name`, `type` (default `\"text\"`), and any of `width`\n(px), `readOnly`, `required` (an empty cell is marked as a problem),\n`options` (select types: `{ value, label, hue: 1–5 }` — the hue is a\nchart token, never a colour), `allowNew: false` (a select that refuses\nnew options), `currency` + `precision` (currency, number), `max`\n(rating, default 5), `multiple: true` (person: several people),\n`dateOrder: \"dmy\"` (how `3/4/2026` is read; default month first). Any\nother key is kept and ignored, so a block on top can carry its own.\n\nWhat each type stores:\n\n| type | value |\n| --- | --- |\n| `text`, `url` | a string |\n| `number`, `currency` | a number, or `null` |\n| `date` | `\"YYYY-MM-DD\"`, or `null` — a day, never a `Date` |\n| `select` | an option's `value`, or `null` |\n| `multi-select` | an array of option values |\n| `person` | a person's `id` (an array with `multiple`) |\n| `checkbox` | `true` / `false` |\n| `rating` | a whole number, `0` = none |\n\n## Props\n\nRequired first; the rest have the defaults shown.\n\n- `rows` — the records, as above.\n- `columns` — the fields, as above.\n\nSaving. Every callback is async and answers `{ ok: true }` or\n`{ ok: false, error }` (a throw counts as a failure; nothing returned\ncounts as ok). The grid applies the change at once and, on a failure,\nputs it back and says so in a toast.\n\n- `onChange(changes)` — cell edits, always a batch: one typed value, a\n paste, a fill, a clear, an undo. `changes` is\n `[{ rowId, key, value, prev }]`. Without it the grid still edits (in\n memory).\n- `onAddRows(rows, { index, restore })` — new rows (from \"Add row\", the\n row menu's insert, a paste that runs past the end, or an Undo of a\n delete when `restore` is true). `index` is where in `rows` they go\n (absent: the end). A row's `id` is made here (`createRow` or a random\n one); answer `{ ok: true, rows: [{ id }] }` to swap in the server's ids.\n Without it there is no Add row.\n- `onDeleteRows(ids)` — rows deleted from the row menu or the selection\n line; a toast offers Undo (which calls `onAddRows` with `restore`).\n Without it rows cannot be deleted.\n- `onColumnsChange(columns, { reason })` — the whole new column list after\n a rename, a type change, an added, deleted or restored field, or a\n select option created while typing; `reason` is `\"rename\"`, `\"type\"`,\n `\"add\"`, `\"delete\"`, `\"restore\"` or `\"options\"`. A type change also sends\n the converted values through `onChange`. Without it the header menu\n cannot change fields (options typed into a select are kept in memory).\n- `createRow()` — a new row's starting values (`{ id, stage: \"lead\" }`).\n Give each call a unique `id`.\n\nThe view — which fields show, in what order and width, what is frozen,\nsorted and filtered. Plain JSON; store it per view (base-view keeps one\nper tab):\n\n```js\n{ order: [\"company\", \"stage\", …], hidden: [\"website\"], widths: { company: 240 },\n frozen: 1, sort: [{ key: \"value\", dir: \"desc\" }],\n filters: [{ id: \"f1\", key: \"stage\", op: \"any\", value: [\"won\", \"negotiation\"] }] }\n```\n\n- `view` + `onViewChange(view)` — controlled; or `defaultView` and the\n grid keeps it (still reporting through `onViewChange`). Keep it in state:\n a new `sort` or `filters` array each render re-sorts each render.\n- A filter's `op` is one of its type's conditions (`COLUMN_TYPES[type]\n .filters[].id`, plus `empty` / `not-empty`); a blank `value` means \"not\n set yet\" and passes every row.\n- Edits do not re-sort: an edited row stays where it is (as in Airtable)\n and the line under the grid offers \"Sort again\". New data from the\n props re-runs the view.\n\nBehaviour and look:\n\n- `people` — `[{ id, name, email?, src? }]`, who a `person` cell can hold.\n- `types` — your own column types, or overrides of the built-ins (see\n below). Keep the object stable (a module constant).\n- `validate(row, column)` — a message when a cell is wrong, else null. A\n wrong cell gets a red corner and its message on hover and under the\n grid; the line under the grid counts problems and jumps to the next.\n- `readOnly` — nothing edits (select, copy, sort, filter still work).\n Per column: `column.readOnly`.\n- `lockFields` — the header menu cannot rename, retype, add or delete\n fields, though `onColumnsChange` is given; select options typed into a\n cell still arrive through it (reason `\"options\"`). csv-import's review\n uses this.\n- `onExpandRow(row)` — adds an open button to each row's number (and\n Space / ⌘↵ / the row menu); draw the record yourself (a Sheet).\n- `onSelectionChange({ active: { rowId, key }, rowIds, keys } | null)`.\n- `loading` — skeleton rows.\n- `label` — what a screen reader calls the grid (`\"Table\"`).\n- `noun` — what a row is (`\"deal\"`): \"Add deal\", \"10,000 deals\". Plural\n is noun + \"s\". Default `\"row\"`.\n- `height` — the grid's height (`560`, or any CSS length); it scrolls\n inside it.\n- `toolbar` — `false` hides the toolbar (a parent draws its own and\n drives the grid through the handle). `toolbarStart`, `toolbarEnd` —\n nodes at the toolbar's ends (an \"Import CSV\" button).\n- `empty` — a node for the empty table, instead of \"No rows yet\".\n- `locale`, `currency` — for numbers, amounts and dates (the reader's\n locale, `\"USD\"`). `today` — `\"YYYY-MM-DD\"` for the date picker (a demo\n that must not change day to day).\n- `ref` — the handle, below.\n\n### The handle\n\n`<Spreadsheet ref={grid} …>` then `grid.current`:\n\n- `focus()` · `clearSelection()`\n- `select({ rowId, key, toRowId?, toKey? })` → true when found; a range\n when `to…` is given\n- `getSelection()` → `{ active, rowIds, keys }` or null\n- `scrollToRow(rowId)` · `edit(rowId, key)` — opens that cell's editor\n- `addRow(values?)` — adds, selects and edits a row (through `onAddRows`)\n- `deleteRows(ids)` · `undo()` · `redo()`\n- `copy()` → the selection as TSV · `paste(text)` — TSV at the selection\n- `visibleRows()` · `visibleColumns()` — after filter and sort, in order\n- `resort()` — the \"Sort again\" button\n\n### The column-type registry\n\n`COLUMN_TYPES` holds the built-ins; `typeOf(column, types)` resolves one\n(your `types` first, then the built-ins, unknown names read as text). A\ntype is plain data and functions — the logic runs on the server too:\n\n```js\n{\n label: \"Currency\", icon: \"dollar\", // a kit glyph for the header\n align: \"end\", width: 130, // default width, px\n editor: \"number\", // the in-cell editor: text · number · date ·\n // select · person · toggle · rating · url · none\n multiple: false, // select editor: pick several\n empty: (v) => v == null, // default: null, \"\" or []\n parse: (text, column, ctx) => ({ value }) | ({ error }), // typed or pasted text in;\n // a select may add `create: [{ value, label, hue }]`\n toText: (v, column, ctx) => \"1250.5\", // what a copy puts on the clipboard, and an edit starts from\n format: (v, column, ctx) => \"$1,250.50\",// what the cell shows (and search matches)\n compare: (a, b, column, ctx) => a - b, // sort order; empties always sort last\n filters: [{ id: \"gt\", label: \">\", arg: \"number\", test: (v, arg, column, ctx) => v > arg }],\n // arg: text · number · date · options · person · none\n render: (v, column, ctx) => <node/>, // optional: draw the cell yourself\n Editor: MyEditor, // optional: your own editor component\n}\n```\n\n`ctx` is `{ people, locale, currency }`. A custom type may be small —\nanything it leaves out comes from `text`:\n\n```js\nconst TYPES = {\n percent: {\n label: \"Percent\", icon: \"hash\", align: \"end\", width: 100, editor: \"number\",\n parse: (t) => { const n = parseNumberText(String(t).replace(\"%\", \"\")); return Number.isNaN(n) ? { error: \"Not a percent\" } : { value: n == null ? null : n / 100 }; },\n toText: (v) => (v == null ? \"\" : String(v * 100)),\n format: (v) => (v == null ? \"\" : `${Math.round(v * 100)}%`),\n compare: (a, b) => a - b,\n },\n};\n```\n\nA custom `Editor` gets `{ value, column, type, ctx, initial, today,\ncanCreate, onCommit(value, move, { create }?), onCancel() }` and draws\ninside the cell (absolutely positioned over it). `initial` is the key\nthat opened it (a typed letter) or null; `move` is `\"down\"`, `\"up\"`,\n`\"right\"`, `\"left\"` or null (stay). Float a panel beside the cell with\nthe exported `CellPanel` (`anchorRef`, `onDismiss`, `width`, `label`).\n\nOther exports, for blocks on top: `CellValue` (a cell's display),\n`OptionBadge`, `editorFor(type)`, `parseCell`, `convertValue(v, from,\nto)`, `guessType(texts)` (csv-import's guess), `parseNumberText`,\n`parseDateText`, `optionValue(label, taken)`, `emptyView`,\n`normalizeView`, `orderedColumns`, `visibleColumns`, `processRows(rows,\ncolumns, view, { search, ctx, custom })`, `passesFilters`, `parseTSV`,\n`toTSV`, `pasteChanges`, `fillChanges`, `clearChanges`, `applyChanges`,\n`invertChanges`, `TYPE_ORDER`, `sortWords(type)`.\n\n## Keys\n\n↑ ↓ ← → move (⌘ to the edge; Shift extends) · Tab / Shift+Tab · Home /\nEnd · Page Up / Down · Enter or F2 edits · typing replaces · Esc cancels ·\nDelete clears · ⌘C ⌘X ⌘V · ⌘D fills down · ⌘Z / ⇧⌘Z · ⌘A all · Shift+Space\nthe row · ⌘Space the column · Space ticks a box or opens the row ·\nShift+F10 the row menu.\n\n## Example\n\n```jsx\n// surfaces/deals.jsx — client: the deals as a grid\n\"use client\";\nimport { useState } from \"react\";\nimport { useRows } from \"../lib/data\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { Spreadsheet } from \"../components/blocks/spreadsheet\";\n\nconst COLUMNS = [\n { key: \"name\", name: \"Name\", type: \"text\", required: true },\n { key: \"stage\", name: \"Stage\", type: \"select\", options: [{ value: \"lead\", label: \"Lead\", hue: 2 }, { value: \"won\", label: \"Won\", hue: 1 }] },\n { key: \"amount\", name: \"Amount\", type: \"currency\", precision: 0 },\n { key: \"close\", name: \"Close date\", type: \"date\" },\n];\n\nexport default function Deals() {\n const deals = useRows({ source: \"deals\", orderBy: \"created_at asc\" });\n const team = useRows({ source: \"people\", orderBy: \"name asc\" });\n const [view, setView] = useState({ sort: [{ key: \"amount\", dir: \"desc\" }] });\n // Each change below is one write, and the door takes 300 a minute: a\n // paste larger than that belongs in one action (runAction).\n return (\n <Spreadsheet\n label=\"Deals\"\n noun=\"deal\"\n rows={deals.rows}\n loading={deals.loading}\n columns={COLUMNS}\n people={team.rows}\n view={view}\n onViewChange={setView}\n onChange={async (changes) => {\n for (const c of changes) {\n const r = await updateRow({ source: \"deals\", id: c.rowId, patch: { [c.key]: c.value } });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n onAddRows={async (rows) => {\n const made = [];\n for (const { id, ...values } of rows) {\n const r = await insertRow({ source: \"deals\", values: { stage: \"lead\", ...values } });\n if (!r.ok) return r;\n made.push({ id: r.id }); // the database's id replaces the one the grid made up\n }\n return { ok: true, rows: made };\n }}\n onDeleteRows={async (ids) => {\n for (const id of ids) {\n const r = await deleteRow({ source: \"deals\", id });\n if (!r.ok) return r;\n }\n return { ok: true };\n }}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- No formulas. A value is what was typed; a derived column is computed by\n you and passed in (mark it `readOnly`).\n- No merged cells, no rich text in a cell, no row heights of their own:\n every row is one height (that is what keeps 10,000 rows smooth).\n- No grouping of rows under headings — that is a view on top (base-view).\n- Search and filters run in the browser over `rows`. For a table too big\n to send, filter on the server and pass the page.\n- Sort and filter follow the view, not the edits: an edited row stays put\n until \"Sort again\" (or new props).\n- Paste reads plain text (TSV). Pasted images and HTML tables are read as\n their text.\n"
55
55
  }
56
56
  ],
57
57
  "categories": [
@@ -83,7 +83,7 @@
83
83
  "docs": "Installed to components/makersclaw/blocks/spreadsheet. Import: import { Spreadsheet, CellValue, CellPanel, OptionBadge, editorFor, TYPE_ORDER, sortWords, COLUMN_TYPES, typeOf, parseCell, convertValue, guessType, parseNumberText, parseDateText, optionValue, isBlank, emptyView, normalizeView, orderedColumns, visibleColumns, processRows, passesFilters, parseTSV, toTSV, pasteChanges, fillChanges, clearChanges, applyChanges, invertChanges } from \"@/components/makersclaw/blocks/spreadsheet\". Props and an example: components/makersclaw/blocks/spreadsheet/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
84
84
  "meta": {
85
85
  "makersclaw": {
86
- "version": "0.5.2",
86
+ "version": "0.7.0",
87
87
  "useWhen": "People need to enter or clean up many records in a grid, fast, with the keyboard: a lead list, an inventory, a budget, a content calendar, a table view over any collection, the grid under an Airtable-like base. Not for a read-mostly list to scan and act on row by row (data-table), records moving through stages (board), or a single record (record-form)."
88
88
  }
89
89
  }
package/r/stat-cards.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Stat cards",
6
6
  "description": "A row of key numbers, each with its name and its change: one bordered strip, or a card each with a change in the corner or after the value, a \"from\" value, a status dot and a goal meter, a sparkline, a \"View more\" footer; or a usage tile per amount against its limit (a bar or a ring, a split by kind with what is free, a warning line near the cap, an action and a limit the founder can change); or a plain list of values with their change. Loading, empty and error states.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/chart.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/chart.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -32,7 +32,7 @@
32
32
  "path": "registry/blocks/stat-cards/README.md",
33
33
  "type": "registry:file",
34
34
  "target": "@components/makersclaw/blocks/stat-cards/README.md",
35
- "content": "# Stat cards\n\nThe few numbers at the top of a dashboard, each with its name and what\nchanged. Four shapes over one list of items: a bordered `strip`; `cards`,\none per number, with a change in the corner or after the value, a \"from\"\nvalue, a status dot and a goal meter, a sparkline, a \"View more\" footer;\n`usage`, one tile per amount against its limit, as a bar or a ring, a\nsplit by kind with what is free, a warning line near the cap, an action\nalong the foot and a limit the founder can change; and a plain `list` of\nvalues with their change. Loading, empty and error states are built in.\n\n```jsx\nimport { StatCards } from \"../components/blocks/stat-cards\"; // a Makersclaw app (\"./blocks/stat-cards\" from components/)\nimport { StatCards } from \"@/components/makersclaw/blocks/stat-cards\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the numbers in the server surface (README §4). A sparkline is drawn\nby the free `chart` block, so `makersclaw add stat-cards` lays that one\ntoo.\n\n## The data\n\nOne item per number, already formatted where it is shown as text:\n\n```js\n{\n id: \"mrr\", // key; default the label\n label: \"Monthly recurring revenue\",\n value: \"$48.6K\", // a string you formatted, or a number (gets thousands separators)\n unit: \"ms\", // after the value, smaller and muted\n sublabel: \"ORBT\", // after the label, muted: a ticker, a region code\n delta: 5.2, // a percent; or Delta's props: { value, suffix, display, label, invert }\n invert: true, // lower is better (churn, cost): down turns green\n previous: \"$46.2K\", // shown after the value as \"from $46.2K\"\n caption: \"Paid plans only\", // a muted line under the value\n status: \"warning\", // a dot and a word at the top right: success · warning · danger · info,\n // or { tone, label: \"Needs attention\" }\n goal: { value: 3, max: 5, label: \"Goals achieved\" }, // a thin meter in the status tone\n trend: [12, 14, 13, 17], // a sparkline under the value (numbers, oldest first)\n trendLabel: \"MRR, last 16 weeks\", // what a screen reader hears for it\n trendTone: \"chart-2\", // default: green when the change is good, red when bad\n href: \"/revenue\", // a \"View more →\" footer link; or onSelect(item) for a button\n linkLabel: \"View region\",\n}\n```\n\nIn `usage`, an item is an amount against a limit:\n\n```js\n{\n id: \"egress\",\n label: \"Egress\",\n used: 188, // a number, in the limit's unit\n limit: 200, // the cap; null means none (\"/ Unlimited\"); left out, no limit is shown\n unit: \"GB\",\n value: \"188 GB\", // how `used` reads; default the number and the unit\n limitText: \"$150 budget\", // how the limit reads; default the number and the unit\n suffix: \"this month\", // after the value when there is no limit\n format: (n) => `$${n}`, // every number shown, default thousands separators\n parts: [{ label: \"Writes\", value: 7410000 }, { label: \"Reads\", value: 2410000 }],\n // a split bar with a legend in place of the meter; with a\n // limit, the rest shows as \"Free\" (restLabel)\n note: \"Past 200 GB is billed per GB.\", // a line under the bar: warning near the cap\n noteTone: \"muted\", // override: muted · success · warning · danger · info\n delta: 9.5, // a badge beside the label\n thresholds: [{ at: 75, tone: \"warning\" }], // default: warning at warnAt\n action: { label: \"Upgrade\", icon: \"zap\", onSelect: (item) => … }, // a button along the foot\n editable: false, // no \"Change limit\" button on this tile\n limitLabel: \"Change budget\", limitPrefix: \"$\", // this tile's limit button and field\n meter: \"ring\", // this tile's shape, over the block's `meter`\n}\n```\n\nOver the limit the bar stays full and turns red; a reader hears \"over by\".\n\n## Props\n\n- `items` — the numbers, as above. Required.\n- `variant` — `\"strip\"` (default) · `\"cards\"` · `\"usage\"` · `\"list\"`.\n- `columns` — how many across from `lg` up, 1 to 4; default the number of\n items, at most 4. Cards and usage tiles are one column on a phone and\n two from `sm`; a strip is two on a phone.\n- `title` · `description` · `actions` — a header over the numbers\n (\"Plan usage\", \"You are on the Team plan. Compare plans\", a button).\n- `label` — the group's name for a screen reader when there is no title.\n\nStats (`strip`, `cards`, `list`)\n\n- `deltaPlacement` — `\"inline\"` (after the value) or `\"corner\"` (at the\n label's right). Default corner in a strip, inline elsewhere. A tile with\n a `status` keeps its change inline.\n- `deltaVariant` — `\"text\"` or `\"badge\"` (a tinted pill). Default badge\n in a list, text elsewhere.\n- `size` — the value's size: `\"sm\"` · `\"md\"` (default in a strip) · `\"lg\"`\n (default in cards).\n- `linkLabel` — the footer link's words, default `\"View more\"`.\n\nUsage\n\n- `meter` — `\"bar\"` (default) or `\"ring\"`, for every tile without its own\n `meter`. A tile with `parts` is always a split bar.\n- `format(n)` — every number shown, unless a tile has its own.\n- `warnAt` — the percent of the limit where the bar and the note turn\n warning, default 90.\n- `onLimitChange(item, limit)` — adds a \"Change limit\" button to each tile\n (not those with `editable: false`). It opens a dialog with the limit in\n a number field; Save calls this with the new number. Return (or resolve\n to) `{ ok: false, error }`, or throw, and the dialog stays open and says\n why; anything else closes it. The block does not change the number on\n screen: pass the new `limit` back in `items`.\n- `limitLabel` — the button's and dialog's words, default `\"Change limit\"`.\n- `limitHint` — the dialog's description, default the current limit and\n what is used.\n\nStates\n\n- `loading` — skeleton tiles in the shape of the variant; `loadingCount`\n of them (default 4).\n- `error` — a danger notice above the numbers (they still show, if any);\n `onRetry` adds a Retry button to it.\n- `emptyTitle` · `emptyBody` · `emptyAction` — when `items` is empty.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// components/Overview.jsx — client: the numbers the server read\n\"use client\";\nimport { StatCards } from \"./blocks/stat-cards\";\nimport { updateRow } from \"../lib/actions\";\n\nexport default function Overview({ kpis, usage }) {\n return (\n <div className=\"grid gap-6\">\n <StatCards\n items={[\n { id: \"mrr\", label: \"MRR\", value: kpis.mrr, delta: kpis.mrrChange, trend: kpis.mrrWeeks, href: \"/revenue\" },\n { id: \"churn\", label: \"Logo churn\", value: kpis.churn, delta: kpis.churnChange, invert: true },\n { id: \"trials\", label: \"Active trials\", value: kpis.trials, previous: kpis.trialsLastWeek, delta: kpis.trialsChange },\n ]}\n variant=\"cards\"\n />\n <StatCards\n variant=\"usage\"\n title=\"Plan usage\"\n items={usage.map((u) => ({ id: u.id, label: u.name, used: u.used, limit: u.limit, unit: u.unit }))}\n onLimitChange={(item, limit) => updateRow({ source: \"public.quotas\", id: item.id, patch: { limit } })}\n limitLabel=\"Change budget\"\n />\n </div>\n );\n}\n```\n\n## What it will not do\n\n- Fetch or compute: numbers come in formatted, changes already worked out.\n- Draw a chart worth reading on its own: that is the `chart` block.\n- Score one subject against thresholds with an overall status, or list\n many meters under a shared summary: those are other blocks.\n- Colour a number for decoration: tones are for status (a change's\n direction, a tile near its cap, a status dot).\n\nA tile it cannot be is your own component in `components/`, built from\nthe kit's `Stat`, `Meter` and `Distribution` — not an edit to this block.\nIts files are fixed: `npm run check` refuses a change, and `makersclaw add\nstat-cards` restores them.\n\n## Credits\n\nPorted from blocks.so's stats (https://github.com/ephraimduncan/blocks,\nMIT): a rewrite on the Makersclaw kit, not their code.\n"
35
+ "content": "# Stat cards\n\nThe few numbers at the top of a dashboard, each with its name and what\nchanged. Four shapes over one list of items: a bordered `strip`; `cards`,\none per number, with a change in the corner or after the value, a \"from\"\nvalue, a status dot and a goal meter, a sparkline, a \"View more\" footer;\n`usage`, one tile per amount against its limit, as a bar or a ring, a\nsplit by kind with what is free, a warning line near the cap, an action\nalong the foot and a limit the founder can change; and a plain `list` of\nvalues with their change. Loading, empty and error states are built in.\n\n```jsx\nimport { StatCards } from \"../components/blocks/stat-cards\"; // a Makersclaw app (\"./blocks/stat-cards\" from components/)\nimport { StatCards } from \"@/components/makersclaw/blocks/stat-cards\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the numbers in the server surface (README §4). A sparkline is drawn\nby the free `chart` block, so `makersclaw add stat-cards` lays that one\ntoo.\n\n## The data\n\nOne item per number, already formatted where it is shown as text:\n\n```js\n{\n id: \"mrr\", // key; default the label\n label: \"Monthly recurring revenue\",\n value: \"$48.6K\", // a string you formatted, or a number (gets thousands separators)\n unit: \"ms\", // after the value, smaller and muted\n sublabel: \"ORBT\", // after the label, muted: a ticker, a region code\n delta: 5.2, // a percent; or Delta's props: { value, suffix, display, label, invert }\n invert: true, // lower is better (churn, cost): down turns green\n previous: \"$46.2K\", // shown after the value as \"from $46.2K\"\n caption: \"Paid plans only\", // a muted line under the value\n status: \"warning\", // a dot and a word at the top right: success · warning · danger · info,\n // or { tone, label: \"Needs attention\" }\n goal: { value: 3, max: 5, label: \"Goals achieved\" }, // a thin meter in the status tone\n trend: [12, 14, 13, 17], // a sparkline under the value (numbers, oldest first)\n trendLabel: \"MRR, last 16 weeks\", // what a screen reader hears for it\n trendTone: \"chart-2\", // default: green when the change is good, red when bad\n href: \"/revenue\", // a \"View more →\" footer link; or onSelect(item) for a button\n linkLabel: \"View region\",\n}\n```\n\nIn `usage`, an item is an amount against a limit:\n\n```js\n{\n id: \"egress\",\n label: \"Egress\",\n used: 188, // a number, in the limit's unit\n limit: 200, // the cap; null means none (\"/ Unlimited\"); left out, no limit is shown\n unit: \"GB\",\n value: \"188 GB\", // how `used` reads; default the number and the unit\n limitText: \"$150 budget\", // how the limit reads; default the number and the unit\n suffix: \"this month\", // after the value when there is no limit\n format: (n) => `$${n}`, // every number shown, default thousands separators\n parts: [{ label: \"Writes\", value: 7410000 }, { label: \"Reads\", value: 2410000 }],\n // a split bar with a legend in place of the meter; with a\n // limit, the rest shows as \"Free\" (restLabel)\n note: \"Past 200 GB is billed per GB.\", // a line under the bar: warning near the cap\n noteTone: \"muted\", // override: muted · success · warning · danger · info\n delta: 9.5, // a badge beside the label\n thresholds: [{ at: 75, tone: \"warning\" }], // default: warning at warnAt\n action: { label: \"Upgrade\", icon: \"zap\", onSelect: (item) => … }, // a button along the foot\n editable: false, // no \"Change limit\" button on this tile\n limitLabel: \"Change budget\", limitPrefix: \"$\", // this tile's limit button and field\n meter: \"ring\", // this tile's shape, over the block's `meter`\n}\n```\n\nOver the limit the bar stays full and turns red; a reader hears \"over by\".\n\n## Props\n\n- `items` — the numbers, as above. Required.\n- `variant` — `\"strip\"` (default) · `\"cards\"` · `\"usage\"` · `\"list\"`.\n- `columns` — how many across from `lg` up, 1 to 4; default the number of\n items, at most 4. Cards and usage tiles are one column on a phone and\n two from `sm`; a strip is two on a phone.\n- `title` · `description` · `actions` — a header over the numbers\n (\"Plan usage\", \"You are on the Team plan. Compare plans\", a button).\n- `label` — the group's name for a screen reader when there is no title.\n\nStats (`strip`, `cards`, `list`)\n\n- `deltaPlacement` — `\"inline\"` (after the value) or `\"corner\"` (at the\n label's right). Default corner in a strip, inline elsewhere. A tile with\n a `status` keeps its change inline.\n- `deltaVariant` — `\"text\"` or `\"badge\"` (a tinted pill). Default badge\n in a list, text elsewhere.\n- `size` — the value's size: `\"sm\"` · `\"md\"` (default in a strip) · `\"lg\"`\n (default in cards).\n- `linkLabel` — the footer link's words, default `\"View more\"`.\n\nUsage\n\n- `meter` — `\"bar\"` (default) or `\"ring\"`, for every tile without its own\n `meter`. A tile with `parts` is always a split bar.\n- `format(n)` — every number shown, unless a tile has its own.\n- `warnAt` — the percent of the limit where the bar and the note turn\n warning, default 90.\n- `onLimitChange(item, limit)` — adds a \"Change limit\" button to each tile\n (not those with `editable: false`). It opens a dialog with the limit in\n a number field; Save calls this with the new number. Return (or resolve\n to) `{ ok: false, error }`, or throw, and the dialog stays open and says\n why; anything else closes it. The block does not change the number on\n screen: pass the new `limit` back in `items`.\n- `limitLabel` — the button's and dialog's words, default `\"Change limit\"`.\n- `limitHint` — the dialog's description, default the current limit and\n what is used.\n\nStates\n\n- `loading` — skeleton tiles in the shape of the variant; `loadingCount`\n of them (default 4).\n- `error` — a danger notice above the numbers (they still show, if any);\n `onRetry` adds a Retry button to it.\n- `emptyTitle` · `emptyBody` · `emptyAction` — when `items` is empty.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/overview.jsx — client: the numbers, read in the browser\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { StatCards } from \"../components/blocks/stat-cards\";\n\nexport default function Overview() {\n const summary = useRows({ source: \"kpi_summary\", limit: 1 }); // a view of the app's: this week's numbers, one row\n const usage = useRows({ source: \"quotas\", orderBy: \"name asc\" });\n const kpis = summary.rows[0] ?? {};\n return (\n <div className=\"grid gap-6\">\n <StatCards\n loading={summary.loading}\n error={summary.error}\n items={[\n { id: \"mrr\", label: \"MRR\", value: kpis.mrr, delta: kpis.mrr_change, trend: kpis.mrr_weeks, href: \"/s/revenue\" },\n { id: \"churn\", label: \"Logo churn\", value: kpis.churn, delta: kpis.churn_change, invert: true },\n { id: \"trials\", label: \"Active trials\", value: kpis.trials, previous: kpis.trials_last_week, delta: kpis.trials_change },\n ]}\n variant=\"cards\"\n />\n <StatCards\n variant=\"usage\"\n title=\"Plan usage\"\n loading={usage.loading}\n items={usage.rows.map((u) => ({ id: u.id, label: u.name, used: u.used, limit: u.limit, unit: u.unit }))}\n onLimitChange={(item, limit) => updateRow({ source: \"quotas\", id: item.id, patch: { limit } })}\n limitLabel=\"Change budget\"\n />\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Fetch or compute: numbers come in formatted, changes already worked out.\n- Draw a chart worth reading on its own: that is the `chart` block.\n- Score one subject against thresholds with an overall status, or list\n many meters under a shared summary: those are other blocks.\n- Colour a number for decoration: tones are for status (a change's\n direction, a tile near its cap, a status dot).\n\nA tile it cannot be is your own component in `components/`, built from\nthe kit's `Stat`, `Meter` and `Distribution` — not an edit to this block.\nIts files are fixed: `npm run check` refuses a change, and `makersclaw add\nstat-cards` restores them.\n\n## Credits\n\nPorted from blocks.so's stats (https://github.com/ephraimduncan/blocks,\nMIT): a rewrite on the Makersclaw kit, not their code.\n"
36
36
  }
37
37
  ],
38
38
  "categories": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/stat-cards. Import: import { StatCards } from \"@/components/makersclaw/blocks/stat-cards\". Props and an example: components/makersclaw/blocks/stat-cards/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.5.2",
62
+ "version": "0.7.0",
63
63
  "useWhen": "The few numbers at the top of a dashboard or a report: revenue, users, conversion, spend against budget, seats and storage against a plan. Not for a trend worth reading on its own (a chart), many rows of records (a table), or one subject scored against thresholds (a scorecard)."
64
64
  }
65
65
  }