makersclaw 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +73 -7
  2. package/package.json +3 -2
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +3 -3
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +3 -3
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +121 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +2 -2
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +14 -5
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chat/README.md +43 -13
  67. package/registry/blocks/code-editor/README.md +4 -0
  68. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  69. package/registry/blocks/comment-box/README.md +20 -13
  70. package/registry/blocks/contact-section/README.md +7 -6
  71. package/registry/blocks/csv-import/README.md +34 -12
  72. package/registry/blocks/data-table/README.md +41 -8
  73. package/registry/blocks/diagram/diagram.jsx +1 -1
  74. package/registry/blocks/doc-editor/README.md +18 -6
  75. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  76. package/registry/blocks/doc-page/README.md +16 -6
  77. package/registry/blocks/file-upload/README.md +7 -1
  78. package/registry/blocks/flow-canvas/README.md +16 -5
  79. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  80. package/registry/blocks/footer/README.md +4 -2
  81. package/registry/blocks/gantt/README.md +18 -12
  82. package/registry/blocks/job-panel/README.md +1 -1
  83. package/registry/blocks/json-editor/README.md +16 -4
  84. package/registry/blocks/map/README.md +19 -3
  85. package/registry/blocks/map/map-view.jsx +1 -1
  86. package/registry/blocks/map/maplibre.js +1 -1
  87. package/registry/blocks/mind-map/README.md +16 -5
  88. package/registry/blocks/org-chart/README.md +10 -7
  89. package/registry/blocks/page-builder/README.md +13 -6
  90. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  91. package/registry/blocks/page-builder/page-view.jsx +1 -1
  92. package/registry/blocks/page-builder/page.js +1 -1
  93. package/registry/blocks/pdf-viewer/README.md +22 -20
  94. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  95. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  96. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  97. package/registry/blocks/product-detail/README.md +13 -7
  98. package/registry/blocks/product-detail/demo.jsx +1 -1
  99. package/registry/blocks/product-grid/README.md +12 -10
  100. package/registry/blocks/record-form/README.md +26 -9
  101. package/registry/blocks/record-notes/README.md +42 -30
  102. package/registry/blocks/search-dialog/README.md +38 -7
  103. package/registry/blocks/settings-list/README.md +29 -17
  104. package/registry/blocks/setup-checklist/README.md +12 -14
  105. package/registry/blocks/share-dialog/README.md +1 -1
  106. package/registry/blocks/signature-pad/README.md +15 -4
  107. package/registry/blocks/sketch-canvas/README.md +13 -4
  108. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  109. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  110. package/registry/blocks/spreadsheet/README.md +17 -8
  111. package/registry/blocks/stat-cards/README.md +17 -8
  112. package/registry/blocks/task-list/README.md +14 -11
  113. package/registry/blocks/task-model/README.md +3 -3
  114. package/registry/blocks/transaction-list/README.md +15 -7
  115. package/registry/blocks/whiteboard/README.md +19 -13
  116. package/src/cli.mjs +65 -9
  117. package/src/install.mjs +360 -21
  118. package/src/lockfile.mjs +468 -0
  119. package/src/pm.mjs +473 -0
  120. package/src/registry.mjs +125 -1
  121. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  122. package/templates/next/browser/app/layout.jsx +94 -0
  123. package/templates/next/browser/app/page.jsx +60 -0
  124. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  125. package/templates/next/browser/lib/actions.js +16 -0
  126. package/templates/next/browser/lib/data.js +43 -0
  127. package/templates/next/files/README.md +818 -82
  128. package/templates/next/files/app/claw-alive/route.js +2 -2
  129. package/templates/next/files/app/frame.js +134 -9
  130. package/templates/next/files/app/layout.jsx +2 -2
  131. package/templates/next/files/check/run.mjs +1491 -53
  132. package/templates/next/files/components/ui/item.jsx +2 -2
  133. package/templates/next/files/components/ui/notice.jsx +1 -1
  134. package/templates/next/files/components/ui/separator.jsx +1 -1
  135. package/templates/next/files/lib/actions.js +171 -52
  136. package/templates/next/files/lib/brain-server.js +96 -0
  137. package/templates/next/files/lib/brain.js +413 -0
  138. package/templates/next/files/lib/claw.js +11 -0
  139. package/templates/next/files/lib/data.js +120 -30
  140. package/templates/next/files/lib/db.js +97 -17
  141. package/templates/next/files/lib/door.js +680 -0
  142. package/templates/next/files/next.config.mjs +85 -9
  143. package/templates/next/files/package.json +3 -3
  144. package/templates/next/files/{middleware.js → proxy.js} +13 -6
  145. package/templates/next/files/surfaces.js +3 -2
  146. package/templates/next/pnpm-lock.yaml +5870 -0
  147. package/templates/next/template.json +13 -6
  148. package/templates/next/files/schema.sql +0 -22
package/r/gantt.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "Gantt",
6
6
  "description": "The Gantt view of work, like Linear's timeline or TeamGantt: a task list on the left (groups that fold, subtasks indented under a summary bar) beside a timeline with a two-tier date header, bars coloured by project or status with their progress, milestones as diamonds, dependency arrows (red when the dates disagree), a today line and weekend shading. Drag a bar to move it, an edge to change its start or end, the dot past its end onto another bar to link them; tasks waiting on a moved one slide along while you drag and the finish date shows what the move cost. Zoom day, week, month or quarter; highlight the critical path; click an empty row to schedule an undated task; quick-add per group; arrow keys move a focused bar; search, filters, group and sort from the shared toolbar. One async onChange saves every change; failures roll back with a notice.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/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": [
@@ -38,7 +38,7 @@
38
38
  "path": "registry/blocks/gantt/README.md",
39
39
  "type": "registry:file",
40
40
  "target": "@components/makersclaw/blocks/gantt/README.md",
41
- "content": "# Gantt\n\nThe Gantt view of work, like Linear's timeline or TeamGantt. The task list\nsits on the left — groups that fold, subtasks indented under their parent\n— and the timeline on the right, under a two-tier date header (months over\ndays, or years over months). Each task is a bar in its project's colour,\nfilled as far as it is done; a parent is a summary bracket over its\nsubtasks; a milestone is a diamond on its day; arrows run from each task to\nthe ones waiting on it, red and dashed when the dates disagree. A line marks\ntoday; weekends are shaded.\n\nDrag a bar to move it, an edge to change its start or end, and the dot past\nits end onto another bar to make that one wait on it. Every task waiting on\na moved one slides along *while you drag*, and \"Finishes Fri, Oct 16 +3\ndays\" at the top says what the move cost. Zoom by day, week, month or\nquarter; switch on the critical path and the chain that sets the finish\nlights up while the rest fades. A task with no dates shows a ghost bar under\nthe pointer: click to schedule it there. Search, filters, group and sort\nlive in the shared toolbar above.\n\nEvery change saves through one async `onChange` and shows at once; when the\nsave fails the timeline puts it back and says why.\n\n```jsx\nimport { Gantt } from \"../components/blocks/gantt\"; // a Makersclaw app (\"./blocks/gantt\" from components/)\nimport { Gantt } from \"@/components/makersclaw/blocks/gantt\"; // 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 — `{ id, title, status, assignees, start?,\ndue?, progress?, parentId?, dependsOn?, project?, … }` — with the statuses,\npeople and projects they refer to. What the gantt reads:\n\n- `start` · `due` — the bar runs from start to due, both days included. One\n of them alone is a one-day bar; neither is an undated task (a row with no\n bar, scheduled by a click).\n- `milestone: true` (or `type: \"milestone\"`) — a diamond on its `due` day\n (else its `start`).\n- `dependsOn: [\"t7\"]` — this task waits on t7: an arrow from t7's end to\n its start (finish to start).\n- `progress` (0–100) — the bar's fill; without it, 100 when done, else\n from its subtasks (task-model's `progressOf`).\n- `parentId` — a subtask, drawn under its parent; the parent's bar is a\n summary bracket over all of them and moves them together.\n- `project` — the bar's colour (the project's `hue`, chart-1…5) when\n `colorBy` is `\"project\"`.\n\nA drag saves only `start` and `due` (and, for a link, `dependsOn`).\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing (they are not rows).\n- `statuses` — `[{ id, label, tone?, hue?, done? }]` in order. Default: To\n do, In progress, In review, Done.\n- `people` — `[{ id, name, src? }]`.\n- `projects` — `[{ id, name, hue? }]`: `hue` 1–5 colours the bars.\n- `fields` — custom fields (`{ key, label, kind, options? }`), for the\n toolbar's filters and grouping.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). A move that pushes\n waiting tasks is one `batch` change; a link is an `update` of\n `dependsOn`. In a Makersclaw app, loop over `changeWrites(change)` into\n insertRow / updateRow / deleteRow (task-model README, \"A change\").\n Without `onChange` the timeline only shows: no drag, links, quick-add or\n menus beyond Open.\n- `onOpen(task)` — a click on a bar or a title (Enter on a focused bar),\n and \"Open\" in the row's menu: open your `Sheet` with the task.\n- `view` · `defaultView` · `onViewChange(view)` — search, filters, group\n and sort (task-model's view). The gantt's default: grouped by project,\n sorted by start date. Group by project, status, assignee, priority or\n none.\n- `zoom` · `defaultZoom` (`\"week\"`) · `onZoomChange(zoom)` — `\"day\"` ·\n `\"week\"` · `\"month\"` · `\"quarter\"`.\n- `colorBy` — `\"project\"` (default; the project's hue, else the primary\n colour) · `\"status\"` (the status's tone) · `\"none\"`.\n- `pushDependents` — default true: moving or stretching a task later slides\n every task waiting on it (and theirs) just far enough to start the day\n after it ends, keeping their lengths. Nothing is pulled earlier. False:\n only the dragged task moves, and conflicts show as red arrows.\n- `defaultCritical` — default false: start with the critical path on.\n- `newTaskLength` — default 3: the days a quick-added or click-scheduled\n task spans (a quick-added task starts today).\n- `today` — `\"YYYY-MM-DD\"`: the today line and where the timeline opens.\n Default the browser's today; fix it in a demo.\n- `weekStartsOn` (default 1, Monday) · `weekend` (default `[0, 6]`) ·\n `holidays` — the header's weeks and the shaded days.\n- `toolbar` — default true; false hides search, filter, group, sort and\n \"Add task\". `toolbarExtra` — your controls before \"Add task\".\n- `addLabel` — default `\"Add task\"`.\n- `starters` — plans the empty state offers:\n `[{ label, icon?, tasks: [{ title, days?, milestone?, parallel? }] }]`.\n Tasks run one after another from today, each waiting on the one before;\n `parallel: true` starts with the previous one instead. One press\n creates them all in one change.\n- `emptyTitle` · `emptyBody` — the empty state's words.\n- `height` — default 560: the timeline's most height in pixels (never\n more than 75% of the window); it scrolls inside.\n- `loading` — skeleton bars in place of the timeline.\n- `error` — a danger Notice above it (a failed read).\n- `label` — what it is called to a screen reader, default `\"Timeline\"`.\n- `locale` — dates in the header and labels; default the browser's.\n- `className` — layout around it (width, margin). Never colour.\n\nThe helpers it draws with are exported for a view of your own:\n`ZOOMS`, `ganttRange(tasks, zoom, today)`, `headerTiers(from, to, zoom)`,\n`planMove(tasks, id, \"move\" | \"start\" | \"end\", days, { push })` (what a\ndrag saves, as `[{ id, patch }]`), `afterBlockersPatch(task, tasks)`,\n`isMilestone`, `summarySpan`, `arrowPath(boxA, boxB)`.\n\n## What it does, precisely\n\n- Moving snaps to whole days at every zoom; the dates show beside the bar\n while it moves. Resizing never crosses the other edge (a bar is at least\n one day). A milestone and a summary bracket move but do not resize; a\n summary moves its whole family.\n- Linking: drag the dot past a bar's end onto another row. A link that\n would loop is refused with a notice. The row menu's \"Waits on\" ticks and\n unticks links from the keyboard.\n- A red, dashed arrow means the waiting task starts before the other ends;\n its row shows a warning and its menu offers \"Start after what it waits\n on\".\n- The critical path is the longest chain of linked, dated work\n (task-model's `criticalPath`): its bars ring in the primary colour, its\n arrows thicken, everything else fades, and a line under the chart says\n how long it is.\n- A task in two groups (two assignees, grouped by person) shows in both;\n the first row drags, the second only opens.\n- Keyboard: Tab reaches the titles, menus and bars. On a bar, ← → move it a\n day, Shift + ← → change its end, Alt + ← → its start; the move saves when\n the keys rest (or on Enter); Escape drops it. Enter opens a bar at rest.\n- On a phone the list pane starts hidden (the bars carry their titles);\n the list button in the chart's bar shows it at 176px. Zoom takes its own\n line. Bars scroll under a finger and drag after a short press; edge grips\n and the link dot are pointer-only (the keyboard and the menu cover them).\n- Delete (row menu) takes the subtasks too; Undo in the toast brings them\n back. A move that pushed other tasks also offers Undo.\n\n## Example\n\n```jsx\n// surfaces/plan.jsx — server: load, hand over\nimport { loadRows } from \"../lib/data\";\nimport Plan from \"../components/Plan\";\n\nexport default async function PlanSurface() {\n const { rows, error } = await loadRows({ source: \"public.tasks\", orderBy: \"start asc\" });\n const people = await loadRows({ source: \"public.people\" });\n const projects = await loadRows({ source: \"public.projects\" });\n return <Plan tasks={rows} people={people.rows} projects={projects.rows} error={error} />;\n}\n```\n\n```jsx\n// components/Plan.jsx — client: the timeline, the task sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { Sheet } from \"./ui\";\nimport { Gantt } from \"./blocks/gantt\";\nimport { changeWrites } from \"./blocks/task-model\";\n\nconst COLUMNS = [\"id\", \"title\", \"status\", \"assignees\", \"start\", \"due\", \"progress\", \"parentId\", \"dependsOn\", \"project\", \"milestone\"];\n\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: \"public.tasks\", values: w.values, detail: w.values.title })\n : w.op === \"update\" ? await updateRow({ source: \"public.tasks\", id: w.id, patch: w.patch })\n : await deleteRow({ source: \"public.tasks\", id: w.id });\n if (r?.ok === false) return r;\n }\n return { ok: true };\n}\n\nexport default function Plan({ tasks, people, projects, error }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <Gantt tasks={tasks} people={people} projects={projects} onChange={save} onOpen={setOpen} error={error}\n starters={[{ label: \"Product launch\", tasks: [{ title: \"Scope\", days: 2 }, { title: \"Build\", days: 10 }, { title: \"Launch\", milestone: true }] }]} />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>{/* the task's fields */}</Sheet>\n </>\n );\n}\n```\n\n`keepNewIds: false` lets the table make a new task's id. A link made\nbetween two tasks created in the same change (a starter) refers to their\nclient ids: keep them (`keepNewIds: true`, a text id column) when you\noffer starters.\n\nBy person, zoomed to days, coloured by status:\n\n```jsx\n<Gantt tasks={tasks} people={people} onChange={save} defaultZoom=\"day\" colorBy=\"status\" defaultView={{ groupBy: \"assignees\" }} />\n```\n\n## What it will not do\n\n- Start-to-start, finish-to-finish or lag on links: every link is finish to\n start, the next day.\n- Count working days: a bar spans calendar days; weekends are shaded, not\n skipped.\n- Baselines, resource levelling or costs.\n- Reorder rows by drag: the order is the view's sort (start date by\n default); change it in the toolbar.\n- Virtualise thousands of rows: filter, or page them in the surface.\n- Fetch: tasks come in as props; the page re-reads after a save.\n\nA timeline it cannot be is your own component in `components/`, built\nfrom task-model's parts and helpers and the ones above — not an edit to\nthis block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add gantt` restores them.\n"
41
+ "content": "# Gantt\n\nThe Gantt view of work, like Linear's timeline or TeamGantt. The task list\nsits on the left — groups that fold, subtasks indented under their parent\n— and the timeline on the right, under a two-tier date header (months over\ndays, or years over months). Each task is a bar in its project's colour,\nfilled as far as it is done; a parent is a summary bracket over its\nsubtasks; a milestone is a diamond on its day; arrows run from each task to\nthe ones waiting on it, red and dashed when the dates disagree. A line marks\ntoday; weekends are shaded.\n\nDrag a bar to move it, an edge to change its start or end, and the dot past\nits end onto another bar to make that one wait on it. Every task waiting on\na moved one slides along *while you drag*, and \"Finishes Fri, Oct 16 +3\ndays\" at the top says what the move cost. Zoom by day, week, month or\nquarter; switch on the critical path and the chain that sets the finish\nlights up while the rest fades. A task with no dates shows a ghost bar under\nthe pointer: click to schedule it there. Search, filters, group and sort\nlive in the shared toolbar above.\n\nEvery change saves through one async `onChange` and shows at once; when the\nsave fails the timeline puts it back and says why.\n\n```jsx\nimport { Gantt } from \"../components/blocks/gantt\"; // a Makersclaw app (\"./blocks/gantt\" from components/)\nimport { Gantt } from \"@/components/makersclaw/blocks/gantt\"; // 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 — `{ id, title, status, assignees, start?,\ndue?, progress?, parentId?, dependsOn?, project?, … }` — with the statuses,\npeople and projects they refer to. What the gantt reads:\n\n- `start` · `due` — the bar runs from start to due, both days included. One\n of them alone is a one-day bar; neither is an undated task (a row with no\n bar, scheduled by a click).\n- `milestone: true` (or `type: \"milestone\"`) — a diamond on its `due` day\n (else its `start`).\n- `dependsOn: [\"t7\"]` — this task waits on t7: an arrow from t7's end to\n its start (finish to start).\n- `progress` (0–100) — the bar's fill; without it, 100 when done, else\n from its subtasks (task-model's `progressOf`).\n- `parentId` — a subtask, drawn under its parent; the parent's bar is a\n summary bracket over all of them and moves them together.\n- `project` — the bar's colour (the project's `hue`, chart-1…5) when\n `colorBy` is `\"project\"`.\n\nA drag saves only `start` and `due` (and, for a link, `dependsOn`).\n\n## Props\n\n- `tasks` — the tasks. Pass `useFixture(rows, SAMPLE).rows` and the\n examples change on screen and save nothing (they are not rows).\n- `statuses` — `[{ id, label, tone?, hue?, done? }]` in order. Default: To\n do, In progress, In review, Done.\n- `people` — `[{ id, name, src? }]`.\n- `projects` — `[{ id, name, hue? }]`: `hue` 1–5 colours the bars.\n- `fields` — custom fields (`{ key, label, kind, options? }`), for the\n toolbar's filters and grouping.\n- `onChange(change)` — save a change; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed). A move that pushes\n waiting tasks is one `batch` change; a link is an `update` of\n `dependsOn`. In a Makersclaw app, loop over `changeWrites(change)` into\n insertRow / updateRow / deleteRow (task-model README, \"A change\").\n Without `onChange` the timeline only shows: no drag, links, quick-add or\n menus beyond Open.\n- `onOpen(task)` — a click on a bar or a title (Enter on a focused bar),\n and \"Open\" in the row's menu: open your `Sheet` with the task.\n- `view` · `defaultView` · `onViewChange(view)` — search, filters, group\n and sort (task-model's view). The gantt's default: grouped by project,\n sorted by start date. Group by project, status, assignee, priority or\n none.\n- `zoom` · `defaultZoom` (`\"week\"`) · `onZoomChange(zoom)` — `\"day\"` ·\n `\"week\"` · `\"month\"` · `\"quarter\"`.\n- `colorBy` — `\"project\"` (default; the project's hue, else the primary\n colour) · `\"status\"` (the status's tone) · `\"none\"`.\n- `pushDependents` — default true: moving or stretching a task later slides\n every task waiting on it (and theirs) just far enough to start the day\n after it ends, keeping their lengths. Nothing is pulled earlier. False:\n only the dragged task moves, and conflicts show as red arrows.\n- `defaultCritical` — default false: start with the critical path on.\n- `newTaskLength` — default 3: the days a quick-added or click-scheduled\n task spans (a quick-added task starts today).\n- `today` — `\"YYYY-MM-DD\"`: the today line and where the timeline opens.\n Default the browser's today; fix it in a demo.\n- `weekStartsOn` (default 1, Monday) · `weekend` (default `[0, 6]`) ·\n `holidays` — the header's weeks and the shaded days.\n- `toolbar` — default true; false hides search, filter, group, sort and\n \"Add task\". `toolbarExtra` — your controls before \"Add task\".\n- `addLabel` — default `\"Add task\"`.\n- `starters` — plans the empty state offers:\n `[{ label, icon?, tasks: [{ title, days?, milestone?, parallel? }] }]`.\n Tasks run one after another from today, each waiting on the one before;\n `parallel: true` starts with the previous one instead. One press\n creates them all in one change.\n- `emptyTitle` · `emptyBody` — the empty state's words.\n- `height` — default 560: the timeline's most height in pixels (never\n more than 75% of the window); it scrolls inside.\n- `loading` — skeleton bars in place of the timeline.\n- `error` — a danger Notice above it (a failed read).\n- `label` — what it is called to a screen reader, default `\"Timeline\"`.\n- `locale` — dates in the header and labels; default the browser's.\n- `className` — layout around it (width, margin). Never colour.\n\nThe helpers it draws with are exported for a view of your own:\n`ZOOMS`, `ganttRange(tasks, zoom, today)`, `headerTiers(from, to, zoom)`,\n`planMove(tasks, id, \"move\" | \"start\" | \"end\", days, { push })` (what a\ndrag saves, as `[{ id, patch }]`), `afterBlockersPatch(task, tasks)`,\n`isMilestone`, `summarySpan`, `arrowPath(boxA, boxB)`.\n\n## What it does, precisely\n\n- Moving snaps to whole days at every zoom; the dates show beside the bar\n while it moves. Resizing never crosses the other edge (a bar is at least\n one day). A milestone and a summary bracket move but do not resize; a\n summary moves its whole family.\n- Linking: drag the dot past a bar's end onto another row. A link that\n would loop is refused with a notice. The row menu's \"Waits on\" ticks and\n unticks links from the keyboard.\n- A red, dashed arrow means the waiting task starts before the other ends;\n its row shows a warning and its menu offers \"Start after what it waits\n on\".\n- The critical path is the longest chain of linked, dated work\n (task-model's `criticalPath`): its bars ring in the primary colour, its\n arrows thicken, everything else fades, and a line under the chart says\n how long it is.\n- A task in two groups (two assignees, grouped by person) shows in both;\n the first row drags, the second only opens.\n- Keyboard: Tab reaches the titles, menus and bars. On a bar, ← → move it a\n day, Shift + ← → change its end, Alt + ← → its start; the move saves when\n the keys rest (or on Enter); Escape drops it. Enter opens a bar at rest.\n- On a phone the list pane starts hidden (the bars carry their titles);\n the list button in the chart's bar shows it at 176px. Zoom takes its own\n line. Bars scroll under a finger and drag after a short press; edge grips\n and the link dot are pointer-only (the keyboard and the menu cover them).\n- Delete (row menu) takes the subtasks too; Undo in the toast brings them\n back. A move that pushed other tasks also offers Undo.\n\n## Example\n\n```jsx\n// surfaces/plan.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Plan from \"../components/Plan\";\n\nexport default function PlanSurface() {\n const tasks = useRows({ source: \"tasks\", orderBy: \"start asc\" });\n const people = useRows({ source: \"people\" });\n const projects = useRows({ source: \"projects\" });\n return (\n <Plan tasks={tasks.rows} people={people.rows} projects={projects.rows}\n loading={tasks.loading || people.loading || projects.loading} error={tasks.error} />\n );\n}\n```\n\n```jsx\n// components/Plan.jsx — client: the timeline, the task sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { Sheet } from \"./ui\";\nimport { Gantt } from \"./blocks/gantt\";\nimport { changeWrites } from \"./blocks/task-model\";\n\nconst COLUMNS = [\"id\", \"title\", \"status\", \"assignees\", \"start\", \"due\", \"progress\", \"parentId\", \"dependsOn\", \"project\", \"milestone\"];\n\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 Plan({ tasks, people, projects, loading, error }) {\n const [open, setOpen] = useState(null);\n return (\n <>\n <Gantt tasks={tasks} people={people} projects={projects} onChange={save} onOpen={setOpen} loading={loading} error={error}\n starters={[{ label: \"Product launch\", tasks: [{ title: \"Scope\", days: 2 }, { title: \"Build\", days: 10 }, { title: \"Launch\", milestone: true }] }]} />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.title}>{/* the task's fields */}</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`keepNewIds: false` lets the table make a new task's id. A link made\nbetween two tasks created in the same change (a starter) refers to their\nclient ids: keep them (`keepNewIds: true`, a text id column) when you\noffer starters.\n\nBy person, zoomed to days, coloured by status:\n\n```jsx\n<Gantt tasks={tasks} people={people} onChange={save} defaultZoom=\"day\" colorBy=\"status\" defaultView={{ groupBy: \"assignees\" }} />\n```\n\n## What it will not do\n\n- Start-to-start, finish-to-finish or lag on links: every link is finish to\n start, the next day.\n- Count working days: a bar spans calendar days; weekends are shaded, not\n skipped.\n- Baselines, resource levelling or costs.\n- Reorder rows by drag: the order is the view's sort (start date by\n default); change it in the toolbar.\n- Virtualise thousands of rows: filter, or page them in the surface.\n- Fetch: tasks come in as props; the page re-reads after a save.\n\nA timeline it cannot be is your own component in `components/`, built\nfrom task-model's parts and helpers and the ones above — not an edit to\nthis block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add gantt` restores them.\n"
42
42
  }
