makersclaw 0.6.0 → 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 (141) hide show
  1. package/README.md +48 -8
  2. package/package.json +1 -1
  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 +31 -4
  117. package/src/install.mjs +263 -21
  118. package/src/registry.mjs +78 -1
  119. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  120. package/templates/next/browser/app/layout.jsx +94 -0
  121. package/templates/next/browser/app/page.jsx +60 -0
  122. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  123. package/templates/next/browser/lib/actions.js +16 -0
  124. package/templates/next/browser/lib/data.js +43 -0
  125. package/templates/next/files/README.md +810 -79
  126. package/templates/next/files/app/frame.js +128 -4
  127. package/templates/next/files/check/run.mjs +1438 -41
  128. package/templates/next/files/components/ui/item.jsx +2 -2
  129. package/templates/next/files/components/ui/notice.jsx +1 -1
  130. package/templates/next/files/components/ui/separator.jsx +1 -1
  131. package/templates/next/files/lib/actions.js +170 -51
  132. package/templates/next/files/lib/brain-server.js +96 -0
  133. package/templates/next/files/lib/brain.js +413 -0
  134. package/templates/next/files/lib/claw.js +11 -0
  135. package/templates/next/files/lib/data.js +119 -29
  136. package/templates/next/files/lib/db.js +97 -17
  137. package/templates/next/files/lib/door.js +680 -0
  138. package/templates/next/files/next.config.mjs +36 -2
  139. package/templates/next/files/surfaces.js +3 -2
  140. package/templates/next/template.json +9 -3
  141. package/templates/next/files/schema.sql +0 -22
package/README.md CHANGED
@@ -53,7 +53,12 @@ them: [makersclaw.com](https://makersclaw.com).
53
53
  every app. `add <component>` only restores one.
54
54
  - **Blocks** — the bigger pieces a surface is assembled from: a board, a
55
55
  job panel, and more to come. No app has one until `add` puts it in
56
- `components/blocks/<name>/`. `search` finds them by what they do.
56
+ `components/blocks/<name>/`. `search` finds them by what they do. A
57
+ block takes its rows as props and hands saves out as callbacks; the
58
+ surface reads and writes. So no block calls the data door (`_claw`,
59
+ `clawUrl`, `claw(`) or imports the app's `lib/`, and one block works
60
+ in a server app, a browser app and a shadcn project alike. `npm test`
61
+ refuses a block that does.
57
62
 
58
63
  Everything this CLI lays is **fixed**: pinned by sha256 in
59
64
  `check/pins.json`, and `npm run check` refuses a change. An app composes
@@ -64,6 +69,8 @@ app's own component in `components/`. Fixes reach apps through `update`.
64
69
 
65
70
  ```
66
71
  npx makersclaw init [dir] [-t next] lay the template: base files, the kit, the check
72
+ [--tables own|public] where its tables live (new apps: own; never changes an app's)
73
+ [--mode browser|server] a browser app or a server app (new apps: server; never changes an app's)
67
74
  npx makersclaw add <name...> install blocks, or restore a kit component
68
75
  npx makersclaw search <words...> find blocks and components by what they do
69
76
  npx makersclaw view <name> one item: props, data, an example
@@ -72,22 +79,51 @@ npx makersclaw remove <block...> uninstall blocks no file imports
72
79
  npx makersclaw diff what update would change in this app
73
80
  npx makersclaw update [--major] lay this version's fixed files and blocks
74
81
  npx makersclaw install the app's packages: pnpm, from pnpm-lock.yaml
82
+ npx makersclaw mode <browser|server> move the app to the browser or to a server, and say what its own code must change
75
83
  ```
76
84
 
77
85
  `--cwd <dir>` names the app (default: the current folder); `--json` prints
78
86
  one object instead of text.
79
87
 
80
88
  An app records what it holds in two files: `makersclaw.json` (the template
81
- and version, and each block with the version it came from) and
82
- `check/pins.json` (the hash of every fixed file). A third says which
83
- package versions it installs: `pnpm-lock.yaml`, cut by `init`, `add` and
84
- `update` from the lock this version ships, so every app on one version
85
- resolves every package the same way. `update` moves an app to
89
+ and version, each block with the version it came from, and where its
90
+ tables live) and `check/pins.json` (the hash of every fixed file). A third
91
+ says which package versions it installs: `pnpm-lock.yaml`, cut by `init`,
92
+ `add` and `update` from the lock this version ships, so every app on one
93
+ version resolves every package the same way. `update` moves an app to
86
94
  this version's fixed files and blocks and never touches its own files —
87
95
  the shell, the surfaces, `components/*.jsx`. Within `0.x` a new minor is a
88
96
  new contract: `update` refuses it until `--major`, because screens written
89
97
  against the old one may need rebuilding.
90
98
 
99
+ `tables` in `makersclaw.json` is `"own"` (the app's own schema; in
100
+ Makersclaw, `app_<name>`) or `"public"` (the schema every app shared
101
+ before 0.7.0; a manifest a lay wrote with no `tables` key is one of
102
+ those). `init` gives a new app `"own"` unless `--tables` says otherwise;
103
+ a new app's manifest may already hold the platform's `brain`, but no lay
104
+ wrote it (it has no `version`). `init` never changes an app's: an app's
105
+ tables move from public to their own schema only with its workspace, so
106
+ a differing `--tables` is refused. Every command keeps the rest of
107
+ `makersclaw.json` (the platform's `brain` declaration, any key it does
108
+ not know), through `init --force` and `update` too.
109
+
110
+ `mode` in `makersclaw.json` says where the app runs. `"server"` (an
111
+ absent key is one) is a server app: a Next server of its own. `"browser"`
112
+ is a browser app: its code runs in the member's browser, its pages are
113
+ files built once that the platform serves, and every read and write
114
+ goes through the platform's data door (people add, edit and delete as
115
+ in any app). Only `"browser"`, exactly, is a browser app. Each mode lays
116
+ its own fixed files: a browser app has its own copy of six
117
+ (`templates/next/browser/`) and leaves out the server's four
118
+ (`proxy.js`, `app/claw-alive/route.js`, `lib/db.js`,
119
+ `lib/brain-server.js`), and the pins follow. `init --mode` sets it on a
120
+ new manifest only; the default stays server, since outside Makersclaw
121
+ no door answers a browser app. `--mode browser` writes `tables: "own"`
122
+ too: the door serves an app's own schema and nothing else. An app
123
+ changes mode only with `makersclaw mode`, which refuses browser unless
124
+ its tables are own, lays that mode's files, removes the other's, and
125
+ prints what the app's own code must change.
126
+
91
127
  `install` runs pnpm with `node-linker=hoisted` (npm's flat node_modules,
92
128
  every file hard-linked from one store), frozen when the lock names
93
129
  exactly package.json's dependencies. It takes pnpm from
@@ -104,7 +140,8 @@ own records: an install in the copy never writes the original's.
104
140
 
105
141
  ```
106
142
  templates/next/template.json which files are fixed, docs, starters or merged; the kit's entries
107
- templates/next/files/ the app — what `init` lays
143
+ templates/next/files/ the app — what `init` lays (a server app)
144
+ templates/next/browser/ a browser app's own copy of six fixed files (template.json `modes.browser`)
108
145
  templates/next/pnpm-lock.yaml the release lock (`npm run lock`): every app's lock is cut from it
109
146
  registry/blocks/<name>/ block.json, the files `add` lays, README.md (what view/add print), demo.jsx
110
147
  src/ the CLI (no dependencies); src/shadcn.mjs builds the shadcn registry
@@ -118,7 +155,10 @@ test/ npm test · npm run check:regress · npm run test
118
155
  a surface, `makersclaw install` (frozen, from the release lock), the kit
119
156
  rendered by React with its warnings on
120
157
  (`test/kit-render.test.mjs`, which `npm test` skips), `npm run check`
121
- (build, start, every surface).
158
+ (build, start, every surface). Then a browser app (`init --mode
159
+ browser`) with every free block's demo as a client surface, checked
160
+ whole with a base path: its export built in a copy, served, every
161
+ surface loaded.
122
162
  - `npm run build:registry` — `r/`, the shadcn registry (`--base <url>
