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
package/r/diagram.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Diagram",
6
6
  "description": "Draws Mermaid text (flowcharts, sequence, class, state, ER, Gantt, pie, mind maps, timelines, git graphs…) in the workspace theme: the tokens are read from the page at run time and handed to Mermaid, so nodes, lines, text and chart colours are the theme's and follow light/dark. Zoom and pan (buttons, Cmd/Ctrl+scroll, pinch, drag, keys), fit to view, a Source tab with line numbers, copy the source or the SVG, download SVG. A mistake shows its line and Mermaid's words, and keeps the last drawing that worked. On the server the frame shows the Mermaid text.",
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
  "mermaid@12.0.0"
@@ -21,7 +21,7 @@
21
21
  "path": "registry/blocks/diagram/diagram.jsx",
22
22
  "type": "registry:component",
23
23
  "target": "@components/makersclaw/blocks/diagram/diagram.jsx",
24
- "content": "\"use client\";\n\n// Diagram — Mermaid text drawn in the workspace theme, with zoom and pan.\n//\n// Client only, one static bundle: Mermaid needs the DOM to measure text,\n// and the app's check refuses import() (so no next/dynamic). The modules\n// load with the page; the drawing happens after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame shows the Mermaid text itself, so the HTML already says what\n// the diagram is.\n//\n// Colours: the tokens are read from the page at run time (palette.js) and\n// handed to Mermaid as its theme; a change of workspace theme or of\n// light/dark reads them again and redraws.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button, Empty, Icon, IconButton, Menu, Notice, Skeleton, ToggleGroup, cx, toast } from \"../../ui\";\nimport { renderMermaid } from \"./mermaid\";\nimport { mermaidTheme, paletteKey, resolvePalette } from \"./palette\";\nimport { diagramKind, diagramTitle } from \"./source\";\n\nconst PAD = 24;\nconst MIN_K = 0.2;\nconst MAX_K = 4;\nconst px = (v) => (typeof v === \"number\" ? `${v}px` : v);\nconst clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\n// The layout effect runs only in the browser; on the server it would warn.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport function Diagram(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DiagramPlaceholder {...props} />;\n return <LiveDiagram {...props} />;\n}\n\nconst FRAME = \"flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground\";\n\nfunction heading(source, title) {\n return title ?? diagramTitle(source) ?? diagramKind(source) ?? \"Diagram\";\n}\n\n/** What the server renders: the frame, its heading, and the Mermaid text. */\nfunction DiagramPlaceholder({ source = \"\", title, height, minHeight = 160, toolbar = true, className }) {\n const kind = diagramKind(source);\n return (\n <figure className={cx(FRAME, className)} aria-busy=\"true\">\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border ps-4 pe-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </figcaption>\n ) : null}\n <pre className=\"overflow-hidden px-4 py-3 font-mono text-xs leading-relaxed text-muted-foreground\" style={height != null ? { height: px(height) } : { minHeight: px(minHeight), maxHeight: px(minHeight * 2) }}>\n {source}\n </pre>\n </figure>\n );\n}\n\nfunction LiveDiagram({ source = \"\", title, label, height, minHeight = 160, maxHeight = 560, toolbar = true, look = \"classic\", onRender, className }) {\n const frame = useRef(null);\n const viewport = useRef(null);\n const content = useRef(null);\n const [palette, setPalette] = useState(null);\n const [result, setResult] = useState(null);\n const [good, setGood] = useState(null);\n const [size, setSize] = useState(null);\n const [box, setBox] = useState({ w: 0, h: 0 });\n const [t, setT] = useState({ k: 1, x: 0, y: 0 });\n const [pristine, setPristine] = useState(true);\n const [smooth, setSmooth] = useState(false);\n const [tab, setTab] = useState(\"diagram\");\n const [dragging, setDragging] = useState(false);\n const onRenderRef = useRef(onRender);\n onRenderRef.current = onRender;\n\n // The tokens, read now and again whenever the theme may have changed.\n useEffect(() => {\n const el = frame.current;\n let raf = 0;\n const read = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => {\n const p = resolvePalette(el);\n setPalette((prev) => (prev && paletteKey(prev) === paletteKey(p) ? prev : p));\n });\n };\n read();\n const mo = new MutationObserver(read);\n const attrs = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] };\n mo.observe(document.documentElement, attrs);\n mo.observe(document.head, { childList: true });\n const themed = el.parentElement?.closest(\"[data-theme]\");\n if (themed) mo.observe(themed, attrs);\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n // A theme stylesheet that finishes loading changes the tokens too.\n const onLoad = (e) => {\n if (e.target?.tagName === \"LINK\") read();\n };\n document.addEventListener(\"load\", onLoad, true);\n return () => {\n cancelAnimationFrame(raf);\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n document.removeEventListener(\"load\", onLoad, true);\n };\n }, []);\n\n // Draw: after the text rests for a moment (someone may be typing it).\n useEffect(() => {\n if (!palette) return;\n if (!source.trim()) {\n setResult(null);\n setGood(null);\n return;\n }\n let live = true;\n const timer = setTimeout(() => {\n const font = getComputedStyle(frame.current).fontFamily;\n renderMermaid(source, { themeVariables: mermaidTheme(palette, { fontFamily: font }), look, fontFamily: font }).then((r) => {\n if (!live) return;\n setResult(r);\n if (r.ok) setGood(r.svg);\n onRenderRef.current?.(r);\n });\n }, result ? 150 : 0);\n return () => {\n live = false;\n clearTimeout(timer);\n };\n // `result` only picks the delay; it must not redraw.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [source, palette, look]);\n\n // The drawing's own size, once it is in the page; Mermaid's width=\"100%\"\n // and max-width are taken off so zoom is ours.\n useIsoLayoutEffect(() => {\n const svg = content.current?.querySelector(\"svg\");\n if (!svg) return;\n const vb = svg.viewBox?.baseVal;\n let w = vb?.width || 0;\n let h = vb?.height || 0;\n if (!w || !h) {\n const r = svg.getBoundingClientRect();\n w = r.width || 400;\n h = r.height || 300;\n }\n svg.style.maxWidth = \"none\";\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"aria-hidden\", \"true\");\n setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }));\n }, [good, tab]);\n\n // The viewport's size.\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const measure = () => setBox((b) => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight }));\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [tab, good]);\n\n // How tall the canvas is: `height`, else the drawing at the width's fit.\n const autoH = size && box.w ? size.h * Math.min(1, (box.w - 2 * PAD) / size.w) + 2 * PAD : minHeight;\n const canvasH = height != null ? px(height) : `${Math.round(clamp(autoH, minHeight, maxHeight))}px`;\n\n const fitted = () => {\n if (!size || !box.w || !box.h) return null;\n const k = clamp(Math.min(1, (box.w - 2 * PAD) / size.w, (box.h - 2 * PAD) / size.h), MIN_K, MAX_K);\n return { k, x: (box.w - size.w * k) / 2, y: (box.h - size.h * k) / 2 };\n };\n\n // Untouched, the drawing follows the frame (a resize, a new drawing).\n useEffect(() => {\n if (!pristine) return;\n const f = fitted();\n if (f) setT(f);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [size, box.w, box.h, pristine]);\n\n const glide = () => {\n setSmooth(true);\n setTimeout(() => setSmooth(false), 180);\n };\n const zoomAt = (factor, cx0 = box.w / 2, cy0 = box.h / 2, animate = true) => {\n setPristine(false);\n if (animate) glide();\n setT((cur) => {\n const k = clamp(cur.k * factor, MIN_K, MAX_K);\n return { k, x: cx0 - ((cx0 - cur.x) * k) / cur.k, y: cy0 - ((cy0 - cur.y) * k) / cur.k };\n });\n };\n const fit = () => {\n glide();\n setPristine(true);\n const f = fitted();\n if (f) setT(f);\n };\n const actual = () => {\n if (!size) return;\n glide();\n setPristine(false);\n setT({ k: 1, x: (box.w - size.w) / 2, y: (box.h - size.h) / 2 });\n };\n const pan = (dx, dy) => {\n setPristine(false);\n setT((cur) => ({ ...cur, x: cur.x + dx, y: cur.y + dy }));\n };\n\n // Pointer: drag pans; two fingers pinch; Cmd/Ctrl+wheel (and a trackpad\n // pinch) zooms at the pointer. A plain wheel scrolls the page, as it should.\n const pointers = useRef(new Map());\n const gesture = useRef(null);\n const local = (e) => {\n const r = viewport.current.getBoundingClientRect();\n return { x: e.clientX - r.left, y: e.clientY - r.top };\n };\n const onPointerDown = (e) => {\n if (e.button !== 0 || !good) return;\n viewport.current.setPointerCapture?.(e.pointerId);\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 2 ? { pinch: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y), t } : { from: local(e), t };\n setDragging(true);\n };\n const onPointerMove = (e) => {\n if (!pointers.current.has(e.pointerId) || !gesture.current) return;\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n const g = gesture.current;\n setPristine(false);\n if (pts.length === 2 && g.pinch) {\n const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };\n const k = clamp(g.t.k * (d / g.pinch), MIN_K, MAX_K);\n setT({ k, x: mid.x - ((mid.x - g.t.x) * k) / g.t.k, y: mid.y - ((mid.y - g.t.y) * k) / g.t.k });\n } else if (g.from) {\n const p = local(e);\n setT({ ...g.t, x: g.t.x + p.x - g.from.x, y: g.t.y + p.y - g.from.y });\n }\n };\n const onPointerUp = (e) => {\n pointers.current.delete(e.pointerId);\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 1 ? { from: pts[0], t } : null;\n if (!pts.length) setDragging(false);\n };\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const onWheel = (e) => {\n if (!(e.ctrlKey || e.metaKey)) return;\n e.preventDefault();\n const r = el.getBoundingClientRect();\n zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top, false);\n };\n el.addEventListener(\"wheel\", onWheel, { passive: false });\n return () => el.removeEventListener(\"wheel\", onWheel);\n });\n const onKeyDown = (e) => {\n const step = e.shiftKey ? 160 : 48;\n const keys = {\n \"+\": () => zoomAt(1.25),\n \"=\": () => zoomAt(1.25),\n \"-\": () => zoomAt(0.8),\n _: () => zoomAt(0.8),\n 0: fit,\n 1: actual,\n ArrowLeft: () => pan(step, 0),\n ArrowRight: () => pan(-step, 0),\n ArrowUp: () => pan(0, step),\n ArrowDown: () => pan(0, -step),\n };\n const fn = keys[e.key];\n if (!fn || e.metaKey || e.ctrlKey || e.altKey) return;\n e.preventDefault();\n fn();\n };\n\n async function copy(text, what) {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`Copied ${what}`);\n } catch {\n toast.error(\"Could not reach the clipboard\");\n }\n }\n const download = () => {\n const blob = new Blob([good], { type: \"image/svg+xml\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${(title ?? diagramTitle(source) ?? diagramKind(source) ?? \"diagram\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"diagram\"}.svg`;\n a.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const kind = diagramKind(source);\n const error = result && !result.ok ? result.error : null;\n const empty = !source.trim();\n const name = label ?? `${heading(source, title)}${kind && title ? `, ${kind.toLowerCase()}` : \"\"}`;\n\n return (\n <figure ref={frame} className={cx(FRAME, className)}>\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border py-1.5 ps-4 pe-1.5\">\n <div className=\"me-auto flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </div>\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Show\"\n value={tab}\n onChange={setTab}\n items={[\n { value: \"diagram\", label: \"Diagram\" },\n { value: \"source\", label: \"Source\" },\n ]}\n />\n <Menu\n label=\"Diagram actions\"\n items={[\n { label: \"Copy Mermaid source\", icon: \"copy\", disabled: empty, onSelect: () => copy(source, \"the Mermaid source\") },\n { label: \"Copy SVG\", icon: \"code\", disabled: !good, onSelect: () => copy(good, \"the SVG\") },\n { label: \"Download SVG\", icon: \"download\", disabled: !good, onSelect: download },\n { type: \"separator\" },\n { label: \"Fit to view\", icon: \"maximize\", shortcut: \"0\", disabled: !good || tab !== \"diagram\", onSelect: fit },\n { label: \"Actual size\", icon: \"search\", shortcut: \"1\", disabled: !good || tab !== \"diagram\", onSelect: actual },\n ]}\n />\n </figcaption>\n ) : null}\n\n {tab === \"source\" ? (\n <SourceView source={source} errorLine={error?.line} height={height != null ? px(height) : canvasH} onCopy={() => copy(source, \"the Mermaid source\")} />\n ) : empty ? (\n <div className=\"p-3\">\n <Empty icon=\"git-branch\" title=\"No diagram yet\" body=\"When there is Mermaid text — a flowchart, a sequence, a timeline — it is drawn here.\" />\n </div>\n ) : (\n // raw: the canvas — a focusable surface that pans and zooms with the\n // pointer, wheel and keys; no kit part is a zoomable viewport.\n <div\n ref={viewport}\n role=\"img\"\n aria-label={name}\n aria-roledescription=\"diagram\"\n tabIndex={good ? 0 : -1}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onDoubleClick={(e) => {\n if (!good) return;\n const p = local(e);\n zoomAt(e.shiftKey ? 0.5 : 2, p.x, p.y);\n }}\n className={cx(\n \"relative touch-none overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n \"bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] [background-size:16px_16px]\",\n good && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n )}\n style={{ height: canvasH }}\n >\n {good ? (\n <div\n ref={content}\n className={cx(\"absolute left-0 top-0 origin-top-left will-change-transform\", smooth && \"transition-transform duration-150 ease-out motion-reduce:transition-none\", error && \"opacity-40\")}\n style={{ transform: `translate(${t.x}px, ${t.y}px) scale(${t.k})`, visibility: size ? \"visible\" : \"hidden\" }}\n dangerouslySetInnerHTML={{ __html: good }}\n />\n ) : null}\n\n {!result && !good ? (\n <div className=\"absolute inset-0 grid place-items-center p-6\" aria-busy=\"true\">\n <Skeleton shape=\"rect\" className=\"h-full max-h-64 w-full max-w-md\" />\n <span className=\"sr-only\">Drawing the diagram</span>\n </div>\n ) : null}\n\n {error && !good ? <ErrorPanel error={error} source={source} onShow={() => setTab(\"source\")} /> : null}\n\n {error && good ? (\n <div className=\"absolute inset-x-3 top-3\" onPointerDown={(e) => e.stopPropagation()}>\n <Notice\n tone=\"warning\"\n title={`${error.message} — showing the last version that worked`}\n action={\n <Button size=\"sm\" variant=\"quiet\" onClick={() => setTab(\"source\")}>\n Show source\n </Button>\n }\n >\n {error.detail}\n </Notice>\n </div>\n ) : null}\n\n {good ? (\n // raw: a floating cluster (the map-style zoom pill); its buttons are kit parts.\n <div\n className=\"absolute bottom-3 right-3 flex items-center gap-0.5 rounded-md border border-border bg-popover p-0.5 text-popover-foreground shadow-sm\"\n onPointerDown={(e) => e.stopPropagation()}\n onDoubleClick={(e) => e.stopPropagation()}\n >\n <IconButton icon=\"minus\" size=\"sm\" label=\"Zoom out\" onClick={() => zoomAt(0.8)} disabled={t.k <= MIN_K} />\n <Button variant=\"quiet\" size=\"sm\" onClick={actual} className=\"tabular-nums max-sm:hidden\" title=\"Actual size (1)\">\n {Math.round(t.k * 100)}%\n </Button>\n <IconButton icon=\"plus\" size=\"sm\" label=\"Zoom in\" onClick={() => zoomAt(1.25)} disabled={t.k >= MAX_K} />\n <IconButton icon=\"maximize\" size=\"sm\" label=\"Fit to view (0)\" onClick={fit} />\n </div>\n ) : null}\n </div>\n )}\n </figure>\n );\n}\n\n/** A mistake with nothing drawn yet: what, where, and the lines around it. */\nfunction ErrorPanel({ error, source, onShow }) {\n const lines = source.split(\"\\n\");\n const at = error.line ?? null;\n const from = at ? Math.max(1, at - 2) : 1;\n const to = at ? Math.min(lines.length, at + 2) : Math.min(lines.length, 5);\n return (\n <div className=\"absolute inset-0 overflow-auto p-4\" onPointerDown={(e) => e.stopPropagation()}>\n <div role=\"alert\" className=\"mx-auto grid max-w-xl gap-3\">\n <div className=\"flex items-start gap-3\">\n <Icon name=\"warning\" className=\"mt-0.5 shrink-0 text-destructive\" />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\">{error.message}</p>\n {error.detail ? <p className=\"mt-1 text-sm text-muted-foreground\">{error.detail}</p> : null}\n </div>\n </div>\n <pre className=\"overflow-x-auto rounded-md border border-border bg-background py-2 font-mono text-xs leading-relaxed\">\n {lines.slice(from - 1, to).map((l, i) => {\n const n = from + i;\n const bad = n === at;\n return (\n <div key={n} className={cx(\"flex pe-3\", bad && \"bg-destructive/10\")}>\n <span className={cx(\"w-10 shrink-0 pe-3 text-right tabular-nums select-none\", bad ? \"text-destructive\" : \"text-muted-foreground\")}>{n}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n );\n })}\n </pre>\n <div>\n <Button size=\"sm\" icon=\"code\" onClick={onShow}>\n Show source\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\n/** The Mermaid text, numbered, the mistake's line marked. */\nfunction SourceView({ source, errorLine, height, onCopy }) {\n const lines = source.split(\"\\n\");\n return (\n <div className=\"relative\">\n <pre className=\"overflow-auto py-3 font-mono text-xs leading-relaxed\" style={{ height }} tabIndex={0} aria-label=\"Mermaid source\">\n {lines.map((l, i) => (\n <div key={i} className={cx(\"flex pe-12\", errorLine === i + 1 && \"bg-destructive/10\")}>\n <span className={cx(\"w-12 shrink-0 pe-3 text-right tabular-nums select-none\", errorLine === i + 1 ? \"text-destructive\" : \"text-muted-foreground\")}>{i + 1}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n ))}\n </pre>\n <IconButton icon=\"copy\" size=\"sm\" label=\"Copy source\" onClick={onCopy} className=\"absolute right-2 top-2\" />\n </div>\n );\n}\n"
24
+ "content": "\"use client\";\n\n// Diagram — Mermaid text drawn in the workspace theme, with zoom and pan.\n//\n// Client only, one bundle: Mermaid needs the DOM to measure text,\n// and the app's check refuses import() (so no next/dynamic). The modules\n// load with the page; the drawing happens after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame shows the Mermaid text itself, so the HTML already says what\n// the diagram is.\n//\n// Colours: the tokens are read from the page at run time (palette.js) and\n// handed to Mermaid as its theme; a change of workspace theme or of\n// light/dark reads them again and redraws.\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button, Empty, Icon, IconButton, Menu, Notice, Skeleton, ToggleGroup, cx, toast } from \"../../ui\";\nimport { renderMermaid } from \"./mermaid\";\nimport { mermaidTheme, paletteKey, resolvePalette } from \"./palette\";\nimport { diagramKind, diagramTitle } from \"./source\";\n\nconst PAD = 24;\nconst MIN_K = 0.2;\nconst MAX_K = 4;\nconst px = (v) => (typeof v === \"number\" ? `${v}px` : v);\nconst clamp = (v, a, b) => Math.max(a, Math.min(b, v));\n\n// The layout effect runs only in the browser; on the server it would warn.\nconst useIsoLayoutEffect = typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport function Diagram(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DiagramPlaceholder {...props} />;\n return <LiveDiagram {...props} />;\n}\n\nconst FRAME = \"flex min-w-0 flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground\";\n\nfunction heading(source, title) {\n return title ?? diagramTitle(source) ?? diagramKind(source) ?? \"Diagram\";\n}\n\n/** What the server renders: the frame, its heading, and the Mermaid text. */\nfunction DiagramPlaceholder({ source = \"\", title, height, minHeight = 160, toolbar = true, className }) {\n const kind = diagramKind(source);\n return (\n <figure className={cx(FRAME, className)} aria-busy=\"true\">\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border ps-4 pe-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </figcaption>\n ) : null}\n <pre className=\"overflow-hidden px-4 py-3 font-mono text-xs leading-relaxed text-muted-foreground\" style={height != null ? { height: px(height) } : { minHeight: px(minHeight), maxHeight: px(minHeight * 2) }}>\n {source}\n </pre>\n </figure>\n );\n}\n\nfunction LiveDiagram({ source = \"\", title, label, height, minHeight = 160, maxHeight = 560, toolbar = true, look = \"classic\", onRender, className }) {\n const frame = useRef(null);\n const viewport = useRef(null);\n const content = useRef(null);\n const [palette, setPalette] = useState(null);\n const [result, setResult] = useState(null);\n const [good, setGood] = useState(null);\n const [size, setSize] = useState(null);\n const [box, setBox] = useState({ w: 0, h: 0 });\n const [t, setT] = useState({ k: 1, x: 0, y: 0 });\n const [pristine, setPristine] = useState(true);\n const [smooth, setSmooth] = useState(false);\n const [tab, setTab] = useState(\"diagram\");\n const [dragging, setDragging] = useState(false);\n const onRenderRef = useRef(onRender);\n onRenderRef.current = onRender;\n\n // The tokens, read now and again whenever the theme may have changed.\n useEffect(() => {\n const el = frame.current;\n let raf = 0;\n const read = () => {\n cancelAnimationFrame(raf);\n raf = requestAnimationFrame(() => {\n const p = resolvePalette(el);\n setPalette((prev) => (prev && paletteKey(prev) === paletteKey(p) ? prev : p));\n });\n };\n read();\n const mo = new MutationObserver(read);\n const attrs = { attributes: true, attributeFilter: [\"class\", \"style\", \"data-theme\"] };\n mo.observe(document.documentElement, attrs);\n mo.observe(document.head, { childList: true });\n const themed = el.parentElement?.closest(\"[data-theme]\");\n if (themed) mo.observe(themed, attrs);\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n // A theme stylesheet that finishes loading changes the tokens too.\n const onLoad = (e) => {\n if (e.target?.tagName === \"LINK\") read();\n };\n document.addEventListener(\"load\", onLoad, true);\n return () => {\n cancelAnimationFrame(raf);\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n document.removeEventListener(\"load\", onLoad, true);\n };\n }, []);\n\n // Draw: after the text rests for a moment (someone may be typing it).\n useEffect(() => {\n if (!palette) return;\n if (!source.trim()) {\n setResult(null);\n setGood(null);\n return;\n }\n let live = true;\n const timer = setTimeout(() => {\n const font = getComputedStyle(frame.current).fontFamily;\n renderMermaid(source, { themeVariables: mermaidTheme(palette, { fontFamily: font }), look, fontFamily: font }).then((r) => {\n if (!live) return;\n setResult(r);\n if (r.ok) setGood(r.svg);\n onRenderRef.current?.(r);\n });\n }, result ? 150 : 0);\n return () => {\n live = false;\n clearTimeout(timer);\n };\n // `result` only picks the delay; it must not redraw.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [source, palette, look]);\n\n // The drawing's own size, once it is in the page; Mermaid's width=\"100%\"\n // and max-width are taken off so zoom is ours.\n useIsoLayoutEffect(() => {\n const svg = content.current?.querySelector(\"svg\");\n if (!svg) return;\n const vb = svg.viewBox?.baseVal;\n let w = vb?.width || 0;\n let h = vb?.height || 0;\n if (!w || !h) {\n const r = svg.getBoundingClientRect();\n w = r.width || 400;\n h = r.height || 300;\n }\n svg.style.maxWidth = \"none\";\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"aria-hidden\", \"true\");\n setSize((s) => (s && s.w === w && s.h === h ? s : { w, h }));\n }, [good, tab]);\n\n // The viewport's size.\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const measure = () => setBox((b) => (b.w === el.clientWidth && b.h === el.clientHeight ? b : { w: el.clientWidth, h: el.clientHeight }));\n measure();\n const ro = new ResizeObserver(measure);\n ro.observe(el);\n return () => ro.disconnect();\n }, [tab, good]);\n\n // How tall the canvas is: `height`, else the drawing at the width's fit.\n const autoH = size && box.w ? size.h * Math.min(1, (box.w - 2 * PAD) / size.w) + 2 * PAD : minHeight;\n const canvasH = height != null ? px(height) : `${Math.round(clamp(autoH, minHeight, maxHeight))}px`;\n\n const fitted = () => {\n if (!size || !box.w || !box.h) return null;\n const k = clamp(Math.min(1, (box.w - 2 * PAD) / size.w, (box.h - 2 * PAD) / size.h), MIN_K, MAX_K);\n return { k, x: (box.w - size.w * k) / 2, y: (box.h - size.h * k) / 2 };\n };\n\n // Untouched, the drawing follows the frame (a resize, a new drawing).\n useEffect(() => {\n if (!pristine) return;\n const f = fitted();\n if (f) setT(f);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [size, box.w, box.h, pristine]);\n\n const glide = () => {\n setSmooth(true);\n setTimeout(() => setSmooth(false), 180);\n };\n const zoomAt = (factor, cx0 = box.w / 2, cy0 = box.h / 2, animate = true) => {\n setPristine(false);\n if (animate) glide();\n setT((cur) => {\n const k = clamp(cur.k * factor, MIN_K, MAX_K);\n return { k, x: cx0 - ((cx0 - cur.x) * k) / cur.k, y: cy0 - ((cy0 - cur.y) * k) / cur.k };\n });\n };\n const fit = () => {\n glide();\n setPristine(true);\n const f = fitted();\n if (f) setT(f);\n };\n const actual = () => {\n if (!size) return;\n glide();\n setPristine(false);\n setT({ k: 1, x: (box.w - size.w) / 2, y: (box.h - size.h) / 2 });\n };\n const pan = (dx, dy) => {\n setPristine(false);\n setT((cur) => ({ ...cur, x: cur.x + dx, y: cur.y + dy }));\n };\n\n // Pointer: drag pans; two fingers pinch; Cmd/Ctrl+wheel (and a trackpad\n // pinch) zooms at the pointer. A plain wheel scrolls the page, as it should.\n const pointers = useRef(new Map());\n const gesture = useRef(null);\n const local = (e) => {\n const r = viewport.current.getBoundingClientRect();\n return { x: e.clientX - r.left, y: e.clientY - r.top };\n };\n const onPointerDown = (e) => {\n if (e.button !== 0 || !good) return;\n viewport.current.setPointerCapture?.(e.pointerId);\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 2 ? { pinch: Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y), t } : { from: local(e), t };\n setDragging(true);\n };\n const onPointerMove = (e) => {\n if (!pointers.current.has(e.pointerId) || !gesture.current) return;\n pointers.current.set(e.pointerId, local(e));\n const pts = [...pointers.current.values()];\n const g = gesture.current;\n setPristine(false);\n if (pts.length === 2 && g.pinch) {\n const d = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);\n const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };\n const k = clamp(g.t.k * (d / g.pinch), MIN_K, MAX_K);\n setT({ k, x: mid.x - ((mid.x - g.t.x) * k) / g.t.k, y: mid.y - ((mid.y - g.t.y) * k) / g.t.k });\n } else if (g.from) {\n const p = local(e);\n setT({ ...g.t, x: g.t.x + p.x - g.from.x, y: g.t.y + p.y - g.from.y });\n }\n };\n const onPointerUp = (e) => {\n pointers.current.delete(e.pointerId);\n const pts = [...pointers.current.values()];\n gesture.current = pts.length === 1 ? { from: pts[0], t } : null;\n if (!pts.length) setDragging(false);\n };\n useEffect(() => {\n const el = viewport.current;\n if (!el) return;\n const onWheel = (e) => {\n if (!(e.ctrlKey || e.metaKey)) return;\n e.preventDefault();\n const r = el.getBoundingClientRect();\n zoomAt(Math.exp(-e.deltaY * 0.01), e.clientX - r.left, e.clientY - r.top, false);\n };\n el.addEventListener(\"wheel\", onWheel, { passive: false });\n return () => el.removeEventListener(\"wheel\", onWheel);\n });\n const onKeyDown = (e) => {\n const step = e.shiftKey ? 160 : 48;\n const keys = {\n \"+\": () => zoomAt(1.25),\n \"=\": () => zoomAt(1.25),\n \"-\": () => zoomAt(0.8),\n _: () => zoomAt(0.8),\n 0: fit,\n 1: actual,\n ArrowLeft: () => pan(step, 0),\n ArrowRight: () => pan(-step, 0),\n ArrowUp: () => pan(0, step),\n ArrowDown: () => pan(0, -step),\n };\n const fn = keys[e.key];\n if (!fn || e.metaKey || e.ctrlKey || e.altKey) return;\n e.preventDefault();\n fn();\n };\n\n async function copy(text, what) {\n try {\n await navigator.clipboard.writeText(text);\n toast.success(`Copied ${what}`);\n } catch {\n toast.error(\"Could not reach the clipboard\");\n }\n }\n const download = () => {\n const blob = new Blob([good], { type: \"image/svg+xml\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${(title ?? diagramTitle(source) ?? diagramKind(source) ?? \"diagram\").toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\") || \"diagram\"}.svg`;\n a.click();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const kind = diagramKind(source);\n const error = result && !result.ok ? result.error : null;\n const empty = !source.trim();\n const name = label ?? `${heading(source, title)}${kind && title ? `, ${kind.toLowerCase()}` : \"\"}`;\n\n return (\n <figure ref={frame} className={cx(FRAME, className)}>\n {toolbar ? (\n <figcaption className=\"flex min-h-12 items-center gap-2 border-b border-border py-1.5 ps-4 pe-1.5\">\n <div className=\"me-auto flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate text-sm font-medium\">{heading(source, title)}</span>\n {kind && title ? <span className=\"truncate text-xs text-muted-foreground max-sm:hidden\">{kind}</span> : null}\n </div>\n <ToggleGroup\n size=\"sm\"\n aria-label=\"Show\"\n value={tab}\n onChange={setTab}\n items={[\n { value: \"diagram\", label: \"Diagram\" },\n { value: \"source\", label: \"Source\" },\n ]}\n />\n <Menu\n label=\"Diagram actions\"\n items={[\n { label: \"Copy Mermaid source\", icon: \"copy\", disabled: empty, onSelect: () => copy(source, \"the Mermaid source\") },\n { label: \"Copy SVG\", icon: \"code\", disabled: !good, onSelect: () => copy(good, \"the SVG\") },\n { label: \"Download SVG\", icon: \"download\", disabled: !good, onSelect: download },\n { type: \"separator\" },\n { label: \"Fit to view\", icon: \"maximize\", shortcut: \"0\", disabled: !good || tab !== \"diagram\", onSelect: fit },\n { label: \"Actual size\", icon: \"search\", shortcut: \"1\", disabled: !good || tab !== \"diagram\", onSelect: actual },\n ]}\n />\n </figcaption>\n ) : null}\n\n {tab === \"source\" ? (\n <SourceView source={source} errorLine={error?.line} height={height != null ? px(height) : canvasH} onCopy={() => copy(source, \"the Mermaid source\")} />\n ) : empty ? (\n <div className=\"p-3\">\n <Empty icon=\"git-branch\" title=\"No diagram yet\" body=\"When there is Mermaid text — a flowchart, a sequence, a timeline — it is drawn here.\" />\n </div>\n ) : (\n // raw: the canvas — a focusable surface that pans and zooms with the\n // pointer, wheel and keys; no kit part is a zoomable viewport.\n <div\n ref={viewport}\n role=\"img\"\n aria-label={name}\n aria-roledescription=\"diagram\"\n tabIndex={good ? 0 : -1}\n onKeyDown={onKeyDown}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n onPointerCancel={onPointerUp}\n onDoubleClick={(e) => {\n if (!good) return;\n const p = local(e);\n zoomAt(e.shiftKey ? 0.5 : 2, p.x, p.y);\n }}\n className={cx(\n \"relative touch-none overflow-hidden outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n \"bg-[radial-gradient(var(--color-border)_1px,transparent_1px)] [background-size:16px_16px]\",\n good && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n )}\n style={{ height: canvasH }}\n >\n {good ? (\n <div\n ref={content}\n className={cx(\"absolute left-0 top-0 origin-top-left will-change-transform\", smooth && \"transition-transform duration-150 ease-out motion-reduce:transition-none\", error && \"opacity-40\")}\n style={{ transform: `translate(${t.x}px, ${t.y}px) scale(${t.k})`, visibility: size ? \"visible\" : \"hidden\" }}\n dangerouslySetInnerHTML={{ __html: good }}\n />\n ) : null}\n\n {!result && !good ? (\n <div className=\"absolute inset-0 grid place-items-center p-6\" aria-busy=\"true\">\n <Skeleton shape=\"rect\" className=\"h-full max-h-64 w-full max-w-md\" />\n <span className=\"sr-only\">Drawing the diagram</span>\n </div>\n ) : null}\n\n {error && !good ? <ErrorPanel error={error} source={source} onShow={() => setTab(\"source\")} /> : null}\n\n {error && good ? (\n <div className=\"absolute inset-x-3 top-3\" onPointerDown={(e) => e.stopPropagation()}>\n <Notice\n tone=\"warning\"\n title={`${error.message} — showing the last version that worked`}\n action={\n <Button size=\"sm\" variant=\"quiet\" onClick={() => setTab(\"source\")}>\n Show source\n </Button>\n }\n >\n {error.detail}\n </Notice>\n </div>\n ) : null}\n\n {good ? (\n // raw: a floating cluster (the map-style zoom pill); its buttons are kit parts.\n <div\n className=\"absolute bottom-3 right-3 flex items-center gap-0.5 rounded-md border border-border bg-popover p-0.5 text-popover-foreground shadow-sm\"\n onPointerDown={(e) => e.stopPropagation()}\n onDoubleClick={(e) => e.stopPropagation()}\n >\n <IconButton icon=\"minus\" size=\"sm\" label=\"Zoom out\" onClick={() => zoomAt(0.8)} disabled={t.k <= MIN_K} />\n <Button variant=\"quiet\" size=\"sm\" onClick={actual} className=\"tabular-nums max-sm:hidden\" title=\"Actual size (1)\">\n {Math.round(t.k * 100)}%\n </Button>\n <IconButton icon=\"plus\" size=\"sm\" label=\"Zoom in\" onClick={() => zoomAt(1.25)} disabled={t.k >= MAX_K} />\n <IconButton icon=\"maximize\" size=\"sm\" label=\"Fit to view (0)\" onClick={fit} />\n </div>\n ) : null}\n </div>\n )}\n </figure>\n );\n}\n\n/** A mistake with nothing drawn yet: what, where, and the lines around it. */\nfunction ErrorPanel({ error, source, onShow }) {\n const lines = source.split(\"\\n\");\n const at = error.line ?? null;\n const from = at ? Math.max(1, at - 2) : 1;\n const to = at ? Math.min(lines.length, at + 2) : Math.min(lines.length, 5);\n return (\n <div className=\"absolute inset-0 overflow-auto p-4\" onPointerDown={(e) => e.stopPropagation()}>\n <div role=\"alert\" className=\"mx-auto grid max-w-xl gap-3\">\n <div className=\"flex items-start gap-3\">\n <Icon name=\"warning\" className=\"mt-0.5 shrink-0 text-destructive\" />\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium\">{error.message}</p>\n {error.detail ? <p className=\"mt-1 text-sm text-muted-foreground\">{error.detail}</p> : null}\n </div>\n </div>\n <pre className=\"overflow-x-auto rounded-md border border-border bg-background py-2 font-mono text-xs leading-relaxed\">\n {lines.slice(from - 1, to).map((l, i) => {\n const n = from + i;\n const bad = n === at;\n return (\n <div key={n} className={cx(\"flex pe-3\", bad && \"bg-destructive/10\")}>\n <span className={cx(\"w-10 shrink-0 pe-3 text-right tabular-nums select-none\", bad ? \"text-destructive\" : \"text-muted-foreground\")}>{n}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n );\n })}\n </pre>\n <div>\n <Button size=\"sm\" icon=\"code\" onClick={onShow}>\n Show source\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\n/** The Mermaid text, numbered, the mistake's line marked. */\nfunction SourceView({ source, errorLine, height, onCopy }) {\n const lines = source.split(\"\\n\");\n return (\n <div className=\"relative\">\n <pre className=\"overflow-auto py-3 font-mono text-xs leading-relaxed\" style={{ height }} tabIndex={0} aria-label=\"Mermaid source\">\n {lines.map((l, i) => (\n <div key={i} className={cx(\"flex pe-12\", errorLine === i + 1 && \"bg-destructive/10\")}>\n <span className={cx(\"w-12 shrink-0 pe-3 text-right tabular-nums select-none\", errorLine === i + 1 ? \"text-destructive\" : \"text-muted-foreground\")}>{i + 1}</span>\n <span className=\"whitespace-pre\">{l || \" \"}</span>\n </div>\n ))}\n </pre>\n <IconButton icon=\"copy\" size=\"sm\" label=\"Copy source\" onClick={onCopy} className=\"absolute right-2 top-2\" />\n </div>\n );\n}\n"
25
25
  },