43
43
  ],
44
44
  "categories": [
@@ -63,7 +63,7 @@
63
63
  "docs": "Installed to components/makersclaw/blocks/gantt. Import: import { Gantt, ZOOMS, ganttRange, headerTiers, planMove, afterBlockersPatch, isMilestone, summarySpan, arrowPath } from \"@/components/makersclaw/blocks/gantt\". Props and an example: components/makersclaw/blocks/gantt/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
64
64
  "meta": {
65
65
  "makersclaw": {
66
- "version": "0.5.2",
66
+ "version": "0.7.0",
67
67
  "useWhen": "A screen that plans dated work and shows what waits on what: a launch plan, a project schedule, a content calendar with lead times, an office move, a release. Not for an undated to-do list (task-list), stage columns (board), or days on a month grid (calendar)."
68
68
  }
69
69
  }
package/r/job-panel.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "JobPanel",
6
6
  "description": "The panel that follows work the founder asked the app's agent for (useJob): each step's label as it runs, Stop, \"Needs your answer\" with Answer while the agent waits on the founder, \"Queued · after …\" while it waits its turn, then done or the reason it stopped — and nothing on screen when no job runs. variant=\"generator\" wraps it in a prompt, the surface's options, Generate and a results shelf, with no model, provider or API key of its own.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -25,7 +25,7 @@
25
25
  "path": "registry/blocks/job-panel/README.md",
26
26
  "type": "registry:file",
27
27
  "target": "@components/makersclaw/blocks/job-panel/README.md",
28
- "content": "# JobPanel\n\nThe panel that follows work the founder asked the app's agent for. It\nshows each step's label as the agent works, a Stop button while it runs,\nthen \"done\" (closing itself a few seconds later) or the reason it\nstopped. When no job runs it renders nothing — a panel that is always\nthere is a monitoring console, and apps do not have one.\n\nThe conversation is not here. Since 0.4.0 a job is a request, and its\nconversation lives in the desk's agent column: the agent's questions,\nthe founder's answers and the yes before it acts are all there. The\npanel says where the work is and points there — \"Needs your answer\"\nwith an Answer button when the agent waits on the founder, \"Queued ·\nafter …\" when it waits its turn.\n\n```jsx\nimport { JobPanel } from \"../components/blocks/job-panel\"; // a Makersclaw app (\"./blocks/job-panel\" from components/)\nimport { JobPanel } from \"@/components/makersclaw/blocks/job-panel\"; // a shadcn project\n```\n\nA client component. It takes the object `useJob()` returns (lib/hooks.js,\nREADME §5 \"Commissioning work\") and draws it; starting the job stays with\nyour surface.\n\n`variant=\"generator\"` is the panel inside a small studio: a prompt, your\noptions, Generate, the running job, and a shelf of what it made. It holds\nno model, provider or API key: Generate hands the prompt to the app's\nagent (`job.start`, or your `onGenerate`), the agent writes rows, and you\npass those rows back in as `results`.\n\n## Props\n\n- `job` — what `useJob()` returns. Required. Outside Makersclaw, any\n object of the same shape: `running`, `steps` (`[{ name, label, status:\n \"running\" | \"done\" | \"failed\" }]`), `text`, `error`, `finishedAt`, and\n `cancel()` / `reset()`. Optional, read when present (useJob sets them\n since 0.4.0): `needsYou`, `queued`, `queuedAfter` (`{ after_title }`)\n and `open()` — see \"Needs you and queued\" below. An object without\n them (the 0.3.x shape) draws exactly as before.\n- `title` — while it runs, what the agent is doing, in the founder's\n words: `\"Drafting the launch plan\"`. Default `\"Working on it\"`.\n- `doneTitle` — after a clean finish: `\"Launch plan drafted\"`.\n- `failedTitle` — when it stopped with an error; the error follows it.\n- `needsTitle` — while the agent waits on the founder's answer. Default\n `\"Needs your answer\"`.\n- `onRetry` — shows \"Try again\" beside Close when the job failed: start\n the same job again.\n- `autoClose` — milliseconds after a clean finish before the panel\n closes itself (`job.reset()`); default 6000, `false` to keep it until\n Close.\n- `maxSteps` — the newest steps shown, default 6; older ones become one\n \"N earlier steps\" line.\n- `showText` — the agent's own account under the steps, default true. It\n is what the agent says it did, never the result: the result is rows.\n It hides itself when `job.text` is empty, as it is on a request: the\n account is in the desk's agent column.\n- `className` — layout around it. Never colour.\n\n### The generator (`variant=\"generator\"`)\n\nEverything above (`job`, `title`, `doneTitle`, `failedTitle`,\n`needsTitle`, `onRetry`, `autoClose`, `maxSteps`, `showText`) goes to the\npanel inside it. Then:\n\n- `onGenerate(prompt)` — start the job. Build the ask from the prompt and\n your options and call `job.start(ask)`; return what it returns, or\n `{ ok: false, error }` and the reason shows under the prompt. Default:\n `job.start(prompt)`. Cmd/Ctrl+Enter in the prompt presses Generate.\n Without `onRetry`, the panel's \"Try again\" re-runs the last prompt.\n- `prompt` / `onPromptChange(text)` — control the prompt; or\n `defaultPrompt`. `promptLabel` (\"What should it make?\"), `placeholder`,\n `promptHint`, `promptRows` (4).\n- `options` — your fields, in a two-column grid under the prompt (one\n column on a phone): a size `Select`, a style `ToggleGroup`, a `Slider`.\n Their values are yours; read them in `onGenerate`.\n- `generateLabel` (\"Generate\") · `generateIcon` (\"wand\") ·\n `canGenerateEmpty` (false) · `disabled` (false).\n- `heading` · `description` — the form card's title and line under it.\n- `aside` — a node under the form: tips, a note on cost.\n- `results` — what the job made, as nodes (an array of cards, say). The\n shelf draws them in `resultsColumns` columns (1–4; fewer on a phone).\n `resultsTitle` (\"Results\") and `resultsActions` (nodes beside it).\n- `placeholderResult` — drawn at the top of the shelf while the job runs\n (default a skeleton); the shelf is `aria-busy` meanwhile.\n- `emptyTitle` · `emptyBody` · `emptyState` — the shelf before anything\n is made.\n- `layout` — `\"stacked\"` (default: the form, then the shelf) or\n `\"split\"` (side by side from lg up; stacked on a phone).\n\n## Needs you and queued\n\nA request can be live and still not moving. The panel draws two such\nstates from the job, and both count as running: no Close, Generate\nstays off, and the panel stays until the job moves on.\n\n- **Needs you** (`job.needsYou`) — the agent stopped to ask the founder\n something, or for a yes before it acts. The heading is `needsTitle`\n (\"Needs your answer\"), the panel takes the warning tone, a line says\n the question is in the agent column, and **Answer** takes Stop's\n place: it calls `job.open()`, which opens the request's conversation\n in the desk's agent column. The founder answers there (or stops it\n there), never in the app; the job then runs on and the panel follows\n it. A job with no `open()` has nowhere to point, so Stop stays.\n- **Queued** (`job.queued`) — one job per list runs at a time, so this\n one waits behind another. The heading names the job it waits for:\n \"Queued · after Find 20 leads\" (`job.queuedAfter.after_title`, else\n \"the job before it\"). Stop stays: it takes the job out of the queue.\n\nWhen it runs again the heading is back to `title`, and the one step\nunder it is the request's line (\"Drafting · 12 of 50\").\n\n## Example\n\n```jsx\n\"use client\";\nimport { useEffect } from \"react\";\nimport { useJob } from \"../lib/hooks\";\nimport { Button } from \"./ui\";\nimport { JobPanel } from \"./blocks/job-panel\";\n\nconst ASK = \"Draft a launch plan for the next product in the list, with dates and owners.\";\n\nexport default function Launches({ launches, reload }) {\n const job = useJob();\n useEffect(() => { reload?.(); }, [job.progress]); // rows appear as they land\n\n return (\n <div className=\"grid gap-4\">\n <div className=\"flex flex-wrap gap-2\">\n <Button variant=\"secondary\" onClick={() => job.start(ASK)} disabled={job.running}>Generate a launch plan</Button>\n <Button variant=\"primary\" icon=\"plus\" onClick={openNewLaunch}>New launch plan</Button>\n </div>\n <JobPanel job={job} title=\"Drafting the launch plan\" doneTitle=\"Launch plan drafted\" onRetry={() => job.start(ASK)} />\n {/* the launches list — the job's result lands here */}\n </div>\n );\n}\n```\n\nThe verb sits on the object list beside the manual \"New\" (README §5, rule\n1), and the panel sits where the result will appear.\n\nA generator for product photos:\n\n```jsx\n\"use client\";\nimport { useEffect, useState } from \"react\";\nimport { useJob } from \"../lib/hooks\";\nimport { Field, Image, Select } from \"./ui\";\nimport { JobPanel } from \"./blocks/job-panel\";\n\nexport default function PhotoStudio({ photos, reload }) {\n const job = useJob();\n const [size, setSize] = useState(\"square\");\n useEffect(() => { reload?.(); }, [job.progress]); // new photos appear as they land\n\n return (\n <JobPanel\n variant=\"generator\"\n job={job}\n heading=\"New product photo\"\n promptLabel=\"Describe the photo\"\n options={\n <Field id=\"size\" label=\"Size\">\n <Select id=\"size\" value={size} onChange={(e) => setSize(e.target.value)}\n options={[{ value: \"square\", label: \"Square\" }, { value: \"portrait\", label: \"Portrait\" }]} />\n </Field>\n }\n onGenerate={(prompt) => job.start(`Make one ${size} product photo: ${prompt}. Save it to public.photos.`)}\n title=\"Making the photo\"\n doneTitle=\"Photo saved\"\n results={photos.map((p) => <Image key={p.id} src={p.url} alt={p.prompt} ratio=\"square\" rounded=\"lg\" />)}\n resultsColumns={3}\n />\n );\n}\n```\n\nWhat it will not do: call a model, hold a provider or an API key, or\nkeep what was made. The agent does the work through the job; the rows\nare the results.\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw add\njob-panel` restores them. A different panel is your own component in\n`components/`, built from the kit.\n\n## Credits\n\nThe generator variant is ported from Creative Tim UI (https://github.com/creativetimofficial/ui, ai-image-generator-01 and ai-video-generator-01, MIT), rewritten on the Makersclaw kit without its provider and API-key fields.\n"
28
+ "content": "# JobPanel\n\nThe panel that follows work the founder asked the app's agent for. It\nshows each step's label as the agent works, a Stop button while it runs,\nthen \"done\" (closing itself a few seconds later) or the reason it\nstopped. When no job runs it renders nothing — a panel that is always\nthere is a monitoring console, and apps do not have one.\n\nThe conversation is not here. Since 0.4.0 a job is a request, and its\nconversation lives in the desk's agent column: the agent's questions,\nthe founder's answers and the yes before it acts are all there. The\npanel says where the work is and points there — \"Needs your answer\"\nwith an Answer button when the agent waits on the founder, \"Queued ·\nafter …\" when it waits its turn.\n\n```jsx\nimport { JobPanel } from \"../components/blocks/job-panel\"; // a Makersclaw app (\"./blocks/job-panel\" from components/)\nimport { JobPanel } from \"@/components/makersclaw/blocks/job-panel\"; // a shadcn project\n```\n\nA client component. It takes the object `useJob()` returns (lib/hooks.js,\nREADME §5 \"Commissioning work\") and draws it; starting the job stays with\nyour surface.\n\n`variant=\"generator\"` is the panel inside a small studio: a prompt, your\noptions, Generate, the running job, and a shelf of what it made. It holds\nno model, provider or API key: Generate hands the prompt to the app's\nagent (`job.start`, or your `onGenerate`), the agent writes rows, and you\npass those rows back in as `results`.\n\n## Props\n\n- `job` — what `useJob()` returns. Required. Outside Makersclaw, any\n object of the same shape: `running`, `steps` (`[{ name, label, status:\n \"running\" | \"done\" | \"failed\" }]`), `text`, `error`, `finishedAt`, and\n `cancel()` / `reset()`. Optional, read when present (useJob sets them\n since 0.4.0): `needsYou`, `queued`, `queuedAfter` (`{ after_title }`)\n and `open()` — see \"Needs you and queued\" below. An object without\n them (the 0.3.x shape) draws exactly as before.\n- `title` — while it runs, what the agent is doing, in the founder's\n words: `\"Drafting the launch plan\"`. Default `\"Working on it\"`.\n- `doneTitle` — after a clean finish: `\"Launch plan drafted\"`.\n- `failedTitle` — when it stopped with an error; the error follows it.\n- `needsTitle` — while the agent waits on the founder's answer. Default\n `\"Needs your answer\"`.\n- `onRetry` — shows \"Try again\" beside Close when the job failed: start\n the same job again.\n- `autoClose` — milliseconds after a clean finish before the panel\n closes itself (`job.reset()`); default 6000, `false` to keep it until\n Close.\n- `maxSteps` — the newest steps shown, default 6; older ones become one\n \"N earlier steps\" line.\n- `showText` — the agent's own account under the steps, default true. It\n is what the agent says it did, never the result: the result is rows.\n It hides itself when `job.text` is empty, as it is on a request: the\n account is in the desk's agent column.\n- `className` — layout around it. Never colour.\n\n### The generator (`variant=\"generator\"`)\n\nEverything above (`job`, `title`, `doneTitle`, `failedTitle`,\n`needsTitle`, `onRetry`, `autoClose`, `maxSteps`, `showText`) goes to the\npanel inside it. Then:\n\n- `onGenerate(prompt)` — start the job. Build the ask from the prompt and\n your options and call `job.start(ask)`; return what it returns, or\n `{ ok: false, error }` and the reason shows under the prompt. Default:\n `job.start(prompt)`. Cmd/Ctrl+Enter in the prompt presses Generate.\n Without `onRetry`, the panel's \"Try again\" re-runs the last prompt.\n- `prompt` / `onPromptChange(text)` — control the prompt; or\n `defaultPrompt`. `promptLabel` (\"What should it make?\"), `placeholder`,\n `promptHint`, `promptRows` (4).\n- `options` — your fields, in a two-column grid under the prompt (one\n column on a phone): a size `Select`, a style `ToggleGroup`, a `Slider`.\n Their values are yours; read them in `onGenerate`.\n- `generateLabel` (\"Generate\") · `generateIcon` (\"wand\") ·\n `canGenerateEmpty` (false) · `disabled` (false).\n- `heading` · `description` — the form card's title and line under it.\n- `aside` — a node under the form: tips, a note on cost.\n- `results` — what the job made, as nodes (an array of cards, say). The\n shelf draws them in `resultsColumns` columns (1–4; fewer on a phone).\n `resultsTitle` (\"Results\") and `resultsActions` (nodes beside it).\n- `placeholderResult` — drawn at the top of the shelf while the job runs\n (default a skeleton); the shelf is `aria-busy` meanwhile.\n- `emptyTitle` · `emptyBody` · `emptyState` — the shelf before anything\n is made.\n- `layout` — `\"stacked\"` (default: the form, then the shelf) or\n `\"split\"` (side by side from lg up; stacked on a phone).\n\n## Needs you and queued\n\nA request can be live and still not moving. The panel draws two such\nstates from the job, and both count as running: no Close, Generate\nstays off, and the panel stays until the job moves on.\n\n- **Needs you** (`job.needsYou`) — the agent stopped to ask the founder\n something, or for a yes before it acts. The heading is `needsTitle`\n (\"Needs your answer\"), the panel takes the warning tone, a line says\n the question is in the agent column, and **Answer** takes Stop's\n place: it calls `job.open()`, which opens the request's conversation\n in the desk's agent column. The founder answers there (or stops it\n there), never in the app; the job then runs on and the panel follows\n it. A job with no `open()` has nowhere to point, so Stop stays.\n- **Queued** (`job.queued`) — one job per list runs at a time, so this\n one waits behind another. The heading names the job it waits for:\n \"Queued · after Find 20 leads\" (`job.queuedAfter.after_title`, else\n \"the job before it\"). Stop stays: it takes the job out of the queue.\n\nWhen it runs again the heading is back to `title`, and the one step\nunder it is the request's line (\"Drafting · 12 of 50\").\n\n## Example\n\n```jsx\n\"use client\";\nimport { useEffect } from \"react\";\nimport { useJob } from \"../lib/hooks\";\nimport { Button } from \"./ui\";\nimport { JobPanel } from \"./blocks/job-panel\";\n\nconst ASK = \"Draft a launch plan for the next product in the list, with dates and owners.\";\n\nexport default function Launches({ launches, reload }) {\n const job = useJob();\n useEffect(() => { reload?.(); }, [job.progress]); // rows appear as they land\n\n return (\n <div className=\"grid gap-4\">\n <div className=\"flex flex-wrap gap-2\">\n <Button variant=\"secondary\" onClick={() => job.start(ASK)} disabled={job.running}>Generate a launch plan</Button>\n <Button variant=\"primary\" icon=\"plus\" onClick={openNewLaunch}>New launch plan</Button>\n </div>\n <JobPanel job={job} title=\"Drafting the launch plan\" doneTitle=\"Launch plan drafted\" onRetry={() => job.start(ASK)} />\n {/* the launches list — the job's result lands here */}\n </div>\n );\n}\n```\n\nThe verb sits on the object list beside the manual \"New\" (README §5, rule\n1), and the panel sits where the result will appear.\n\nA generator for product photos:\n\n```jsx\n\"use client\";\nimport { useEffect, useState } from \"react\";\nimport { useJob } from \"../lib/hooks\";\nimport { Field, Image, Select } from \"./ui\";\nimport { JobPanel } from \"./blocks/job-panel\";\n\nexport default function PhotoStudio({ photos, reload }) {\n const job = useJob();\n const [size, setSize] = useState(\"square\");\n useEffect(() => { reload?.(); }, [job.progress]); // new photos appear as they land\n\n return (\n <JobPanel\n variant=\"generator\"\n job={job}\n heading=\"New product photo\"\n promptLabel=\"Describe the photo\"\n options={\n <Field id=\"size\" label=\"Size\">\n <Select id=\"size\" value={size} onChange={(e) => setSize(e.target.value)}\n options={[{ value: \"square\", label: \"Square\" }, { value: \"portrait\", label: \"Portrait\" }]} />\n </Field>\n }\n onGenerate={(prompt) => job.start(`Make one ${size} product photo: ${prompt}. Save it to the photos table.`)}\n title=\"Making the photo\"\n doneTitle=\"Photo saved\"\n results={photos.map((p) => <Image key={p.id} src={p.url} alt={p.prompt} ratio=\"square\" rounded=\"lg\" />)}\n resultsColumns={3}\n />\n );\n}\n```\n\nWhat it will not do: call a model, hold a provider or an API key, or\nkeep what was made. The agent does the work through the job; the rows\nare the results.\n\nIts files are fixed: `npm run check` refuses a change, and `makersclaw add\njob-panel` restores them. A different panel is your own component in\n`components/`, built from the kit.\n\n## Credits\n\nThe generator variant is ported from Creative Tim UI (https://github.com/creativetimofficial/ui, ai-image-generator-01 and ai-video-generator-01, MIT), rewritten on the Makersclaw kit without its provider and API-key fields.\n"
29
29
  }