123
163
  --out <dir>` to serve it locally for a test).
124
164
  - `npm run lock` — the release lock, after a dependency of the template or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "makersclaw",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "The Makersclaw app template, its kit and its blocks: init an app, add the blocks a surface needs, search the registry.",
5
5
  "type": "module",
6
6
  "bin": {
package/r/annotate.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Annotate",
6
6
  "description": "Mark up a screenshot: drop, pick or paste one, then draw arrows, boxes, highlights, redaction bars and numbered notes on it (Excalidraw underneath, via sketch-canvas). A side list holds the words of each numbered note; a click on a note's number shows its pin. Starter templates (bug report, design review, walkthrough) on a fresh screenshot. The annotation is plain JSON saved through onChange / an async onSave; onSubmit hands over a PNG of the marked-up screenshot with the notes and the sketch.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/sketch-canvas.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/sketch-canvas.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -32,7 +32,7 @@
32
32
  "path": "registry/blocks/annotate/README.md",
33
33
  "type": "registry:file",
34
34
  "target": "@components/makersclaw/blocks/annotate/README.md",
35
- "content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n\"use client\";\nimport { Annotate } from \"./blocks/annotate\";\nimport { insertRow, updateRow } from \"../lib/actions\";\n\nexport default function Feedback({ ticket }) {\n return (\n <Annotate\n title=\"What should change?\"\n defaultValue={ticket.annotation} // jsonb, may be null\n onSave={(annotation) => updateRow({ source: \"public.tickets\", id: ticket.id, patch: { annotation } })}\n onSubmit={({ notes, text }) => // image: a PNG Blob, to upload if you keep it\n insertRow({ source: \"public.bug_reports\", values: { ticket_id: ticket.id, summary: text, note_count: notes.length } })\n }\n submitLabel=\"File the bug\"\n className=\"h-dvh\"\n />\n );\n}\n```\n\n## What it will not do\n\n- Crop, rotate or resize the screenshot. Replace swaps it for another;\n the marks stay where they were.\n- Annotate several screenshots at once: one image per annotation (keep a\n list of annotations for a series).\n- Renumber pins after a delete — numbers stay what they were.\n- Blur: \"hide\" is a solid bar drawn over the image. The original pixels\n are still in `image.src`; send the exported PNG, not the source, when\n what is hidden must not leave.\n- Take a screenshot for you: the browser cannot capture the page without\n asking; the person drops or pastes one.\n- Collaborate live: saves are last-write-wins.\n\nA markup tool it cannot be is your own component in `components/` —\nnot an edit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add annotate` restores them.\n"
35
+ "content": "# Annotate\n\nMark up a screenshot and say what each mark means. Drop a screenshot on\nit, pick one, or paste one (Cmd/Ctrl+V). It lies locked at the back of the\ncanvas, and on top you draw: numbered notes (pins, <kbd>N</kbd>), arrows,\nboxes, ellipses, freehand, text, a highlight band, and a solid bar to hide\nsomething private. Every numbered pin has a row in the Notes list beside\nthe canvas, where its words are typed; a click on a row's number shows its\npin. A fresh screenshot offers three starters — Bug report, Design review,\nWalkthrough — that lay pins (and a box or arrows) with prompts in their\nrows. A top bar holds the title, the note count, the save state, Replace,\nStart over and the send button.\n\nIt is `sketch-canvas` underneath (Excalidraw, in the workspace theme): its\ntool rail, selection toolbar, zoom, undo and PNG export.\n\n```jsx\nimport { Annotate, annotationToText } from \"../components/blocks/annotate\"; // a Makersclaw app (\"./blocks/annotate\" from components/)\nimport { Annotate, annotationToText } from \"@/components/makersclaw/blocks/annotate\"; // a shadcn project\n```\n\n`Annotate` is a client component: render it from a `\"use client\"`\ncomponent. The helpers are plain functions and work on the server.\n`makersclaw add annotate` adds `sketch-canvas` with it (and Excalidraw:\nrun `npm install` after).\n\n## The data\n\nAn annotation is plain JSON. Store it as it comes out of `onChange` /\n`onSave` (a `jsonb` column).\n\n```js\n{\n type: \"annotation\",\n version: 1,\n image: { src: \"data:image/png;base64,…\", width: 1280, height: 800 }, // or an https URL; null before one is added\n sketch: { type: \"sketch\", version: 1, elements: [ … ], files: {}, appState: {} }, // sketch-canvas's value: the marks\n notes: [\n { n: 1, text: \"The total is wrong here\" },\n { n: 2, text: \"\", hint: \"What did you expect instead?\" }, // hint: a starter's prompt, shown while empty\n ],\n}\n```\n\n- **A pin is the truth for which notes exist.** A pin is an ellipse in the\n sketch whose `customData.note` is its number, with the number as its\n label. `notes` lists one entry per pin on the sketch, by number. Delete\n a pin on the canvas and its entry is gone from the next `onChange`; the\n words stay in memory, so an undo brings both back.\n- **Numbers are stable.** Deleting 2 of 1, 2, 3 leaves 1 and 3, and the\n next pin is 4. A pin duplicated (Cmd+D) or pasted gets the next free\n number.\n- **The screenshot is a data URL** unless you pass `uploadImage`. It is\n scaled down to `maxImageSize` on its long side first (1600 px: about\n 0.5–2 MB as a PNG). To keep images out of the row, upload the file and\n store the URL (see `uploadImage`).\n- Colours in the marks are theme tokens (see sketch-canvas's README): a\n saved annotation repaints with the theme.\n\n## Props\n\n- `value` — the annotation, controlled. A new `value` that is not what the\n block last sent loads it (another record). Pass back exactly what\n `onChange` gave you and nothing resets.\n- `defaultValue` — the starting annotation, uncontrolled. `{ image }`\n alone opens a screenshot with nothing on it. With neither, the block\n starts at the drop zone.\n- `onChange(annotation)` — the whole annotation after an edit settles: a\n mark (250 ms after the canvas goes quiet), a note's words (500 ms after\n typing stops), a new screenshot (at once). Not called for pan, zoom or\n selection.\n- `onSave(annotation)` — `async`, returns `{ ok: true }` or\n `{ ok: false, error? }`. Called right after `onChange`. The top bar\n shows \"Saving…\", \"Saved\", or the error with Retry. A throw counts as a\n failure.\n- `onSubmit({ image, notes, sketch, screenshot, text })` — `async`,\n `{ ok }`. Without it there is no send button. `image` is a PNG `Blob`\n of the screenshot with the marks (2× scale); `notes` is\n `[{ n, text, hint?, x, y }]` (x, y: the pin's centre in screenshot\n pixels); `sketch` the marks; `screenshot` the `{ src, width, height }`\n underneath; `text` is `annotationToText` of it all. The bar shows\n \"Sent\" or the error.\n- `submitLabel` — the send button's words (default \"Send\"): \"File the\n bug\", \"Send to support\", \"Ask the agent\".\n- `uploadImage(file)` — `async`, returns `{ ok: true, src }` or\n `{ ok: false, error? }`. With it, a dropped or picked screenshot is\n uploaded as it is and `image.src` is the URL you return (same origin,\n or `exportImage` cannot read it back). Without it, the file becomes a\n data URL, scaled to `maxImageSize`.\n- `maxImageSize` — px on the long side a screenshot is scaled down to when\n it becomes a data URL (default 1600; `0` keeps it as it is).\n- `title` — the top bar's heading and the region's name (default\n \"Annotate a screenshot\").\n- `readOnly` — `false` (default). `true`: the marks to look at (pan and\n zoom), the notes as text; no rail, no drop, no send.\n- `tone` — the pins' colour, a token: `destructive` (default), `primary`,\n `warning`, `success`, `info`, `chart-2` … `chart-5`.\n- `tools` — the canvas tools in the rail (sketch-canvas ids). Default\n `ANNOTATE_TOOLS`: select, pan, arrow, rectangle, ellipse, draw, text,\n eraser. The three annotate stamps (numbered note, highlight, hide) always\n follow them.\n- `canvasRef` — a ref (`useSketchCanvas()` from sketch-canvas) that\n receives the canvas's handle: `exportImage()`, `getScene()`,\n `zoomToFit()`…\n- `label` — the region's accessible name (default: `title`).\n- `className` — layout: width, margin, height. From 768 px the default\n height is `h-[36rem]` (canvas and notes side by side); a height class of\n yours replaces it. Below that the notes stack under a 26rem canvas.\n Never colour.\n\n### Keys\n\nOn the canvas: <kbd>N</kbd> a numbered note (click to place it; Enter\ndrops it in the middle), <kbd>A</kbd> arrow, <kbd>R</kbd> rectangle,\n<kbd>O</kbd> ellipse, <kbd>P</kbd> draw, <kbd>T</kbd> text, <kbd>E</kbd>\neraser, <kbd>V</kbd> select, <kbd>H</kbd> pan, Delete, Cmd/Ctrl+D, Cmd/Ctrl+Z,\nShift+1 fit. \"Add note\" in the Notes list drops a pin in the middle of the\nview without the mouse.\n\n### Helpers\n\nPlain functions; they take an annotation (or a sketch) and never throw on\na partial one:\n\n- `emptyAnnotation()`, `toAnnotation(value)` — the shape above.\n- `annotationNotes(sketch, notes)` — `[{ n, text, hint?, x, y }]`: one per\n pin, by number.\n- `annotationToText(annotation)` — the words, for an agent or a reader:\n `\"1. The total is wrong here\\n2. …\"`, then any free text drawn on it\n under \"Also written on it:\".\n- `noteMarkers(sketch)` — the pins: `[{ n, id, x, y, width, height }]`.\n- `nextNoteNumber(sketch)` — the number the next pin gets.\n- `markerSkeleton(n, { size, tone })` — a pin as sketch-canvas skeletons,\n for `canvasRef.current.insertElements` (a pin you place from code).\n- `ANNOTATE_TEMPLATES`, `ANNOTATE_TOOLS` — the starters and the default\n tools.\n\n## Example\n\nA feedback widget that saves itself and files a ticket:\n\n```jsx\n// surfaces/feedback.jsx — client: one ticket, annotated\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { insertRow, updateRow } from \"../lib/actions\";\nimport { Annotate } from \"../components/blocks/annotate\";\n\nexport default function Feedback() {\n const id = useSearchParams().get(\"row\");\n const { row: ticket, loading } = useRow({ source: \"tickets\", id });\n if (loading || !ticket) return null;\n return (\n <Annotate\n key={ticket.id}\n title=\"What should change?\"\n defaultValue={ticket.annotation} // jsonb, may be null; read once per ticket\n onSave={(annotation) => updateRow({ source: \"tickets\", id: ticket.id, patch: { annotation } })}\n onSubmit={({ notes, text }) => // image: a PNG Blob, to upload if you keep it\n insertRow({ source: \"bug_reports\", values: { ticket_id: ticket.id, summary: text, note_count: notes.length } })\n }\n submitLabel=\"File the bug\"\n className=\"h-dvh\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Crop, rotate or resize the screenshot. Replace swaps it for another;\n the marks stay where they were.\n- Annotate several screenshots at once: one image per annotation (keep a\n list of annotations for a series).\n- Renumber pins after a delete — numbers stay what they were.\n- Blur: \"hide\" is a solid bar drawn over the image. The original pixels\n are still in `image.src`; send the exported PNG, not the source, when\n what is hidden must not leave.\n- Take a screenshot for you: the browser cannot capture the page without\n asking; the person drops or pastes one.\n- Collaborate live: saves are last-write-wins.\n\nA markup tool it cannot be is your own component in `components/` —\nnot an edit to this block. Its files are fixed: `npm run check` refuses a\nchange, and `makersclaw add annotate` restores them.\n"
36
36
  }
37
37
  ],