26
26
  {
27
27
  "path": "registry/blocks/diagram/mermaid.js",
@@ -67,7 +67,7 @@
67
67
  "docs": "Installed to components/makersclaw/blocks/diagram. Import: import { Diagram, diagramKind, diagramTitle, describeError, mermaidTheme } from \"@/components/makersclaw/blocks/diagram\". Props and an example: components/makersclaw/blocks/diagram/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
68
68
  "meta": {
69
69
  "makersclaw": {
70
- "version": "0.5.2",
70
+ "version": "0.7.0",
71
71
  "useWhen": "A screen shows a diagram written as text: a process or decision flow an agent wrote, a sequence of API calls, a data model, a project timeline, an org or system sketch in docs, a mind map. Not for a board people arrange by hand (flow-canvas), freehand drawing (sketch-canvas), or charts of numbers (chart)."
72
72
  }
73
73
  }
package/r/doc-editor.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "DocEditor",
6
6
  "description": "A Notion-style rich-text editor (BlockNote) painted in the workspace theme: headings, lists, checklists, quotes, code, tables, images and dividers from a / menu or markdown shortcuts, a formatting toolbar, drag handles; @mentions and {merge fields} as inline chips; a minimal mode for comment boxes with Cmd/Ctrl+Enter to submit. The document is BlockNote JSON; helpers turn it into markdown or plain text and read its checklist, fields and mentions.",
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
  "@blocknote/core@0.55.0",
@@ -23,7 +23,7 @@
23
23
  "path": "registry/blocks/doc-editor/doc-editor.jsx",
24
24
  "type": "registry:component",
25
25
  "target": "@components/makersclaw/blocks/doc-editor/doc-editor.jsx",
26
- "content": "\"use client\";\n\n// DocEditor — BlockNote, in the workspace theme, behind one set of props.\n//\n// Client only, one static bundle. BlockNote needs the DOM, and the app's\n// check refuses import() (so no next/dynamic): the modules load with the\n// page, and the editor itself is created only after the first effect\n// (the mount guard). Until then — on the server and in the first client\n// paint — a placeholder shows the doc's words in the same box, so the\n// server HTML already reads right and nothing jumps.\nimport \"@blocknote/shadcn/style.css\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { filterSuggestionItems } from \"@blocknote/core/extensions\";\nimport { en } from \"@blocknote/core/locales\";\nimport {\n DragHandleMenu,\n FormattingToolbar,\n FormattingToolbarController,\n RemoveBlockItem,\n SideMenu,\n SideMenuController,\n SuggestionMenuController,\n TableColumnHeaderItem,\n TableRowHeaderItem,\n getDefaultReactSlashMenuItems,\n getFormattingToolbarItems,\n useCreateBlockNote,\n} from \"@blocknote/react\";\nimport { BlockNoteView } from \"@blocknote/shadcn\";\nimport { Icon, cx } from \"../../ui\";\nimport { SCHEMA } from \"./schema\";\nimport { THEME } from \"./theme\";\nimport { docToText, emptyDoc, isDocEmpty } from \"./doc\";\n\n/** A ref for the editor's handle: `const ed = useDocEditor()`, then\n * `<DocEditor editorRef={ed} />` and `ed.current?.insertField(\"first_name\")`. */\nexport function useDocEditor() {\n return useRef(null);\n}\n\nconst nonEmpty = (doc) => (Array.isArray(doc) && doc.length ? doc : null);\n\n// The box, the same in the placeholder and the live editor.\nconst BOX = {\n full: \"min-h-40\",\n minimal: \"min-h-10\",\n};\n// `compact`: the full editor (menus, handles) in a short box, for a doc cut\n// into many small sections (brief-doc). A className cannot lower min-h-40:\n// cx joins classes without merging, and min-h-40 won (2026-09-24).\nconst boxOf = (minimal, compact) => (minimal ? BOX.minimal : compact ? \"min-h-16\" : BOX.full);\n// BlockNote's editor element: its own padding off (54px each side, for the\n// drag handles), ours on. Full mode keeps room for the handles from 640px;\n// on a phone there is no hover, so no handles and no gutter.\nconst EDITOR = {\n full: \"[&_.bn-editor]:px-0! [&_.bn-editor]:py-1! sm:[&_.bn-editor]:ps-12! text-base\",\n minimal: \"[&_.bn-editor]:px-0! [&_.bn-editor]:py-0! text-sm\",\n};\n\nexport function DocEditor(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DocPlaceholder {...props} />;\n return <LiveEditor {...props} />;\n}\n\n/** What the server renders (and the first client paint): the doc's words,\n * or the placeholder, in the editor's box. Not editable, not focusable. */\nfunction DocPlaceholder({ value, defaultValue, placeholder, minimal = false, compact = false, className }) {\n const doc = nonEmpty(value) ?? nonEmpty(defaultValue);\n const text = doc && !isDocEmpty(doc) ? docToText(doc) : \"\";\n const mode = minimal ? \"minimal\" : \"full\";\n return (\n <div className={cx(boxOf(minimal, compact), className)} aria-busy=\"true\">\n <div className={cx(EDITOR[mode], !minimal && \"sm:ps-12\", \"whitespace-pre-wrap\", text ? \"text-foreground\" : \"text-muted-foreground\")}>\n {text || placeholder || \" \"}\n </div>\n </div>\n );\n}\n\n/** Keep the members of a group together, in first-seen order: BlockNote's\n * menu draws a group heading each time the group changes. */\nfunction grouped(items) {\n const order = [];\n const by = new Map();\n for (const it of items) {\n const g = it.group ?? \"\";\n if (!by.has(g)) {\n by.set(g, []);\n order.push(g);\n }\n by.get(g).push(it);\n }\n return order.flatMap((g) => by.get(g));\n}\n\nconst iconOf = (icon) => (typeof icon === \"string\" ? <Icon name={icon} size={18} /> : icon);\n\n/** The workspace's light or dark, read off <html> (the desk sets data-theme). */\nfunction schemeOf() {\n const root = document.documentElement;\n if (root.dataset.theme === \"light\" || root.dataset.theme === \"dark\") return root.dataset.theme;\n const cs = getComputedStyle(root).colorScheme || \"\";\n return /\\blight\\b/.test(cs) && !/\\bdark\\b/.test(cs) ? \"light\" : \"dark\";\n}\n\nfunction LiveEditor({\n value,\n defaultValue,\n onChange,\n editable = true,\n placeholder,\n slashItems,\n mentions,\n fields,\n minimal = false,\n compact = false,\n autoFocus = false,\n onSubmit,\n onKeyDown,\n uploadFile,\n editorRef,\n className,\n label,\n}) {\n const mode = minimal ? \"minimal\" : \"full\";\n const initial = useRef(nonEmpty(value) ?? nonEmpty(defaultValue) ?? emptyDoc());\n const dictionary = useMemo(\n () => ({\n ...en,\n placeholders: {\n ...en.placeholders,\n emptyDocument: placeholder || undefined,\n // \"type '/' for commands\" is a lie with no slash menu.\n default: minimal ? undefined : en.placeholders.default,\n },\n }),\n // The editor is created once; a new placeholder needs a new key.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n const editor = useCreateBlockNote({ schema: SCHEMA, initialContent: initial.current, dictionary, uploadFile, trailingBlock: !minimal }, []);\n const [scheme, setScheme] = useState(\"dark\");\n useEffect(() => {\n setScheme(schemeOf());\n const obs = new MutationObserver(() => setScheme(schemeOf()));\n obs.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n return () => obs.disconnect();\n }, []);\n\n // Controlled: a `value` that is not what we last sent replaces the doc.\n const last = useRef(JSON.stringify(initial.current));\n const applying = useRef(false);\n useEffect(() => {\n if (value === undefined) return;\n const next = nonEmpty(value) ?? emptyDoc();\n const s = JSON.stringify(next);\n if (s === last.current) return;\n last.current = s;\n applying.current = true;\n try {\n editor.replaceBlocks(editor.document, next);\n } finally {\n applying.current = false;\n }\n }, [value, editor]);\n\n const emit = () => {\n if (applying.current) return;\n const doc = editor.document;\n last.current = JSON.stringify(doc);\n onChange?.(doc);\n };\n\n // The handle a block reaches through editorRef / useDocEditor().\n const handle = useMemo(\n () => ({\n editor,\n focus: () => editor.focus(),\n getDoc: () => editor.document,\n setDoc: (doc) => editor.replaceBlocks(editor.document, nonEmpty(doc) ?? emptyDoc()),\n clear: () => editor.replaceBlocks(editor.document, emptyDoc()),\n isEmpty: () => isDocEmpty(editor.document),\n insertText: (text) => editor.insertInlineContent([String(text ?? \"\")]),\n insertMention: ({ id, label: l }) => editor.insertInlineContent([{ type: \"mention\", props: { id: String(id), label: String(l ?? id) } }, \" \"]),\n insertField: (key, l) => {\n const f = (fields ?? []).find((x) => x.key === key);\n editor.insertInlineContent([{ type: \"field\", props: { key, label: l ?? f?.label ?? key } }, \" \"]);\n },\n }),\n [editor, fields],\n );\n useEffect(() => {\n if (!editorRef) return;\n editorRef.current = handle;\n return () => {\n if (editorRef.current === handle) editorRef.current = null;\n };\n }, [editorRef, handle]);\n\n const fieldItems = (query, group) =>\n filterSuggestionItems(\n (fields ?? []).map((f) => ({\n title: f.label ?? f.key,\n subtext: `{${f.key}}`,\n aliases: [f.key, \"field\", \"merge\"],\n group,\n icon: iconOf(\"tag\"),\n onItemClick: () => handle.insertField(f.key, f.label),\n })),\n query,\n );\n\n const getSlashItems = async (query) => {\n const own = (slashItems ?? []).map((it) => ({\n title: it.title,\n subtext: it.subtext,\n aliases: it.aliases,\n group: it.group ?? \"Other\",\n icon: iconOf(it.icon),\n onItemClick: () => it.onSelect?.(handle),\n }));\n const all = grouped([...getDefaultReactSlashMenuItems(editor), ...own]);\n return [...filterSuggestionItems(all, query), ...(fields?.length ? fieldItems(query, \"Fields\") : [])];\n };\n\n const getMentionItems = async (query) => {\n let people = [];\n try {\n people = (await mentions(query)) ?? [];\n } catch {\n people = [];\n }\n return people.slice(0, 20).map((p) => ({\n title: p.label ?? String(p.id),\n subtext: p.description,\n icon: iconOf(\"user\"),\n onItemClick: () => handle.insertMention({ id: p.id, label: p.label }),\n }));\n };\n\n // Cmd/Ctrl+Enter submits, before BlockNote sees the Enter.\n const keyDown = (e) => {\n if (onSubmit && e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {\n e.preventDefault();\n e.stopPropagation();\n onSubmit(editor.document);\n return;\n }\n onKeyDown?.(e);\n };\n\n return (\n // raw: a plain box that hears keys on their way into BlockNote's\n // contenteditable (capture), for Cmd/Ctrl+Enter; no control of its own.\n <div className={cx(boxOf(minimal, compact), className)} onKeyDownCapture={keyDown}>\n <BlockNoteView\n editor={editor}\n editable={editable}\n autoFocus={autoFocus}\n theme={scheme}\n className={cx(THEME, EDITOR[mode])}\n onChange={emit}\n aria-label={label}\n slashMenu={false}\n sideMenu={false}\n formattingToolbar={false}\n emojiPicker={!minimal}\n >\n <FormattingToolbarController formattingToolbar={Toolbar} />\n {!minimal && <SideMenuController sideMenu={Handles} />}\n {!minimal && <SuggestionMenuController triggerCharacter=\"/\" getItems={getSlashItems} />}\n {mentions && <SuggestionMenuController triggerCharacter=\"@\" getItems={getMentionItems} />}\n {fields?.length ? <SuggestionMenuController triggerCharacter=\"{\" getItems={async (q) => fieldItems(q)} /> : null}\n </BlockNoteView>\n </div>\n );\n}\n\n// BlockNote's toolbar and block menu without their colour pickers: the\n// theme has no red or purple to offer (theme.js maps what is stored).\nconst Toolbar = () => <FormattingToolbar>{getFormattingToolbarItems().filter((el) => el.key !== \"colorStyleButton\")}</FormattingToolbar>;\nconst BlockMenu = () => (\n <DragHandleMenu>\n <RemoveBlockItem>{en.drag_handle.delete_menuitem}</RemoveBlockItem>\n <TableRowHeaderItem>{en.drag_handle.header_row_menuitem}</TableRowHeaderItem>\n <TableColumnHeaderItem>{en.drag_handle.header_column_menuitem}</TableColumnHeaderItem>\n </DragHandleMenu>\n);\nconst Handles = (props) => <SideMenu {...props} dragHandleMenu={BlockMenu} />;\n"
26
+ "content": "\"use client\";\n\n// DocEditor — BlockNote, in the workspace theme, behind one set of props.\n//\n// Client only, one bundle. BlockNote needs the DOM, and the app's\n// check refuses import() (so no next/dynamic): the modules load with the\n// page, and the editor itself is created only after the first effect\n// (the mount guard). Until then — on the server and in the first client\n// paint — a placeholder shows the doc's words in the same box, so the\n// server HTML already reads right and nothing jumps.\nimport \"@blocknote/shadcn/style.css\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { filterSuggestionItems } from \"@blocknote/core/extensions\";\nimport { en } from \"@blocknote/core/locales\";\nimport {\n DragHandleMenu,\n FormattingToolbar,\n FormattingToolbarController,\n RemoveBlockItem,\n SideMenu,\n SideMenuController,\n SuggestionMenuController,\n TableColumnHeaderItem,\n TableRowHeaderItem,\n getDefaultReactSlashMenuItems,\n getFormattingToolbarItems,\n useCreateBlockNote,\n} from \"@blocknote/react\";\nimport { BlockNoteView } from \"@blocknote/shadcn\";\nimport { Icon, cx } from \"../../ui\";\nimport { SCHEMA } from \"./schema\";\nimport { THEME } from \"./theme\";\nimport { docToText, emptyDoc, isDocEmpty } from \"./doc\";\n\n/** A ref for the editor's handle: `const ed = useDocEditor()`, then\n * `<DocEditor editorRef={ed} />` and `ed.current?.insertField(\"first_name\")`. */\nexport function useDocEditor() {\n return useRef(null);\n}\n\nconst nonEmpty = (doc) => (Array.isArray(doc) && doc.length ? doc : null);\n\n// The box, the same in the placeholder and the live editor.\nconst BOX = {\n full: \"min-h-40\",\n minimal: \"min-h-10\",\n};\n// `compact`: the full editor (menus, handles) in a short box, for a doc cut\n// into many small sections (brief-doc). A className cannot lower min-h-40:\n// cx joins classes without merging, and min-h-40 won (2026-09-24).\nconst boxOf = (minimal, compact) => (minimal ? BOX.minimal : compact ? \"min-h-16\" : BOX.full);\n// BlockNote's editor element: its own padding off (54px each side, for the\n// drag handles), ours on. Full mode keeps room for the handles from 640px;\n// on a phone there is no hover, so no handles and no gutter.\nconst EDITOR = {\n full: \"[&_.bn-editor]:px-0! [&_.bn-editor]:py-1! sm:[&_.bn-editor]:ps-12! text-base\",\n minimal: \"[&_.bn-editor]:px-0! [&_.bn-editor]:py-0! text-sm\",\n};\n\nexport function DocEditor(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) return <DocPlaceholder {...props} />;\n return <LiveEditor {...props} />;\n}\n\n/** What the server renders (and the first client paint): the doc's words,\n * or the placeholder, in the editor's box. Not editable, not focusable. */\nfunction DocPlaceholder({ value, defaultValue, placeholder, minimal = false, compact = false, className }) {\n const doc = nonEmpty(value) ?? nonEmpty(defaultValue);\n const text = doc && !isDocEmpty(doc) ? docToText(doc) : \"\";\n const mode = minimal ? \"minimal\" : \"full\";\n return (\n <div className={cx(boxOf(minimal, compact), className)} aria-busy=\"true\">\n <div className={cx(EDITOR[mode], !minimal && \"sm:ps-12\", \"whitespace-pre-wrap\", text ? \"text-foreground\" : \"text-muted-foreground\")}>\n {text || placeholder || \" \"}\n </div>\n </div>\n );\n}\n\n/** Keep the members of a group together, in first-seen order: BlockNote's\n * menu draws a group heading each time the group changes. */\nfunction grouped(items) {\n const order = [];\n const by = new Map();\n for (const it of items) {\n const g = it.group ?? \"\";\n if (!by.has(g)) {\n by.set(g, []);\n order.push(g);\n }\n by.get(g).push(it);\n }\n return order.flatMap((g) => by.get(g));\n}\n\nconst iconOf = (icon) => (typeof icon === \"string\" ? <Icon name={icon} size={18} /> : icon);\n\n/** The workspace's light or dark, read off <html> (the desk sets data-theme). */\nfunction schemeOf() {\n const root = document.documentElement;\n if (root.dataset.theme === \"light\" || root.dataset.theme === \"dark\") return root.dataset.theme;\n const cs = getComputedStyle(root).colorScheme || \"\";\n return /\\blight\\b/.test(cs) && !/\\bdark\\b/.test(cs) ? \"light\" : \"dark\";\n}\n\nfunction LiveEditor({\n value,\n defaultValue,\n onChange,\n editable = true,\n placeholder,\n slashItems,\n mentions,\n fields,\n minimal = false,\n compact = false,\n autoFocus = false,\n onSubmit,\n onKeyDown,\n uploadFile,\n editorRef,\n className,\n label,\n}) {\n const mode = minimal ? \"minimal\" : \"full\";\n const initial = useRef(nonEmpty(value) ?? nonEmpty(defaultValue) ?? emptyDoc());\n const dictionary = useMemo(\n () => ({\n ...en,\n placeholders: {\n ...en.placeholders,\n emptyDocument: placeholder || undefined,\n // \"type '/' for commands\" is a lie with no slash menu.\n default: minimal ? undefined : en.placeholders.default,\n },\n }),\n // The editor is created once; a new placeholder needs a new key.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [],\n );\n const editor = useCreateBlockNote({ schema: SCHEMA, initialContent: initial.current, dictionary, uploadFile, trailingBlock: !minimal }, []);\n const [scheme, setScheme] = useState(\"dark\");\n useEffect(() => {\n setScheme(schemeOf());\n const obs = new MutationObserver(() => setScheme(schemeOf()));\n obs.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n return () => obs.disconnect();\n }, []);\n\n // Controlled: a `value` that is not what we last sent replaces the doc.\n const last = useRef(JSON.stringify(initial.current));\n const applying = useRef(false);\n useEffect(() => {\n if (value === undefined) return;\n const next = nonEmpty(value) ?? emptyDoc();\n const s = JSON.stringify(next);\n if (s === last.current) return;\n last.current = s;\n applying.current = true;\n try {\n editor.replaceBlocks(editor.document, next);\n } finally {\n applying.current = false;\n }\n }, [value, editor]);\n\n const emit = () => {\n if (applying.current) return;\n const doc = editor.document;\n last.current = JSON.stringify(doc);\n onChange?.(doc);\n };\n\n // The handle a block reaches through editorRef / useDocEditor().\n const handle = useMemo(\n () => ({\n editor,\n focus: () => editor.focus(),\n getDoc: () => editor.document,\n setDoc: (doc) => editor.replaceBlocks(editor.document, nonEmpty(doc) ?? emptyDoc()),\n clear: () => editor.replaceBlocks(editor.document, emptyDoc()),\n isEmpty: () => isDocEmpty(editor.document),\n insertText: (text) => editor.insertInlineContent([String(text ?? \"\")]),\n insertMention: ({ id, label: l }) => editor.insertInlineContent([{ type: \"mention\", props: { id: String(id), label: String(l ?? id) } }, \" \"]),\n insertField: (key, l) => {\n const f = (fields ?? []).find((x) => x.key === key);\n editor.insertInlineContent([{ type: \"field\", props: { key, label: l ?? f?.label ?? key } }, \" \"]);\n },\n }),\n [editor, fields],\n );\n useEffect(() => {\n if (!editorRef) return;\n editorRef.current = handle;\n return () => {\n if (editorRef.current === handle) editorRef.current = null;\n };\n }, [editorRef, handle]);\n\n const fieldItems = (query, group) =>\n filterSuggestionItems(\n (fields ?? []).map((f) => ({\n title: f.label ?? f.key,\n subtext: `{${f.key}}`,\n aliases: [f.key, \"field\", \"merge\"],\n group,\n icon: iconOf(\"tag\"),\n onItemClick: () => handle.insertField(f.key, f.label),\n })),\n query,\n );\n\n const getSlashItems = async (query) => {\n const own = (slashItems ?? []).map((it) => ({\n title: it.title,\n subtext: it.subtext,\n aliases: it.aliases,\n group: it.group ?? \"Other\",\n icon: iconOf(it.icon),\n onItemClick: () => it.onSelect?.(handle),\n }));\n const all = grouped([...getDefaultReactSlashMenuItems(editor), ...own]);\n return [...filterSuggestionItems(all, query), ...(fields?.length ? fieldItems(query, \"Fields\") : [])];\n };\n\n const getMentionItems = async (query) => {\n let people = [];\n try {\n people = (await mentions(query)) ?? [];\n } catch {\n people = [];\n }\n return people.slice(0, 20).map((p) => ({\n title: p.label ?? String(p.id),\n subtext: p.description,\n icon: iconOf(\"user\"),\n onItemClick: () => handle.insertMention({ id: p.id, label: p.label }),\n }));\n };\n\n // Cmd/Ctrl+Enter submits, before BlockNote sees the Enter.\n const keyDown = (e) => {\n if (onSubmit && e.key === \"Enter\" && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {\n e.preventDefault();\n e.stopPropagation();\n onSubmit(editor.document);\n return;\n }\n onKeyDown?.(e);\n };\n\n return (\n // raw: a plain box that hears keys on their way into BlockNote's\n // contenteditable (capture), for Cmd/Ctrl+Enter; no control of its own.\n <div className={cx(boxOf(minimal, compact), className)} onKeyDownCapture={keyDown}>\n <BlockNoteView\n editor={editor}\n editable={editable}\n autoFocus={autoFocus}\n theme={scheme}\n className={cx(THEME, EDITOR[mode])}\n onChange={emit}\n aria-label={label}\n slashMenu={false}\n sideMenu={false}\n formattingToolbar={false}\n emojiPicker={!minimal}\n >\n <FormattingToolbarController formattingToolbar={Toolbar} />\n {!minimal && <SideMenuController sideMenu={Handles} />}\n {!minimal && <SuggestionMenuController triggerCharacter=\"/\" getItems={getSlashItems} />}\n {mentions && <SuggestionMenuController triggerCharacter=\"@\" getItems={getMentionItems} />}\n {fields?.length ? <SuggestionMenuController triggerCharacter=\"{\" getItems={async (q) => fieldItems(q)} /> : null}\n </BlockNoteView>\n </div>\n );\n}\n\n// BlockNote's toolbar and block menu without their colour pickers: the\n// theme has no red or purple to offer (theme.js maps what is stored).\nconst Toolbar = () => <FormattingToolbar>{getFormattingToolbarItems().filter((el) => el.key !== \"colorStyleButton\")}</FormattingToolbar>;\nconst BlockMenu = () => (\n <DragHandleMenu>\n <RemoveBlockItem>{en.drag_handle.delete_menuitem}</RemoveBlockItem>\n <TableRowHeaderItem>{en.drag_handle.header_row_menuitem}</TableRowHeaderItem>\n <TableColumnHeaderItem>{en.drag_handle.header_column_menuitem}</TableColumnHeaderItem>\n </DragHandleMenu>\n);\nconst Handles = (props) => <SideMenu {...props} dragHandleMenu={BlockMenu} />;\n"
27
27
  },