30
30
  ],
31
31
  "categories": [
@@ -52,7 +52,7 @@
52
52
  "docs": "Installed to components/makersclaw/blocks/job-panel. Import: import { JobPanel } from \"@/components/makersclaw/blocks/job-panel\". Props and an example: components/makersclaw/blocks/job-panel/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
53
53
  "meta": {
54
54
  "makersclaw": {
55
- "version": "0.5.2",
55
+ "version": "0.7.0",
56
56
  "useWhen": "A surface has a verb that asks the agent to make or find something (\"Generate a launch plan\", \"Find 20 leads\", \"Plan next sprint\") and the founder should watch it happen where the result will land. variant=\"generator\" when the screen IS the place to ask: a prompt and a few options, the results under them (product photos, ad copy, names)."
57
57
  }
58
58
  }
@@ -5,8 +5,8 @@
5
5
  "title": "JsonEditor",
6
6
  "description": "A JSON editor in the workspace theme with two views of one value: a Tree of folding rows (edit a value or rename a key in place, add keys and items, change a type, copy a path, delete with Undo, arrow keys throughout) and Code (the code-editor block in JSON mode). Checked as it changes: the status line says what is wrong in plain words and which line, and clicking it goes there. A Format button, copy as pretty or one-line JSON, a friendly empty state with starters, and an optional Save that is optimistic and rolls back with a notice when it fails.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/code-editor.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/code-editor.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -38,7 +38,7 @@
38
38
  "path": "registry/blocks/json-editor/README.md",
39
39
  "type": "registry:file",
40
40
  "target": "@components/makersclaw/blocks/json-editor/README.md",
41
- "content": "# JsonEditor\n\nA JSON editor with two views of one value. **Tree** shows it as folding\nrows: double-click a value to edit it, double-click a key to rename it,\n`+` adds a key or an item, and each row's ⋯ menu copies its path\n(`deal.owner.email`) or value, changes its type, or deletes it — with Undo\nin a toast. **Code** is the text, in the `code-editor` block's JSON mode.\n\nIt is checked as it changes. The status line under the title says\n`Valid JSON · 5 keys`, or what is wrong in plain words and where —\n`Line 4: A comma is missing before this` — and clicking it opens the Code\nview at that spot. While the text does not parse the last valid value\nstands and the Tree view waits. Format lays the text out again (keys,\norder and numbers as written); the ⋯ menu copies it pretty or on one\nline, and expands or collapses the tree. Empty, it offers a new object, a\nnew list, or a place to paste.\n\nBuilt on `code-editor` (which `add` installs with it); it never imports\nCodeMirror itself.\n\n```jsx\nimport { JsonEditor } from \"../components/blocks/json-editor\"; // a Makersclaw app (\"./blocks/json-editor\" from components/)\nimport { JsonEditor } from \"@/components/makersclaw/blocks/json-editor\"; // a shadcn project\n```\n\n`JsonEditor` is a client component: render it from a `\"use client\"`\ncomponent. The tree helpers (`getAt`, `setAt`, `pathLabel`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA plain JSON value: an object, a list, text, a number, true/false or null\n— what `JSON.parse` gives and a `jsonb` column stores. The editor hands\nback a new value on every valid edit and never changes the one you gave\nit.\n\nTwo things JavaScript does to any JSON value, here too: keys that look\nlike whole numbers (`\"2\"`) come first in an object, and numbers past 2^53\nlose precision. The Code view's text keeps both as typed until it is\nedited from the Tree.\n\n## Props\n\n- `value` — the JSON value, controlled. A new `value` that is not what\n the editor last sent through `onChange` is another record: it replaces\n the text, the folds and the saved state.\n- `defaultValue` — the starting value, uncontrolled. With neither, the\n editor starts empty.\n- `onChange(value)` — every valid edit, with the whole value. A keystroke\n that leaves the text broken sends nothing.\n- `onSave(value) → { ok, error? }` — optional, may be async. Shows a Save\n button (and Cmd/Ctrl+S) that is live when there are valid unsaved\n changes. It is optimistic: the status says Saved at once; `{ ok: false,\n error }` (or a throw) puts \"Unsaved changes\" back and shows `error` in a\n notice with Try again. Without `onSave`, save through `onChange`\n yourself.\n- `view` / `defaultView` — `\"tree\"` (default) or `\"code\"`, controlled or\n not; `onViewChange(view)` hears the toggle.\n- `readOnly` — `false` (default). `true`: the tree only folds and copies,\n the text only selects and copies.\n- `title` — a heading above the status line (\"Webhook payload\").\n- `expandDepth` — how many levels the tree opens with, default `2`\n (`0` shows only the top level).\n- `height` — a fixed height (px number or CSS length) for both views.\n Without it they grow to `maxHeight` (480), then scroll.\n- `placeholder` — the empty Code view's hint.\n- `label` — what a screen reader calls the tree and the editor (default:\n `title`, else \"JSON\").\n- `className` — the frame's classes.\n\n### Keys\n\nIn the tree (one Tab stop): ↑ ↓ move · → open, then into it · ← close,\nthen to the parent · Home / End · Enter or Space opens a folder or edits a\nvalue · F2 renames a key · Delete removes the row (Undo in the toast).\nEditing: Enter keeps it, Esc puts it back. Cmd/Ctrl+S saves (with\n`onSave`); the Code view keeps every `code-editor` key (Shift+Alt+F\nformats).\n\n### Editing a value\n\nText is edited bare (no quotes) and stays text. Anything else is read as\nJSON: `42`, `true`, `null`, `[1, 2]`, `{\"a\": 1}`; what does not read stays\nas text. To turn text into a number (or a list into an object…), use the\nrow's ⋯ → Change type, which keeps what it can.\n\n## The helpers\n\n`typeOf(v)`, `pathLabel(path)` (`[\"deal\", \"tags\", 0]` → `deal.tags[0]`),\n`getAt(root, path)`, `setAt(root, path, v)`, `removeAt(root, path)` →\n`{ root, removed, index }`, `insertAt(root, parentPath, { key?, index?,\nvalue })`, `renameKey(root, path, to)` → `{ root }` or `{ error }`,\n`flattenTree(value, openIds)`, `expandedTo(value, depth)`, `summary(v)`\n(\"3 keys\"), `preview(v)`. Every edit returns a new root. `JsonTree` is the\ntree on its own (`value`, `onEdit(next, { undo?, message? })`, `open`,\n`setOpen`, `readOnly`) for a screen that wants rows without the frame.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { JsonEditor } from \"../components/blocks/json-editor\";\nimport { saveIntegrationSettings } from \"../lib/actions\"; // your server action → { ok, error? }\n\nexport function WebhookSettings({ integration }) {\n const [settings, setSettings] = useState(integration.settings);\n return (\n <JsonEditor\n title=\"Webhook settings\"\n value={settings}\n onChange={setSettings}\n onSave={(v) => saveIntegrationSettings(integration.id, v)}\n />\n );\n}\n```\n\nInspect a payload, read-only, as text first:\n\n```jsx\n<JsonEditor title=\"Last delivery\" value={delivery.body} readOnly defaultView=\"code\" height={320} />\n```\n\n## What it will not do\n\n- No JSON Schema: it checks that the text is JSON, not that it has the\n keys your app needs. Check that in `onSave` and return `{ ok: false,\n error: \"…\" }`.\n- No drag to reorder rows; order changes in the Code view.\n- It does not diff two values or merge edits from two people.\n- Very large documents (tens of thousands of rows) are better read in the\n Code view; the tree draws every open row.\n"
41
+ "content": "# JsonEditor\n\nA JSON editor with two views of one value. **Tree** shows it as folding\nrows: double-click a value to edit it, double-click a key to rename it,\n`+` adds a key or an item, and each row's ⋯ menu copies its path\n(`deal.owner.email`) or value, changes its type, or deletes it — with Undo\nin a toast. **Code** is the text, in the `code-editor` block's JSON mode.\n\nIt is checked as it changes. The status line under the title says\n`Valid JSON · 5 keys`, or what is wrong in plain words and where —\n`Line 4: A comma is missing before this` — and clicking it opens the Code\nview at that spot. While the text does not parse the last valid value\nstands and the Tree view waits. Format lays the text out again (keys,\norder and numbers as written); the ⋯ menu copies it pretty or on one\nline, and expands or collapses the tree. Empty, it offers a new object, a\nnew list, or a place to paste.\n\nBuilt on `code-editor` (which `add` installs with it); it never imports\nCodeMirror itself.\n\n```jsx\nimport { JsonEditor } from \"../components/blocks/json-editor\"; // a Makersclaw app (\"./blocks/json-editor\" from components/)\nimport { JsonEditor } from \"@/components/makersclaw/blocks/json-editor\"; // a shadcn project\n```\n\n`JsonEditor` is a client component: render it from a `\"use client\"`\ncomponent. The tree helpers (`getAt`, `setAt`, `pathLabel`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA plain JSON value: an object, a list, text, a number, true/false or null\n— what `JSON.parse` gives and a `jsonb` column stores. The editor hands\nback a new value on every valid edit and never changes the one you gave\nit.\n\nTwo things JavaScript does to any JSON value, here too: keys that look\nlike whole numbers (`\"2\"`) come first in an object, and numbers past 2^53\nlose precision. The Code view's text keeps both as typed until it is\nedited from the Tree.\n\n## Props\n\n- `value` — the JSON value, controlled. A new `value` that is not what\n the editor last sent through `onChange` is another record: it replaces\n the text, the folds and the saved state.\n- `defaultValue` — the starting value, uncontrolled. With neither, the\n editor starts empty.\n- `onChange(value)` — every valid edit, with the whole value. A keystroke\n that leaves the text broken sends nothing.\n- `onSave(value) → { ok, error? }` — optional, may be async. Shows a Save\n button (and Cmd/Ctrl+S) that is live when there are valid unsaved\n changes. It is optimistic: the status says Saved at once; `{ ok: false,\n error }` (or a throw) puts \"Unsaved changes\" back and shows `error` in a\n notice with Try again. Without `onSave`, save through `onChange`\n yourself.\n- `view` / `defaultView` — `\"tree\"` (default) or `\"code\"`, controlled or\n not; `onViewChange(view)` hears the toggle.\n- `readOnly` — `false` (default). `true`: the tree only folds and copies,\n the text only selects and copies.\n- `title` — a heading above the status line (\"Webhook payload\").\n- `expandDepth` — how many levels the tree opens with, default `2`\n (`0` shows only the top level).\n- `height` — a fixed height (px number or CSS length) for both views.\n Without it they grow to `maxHeight` (480), then scroll.\n- `placeholder` — the empty Code view's hint.\n- `label` — what a screen reader calls the tree and the editor (default:\n `title`, else \"JSON\").\n- `className` — the frame's classes.\n\n### Keys\n\nIn the tree (one Tab stop): ↑ ↓ move · → open, then into it · ← close,\nthen to the parent · Home / End · Enter or Space opens a folder or edits a\nvalue · F2 renames a key · Delete removes the row (Undo in the toast).\nEditing: Enter keeps it, Esc puts it back. Cmd/Ctrl+S saves (with\n`onSave`); the Code view keeps every `code-editor` key (Shift+Alt+F\nformats).\n\n### Editing a value\n\nText is edited bare (no quotes) and stays text. Anything else is read as\nJSON: `42`, `true`, `null`, `[1, 2]`, `{\"a\": 1}`; what does not read stays\nas text. To turn text into a number (or a list into an object…), use the\nrow's ⋯ → Change type, which keeps what it can.\n\n## The helpers\n\n`typeOf(v)`, `pathLabel(path)` (`[\"deal\", \"tags\", 0]` → `deal.tags[0]`),\n`getAt(root, path)`, `setAt(root, path, v)`, `removeAt(root, path)` →\n`{ root, removed, index }`, `insertAt(root, parentPath, { key?, index?,\nvalue })`, `renameKey(root, path, to)` → `{ root }` or `{ error }`,\n`flattenTree(value, openIds)`, `expandedTo(value, depth)`, `summary(v)`\n(\"3 keys\"), `preview(v)`. Every edit returns a new root. `JsonTree` is the\ntree on its own (`value`, `onEdit(next, { undo?, message? })`, `open`,\n`setOpen`, `readOnly`) for a screen that wants rows without the frame.\n\n## Example\n\n```jsx\n// surfaces/webhook.jsx — client: one integration's settings\n\"use client\";\nimport { useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { JsonEditor } from \"../components/blocks/json-editor\";\n\nexport default function WebhookSurface() {\n const id = useSearchParams().get(\"row\");\n const { row: integration, loading } = useRow({ source: \"integrations\", id });\n if (loading || !integration) return null;\n return <WebhookSettings key={integration.id} integration={integration} />;\n}\n\nfunction WebhookSettings({ integration }) {\n const [settings, setSettings] = useState(integration.settings); // jsonb; read once per integration\n return (\n <JsonEditor\n title=\"Webhook settings\"\n value={settings}\n onChange={setSettings}\n onSave={(v) => updateRow({ source: \"integrations\", id: integration.id, patch: { settings: v } })}\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\nInspect a payload, read-only, as text first:\n\n```jsx\n<JsonEditor title=\"Last delivery\" value={delivery.body} readOnly defaultView=\"code\" height={320} />\n```\n\n## What it will not do\n\n- No JSON Schema: it checks that the text is JSON, not that it has the\n keys your app needs. Check that in `onSave` and return `{ ok: false,\n error: \"…\" }`.\n- No drag to reorder rows; order changes in the Code view.\n- It does not diff two values or merge edits from two people.\n- Very large documents (tens of thousands of rows) are better read in the\n Code view; the tree draws every open row.\n"
42
42
  }
43
43
  ],