38
38
  "categories": [
@@ -55,7 +55,7 @@
55
55
  "docs": "Installed to components/makersclaw/blocks/annotate. Import: import { Annotate, ANNOTATE_TOOLS, ANNOTATE_TEMPLATES, emptyAnnotation, toAnnotation, annotationNotes, annotationToText, noteMarkers, nextNoteNumber, markerSkeleton } from \"@/components/makersclaw/blocks/annotate\". Props and an example: components/makersclaw/blocks/annotate/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
56
56
  "meta": {
57
57
  "makersclaw": {
58
- "version": "0.6.0",
58
+ "version": "0.7.0",
59
59
  "useWhen": "Someone needs to point at parts of a screen and say something about each: a bug report, design feedback, a support question, a walkthrough, a request to an agent about what to change. Not a blank drawing board (whiteboard or sketch-canvas), not a wireframe (sketch-to-app), not an image upload alone (file-upload)."
60
60
  }
61
61
  }
@@ -5,7 +5,7 @@
5
5
  "title": "ApprovalRequest",
6
6
  "description": "One request waiting on a decision: what is asked for as label/value facts (an amount in large type), who asked and when, why, a status badge, and Approve / Reject (reject asks for a reason). The decision goes out through async onApprove / onReject; the block shows it working, a failure, then the outcome in place of the buttons. A card alone, or a page of detail sections beside the card.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/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/approval-request/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/approval-request/README.md",
28
- "content": "# ApprovalRequest\n\nOne request waiting on a decision: what is asked for (label and value\nfacts, the amount in large type), who asked and when, why, and Approve /\nReject. Reject asks for a reason the requester will see. The decision\ngoes out through your callbacks; while one runs its button spins, when it\nfails the card says why and the buttons stay, and once it lands the card\nshows the outcome (\"Approved by you · 23 September 2026\") in their place.\n\n```jsx\nimport { ApprovalRequest } from \"../components/blocks/approval-request\"; // a Makersclaw app (\"./blocks/approval-request\" from components/)\nimport { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\"; // a shadcn project\n```\n\nA client component. Load the request in the server surface (README §4)\nand pass its fields in.\n\n## The data\n\n- `details` — the card's facts, in order:\n `[{ label, value, icon?, emphasis? }]`. `value` is a string or a node;\n `icon` is a kit `Icon` name; `emphasis` sets the value large (the\n amount).\n- `sections` — `variant=\"page\"` only, the detail beside the card:\n `[{ title, fields: [{ label, value, emphasis? }] }]`.\n- `requester` — `{ name, role?, src? }` (`src` a picture URL).\n\n## Props\n\n- `title` — what is asked for (\"Linear Business\", \"Refund for order\n 20931\"). Without it (and not `loading`) the block shows `emptyLabel`.\n- `onApprove()` — save the yes. Return `{ ok: true }` or\n `{ ok: false, error }`; a throw counts as failed and its message shows.\n- `onReject(reason)` — save the no, with the reason typed (a trimmed\n string, \"\" when `reasonRequired` is off). Same return as `onApprove`.\n Leave either out and its button is not drawn.\n- `variant` — `\"card\"` (default: the card alone, with the requester, the\n date and the reason why) or `\"page\"`: a heading, the `sections` and \"The\n request\" (requester, date, why) on the left, the card on the right\n (under them on a phone), sticky from 1024px.\n- `status` — `\"pending\"` (default) · `\"approved\"` · `\"rejected\"`. The\n block shows its own outcome as soon as a decision saves; a new\n `status` from you replaces it.\n- `decidedBy` · `decidedAt` · `decisionNote` — for a request decided\n before: who, when, and the reason, shown under the outcome.\n- `kind` — a small badge beside the title (\"Per seat\", \"Refund\").\n- `requestedAt` — an ISO time, a `Date`, or a day (`\"2026-09-28\"`, a\n `date` column), which reads as that day wherever the viewer is.\n- `justification` — the requester's reason, as a paragraph.\n- `reasonRequired` — default `true`: Reject opens a reason field and\n sends only when it is filled. `false`: Reject sends at once.\n- `approveLabel` · `rejectLabel` — default \"Approve\" / \"Reject\"\n (\"Refund €149.00\", \"Decline\").\n- `heading` · `description` — `page`: the page's title (default `title`)\n and the line under it.\n- `formatDate(value)` — how a date reads; default the viewer's long date\n (\"21 September 2026\").\n- `loading` — a skeleton card while the request loads.\n- `emptyLabel` — default \"Nothing is waiting for a decision.\".\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// components/PurchaseApproval.jsx — client\n\"use client\";\nimport { useRouter } from \"next/navigation\";\nimport { updateRow } from \"../lib/actions\";\nimport { ApprovalRequest } from \"./blocks/approval-request\";\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" });\n\nexport default function PurchaseApproval({ request }) {\n const router = useRouter();\n const decide = (status, reason) =>\n updateRow({ source: \"public.purchase_requests\", id: request.id, patch: { status, reason: reason ?? null }, detail: request.product }).then(\n (res) => (res.ok && router.refresh(), res),\n );\n return (\n <ApprovalRequest\n title={request.product}\n kind={request.pricing}\n details={[\n { label: \"Starts\", value: request.starts_on, icon: \"calendar\" },\n { label: \"Seats\", value: `${request.seats} people`, icon: \"users\" },\n { label: \"Yearly cost\", value: usd.format(request.amount), icon: \"dollar\", emphasis: true },\n ]}\n requester={{ name: request.requested_by, role: request.team }}\n requestedAt={request.created_at}\n justification={request.why}\n status={request.status}\n decidedBy={request.decided_by}\n decidedAt={request.decided_at}\n decisionNote={request.reason}\n onApprove={() => decide(\"approved\")}\n onReject={(reason) => decide(\"rejected\", reason)}\n />\n );\n}\n```\n\n## What it will not do\n\n- List many requests: put them in a `data-table` and open one in this\n block (a `Sheet`), or render one per row.\n- Take a decision in several steps (two approvers, a delegate): `status`\n is one of three.\n- Edit the request: its facts are read-only here.\n\nA request it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add approval-request` restores\nthem.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui, software-purchase-01 and software-purchase-card, MIT), rewritten on the Makersclaw kit.\n"
28
+ "content": "# ApprovalRequest\n\nOne request waiting on a decision: what is asked for (label and value\nfacts, the amount in large type), who asked and when, why, and Approve /\nReject. Reject asks for a reason the requester will see. The decision\ngoes out through your callbacks; while one runs its button spins, when it\nfails the card says why and the buttons stay, and once it lands the card\nshows the outcome (\"Approved by you · 23 September 2026\") in their place.\n\n```jsx\nimport { ApprovalRequest } from \"../components/blocks/approval-request\"; // a Makersclaw app (\"./blocks/approval-request\" from components/)\nimport { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\"; // a shadcn project\n```\n\nA client component. Load the request in the server surface (README §4)\nand pass its fields in.\n\n## The data\n\n- `details` — the card's facts, in order:\n `[{ label, value, icon?, emphasis? }]`. `value` is a string or a node;\n `icon` is a kit `Icon` name; `emphasis` sets the value large (the\n amount).\n- `sections` — `variant=\"page\"` only, the detail beside the card:\n `[{ title, fields: [{ label, value, emphasis? }] }]`.\n- `requester` — `{ name, role?, src? }` (`src` a picture URL).\n\n## Props\n\n- `title` — what is asked for (\"Linear Business\", \"Refund for order\n 20931\"). Without it (and not `loading`) the block shows `emptyLabel`.\n- `onApprove()` — save the yes. Return `{ ok: true }` or\n `{ ok: false, error }`; a throw counts as failed and its message shows.\n- `onReject(reason)` — save the no, with the reason typed (a trimmed\n string, \"\" when `reasonRequired` is off). Same return as `onApprove`.\n Leave either out and its button is not drawn.\n- `variant` — `\"card\"` (default: the card alone, with the requester, the\n date and the reason why) or `\"page\"`: a heading, the `sections` and \"The\n request\" (requester, date, why) on the left, the card on the right\n (under them on a phone), sticky from 1024px.\n- `status` — `\"pending\"` (default) · `\"approved\"` · `\"rejected\"`. The\n block shows its own outcome as soon as a decision saves; a new\n `status` from you replaces it.\n- `decidedBy` · `decidedAt` · `decisionNote` — for a request decided\n before: who, when, and the reason, shown under the outcome.\n- `kind` — a small badge beside the title (\"Per seat\", \"Refund\").\n- `requestedAt` — an ISO time, a `Date`, or a day (`\"2026-09-28\"`, a\n `date` column), which reads as that day wherever the viewer is.\n- `justification` — the requester's reason, as a paragraph.\n- `reasonRequired` — default `true`: Reject opens a reason field and\n sends only when it is filled. `false`: Reject sends at once.\n- `approveLabel` · `rejectLabel` — default \"Approve\" / \"Reject\"\n (\"Refund €149.00\", \"Decline\").\n- `heading` · `description` — `page`: the page's title (default `title`)\n and the line under it.\n- `formatDate(value)` — how a date reads; default the viewer's long date\n (\"21 September 2026\").\n- `loading` — a skeleton card while the request loads.\n- `emptyLabel` — default \"Nothing is waiting for a decision.\".\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/approval.jsx — client: one request, read and decided\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { ApprovalRequest } from \"../components/blocks/approval-request\";\n\nconst usd = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency: \"USD\" });\n\nexport default function PurchaseApproval() {\n const id = useSearchParams().get(\"row\"); // /s/approval?row=<id>\n const { row: request, loading } = useRow({ source: \"purchase_requests\", id });\n if (loading || !request) return null;\n // decided_by and decided_at: a before-update trigger sets them from the member, never the page (the app's README §5)\n const decide = (status, reason) =>\n updateRow({ source: \"purchase_requests\", id: request.id, patch: { status, reason: reason ?? null }, detail: request.product });\n return (\n <ApprovalRequest\n title={request.product}\n kind={request.pricing}\n details={[\n { label: \"Starts\", value: request.starts_on, icon: \"calendar\" },\n { label: \"Seats\", value: `${request.seats} people`, icon: \"users\" },\n { label: \"Yearly cost\", value: usd.format(request.amount), icon: \"dollar\", emphasis: true },\n ]}\n requester={{ name: request.requested_by, role: request.team }}\n requestedAt={request.created_at}\n justification={request.why}\n status={request.status}\n decidedBy={request.decided_by}\n decidedAt={request.decided_at}\n decisionNote={request.reason}\n onApprove={() => decide(\"approved\")}\n onReject={(reason) => decide(\"rejected\", reason)}\n />\n );\n}\n```\n\nA decision that answers ok reads the request again, so the card shows\nit decided.\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- List many requests: put them in a `data-table` and open one in this\n block (a `Sheet`), or render one per row.\n- Take a decision in several steps (two approvers, a delegate): `status`\n is one of three.\n- Edit the request: its facts are read-only here.\n\nA request it cannot be is your own component in `components/`, built\nfrom the kit — not an edit to this block. Its files are fixed: `npm run\ncheck` refuses a change, and `makersclaw add approval-request` restores\nthem.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui, software-purchase-01 and software-purchase-card, MIT), rewritten on the Makersclaw kit.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/approval-request. Import: import { ApprovalRequest } from \"@/components/makersclaw/blocks/approval-request\". Props and an example: components/makersclaw/blocks/approval-request/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.6.0",
48
+ "version": "0.7.0",
49
49
  "useWhen": "Someone must say yes or no to one thing another person asked for: a purchase, a refund, time off, a discount, an access request, a published post. Not for a queue of many requests (a data-table with row actions) or a yes/no about the founder's own action (kit Confirm)."
50
50
  }
51
51
  }
@@ -5,7 +5,7 @@
5
5
  "title": "AudioPlayer",
6
6
  "description": "A waveform audio player (wavesurfer.js) in the workspace theme: play, skip back and forward, speed, mute; regions (spans of the audio in a theme tone, labelled in a lane under the waveform, click to play one) and markers (pins at moments, click to jump); a hover time readout; drag across the waveform to mark a new region; add a marker at the playhead; keyboard like a video player (K, J, L, M, arrows). Time events and a handle (seek, play a range) for syncing a transcript.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "wavesurfer.js@8.0.0",
@@ -46,7 +46,7 @@
46
46
  "path": "registry/blocks/audio-player/README.md",
47
47
  "type": "registry:file",
48
48
  "target": "@components/makersclaw/blocks/audio-player/README.md",
49
- "content": "# AudioPlayer\n\nA waveform audio player in the workspace theme. Play, skip back and\nforward 15 seconds, change speed, mute. Hover the waveform to read the\ntime; click it to jump; drag across it to mark a region. **Regions** are\nspans of the audio in a theme tone — a topic, a speaker turn, a clip —\ntinted on the waveform and labelled in a lane under it; click a label to\nplay just that span. **Markers** are moments — a comment, a flag — shown\nas pins above the waveform; click one to jump there. \"Marker\" adds one at\nthe playhead. It loads with a skeleton waveform, says so plainly when a\nfile will not play, and shows a friendly empty state when there is no\naudio yet.\n\nIt is [wavesurfer.js](https://wavesurfer.xyz) (BSD-3-Clause) underneath:\nit decodes, plays and paints the waveform. Regions, markers, the hover\nline, the controls and every state are ours, in the kit. The blocks built\non this one — `call-review` — import it, never `wavesurfer.js`.\n\n```jsx\nimport { AudioPlayer, useAudioPlayer } from \"../components/blocks/audio-player\"; // a Makersclaw app (\"./blocks/audio-player\" from components/)\nimport { AudioPlayer, useAudioPlayer } from \"@/components/makersclaw/blocks/audio-player\"; // a shadcn project\n```\n\n`AudioPlayer` is a client component: render it from a `\"use client\"`\ncomponent. `formatTime`, `formatRange`, `spokenTime` and `regionAt` are\nplain functions and work on the server.\n\n`makersclaw add audio-player` adds `wavesurfer.js` and\n`@wavesurfer/react` (pinned, exact) to package.json: run `npm install`\nafter. Both load with the page (no `import()`); the player itself starts\nin the browser, and until then the frame shows a skeleton waveform.\n\n## The data\n\nTimes are **seconds** from the start of the audio (numbers, fractions\nallowed).\n\n```js\n// a region: a span of the audio\n{ id: \"pricing\", start: 12.4, end: 25.6, label: \"Pricing and SSO\", tone: \"chart-3\" }\n\n// a marker: a moment\n{ id: \"m1\", time: 17.2, label: \"Asked whether SSO is on every plan\", tone: \"warning\" }\n```\n\n- `id` — unique within its list; it comes back to you in the callbacks.\n- `label` — shown in the lane (a region) or the tooltip (both), and read\n to a screen reader. Keep it short.\n- `tone` — a theme token, never a colour: `primary` (the default),\n `chart-2` … `chart-5`, `success`, `warning`, `info`, `destructive`,\n `muted`. `TONES` lists them. Use the `chart-*` tones for kinds (topics,\n speakers) and the status tones for meaning (a risk, a win).\n- Any other keys ride along untouched (a comment's author, a transcript\n line's id…) and come back in `onRegionClick`, `onMarkerClick` and\n `onActiveRegionChange`.\n- Regions may overlap; where they do, the one that started last is the\n active one. Anything past the end of the audio is clipped to it.\n\n## Props\n\n- `src` — the audio: a URL (`https://…`, `/api/recordings/123`, a\n `data:` URL) or a `Blob` / `File` (from an upload, a recording). A Blob\n plays through an object URL the player makes and revokes. Nothing\n (`null`) shows the empty state.\n- `peaks` — optional precomputed waveform (`[Float32Array | number[]]`,\n one per channel) with `duration`: the waveform draws at once without\n decoding the file — use it for long calls. Keep the array's identity\n stable (state or `useMemo`); a new array rebuilds the player.\n- `duration` — seconds, with `peaks`. Otherwise it is read from the file.\n- `title`, `description` — the heading over the waveform and the muted\n line under it (\"Discovery call · Northwind\", \"Maya and Leo · Sep 18\").\n- `regions` — `[{ id, start, end, label?, tone? }]` (default none).\n- `markers` — `[{ id, time, label?, tone? }]` (default none).\n- `onAddMarker({ time })` — shows the \"Marker\" button (and the M key); the\n new pin appears at once, dimmed, while you save. Add the marker to\n `markers` and return `{ ok: true }`; return `{ ok: false, error }` (or\n throw) and the pin goes, with a notice that says why. Without it there\n is no button.\n- `onRegionCreate({ start, end })` — makes a drag across the waveform\n mark a region (a crosshair cursor, the span and its times shown while\n dragging; shorter than half a second counts as a click). Same promise\n as `onAddMarker`: add it to `regions`, return `{ ok }`. Without it a\n drag scrubs.\n- `onMarkerClick(marker)` — after the jump to it.\n- `onRegionClick(region)` — after it starts playing (it stops at its end).\n- `onActiveRegionChange(region | null)` — once each time the region under\n the playhead changes (a transcript highlighting the line being said).\n- `onTimeUpdate(seconds)` — continuously while playing (every frame):\n keep it cheap.\n- `onSeek(seconds)` — every jump: a click, a key, a skip, a pin, the handle.\n- `onReady({ duration })`, `onPlay()`, `onPause()`, `onFinish()`,\n `onError(error)` — as named.\n- `rates` — the speeds offered, default `[0.75, 1, 1.25, 1.5, 2]`.\n- `defaultRate` — the starting speed, default `1`.\n- `skipSeconds` — the skip buttons' step (and J / L, Page Up / Down),\n default `15`.\n- `emptyTitle`, `emptyBody`, `emptyAction` — the empty state's words and\n one action (a node: an upload button). Defaults: \"No recording yet\",\n \"When there is audio to play, it shows up here.\", none.\n- `bare` — no card around it (no border, background or padding), for a\n player inside your own card or row. Default `false`.\n- `playerRef` — a ref from `useAudioPlayer()` for the handle below.\n- `className` — on the outer element.\n\n### The handle\n\n```jsx\nconst player = useAudioPlayer();\n<AudioPlayer src={url} playerRef={player} />;\nplayer.current?.seek(84);\n```\n\n`play()`, `pause()`, `toggle()`, `seek(seconds)`, `skip(±seconds)`,\n`playRange(start, end?)` (stops at `end`), `playRegion(id)`,\n`setRate(n)`, `setMuted(bool)`, `addMarker()` (as the button),\n`getTime()`, `getDuration()`, `isPlaying()`, `isReady()`. Calls before\nthe audio is ready do nothing.\n\n### Keys\n\nOn the waveform (a slider, one Tab stop): ← / → 5 seconds, Page Down /\nPage Up `skipSeconds`, Home / End, Space or Enter to play or pause.\nAnywhere in the player (not while typing, not with ⌘ / Ctrl / Alt): K\nplay / pause, J / L back / forward `skipSeconds`, M add a marker. Pins\nand region labels are buttons in Tab order.\n\n## Example\n\nA call recording with its topics and comments, saved with\n`lib/actions.js` in a Makersclaw app.\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow } from \"../lib/actions\";\nimport { AudioPlayer } from \"./blocks/audio-player\";\n\nexport default function CallRecording({ call, topics, comments: initial }) {\n const [comments, setComments] = useState(initial);\n return (\n <AudioPlayer\n src={call.recording_url}\n title={call.title}\n description={`${call.participants} · ${call.date}`}\n regions={topics.map((t) => ({ id: t.id, start: t.start_s, end: t.end_s, label: t.name, tone: \"chart-2\" }))}\n markers={comments.map((c) => ({ id: c.id, time: c.at_s, label: c.body, tone: \"primary\" }))}\n onAddMarker={async ({ time }) => {\n const values = { call_id: call.id, at_s: time, body: \"New comment\" };\n const res = await insertRow({ source: \"public.call_comments\", values });\n if (res.ok) setComments((cs) => [...cs, { ...values, id: res.id }]);\n return res;\n }}\n />\n );\n}\n```\n\n## What it will not do\n\n- Record from the microphone, or play video.\n- Zoom or scroll the waveform: the whole recording always fits the\n width (a long call reads as an overview; use `peaks` so it draws fast).\n- Move, resize, rename or delete a region, or edit a marker, inside the\n player: those are your list's actions — change `regions` / `markers`.\n- Show a volume slider (mute only; the device's volume does the rest).\n- Stream: the file is fetched and decoded in the browser (pass `peaks`\n and `duration` for long files).\n"
49
+ "content": "# AudioPlayer\n\nA waveform audio player in the workspace theme. Play, skip back and\nforward 15 seconds, change speed, mute. Hover the waveform to read the\ntime; click it to jump; drag across it to mark a region. **Regions** are\nspans of the audio in a theme tone — a topic, a speaker turn, a clip —\ntinted on the waveform and labelled in a lane under it; click a label to\nplay just that span. **Markers** are moments — a comment, a flag — shown\nas pins above the waveform; click one to jump there. \"Marker\" adds one at\nthe playhead. It loads with a skeleton waveform, says so plainly when a\nfile will not play, and shows a friendly empty state when there is no\naudio yet.\n\nIt is [wavesurfer.js](https://wavesurfer.xyz) (BSD-3-Clause) underneath:\nit decodes, plays and paints the waveform. Regions, markers, the hover\nline, the controls and every state are ours, in the kit. The blocks built\non this one — `call-review` — import it, never `wavesurfer.js`.\n\n```jsx\nimport { AudioPlayer, useAudioPlayer } from \"../components/blocks/audio-player\"; // a Makersclaw app (\"./blocks/audio-player\" from components/)\nimport { AudioPlayer, useAudioPlayer } from \"@/components/makersclaw/blocks/audio-player\"; // a shadcn project\n```\n\n`AudioPlayer` is a client component: render it from a `\"use client\"`\ncomponent. `formatTime`, `formatRange`, `spokenTime` and `regionAt` are\nplain functions and work on the server.\n\n`makersclaw add audio-player` adds `wavesurfer.js` and\n`@wavesurfer/react` (pinned, exact) to package.json: run `npm install`\nafter. Both load with the page (no `import()`); the player itself starts\nin the browser, and until then the frame shows a skeleton waveform.\n\n## The data\n\nTimes are **seconds** from the start of the audio (numbers, fractions\nallowed).\n\n```js\n// a region: a span of the audio\n{ id: \"pricing\", start: 12.4, end: 25.6, label: \"Pricing and SSO\", tone: \"chart-3\" }\n\n// a marker: a moment\n{ id: \"m1\", time: 17.2, label: \"Asked whether SSO is on every plan\", tone: \"warning\" }\n```\n\n- `id` — unique within its list; it comes back to you in the callbacks.\n- `label` — shown in the lane (a region) or the tooltip (both), and read\n to a screen reader. Keep it short.\n- `tone` — a theme token, never a colour: `primary` (the default),\n `chart-2` … `chart-5`, `success`, `warning`, `info`, `destructive`,\n `muted`. `TONES` lists them. Use the `chart-*` tones for kinds (topics,\n speakers) and the status tones for meaning (a risk, a win).\n- Any other keys ride along untouched (a comment's author, a transcript\n line's id…) and come back in `onRegionClick`, `onMarkerClick` and\n `onActiveRegionChange`.\n- Regions may overlap; where they do, the one that started last is the\n active one. Anything past the end of the audio is clipped to it.\n\n## Props\n\n- `src` — the audio: a URL (`https://…`, `/api/recordings/123`, a\n `data:` URL) or a `Blob` / `File` (from an upload, a recording). A Blob\n plays through an object URL the player makes and revokes. Nothing\n (`null`) shows the empty state.\n- `peaks` — optional precomputed waveform (`[Float32Array | number[]]`,\n one per channel) with `duration`: the waveform draws at once without\n decoding the file — use it for long calls. Keep the array's identity\n stable (state or `useMemo`); a new array rebuilds the player.\n- `duration` — seconds, with `peaks`. Otherwise it is read from the file.\n- `title`, `description` — the heading over the waveform and the muted\n line under it (\"Discovery call · Northwind\", \"Maya and Leo · Sep 18\").\n- `regions` — `[{ id, start, end, label?, tone? }]` (default none).\n- `markers` — `[{ id, time, label?, tone? }]` (default none).\n- `onAddMarker({ time })` — shows the \"Marker\" button (and the M key); the\n new pin appears at once, dimmed, while you save. Add the marker to\n `markers` and return `{ ok: true }`; return `{ ok: false, error }` (or\n throw) and the pin goes, with a notice that says why. Without it there\n is no button.\n- `onRegionCreate({ start, end })` — makes a drag across the waveform\n mark a region (a crosshair cursor, the span and its times shown while\n dragging; shorter than half a second counts as a click). Same promise\n as `onAddMarker`: add it to `regions`, return `{ ok }`. Without it a\n drag scrubs.\n- `onMarkerClick(marker)` — after the jump to it.\n- `onRegionClick(region)` — after it starts playing (it stops at its end).\n- `onActiveRegionChange(region | null)` — once each time the region under\n the playhead changes (a transcript highlighting the line being said).\n- `onTimeUpdate(seconds)` — continuously while playing (every frame):\n keep it cheap.\n- `onSeek(seconds)` — every jump: a click, a key, a skip, a pin, the handle.\n- `onReady({ duration })`, `onPlay()`, `onPause()`, `onFinish()`,\n `onError(error)` — as named.\n- `rates` — the speeds offered, default `[0.75, 1, 1.25, 1.5, 2]`.\n- `defaultRate` — the starting speed, default `1`.\n- `skipSeconds` — the skip buttons' step (and J / L, Page Up / Down),\n default `15`.\n- `emptyTitle`, `emptyBody`, `emptyAction` — the empty state's words and\n one action (a node: an upload button). Defaults: \"No recording yet\",\n \"When there is audio to play, it shows up here.\", none.\n- `bare` — no card around it (no border, background or padding), for a\n player inside your own card or row. Default `false`.\n- `playerRef` — a ref from `useAudioPlayer()` for the handle below.\n- `className` — on the outer element.\n\n### The handle\n\n```jsx\nconst player = useAudioPlayer();\n<AudioPlayer src={url} playerRef={player} />;\nplayer.current?.seek(84);\n```\n\n`play()`, `pause()`, `toggle()`, `seek(seconds)`, `skip(±seconds)`,\n`playRange(start, end?)` (stops at `end`), `playRegion(id)`,\n`setRate(n)`, `setMuted(bool)`, `addMarker()` (as the button),\n`getTime()`, `getDuration()`, `isPlaying()`, `isReady()`. Calls before\nthe audio is ready do nothing.\n\n### Keys\n\nOn the waveform (a slider, one Tab stop): ← / → 5 seconds, Page Down /\nPage Up `skipSeconds`, Home / End, Space or Enter to play or pause.\nAnywhere in the player (not while typing, not with ⌘ / Ctrl / Alt): K\nplay / pause, J / L back / forward `skipSeconds`, M add a marker. Pins\nand region labels are buttons in Tab order.\n\n## Example\n\nA call recording with its topics and comments, read with the hooks of\n`lib/data.js` and saved with `lib/actions.js` in a Makersclaw app.\n\n```jsx\n// surfaces/call.jsx — client: one call, its topics and comments\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow, useRows, whenLanded } from \"../lib/data\";\nimport { insertRow } from \"../lib/actions\";\nimport { AudioPlayer } from \"../components/blocks/audio-player\";\n\nexport default function CallRecording() {\n const id = useSearchParams().get(\"row\");\n const { row: call, loading } = useRow({ source: \"calls\", id });\n const { rows: topics } = useRows({ source: \"call_topics\", filter: { call_id: id }, orderBy: \"start_s asc\" });\n const { rows: comments } = useRows({ source: \"call_comments\", filter: { call_id: id }, orderBy: \"at_s asc\" });\n if (loading || !call) return null;\n return (\n <AudioPlayer\n src={call.recording_url}\n title={call.title}\n description={`${call.participants} · ${call.date}`}\n regions={topics.map((t) => ({ id: t.id, start: t.start_s, end: t.end_s, label: t.name, tone: \"chart-2\" }))}\n markers={comments.map((c) => ({ id: c.id, time: c.at_s, label: c.body, tone: \"primary\" }))}\n onAddMarker={async ({ time }) => {\n const res = await insertRow({ source: \"call_comments\", values: { call_id: call.id, at_s: time, body: \"New comment\" } });\n if (res.ok) await whenLanded(); // the comments are read again: the new marker is in them\n return res;\n }}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Record from the microphone, or play video.\n- Zoom or scroll the waveform: the whole recording always fits the\n width (a long call reads as an overview; use `peaks` so it draws fast).\n- Move, resize, rename or delete a region, or edit a marker, inside the\n player: those are your list's actions — change `regions` / `markers`.\n- Show a volume slider (mute only; the device's volume does the rest).\n- Stream: the file is fetched and decoded in the browser (pass `peaks`\n and `duration` for long files).\n"
50
50
  }