28
28
  {
29
29
  "path": "registry/blocks/doc-editor/schema.jsx",
@@ -53,7 +53,7 @@
53
53
  "path": "registry/blocks/doc-editor/README.md",
54
54
  "type": "registry:file",
55
55
  "target": "@components/makersclaw/blocks/doc-editor/README.md",
56
- "content": "# DocEditor\n\nA Notion-style rich-text editor in the workspace theme. Type `/` for a\nblock (headings, lists, checklists, quotes, code, tables, images,\ndividers), or use markdown shortcuts (`#`, `-`, `1.`, `[]`, `>`). Select\ntext for the formatting toolbar; hover a block for its drag handle. `@`\nmentions a person, `{` inserts a merge field; both sit in the text as\nchips. In `minimal` mode it is a comment box: no side menu, no `/` menu,\nand Cmd/Ctrl+Enter submits.\n\nIt is [BlockNote](https://www.blocknotejs.org) (MPL-2.0) underneath, with\nits colours, face and radius mapped onto the workspace's tokens. The\nother text blocks — `doc-page`, `comment-box`, `record-notes`,\n`email-composer`, `meeting-notes`, `brief-doc`, `knowledge-base` — are\nbuilt on this one: they import it, never `@blocknote/*`.\n\n```jsx\nimport { DocEditor, docToMarkdown } from \"../components/blocks/doc-editor\"; // a Makersclaw app (\"./blocks/doc-editor\" from components/)\nimport { DocEditor, docToMarkdown } from \"@/components/makersclaw/blocks/doc-editor\"; // a shadcn project\n```\n\n`DocEditor` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`docToMarkdown`, `docToText`, `docChecklist`…) are\nplain functions and work on the server too.\n\n`makersclaw add doc-editor` adds `@blocknote/core`, `@blocknote/react` and\n`@blocknote/shadcn` (pinned, exact) to package.json: run `npm install`\nafter. It loads with the page (no `import()`): the editor is created\nafter the first render, and until then — on the server — the box shows\nthe doc's words as plain text.\n\n## The data\n\nThe document is BlockNote's JSON: an array of blocks. Store it as it\ncomes out of `onChange` (a `jsonb` column).\n\n```js\n[\n { id: \"…\", type: \"heading\", props: { level: 2 }, content: [{ type: \"text\", text: \"Launch\", styles: {} }], children: [] },\n { id: \"…\", type: \"paragraph\", content: [\n { type: \"text\", text: \"Hi \", styles: {} },\n { type: \"field\", props: { key: \"first_name\", label: \"First name\" } },\n { type: \"text\", text: \", \", styles: {} },\n { type: \"mention\", props: { id: \"u-42\", label: \"Priya Raman\" } },\n { type: \"text\", text: \" will call.\", styles: { bold: true } },\n ], children: [] },\n { id: \"…\", type: \"checkListItem\", props: { checked: false }, content: [{ type: \"text\", text: \"Send the deck\", styles: {} }], children: [] },\n]\n```\n\n- Block types: `paragraph`, `heading` (`props.level` 1–3), `bulletListItem`,\n `numberedListItem`, `checkListItem` (`props.checked`), `toggleListItem`,\n `quote`, `codeBlock` (`props.language`), `table`, `image` (`props.url`,\n `caption`), `divider`. Blocks nest through `children`.\n- Inline content: `text` (`styles`: `bold`, `italic`, `underline`,\n `strike`, `code`), `link` (`href`, `content`), and two of ours:\n - `{ type: \"mention\", props: { id, label } }` — a person, drawn `@Label`.\n - `{ type: \"field\", props: { key, label } }` — a merge field, drawn as a\n chip with its label, written out as `{key}` or its value.\n- When you write a doc by hand (a starter doc, a template), ids, `props`\n and `children` may be left out and `content` may be a string:\n `[{ type: \"paragraph\", content: \"Hello\" }]`. The editor gives blocks\n ids on the first edit; give them ids yourself when something reads\n them before that (`setChecklistItem` on a starter checklist).\n\n## Props\n\n- `value` — the doc, controlled. A new `value` that differs from what the\n editor last sent through `onChange` replaces the document (loading\n another record). Pass back exactly what `onChange` gave you and nothing\n resets.\n- `defaultValue` — the starting doc, uncontrolled. With neither, the doc\n starts empty.\n- `onChange(doc)` — every edit, with the whole doc. Debounce a save\n yourself (doc-page does).\n- `editable` — `true` (default). `false` shows the doc read-only: no\n caret, no menus.\n- `placeholder` — shown while the doc is empty (and on the server while\n it is).\n- `minimal` — `false` (default). `true` for a comment box: no side menu\n (drag handles, +), no `/` menu, no emoji picker, smaller text, no\n padding of its own. Mentions, fields and the formatting toolbar still\n work.\n- `compact` — `false` (default). `true` keeps the full editor (menus,\n handles) in a short box: 4rem at least instead of 10rem. For a doc cut\n into many small sections (brief-doc).\n- `autoFocus` — put the caret in it when it mounts.\n- `onSubmit(doc)` — called on Cmd/Ctrl+Enter (and not passed to the\n editor). Post a comment, send a reply. Clear the box yourself through\n the handle (below).\n- `onKeyDown(event)` — every other key, before the editor sees it\n (`event.preventDefault()` stops it).\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@`. Called as the person types after `@` (`query` is the text after\n it, `\"\"` at first); return at most 20. A throw shows nothing. The chosen\n person goes in as a mention chip.\n- `fields` — `[{ key, label }]`: turns on merge fields. `{` opens a menu of\n them, filtered as you type; the `/` menu lists them under \"Fields\".\n Each goes in as a field chip showing its label.\n- `slashItems` — more entries for the `/` menu: `[{ title, onSelect(handle),\n subtext?, aliases?, group?, icon? }]`. `onSelect` gets the handle\n (below); `group` puts it under a heading (\"Other\" when left out);\n `icon` is a kit icon name. BlockNote's own entries come first.\n- `uploadFile` — `async (file) => url`: lets an image block upload a\n file (without it, an image is added by URL).\n- `editorRef` — a ref (`useDocEditor()`) that receives the handle.\n- `label` — the accessible name of the editing area (\"Meeting notes\").\n- `className` — layout around it (width, margin, min-height). Never colour.\n\nThe handle (`editorRef.current`, and what a slash item's `onSelect` gets):\n\n- `focus()` · `clear()` · `isEmpty()` · `getDoc()` · `setDoc(doc)`\n- `insertText(text)` · `insertField(key, label?)` · `insertMention({ id, label })`\n — at the caret.\n- `editor` — the BlockNote editor itself, for anything else\n (`editor.insertBlocks(…)`, `editor.getTextCursorPosition()`). Reaching\n for it ties you to BlockNote's API; prefer the methods above.\n\nThe helpers (import them from the block; they take a doc and never throw\non a partial one):\n\n- `emptyDoc()` — `[{ type: \"paragraph\", content: [] }]`.\n- `isDocEmpty(doc)` — no text, chip or non-paragraph block. Use it to\n disable Post.\n- `docToText(doc, { fields? })` — plain text: one line per block (an empty\n paragraph is an empty line), children indented, list markers kept\n (`- `, `1. `, `[ ] `/`[x] `), a mention as `@Label`, a field as its value\n or `{key}`. `fields` is `{ key: value }` or `(key, props) => value`; an\n empty value leaves `{key}` in place.\n- `docToMarkdown(doc, { fields? })` — GitHub markdown: headings, nested\n lists, `- [ ]` checklists, quotes, fenced code, tables, images, dividers,\n bold/italic/strike/code, links; mentions and fields as in `docToText`.\n Lossy where markdown has no word: underline, colours, alignment.\n- `docChecklist(doc)` — `[{ id, text, checked, depth, mentions: [{ id,\n label }] }]`, every checklist item in order, nested ones too. Read an\n owner off its mentions.\n- `setChecklistItem(doc, id, checked)` — a new doc with that item ticked\n or not (for a side list of action items).\n- `docFields(doc)` — `[{ key, label, count }]`, the merge fields the doc\n uses, in order of first use. Compare with a recipient's values to warn\n of a missing one.\n- `docMentions(doc)` — `[{ id, label, count }]`, the people it mentions.\n\n## Example\n\n```jsx\n// components/DealNotes.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { DocEditor } from \"./blocks/doc-editor\";\n\nexport default function DealNotes({ deal, team }) {\n const [doc, setDoc] = useState(deal.notes); // jsonb, may be null\n return (\n <DocEditor\n value={doc}\n onChange={(d) => {\n setDoc(d);\n updateRow({ source: \"public.deals\", id: deal.id, patch: { notes: d } }); // debounce this in a real app\n }}\n mentions={async (q) => team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))}\n placeholder=\"Notes on this deal…\"\n label=\"Deal notes\"\n />\n );\n}\n```\n\nA comment box that posts on Cmd/Ctrl+Enter and clears itself:\n\n```jsx\nconst box = useDocEditor();\nconst post = async (doc) => {\n if (isDocEmpty(doc)) return;\n const res = await insertRow({ source: \"public.comments\", values: { body: doc, body_text: docToText(doc) } });\n if (res.ok) box.current?.clear();\n};\n\n<DocEditor minimal editorRef={box} onSubmit={post} mentions={findPeople} placeholder=\"Write a comment…\" />\n```\n\nAn email body with merge fields, filled per recipient:\n\n```jsx\nconst FIELDS = [{ key: \"first_name\", label: \"First name\" }, { key: \"company\", label: \"Company\" }];\n\n<DocEditor value={body} onChange={setBody} fields={FIELDS} />\nconst text = docToText(body, { fields: contact }); // \"Hi Ana, …\"\nconst missing = docFields(body).filter((f) => !contact[f.key]); // warn before sending\n```\n\n## What it will not do\n\n- Collaborate live (several carets at once): that is BlockNote's Yjs\n mode, not wired here. Saves are last-write-wins.\n- Export to PDF or Word, multi-column layouts or AI writing: those are\n BlockNote's `xl-` packages (GPL / commercial), which never come here.\n- Colour text: the toolbar has no colour picker (the theme has no red or\n purple to offer). Coloured text pasted in is shown in status and chart\n tokens.\n- Upload files, audio or video: an image by URL, or through your\n `uploadFile`.\n- Render on the server as rich text: the server shows the plain text\n (`docToText`) until the editor mounts. For a read-only page that must\n be rich in the HTML, render `docToMarkdown` yourself.\n\nAn editor it cannot be is your own component in `components/` — not an\nedit to this block. Its files are fixed: `npm run check` refuses a change,\nand `makersclaw add doc-editor` restores them.\n\n## Credits\n\nBlockNote (https://github.com/TypeCellOS/BlockNote), MPL-2.0 — installed\nfrom npm as a dependency, not copied here. See THIRD-PARTY-NOTICES.md.\n"
56
+ "content": "# DocEditor\n\nA Notion-style rich-text editor in the workspace theme. Type `/` for a\nblock (headings, lists, checklists, quotes, code, tables, images,\ndividers), or use markdown shortcuts (`#`, `-`, `1.`, `[]`, `>`). Select\ntext for the formatting toolbar; hover a block for its drag handle. `@`\nmentions a person, `{` inserts a merge field; both sit in the text as\nchips. In `minimal` mode it is a comment box: no side menu, no `/` menu,\nand Cmd/Ctrl+Enter submits.\n\nIt is [BlockNote](https://www.blocknotejs.org) (MPL-2.0) underneath, with\nits colours, face and radius mapped onto the workspace's tokens. The\nother text blocks — `doc-page`, `comment-box`, `record-notes`,\n`email-composer`, `meeting-notes`, `brief-doc`, `knowledge-base` — are\nbuilt on this one: they import it, never `@blocknote/*`.\n\n```jsx\nimport { DocEditor, docToMarkdown } from \"../components/blocks/doc-editor\"; // a Makersclaw app (\"./blocks/doc-editor\" from components/)\nimport { DocEditor, docToMarkdown } from \"@/components/makersclaw/blocks/doc-editor\"; // a shadcn project\n```\n\n`DocEditor` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`docToMarkdown`, `docToText`, `docChecklist`…) are\nplain functions and work on the server too.\n\n`makersclaw add doc-editor` adds `@blocknote/core`, `@blocknote/react` and\n`@blocknote/shadcn` (pinned, exact) to package.json: run `npm install`\nafter. It loads with the page (no `import()`): the editor is created\nafter the first render, and until then — on the server — the box shows\nthe doc's words as plain text.\n\n## The data\n\nThe document is BlockNote's JSON: an array of blocks. Store it as it\ncomes out of `onChange` (a `jsonb` column).\n\n```js\n[\n { id: \"…\", type: \"heading\", props: { level: 2 }, content: [{ type: \"text\", text: \"Launch\", styles: {} }], children: [] },\n { id: \"…\", type: \"paragraph\", content: [\n { type: \"text\", text: \"Hi \", styles: {} },\n { type: \"field\", props: { key: \"first_name\", label: \"First name\" } },\n { type: \"text\", text: \", \", styles: {} },\n { type: \"mention\", props: { id: \"u-42\", label: \"Priya Raman\" } },\n { type: \"text\", text: \" will call.\", styles: { bold: true } },\n ], children: [] },\n { id: \"…\", type: \"checkListItem\", props: { checked: false }, content: [{ type: \"text\", text: \"Send the deck\", styles: {} }], children: [] },\n]\n```\n\n- Block types: `paragraph`, `heading` (`props.level` 1–3), `bulletListItem`,\n `numberedListItem`, `checkListItem` (`props.checked`), `toggleListItem`,\n `quote`, `codeBlock` (`props.language`), `table`, `image` (`props.url`,\n `caption`), `divider`. Blocks nest through `children`.\n- Inline content: `text` (`styles`: `bold`, `italic`, `underline`,\n `strike`, `code`), `link` (`href`, `content`), and two of ours:\n - `{ type: \"mention\", props: { id, label } }` — a person, drawn `@Label`.\n - `{ type: \"field\", props: { key, label } }` — a merge field, drawn as a\n chip with its label, written out as `{key}` or its value.\n- When you write a doc by hand (a starter doc, a template), ids, `props`\n and `children` may be left out and `content` may be a string:\n `[{ type: \"paragraph\", content: \"Hello\" }]`. The editor gives blocks\n ids on the first edit; give them ids yourself when something reads\n them before that (`setChecklistItem` on a starter checklist).\n\n## Props\n\n- `value` — the doc, controlled. A new `value` that differs from what the\n editor last sent through `onChange` replaces the document (loading\n another record). Pass back exactly what `onChange` gave you and nothing\n resets.\n- `defaultValue` — the starting doc, uncontrolled. With neither, the doc\n starts empty.\n- `onChange(doc)` — every edit, with the whole doc. Debounce a save\n yourself (doc-page does).\n- `editable` — `true` (default). `false` shows the doc read-only: no\n caret, no menus.\n- `placeholder` — shown while the doc is empty (and on the server while\n it is).\n- `minimal` — `false` (default). `true` for a comment box: no side menu\n (drag handles, +), no `/` menu, no emoji picker, smaller text, no\n padding of its own. Mentions, fields and the formatting toolbar still\n work.\n- `compact` — `false` (default). `true` keeps the full editor (menus,\n handles) in a short box: 4rem at least instead of 10rem. For a doc cut\n into many small sections (brief-doc).\n- `autoFocus` — put the caret in it when it mounts.\n- `onSubmit(doc)` — called on Cmd/Ctrl+Enter (and not passed to the\n editor). Post a comment, send a reply. Clear the box yourself through\n the handle (below).\n- `onKeyDown(event)` — every other key, before the editor sees it\n (`event.preventDefault()` stops it).\n- `mentions` — `async (query) => [{ id, label, description? }]`: turns on\n `@`. Called as the person types after `@` (`query` is the text after\n it, `\"\"` at first); return at most 20. A throw shows nothing. The chosen\n person goes in as a mention chip.\n- `fields` — `[{ key, label }]`: turns on merge fields. `{` opens a menu of\n them, filtered as you type; the `/` menu lists them under \"Fields\".\n Each goes in as a field chip showing its label.\n- `slashItems` — more entries for the `/` menu: `[{ title, onSelect(handle),\n subtext?, aliases?, group?, icon? }]`. `onSelect` gets the handle\n (below); `group` puts it under a heading (\"Other\" when left out);\n `icon` is a kit icon name. BlockNote's own entries come first.\n- `uploadFile` — `async (file) => url`: lets an image block upload a\n file (without it, an image is added by URL).\n- `editorRef` — a ref (`useDocEditor()`) that receives the handle.\n- `label` — the accessible name of the editing area (\"Meeting notes\").\n- `className` — layout around it (width, margin, min-height). Never colour.\n\nThe handle (`editorRef.current`, and what a slash item's `onSelect` gets):\n\n- `focus()` · `clear()` · `isEmpty()` · `getDoc()` · `setDoc(doc)`\n- `insertText(text)` · `insertField(key, label?)` · `insertMention({ id, label })`\n — at the caret.\n- `editor` — the BlockNote editor itself, for anything else\n (`editor.insertBlocks(…)`, `editor.getTextCursorPosition()`). Reaching\n for it ties you to BlockNote's API; prefer the methods above.\n\nThe helpers (import them from the block; they take a doc and never throw\non a partial one):\n\n- `emptyDoc()` — `[{ type: \"paragraph\", content: [] }]`.\n- `isDocEmpty(doc)` — no text, chip or non-paragraph block. Use it to\n disable Post.\n- `docToText(doc, { fields? })` — plain text: one line per block (an empty\n paragraph is an empty line), children indented, list markers kept\n (`- `, `1. `, `[ ] `/`[x] `), a mention as `@Label`, a field as its value\n or `{key}`. `fields` is `{ key: value }` or `(key, props) => value`; an\n empty value leaves `{key}` in place.\n- `docToMarkdown(doc, { fields? })` — GitHub markdown: headings, nested\n lists, `- [ ]` checklists, quotes, fenced code, tables, images, dividers,\n bold/italic/strike/code, links; mentions and fields as in `docToText`.\n Lossy where markdown has no word: underline, colours, alignment.\n- `docChecklist(doc)` — `[{ id, text, checked, depth, mentions: [{ id,\n label }] }]`, every checklist item in order, nested ones too. Read an\n owner off its mentions.\n- `setChecklistItem(doc, id, checked)` — a new doc with that item ticked\n or not (for a side list of action items).\n- `docFields(doc)` — `[{ key, label, count }]`, the merge fields the doc\n uses, in order of first use. Compare with a recipient's values to warn\n of a missing one.\n- `docMentions(doc)` — `[{ id, label, count }]`, the people it mentions.\n\n## Example\n\n```jsx\n// surfaces/deal.jsx — client: one deal's notes\n\"use client\";\nimport { useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow, useRows } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { DocEditor } from \"../components/blocks/doc-editor\";\n\nexport default function DealSurface() {\n const id = useSearchParams().get(\"row\");\n const { row: deal, loading } = useRow({ source: \"deals\", id });\n const { rows: team } = useRows({ source: \"people\", orderBy: \"name asc\" });\n if (loading || !deal) return null;\n return <DealNotes key={deal.id} deal={deal} team={team} />;\n}\n\nfunction DealNotes({ deal, team }) {\n const [doc, setDoc] = useState(deal.notes); // jsonb, may be null; read once per deal, so a read never moves the cursor\n return (\n <DocEditor\n value={doc}\n onChange={(d) => {\n setDoc(d);\n updateRow({ source: \"deals\", id: deal.id, patch: { notes: d } }); // debounce this in a real app\n }}\n mentions={async (q) => team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))}\n placeholder=\"Notes on this deal…\"\n label=\"Deal notes\"\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\nA comment box that posts on Cmd/Ctrl+Enter and clears itself:\n\n```jsx\nconst box = useDocEditor();\nconst post = async (doc) => {\n if (isDocEmpty(doc)) return;\n const res = await insertRow({ source: \"comments\", values: { body: doc, body_text: docToText(doc) } });\n if (res.ok) box.current?.clear();\n};\n\n<DocEditor minimal editorRef={box} onSubmit={post} mentions={findPeople} placeholder=\"Write a comment…\" />\n```\n\nAn email body with merge fields, filled per recipient:\n\n```jsx\nconst FIELDS = [{ key: \"first_name\", label: \"First name\" }, { key: \"company\", label: \"Company\" }];\n\n<DocEditor value={body} onChange={setBody} fields={FIELDS} />\nconst text = docToText(body, { fields: contact }); // \"Hi Ana, …\"\nconst missing = docFields(body).filter((f) => !contact[f.key]); // warn before sending\n```\n\n## What it will not do\n\n- Collaborate live (several carets at once): that is BlockNote's Yjs\n mode, not wired here. Saves are last-write-wins.\n- Export to PDF or Word, multi-column layouts or AI writing: those are\n BlockNote's `xl-` packages (GPL / commercial), which never come here.\n- Colour text: the toolbar has no colour picker (the theme has no red or\n purple to offer). Coloured text pasted in is shown in status and chart\n tokens.\n- Upload files, audio or video: an image by URL, or through your\n `uploadFile`.\n- Render on the server as rich text: the server shows the plain text\n (`docToText`) until the editor mounts. For a read-only page that must\n be rich in the HTML, render `docToMarkdown` yourself.\n\nAn editor it cannot be is your own component in `components/` — not an\nedit to this block. Its files are fixed: `npm run check` refuses a change,\nand `makersclaw add doc-editor` restores them.\n\n## Credits\n\nBlockNote (https://github.com/TypeCellOS/BlockNote), MPL-2.0 — installed\nfrom npm as a dependency, not copied here. See THIRD-PARTY-NOTICES.md.\n"
57
57
  }