44
44
  "categories": [
@@ -59,7 +59,7 @@
59
59
  "docs": "Installed to components/makersclaw/blocks/json-editor. Import: import { JsonEditor, JsonTree, typeOf, pathLabel, getAt, setAt, removeAt, insertAt, renameKey, flattenTree, expandedTo, summary, preview } from \"@/components/makersclaw/blocks/json-editor\". Props and an example: components/makersclaw/blocks/json-editor/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
60
60
  "meta": {
61
61
  "makersclaw": {
62
- "version": "0.5.2",
62
+ "version": "0.7.0",
63
63
  "useWhen": "A screen edits or inspects a JSON value: an integration's settings, a webhook payload, a record's metadata or custom fields column (jsonb), an API response, feature flags. Not for editing SQL or other code (code-editor), a form of known fields (record-form), or a table of rows (data-table)."
64
64
  }
65
65
  }
package/r/map.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "MapView",
6
6
  "description": "An interactive map (MapLibre) in the workspace theme: places from rows as markers in token tones or your own kit content, clustered when they crowd, a popup with kit parts, a searchable list beside the map that shares one selection and hover with it, fit-to-all, zoom controls, draggable markers with an optimistic save, and tinted areas for territories. Light and dark tiles follow the workspace scheme (OpenFreeMap by default, no key); `styleUrl` swaps them. Helpers for bounds, clustering and distances run anywhere.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "maplibre-gl@6.11.1",
@@ -22,7 +22,7 @@
22
22
  "path": "registry/blocks/map/map-view.jsx",
23
23
  "type": "registry:component",
24
24
  "target": "@components/makersclaw/blocks/map/map-view.jsx",
25
- "content": "\"use client\";\n\n// MapView — places on a map, with the list beside it, behind one set of\n// props (README.md beside this file). MapLibre draws the tiles; every mark\n// on them (markers, clusters, the popup) and every control is ours, in the\n// kit and the workspace's tokens.\n//\n// Client only, one static bundle: maplibre loads with the page (see\n// maplibre.js), and the map itself mounts after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame, the list and a quiet placeholder are already there, so the\n// page does not jump and the places can be read before the map arrives.\n//\n// One selection, one hover, shared by the map, the list, the popup and the\n// phone's card strip. Picking in the list moves the camera to the place\n// (zooming until it stands clear of its cluster); picking on the map\n// scrolls the list to it. Clustering is ours (geo.js clusterPoints, in\n// screen pixels at the current zoom), so a cluster is kit-drawn HTML like\n// every other mark.\nimport { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport { Button, ButtonGroup, Empty, Icon, IconButton, Notice, cx, toast } from \"../../ui\";\nimport { boundsOf, clusterPoints, idsKey, inBounds, isPointBounds, matches, readPosition, zoomToReveal } from \"./geo\";\nimport { ListPanel, Strip } from \"./list\";\nimport { AttributionControl, GLMap, Layer, Marker, Popup, Source, WORKER_URL, maplibregl } from \"./maplibre\";\nimport { ClusterMark, DefaultPopup, PointMark, PopupCard, StackList } from \"./marks\";\nimport { CHROME, TONES, prefersReducedMotion, resolveTones, schemeOf, styleFor, toneOrDefault } from \"./theme\";\n\n/** A ref for the map's handle: `const map = useMapView()`, then\n * `<MapView mapRef={map} />` and `map.current?.fitAll()`. */\nexport function useMapView() {\n return useRef(null);\n}\n\nconst WORLD = { lat: 30, lng: 0, zoom: 1.2 };\nconst NO_ITEMS = [];\nconst step = (zoom) => Math.round(zoom * 2) / 2;\nconst text = (v) => (typeof v === \"string\" || typeof v === \"number\" ? String(v) : \"\");\nconst defaultTitle = (item) => item?.title ?? item?.name ?? item?.label ?? item?.location?.label ?? \"Untitled\";\nconst defaultId = (item) => item?.id;\n\nexport function MapView(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return <Frame {...props} mounted={mounted} />;\n}\n\nfunction Frame({\n mounted,\n items = NO_ITEMS,\n positionOf = readPosition,\n idOf = defaultId,\n titleOf = defaultTitle,\n metaOf,\n asideOf,\n toneOf,\n searchOf,\n renderMarker,\n renderPopup,\n selectedId,\n defaultSelectedId = null,\n onSelect,\n onMove,\n onMapClick,\n onViewChange,\n areas,\n onAreaClick,\n list = \"side\",\n search = true,\n cluster = true,\n clusterRadius = 48,\n popup = true,\n styleUrl,\n scheme: schemeProp = \"auto\",\n workerUrl = WORKER_URL,\n initialView,\n fitMaxZoom = 14,\n maxZoom = 18,\n fitOnChange = true,\n scrollZoom = true,\n controls = true,\n height = 520,\n noun = \"place\",\n nounPlural,\n emptyTitle,\n emptyBody,\n emptyAction,\n label = \"Map\",\n mapRef,\n className,\n}) {\n const plural = nounPlural ?? `${noun}s`;\n const rootRef = useRef(null);\n const glRef = useRef(null);\n const listRef = useRef(null);\n const stripRef = useRef(null);\n const hasList = list !== \"none\";\n\n const [query, setQuery] = useState(\"\");\n const [inner, setInner] = useState(defaultSelectedId);\n const selected = selectedId !== undefined ? selectedId : inner;\n const [hovered, setHovered] = useState(null);\n const [overrides, setOverrides] = useState({});\n const [zoomStep, setZoomStep] = useState(null);\n const [loaded, setLoaded] = useState(false);\n const [failure, setFailure] = useState(null);\n const [attempt, setAttempt] = useState(0);\n const [scheme, setScheme] = useState(schemeProp === \"dark\" ? \"dark\" : \"light\");\n const [tones, setTones] = useState({});\n const [phoneView, setPhoneView] = useState(\"map\");\n const [stack, setStack] = useState(null);\n const dragged = useRef(false);\n\n // ---- the rows -------------------------------------------------------------\n const rows = useMemo(\n () =>\n items.map((item) => {\n const id = String(idOf(item));\n const own = positionOf(item);\n const o = overrides[id];\n // An optimistic move holds until the row itself changes position.\n const pos = o && samePos(o.base, own) ? o.pos : own;\n const meta = metaOf?.(item) ?? null;\n const title = text(titleOf(item)) || \"Untitled\";\n return {\n id,\n item,\n pos,\n title,\n meta,\n aside: asideOf?.(item) ?? null,\n tone: toneOrDefault(toneOf?.(item)),\n words: searchOf ? text(searchOf(item)) : `${title} ${text(meta)}`,\n };\n }),\n [items, idOf, positionOf, titleOf, metaOf, asideOf, toneOf, searchOf, overrides],\n );\n const byId = useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]);\n const visible = useMemo(() => (query.trim() ? rows.filter((r) => matches(r.words, query)) : rows), [rows, query]);\n const located = useMemo(() => visible.filter((r) => r.pos), [visible]);\n const unlocated = useMemo(() => visible.filter((r) => !r.pos), [visible]);\n const points = useMemo(() => located.map((r) => ({ id: r.id, lat: r.pos.lat, lng: r.pos.lng })), [located]);\n const groups = useMemo(() => {\n if (zoomStep == null) return [];\n return cluster ? clusterPoints(points, zoomStep, clusterRadius) : points.map((p) => ({ type: \"point\", ...p }));\n }, [points, zoomStep, cluster, clusterRadius]);\n const locatedKey = useMemo(() => idsKey(points.map((p) => `${p.id}@${p.lat},${p.lng}`)), [points]);\n\n // ---- the camera -----------------------------------------------------------\n const map = () => glRef.current?.getMap?.() ?? null;\n const duration = (ms) => (prefersReducedMotion() ? 0 : ms);\n const padding = () => {\n const w = rootRef.current?.clientWidth ?? 800;\n return w < 480 ? 40 : 64;\n };\n\n const fitAll = useCallback(\n ({ animate = true } = {}) => {\n const m = map();\n if (!m) return;\n const b = boundsOf(points);\n const ms = animate ? duration(700) : 0;\n if (!b) {\n const v = initialView ?? WORLD;\n m.easeTo({ center: [v.lng, v.lat], zoom: v.zoom ?? WORLD.zoom, duration: ms });\n } else if (isPointBounds(b)) {\n m.easeTo({ center: [b.west, b.south], zoom: Math.max(m.getZoom(), Math.min(fitMaxZoom, 13)), duration: ms });\n } else {\n m.fitBounds(\n [\n [b.west, b.south],\n [b.east, b.north],\n ],\n { padding: padding(), maxZoom: fitMaxZoom, duration: ms },\n );\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [points, initialView, fitMaxZoom],\n );\n\n /** Bring a place into view, clear of any cluster that hides it. */\n const reveal = useCallback(\n (id) => {\n const m = map();\n const r = byId.get(String(id));\n if (!m || !r?.pos) return;\n const z = m.getZoom();\n const need = cluster ? zoomToReveal(points, r.id, z, { radius: clusterRadius, maxZoom: Math.min(maxZoom, fitMaxZoom + 2) }) : z;\n const zoom = Math.max(z, need ?? z);\n const b = m.getBounds();\n const view = { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() };\n // Already on screen, away from the edges, and not hidden in a cluster: leave the camera be.\n const inset = { west: view.west + (view.east - view.west) * 0.12, east: view.east - (view.east - view.west) * 0.12, south: view.south + (view.north - view.south) * 0.12, north: view.north - (view.north - view.south) * 0.25 };\n if (zoom === z && inBounds(inset, r.pos)) return;\n m.easeTo({ center: [r.pos.lng, r.pos.lat], zoom, duration: duration(600) });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [byId, points, cluster, clusterRadius, maxZoom, fitMaxZoom],\n );\n\n // ---- selection ------------------------------------------------------------\n const pending = useRef({ id: undefined, reveal: false });\n const select = useCallback(\n (id, { reveal: move = false } = {}) => {\n const next = id == null ? null : String(id);\n pending.current = { id: next, reveal: move };\n setStack(null);\n if (selectedId === undefined) setInner(next);\n onSelect?.(next, next == null ? null : byId.get(next)?.item ?? null);\n },\n [selectedId, onSelect, byId],\n );\n\n // A selection that changed — from the list, the handle or the parent —\n // moves the camera; one made on the map itself does not.\n const seen = useRef(selected);\n useEffect(() => {\n if (seen.current === selected) return;\n seen.current = selected;\n const own = pending.current.id === (selected == null ? null : String(selected));\n const move = own ? pending.current.reveal : true;\n pending.current = { id: undefined, reveal: false };\n if (selected == null) return;\n if (move && loaded) reveal(selected);\n // Keep the chosen row and card in view.\n const q = `[data-map-id=\"${cssEscape(String(selected))}\"]`;\n listRef.current?.querySelector(q)?.scrollIntoView({ block: \"nearest\", behavior: prefersReducedMotion() ? \"auto\" : \"smooth\" });\n const card = stripRef.current?.querySelector(q);\n if (card && stripRef.current) {\n const s = stripRef.current;\n s.scrollTo({ left: card.offsetLeft - (s.clientWidth - card.clientWidth) / 2, behavior: prefersReducedMotion() ? \"auto\" : \"smooth\" });\n }\n }, [selected, loaded, reveal]);\n\n // Refit when the set of places on the map changes (new rows, a search).\n const fittedKey = useRef(null);\n useEffect(() => {\n if (!loaded) return;\n if (fittedKey.current === null) {\n fittedKey.current = locatedKey;\n return;\n }\n if (fittedKey.current === locatedKey) return;\n fittedKey.current = locatedKey;\n if (fitOnChange && points.length) fitAll();\n }, [locatedKey, loaded, fitOnChange, fitAll, points.length]);\n\n // ---- the scheme, and the tones areas are painted in ------------------------\n // Read on the first effect — the same flush that mounts the map — so the\n // map's first style is already the right one (no light flash in dark).\n useEffect(() => {\n const root = rootRef.current;\n const read = () => {\n const s = schemeProp === \"light\" || schemeProp === \"dark\" ? schemeProp : schemeOf(root);\n setScheme(s);\n if (areas?.length) setTones(resolveTones(root, TONES));\n };\n read();\n const mo = new MutationObserver(read);\n mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const scoped = root?.parentElement?.closest(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) mo.observe(scoped, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n return () => {\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n };\n }, [schemeProp, areas?.length]);\n\n const mapStyle = styleFor(styleUrl, scheme);\n\n // ---- areas (territories, zones) -------------------------------------------\n const areaData = useMemo(() => {\n if (!areas?.length) return null;\n return {\n type: \"FeatureCollection\",\n features: areas\n .filter((a) => a?.geometry)\n .map((a, i) => {\n const tone = toneOrDefault(a.tone);\n const colour = tones[tone] ?? tones.primary ?? null;\n return { type: \"Feature\", id: i, geometry: a.geometry, properties: { id: String(a.id ?? i), label: text(a.label), paint: colour } };\n })\n .filter((f) => f.properties.paint),\n };\n }, [areas, tones]);\n\n // ---- the handle -----------------------------------------------------------\n const api = useRef({});\n api.current = {\n fitAll: (o) => fitAll(o),\n select: (id, { reveal: move = true } = {}) => select(id, { reveal: move }),\n flyTo: ({ lat, lng, zoom }) => map()?.easeTo({ center: [lng, lat], zoom: zoom ?? map()?.getZoom(), duration: duration(700) }),\n fitBounds: (b, { maxZoom: mz = fitMaxZoom } = {}) =>\n b &&\n map()?.fitBounds(\n [\n [b.west, b.south],\n [b.east, b.north],\n ],\n { padding: padding(), maxZoom: mz, duration: duration(700) },\n ),\n zoomIn: () => map()?.zoomIn({ duration: duration(250) }),\n zoomOut: () => map()?.zoomOut({ duration: duration(250) }),\n getView: () => viewOf(map()),\n getMap: () => map(),\n };\n useImperativeHandle(\n mapRef,\n () => ({\n fitAll: (o) => api.current.fitAll(o),\n select: (id, o) => api.current.select(id, o),\n flyTo: (p) => api.current.flyTo(p),\n fitBounds: (b, o) => api.current.fitBounds(b, o),\n zoomIn: () => api.current.zoomIn(),\n zoomOut: () => api.current.zoomOut(),\n getView: () => api.current.getView(),\n getMap: () => api.current.getMap(),\n }),\n [],\n );\n\n // ---- a moved marker: optimistic, rolled back when the save fails ---------\n async function moveTo(r, pos) {\n const base = positionOf(r.item);\n setOverrides((o) => ({ ...o, [r.id]: { pos, base } }));\n let res;\n try {\n res = await onMove(r.item, pos);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (res?.ok) return;\n setOverrides((o) => {\n const next = { ...o };\n delete next[r.id];\n return next;\n });\n toast.error(`${r.title} wasn’t moved`, { description: res?.error || \"The new spot didn’t save, so it’s back where it was. Try again.\" });\n }\n\n // The map is made only once its box has a size. Made while hidden (a\n // container query shows it only when the frame is wide enough), it fitted\n // its first view to a 0×0 box, the camera came out invalid and no tile\n // was ever asked for: \"Loading the map…\" for good (store-locator and\n // location-map, seen in the pane 2026-09-24).\n const stageRef = useRef(null);\n const [sized, setSized] = useState(false);\n useEffect(() => {\n const el = stageRef.current;\n if (!el || sized) return;\n const has = () => el.clientWidth > 0 && el.clientHeight > 0;\n if (has()) return setSized(true);\n if (typeof ResizeObserver === \"undefined\") return setSized(true);\n const ro = new ResizeObserver(() => has() && setSized(true));\n ro.observe(el);\n return () => ro.disconnect();\n }, [mounted, sized]);\n\n // ---- the first view -------------------------------------------------------\n const firstView = useMemo(() => {\n const b = boundsOf(points);\n if (b && !isPointBounds(b)) {\n return {\n bounds: [\n [b.west, b.south],\n [b.east, b.north],\n ],\n fitBoundsOptions: { padding: 64, maxZoom: fitMaxZoom },\n };\n }\n if (b) return { longitude: b.west, latitude: b.south, zoom: Math.min(fitMaxZoom, 13) };\n const v = initialView ?? WORLD;\n return { longitude: v.lng, latitude: v.lat, zoom: v.zoom ?? WORLD.zoom };\n // Only the first render's matters: react-map-gl reads it once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [attempt]);\n\n // `load` can be missed: in development React mounts twice, and the event\n // fired for a map the block had already let go of, so the cover stayed on\n // over a map that was ready (\"Loading the map…\" for good, seen in the pane\n // 2026-09-24). The first `idle` counts as loaded too.\n const loadedRef = useRef(false);\n loadedRef.current = loaded;\n // And a map already loaded and idle by the time we listen fires neither\n // again: ask it directly for a moment after it mounts.\n useEffect(() => {\n if (!mounted || !sized || loaded) return;\n let tries = 0;\n const t = setInterval(() => {\n const m = glRef.current?.getMap?.();\n if (m?.loaded?.() && !loadedRef.current) onLoad({ target: m });\n if (loadedRef.current || ++tries > 40) clearInterval(t);\n }, 250);\n return () => clearInterval(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [mounted, sized, loaded, attempt]);\n\n function onLoad(e) {\n const m = e.target;\n loadedRef.current = true;\n m.touchZoomRotate?.disableRotation?.();\n setZoomStep(step(m.getZoom()));\n setLoaded(true);\n setFailure(null);\n if (selected != null) reveal(selected);\n onViewChange?.(viewOf(m));\n }\n\n function onError(e) {\n // A tile or an icon that failed is maplibre's to retry; only a map that\n // never came up (the style unreachable, no WebGL) is worth a notice.\n if (loaded || e?.tile || e?.sourceId || e?.source) return;\n const msg = String(e?.error?.message ?? e?.message ?? \"\");\n setFailure(/webgl/i.test(msg) ? \"webgl\" : \"network\");\n }\n\n function retry() {\n setFailure(null);\n setLoaded(false);\n setZoomStep(null);\n fittedKey.current = null;\n setAttempt((a) => a + 1);\n }\n\n const selectedRow = selected == null ? null : byId.get(String(selected)) ?? null;\n const popupRow = popup && selectedRow?.pos && located.includes(selectedRow) ? selectedRow : null;\n const stackRows = stack ? stack.ids.map((id) => byId.get(id)).filter(Boolean) : [];\n const hoveredId = hovered == null ? null : String(hovered);\n\n const empty = (\n <Empty\n icon=\"map\"\n title={emptyTitle ?? `No ${plural} yet`}\n body={emptyBody ?? `${cap(plural)} with an address or coordinates show up here and on the map.`}\n action={emptyAction}\n className=\"mx-1 mt-2 border-0\"\n />\n );\n\n return (\n <div\n ref={rootRef}\n role=\"region\"\n aria-label={label}\n onKeyDown={(e) => {\n if (e.key !== \"Escape\" || e.defaultPrevented) return;\n if (stack) setStack(null);\n else if (selected != null) select(null);\n }}\n style={{ height }}\n className={cx(\"@container relative flex overflow-hidden rounded-lg border border-border bg-card text-card-foreground\", CHROME, className)}\n >\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col @3xl:flex-row\">\n {hasList ? (\n <ListPanel\n ref={listRef}\n rows={located.map((r) => (r.id === hoveredId ? { ...r, hovered: true } : r))}\n unlocated={unlocated}\n total={rows.length}\n query={query}\n onQuery={setQuery}\n search={search}\n noun={noun}\n plural={plural}\n selected={selected == null ? null : String(selected)}\n onPick={(id) => {\n setPhoneView(\"map\");\n select(id, { reveal: true });\n }}\n onHover={setHovered}\n onShowMap={() => setPhoneView(\"map\")}\n empty={empty}\n className={cx(\"flex-1 @3xl:flex @3xl:w-80 @3xl:flex-none @3xl:border-r @3xl:border-border\", phoneView === \"list\" ? \"flex\" : \"hidden\")}\n />\n ) : null}\n\n <div ref={stageRef} className={cx(\"relative min-h-0 min-w-0 flex-1\", hasList && phoneView === \"list\" && \"hidden @3xl:block\")}>\n {mounted && sized ? (\n <GLMap\n key={attempt}\n ref={glRef}\n mapLib={maplibregl}\n workerUrl={workerUrl}\n initialViewState={firstView}\n mapStyle={mapStyle}\n maxZoom={maxZoom}\n dragRotate={false}\n pitchWithRotate={false}\n touchPitch={false}\n scrollZoom={scrollZoom}\n attributionControl={false}\n interactiveLayerIds={areaData && onAreaClick ? [\"mc-areas-fill\"] : undefined}\n onLoad={onLoad}\n onIdle={(e) => {\n if (!loadedRef.current) onLoad(e);\n }}\n onError={onError}\n onMove={(e) => {\n const s = step(e.viewState.zoom);\n if (s !== zoomStep) setZoomStep(s);\n }}\n onMoveEnd={(e) => onViewChange?.(viewOf(e.target))}\n onClick={(e) => {\n const f = e.features?.[0];\n if (f && onAreaClick) {\n const a = areas.find((x, i) => String(x.id ?? i) === f.properties?.id);\n if (a) return onAreaClick(a);\n }\n if (stack) setStack(null);\n else if (selected != null) select(null);\n onMapClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng });\n }}\n style={{ width: \"100%\", height: \"100%\" }}\n >\n <AttributionControl compact={false} position=\"bottom-right\" />\n {areaData ? (\n <Source id=\"mc-areas\" type=\"geojson\" data={areaData}>\n <Layer id=\"mc-areas-fill\" type=\"fill\" filter={[\"in\", [\"geometry-type\"], [\"literal\", [\"Polygon\", \"MultiPolygon\"]]]} paint={{ \"fill-color\": [\"get\", \"paint\"], \"fill-opacity\": 0.14 }} />\n <Layer id=\"mc-areas-line\" type=\"line\" paint={{ \"line-color\": [\"get\", \"paint\"], \"line-width\": 1.5, \"line-opacity\": 0.8 }} />\n </Source>\n ) : null}\n\n {groups.map((g) => {\n if (g.type === \"cluster\") {\n const holds = selected != null && g.ids.includes(String(selected));\n return (\n <Marker\n key={g.id}\n longitude={g.lng}\n latitude={g.lat}\n anchor=\"center\"\n onClick={(e) => {\n // A native listener on the marker, so it runs before\n // maplibre's own map click (which would clear the selection).\n e.originalEvent.stopPropagation();\n const m = map();\n if (!m) return;\n if (isPointBounds(g.bounds) || m.getZoom() >= maxZoom - 0.5) {\n setStack(g);\n return;\n }\n m.fitBounds(\n [\n [g.bounds.west, g.bounds.south],\n [g.bounds.east, g.bounds.north],\n ],\n { padding: 96, maxZoom, duration: duration(500) },\n );\n }}\n >\n <ClusterMark count={g.count} holdsSelected={holds} label={`${g.count} ${plural} here. Zoom in`} />\n </Marker>\n );\n }\n const r = byId.get(g.id);\n if (!r) return null;\n const isSel = selected != null && String(selected) === r.id;\n const isHover = hoveredId === r.id;\n return (\n <Marker\n key={r.id}\n longitude={g.lng}\n latitude={g.lat}\n anchor=\"center\"\n draggable={Boolean(onMove)}\n onDragStart={() => {\n dragged.current = true;\n }}\n onDragEnd={(e) => {\n setTimeout(() => {\n dragged.current = false;\n }, 0);\n moveTo(r, { lat: e.lngLat.lat, lng: e.lngLat.lng });\n }}\n onClick={(e) => {\n // Native, on the marker: runs before maplibre's map click.\n // A keyboard Enter on the marker's button arrives here too.\n e.originalEvent.stopPropagation();\n if (dragged.current) return;\n select(isSel ? null : r.id);\n }}\n >\n <PointMark\n title={r.title}\n tone={r.tone}\n selected={isSel}\n hovered={isHover}\n showLabel={isHover && !(isSel && popupRow)}\n focusable={!hasList}\n custom={renderMarker ? renderMarker(r.item, { selected: isSel, hovered: isHover, tone: r.tone }) : null}\n onHover={(on) => setHovered(on ? r.id : (h) => (h === r.id ? null : h))}\n />\n </Marker>\n );\n })}\n\n {popupRow ? (\n <Popup\n key={`pop:${popupRow.id}`}\n longitude={popupRow.pos.lng}\n latitude={popupRow.pos.lat}\n anchor=\"bottom\"\n offset={20}\n closeButton={false}\n closeOnClick={false}\n focusAfterOpen={false}\n maxWidth=\"none\"\n >\n <PopupCard onClose={() => select(null)}>\n {renderPopup ? (\n renderPopup(popupRow.item, { close: () => select(null) })\n ) : (\n <DefaultPopup title={popupRow.title} meta={popupRow.meta} aside={popupRow.aside} />\n )}\n </PopupCard>\n </Popup>\n ) : null}\n\n {stack && stackRows.length ? (\n <Popup key={`stack:${stack.id}`} longitude={stack.lng} latitude={stack.lat} anchor=\"bottom\" offset={28} closeButton={false} closeOnClick={false} focusAfterOpen={false} maxWidth=\"none\">\n <PopupCard onClose={() => setStack(null)}>\n <StackList rows={stackRows} plural={plural} onPick={(id) => select(id)} />\n </PopupCard>\n </Popup>\n ) : null}\n </GLMap>\n ) : null}\n\n {!loaded && !failure ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center bg-muted\">\n <div className=\"absolute inset-0 animate-pulse bg-accent/40 motion-reduce:animate-none\" aria-hidden=\"true\" />\n <p className=\"relative flex items-center gap-2 text-sm text-muted-foreground\">\n <Icon name=\"map\" />\n Loading the map…\n </p>\n {!hasList && rows.length ? (\n <ul className=\"sr-only\">\n {rows.map((r) => (\n <li key={r.id}>{r.title}</li>\n ))}\n </ul>\n ) : null}\n </div>\n ) : null}\n\n {failure ? (\n <div className=\"absolute inset-0 z-[4] grid place-items-center bg-muted p-4\">\n <Notice\n tone=\"warning\"\n title={failure === \"webgl\" ? \"This browser can’t draw maps\" : \"The map didn’t load\"}\n action={\n failure === \"webgl\" ? null : (\n <Button variant=\"secondary\" size=\"sm\" onClick={retry}>\n Try again\n </Button>\n )\n }\n className=\"max-w-sm\"\n >\n {failure === \"webgl\" ? \"WebGL is turned off or not supported here.\" : \"The map tiles couldn’t be reached. Check the connection, then try again.\"}\n {hasList ? ` The ${plural} are still listed.` : \"\"}\n </Notice>\n </div>\n ) : null}\n\n {controls && loaded ? (\n <div className=\"absolute right-3 top-3 z-[3] flex flex-col items-end gap-2\">\n <ButtonGroup orientation=\"vertical\" size=\"sm\" aria-label=\"Zoom\" className=\"rounded-md shadow-sm\">\n <IconButton icon=\"plus\" label=\"Zoom in\" size=\"sm\" variant=\"secondary\" onClick={() => api.current.zoomIn()} />\n <IconButton icon=\"minus\" label=\"Zoom out\" size=\"sm\" variant=\"secondary\" onClick={() => api.current.zoomOut()} />\n </ButtonGroup>\n <IconButton icon=\"maximize\" label={`Show all ${plural}`} size=\"sm\" variant=\"secondary\" className=\"shadow-sm\" disabled={!points.length} onClick={() => fitAll()} />\n </div>\n ) : null}\n\n {hasList ? (\n <div className=\"absolute left-3 top-3 z-[3] @3xl:hidden\">\n <Button variant=\"secondary\" size=\"sm\" icon=\"list\" className=\"shadow-sm\" onClick={() => setPhoneView(\"list\")}>\n List\n <span className=\"text-muted-foreground\" data-nums>\n {visible.length}\n </span>\n </Button>\n </div>\n ) : null}\n </div>\n\n {hasList && located.length ? (\n <Strip ref={stripRef} rows={located} selected={selected == null ? null : String(selected)} onPick={(id) => select(id, { reveal: true })} className={cx(\"@3xl:hidden\", phoneView === \"list\" && \"hidden\")} />\n ) : null}\n </div>\n </div>\n );\n}\n\nfunction samePos(a, b) {\n if (!a && !b) return true;\n if (!a || !b) return false;\n return Math.abs(a.lat - b.lat) < 1e-9 && Math.abs(a.lng - b.lng) < 1e-9;\n}\n\nfunction viewOf(m) {\n if (!m) return null;\n const c = m.getCenter();\n const b = m.getBounds();\n return { center: { lat: c.lat, lng: c.lng }, zoom: m.getZoom(), bounds: { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() } };\n}\n\nconst cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);\n\nfunction cssEscape(s) {\n return typeof CSS !== \"undefined\" && CSS.escape ? CSS.escape(s) : s.replace(/[\"\\\\]/g, \"\\\\$&\");\n}\n"
25
+ "content": "\"use client\";\n\n// MapView — places on a map, with the list beside it, behind one set of\n// props (README.md beside this file). MapLibre draws the tiles; every mark\n// on them (markers, clusters, the popup) and every control is ours, in the\n// kit and the workspace's tokens.\n//\n// Client only, one bundle: maplibre loads with the page (see\n// maplibre.js), and the map itself mounts after the first effect (the\n// mount guard). Until then — on the server and in the first client paint —\n// the frame, the list and a quiet placeholder are already there, so the\n// page does not jump and the places can be read before the map arrives.\n//\n// One selection, one hover, shared by the map, the list, the popup and the\n// phone's card strip. Picking in the list moves the camera to the place\n// (zooming until it stands clear of its cluster); picking on the map\n// scrolls the list to it. Clustering is ours (geo.js clusterPoints, in\n// screen pixels at the current zoom), so a cluster is kit-drawn HTML like\n// every other mark.\nimport { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from \"react\";\nimport { Button, ButtonGroup, Empty, Icon, IconButton, Notice, cx, toast } from \"../../ui\";\nimport { boundsOf, clusterPoints, idsKey, inBounds, isPointBounds, matches, readPosition, zoomToReveal } from \"./geo\";\nimport { ListPanel, Strip } from \"./list\";\nimport { AttributionControl, GLMap, Layer, Marker, Popup, Source, WORKER_URL, maplibregl } from \"./maplibre\";\nimport { ClusterMark, DefaultPopup, PointMark, PopupCard, StackList } from \"./marks\";\nimport { CHROME, TONES, prefersReducedMotion, resolveTones, schemeOf, styleFor, toneOrDefault } from \"./theme\";\n\n/** A ref for the map's handle: `const map = useMapView()`, then\n * `<MapView mapRef={map} />` and `map.current?.fitAll()`. */\nexport function useMapView() {\n return useRef(null);\n}\n\nconst WORLD = { lat: 30, lng: 0, zoom: 1.2 };\nconst NO_ITEMS = [];\nconst step = (zoom) => Math.round(zoom * 2) / 2;\nconst text = (v) => (typeof v === \"string\" || typeof v === \"number\" ? String(v) : \"\");\nconst defaultTitle = (item) => item?.title ?? item?.name ?? item?.label ?? item?.location?.label ?? \"Untitled\";\nconst defaultId = (item) => item?.id;\n\nexport function MapView(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n return <Frame {...props} mounted={mounted} />;\n}\n\nfunction Frame({\n mounted,\n items = NO_ITEMS,\n positionOf = readPosition,\n idOf = defaultId,\n titleOf = defaultTitle,\n metaOf,\n asideOf,\n toneOf,\n searchOf,\n renderMarker,\n renderPopup,\n selectedId,\n defaultSelectedId = null,\n onSelect,\n onMove,\n onMapClick,\n onViewChange,\n areas,\n onAreaClick,\n list = \"side\",\n search = true,\n cluster = true,\n clusterRadius = 48,\n popup = true,\n styleUrl,\n scheme: schemeProp = \"auto\",\n workerUrl = WORKER_URL,\n initialView,\n fitMaxZoom = 14,\n maxZoom = 18,\n fitOnChange = true,\n scrollZoom = true,\n controls = true,\n height = 520,\n noun = \"place\",\n nounPlural,\n emptyTitle,\n emptyBody,\n emptyAction,\n label = \"Map\",\n mapRef,\n className,\n}) {\n const plural = nounPlural ?? `${noun}s`;\n const rootRef = useRef(null);\n const glRef = useRef(null);\n const listRef = useRef(null);\n const stripRef = useRef(null);\n const hasList = list !== \"none\";\n\n const [query, setQuery] = useState(\"\");\n const [inner, setInner] = useState(defaultSelectedId);\n const selected = selectedId !== undefined ? selectedId : inner;\n const [hovered, setHovered] = useState(null);\n const [overrides, setOverrides] = useState({});\n const [zoomStep, setZoomStep] = useState(null);\n const [loaded, setLoaded] = useState(false);\n const [failure, setFailure] = useState(null);\n const [attempt, setAttempt] = useState(0);\n const [scheme, setScheme] = useState(schemeProp === \"dark\" ? \"dark\" : \"light\");\n const [tones, setTones] = useState({});\n const [phoneView, setPhoneView] = useState(\"map\");\n const [stack, setStack] = useState(null);\n const dragged = useRef(false);\n\n // ---- the rows -------------------------------------------------------------\n const rows = useMemo(\n () =>\n items.map((item) => {\n const id = String(idOf(item));\n const own = positionOf(item);\n const o = overrides[id];\n // An optimistic move holds until the row itself changes position.\n const pos = o && samePos(o.base, own) ? o.pos : own;\n const meta = metaOf?.(item) ?? null;\n const title = text(titleOf(item)) || \"Untitled\";\n return {\n id,\n item,\n pos,\n title,\n meta,\n aside: asideOf?.(item) ?? null,\n tone: toneOrDefault(toneOf?.(item)),\n words: searchOf ? text(searchOf(item)) : `${title} ${text(meta)}`,\n };\n }),\n [items, idOf, positionOf, titleOf, metaOf, asideOf, toneOf, searchOf, overrides],\n );\n const byId = useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]);\n const visible = useMemo(() => (query.trim() ? rows.filter((r) => matches(r.words, query)) : rows), [rows, query]);\n const located = useMemo(() => visible.filter((r) => r.pos), [visible]);\n const unlocated = useMemo(() => visible.filter((r) => !r.pos), [visible]);\n const points = useMemo(() => located.map((r) => ({ id: r.id, lat: r.pos.lat, lng: r.pos.lng })), [located]);\n const groups = useMemo(() => {\n if (zoomStep == null) return [];\n return cluster ? clusterPoints(points, zoomStep, clusterRadius) : points.map((p) => ({ type: \"point\", ...p }));\n }, [points, zoomStep, cluster, clusterRadius]);\n const locatedKey = useMemo(() => idsKey(points.map((p) => `${p.id}@${p.lat},${p.lng}`)), [points]);\n\n // ---- the camera -----------------------------------------------------------\n const map = () => glRef.current?.getMap?.() ?? null;\n const duration = (ms) => (prefersReducedMotion() ? 0 : ms);\n const padding = () => {\n const w = rootRef.current?.clientWidth ?? 800;\n return w < 480 ? 40 : 64;\n };\n\n const fitAll = useCallback(\n ({ animate = true } = {}) => {\n const m = map();\n if (!m) return;\n const b = boundsOf(points);\n const ms = animate ? duration(700) : 0;\n if (!b) {\n const v = initialView ?? WORLD;\n m.easeTo({ center: [v.lng, v.lat], zoom: v.zoom ?? WORLD.zoom, duration: ms });\n } else if (isPointBounds(b)) {\n m.easeTo({ center: [b.west, b.south], zoom: Math.max(m.getZoom(), Math.min(fitMaxZoom, 13)), duration: ms });\n } else {\n m.fitBounds(\n [\n [b.west, b.south],\n [b.east, b.north],\n ],\n { padding: padding(), maxZoom: fitMaxZoom, duration: ms },\n );\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [points, initialView, fitMaxZoom],\n );\n\n /** Bring a place into view, clear of any cluster that hides it. */\n const reveal = useCallback(\n (id) => {\n const m = map();\n const r = byId.get(String(id));\n if (!m || !r?.pos) return;\n const z = m.getZoom();\n const need = cluster ? zoomToReveal(points, r.id, z, { radius: clusterRadius, maxZoom: Math.min(maxZoom, fitMaxZoom + 2) }) : z;\n const zoom = Math.max(z, need ?? z);\n const b = m.getBounds();\n const view = { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() };\n // Already on screen, away from the edges, and not hidden in a cluster: leave the camera be.\n const inset = { west: view.west + (view.east - view.west) * 0.12, east: view.east - (view.east - view.west) * 0.12, south: view.south + (view.north - view.south) * 0.12, north: view.north - (view.north - view.south) * 0.25 };\n if (zoom === z && inBounds(inset, r.pos)) return;\n m.easeTo({ center: [r.pos.lng, r.pos.lat], zoom, duration: duration(600) });\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [byId, points, cluster, clusterRadius, maxZoom, fitMaxZoom],\n );\n\n // ---- selection ------------------------------------------------------------\n const pending = useRef({ id: undefined, reveal: false });\n const select = useCallback(\n (id, { reveal: move = false } = {}) => {\n const next = id == null ? null : String(id);\n pending.current = { id: next, reveal: move };\n setStack(null);\n if (selectedId === undefined) setInner(next);\n onSelect?.(next, next == null ? null : byId.get(next)?.item ?? null);\n },\n [selectedId, onSelect, byId],\n );\n\n // A selection that changed — from the list, the handle or the parent —\n // moves the camera; one made on the map itself does not.\n const seen = useRef(selected);\n useEffect(() => {\n if (seen.current === selected) return;\n seen.current = selected;\n const own = pending.current.id === (selected == null ? null : String(selected));\n const move = own ? pending.current.reveal : true;\n pending.current = { id: undefined, reveal: false };\n if (selected == null) return;\n if (move && loaded) reveal(selected);\n // Keep the chosen row and card in view.\n const q = `[data-map-id=\"${cssEscape(String(selected))}\"]`;\n listRef.current?.querySelector(q)?.scrollIntoView({ block: \"nearest\", behavior: prefersReducedMotion() ? \"auto\" : \"smooth\" });\n const card = stripRef.current?.querySelector(q);\n if (card && stripRef.current) {\n const s = stripRef.current;\n s.scrollTo({ left: card.offsetLeft - (s.clientWidth - card.clientWidth) / 2, behavior: prefersReducedMotion() ? \"auto\" : \"smooth\" });\n }\n }, [selected, loaded, reveal]);\n\n // Refit when the set of places on the map changes (new rows, a search).\n const fittedKey = useRef(null);\n useEffect(() => {\n if (!loaded) return;\n if (fittedKey.current === null) {\n fittedKey.current = locatedKey;\n return;\n }\n if (fittedKey.current === locatedKey) return;\n fittedKey.current = locatedKey;\n if (fitOnChange && points.length) fitAll();\n }, [locatedKey, loaded, fitOnChange, fitAll, points.length]);\n\n // ---- the scheme, and the tones areas are painted in ------------------------\n // Read on the first effect — the same flush that mounts the map — so the\n // map's first style is already the right one (no light flash in dark).\n useEffect(() => {\n const root = rootRef.current;\n const read = () => {\n const s = schemeProp === \"light\" || schemeProp === \"dark\" ? schemeProp : schemeOf(root);\n setScheme(s);\n if (areas?.length) setTones(resolveTones(root, TONES));\n };\n read();\n const mo = new MutationObserver(read);\n mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const scoped = root?.parentElement?.closest(\"[data-theme]\");\n if (scoped && scoped !== document.documentElement) mo.observe(scoped, { attributes: true, attributeFilter: [\"data-theme\", \"class\", \"style\"] });\n const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n mq?.addEventListener?.(\"change\", read);\n return () => {\n mo.disconnect();\n mq?.removeEventListener?.(\"change\", read);\n };\n }, [schemeProp, areas?.length]);\n\n const mapStyle = styleFor(styleUrl, scheme);\n\n // ---- areas (territories, zones) -------------------------------------------\n const areaData = useMemo(() => {\n if (!areas?.length) return null;\n return {\n type: \"FeatureCollection\",\n features: areas\n .filter((a) => a?.geometry)\n .map((a, i) => {\n const tone = toneOrDefault(a.tone);\n const colour = tones[tone] ?? tones.primary ?? null;\n return { type: \"Feature\", id: i, geometry: a.geometry, properties: { id: String(a.id ?? i), label: text(a.label), paint: colour } };\n })\n .filter((f) => f.properties.paint),\n };\n }, [areas, tones]);\n\n // ---- the handle -----------------------------------------------------------\n const api = useRef({});\n api.current = {\n fitAll: (o) => fitAll(o),\n select: (id, { reveal: move = true } = {}) => select(id, { reveal: move }),\n flyTo: ({ lat, lng, zoom }) => map()?.easeTo({ center: [lng, lat], zoom: zoom ?? map()?.getZoom(), duration: duration(700) }),\n fitBounds: (b, { maxZoom: mz = fitMaxZoom } = {}) =>\n b &&\n map()?.fitBounds(\n [\n [b.west, b.south],\n [b.east, b.north],\n ],\n { padding: padding(), maxZoom: mz, duration: duration(700) },\n ),\n zoomIn: () => map()?.zoomIn({ duration: duration(250) }),\n zoomOut: () => map()?.zoomOut({ duration: duration(250) }),\n getView: () => viewOf(map()),\n getMap: () => map(),\n };\n useImperativeHandle(\n mapRef,\n () => ({\n fitAll: (o) => api.current.fitAll(o),\n select: (id, o) => api.current.select(id, o),\n flyTo: (p) => api.current.flyTo(p),\n fitBounds: (b, o) => api.current.fitBounds(b, o),\n zoomIn: () => api.current.zoomIn(),\n zoomOut: () => api.current.zoomOut(),\n getView: () => api.current.getView(),\n getMap: () => api.current.getMap(),\n }),\n [],\n );\n\n // ---- a moved marker: optimistic, rolled back when the save fails ---------\n async function moveTo(r, pos) {\n const base = positionOf(r.item);\n setOverrides((o) => ({ ...o, [r.id]: { pos, base } }));\n let res;\n try {\n res = await onMove(r.item, pos);\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (res?.ok) return;\n setOverrides((o) => {\n const next = { ...o };\n delete next[r.id];\n return next;\n });\n toast.error(`${r.title} wasn’t moved`, { description: res?.error || \"The new spot didn’t save, so it’s back where it was. Try again.\" });\n }\n\n // The map is made only once its box has a size. Made while hidden (a\n // container query shows it only when the frame is wide enough), it fitted\n // its first view to a 0×0 box, the camera came out invalid and no tile\n // was ever asked for: \"Loading the map…\" for good (store-locator and\n // location-map, seen in the pane 2026-09-24).\n const stageRef = useRef(null);\n const [sized, setSized] = useState(false);\n useEffect(() => {\n const el = stageRef.current;\n if (!el || sized) return;\n const has = () => el.clientWidth > 0 && el.clientHeight > 0;\n if (has()) return setSized(true);\n if (typeof ResizeObserver === \"undefined\") return setSized(true);\n const ro = new ResizeObserver(() => has() && setSized(true));\n ro.observe(el);\n return () => ro.disconnect();\n }, [mounted, sized]);\n\n // ---- the first view -------------------------------------------------------\n const firstView = useMemo(() => {\n const b = boundsOf(points);\n if (b && !isPointBounds(b)) {\n return {\n bounds: [\n [b.west, b.south],\n [b.east, b.north],\n ],\n fitBoundsOptions: { padding: 64, maxZoom: fitMaxZoom },\n };\n }\n if (b) return { longitude: b.west, latitude: b.south, zoom: Math.min(fitMaxZoom, 13) };\n const v = initialView ?? WORLD;\n return { longitude: v.lng, latitude: v.lat, zoom: v.zoom ?? WORLD.zoom };\n // Only the first render's matters: react-map-gl reads it once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [attempt]);\n\n // `load` can be missed: in development React mounts twice, and the event\n // fired for a map the block had already let go of, so the cover stayed on\n // over a map that was ready (\"Loading the map…\" for good, seen in the pane\n // 2026-09-24). The first `idle` counts as loaded too.\n const loadedRef = useRef(false);\n loadedRef.current = loaded;\n // And a map already loaded and idle by the time we listen fires neither\n // again: ask it directly for a moment after it mounts.\n useEffect(() => {\n if (!mounted || !sized || loaded) return;\n let tries = 0;\n const t = setInterval(() => {\n const m = glRef.current?.getMap?.();\n if (m?.loaded?.() && !loadedRef.current) onLoad({ target: m });\n if (loadedRef.current || ++tries > 40) clearInterval(t);\n }, 250);\n return () => clearInterval(t);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [mounted, sized, loaded, attempt]);\n\n function onLoad(e) {\n const m = e.target;\n loadedRef.current = true;\n m.touchZoomRotate?.disableRotation?.();\n setZoomStep(step(m.getZoom()));\n setLoaded(true);\n setFailure(null);\n if (selected != null) reveal(selected);\n onViewChange?.(viewOf(m));\n }\n\n function onError(e) {\n // A tile or an icon that failed is maplibre's to retry; only a map that\n // never came up (the style unreachable, no WebGL) is worth a notice.\n if (loaded || e?.tile || e?.sourceId || e?.source) return;\n const msg = String(e?.error?.message ?? e?.message ?? \"\");\n setFailure(/webgl/i.test(msg) ? \"webgl\" : \"network\");\n }\n\n function retry() {\n setFailure(null);\n setLoaded(false);\n setZoomStep(null);\n fittedKey.current = null;\n setAttempt((a) => a + 1);\n }\n\n const selectedRow = selected == null ? null : byId.get(String(selected)) ?? null;\n const popupRow = popup && selectedRow?.pos && located.includes(selectedRow) ? selectedRow : null;\n const stackRows = stack ? stack.ids.map((id) => byId.get(id)).filter(Boolean) : [];\n const hoveredId = hovered == null ? null : String(hovered);\n\n const empty = (\n <Empty\n icon=\"map\"\n title={emptyTitle ?? `No ${plural} yet`}\n body={emptyBody ?? `${cap(plural)} with an address or coordinates show up here and on the map.`}\n action={emptyAction}\n className=\"mx-1 mt-2 border-0\"\n />\n );\n\n return (\n <div\n ref={rootRef}\n role=\"region\"\n aria-label={label}\n onKeyDown={(e) => {\n if (e.key !== \"Escape\" || e.defaultPrevented) return;\n if (stack) setStack(null);\n else if (selected != null) select(null);\n }}\n style={{ height }}\n className={cx(\"@container relative flex overflow-hidden rounded-lg border border-border bg-card text-card-foreground\", CHROME, className)}\n >\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col @3xl:flex-row\">\n {hasList ? (\n <ListPanel\n ref={listRef}\n rows={located.map((r) => (r.id === hoveredId ? { ...r, hovered: true } : r))}\n unlocated={unlocated}\n total={rows.length}\n query={query}\n onQuery={setQuery}\n search={search}\n noun={noun}\n plural={plural}\n selected={selected == null ? null : String(selected)}\n onPick={(id) => {\n setPhoneView(\"map\");\n select(id, { reveal: true });\n }}\n onHover={setHovered}\n onShowMap={() => setPhoneView(\"map\")}\n empty={empty}\n className={cx(\"flex-1 @3xl:flex @3xl:w-80 @3xl:flex-none @3xl:border-r @3xl:border-border\", phoneView === \"list\" ? \"flex\" : \"hidden\")}\n />\n ) : null}\n\n <div ref={stageRef} className={cx(\"relative min-h-0 min-w-0 flex-1\", hasList && phoneView === \"list\" && \"hidden @3xl:block\")}>\n {mounted && sized ? (\n <GLMap\n key={attempt}\n ref={glRef}\n mapLib={maplibregl}\n workerUrl={workerUrl}\n initialViewState={firstView}\n mapStyle={mapStyle}\n maxZoom={maxZoom}\n dragRotate={false}\n pitchWithRotate={false}\n touchPitch={false}\n scrollZoom={scrollZoom}\n attributionControl={false}\n interactiveLayerIds={areaData && onAreaClick ? [\"mc-areas-fill\"] : undefined}\n onLoad={onLoad}\n onIdle={(e) => {\n if (!loadedRef.current) onLoad(e);\n }}\n onError={onError}\n onMove={(e) => {\n const s = step(e.viewState.zoom);\n if (s !== zoomStep) setZoomStep(s);\n }}\n onMoveEnd={(e) => onViewChange?.(viewOf(e.target))}\n onClick={(e) => {\n const f = e.features?.[0];\n if (f && onAreaClick) {\n const a = areas.find((x, i) => String(x.id ?? i) === f.properties?.id);\n if (a) return onAreaClick(a);\n }\n if (stack) setStack(null);\n else if (selected != null) select(null);\n onMapClick?.({ lat: e.lngLat.lat, lng: e.lngLat.lng });\n }}\n style={{ width: \"100%\", height: \"100%\" }}\n >\n <AttributionControl compact={false} position=\"bottom-right\" />\n {areaData ? (\n <Source id=\"mc-areas\" type=\"geojson\" data={areaData}>\n <Layer id=\"mc-areas-fill\" type=\"fill\" filter={[\"in\", [\"geometry-type\"], [\"literal\", [\"Polygon\", \"MultiPolygon\"]]]} paint={{ \"fill-color\": [\"get\", \"paint\"], \"fill-opacity\": 0.14 }} />\n <Layer id=\"mc-areas-line\" type=\"line\" paint={{ \"line-color\": [\"get\", \"paint\"], \"line-width\": 1.5, \"line-opacity\": 0.8 }} />\n </Source>\n ) : null}\n\n {groups.map((g) => {\n if (g.type === \"cluster\") {\n const holds = selected != null && g.ids.includes(String(selected));\n return (\n <Marker\n key={g.id}\n longitude={g.lng}\n latitude={g.lat}\n anchor=\"center\"\n onClick={(e) => {\n // A native listener on the marker, so it runs before\n // maplibre's own map click (which would clear the selection).\n e.originalEvent.stopPropagation();\n const m = map();\n if (!m) return;\n if (isPointBounds(g.bounds) || m.getZoom() >= maxZoom - 0.5) {\n setStack(g);\n return;\n }\n m.fitBounds(\n [\n [g.bounds.west, g.bounds.south],\n [g.bounds.east, g.bounds.north],\n ],\n { padding: 96, maxZoom, duration: duration(500) },\n );\n }}\n >\n <ClusterMark count={g.count} holdsSelected={holds} label={`${g.count} ${plural} here. Zoom in`} />\n </Marker>\n );\n }\n const r = byId.get(g.id);\n if (!r) return null;\n const isSel = selected != null && String(selected) === r.id;\n const isHover = hoveredId === r.id;\n return (\n <Marker\n key={r.id}\n longitude={g.lng}\n latitude={g.lat}\n anchor=\"center\"\n draggable={Boolean(onMove)}\n onDragStart={() => {\n dragged.current = true;\n }}\n onDragEnd={(e) => {\n setTimeout(() => {\n dragged.current = false;\n }, 0);\n moveTo(r, { lat: e.lngLat.lat, lng: e.lngLat.lng });\n }}\n onClick={(e) => {\n // Native, on the marker: runs before maplibre's map click.\n // A keyboard Enter on the marker's button arrives here too.\n e.originalEvent.stopPropagation();\n if (dragged.current) return;\n select(isSel ? null : r.id);\n }}\n >\n <PointMark\n title={r.title}\n tone={r.tone}\n selected={isSel}\n hovered={isHover}\n showLabel={isHover && !(isSel && popupRow)}\n focusable={!hasList}\n custom={renderMarker ? renderMarker(r.item, { selected: isSel, hovered: isHover, tone: r.tone }) : null}\n onHover={(on) => setHovered(on ? r.id : (h) => (h === r.id ? null : h))}\n />\n </Marker>\n );\n })}\n\n {popupRow ? (\n <Popup\n key={`pop:${popupRow.id}`}\n longitude={popupRow.pos.lng}\n latitude={popupRow.pos.lat}\n anchor=\"bottom\"\n offset={20}\n closeButton={false}\n closeOnClick={false}\n focusAfterOpen={false}\n maxWidth=\"none\"\n >\n <PopupCard onClose={() => select(null)}>\n {renderPopup ? (\n renderPopup(popupRow.item, { close: () => select(null) })\n ) : (\n <DefaultPopup title={popupRow.title} meta={popupRow.meta} aside={popupRow.aside} />\n )}\n </PopupCard>\n </Popup>\n ) : null}\n\n {stack && stackRows.length ? (\n <Popup key={`stack:${stack.id}`} longitude={stack.lng} latitude={stack.lat} anchor=\"bottom\" offset={28} closeButton={false} closeOnClick={false} focusAfterOpen={false} maxWidth=\"none\">\n <PopupCard onClose={() => setStack(null)}>\n <StackList rows={stackRows} plural={plural} onPick={(id) => select(id)} />\n </PopupCard>\n </Popup>\n ) : null}\n </GLMap>\n ) : null}\n\n {!loaded && !failure ? (\n <div className=\"pointer-events-none absolute inset-0 grid place-items-center bg-muted\">\n <div className=\"absolute inset-0 animate-pulse bg-accent/40 motion-reduce:animate-none\" aria-hidden=\"true\" />\n <p className=\"relative flex items-center gap-2 text-sm text-muted-foreground\">\n <Icon name=\"map\" />\n Loading the map…\n </p>\n {!hasList && rows.length ? (\n <ul className=\"sr-only\">\n {rows.map((r) => (\n <li key={r.id}>{r.title}</li>\n ))}\n </ul>\n ) : null}\n </div>\n ) : null}\n\n {failure ? (\n <div className=\"absolute inset-0 z-[4] grid place-items-center bg-muted p-4\">\n <Notice\n tone=\"warning\"\n title={failure === \"webgl\" ? \"This browser can’t draw maps\" : \"The map didn’t load\"}\n action={\n failure === \"webgl\" ? null : (\n <Button variant=\"secondary\" size=\"sm\" onClick={retry}>\n Try again\n </Button>\n )\n }\n className=\"max-w-sm\"\n >\n {failure === \"webgl\" ? \"WebGL is turned off or not supported here.\" : \"The map tiles couldn’t be reached. Check the connection, then try again.\"}\n {hasList ? ` The ${plural} are still listed.` : \"\"}\n </Notice>\n </div>\n ) : null}\n\n {controls && loaded ? (\n <div className=\"absolute right-3 top-3 z-[3] flex flex-col items-end gap-2\">\n <ButtonGroup orientation=\"vertical\" size=\"sm\" aria-label=\"Zoom\" className=\"rounded-md shadow-sm\">\n <IconButton icon=\"plus\" label=\"Zoom in\" size=\"sm\" variant=\"secondary\" onClick={() => api.current.zoomIn()} />\n <IconButton icon=\"minus\" label=\"Zoom out\" size=\"sm\" variant=\"secondary\" onClick={() => api.current.zoomOut()} />\n </ButtonGroup>\n <IconButton icon=\"maximize\" label={`Show all ${plural}`} size=\"sm\" variant=\"secondary\" className=\"shadow-sm\" disabled={!points.length} onClick={() => fitAll()} />\n </div>\n ) : null}\n\n {hasList ? (\n <div className=\"absolute left-3 top-3 z-[3] @3xl:hidden\">\n <Button variant=\"secondary\" size=\"sm\" icon=\"list\" className=\"shadow-sm\" onClick={() => setPhoneView(\"list\")}>\n List\n <span className=\"text-muted-foreground\" data-nums>\n {visible.length}\n </span>\n </Button>\n </div>\n ) : null}\n </div>\n\n {hasList && located.length ? (\n <Strip ref={stripRef} rows={located} selected={selected == null ? null : String(selected)} onPick={(id) => select(id, { reveal: true })} className={cx(\"@3xl:hidden\", phoneView === \"list\" && \"hidden\")} />\n ) : null}\n </div>\n </div>\n );\n}\n\nfunction samePos(a, b) {\n if (!a && !b) return true;\n if (!a || !b) return false;\n return Math.abs(a.lat - b.lat) < 1e-9 && Math.abs(a.lng - b.lng) < 1e-9;\n}\n\nfunction viewOf(m) {\n if (!m) return null;\n const c = m.getCenter();\n const b = m.getBounds();\n return { center: { lat: c.lat, lng: c.lng }, zoom: m.getZoom(), bounds: { west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() } };\n}\n\nconst cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);\n\nfunction cssEscape(s) {\n return typeof CSS !== \"undefined\" && CSS.escape ? CSS.escape(s) : s.replace(/[\"\\\\]/g, \"\\\\$&\");\n}\n"
26
26
  },