51
51
  ],
52
52
  "categories": [
@@ -67,7 +67,7 @@
67
67
  "docs": "Installed to components/makersclaw/blocks/audio-player. Import: import { AudioPlayer, useAudioPlayer, formatTime, formatRange, spokenTime, regionAt, TONES, RATES } from \"@/components/makersclaw/blocks/audio-player\". Props and an example: components/makersclaw/blocks/audio-player/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
68
68
  "meta": {
69
69
  "makersclaw": {
70
- "version": "0.6.0",
70
+ "version": "0.7.0",
71
71
  "useWhen": "A screen plays a recording: a call or meeting recording, a voice note, a podcast clip, a voicemail, a sound file on a record. The call-review block is built on it. Not for video, not for recording audio from the microphone."
72
72
  }
73
73
  }
package/r/auth-form.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "AuthForm",
6
6
  "description": "The account screens in one form: sign in (password, email only, or provider buttons for Google, GitHub, Apple and Microsoft), sign up with extra fields, a magic link, a reset link, and changing a password against a live rules checklist. The auth is yours: each submit is an async callback that returns { ok } or an error the form shows.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -48,7 +48,7 @@
48
48
  "docs": "Installed to components/makersclaw/blocks/auth-form. Import: import { AuthForm, PASSWORD_RULES } from \"@/components/makersclaw/blocks/auth-form\". Props and an example: components/makersclaw/blocks/auth-form/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
49
49
  "meta": {
50
50
  "makersclaw": {
51
- "version": "0.6.0",
51
+ "version": "0.7.0",
52
52
  "useWhen": "An app needs a sign-in, sign-up, forgot-password or change-password screen, or a settings page where a signed-in person changes their password."
53
53
  }
54
54
  }
package/r/board.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Board",
6
6
  "description": "Records as cards in columns, one per stage; drag a card to another column (mouse, touch, or the card's menu from the keyboard) and the stage is saved. Optional column icons, a per-column total (\"13pt\"), a category colour on each card's edge, and a stacked layout of folding sections with compact rows.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -19,13 +19,13 @@
19
19
  "path": "registry/blocks/board/board.jsx",
20
20
  "type": "registry:component",
21
21
  "target": "@components/makersclaw/blocks/board/board.jsx",
22
- "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Collapsible, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// Opt-in: an icon per column (`columns[].icon`), a figure beside each\n// count (`aggregate(rows)` → \"13pt\"), a category's colour on each card's\n// edge (`accentOf(row)` → 1…5, `accentEdge` top | start), and\n// `layout=\"stacked\"` — the stages as folding sections of compact rows.\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\n\n// A card's accent: a category's colour on one edge, from the chart tokens\n// (chart-1…5), written out whole so Tailwind finds every class.\nconst ACCENTS = {\n top: {\n 1: \"border-t-[3px] border-t-chart-1\",\n 2: \"border-t-[3px] border-t-chart-2\",\n 3: \"border-t-[3px] border-t-chart-3\",\n 4: \"border-t-[3px] border-t-chart-4\",\n 5: \"border-t-[3px] border-t-chart-5\",\n },\n start: {\n 1: \"border-s-[3px] border-s-chart-1\",\n 2: \"border-s-[3px] border-s-chart-2\",\n 3: \"border-s-[3px] border-s-chart-3\",\n 4: \"border-s-[3px] border-s-chart-4\",\n 5: \"border-s-[3px] border-s-chart-5\",\n },\n};\n\nfunction accentHue(value) {\n const n = typeof value === \"string\" ? Number(value.replace(/^chart-/, \"\")) : value;\n return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;\n}\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n aggregate,\n accentOf,\n accentEdge = \"top\",\n layout = \"columns\",\n defaultOpen,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const stacked = layout === \"stacked\";\n const accent = (row, edge = accentEdge) => {\n if (!accentOf) return null;\n const hue = accentHue(accentOf(row));\n return hue ? (ACCENTS[edge] ?? ACCENTS.top)[hue] : null;\n };\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n // The \"Add\" at a stage's foot, and the inline field it opens.\n function adder(col, headingId, pad = \"px-2 pb-2\") {\n if (!onCreate || col.other) return null;\n if (adding !== col.key) {\n return (\n <div className={pad}>\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n align=\"start\"\n className=\"w-full\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n );\n }\n return (\n <form onSubmit={(e) => create(e, col.key)} className={pad}>\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit submit: a\n // synthetic Enter (an agent testing the app) has no keypress to\n // submit with. Never while an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n );\n }\n\n function header(col, cards) {\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n return {\n icon: col.icon ? <Icon name={col.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null,\n total: aggregate ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {aggregate(cards, col.other ? null : col.key)}\n </span>\n ) : null,\n count: (\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n ),\n };\n }\n\n function moveItems(row) {\n return movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n }\n\n function titleButton(row, title, extra) {\n return onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className={cx(\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\", extra)}\n >\n {title}\n </button>\n ) : (\n <p className={cx(\"text-sm font-medium\", extra)}>{title}</p>\n );\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {titleButton(row, title)}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row), layout: \"columns\" })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 -mt-2 -mb-2 shrink-0\" /> : null}\n </div>\n );\n }\n\n const notice = error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null;\n const live = (\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n );\n\n // Stacked: the stages one under another, each a section that folds, the\n // records as compact rows. Nothing drags here — a row moves through its\n // menu (\"Move to …\"), the same path the keyboard takes on the columns —\n // so a phone scrolls the page, never a board sideways.\n if (stacked) {\n const opens = defaultOpen == null ? null : new Set((Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]).map(String));\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div role=\"region\" aria-label={label} className=\"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\">\n {shown.map((col) => {\n const rowsHere = byColumn.get(col.key) ?? [];\n const head = header(col, rowsHere);\n const headingId = `${uid}-${col.key}`;\n return (\n <Collapsible\n key={col.key}\n variant=\"flush\"\n defaultOpen={opens ? opens.has(col.key) : true}\n title={\n <span className=\"flex min-w-0 items-center gap-2\">\n {head.icon}\n <span id={headingId} className=\"truncate font-semibold\">\n {col.label}\n </span>\n </span>\n }\n meta={\n <span className=\"flex items-center gap-2\">\n {head.total}\n {head.count}\n </span>\n }\n >\n <ul aria-labelledby={headingId} className={cx(\"-mx-4 divide-y divide-border border-t border-border\", !onCreate || col.other ? \"-mb-4\" : \"mb-2\")}>\n {rowsHere.map((row) => {\n const id = keyOf(row);\n const title = titleOf(row);\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <li key={id} data-board-card={id} className={cx(\"flex min-h-11 items-center gap-3 px-4 py-1.5\", accent(row, \"start\"))}>\n <div className=\"min-w-0 flex-1\">\n {titleButton(row, title, \"truncate\")}\n {metaOf ? <div className=\"truncate text-xs text-muted-foreground\">{metaOf(row)}</div> : null}\n </div>\n {renderDetails ? (\n <div className=\"flex shrink-0 items-center gap-2\">{renderDetails(row, { column: columnOf(row), layout: \"stacked\" })}</div>\n ) : null}\n <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 shrink-0\" />\n </li>\n );\n })}\n {rowsHere.length === 0 ? <li className=\"px-4 py-3 text-xs text-muted-foreground\">{emptyLabel}</li> : null}\n </ul>\n {adder(col, headingId, \"-mx-2\")}\n </Collapsible>\n );\n })}\n </div>\n {live}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const head = header(col, cards);\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center gap-2 px-3\">\n {head.icon}\n <h3 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {col.label}\n </h3>\n {head.total}\n {head.count}\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n accent(row),\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {adder(col, headingId)}\n </section>\n );\n })}\n </div>\n {live}\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\",\n accent(drag.row),\n )}\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
22
+ "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { isSample } from \"../../parts\";\nimport { Button, Collapsible, Icon, Input, Menu, Notice, cx } from \"../../ui\";\n\n// Board — records as cards in columns, one column per stage, moved by\n// dragging or from the card's menu. The block owns the behaviour a board\n// gets wrong when it is drawn by hand: pointer dragging that works with a\n// mouse and on a phone, a keyboard path (the menu), the move shown at\n// once and put back with a reason when the write fails, examples that\n// move on screen and save nothing, the count in every header, and cards\n// no stage claims kept in view instead of lost. The surface owns what a\n// card says under its title (renderDetails) and how a move is saved\n// (onMove): the block imports nothing but React, the kit and the parts,\n// so it runs in a Makersclaw app and in any shadcn project alike.\n//\n// <Board rows={deals} groupBy=\"stage\" columns={STAGES}\n// onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}\n// titleOf={(d) => d.name} renderDetails={(d) => <DealFacts deal={d} />}\n// onOpen={setEditing} />\n//\n// Opt-in: an icon per column (`columns[].icon`), a figure beside each\n// count (`aggregate(rows)` → \"13pt\"), a category's colour on each card's\n// edge (`accentOf(row)` → 1…5, `accentEdge` top | start), and\n// `layout=\"stacked\"` — the stages as folding sections of compact rows.\n//\n// README.md beside this file lists every prop.\n\nconst OTHER = \"__other\";\n\n// A card's accent: a category's colour on one edge, from the chart tokens\n// (chart-1…5), written out whole so Tailwind finds every class.\nconst ACCENTS = {\n top: {\n 1: \"border-t-[3px] border-t-chart-1\",\n 2: \"border-t-[3px] border-t-chart-2\",\n 3: \"border-t-[3px] border-t-chart-3\",\n 4: \"border-t-[3px] border-t-chart-4\",\n 5: \"border-t-[3px] border-t-chart-5\",\n },\n start: {\n 1: \"border-s-[3px] border-s-chart-1\",\n 2: \"border-s-[3px] border-s-chart-2\",\n 3: \"border-s-[3px] border-s-chart-3\",\n 4: \"border-s-[3px] border-s-chart-4\",\n 5: \"border-s-[3px] border-s-chart-5\",\n },\n};\n\nfunction accentHue(value) {\n const n = typeof value === \"string\" ? Number(value.replace(/^chart-/, \"\")) : value;\n return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;\n}\nconst DRAG_START_PX = { mouse: 5, pen: 6, touch: 8 };\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"—\";\n}\n\nfunction normalizeColumns(columns) {\n return (columns ?? []).map((c) =>\n typeof c === \"string\" || typeof c === \"number\"\n ? { key: String(c), label: humanize(c) }\n : { ...c, key: String(c.key), label: c.label ?? humanize(c.key) },\n );\n}\n\nexport function Board({\n rows,\n groupBy,\n columns,\n idColumn = \"id\",\n onMove,\n renderDetails,\n titleOf = (row) => row.title ?? row.name ?? String(row[idColumn] ?? \"\"),\n metaOf,\n onOpen,\n actionsOf,\n onCreate,\n addLabel = \"Add\",\n createPlaceholder = \"Title, then Enter\",\n limits,\n aggregate,\n accentOf,\n accentEdge = \"top\",\n layout = \"columns\",\n defaultOpen,\n label = \"Board\",\n emptyLabel = \"Nothing here yet\",\n otherLabel = \"No stage\",\n className,\n}) {\n const uid = useId();\n const boardRef = useRef(null);\n const dragRef = useRef(null);\n const suppressClick = useRef(false);\n const [overrides, setOverrides] = useState(() => new Map());\n const [drag, setDrag] = useState(null);\n const [error, setError] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [adding, setAdding] = useState(null);\n const [draft, setDraft] = useState(\"\");\n const [busy, setBusy] = useState(false);\n const [portal, setPortal] = useState(null);\n\n const movable = typeof onMove === \"function\";\n const list = rows ?? [];\n const keyOf = (row) => String(row?.[idColumn]);\n const cols = useMemo(() => normalizeColumns(columns), [columns]);\n const known = useMemo(() => new Set(cols.map((c) => c.key)), [cols]);\n const columnOf = (row) => {\n const moved = overrides.get(keyOf(row));\n const value = moved ?? (row?.[groupBy] == null ? \"\" : String(row[groupBy]));\n return known.has(value) ? value : OTHER;\n };\n\n useEffect(() => setPortal(document.body), []);\n\n // A move stays on screen until the rows the page re-reads carry it; then\n // the override has nothing left to say.\n useEffect(() => {\n setOverrides((current) => {\n if (current.size === 0) return current;\n const next = new Map(current);\n for (const [id, to] of current) {\n const row = list.find((r) => keyOf(r) === id);\n if (!row || String(row[groupBy]) === to) next.delete(id);\n }\n return next.size === current.size ? current : next;\n });\n // `list` changes identity with every read; that is the point.\n }, [rows, groupBy]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const byColumn = useMemo(() => {\n const out = new Map(cols.map((c) => [c.key, []]));\n out.set(OTHER, []);\n for (const row of list) out.get(columnOf(row)).push(row);\n return out;\n }, [list, cols, overrides]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const stacked = layout === \"stacked\";\n const accent = (row, edge = accentEdge) => {\n if (!accentOf) return null;\n const hue = accentHue(accentOf(row));\n return hue ? (ACCENTS[edge] ?? ACCENTS.top)[hue] : null;\n };\n const shown = byColumn.get(OTHER).length ? [...cols, { key: OTHER, label: otherLabel, other: true }] : cols;\n const labelOf = (key) => shown.find((c) => c.key === key)?.label ?? humanize(key);\n\n async function move(row, to) {\n const from = columnOf(row);\n if (!movable || to === from || to === OTHER || !known.has(to)) return;\n const id = keyOf(row);\n const title = titleOf(row);\n setError(null);\n setOverrides((m) => new Map(m).set(id, to));\n setAnnounce(`${title} moved to ${labelOf(to)}`);\n // An example moves on screen and saves nothing: it is not a row.\n if (isSample(row)) return;\n let result;\n try {\n result = await onMove(row, to, from === OTHER ? null : from);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n if (result && result.ok === false) {\n setOverrides((m) => {\n const next = new Map(m);\n next.delete(id);\n return next;\n });\n setError(`“${title}” is back in ${labelOf(from)}: ${result.error ?? \"the move was not saved\"}`);\n setAnnounce(`${title} could not be moved`);\n }\n }\n\n // ---- pointer dragging ---------------------------------------------------\n // A mouse drags a card from anywhere on it; touch and pen drag from the\n // grip, so the column still scrolls under a finger.\n\n function onPointerDown(e, row) {\n if (!movable) return;\n if (e.pointerType === \"mouse\" && e.button !== 0) return;\n const onGrip = Boolean(e.target.closest?.(\"[data-board-grip]\"));\n if (e.pointerType !== \"mouse\" && !onGrip) return;\n if (!onGrip && e.target.closest?.(\"input, textarea, select, a, [aria-haspopup], [role=menuitem]\")) return;\n // No text selection while a mouse drags; the click still lands.\n if (e.pointerType === \"mouse\") e.preventDefault();\n const card = e.currentTarget;\n const rect = card.getBoundingClientRect();\n dragRef.current = {\n row,\n id: keyOf(row),\n from: columnOf(row),\n pointerId: e.pointerId,\n type: e.pointerType,\n card,\n startX: e.clientX,\n startY: e.clientY,\n offsetX: e.clientX - rect.left,\n offsetY: e.clientY - rect.top,\n width: rect.width,\n active: false,\n over: null,\n x: e.clientX,\n y: e.clientY,\n };\n }\n\n function columnAt(x, y) {\n const el = document.elementFromPoint(x, y);\n const col = el?.closest?.(\"[data-board-column]\");\n return col && boardRef.current?.contains(col) ? col.getAttribute(\"data-board-column\") : null;\n }\n\n function onPointerMove(e) {\n const d = dragRef.current;\n if (!d || e.pointerId !== d.pointerId) return;\n if (!d.active) {\n const far = Math.hypot(e.clientX - d.startX, e.clientY - d.startY) >= (DRAG_START_PX[d.type] ?? 5);\n if (!far) return;\n d.active = true;\n try {\n d.card.setPointerCapture(e.pointerId);\n } catch {\n // the pointer is already gone; the move below still works\n }\n }\n e.preventDefault();\n d.x = e.clientX;\n d.y = e.clientY;\n d.over = columnAt(e.clientX, e.clientY);\n const board = boardRef.current;\n if (board) {\n const r = board.getBoundingClientRect();\n if (e.clientX < r.left + 48) board.scrollBy({ left: -18 });\n else if (e.clientX > r.right - 48) board.scrollBy({ left: 18 });\n }\n setDrag({ id: d.id, over: d.over, x: d.x, y: d.y, offsetX: d.offsetX, offsetY: d.offsetY, width: d.width, row: d.row });\n }\n\n function finish(e, drop) {\n const d = dragRef.current;\n if (!d || (e && e.pointerId !== d.pointerId)) return;\n dragRef.current = null;\n if (!d.active) return;\n suppressClick.current = true;\n setTimeout(() => {\n suppressClick.current = false;\n }, 0);\n setDrag(null);\n if (drop && d.over) move(d.row, d.over);\n }\n\n useEffect(() => {\n if (!drag) return undefined;\n const onKey = (e) => {\n if (e.key === \"Escape\") finish(null, false);\n };\n window.addEventListener(\"keydown\", onKey);\n return () => window.removeEventListener(\"keydown\", onKey);\n }, [drag]); // eslint-disable-line react-hooks/exhaustive-deps\n\n async function create(e, column) {\n e.preventDefault();\n const title = draft.trim();\n if (!title || busy) return;\n setBusy(true);\n setError(null);\n let result;\n try {\n result = await onCreate(title, column);\n } catch (err) {\n result = { ok: false, error: err?.message ?? String(err) };\n }\n setBusy(false);\n if (result && result.ok === false) setError(`“${title}” was not added: ${result.error ?? \"the write failed\"}`);\n else {\n setDraft(\"\");\n setAnnounce(`${title} added to ${labelOf(column)}`);\n }\n }\n\n // The \"Add\" at a stage's foot, and the inline field it opens.\n function adder(col, headingId, pad = \"px-2 pb-2\") {\n if (!onCreate || col.other) return null;\n if (adding !== col.key) {\n return (\n <div className={pad}>\n <Button\n variant=\"quiet\"\n size=\"sm\"\n icon=\"plus\"\n align=\"start\"\n className=\"w-full\"\n onClick={() => {\n setAdding(col.key);\n setDraft(\"\");\n }}\n >\n {addLabel}\n </Button>\n </div>\n );\n }\n return (\n <form onSubmit={(e) => create(e, col.key)} className={pad}>\n <label htmlFor={`${headingId}-new`} className=\"sr-only\">\n New in {col.label}\n </label>\n <Input\n id={`${headingId}-new`}\n autoFocus\n value={draft}\n disabled={busy}\n placeholder={createPlaceholder}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n // Enter adds here, not through the form's implicit submit: a\n // synthetic Enter (an agent testing the app) has no keypress to\n // submit with. Never while an input method is composing a word.\n if (e.key === \"Enter\" && !e.nativeEvent.isComposing) create(e, col.key);\n else if (e.key === \"Escape\") {\n setAdding(null);\n setDraft(\"\");\n }\n }}\n onBlur={() => {\n if (!draft.trim()) setAdding(null);\n }}\n />\n </form>\n );\n }\n\n function header(col, cards) {\n const limit = limits?.[col.key];\n const over = typeof limit === \"number\" && cards.length > limit;\n return {\n icon: col.icon ? <Icon name={col.icon} size={16} className=\"shrink-0 text-muted-foreground\" /> : null,\n total: aggregate ? (\n <span data-nums className=\"shrink-0 text-xs text-muted-foreground\">\n {aggregate(cards, col.other ? null : col.key)}\n </span>\n ) : null,\n count: (\n <span\n data-nums\n className={cx(\"shrink-0 font-mono text-xs\", over ? \"font-semibold text-warning\" : \"text-muted-foreground\")}\n title={typeof limit === \"number\" ? `${cards.length} of a limit of ${limit}` : undefined}\n >\n {typeof limit === \"number\" ? `${cards.length}/${limit}` : cards.length}\n </span>\n ),\n };\n }\n\n function moveItems(row) {\n return movable\n ? shown\n .filter((c) => !c.other && c.key !== columnOf(row))\n .map((c) => ({ label: `Move to ${c.label}`, icon: \"arrow-right\", onSelect: () => move(row, c.key) }))\n : [];\n }\n\n function titleButton(row, title, extra) {\n return onOpen ? (\n <button\n type=\"button\"\n onClick={() => onOpen(row)}\n className={cx(\"block w-full rounded-sm text-left text-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring\", extra)}\n >\n {title}\n </button>\n ) : (\n <p className={cx(\"text-sm font-medium\", extra)}>{title}</p>\n );\n }\n\n function card(row, { ghost = false } = {}) {\n const title = titleOf(row);\n const body = (\n <>\n {titleButton(row, title)}\n {renderDetails ? (\n <div className=\"mt-2\">{renderDetails(row, { column: columnOf(row), layout: \"columns\" })}</div>\n ) : metaOf ? (\n <div className=\"mt-1 text-xs text-muted-foreground\">{metaOf(row)}</div>\n ) : null}\n </>\n );\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <div className=\"flex items-start gap-1\">\n {movable && !ghost ? (\n <span\n data-board-grip\n aria-hidden=\"true\"\n className=\"-ml-1 mt-0.5 hidden h-8 w-6 shrink-0 cursor-grab items-center justify-center text-muted-foreground [@media(hover:none)]:flex\"\n style={{ touchAction: \"none\" }}\n >\n <Icon name=\"grip\" size={16} />\n </span>\n ) : null}\n <div className=\"min-w-0 flex-1\">{body}</div>\n {!ghost ? <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 -mt-2 -mb-2 shrink-0\" /> : null}\n </div>\n );\n }\n\n const notice = error ? (\n <Notice tone=\"danger\" onDismiss={() => setError(null)}>\n {error}\n </Notice>\n ) : null;\n const live = (\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n );\n\n // Stacked: the stages one under another, each a section that folds, the\n // records as compact rows. Nothing drags here — a row moves through its\n // menu (\"Move to …\"), the same path the keyboard takes on the columns —\n // so a phone scrolls the page, never a board sideways.\n if (stacked) {\n const opens = defaultOpen == null ? null : new Set((Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]).map(String));\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div role=\"region\" aria-label={label} className=\"overflow-hidden rounded-lg border border-border bg-card text-card-foreground\">\n {shown.map((col) => {\n const rowsHere = byColumn.get(col.key) ?? [];\n const head = header(col, rowsHere);\n const headingId = `${uid}-${col.key}`;\n return (\n <Collapsible\n key={col.key}\n variant=\"flush\"\n defaultOpen={opens ? opens.has(col.key) : true}\n title={\n <span className=\"flex min-w-0 items-center gap-2\">\n {head.icon}\n <span id={headingId} className=\"truncate font-semibold\">\n {col.label}\n </span>\n </span>\n }\n meta={\n <span className=\"flex items-center gap-2\">\n {head.total}\n {head.count}\n </span>\n }\n >\n <ul aria-labelledby={headingId} className={cx(\"-mx-4 divide-y divide-border border-t border-border\", !onCreate || col.other ? \"-mb-4\" : \"mb-2\")}>\n {rowsHere.map((row) => {\n const id = keyOf(row);\n const title = titleOf(row);\n const items = [...(actionsOf?.(row) ?? []), ...moveItems(row)];\n return (\n <li key={id} data-board-card={id} className={cx(\"flex min-h-11 items-center gap-3 px-4 py-1.5\", accent(row, \"start\"))}>\n <div className=\"min-w-0 flex-1\">\n {titleButton(row, title, \"truncate\")}\n {metaOf ? <div className=\"truncate text-xs text-muted-foreground\">{metaOf(row)}</div> : null}\n </div>\n {renderDetails ? (\n <div className=\"flex shrink-0 items-center gap-2\">{renderDetails(row, { column: columnOf(row), layout: \"stacked\" })}</div>\n ) : null}\n <Menu items={items} label={`Actions for ${title}`} size=\"md\" className=\"-mr-2 shrink-0\" />\n </li>\n );\n })}\n {rowsHere.length === 0 ? <li className=\"px-4 py-3 text-xs text-muted-foreground\">{emptyLabel}</li> : null}\n </ul>\n {adder(col, headingId, \"-mx-2\")}\n </Collapsible>\n );\n })}\n </div>\n {live}\n </div>\n );\n }\n\n return (\n <div className={cx(\"grid gap-3\", className)}>\n {notice}\n <div\n ref={boardRef}\n role=\"region\"\n aria-label={label}\n tabIndex={0}\n className=\"flex snap-x snap-mandatory gap-3 overflow-x-auto rounded-lg pb-2 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:snap-none\"\n >\n {shown.map((col) => {\n const cards = byColumn.get(col.key) ?? [];\n const head = header(col, cards);\n const target = drag && drag.over === col.key && !col.other && columnOf(drag.row) !== col.key;\n const headingId = `${uid}-${col.key}`;\n return (\n <section\n key={col.key}\n data-board-column={col.other ? undefined : col.key}\n aria-labelledby={headingId}\n className={cx(\n \"flex max-h-[75dvh] w-[min(18rem,85vw)] shrink-0 snap-start flex-col rounded-lg border border-border bg-muted/50\",\n target && \"bg-accent ring-2 ring-ring\",\n )}\n >\n <header className=\"flex min-h-11 items-center gap-2 px-3\">\n {head.icon}\n <h3 id={headingId} className=\"min-w-0 flex-1 truncate text-sm font-semibold\">\n {col.label}\n </h3>\n {head.total}\n {head.count}\n </header>\n <ul className=\"flex min-h-16 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2\">\n {cards.map((row) => {\n const id = keyOf(row);\n const lifted = drag?.id === id;\n return (\n <li\n key={id}\n data-board-card={id}\n onPointerDown={(e) => onPointerDown(e, row)}\n onPointerMove={onPointerMove}\n onPointerUp={(e) => finish(e, true)}\n onPointerCancel={(e) => finish(e, false)}\n onClickCapture={(e) => {\n if (suppressClick.current) {\n e.preventDefault();\n e.stopPropagation();\n }\n }}\n className={cx(\n \"rounded-md border border-border bg-card p-3 text-card-foreground shadow-sm\",\n accent(row),\n movable && \"sm:cursor-grab\",\n lifted && \"opacity-40\",\n )}\n >\n {card(row)}\n </li>\n );\n })}\n {cards.length === 0 ? (\n <li className=\"rounded-md border border-dashed border-border p-3 text-xs text-muted-foreground\">{emptyLabel}</li>\n ) : null}\n </ul>\n {adder(col, headingId)}\n </section>\n );\n })}\n </div>\n {live}\n {drag && portal\n ? createPortal(\n <div\n aria-hidden=\"true\"\n className={cx(\n \"pointer-events-none fixed z-50 rotate-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-lg\",\n accent(drag.row),\n )}\n style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}\n >\n {card(drag.row, { ghost: true })}\n </div>,\n portal,\n )\n : null}\n </div>\n );\n}\n"
23
23
  },