58
58
  ],
59
59
  "categories": [
@@ -75,7 +75,7 @@
75
75
  "docs": "Installed to components/makersclaw/blocks/doc-editor. Import: import { DocEditor, useDocEditor, docToMarkdown, docToText, emptyDoc, isDocEmpty, docChecklist, setChecklistItem, docFields, docMentions } from \"@/components/makersclaw/blocks/doc-editor\". Props and an example: components/makersclaw/blocks/doc-editor/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
76
76
  "meta": {
77
77
  "makersclaw": {
78
- "version": "0.5.2",
78
+ "version": "0.7.0",
79
79
  "useWhen": "A screen edits rich text: a wiki or doc page, notes on a record, a brief, meeting notes, an email body with merge fields, a comment field with mentions. The other text blocks (doc-page, record-notes, comment-box…) are built on it. Not for one line of plain text (the kit's Input) or a form of fields (record-form)."
80
80
  }
81
81
  }
package/r/doc-page.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "DocPage",
6
6
  "description": "A Notion-style page: an emoji icon (picked from a grid), a big plain title, and the rich-text editor under it (doc-editor: / menu, markdown shortcuts, @mentions). Edits autosave through an async onSave a moment after typing stops, with Saved / Saving… / Couldn't save + Retry in the top bar, Cmd/Ctrl+S to save now and unsent edits sent when the page is left. A read-only mode shows the same page with nothing to edit; slots for a breadcrumb, actions and a meta line.",
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/doc-editor.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/doc-editor.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -32,7 +32,7 @@
32
32
  "path": "registry/blocks/doc-page/README.md",
33
33
  "type": "registry:file",
34
34
  "target": "@components/makersclaw/blocks/doc-page/README.md",
35
- "content": "# DocPage\n\nOne Notion-style page: an emoji icon, a big title, and the rich text\nunder it. The text is the `doc-editor` block (type `/` for headings,\nlists, checklists, tables; `@` mentions a person). Edits save themselves\nthrough `onSave` a moment after typing stops; the top bar says **Saved**,\n**Saving…** or **Couldn't save** with a Retry. Cmd/Ctrl+S saves at once.\nRead-only shows the same page with nothing to edit. It is the wiki page:\n`knowledge-base` puts a page tree beside it.\n\n```jsx\nimport { DocPage } from \"../components/blocks/doc-page\"; // a Makersclaw app (\"./blocks/doc-page\" from components/)\nimport { DocPage } from \"@/components/makersclaw/blocks/doc-page\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the page in the server surface (README §4). `makersclaw add\ndoc-page` adds `doc-editor` too (and its BlockNote packages: run `npm\ninstall` after).\n\n## The data\n\nA page is `{ id, title, icon, doc }`:\n\n- `id` — the page's key.\n- `title` — plain text, one line (it wraps on screen).\n- `icon` — an emoji, or `null` for none.\n- `doc` — the body as BlockNote JSON (see the doc-editor README): store\n it as it comes, in a `jsonb` column.\n\n`onSave` receives the whole page, every time.\n\n## Props\n\n- `id` — which page this is. The page reads `title`, `icon` and `doc`\n when it mounts and again whenever `id` changes; in between it owns the\n draft, so a parent re-rendering with older props never throws away\n typing. To show another page, change `id`.\n- `title` — default `\"\"`.\n- `icon` — default `null`.\n- `doc` — default an empty doc.\n- `onSave(page)` — `async ({ id, title, icon, doc }) => ({ ok, error? })`.\n Called `saveDelay` ms after the last edit, one save at a time (edits\n made during a save go out right after it). `{ ok: false, error }` or a\n throw shows \"Couldn't save\" with `error` as the reason and a Retry; the\n edits stay on screen and the next edit tries again. Without `onSave`\n there is no save state (an editor you read from `onChange`).\n- `onChange(page)` — every edit, with the whole page (to update a title\n in a list beside it as it is typed). Not a save.\n- `readOnly` — `false` (default). `true`: the title is a heading, the\n icon a picture, the editor read-only; no save state.\n- `saveDelay` — ms of quiet before a save, default `1000`.\n- `placeholder` — the empty body's hint, default \"Write something, or\n press / for blocks\".\n- `titlePlaceholder` — default \"Untitled\".\n- `icons` — the emoji offered by the icon picker (default 24 common\n ones). `[]` turns picking off (a given `icon` still shows).\n- `meta` — a node under the title: \"Edited by June · 2 days ago\".\n- `breadcrumb` — a node at the left of the top bar (where the page\n sits).\n- `actions` — a node at the right of the top bar: a kit `Menu` of page\n actions, a Share button.\n- `width` — `\"normal\"` (default, a reading column) · `\"wide\"` · `\"full\"`.\n- `autoFocus` — put the caret in the title when it mounts (a page just\n created).\n- `mentions`, `fields`, `slashItems`, `uploadFile` — passed to the\n editor; see the doc-editor README.\n- `className` — layout around it. Never colour.\n\nKeys: Enter or ↓ at the end of the title moves into the body; Cmd/Ctrl+S\nsaves now. A tab closed with an edit unsaved asks first; a page left\n(another `id`, the surface unmounted) sends its unsent edit on the way\nout without waiting for the answer.\n\n## Example\n\n```jsx\n// components/WikiPage.jsx — client\n\"use client\";\nimport { updateRow } from \"../lib/actions\";\nimport { DocPage } from \"./blocks/doc-page\";\n\nexport default function WikiPage({ page, team, canEdit }) {\n return (\n <DocPage\n id={page.id}\n title={page.title}\n icon={page.icon}\n doc={page.body}\n readOnly={!canEdit}\n meta={`Edited by ${page.edited_by_name}`}\n onSave={({ id, title, icon, doc }) =>\n updateRow({ source: \"public.wiki_pages\", id, patch: { title, icon, body: doc } })\n }\n mentions={async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))\n }\n />\n );\n}\n```\n\nFor search or a preview, keep a plain-text copy beside the doc:\n`docToText(doc)` from the doc-editor block, saved in the same `onSave`.\n\n## What it will not do\n\n- Hold more than one page, or a tree of them: that is `knowledge-base`.\n- Collaborate live: saves are last-write-wins, whole page at a time.\n- Keep an edit when the browser is closed without an answer: the tab\n asks before closing while an edit is unsaved; after that it is gone.\n- Cover images, page history, comments or sharing: put your own\n controls in `actions`.\n- Pick any emoji in the world: the grid is `icons`.\n\nA page it cannot be is your own component in `components/`, built on\nthe doc-editor block — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add doc-page` restores\nthem.\n"
35
+ "content": "# DocPage\n\nOne Notion-style page: an emoji icon, a big title, and the rich text\nunder it. The text is the `doc-editor` block (type `/` for headings,\nlists, checklists, tables; `@` mentions a person). Edits save themselves\nthrough `onSave` a moment after typing stops; the top bar says **Saved**,\n**Saving…** or **Couldn't save** with a Retry. Cmd/Ctrl+S saves at once.\nRead-only shows the same page with nothing to edit. It is the wiki page:\n`knowledge-base` puts a page tree beside it.\n\n```jsx\nimport { DocPage } from \"../components/blocks/doc-page\"; // a Makersclaw app (\"./blocks/doc-page\" from components/)\nimport { DocPage } from \"@/components/makersclaw/blocks/doc-page\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the page in the server surface (README §4). `makersclaw add\ndoc-page` adds `doc-editor` too (and its BlockNote packages: run `npm\ninstall` after).\n\n## The data\n\nA page is `{ id, title, icon, doc }`:\n\n- `id` — the page's key.\n- `title` — plain text, one line (it wraps on screen).\n- `icon` — an emoji, or `null` for none.\n- `doc` — the body as BlockNote JSON (see the doc-editor README): store\n it as it comes, in a `jsonb` column.\n\n`onSave` receives the whole page, every time.\n\n## Props\n\n- `id` — which page this is. The page reads `title`, `icon` and `doc`\n when it mounts and again whenever `id` changes; in between it owns the\n draft, so a parent re-rendering with older props never throws away\n typing. To show another page, change `id`.\n- `title` — default `\"\"`.\n- `icon` — default `null`.\n- `doc` — default an empty doc.\n- `onSave(page)` — `async ({ id, title, icon, doc }) => ({ ok, error? })`.\n Called `saveDelay` ms after the last edit, one save at a time (edits\n made during a save go out right after it). `{ ok: false, error }` or a\n throw shows \"Couldn't save\" with `error` as the reason and a Retry; the\n edits stay on screen and the next edit tries again. Without `onSave`\n there is no save state (an editor you read from `onChange`).\n- `onChange(page)` — every edit, with the whole page (to update a title\n in a list beside it as it is typed). Not a save.\n- `readOnly` — `false` (default). `true`: the title is a heading, the\n icon a picture, the editor read-only; no save state.\n- `saveDelay` — ms of quiet before a save, default `1000`.\n- `placeholder` — the empty body's hint, default \"Write something, or\n press / for blocks\".\n- `titlePlaceholder` — default \"Untitled\".\n- `icons` — the emoji offered by the icon picker (default 24 common\n ones). `[]` turns picking off (a given `icon` still shows).\n- `meta` — a node under the title: \"Edited by June · 2 days ago\".\n- `breadcrumb` — a node at the left of the top bar (where the page\n sits).\n- `actions` — a node at the right of the top bar: a kit `Menu` of page\n actions, a Share button.\n- `width` — `\"normal\"` (default, a reading column) · `\"wide\"` · `\"full\"`.\n- `autoFocus` — put the caret in the title when it mounts (a page just\n created).\n- `mentions`, `fields`, `slashItems`, `uploadFile` — passed to the\n editor; see the doc-editor README.\n- `className` — layout around it. Never colour.\n\nKeys: Enter or ↓ at the end of the title moves into the body; Cmd/Ctrl+S\nsaves now. A tab closed with an edit unsaved asks first; a page left\n(another `id`, the surface unmounted) sends its unsent edit on the way\nout without waiting for the answer.\n\n## Example\n\n```jsx\n// surfaces/wiki.jsx — client: one page, read and saved\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow, useRows } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { DocPage } from \"../components/blocks/doc-page\";\n\nexport default function WikiPage() {\n const id = useSearchParams().get(\"row\");\n const { row: page, loading } = useRow({ source: \"wiki_pages\", id });\n const { rows: team } = useRows({ source: \"people\", orderBy: \"name asc\" });\n if (loading || !page) return null;\n return (\n <DocPage\n id={page.id}\n title={page.title}\n icon={page.icon}\n doc={page.body}\n meta={`Edited by ${page.edited_by}`} // a before-update trigger sets edited_by (the app's README §5)\n onSave={({ id, title, icon, doc }) =>\n updateRow({ source: \"wiki_pages\", id, patch: { title, icon, body: doc } })\n }\n mentions={async (q) =>\n team.filter((p) => p.name.toLowerCase().includes(q.toLowerCase())).map((p) => ({ id: p.id, label: p.name }))\n }\n />\n );\n}\n```\n\nThe page reads `title`, `icon` and `doc` when `id` changes and owns the\ndraft in between, so the read after each save never moves the cursor.\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nFor search or a preview, keep a plain-text copy beside the doc:\n`docToText(doc)` from the doc-editor block, saved in the same `onSave`.\n\n## What it will not do\n\n- Hold more than one page, or a tree of them: that is `knowledge-base`.\n- Collaborate live: saves are last-write-wins, whole page at a time.\n- Keep an edit when the browser is closed without an answer: the tab\n asks before closing while an edit is unsaved; after that it is gone.\n- Cover images, page history, comments or sharing: put your own\n controls in `actions`.\n- Pick any emoji in the world: the grid is `icons`.\n\nA page it cannot be is your own component in `components/`, built on\nthe doc-editor block — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add doc-page` restores\nthem.\n"
36
36
  }