27
27
  {
28
28
  "path": "registry/blocks/map/list.jsx",
@@ -40,7 +40,7 @@
40
40
  "path": "registry/blocks/map/maplibre.js",
41
41
  "type": "registry:component",
42
42
  "target": "@components/makersclaw/blocks/map/maplibre.js",
43
- "content": "// The one door to MapLibre (maplibre-gl, BSD-3-Clause) and its React\n// binding (react-map-gl, MIT). Every file in this block reaches them\n// through here, and the blocks built on this one reach them through\n// MapView — never the packages directly.\n//\n// No server shim is needed: maplibre-gl 6 and react-map-gl 8 touch no\n// browser global while their modules load (checked in node), and the map\n// itself mounts only after the first effect (MapView's mount guard).\n//\n// Two things are passed explicitly instead of left to the libraries:\n//\n// mapLib react-map-gl would otherwise load maplibre-gl with an\n// import() of its own. Handing it the module we import here\n// keeps the app one static bundle.\n// WORKER_URL maplibre-gl 6 draws tiles in a Web Worker that it loads\n// from a file beside its own module (import.meta.url). Inside\n// an app bundle that URL is a build-time file:// path, so the\n// worker would never load. It is loaded from jsDelivr instead,\n// at exactly the version installed (getVersion()), so the\n// worker and the page always match. A map already fetches its\n// style, tiles and fonts at run time; this is one more file.\n// Self-host it with MapView's `workerUrl` prop.\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport * as maplibregl from \"maplibre-gl\";\nimport { AttributionControl, Layer, Map as GLMap, Marker, Popup, Source } from \"react-map-gl/maplibre\";\n\nexport const MAPLIBRE_VERSION = maplibregl.getVersion();\nexport const WORKER_URL = `https://cdn.jsdelivr.net/npm/maplibre-gl@${MAPLIBRE_VERSION}/dist/maplibre-gl-worker.mjs`;\n\nexport { AttributionControl, GLMap, Layer, Marker, Popup, Source, maplibregl };\n"
43
+ "content": "// The one door to MapLibre (maplibre-gl, BSD-3-Clause) and its React\n// binding (react-map-gl, MIT). Every file in this block reaches them\n// through here, and the blocks built on this one reach them through\n// MapView — never the packages directly.\n//\n// No server shim is needed: maplibre-gl 6 and react-map-gl 8 touch no\n// browser global while their modules load (checked in node), and the map\n// itself mounts only after the first effect (MapView's mount guard).\n//\n// Two things are passed explicitly instead of left to the libraries:\n//\n// mapLib react-map-gl would otherwise load maplibre-gl with an\n// import() of its own. Handing it the module we import here\n// keeps the app one bundle.\n// WORKER_URL maplibre-gl 6 draws tiles in a Web Worker that it loads\n// from a file beside its own module (import.meta.url). Inside\n// an app bundle that URL is a build-time file:// path, so the\n// worker would never load. It is loaded from jsDelivr instead,\n// at exactly the version installed (getVersion()), so the\n// worker and the page always match. A map already fetches its\n// style, tiles and fonts at run time; this is one more file.\n// Self-host it with MapView's `workerUrl` prop.\nimport \"maplibre-gl/dist/maplibre-gl.css\";\nimport * as maplibregl from \"maplibre-gl\";\nimport { AttributionControl, Layer, Map as GLMap, Marker, Popup, Source } from \"react-map-gl/maplibre\";\n\nexport const MAPLIBRE_VERSION = maplibregl.getVersion();\nexport const WORKER_URL = `https://cdn.jsdelivr.net/npm/maplibre-gl@${MAPLIBRE_VERSION}/dist/maplibre-gl-worker.mjs`;\n\nexport { AttributionControl, GLMap, Layer, Marker, Popup, Source, maplibregl };\n"
44
44
  },