24
24
  {
25
25
  "path": "registry/blocks/board/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/board/README.md",
28
- "content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\", icon? }, …]`.\n `icon` is a kit `Icon` name, drawn before the stage's label.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}`.\n Without `onMove` the board only shows; nothing can be moved.\n- `idColumn` — default `\"id\"`.\n- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It\n also names the card in its menu, in announcements and in errors.\n- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit\n the record. The block draws that button; you never style one.\n- `renderDetails(row, { column, layout })` — what the card says under its\n title: the part of the board that is yours. Kit parts and tokens only.\n In `layout=\"stacked\"` it sits at the row's end instead, so keep it short\n there (a badge, an avatar); `layout` tells you which.\n- `metaOf(row)` — a plain muted line under the title, when that is all\n the card needs (ignored on cards when `renderDetails` is set; a stacked\n row shows both).\n- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:\n `[{ label, onSelect, icon?, tone?: \"danger\" }]`. The \"Move to\" items\n follow them.\n- `onCreate(title, column)` — an \"Add\" at every column's foot opens an\n inline field; Enter calls this (insert the row with `insertRow`), the\n field stays open for the next one. Return `{ ok: false, error }` to show\n why it failed.\n- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.\n- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows\n `4/3` in the header, in the warning tone, when it is exceeded.\n- `aggregate(rows, column)` — a figure beside each stage's count, from the\n records in it: ``(list) => `${list.reduce((s, r) => s + r.points, 0)}pt` ``\n → \"13pt\". A string or a node; `column` is null for \"No stage\".\n- `accentOf(row)` — the record's category as a colour on one edge of its\n card: `1` … `5` (the chart tokens, `chart-1` … `chart-5`), or `null` for\n none. A category, never a status: show what it means in words too (a\n legend, or the type on the card).\n- `accentEdge` — `\"top\"` (default) or `\"start\"` (the left edge, in a\n right-to-left page the right). Stacked rows always use the start edge.\n- `layout` — `\"columns\"` (default: side by side, dragged) or `\"stacked\"`:\n the stages one under another as folding sections (icon, label, count,\n chevron), each record a compact row. Rows move through their menu\n (\"Move to …\"); nothing drags, so on a phone the page just scrolls.\n- `defaultOpen` — stacked: the stage keys open at first, e.g.\n `[\"todo\"]`. Default: every stage open.\n- `label` — what the board is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/pipeline.jsx — server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport Pipeline from \"../components/Pipeline\";\n\nexport default async function PipelineSurface() {\n const { rows, error } = await loadRows({ source: \"public.deals\", orderBy: \"updated_at desc\" });\n return <Pipeline deals={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Pipeline.jsx — client: the board, the card, the edit sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow, moveRow } from \"../lib/actions\";\nimport { Badge, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { Board } from \"./blocks/board\";\n\nconst STAGES = [\n { key: \"lead\", label: \"Lead\" },\n { key: \"call\", label: \"Call booked\" },\n { key: \"proposal\", label: \"Proposal\" },\n { key: \"won\", label: \"Won\" },\n];\nconst SAMPLE = [\n { name: \"Northwind\", stage: \"call\", value: 12000, owner: \"Maya\" },\n { name: \"Acme Ltd\", stage: \"lead\", value: 4000, owner: \"Leon\" },\n];\n\nexport default function Pipeline({ deals, error }) {\n const fixture = useFixture(deals, SAMPLE);\n const [editing, setEditing] = useState(null);\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <Board\n rows={fixture.rows}\n groupBy=\"stage\"\n columns={STAGES}\n onMove={(d, to) => moveRow({ source: \"public.deals\", id: d.id, stateColumn: \"stage\", to })}\n titleOf={(d) => d.name}\n limits={{ proposal: 5 }}\n onCreate={(name, stage) => insertRow({ source: \"public.deals\", values: { name, stage }, detail: name })}\n onOpen={setEditing}\n renderDetails={(d) => (\n <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n <span>{d.owner ?? \"Unowned\"}</span>\n <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>\n </div>\n )}\n label=\"Deals by stage\"\n />\n {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}\n </div>\n );\n}\n```\n\nA sprint (icons, points per stage, a top edge by type) and a stacked list:\n\n```jsx\nconst TYPE_HUE = { feature: 1, bug: 4, task: 2 };\n\n<Board\n rows={stories}\n groupBy=\"status\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"doing\", label: \"In progress\", icon: \"zap\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n onMove={(s, to) => moveRow({ source: \"public.stories\", id: s.id, stateColumn: \"status\", to })}\n aggregate={(list) => `${list.reduce((n, s) => n + (s.points ?? 0), 0)}pt`}\n accentOf={(s) => TYPE_HUE[s.type] ?? null}\n renderDetails={(s) => <Badge>{s.points}pt</Badge>}\n label=\"Stories by status\"\n/>\n\n<Board\n layout=\"stacked\"\n rows={chores}\n groupBy=\"state\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n defaultOpen={[\"todo\"]}\n onMove={(c, to) => moveRow({ source: \"public.chores\", id: c.id, stateColumn: \"state\", to })}\n metaOf={(c) => c.room}\n renderDetails={(c) => <Badge tone={c.every === \"daily\" ? \"warning\" : \"info\"}>{c.every}</Badge>}\n label=\"Chores by state\"\n/>\n```\n\n## What it will not do\n\n- Order cards inside a column: they keep the order of `rows` — sort in\n the surface's `loadRows`.\n- Create a stage: `columns` is the surface's; a new stage is a new entry.\n- Colour a column: stages are not status. Put a `Badge` on the card when a\n stage means something the founder must see. (`accentOf` colours a card\n by its category, not its stage.)\n- Drag in `layout=\"stacked\"`: rows move from their menu.\n\nA board it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add board` restores them.\n"
28
+ "content": "# Board\n\nRecords as cards in columns, one column per stage. Drag a card to another\ncolumn — with a mouse from anywhere on the card, on a phone from its grip,\nfrom the keyboard through the card's menu (\"Move to …\") — and the stage is\nsaved. The move shows at once; when the write fails the card goes back and\nthe board says why.\n\n```jsx\nimport { Board } from \"../components/blocks/board\"; // a Makersclaw app (\"./blocks/board\" from components/)\nimport { Board } from \"@/components/makersclaw/blocks/board\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nOne table, one column that holds the stage (`groupBy`). Each row is a card.\n`columns` is the stages in order — the values that column holds. A row\nwhose stage is not in `columns` lands in a last \"No stage\" column, so no\nrecord goes missing; it can be dragged out of it, not into it.\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n example cards move on screen without saving (they are not rows).\n- `groupBy` — the column that holds the stage, e.g. `\"stage\"`.\n- `columns` — the stages in order: `[\"lead\", \"call\", \"won\"]` (labels are\n made from the keys) or `[{ key: \"lead\", label: \"New lead\", icon? }, …]`.\n `icon` is a kit `Icon` name, drawn before the stage's label.\n- `onMove(row, to, from)` — save the move; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app it\n is one line over `moveRow` (lib/actions.js — the activity row included):\n `onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}`.\n Without `onMove` the board only shows; nothing can be moved.\n- `idColumn` — default `\"id\"`.\n- `titleOf(row)` — the card's title, default `row.title ?? row.name`. It\n also names the card in its menu, in announcements and in errors.\n- `onOpen(row)` — the title becomes a button: open your `Sheet` to edit\n the record. The block draws that button; you never style one.\n- `renderDetails(row, { column, layout })` — what the card says under its\n title: the part of the board that is yours. Kit parts and tokens only.\n In `layout=\"stacked\"` it sits at the row's end instead, so keep it short\n there (a badge, an avatar); `layout` tells you which.\n- `metaOf(row)` — a plain muted line under the title, when that is all\n the card needs (ignored on cards when `renderDetails` is set; a stacked\n row shows both).\n- `actionsOf(row)` — more menu items for a card, kit `Menu` shape:\n `[{ label, onSelect, icon?, tone?: \"danger\" }]`. The \"Move to\" items\n follow them.\n- `onCreate(title, column)` — an \"Add\" at every column's foot opens an\n inline field; Enter calls this (insert the row with `insertRow`), the\n field stays open for the next one. Return `{ ok: false, error }` to show\n why it failed.\n- `addLabel` · `createPlaceholder` · `emptyLabel` · `otherLabel` — words.\n- `limits` — a work-in-progress limit per stage: `{ doing: 3 }` shows\n `4/3` in the header, in the warning tone, when it is exceeded.\n- `aggregate(rows, column)` — a figure beside each stage's count, from the\n records in it: ``(list) => `${list.reduce((s, r) => s + r.points, 0)}pt` ``\n → \"13pt\". A string or a node; `column` is null for \"No stage\".\n- `accentOf(row)` — the record's category as a colour on one edge of its\n card: `1` … `5` (the chart tokens, `chart-1` … `chart-5`), or `null` for\n none. A category, never a status: show what it means in words too (a\n legend, or the type on the card).\n- `accentEdge` — `\"top\"` (default) or `\"start\"` (the left edge, in a\n right-to-left page the right). Stacked rows always use the start edge.\n- `layout` — `\"columns\"` (default: side by side, dragged) or `\"stacked\"`:\n the stages one under another as folding sections (icon, label, count,\n chevron), each record a compact row. Rows move through their menu\n (\"Move to …\"); nothing drags, so on a phone the page just scrolls.\n- `defaultOpen` — stacked: the stage keys open at first, e.g.\n `[\"todo\"]`. Default: every stage open.\n- `label` — what the board is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n## Example\n\n```jsx\n// surfaces/pipeline.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Pipeline from \"../components/Pipeline\";\n\nexport default function PipelineSurface() {\n const { rows, error, loading } = useRows({ source: \"deals\", orderBy: \"updated_at desc\" });\n if (loading) return null; // rows only once read: to useFixture, a read in flight looks like an empty table\n return <Pipeline deals={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Pipeline.jsx — client: the board, the card, the edit sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { insertRow, moveRow } from \"../lib/actions\";\nimport { Badge, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { Board } from \"./blocks/board\";\n\nconst STAGES = [\n { key: \"lead\", label: \"Lead\" },\n { key: \"call\", label: \"Call booked\" },\n { key: \"proposal\", label: \"Proposal\" },\n { key: \"won\", label: \"Won\" },\n];\nconst SAMPLE = [\n { name: \"Northwind\", stage: \"call\", value: 12000, owner: \"Maya\" },\n { name: \"Acme Ltd\", stage: \"lead\", value: 4000, owner: \"Leon\" },\n];\n\nexport default function Pipeline({ deals, error }) {\n const fixture = useFixture(deals, SAMPLE);\n const [editing, setEditing] = useState(null);\n return (\n <div className=\"grid gap-4\">\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <Board\n rows={fixture.rows}\n groupBy=\"stage\"\n columns={STAGES}\n onMove={(d, to) => moveRow({ source: \"deals\", id: d.id, stateColumn: \"stage\", to })}\n titleOf={(d) => d.name}\n limits={{ proposal: 5 }}\n onCreate={(name, stage) => insertRow({ source: \"deals\", values: { name, stage }, detail: name })}\n onOpen={setEditing}\n renderDetails={(d) => (\n <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n <span>{d.owner ?? \"Unowned\"}</span>\n <Badge>{Number(d.value ?? 0).toLocaleString()}</Badge>\n </div>\n )}\n label=\"Deals by stage\"\n />\n {/* <Sheet open={Boolean(editing)} …> the record's fields </Sheet> */}\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nA sprint (icons, points per stage, a top edge by type) and a stacked list:\n\n```jsx\nconst TYPE_HUE = { feature: 1, bug: 4, task: 2 };\n\n<Board\n rows={stories}\n groupBy=\"status\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"doing\", label: \"In progress\", icon: \"zap\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n onMove={(s, to) => moveRow({ source: \"stories\", id: s.id, stateColumn: \"status\", to })}\n aggregate={(list) => `${list.reduce((n, s) => n + (s.points ?? 0), 0)}pt`}\n accentOf={(s) => TYPE_HUE[s.type] ?? null}\n renderDetails={(s) => <Badge>{s.points}pt</Badge>}\n label=\"Stories by status\"\n/>\n\n<Board\n layout=\"stacked\"\n rows={chores}\n groupBy=\"state\"\n columns={[{ key: \"todo\", label: \"To do\", icon: \"inbox\" }, { key: \"done\", label: \"Done\", icon: \"circle-check\" }]}\n defaultOpen={[\"todo\"]}\n onMove={(c, to) => moveRow({ source: \"chores\", id: c.id, stateColumn: \"state\", to })}\n metaOf={(c) => c.room}\n renderDetails={(c) => <Badge tone={c.every === \"daily\" ? \"warning\" : \"info\"}>{c.every}</Badge>}\n label=\"Chores by state\"\n/>\n```\n\n## What it will not do\n\n- Order cards inside a column: they keep the order of `rows` — sort in\n the surface's `loadRows`.\n- Create a stage: `columns` is the surface's; a new stage is a new entry.\n- Colour a column: stages are not status. Put a `Badge` on the card when a\n stage means something the founder must see. (`accentOf` colours a card\n by its category, not its stage.)\n- Drag in `layout=\"stacked\"`: rows move from their menu.\n\nA board it cannot be is your own component in `components/`, built from\nthe kit — not an edit to this block. Its files are fixed: `npm run check`\nrefuses a change, and `makersclaw add board` restores them.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -48,7 +48,7 @@
48
48
  "docs": "Installed to components/makersclaw/blocks/board. Import: import { Board } from \"@/components/makersclaw/blocks/board\". Props and an example: components/makersclaw/blocks/board/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
49
49
  "meta": {
50
50
  "makersclaw": {
51
- "version": "0.6.0",
51
+ "version": "0.7.0",
52
52
  "useWhen": "A process where each record sits in one stage and moves forward: a pipeline, a hiring funnel, a sprint, a content calendar by status, outreach by reply state. Not for records with no stages (a table) or items placed on a timeline."
53
53
  }
54
54
  }
@@ -5,8 +5,8 @@
5
5
  "title": "Calendar",
6
6
  "description": "Tasks on a calendar, like Google Calendar, Cron or Notion Calendar: month, week, day and agenda views over task-model's work items. Month shows all-day tasks and spans as tinted bars across their days (stacked in lanes, \"2 more\" past three) and timed ones as a dot and a time; week and day show an all-day strip over an hour grid where timed tasks are blocks placed by their times, overlapping ones side by side, with a line at now. Drag an item to another day or hour (it snaps to the quarter hour and the day columns as it moves), drag its bottom or end to change how long it runs, click an empty day or hour (or drag across hours) to type a new task in place, drag tasks without a date in from an unscheduled tray (or drop one there to take its date off). Arrow keys move a focused block, Shift+arrows resize it, Delete removes with Undo; T, J/K, M/W/D/A and C for today, next/previous, the views and a new task. Search and filters from the shared ViewToolbar, a colour per project or status, a real phone layout (a dotted month with the tapped day listed, a three-day week, a sheet for the tray). An available-time layer (closed hours muted, open slots as boxes you pick, from `availability`, `slots` or an async `getSlots`, with pure `freeSlots` / `availabilityOn` helpers) is what a booking page builds on. One async onChange saves every change; failures roll back with a notice.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.6.0/r/task-model.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/task-model.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -68,7 +68,7 @@
68
68
  "path": "registry/blocks/calendar-view/README.md",
69
69
  "type": "registry:file",
70
70
  "target": "@components/makersclaw/blocks/calendar-view/README.md",
71
- "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\nA task with `locked: true` stays where it is: no drag, resize, arrow\nkeys, Delete or done tick for it, and it still opens (`onOpen`). For\nwhat the app shows but did not make — events mirrored from the person's\ncalendar — beside what it may move.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `canCreate` — `false` keeps every change to the tasks there are and\n takes away every way to add one: a click or a drag across an empty\n day or hour, C, the Add buttons (default `true`; with `onChange`).\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { Sheet } from \"./ui\";\nimport { CalendarView } from \"./blocks/calendar-view\";\n\nexport default function Schedule({ tasks, people, projects, onChange }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks} people={people} projects={projects} onChange={onChange} onOpen={setOpen} defaultMode=\"week\" />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
71
+ "content": "# Calendar\n\nTasks on a calendar, the way Google Calendar, Cron and Notion Calendar\ndraw them. **Month** shows all-day tasks and multi-day spans as tinted bars\nacross their days (stacked, never on top of each other; past three a day\nsays \"2 more\") and timed tasks as a dot, a time and a title. **Week** and\n**Day** show an all-day strip over an hour grid: timed tasks are blocks\nplaced by their times, overlapping ones side by side, and a line marks\nnow. **Agenda** lists the days ahead with a round check to complete each\nitem.\n\nDrag an item to another day or hour — a block snaps to the quarter hour\nand to the day columns as it moves. Drag a block's bottom edge (or a\nbar's end) to change how long it runs. Click an empty day or hour, or drag\nacross a few hours, and type the new task in place. Tasks with no date\nwait in the **unscheduled tray** beside the calendar: drag one onto a day\nor an hour to schedule it, or drop a scheduled one there to take its date\noff. Search and filters come from task-model's toolbar, so the calendar\nfilters exactly like the list.\n\nThe **available-time layer** — closed hours muted, open slots as boxes\nyou can pick — is what a booking page is built on (`availability`,\n`slots` / `getSlots`, `onSlotClick`, and the pure `freeSlots`).\n\nEvery change saves through one async `onChange(change)` and shows at\nonce; when the save fails the calendar puts it back and says why.\n\n```jsx\nimport { CalendarView } from \"../components/blocks/calendar-view\"; // a Makersclaw app (\"./blocks/calendar-view\" from components/)\nimport { CalendarView } from \"@/components/makersclaw/blocks/calendar-view\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the tasks in the server surface (README §4). It is built on\n`task-model` (added with it): the task shape, the change shapes and the\nhelpers are documented there.\n\n## The data\n\nTasks in task-model's shape. Where a task lands is read from `start` and\n`due` (wall-calendar strings, no time zone):\n\n| The task has | It shows as |\n| --- | --- |\n| `due: \"2026-09-24\"` | an all-day item on the 24th |\n| `start: \"2026-09-22\", due: \"2026-09-24\"` | a bar across the 22nd–24th |\n| `start: \"…T09:30\", due: \"…T11:00\"` (same day) | a 9:30–11:00 block |\n| `start: \"…T09:30\"` only | a block running its `estimate` (hours), else `defaultDuration` |\n| `due: \"…T17:00\"` only | a deadline: a half-hour block at 5 PM, \"Due 5:00 PM\" |\n| neither | the unscheduled tray (open tasks only) |\n\nA drag saves only `start` and `due` (a move keeps the length and the\ntimes; dropping a timed block on the all-day strip saves `start: null`;\ndropping on the tray saves both as `null`). A new task from a click is\n`draftTask(title, ctx, { due })` or `{ start, due }`.\n\nA task with `locked: true` stays where it is: no drag, resize, arrow\nkeys, Delete or done tick for it, and it still opens (`onOpen`). For\nwhat the app shows but did not make — events mirrored from the person's\ncalendar — beside what it may move.\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing.\n- `statuses` · `people` · `projects` · `fields` — as task-list and\n task-model take them. A project's `hue` (1–5) colours its items.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). In a Makersclaw app,\n loop over `changeWrites(change)` (task-model README, \"A change\").\n Without it the calendar only shows: no drag, no quick-add, no checks.\n- `canCreate` — `false` keeps every change to the tasks there are and\n takes away every way to add one: a click or a drag across an empty\n day or hour, C, the Add buttons (default `true`; with `onChange`).\n- `onOpen(task)` — clicking an item (or Enter on it) opens your `Sheet`.\n- `mode` · `defaultMode` (`\"month\"`) · `onModeChange(mode)` — month ·\n week · day · agenda. `modes` — which ones the switcher offers (default\n all four; one hides the switcher).\n- `date` · `defaultDate` (today) · `onDateChange(day)` — the day the\n view is around.\n- `view` · `defaultView` · `onViewChange(view)` — task-model's view\n (search, filters, hideDone); grouping and sort do not apply here.\n- `today` — \"YYYY-MM-DD\"; fix it in a demo. `now` — \"YYYY-MM-DDTHH:mm\"\n for the now line; default the browser's clock (after mount).\n- `weekStartsOn` (1, Monday) · `weekend` (`[0, 6]`, muted) · `holidays`\n · `locale` (the browser's) — dates, hours and headings follow it.\n- `colorBy` — `\"project\"` (default) · `\"status\"` (the status's tone).\n- `tray` (true) — the unscheduled tray: a column beside the calendar\n when it is 960px wide or more (the toolbar's \"Unscheduled\" button\n folds it), a sheet with a date picker per task below that.\n- `header` (true) — the title, Today, arrows and the view switcher.\n `toolbar` (true) — search, filter, \"Unscheduled\", \"Add task\";\n `toolbarExtra` — your buttons at its end. `addLabel` (\"Add task\").\n- `hourHeight` (48) — pixels per hour. `height` (\"40rem\") — the hour\n grid's scrolling height. `snap` (15) — minutes a drag snaps to.\n `defaultDuration` (60) — minutes of a new block. `agendaDays` (14).\n- `loading` — a skeleton month. `error` — a read error, as a Notice.\n- `label` (\"Calendar\") — the region's name.\n\n### The available-time layer (week and day)\n\n- `availability` — the open hours; everything else is drawn muted:\n `{ days: [1,2,3,4,5], from: \"09:00\", to: \"17:00\" }`, or per weekday\n `{ weekly: { 1: [[\"09:00\",\"12:00\"], [\"13:00\",\"17:00\"]], … } }`, either\n with `dates: { \"2026-12-24\": [[\"09:00\",\"12:00\"]], \"2026-12-25\": [] }`,\n or a function `(day) → [[\"09:00\",\"17:00\"]]`.\n- `slots` — open times to show: `[{ start: \"2026-09-24T10:00\", end:\n \"2026-09-24T10:30\", id?, …yours }]`.\n- `getSlots({ from, to })` — the same, fetched for the days on screen\n (may be async; called again when the range changes). Pass a stable\n function (`useCallback`); a new one fetches again.\n- `onSlotClick(slot)` — slots become buttons; `selectedSlot` (a slot's\n `id` or `start`) fills the picked one. `slotLabel` (\"Open\") is what a\n screen reader says before the time. `renderSlot(slot, { selected })`\n draws a slot's inside.\n\nA booking page is the calendar with `modes={[\"week\", \"day\"]}`,\n`toolbar={false}`, `tray={false}`, the host's meetings as `tasks`, and:\n\n```js\nconst getSlots = useCallback(({ from, to }) =>\n freeSlots({ from, to, availability: HOURS, busy: meetings, minutes: 30, buffer: 10, notice: 120, now }), [meetings, now]);\n```\n\n### Helpers (pure, from the same import)\n\n- `freeSlots({ from, to, availability, busy, minutes = 30, step, buffer, notice, now })`\n → `[{ id, day, start, end, from, to }]` — `busy` mixes tasks (their\n timed blocks; all-day items do not take time) and `{ start, end }`\n intervals; `buffer` minutes kept free around each; nothing before\n `now` + `notice` minutes. `availabilityOn(day, availability)` /\n `closedOn(day, availability)` → `[[from, to], …]` minutes.\n- `eventOf(task)` → `{ id, task, timed, start, end, from, to, deadline, startOnly }`\n or null; `eventsOf(tasks)` · `unscheduledOf(tasks)`.\n- `layoutTimed(events)` → Map id → `{ col, cols, span }` (side by side);\n `layoutLanes(events, days)` → `{ items: [{ event, lane, col, span, clippedStart, clippedEnd }], lanes }`.\n- The patches a gesture saves: `moveEventPatch(event, day, from)` ·\n `resizeEventPatch(event, to)` · `placeEventPatch(event, day, from, to)` ·\n `timedPatch(day, from, minutes)` · `dayPatch(task, day, { grabbed })` ·\n `allDayPatch(day)` · `extendPatch(event, endDay)` · `UNSCHEDULE`.\n- `rangeFor(mode, anchor, { weekStartsOn, count, agendaDays })` ·\n `stepAnchor` · `titleFor` · `rangeLabel` · `hourLabel` ·\n `minutesLabel` · `eventTimeLabel` · `snapMinutes` · `durationOf` ·\n `CALENDAR_MODES` · `MODE_LABELS` · `DAY_MINUTES`.\n\n### Keyboard\n\n- A focused block: ↑/↓ moves it 15 minutes, ←/→ a day, Shift+↑/↓\n changes its length (saved once the keys rest); Enter opens it;\n Delete removes it, with Undo in the toast.\n- A focused bar or month item: Space picks it up, the arrows move it\n between days, Space drops it, Escape puts it back; Shift+←/→ changes\n a bar's last day.\n- Anywhere in the calendar: T today · J / K next / previous · M W D A\n the views · C a new task.\n\n## Example\n\n```jsx\n// surfaces/schedule.jsx — client: the tasks on a calendar\n\"use client\";\nimport { useState } from \"react\";\nimport { useRows } from \"../lib/data\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { Sheet } from \"../components/ui\";\nimport { CalendarView } from \"../components/blocks/calendar-view\";\nimport { changeWrites } from \"../components/blocks/task-model\";\n\nconst COLUMNS = [\"id\", \"title\", \"status\", \"assignees\", \"start\", \"due\", \"project\"];\n\n// One change of the calendar's, as the rows it writes (task-list's README has the same).\nasync function save(change) {\n for (const w of changeWrites(change, { columns: COLUMNS, keepNewIds: false })) {\n const r =\n w.op === \"insert\" ? await insertRow({ source: \"tasks\", values: w.values, detail: w.values.title })\n : w.op === \"update\" ? await updateRow({ source: \"tasks\", id: w.id, patch: w.patch })\n : await deleteRow({ source: \"tasks\", id: w.id });\n if (r?.ok === false) return r;\n }\n return { ok: true };\n}\n\nexport default function Schedule() {\n const tasks = useRows({ source: \"tasks\", orderBy: \"start asc\" });\n const people = useRows({ source: \"people\" });\n const projects = useRows({ source: \"projects\" });\n const [open, setOpen] = useState(null);\n return (\n <>\n <CalendarView tasks={tasks.rows} people={people.rows} projects={projects.rows} onChange={save} onOpen={setOpen} defaultMode=\"week\"\n loading={tasks.loading || people.loading || projects.loading} error={tasks.error} />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>…</Sheet>\n </>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Recurring events, time zones, or events that are not tasks: a repeat\n is the page's to create; a time is wall time.\n- Timed blocks that cross midnight: a task over several days is an\n all-day bar.\n- Fetch or save: tasks come in as props, changes go out through\n `onChange`; slots come from `slots` or your `getSlots`.\n- Take a booking: `onSlotClick` hands you the slot; the form and the\n write are the page's (booking-page).\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw\nadd calendar-view` restores them.\n"
72
72
  }