37
37
  ],
38
38
  "categories": [
@@ -54,7 +54,7 @@
54
54
  "docs": "Installed to components/makersclaw/blocks/doc-page. Import: import { DocPage } from \"@/components/makersclaw/blocks/doc-page\". Props and an example: components/makersclaw/blocks/doc-page/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
55
55
  "meta": {
56
56
  "makersclaw": {
57
- "version": "0.5.2",
57
+ "version": "0.7.0",
58
58
  "useWhen": "A screen is one document someone reads and writes: a wiki or handbook page, a playbook, a policy, a project page, a one-pager. The knowledge-base block puts a page tree beside it. Not for a short field of rich text inside a form (doc-editor), notes on a record (record-notes) or a comment (comment-box)."
59
59
  }
60
60
  }
@@ -5,7 +5,7 @@
5
5
  "title": "EmailRender",
6
6
  "description": "Email HTML from React (react-email) in the workspace theme: a layout with the 600px column and the inbox preview line, a header brand line, a footer with the address and unsubscribe link, buttons, headings and text, and DocEmail, which draws a doc-editor document as email markup (lists, quotes, code, tables, images, merge fields). Email cannot read CSS variables, so resolveEmailTheme reads the tokens where the editor sits and hands the email plain hex values; renderEmail returns { html, text }, in the browser for a preview and on the server for a send.",
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
  "@react-email/components@1.0.12",
@@ -66,7 +66,7 @@
66
66
  "docs": "Installed to components/makersclaw/blocks/email-render. Import: import { EmailLayout, EmailCard, EmailHeader, EmailFooter, EmailButton, EmailHeading, EmailText, EmailDivider, EMAIL_WIDTH, DocEmail, fieldText, renderEmail, emailPlainText, EMAIL_TOKENS, EMAIL_FALLBACK, resolveEmailTheme, emailColor, emailFontStack, emailThemeKey, luminance, toHex } from \"@/components/makersclaw/blocks/email-render\". Props and an example: components/makersclaw/blocks/email-render/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
67
67
  "meta": {
68
68
  "makersclaw": {
69
- "version": "0.5.2",
69
+ "version": "0.7.0",
70
70
  "useWhen": "Code that must produce an email's HTML: a newsletter, a transactional email (receipt, invite, reset), a digest, a notification, a test send from an editor. newsletter-builder is built on it. Not for writing the email (doc-editor or email-composer), sending it (your sender or the app's server) or a web page (page-builder)."
71
71
  }
72
72
  }
package/r/faq.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "FAQ",
6
6
  "description": "A marketing section of questions and answers: an accordion (one open at a time, or several), a grid of cards with optional icons, or a plain list with every answer shown; the title above or beside them, and an optional \"need more help?\" call to action. Loading and empty states.",
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
  "files": [
@@ -43,7 +43,7 @@
43
43
  "docs": "Installed to components/makersclaw/blocks/faq. Import: import { Faq } from \"@/components/makersclaw/blocks/faq\". Props and an example: components/makersclaw/blocks/faq/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
44
44
  "meta": {
45
45
  "makersclaw": {
46
- "version": "0.5.2",
46
+ "version": "0.7.0",
47
47
  "useWhen": "The questions section of a public page: a landing page, pricing, a help centre's front page, a product page. Not for a searchable help centre with articles and categories, or collapsible settings inside an app (use the kit's Accordion)."
48
48
  }
49
49
  }
@@ -5,7 +5,7 @@
5
5
  "title": "FileUpload",
6
6
  "description": "Files chosen or dropped, each uploaded through your async onUpload(file, { onProgress, signal }) with a progress bar, cancel while it runs, a retry when it fails and a remove once done. Variants: a list of file cards, one file at a time, Uploading/Finished sections, image thumbnails (one or two columns), and a profile picture.",
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
  "files": [
@@ -25,7 +25,7 @@
25
25
  "path": "registry/blocks/file-upload/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/file-upload/README.md",
28
- "content": "# FileUpload\n\nFiles chosen or dropped, each one uploaded through your `onUpload` as\nsoon as it lands. Every file shows its bar while it runs (with Cancel),\nthe reason and a Retry when it fails, and a Remove once it is up. Files\nthe picker refuses (wrong type, too big, too many) are named in a notice.\nThe upload itself is yours: the block never fetches.\n\n```jsx\nimport { FileUpload } from \"../components/blocks/file-upload\"; // a Makersclaw app (\"./blocks/file-upload\" from components/)\nimport { FileUpload } from \"@/components/makersclaw/blocks/file-upload\"; // a shadcn project\n```\n\nA client component. The picker is the kit's `FileDrop` (or `FileInput`\nwith `picker=\"button\"`), so the browser's own file dialog opens and the\nkeyboard works: Tab to the zone, Enter or Space.\n\n## The data\n\nWhat the block tells you about a file (in `onChange` and `onRemove`):\n`{ id, name, size, type, url?, ...whatever your onUpload returned }`.\nNever the `File` and never the block's own state.\n\n## Props\n\n- `onUpload(file, { onProgress, signal })` — upload one `File`. Call\n `onProgress(percent)` (0–100) as it goes; without it the bar sweeps.\n Watch `signal` (an AbortSignal): it aborts when the person cancels or\n replaces the file, and pass it to `fetch`. Return `{ ok: true, url?,\n ...anything }` or `{ ok: false, error }`; a throw counts as failed and\n its message is shown. Without `onUpload` the block is a picker only:\n files count as added at once.\n- `variant` — how the files show, default `\"list\"`:\n - `list` — the drop zone, then a card per file (name, size, state).\n - `single` — one file at a time in a filled panel; a new pick replaces\n it (and aborts its upload). An import, a contract.\n - `sections` — a one-row drop zone, then the files grouped under\n \"Ready\", \"Uploading\" (failed ones stay here with Retry) and\n \"Finished\".\n - `preview` — cards with the picture itself for images. `columns={2}`\n lays them two across from 640px.\n - `avatar` — one picture of a person or a company: the current one (or\n the initials), Upload picture, Remove. Takes `value` (the current\n URL) and `name`; `onChange(url | null)`.\n- `onRemove(file)` — delete an uploaded file from storage; return\n `{ ok }` like `onUpload`. The row shows a spinner, and on failure stays\n with the reason. Without it Remove only drops the row. Removing a file\n that is still queued, uploading or failed never calls it.\n- `onChange(files)` — the uploaded files, every time that list changes.\n Save their ids or URLs with the record here.\n- `defaultFiles` — what shows at first: files already uploaded\n (`[{ id, name, size, type, url? }]`). An entry that is `{ id?, file }`\n (a `File` you got from a paste or a drop elsewhere on the page) starts\n uploading on mount.\n- `accept` — as the input's: `\"image/*\"`, `\".csv,.xlsx\"`. The picker\n greys out the rest; a drop of the rest is refused and named.\n- `multiple` — default `true` (`false` for `single` and `avatar`).\n- `maxSize` — bytes, per file. Bigger files are refused and named.\n- `maxFiles` — how many in all; the zone says so when it is full.\n- `autoUpload` — default `true`. `false` holds new files as \"Ready to\n upload\" with Clear and an \"Upload N files\" button, for a form that\n sends its attachments on submit.\n- `picker` — `\"drop\"` (default: the drop zone) · `\"button\"` (a\n `FileInput` button and the hint beside it, for a tight form row).\n- `label` — the zone's line (default \"Drop files here or browse\"); in\n `avatar`, the name beside the picture (\"Profile picture\").\n- `hint` — the muted line: default built from `accept` and `maxSize`\n (\"Images · up to 4 MB\").\n- `buttonLabel` — `picker=\"button\"`'s text, default \"Add files\".\n- `uploadLabel` — the queue's submit text, or `avatar`'s button text.\n- `columns` — `1` (default) or `2`, for `list` and `preview`.\n- `disabled` — no picking, no retry, no remove.\n- `aria-label` — the drop zone's name when `label` is not enough.\n- `className` — layout around it. Never colour.\n\n`formatSize(bytes)` (\"3.1 MB\") and `fileIcon(type, name)` (the kit icon\nfor a file) are exported too, for your own file rows.\n\n## Example\n\n```jsx\n// components/DealFiles.jsx — client\n\"use client\";\nimport { FileUpload } from \"./blocks/file-upload\";\n\n// Your storage route answers a PUT with { id, url }. XMLHttpRequest, not\n// fetch, because only it reports upload progress.\nfunction put(file, { onProgress, signal }) {\n return new Promise((resolve) => {\n const xhr = new XMLHttpRequest();\n xhr.open(\"PUT\", `/api/files?name=${encodeURIComponent(file.name)}`);\n xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);\n xhr.onload = () =>\n xhr.status < 300 ? resolve({ ok: true, ...JSON.parse(xhr.responseText) }) : resolve({ ok: false, error: `Upload failed (${xhr.status}).` });\n xhr.onerror = () => resolve({ ok: false, error: \"The connection dropped. Try again.\" });\n signal.addEventListener(\"abort\", () => xhr.abort());\n xhr.send(file);\n });\n}\n\nexport default function DealFiles({ deal, onSaveFiles }) {\n return (\n <FileUpload\n defaultFiles={deal.files}\n onUpload={put}\n onRemove={(f) => fetch(`/api/files/${f.id}`, { method: \"DELETE\" }).then((r) => ({ ok: r.ok }))}\n onChange={(files) => onSaveFiles(files.map((f) => ({ id: f.id, name: f.name, size: f.size, url: f.url })))}\n maxSize={50 * 1024 * 1024}\n />\n );\n}\n```\n\nOne spreadsheet to import, and a profile picture:\n\n```jsx\n<FileUpload variant=\"single\" accept=\".csv,.xlsx\" maxSize={10 * 1024 * 1024} onUpload={put} onChange={([f]) => setImport(f ?? null)} />\n\n<FileUpload variant=\"avatar\" name={me.name} value={me.avatarUrl} onUpload={put} onChange={(url) => saveProfile({ avatar_url: url })} />\n```\n\n## What it will not do\n\n- Store anything: `onUpload` is yours (a storage bucket, a signed URL).\n- Crop or resize a picture, or read a spreadsheet's rows: do that in\n `onUpload` or after `onChange`.\n- Reorder files, or keep a list you control from outside: `defaultFiles`\n is where it starts; remount it (a `key`) to start again.\n- Show a chat message's attachments: that is the chat block's composer.\n\nA file picker it cannot be is your own component in `components/`,\nbuilt on the kit's `FileInput` or `FileDrop` — not an edit to this\nblock. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add file-upload` restores them.\n\n## Credits\n\nPorted from blocks.so (https://blocks.so, file-upload 01, 03–06, MIT) and Creative Tim UI (https://github.com/creativetimofficial/ui, modals-05 and account-avatar-upload-01, MIT), rewritten on the Makersclaw kit.\n"
28
+ "content": "# FileUpload\n\nFiles chosen or dropped, each one uploaded through your `onUpload` as\nsoon as it lands. Every file shows its bar while it runs (with Cancel),\nthe reason and a Retry when it fails, and a Remove once it is up. Files\nthe picker refuses (wrong type, too big, too many) are named in a notice.\nThe upload itself is yours: the block never fetches.\n\n```jsx\nimport { FileUpload } from \"../components/blocks/file-upload\"; // a Makersclaw app (\"./blocks/file-upload\" from components/)\nimport { FileUpload } from \"@/components/makersclaw/blocks/file-upload\"; // a shadcn project\n```\n\nA client component. The picker is the kit's `FileDrop` (or `FileInput`\nwith `picker=\"button\"`), so the browser's own file dialog opens and the\nkeyboard works: Tab to the zone, Enter or Space.\n\n## The data\n\nWhat the block tells you about a file (in `onChange` and `onRemove`):\n`{ id, name, size, type, url?, ...whatever your onUpload returned }`.\nNever the `File` and never the block's own state.\n\n## Props\n\n- `onUpload(file, { onProgress, signal })` — upload one `File`. Call\n `onProgress(percent)` (0–100) as it goes; without it the bar sweeps.\n Watch `signal` (an AbortSignal): it aborts when the person cancels or\n replaces the file, and pass it to `fetch`. Return `{ ok: true, url?,\n ...anything }` or `{ ok: false, error }`; a throw counts as failed and\n its message is shown. Without `onUpload` the block is a picker only:\n files count as added at once.\n- `variant` — how the files show, default `\"list\"`:\n - `list` — the drop zone, then a card per file (name, size, state).\n - `single` — one file at a time in a filled panel; a new pick replaces\n it (and aborts its upload). An import, a contract.\n - `sections` — a one-row drop zone, then the files grouped under\n \"Ready\", \"Uploading\" (failed ones stay here with Retry) and\n \"Finished\".\n - `preview` — cards with the picture itself for images. `columns={2}`\n lays them two across from 640px.\n - `avatar` — one picture of a person or a company: the current one (or\n the initials), Upload picture, Remove. Takes `value` (the current\n URL) and `name`; `onChange(url | null)`.\n- `onRemove(file)` — delete an uploaded file from storage; return\n `{ ok }` like `onUpload`. The row shows a spinner, and on failure stays\n with the reason. Without it Remove only drops the row. Removing a file\n that is still queued, uploading or failed never calls it.\n- `onChange(files)` — the uploaded files, every time that list changes.\n Save their ids or URLs with the record here.\n- `defaultFiles` — what shows at first: files already uploaded\n (`[{ id, name, size, type, url? }]`). An entry that is `{ id?, file }`\n (a `File` you got from a paste or a drop elsewhere on the page) starts\n uploading on mount.\n- `accept` — as the input's: `\"image/*\"`, `\".csv,.xlsx\"`. The picker\n greys out the rest; a drop of the rest is refused and named.\n- `multiple` — default `true` (`false` for `single` and `avatar`).\n- `maxSize` — bytes, per file. Bigger files are refused and named.\n- `maxFiles` — how many in all; the zone says so when it is full.\n- `autoUpload` — default `true`. `false` holds new files as \"Ready to\n upload\" with Clear and an \"Upload N files\" button, for a form that\n sends its attachments on submit.\n- `picker` — `\"drop\"` (default: the drop zone) · `\"button\"` (a\n `FileInput` button and the hint beside it, for a tight form row).\n- `label` — the zone's line (default \"Drop files here or browse\"); in\n `avatar`, the name beside the picture (\"Profile picture\").\n- `hint` — the muted line: default built from `accept` and `maxSize`\n (\"Images · up to 4 MB\").\n- `buttonLabel` — `picker=\"button\"`'s text, default \"Add files\".\n- `uploadLabel` — the queue's submit text, or `avatar`'s button text.\n- `columns` — `1` (default) or `2`, for `list` and `preview`.\n- `disabled` — no picking, no retry, no remove.\n- `aria-label` — the drop zone's name when `label` is not enough.\n- `className` — layout around it. Never colour.\n\n`formatSize(bytes)` (\"3.1 MB\") and `fileIcon(type, name)` (the kit icon\nfor a file) are exported too, for your own file rows.\n\n## Example\n\n```jsx\n// components/DealFiles.jsx — client, in a server app\n\"use client\";\nimport { FileUpload } from \"./blocks/file-upload\";\n\n// Your storage route answers a PUT with { id, url }. XMLHttpRequest, not\n// fetch, because only it reports upload progress.\nfunction put(file, { onProgress, signal }) {\n return new Promise((resolve) => {\n const xhr = new XMLHttpRequest();\n xhr.open(\"PUT\", `/api/files?name=${encodeURIComponent(file.name)}`);\n xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100);\n xhr.onload = () =>\n xhr.status < 300 ? resolve({ ok: true, ...JSON.parse(xhr.responseText) }) : resolve({ ok: false, error: `Upload failed (${xhr.status}).` });\n xhr.onerror = () => resolve({ ok: false, error: \"The connection dropped. Try again.\" });\n signal.addEventListener(\"abort\", () => xhr.abort());\n xhr.send(file);\n });\n}\n\nexport default function DealFiles({ deal, onSaveFiles }) {\n return (\n <FileUpload\n defaultFiles={deal.files}\n onUpload={put}\n onRemove={(f) => fetch(`/api/files/${f.id}`, { method: \"DELETE\" }).then((r) => ({ ok: r.ok }))}\n onChange={(files) => onSaveFiles(files.map((f) => ({ id: f.id, name: f.name, size: f.size, url: f.url })))}\n maxSize={50 * 1024 * 1024}\n />\n );\n}\n```\n\nA server app's only. `put` needs a route of the app's own to take the\nfile (`/api/files` here), and a browser app has none: the data door\ntakes no files until uploads have a home. In a browser app, leave\n`onUpload` out and use the block as a picker: read the file in the\nbrowser, as csv-import does.\n\nOne spreadsheet to import, and a profile picture:\n\n```jsx\n<FileUpload variant=\"single\" accept=\".csv,.xlsx\" maxSize={10 * 1024 * 1024} onUpload={put} onChange={([f]) => setImport(f ?? null)} />\n\n<FileUpload variant=\"avatar\" name={me.name} value={me.avatarUrl} onUpload={put} onChange={(url) => saveProfile({ avatar_url: url })} />\n```\n\n## What it will not do\n\n- Store anything: `onUpload` is yours (a storage bucket, a signed URL).\n- Crop or resize a picture, or read a spreadsheet's rows: do that in\n `onUpload` or after `onChange`.\n- Reorder files, or keep a list you control from outside: `defaultFiles`\n is where it starts; remount it (a `key`) to start again.\n- Show a chat message's attachments: that is the chat block's composer.\n\nA file picker it cannot be is your own component in `components/`,\nbuilt on the kit's `FileInput` or `FileDrop` — not an edit to this\nblock. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add file-upload` restores them.\n\n## Credits\n\nPorted from blocks.so (https://blocks.so, file-upload 01, 03–06, MIT) and Creative Tim UI (https://github.com/creativetimofficial/ui, modals-05 and account-avatar-upload-01, MIT), rewritten on the Makersclaw kit.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -47,7 +47,7 @@
47
47
  "docs": "Installed to components/makersclaw/blocks/file-upload. Import: import { FileUpload, formatSize, fileIcon } from \"@/components/makersclaw/blocks/file-upload\". Props and an example: components/makersclaw/blocks/file-upload/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
48
48
  "meta": {
49
49
  "makersclaw": {
50
- "version": "0.5.2",
50
+ "version": "0.7.0",
51
51
  "useWhen": "A form or page takes files the app stores: attachments on a record, a CSV to import, product photos, a logo, a profile picture. Not for showing files already stored with no way to add (a table or list), and not for a chat message's attachments (the chat block's composer has those)."
52
52
  }
53
53
  }