45
45
  {
46
46
  "path": "registry/blocks/map/theme.js",
@@ -58,7 +58,7 @@
58
58
  "path": "registry/blocks/map/README.md",
59
59
  "type": "registry:file",
60
60
  "target": "@components/makersclaw/blocks/map/README.md",
61
- "content": "# MapView\n\nPlaces on an interactive map, with their list beside it. Every row that\nhas a position becomes a marker — a dot in a token tone, or your own kit\ncontent. Markers that crowd merge into a count; click it to zoom in. Click\na marker and a popup opens with kit parts. The list on the left is\nsearchable and shares one selection and one hover with the map: pick a\nrow and the map moves to it, pick a marker and the list scrolls to it.\nZoom buttons and \"Show all\" sit top right. Rows with no position are\nlisted under \"Not on the map\", never lost.\n\nOn a phone the map fills the block, a strip of cards sits under it, and a\n\"List\" button swaps to the full list.\n\nIt is [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js)\n(BSD-3-Clause) underneath, through\n[react-map-gl](https://github.com/visgl/react-map-gl) (MIT): its tiles,\ncamera, gestures and keyboard (arrows pan, + and − zoom). The markers,\nclusters, popup, list and controls are ours, in the kit and the\nworkspace's tokens. The blocks built on this one — `location-map`,\n`store-locator`, the Map view of `work-views` — import it, never the map\npackages.\n\n```jsx\nimport { MapView, useMapView } from \"../components/blocks/map\"; // a Makersclaw app (\"./blocks/map\" from components/)\nimport { MapView, useMapView } from \"@/components/makersclaw/blocks/map\"; // a shadcn project\n```\n\n`MapView` is a client component: render it from a `\"use client\"`\ncomponent, and load the rows in the server surface (README §4). The\nhelpers (`readPosition`, `boundsOf`, `distanceKm`…) are plain functions and\nwork on the server.\n\n`makersclaw add map` adds `maplibre-gl` and `react-map-gl` (pinned, exact)\nto package.json: run `npm install` after. They load with the page (no\n`import()`); the map mounts after the first render. Until then — on the\nserver — the frame, the list and a quiet \"Loading the map…\" show.\n\n## What it fetches at run time\n\nA map cannot ship its world inside the app. In the browser it fetches:\n\n- **The tiles, style and fonts** from the style URL. The default is\n [OpenFreeMap](https://openfreemap.org): free, no key, no sign-up,\n OpenStreetMap data. `positron` (light) or `dark`, picked from the\n workspace's scheme and swapped when it changes. The attribution line\n (bottom right) is the data licence's condition: it stays.\n- **MapLibre's tile worker** from jsDelivr, at exactly the installed\n version (`https://cdn.jsdelivr.net/npm/maplibre-gl@<version>/dist/maplibre-gl-worker.mjs`).\n MapLibre 6 finds its worker beside its own file, which a bundled app\n does not have. Host the file yourself and pass `workerUrl` to avoid\n the CDN.\n\nNothing else leaves the page: no geocoding, no analytics.\n\n## The data\n\nRows are your records. A row's position is read by `positionOf`; the\ndefault reads any of these:\n\n```js\n{ lat: 38.71, lng: -9.14 } // or latitude / longitude, or lon\n{ location: { lat: 38.71, lng: -9.14, label } } // the task-model shape\n{ position: [-9.14, 38.71] } // a GeoJSON pair: [lng, lat]\n```\n\nNumbers or numeric strings, in degrees (WGS 84 — what GPS and every\ngeocoder give). A row with no valid position is listed, not mapped.\n\nTones are token names, never colours: `primary` (the default), `success`,\n`warning`, `destructive`, `info`, `chart-1` … `chart-5`, `muted`. A tone\nmeans something — status, owner, type — so say it in words too (a badge\nin `asideOf`).\n\n## Props\n\n- `items` — the rows. Pass `useFixture(rows, SAMPLE).rows` to open with an\n example.\n- `positionOf(row)` — `{ lat, lng }` or null. Default: `readPosition` (above).\n- `idOf(row)` — default `row.id`. Ids are compared as strings.\n- `titleOf(row)` — the name on the marker's label, the list row and the\n popup. Default `row.title ?? row.name ?? row.label ?? row.location.label`.\n- `metaOf(row)` — a muted line under the title (list, card, popup): a\n city, an address. A string, or a node.\n- `asideOf(row)` — a node at the row's end: a `Badge`, a distance.\n- `toneOf(row)` — the marker's tone (above). Default `primary`.\n- `searchOf(row)` — the words the search finds a row by. Default: title\n and meta. Every word typed must appear.\n- `renderMarker(row, { selected, hovered, tone })` — your own marker\n content (an `Avatar`, a price, an icon), kit parts only. It sits inside\n the block's button, so clicks, labels and selection still work.\n- `renderPopup(row, { close })` — the popup's content: kit parts and\n tokens. Default: title, meta and aside. The box and its close button\n are the block's.\n- `selectedId` · `onSelect(id, row)` — control the selection (the row\n your form edits, a URL param). `id` is a string, or null when cleared.\n Leave `selectedId` out and the block keeps its own; `defaultSelectedId`\n sets where it starts. A selection set from outside moves the map to it.\n- `onMove(row, { lat, lng })` — markers become draggable. The marker stays\n where it was dropped at once; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed) and a failed save puts\n it back with a toast saying why. The block keeps the new spot until\n `positionOf(row)` reports a different one.\n- `onMapClick({ lat, lng })` — a click on the map, not a marker (\"add a\n place here\"). The block also clears the selection.\n- `onViewChange({ center, zoom, bounds })` — after the map stops moving\n (and once on load). `bounds` is `{ west, south, east, north }`: search\n this area, load what is in view.\n- `areas` — tinted regions: `[{ id, label?, tone?, geometry }]`, where\n `geometry` is GeoJSON (`Polygon`, `MultiPolygon`, `LineString`). A soft\n fill and a line in the tone, repainted when the scheme changes.\n- `onAreaClick(area)` — an area is clicked.\n- `list` — `\"side\"` (default: the list beside the map, the card strip on\n a phone) or `\"none\"` (the map alone; markers then take Tab focus).\n- `search` — the search field over the list. Default `true`.\n- `cluster` — merge crowded markers. Default `true`. `clusterRadius` —\n how close in pixels, default `48`.\n- `popup` — open a popup for the selected row. Default `true`.\n- `styleUrl` — the tiles: one URL, or `{ light, dark }` to follow the\n scheme. Default `DEFAULT_STYLE` (OpenFreeMap positron / dark).\n `MAP_STYLES` has OpenFreeMap's five: `positron`, `liberty`, `bright`,\n `dark`, `fiord`. Any MapLibre style URL works (MapTiler, Stadia, your\n own), with the provider's key in the URL if it needs one.\n- `scheme` — `\"auto\"` (default: the workspace's light or dark), `\"light\"`\n or `\"dark\"`.\n- `workerUrl` — where MapLibre's worker loads from (above).\n- `initialView` — `{ lat, lng, zoom }` when there are no positions to\n fit. Default: the whole world.\n- `fitOnChange` — refit to the places when the set on the map changes\n (new rows, a search). Default `true`. `fitMaxZoom` — how close a fit\n may go, default `14`. `maxZoom` — default `18`.\n- `scrollZoom` — the wheel zooms. Default `true`; set `false` for a map\n inside a long page (buttons and pinch still zoom).\n- `controls` — the zoom and \"Show all\" buttons. Default `true`.\n- `height` — the block's height: a number (px) or a CSS length. Default\n `520`. The map needs one; the block never sizes itself to its content.\n- `noun` · `nounPlural` — what a row is called: \"account\" → \"Search\n accounts\", \"12 accounts\", \"No accounts yet\". Default `place`.\n- `emptyTitle` · `emptyBody` · `emptyAction` — the empty list's words and\n its one button (a `Button`), when `items` is empty.\n- `label` — what the region is called to a screen reader. Default `Map`.\n- `mapRef` — the handle (below).\n- `className` — layout around it (width, margin). Never colour.\n\n## The handle\n\n```jsx\nconst map = useMapView();\n<MapView mapRef={map} items={rows} />\nmap.current?.fitAll(); // every place on the map in view\nmap.current?.select(\"acc-07\"); // select and move to it\nmap.current?.flyTo({ lat: 48.86, lng: 2.35, zoom: 11 });\nmap.current?.fitBounds({ west, south, east, north });\nmap.current?.zoomIn(); map.current?.zoomOut();\nmap.current?.getView(); // { center, zoom, bounds } or null before load\nmap.current?.getMap(); // the maplibre Map, for what the props do not cover\n```\n\n## Helpers\n\n- `readPosition(row)` → `{ lat, lng }` or null.\n- `boundsOf(positions)` → `{ west, south, east, north }` or null;\n `inBounds(bounds, position)`.\n- `distanceKm(a, b)` — great-circle distance; `formatDistance(km, \"km\" | \"mi\")`\n → \"350 m\", \"4.2 km\", \"24 mi\".\n- `sortByDistance(rows, origin, positionOf?)` → `[{ row, distanceKm }]`,\n nearest first, rows without a position last.\n- `clusterPoints(points, zoom, radius?)` and `zoomToReveal(points, id, zoom)`\n — the clustering the map uses, for a list that groups the same way.\n\n## Example\n\n```jsx\n// components/Customers.jsx — client: the map, the list, the popup\n\"use client\";\nimport { Badge, Button } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { MapView } from \"./blocks/map\";\n\nconst SAMPLE = [\n { name: \"Northwind Café\", city: \"Lisbon\", lat: 38.7107, lng: -9.1422, status: \"active\" },\n { name: \"Acme Bakery\", city: \"Porto\", lat: 41.1405, lng: -8.6132, status: \"at risk\" },\n];\n\nexport default function Customers({ customers }) {\n const fixture = useFixture(customers, SAMPLE);\n return (\n <div className=\"grid gap-4\">\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <MapView\n items={fixture.rows}\n noun=\"customer\"\n titleOf={(c) => c.name}\n metaOf={(c) => c.city}\n toneOf={(c) => (c.status === \"active\" ? \"success\" : \"warning\")}\n asideOf={(c) => <Badge tone={c.status === \"active\" ? \"success\" : \"warning\"} variant=\"soft\">{c.status}</Badge>}\n renderPopup={(c) => (\n <div className=\"grid gap-2 pr-8\">\n <p className=\"font-medium\">{c.name}</p>\n <p className=\"text-muted-foreground\">{c.city}</p>\n <Button size=\"sm\" variant=\"primary\" href={`/customers/${c.id}`}>Open</Button>\n </div>\n )}\n />\n </div>\n );\n}\n```\n\n## What it will not do\n\n- Geocode. It maps coordinates; turning an address into them is a\n server job (store the result on the row).\n- Heatmaps, routes, 3D buildings, satellite imagery. Pass `styleUrl` for\n another base map; `getMap()` reaches maplibre for the rest.\n- Move a marker from the keyboard. Dragging is pointer-only; keep a\n coordinates field in the row's form for keyboard users.\n- Scale past a few thousand markers. Each marker is a DOM node; for tens\n of thousands, use maplibre layers through `getMap()`.\n- Work offline or without WebGL: the list stays, and the map says why it\n cannot show.\n"
61
+ "content": "# MapView\n\nPlaces on an interactive map, with their list beside it. Every row that\nhas a position becomes a marker — a dot in a token tone, or your own kit\ncontent. Markers that crowd merge into a count; click it to zoom in. Click\na marker and a popup opens with kit parts. The list on the left is\nsearchable and shares one selection and one hover with the map: pick a\nrow and the map moves to it, pick a marker and the list scrolls to it.\nZoom buttons and \"Show all\" sit top right. Rows with no position are\nlisted under \"Not on the map\", never lost.\n\nOn a phone the map fills the block, a strip of cards sits under it, and a\n\"List\" button swaps to the full list.\n\nIt is [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js)\n(BSD-3-Clause) underneath, through\n[react-map-gl](https://github.com/visgl/react-map-gl) (MIT): its tiles,\ncamera, gestures and keyboard (arrows pan, + and − zoom). The markers,\nclusters, popup, list and controls are ours, in the kit and the\nworkspace's tokens. The blocks built on this one — `location-map`,\n`store-locator`, the Map view of `work-views` — import it, never the map\npackages.\n\n```jsx\nimport { MapView, useMapView } from \"../components/blocks/map\"; // a Makersclaw app (\"./blocks/map\" from components/)\nimport { MapView, useMapView } from \"@/components/makersclaw/blocks/map\"; // a shadcn project\n```\n\n`MapView` is a client component: render it from a `\"use client\"`\ncomponent, and load the rows in the server surface (README §4). The\nhelpers (`readPosition`, `boundsOf`, `distanceKm`…) are plain functions and\nwork on the server.\n\n`makersclaw add map` adds `maplibre-gl` and `react-map-gl` (pinned, exact)\nto package.json: run `npm install` after. They load with the page (no\n`import()`); the map mounts after the first render. Until then — on the\nserver — the frame, the list and a quiet \"Loading the map…\" show.\n\n## What it fetches at run time\n\nA map cannot ship its world inside the app. In the browser it fetches:\n\n- **The tiles, style and fonts** from the style URL. The default is\n [OpenFreeMap](https://openfreemap.org): free, no key, no sign-up,\n OpenStreetMap data. `positron` (light) or `dark`, picked from the\n workspace's scheme and swapped when it changes. The attribution line\n (bottom right) is the data licence's condition: it stays.\n- **MapLibre's tile worker** from jsDelivr, at exactly the installed\n version (`https://cdn.jsdelivr.net/npm/maplibre-gl@<version>/dist/maplibre-gl-worker.mjs`).\n MapLibre 6 finds its worker beside its own file, which a bundled app\n does not have. Host the file yourself and pass `workerUrl` to avoid\n the CDN.\n\nNothing else leaves the page: no geocoding, no analytics.\n\n## The data\n\nRows are your records. A row's position is read by `positionOf`; the\ndefault reads any of these:\n\n```js\n{ lat: 38.71, lng: -9.14 } // or latitude / longitude, or lon\n{ location: { lat: 38.71, lng: -9.14, label } } // the task-model shape\n{ position: [-9.14, 38.71] } // a GeoJSON pair: [lng, lat]\n```\n\nNumbers or numeric strings, in degrees (WGS 84 — what GPS and every\ngeocoder give). A row with no valid position is listed, not mapped.\n\nTones are token names, never colours: `primary` (the default), `success`,\n`warning`, `destructive`, `info`, `chart-1` … `chart-5`, `muted`. A tone\nmeans something — status, owner, type — so say it in words too (a badge\nin `asideOf`).\n\n## Props\n\n- `items` — the rows. Pass `useFixture(rows, SAMPLE).rows` to open with an\n example.\n- `positionOf(row)` — `{ lat, lng }` or null. Default: `readPosition` (above).\n- `idOf(row)` — default `row.id`. Ids are compared as strings.\n- `titleOf(row)` — the name on the marker's label, the list row and the\n popup. Default `row.title ?? row.name ?? row.label ?? row.location.label`.\n- `metaOf(row)` — a muted line under the title (list, card, popup): a\n city, an address. A string, or a node.\n- `asideOf(row)` — a node at the row's end: a `Badge`, a distance.\n- `toneOf(row)` — the marker's tone (above). Default `primary`.\n- `searchOf(row)` — the words the search finds a row by. Default: title\n and meta. Every word typed must appear.\n- `renderMarker(row, { selected, hovered, tone })` — your own marker\n content (an `Avatar`, a price, an icon), kit parts only. It sits inside\n the block's button, so clicks, labels and selection still work.\n- `renderPopup(row, { close })` — the popup's content: kit parts and\n tokens. Default: title, meta and aside. The box and its close button\n are the block's.\n- `selectedId` · `onSelect(id, row)` — control the selection (the row\n your form edits, a URL param). `id` is a string, or null when cleared.\n Leave `selectedId` out and the block keeps its own; `defaultSelectedId`\n sets where it starts. A selection set from outside moves the map to it.\n- `onMove(row, { lat, lng })` — markers become draggable. The marker stays\n where it was dropped at once; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed) and a failed save puts\n it back with a toast saying why. The block keeps the new spot until\n `positionOf(row)` reports a different one.\n- `onMapClick({ lat, lng })` — a click on the map, not a marker (\"add a\n place here\"). The block also clears the selection.\n- `onViewChange({ center, zoom, bounds })` — after the map stops moving\n (and once on load). `bounds` is `{ west, south, east, north }`: search\n this area, load what is in view.\n- `areas` — tinted regions: `[{ id, label?, tone?, geometry }]`, where\n `geometry` is GeoJSON (`Polygon`, `MultiPolygon`, `LineString`). A soft\n fill and a line in the tone, repainted when the scheme changes.\n- `onAreaClick(area)` — an area is clicked.\n- `list` — `\"side\"` (default: the list beside the map, the card strip on\n a phone) or `\"none\"` (the map alone; markers then take Tab focus).\n- `search` — the search field over the list. Default `true`.\n- `cluster` — merge crowded markers. Default `true`. `clusterRadius` —\n how close in pixels, default `48`.\n- `popup` — open a popup for the selected row. Default `true`.\n- `styleUrl` — the tiles: one URL, or `{ light, dark }` to follow the\n scheme. Default `DEFAULT_STYLE` (OpenFreeMap positron / dark).\n `MAP_STYLES` has OpenFreeMap's five: `positron`, `liberty`, `bright`,\n `dark`, `fiord`. Any MapLibre style URL works (MapTiler, Stadia, your\n own), with the provider's key in the URL if it needs one.\n- `scheme` — `\"auto\"` (default: the workspace's light or dark), `\"light\"`\n or `\"dark\"`.\n- `workerUrl` — where MapLibre's worker loads from (above).\n- `initialView` — `{ lat, lng, zoom }` when there are no positions to\n fit. Default: the whole world.\n- `fitOnChange` — refit to the places when the set on the map changes\n (new rows, a search). Default `true`. `fitMaxZoom` — how close a fit\n may go, default `14`. `maxZoom` — default `18`.\n- `scrollZoom` — the wheel zooms. Default `true`; set `false` for a map\n inside a long page (buttons and pinch still zoom).\n- `controls` — the zoom and \"Show all\" buttons. Default `true`.\n- `height` — the block's height: a number (px) or a CSS length. Default\n `520`. The map needs one; the block never sizes itself to its content.\n- `noun` · `nounPlural` — what a row is called: \"account\" → \"Search\n accounts\", \"12 accounts\", \"No accounts yet\". Default `place`.\n- `emptyTitle` · `emptyBody` · `emptyAction` — the empty list's words and\n its one button (a `Button`), when `items` is empty.\n- `label` — what the region is called to a screen reader. Default `Map`.\n- `mapRef` — the handle (below).\n- `className` — layout around it (width, margin). Never colour.\n\n## The handle\n\n```jsx\nconst map = useMapView();\n<MapView mapRef={map} items={rows} />\nmap.current?.fitAll(); // every place on the map in view\nmap.current?.select(\"acc-07\"); // select and move to it\nmap.current?.flyTo({ lat: 48.86, lng: 2.35, zoom: 11 });\nmap.current?.fitBounds({ west, south, east, north });\nmap.current?.zoomIn(); map.current?.zoomOut();\nmap.current?.getView(); // { center, zoom, bounds } or null before load\nmap.current?.getMap(); // the maplibre Map, for what the props do not cover\n```\n\n## Helpers\n\n- `readPosition(row)` → `{ lat, lng }` or null.\n- `boundsOf(positions)` → `{ west, south, east, north }` or null;\n `inBounds(bounds, position)`.\n- `distanceKm(a, b)` — great-circle distance; `formatDistance(km, \"km\" | \"mi\")`\n → \"350 m\", \"4.2 km\", \"24 mi\".\n- `sortByDistance(rows, origin, positionOf?)` → `[{ row, distanceKm }]`,\n nearest first, rows without a position last.\n- `clusterPoints(points, zoom, radius?)` and `zoomToReveal(points, id, zoom)`\n — the clustering the map uses, for a list that groups the same way.\n\n## Example\n\n```jsx\n// surfaces/customers.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Customers from \"../components/Customers\";\n\nexport default function CustomersSurface() {\n const { rows, error, loading } = useRows({ source: \"customers\", orderBy: \"name asc\" });\n if (loading) return null; // rows only once read: to useFixture, a read in flight looks like an empty table\n return <Customers customers={rows} error={error} />;\n}\n```\n\n```jsx\n// components/Customers.jsx — client: the map, the list, the popup\n\"use client\";\nimport { Badge, Button, Notice } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { MapView } from \"./blocks/map\";\n\nconst SAMPLE = [\n { name: \"Northwind Café\", city: \"Lisbon\", lat: 38.7107, lng: -9.1422, status: \"active\" },\n { name: \"Acme Bakery\", city: \"Porto\", lat: 41.1405, lng: -8.6132, status: \"at risk\" },\n];\n\nexport default function Customers({ customers, error }) {\n const fixture = useFixture(customers, SAMPLE);\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 <MapView\n items={fixture.rows}\n noun=\"customer\"\n titleOf={(c) => c.name}\n metaOf={(c) => c.city}\n toneOf={(c) => (c.status === \"active\" ? \"success\" : \"warning\")}\n asideOf={(c) => <Badge tone={c.status === \"active\" ? \"success\" : \"warning\"} variant=\"soft\">{c.status}</Badge>}\n renderPopup={(c) => (\n <div className=\"grid gap-2 pr-8\">\n <p className=\"font-medium\">{c.name}</p>\n <p className=\"text-muted-foreground\">{c.city}</p>\n <Button size=\"sm\" variant=\"primary\" href={`/s/customers?row=${c.id}`}>Open</Button>\n </div>\n )}\n />\n </div>\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\n## What it will not do\n\n- Geocode. It maps coordinates; turning an address into them is a\n server job (store the result on the row).\n- Heatmaps, routes, 3D buildings, satellite imagery. Pass `styleUrl` for\n another base map; `getMap()` reaches maplibre for the rest.\n- Move a marker from the keyboard. Dragging is pointer-only; keep a\n coordinates field in the row's form for keyboard users.\n- Scale past a few thousand markers. Each marker is a DOM node; for tens\n of thousands, use maplibre layers through `getMap()`.\n- Work offline or without WebGL: the list stays, and the map says why it\n cannot show.\n"
62
62
  }