73
73
  ],
74
74
  "categories": [
@@ -98,7 +98,7 @@
98
98
  "docs": "Installed to components/makersclaw/blocks/calendar-view. Import: import { CalendarView, CALENDAR_MODES, MODE_LABELS, DAY_MINUTES, UNSCHEDULE, snapMinutes, durationOf, eventOf, eventsOf, unscheduledOf, layoutTimed, layoutLanes, moveEventPatch, resizeEventPatch, placeEventPatch, timedPatch, dayPatch, allDayPatch, extendPatch, rangeFor, stepAnchor, rangeLabel, titleFor, hourLabel, minutesLabel, eventTimeLabel, availabilityOn, closedOn, freeSlots, normalizeSlots } from \"@/components/makersclaw/blocks/calendar-view\". Props and an example: components/makersclaw/blocks/calendar-view/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
99
99
  "meta": {
100
100
  "makersclaw": {
101
- "version": "0.6.0",
101
+ "version": "0.7.0",
102
102
  "useWhen": "A screen where dated work or events are seen by day and moved around: a team's deadlines, a content calendar, a launch plan, shifts, a personal schedule, meetings on tasks, or the slot picker of a booking page (modes week/day, slots from getSlots). Not for dependencies over a long timeline (gantt), for undated work (task-list, board), or for a single date field (the kit's DatePicker)."
103
103
  }
104
104
  }