63
63
  ],
64
64
  "categories": [
@@ -82,7 +82,7 @@
82
82
  "docs": "Installed to components/makersclaw/blocks/map. Import: import { MapView, useMapView, MAP_STYLES, DEFAULT_STYLE, TONES, readPosition, boundsOf, inBounds, clusterPoints, zoomToReveal, distanceKm, formatDistance, sortByDistance } from \"@/components/makersclaw/blocks/map\". Props and an example: components/makersclaw/blocks/map/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
83
83
  "meta": {
84
84
  "makersclaw": {
85
- "version": "0.5.2",
85
+ "version": "0.7.0",
86
86
  "useWhen": "A screen shows records that have a place: customers, stores, offices, field visits, deliveries, events, territories. The location-map and store-locator blocks and the Map view of work-views are built on it. Not for a chart of numbers by region (chart) or a hand-drawn diagram (sketch-canvas, flow-canvas)."
87
87
  }
88
88
  }
package/r/menubar.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Menubar",
6
6
  "description": "A desktop app's menu bar — File, Edit, View, Help — each a menu of actions with shortcuts, checkboxes, radio groups and submenus; click to open, hover to switch, arrow keys along the bar; one menu button on a phone.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -45,7 +45,7 @@
45
45
  "docs": "Installed to components/makersclaw/blocks/menubar. Import: import { Menubar } from \"@/components/makersclaw/blocks/menubar\". Props and an example: components/makersclaw/blocks/menubar/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
46
46
  "meta": {
47
47
  "makersclaw": {
48
- "version": "0.5.2",
48
+ "version": "0.7.0",
49
49
  "useWhen": "A document-style tool with many commands grouped by kind: an editor, a canvas, a spreadsheet, an admin console that mimics a desktop app. Not for a site's navigation (a navigation menu) or one record's actions (a menu on the row)."
50
50
  }
51
51
  }
package/r/mind-map.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "MindMap",
6
6
  "description": "A mind map you can build without the mouse, on flow-canvas: a centre topic with main branches balanced on both sides, each branch a tidy auto-laid-out tree in its own chart hue. Tab adds a subtopic, Enter the next topic, typing renames, arrows walk the map, ⌘↑/⌘↓ fold a branch (a folded one shows how many it hides), ⌥↑/⌥↓ reorder, Shift+Tab moves up a level; drag a topic onto another to move its branch. Undo/redo, a colour per branch, an Outline view, copy a branch as an outline and paste an outline as topics, starter templates. The map is plain JSON (a tree of topics); saves are async callbacks answering { ok }.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/flow-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/flow-canvas.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -38,7 +38,7 @@
38
38
  "path": "registry/blocks/mind-map/README.md",
39
39
  "type": "registry:file",
40
40
  "target": "@components/makersclaw/blocks/mind-map/README.md",
41
- "content": "# MindMap\n\nA mind map you can build without touching the mouse. One centre topic,\nmain branches balanced on both sides of it, each branch a tidy tree in its\nown colour. **Tab** adds a subtopic, **Enter** the next topic, typing\nrenames, the arrows walk the map. The layout is automatic: topics never\nneed dragging into place, and the map re-balances as it grows.\n\nA bar on top holds undo and redo, the save state, the selected topic's\nactions (add, colour, fold, delete) and a Map / Outline switch. The\nOutline view is the map as a nested list — the reading of it for a screen\nreader, and a quick way to jump to a topic.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nowns the tree, the layout and the keys.\n\nA map opens fitted to its view, but never smaller than its words can be\nread: 79%, where its smallest words (14px) draw at the kit's smallest\ntext size (11px). A map too big for that — most maps on a phone — opens\nat 79% centred on its centre topic; ⇧1 still fits the whole of it.\n\n```jsx\nimport { MindMap } from \"../components/blocks/mind-map\"; // a Makersclaw app (\"./blocks/mind-map\" from components/)\nimport { MindMap } from \"@/components/makersclaw/blocks/mind-map\"; // a shadcn project\n```\n\n`MindMap` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add mind-map` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The tree helpers\n(`mindMapToText`, `outlineToMindMap`, `layoutMindMap`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA map is one tree of topics. Store it as it comes out of `onChange` (one\n`jsonb` column), and keep `mindMapToText(map)` beside it for search and\nagents.\n\n```js\n{\n id: \"root\", text: \"Relay 2.0 launch\",\n children: [\n { id: \"why\", text: \"Why now\", side: \"right\", children: [\n { id: \"why-1\", text: \"Churn up 3 pts\" },\n ] },\n { id: \"risks\", text: \"Risks\", side: \"left\", color: \"chart-4\", collapsed: true, children: [ … ] },\n ],\n}\n```\n\n- `id` — any string, unique in the map. `text` — the words (a newline is\n allowed). `children` — the subtopics, in order.\n- `collapsed: true` — the branch is folded; the map shows how many topics\n it hides.\n- `color` — a colour **token**, never a colour value: `\"chart-2\"` …\n `\"chart-5\"`, `\"chart-1\"` (the theme's own), `\"warning\"`, `\"success\"`,\n `\"info\"`, `\"destructive\"`. Subtopics inherit their branch's. A main\n branch with none takes the next of `colors`.\n- `side` — `\"left\"` or `\"right\"`, main branches only. Missing, the map\n splits the branches to balance the two sides; the first change pins\n them, so adding a branch never flips the others across the centre.\n- Anything else on a topic (`note`, your own keys) is kept as it is.\n\n`normalizeMap(anything)` cleans what it is given: missing or repeated ids,\n`title`/`label` for `text`, `topics`/`items` for `children`, an outline\nstring, or nothing (a fresh map). The component runs it on `value`.\n\n## Props\n\n- `value` — the map, controlled. A new `value` that is not what the map\n last sent through `onChange` replaces it (loading another record); that\n replacement can be undone, and when it is another map (a new centre, or\n other main branches) the view fits it, as it fits a first one. Pass\n back exactly what `onChange` gave you.\n- `defaultValue` — the starting map, uncontrolled. With neither, a centre\n topic called \"Central topic\" and the first-run card.\n- `onChange(map)` — the whole tree after a change settles (debounced by\n `changeDelay`; typing is saved as it goes). Selection, folding in\n read-only mode, panning and zooming never call it.\n- `onSave(map)` — `async`, returns `{ ok: true }` or `{ ok: false,\n error? }` (a throw counts as failed). Called right after `onChange`. The\n bar shows \"Saving…\", \"Saved\", or the error with Retry. A failure keeps\n the map as it is on screen (nothing typed is thrown away); Retry sends\n it again.\n- `changeDelay` — ms of quiet before `onChange` / `onSave` (default 400).\n- `readOnly` — `false` (default). `true`: no editing; the arrows still\n walk the map, Space folds and unfolds (not saved), the Outline view and\n copying work.\n- `colors` — the branch palette, token names (default `BRANCH_COLORS`:\n chart-2, 3, 4, 5, 1). The colour picker offers these plus warning,\n success, info and destructive.\n- `templates` — what the first-run card offers: `[{ id, label, icon?,\n description?, map }]`, `map` a tree or a function returning one (default\n `mindMapTemplates()`: Project plan, Brainstorm, Meeting notes). `[]` for\n none.\n- `placeholder` — what an empty topic shows while it is typed (default\n \"New topic\").\n- `height` — px or any CSS length, default `600`. A height class in\n `className` (`h-dvh`, `h-full`, `flex-1`) replaces it.\n- `label` — the map's accessible name (default \"Mind map\").\n- `onSelectionChange(topic | null)` — the selected topic changed (a side\n panel with its notes).\n- `className` — layout around it (margin, width, height). Never colour.\n\n## Keyboard\n\nWith a topic selected (click one, or press an arrow):\n\n| Key | Does |\n| --- | --- |\n| Tab | add a subtopic and type it |\n| Enter / Shift+Enter | add the next topic below / above |\n| Space, F2, or just type | rename |\n| Delete / Backspace | delete the topic and its branch (a toast offers Undo) |\n| ← → ↑ ↓ | walk the map: outwards to a child, inwards to the parent, up and down the column |\n| ⌥↑ / ⌥↓ | move the topic up or down among its siblings |\n| Shift+Tab | move the topic up a level |\n| ⌘↑ / ⌘↓ | fold / unfold the branch |\n| ⌘D | duplicate the branch |\n| ⌘C / ⌘X / ⌘V | copy or cut a branch as an outline; paste an outline (from notes, a doc, an agent) as subtopics |\n| ⌘Z / ⇧⌘Z | undo / redo |\n| Home | the centre |\n| Esc | clear the selection |\n| ⇧1 | fit the whole map to the screen, however small |\n| ? | the shortcuts sheet |\n\nWhile typing a topic: **Enter** finishes it and starts the next one (on\nan empty one it steps out a level, so a run of topics never needs the\nmouse), **Tab** finishes it and starts a subtopic (on an empty one it\nmoves in a level), **Backspace** on an empty topic removes it and goes\nback to the one before, **Esc** finishes. Shift+Enter is a new line.\n\nWith the mouse: click selects, double-click renames, the **+** at a\ntopic's end adds a subtopic, the chip at a branch's end folds it (folded,\nit shows the count it hides), and dragging a topic onto another moves its\nbranch there. A plain drag on the empty canvas pans; pinch or ⌘-scroll\nzooms. On a touch screen a drag always pans; tap selects, double-tap\nrenames, and the bar's buttons add.\n\n## The helpers\n\nPlain functions over a tree; each returns a new tree and leaves the old\none as it was.\n\n- Read: `findTopic(map, id)`, `pathTo(map, id)` → `[root, …, topic]`,\n `parentOf(map, id)`, `countTopics(topic)` (everything under it).\n- Change: `updateTopic(map, id, patch)`, `insertTopic(map, parentId,\n topic, index?)`, `insertSibling(map, id, topic, { before })`,\n `removeTopic(map, id)`, `moveTopic(map, id, parentId, index?)` (refused\n into its own branch), `shiftTopic(map, id, ±1)`, `indentTopic`,\n `outdentTopic`, `duplicateTopic(map, id)` → `{ root, id }`,\n `setAllCollapsed(map, on)`, `createTopic(text)`.\n- Text: `mindMapToText(map)` (an indented outline, for an agent or search),\n `mindMapToMarkdown(map)` (a heading and a nested list),\n `outlineToMindMap(text)` and `outlineToTopics(text)` (bullets, numbers,\n headings, tabs or spaces — what an agent writes).\n- Layout: `layoutMindMap(map, { measure?, colors? })` → `{ items, bounds }`\n (every visible topic's box, side, depth, colour and the line to its\n parent), `mindMapToBoard(map)` (the same as a flow-canvas board).\n- `mindMapTemplates()`, `BRANCH_COLORS`.\n\n## Example\n\n```jsx\n// components/PlanMap.jsx — client\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { MindMap, mindMapToText } from \"./blocks/mind-map\";\n\nexport default function PlanMap({ plan }) {\n const [map, setMap] = useState(plan.map); // jsonb, may be null\n return (\n <MindMap\n value={map ?? undefined}\n defaultValue={{ text: plan.name }}\n onChange={setMap}\n onSave={(m) => updateRow({ source: \"public.plans\", id: plan.id, patch: { map: m, map_text: mindMapToText(m) } })}\n label={`${plan.name} map`}\n className=\"h-[calc(100dvh-8rem)]\"\n />\n );\n}\n```\n\nAn agent's outline, straight onto a map:\n\n```jsx\nconst map = outlineToMindMap(`# Hiring plan\n- Roles\n - Founding engineer\n - Designer\n- Sourcing\n - Referrals\n - Communities`);\n<MindMap defaultValue={map} onSave={save} />\n```\n\n## What it will not do\n\n- Free placement: topics sit where the layout puts them (drag moves a\n branch to another topic, not to a spot). For free boards use\n `flow-canvas` or `whiteboard`.\n- Cross-links between branches, images or icons on topics.\n- Collaborate live: saves are last-write-wins.\n- Export an image.\n- Render the map on the server: the server shows it as text until the\n canvas mounts. For a read-only page that must be rich in the HTML,\n render `mindMapToMarkdown` yourself.\n\nA map it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add mind-map` restores them.\n"
41
+ "content": "# MindMap\n\nA mind map you can build without touching the mouse. One centre topic,\nmain branches balanced on both sides of it, each branch a tidy tree in its\nown colour. **Tab** adds a subtopic, **Enter** the next topic, typing\nrenames, the arrows walk the map. The layout is automatic: topics never\nneed dragging into place, and the map re-balances as it grows.\n\nA bar on top holds undo and redo, the save state, the selected topic's\nactions (add, colour, fold, delete) and a Map / Outline switch. The\nOutline view is the map as a nested list — the reading of it for a screen\nreader, and a quick way to jump to a topic.\n\nIt is built on `flow-canvas` (which draws it, pans and zooms): this block\nowns the tree, the layout and the keys.\n\nA map opens fitted to its view, but never smaller than its words can be\nread: 79%, where its smallest words (14px) draw at the kit's smallest\ntext size (11px). A map too big for that — most maps on a phone — opens\nat 79% centred on its centre topic; ⇧1 still fits the whole of it.\n\n```jsx\nimport { MindMap } from \"../components/blocks/mind-map\"; // a Makersclaw app (\"./blocks/mind-map\" from components/)\nimport { MindMap } from \"@/components/makersclaw/blocks/mind-map\"; // a shadcn project\n```\n\n`MindMap` is a client component: render it from a `\"use client\"`\ncomponent. `makersclaw add mind-map` adds `flow-canvas` with it (and its\nnpm package, `@xyflow/react`: run `npm install` after). The tree helpers\n(`mindMapToText`, `outlineToMindMap`, `layoutMindMap`…) are plain\nfunctions and work on the server too.\n\n## The data\n\nA map is one tree of topics. Store it as it comes out of `onChange` (one\n`jsonb` column), and keep `mindMapToText(map)` beside it for search and\nagents.\n\n```js\n{\n id: \"root\", text: \"Relay 2.0 launch\",\n children: [\n { id: \"why\", text: \"Why now\", side: \"right\", children: [\n { id: \"why-1\", text: \"Churn up 3 pts\" },\n ] },\n { id: \"risks\", text: \"Risks\", side: \"left\", color: \"chart-4\", collapsed: true, children: [ … ] },\n ],\n}\n```\n\n- `id` — any string, unique in the map. `text` — the words (a newline is\n allowed). `children` — the subtopics, in order.\n- `collapsed: true` — the branch is folded; the map shows how many topics\n it hides.\n- `color` — a colour **token**, never a colour value: `\"chart-2\"` …\n `\"chart-5\"`, `\"chart-1\"` (the theme's own), `\"warning\"`, `\"success\"`,\n `\"info\"`, `\"destructive\"`. Subtopics inherit their branch's. A main\n branch with none takes the next of `colors`.\n- `side` — `\"left\"` or `\"right\"`, main branches only. Missing, the map\n splits the branches to balance the two sides; the first change pins\n them, so adding a branch never flips the others across the centre.\n- Anything else on a topic (`note`, your own keys) is kept as it is.\n\n`normalizeMap(anything)` cleans what it is given: missing or repeated ids,\n`title`/`label` for `text`, `topics`/`items` for `children`, an outline\nstring, or nothing (a fresh map). The component runs it on `value`.\n\n## Props\n\n- `value` — the map, controlled. A new `value` that is not what the map\n last sent through `onChange` replaces it (loading another record); that\n replacement can be undone, and when it is another map (a new centre, or\n other main branches) the view fits it, as it fits a first one. Pass\n back exactly what `onChange` gave you.\n- `defaultValue` — the starting map, uncontrolled. With neither, a centre\n topic called \"Central topic\" and the first-run card.\n- `onChange(map)` — the whole tree after a change settles (debounced by\n `changeDelay`; typing is saved as it goes). Selection, folding in\n read-only mode, panning and zooming never call it.\n- `onSave(map)` — `async`, returns `{ ok: true }` or `{ ok: false,\n error? }` (a throw counts as failed). Called right after `onChange`. The\n bar shows \"Saving…\", \"Saved\", or the error with Retry. A failure keeps\n the map as it is on screen (nothing typed is thrown away); Retry sends\n it again.\n- `changeDelay` — ms of quiet before `onChange` / `onSave` (default 400).\n- `readOnly` — `false` (default). `true`: no editing; the arrows still\n walk the map, Space folds and unfolds (not saved), the Outline view and\n copying work.\n- `colors` — the branch palette, token names (default `BRANCH_COLORS`:\n chart-2, 3, 4, 5, 1). The colour picker offers these plus warning,\n success, info and destructive.\n- `templates` — what the first-run card offers: `[{ id, label, icon?,\n description?, map }]`, `map` a tree or a function returning one (default\n `mindMapTemplates()`: Project plan, Brainstorm, Meeting notes). `[]` for\n none.\n- `placeholder` — what an empty topic shows while it is typed (default\n \"New topic\").\n- `height` — px or any CSS length, default `600`. A height class in\n `className` (`h-dvh`, `h-full`, `flex-1`) replaces it.\n- `label` — the map's accessible name (default \"Mind map\").\n- `onSelectionChange(topic | null)` — the selected topic changed (a side\n panel with its notes).\n- `className` — layout around it (margin, width, height). Never colour.\n\n## Keyboard\n\nWith a topic selected (click one, or press an arrow):\n\n| Key | Does |\n| --- | --- |\n| Tab | add a subtopic and type it |\n| Enter / Shift+Enter | add the next topic below / above |\n| Space, F2, or just type | rename |\n| Delete / Backspace | delete the topic and its branch (a toast offers Undo) |\n| ← → ↑ ↓ | walk the map: outwards to a child, inwards to the parent, up and down the column |\n| ⌥↑ / ⌥↓ | move the topic up or down among its siblings |\n| Shift+Tab | move the topic up a level |\n| ⌘↑ / ⌘↓ | fold / unfold the branch |\n| ⌘D | duplicate the branch |\n| ⌘C / ⌘X / ⌘V | copy or cut a branch as an outline; paste an outline (from notes, a doc, an agent) as subtopics |\n| ⌘Z / ⇧⌘Z | undo / redo |\n| Home | the centre |\n| Esc | clear the selection |\n| ⇧1 | fit the whole map to the screen, however small |\n| ? | the shortcuts sheet |\n\nWhile typing a topic: **Enter** finishes it and starts the next one (on\nan empty one it steps out a level, so a run of topics never needs the\nmouse), **Tab** finishes it and starts a subtopic (on an empty one it\nmoves in a level), **Backspace** on an empty topic removes it and goes\nback to the one before, **Esc** finishes. Shift+Enter is a new line.\n\nWith the mouse: click selects, double-click renames, the **+** at a\ntopic's end adds a subtopic, the chip at a branch's end folds it (folded,\nit shows the count it hides), and dragging a topic onto another moves its\nbranch there. A plain drag on the empty canvas pans; pinch or ⌘-scroll\nzooms. On a touch screen a drag always pans; tap selects, double-tap\nrenames, and the bar's buttons add.\n\n## The helpers\n\nPlain functions over a tree; each returns a new tree and leaves the old\none as it was.\n\n- Read: `findTopic(map, id)`, `pathTo(map, id)` → `[root, …, topic]`,\n `parentOf(map, id)`, `countTopics(topic)` (everything under it).\n- Change: `updateTopic(map, id, patch)`, `insertTopic(map, parentId,\n topic, index?)`, `insertSibling(map, id, topic, { before })`,\n `removeTopic(map, id)`, `moveTopic(map, id, parentId, index?)` (refused\n into its own branch), `shiftTopic(map, id, ±1)`, `indentTopic`,\n `outdentTopic`, `duplicateTopic(map, id)` → `{ root, id }`,\n `setAllCollapsed(map, on)`, `createTopic(text)`.\n- Text: `mindMapToText(map)` (an indented outline, for an agent or search),\n `mindMapToMarkdown(map)` (a heading and a nested list),\n `outlineToMindMap(text)` and `outlineToTopics(text)` (bullets, numbers,\n headings, tabs or spaces — what an agent writes).\n- Layout: `layoutMindMap(map, { measure?, colors? })` → `{ items, bounds }`\n (every visible topic's box, side, depth, colour and the line to its\n parent), `mindMapToBoard(map)` (the same as a flow-canvas board).\n- `mindMapTemplates()`, `BRANCH_COLORS`.\n\n## Example\n\n```jsx\n// surfaces/plan-map.jsx — client: one plan's map\n\"use client\";\nimport { useState } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport { updateRow } from \"../lib/actions\";\nimport { MindMap, mindMapToText } from \"../components/blocks/mind-map\";\n\nexport default function PlanMapSurface() {\n const id = useSearchParams().get(\"row\");\n const { row: plan, loading } = useRow({ source: \"plans\", id });\n if (loading || !plan) return null;\n return <PlanMap key={plan.id} plan={plan} />;\n}\n\nfunction PlanMap({ plan }) {\n const [map, setMap] = useState(plan.map); // jsonb, may be null; read once per plan\n return (\n <MindMap\n value={map ?? undefined}\n defaultValue={{ text: plan.name }}\n onChange={setMap}\n onSave={(m) => updateRow({ source: \"plans\", id: plan.id, patch: { map: m, map_text: mindMapToText(m) } })}\n label={`${plan.name} map`}\n className=\"h-[calc(100dvh-8rem)]\"\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\nAn agent's outline, straight onto a map:\n\n```jsx\nconst map = outlineToMindMap(`# Hiring plan\n- Roles\n - Founding engineer\n - Designer\n- Sourcing\n - Referrals\n - Communities`);\n<MindMap defaultValue={map} onSave={save} />\n```\n\n## What it will not do\n\n- Free placement: topics sit where the layout puts them (drag moves a\n branch to another topic, not to a spot). For free boards use\n `flow-canvas` or `whiteboard`.\n- Cross-links between branches, images or icons on topics.\n- Collaborate live: saves are last-write-wins.\n- Export an image.\n- Render the map on the server: the server shows it as text until the\n canvas mounts. For a read-only page that must be rich in the HTML,\n render `mindMapToMarkdown` yourself.\n\nA map it cannot be is your own component in `components/` — not an edit\nto this block. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add mind-map` restores them.\n"
42
42
  }
43
43
  ],
44
44
  "categories": [
@@ -60,7 +60,7 @@
60
60
  "docs": "Installed to components/makersclaw/blocks/mind-map. Import: import { MindMap, BRANCH_COLORS, normalizeMap, createTopic, findTopic, updateTopic, insertTopic, removeTopic, moveTopic, layoutMindMap, mindMapToBoard, mindMapToText, mindMapToMarkdown, outlineToMindMap, outlineToTopics, mindMapTemplates } from \"@/components/makersclaw/blocks/mind-map\". Props and an example: components/makersclaw/blocks/mind-map/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
61
61
  "meta": {
62
62
  "makersclaw": {
63
- "version": "0.5.2",
63
+ "version": "0.7.0",
64
64
  "useWhen": "A screen for thinking in a tree: brainstorming around one idea, planning a project, breaking a goal down, notes from a meeting, a topic outline an agent drafted. Not for free-form boards with stickies and arrows (flow-canvas, whiteboard), a people hierarchy (org-chart), or a process with steps (process-map)."
65
65
  }
66
66
  }