makersclaw 0.4.0 → 0.5.2

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 (102) hide show
  1. package/package.json +1 -1
  2. package/r/annotate.json +3 -3
  3. package/r/approval-request.json +4 -4
  4. package/r/audio-player.json +2 -2
  5. package/r/auth-form.json +2 -2
  6. package/r/board.json +2 -2
  7. package/r/calendar-view.json +9 -9
  8. package/r/cart.json +2 -2
  9. package/r/chart.json +7 -7
  10. package/r/chat.json +3 -3
  11. package/r/code-editor.json +2 -2
  12. package/r/comment-box.json +5 -5
  13. package/r/contact-section.json +2 -2
  14. package/r/core.json +5 -5
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +4 -4
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +4 -4
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +6 -6
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +4 -4
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +3 -3
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +6 -6
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +4 -4
  48. package/r/signature-pad.json +3 -3
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +3 -3
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/registry/blocks/approval-request/README.md +2 -1
  58. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  59. package/registry/blocks/calendar-view/README.md +8 -0
  60. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  61. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  62. package/registry/blocks/calendar-view/demo.jsx +2 -0
  63. package/registry/blocks/calendar-view/month.jsx +6 -5
  64. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  65. package/registry/blocks/calendar-view/tray.jsx +5 -4
  66. package/registry/blocks/chart/README.md +9 -4
  67. package/registry/blocks/chart/cartesian.jsx +31 -24
  68. package/registry/blocks/chart/core.jsx +21 -0
  69. package/registry/blocks/chart/pie.jsx +15 -10
  70. package/registry/blocks/chart/sparkline.jsx +32 -14
  71. package/registry/blocks/chat/message.jsx +4 -4
  72. package/registry/blocks/comment-box/README.md +1 -1
  73. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  74. package/registry/blocks/data-table/README.md +4 -0
  75. package/registry/blocks/data-table/data-table.jsx +20 -6
  76. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  77. package/registry/blocks/flow-canvas/theme.js +59 -48
  78. package/registry/blocks/mind-map/README.md +9 -2
  79. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  80. package/registry/blocks/mind-map/tree.js +33 -0
  81. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  82. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  83. package/registry/blocks/record-notes/README.md +1 -1
  84. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  85. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  86. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  87. package/registry/blocks/signature-pad/signature.js +8 -1
  88. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  89. package/templates/next/files/README.md +77 -19
  90. package/templates/next/files/app/auto-refresh.jsx +39 -74
  91. package/templates/next/files/app/frame.js +197 -6
  92. package/templates/next/files/check/run.mjs +96 -11
  93. package/templates/next/files/components/ui/calendar.jsx +24 -0
  94. package/templates/next/files/components/ui/field.jsx +12 -3
  95. package/templates/next/files/components/ui/index.js +1 -0
  96. package/templates/next/files/components/ui/sheet.jsx +38 -2
  97. package/templates/next/files/components/ui/toggle.jsx +20 -2
  98. package/templates/next/files/lib/actions.js +63 -5
  99. package/templates/next/files/lib/data.js +4 -1
  100. package/templates/next/files/lib/db.js +18 -1
  101. package/templates/next/files/lib/hooks.js +52 -17
  102. package/templates/next/template.json +2 -2
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useId } from "react";
4
4
  import { cx } from "../../ui";
5
- import { defaultFormat, idFrom, num, tone, useReveal, useWidth } from "./core";
5
+ import { defaultFormat, idFrom, num, runs, tone, useReveal, useWidth } from "./core";
6
6
 
7
7
  // Sparkline — a trend with no axes, the size of a line of text: beside a
8
8
  // KPI, in a table cell. Its scale is its own low to high (not zero), so
@@ -26,14 +26,20 @@ export function Sparkline({
26
26
  const [boxRef, width] = useWidth();
27
27
  const shown = useReveal(width > 0);
28
28
 
29
- const vals = (Array.isArray(data) ? data : []).map((d) => num(typeof d === "object" && d !== null ? d[value] : d)).filter((v) => v != null);
29
+ // Every entry keeps its place along the width; a missing one is a gap —
30
+ // the line breaks there — never a zero. The scale is the values there are.
31
+ const raw = (Array.isArray(data) ? data : []).map((d) => num(typeof d === "object" && d !== null ? d[value] : d));
32
+ const vals = raw.filter((v) => v != null);
30
33
  const n = vals.length;
31
34
  const lo = n ? Math.min(...vals) : 0;
32
35
  const hi = n ? Math.max(...vals) : 0;
33
36
  const pad = 3;
34
- const px = (i) => pad + (n === 1 ? (width - pad * 2) / 2 : (i * (width - pad * 2)) / (n - 1));
37
+ const px = (i) => pad + (raw.length === 1 ? (width - pad * 2) / 2 : (i * (width - pad * 2)) / (raw.length - 1));
35
38
  const py = (v) => pad + (hi === lo ? (height - pad * 2) / 2 : (1 - (v - lo) / (hi - lo)) * (height - pad * 2));
36
- const line = vals.map((v, i) => `${i ? "L" : "M"}${f(px(i))},${f(py(v))}`).join("");
39
+ const segs = runs(raw.map((v, i) => (v == null ? null : [px(i), py(v)])));
40
+ const path = (seg) => seg.map(([x, y], i) => `${i ? "L" : "M"}${f(x)},${f(y)}`).join("");
41
+ const lastSeg = segs[segs.length - 1];
42
+ const last = lastSeg?.[lastSeg.length - 1];
37
43
  const words = n
38
44
  ? `${label}: from ${format(vals[0])} to ${format(vals[n - 1])}, low ${format(lo)}, high ${format(hi)}.`
39
45
  : `${label}: no data yet.`;
@@ -68,16 +74,28 @@ export function Sparkline({
68
74
  </mask>
69
75
  </defs>
70
76
  <g clipPath={`url(#${uid}-clip)`}>
71
- {area && n > 1 ? (
72
- <path
73
- d={`${line}L${f(px(n - 1))},${height}L${f(px(0))},${height}Z`}
74
- className={tone(t, "fill")}
75
- fillOpacity={0.3}
76
- mask={`url(#${uid}-fade)`}
77
- />
78
- ) : null}
79
- <path d={line} fill="none" className={tone(t, "stroke")} strokeWidth={1.75} strokeLinejoin="round" strokeLinecap="round" />
80
- {dot ? <circle cx={px(n - 1)} cy={py(vals[n - 1])} r={2.5} className={tone(t, "fill")} /> : null}
77
+ {area
78
+ ? segs.map((seg, k) =>
79
+ seg.length > 1 ? (
80
+ <path
81
+ key={k}
82
+ d={`${path(seg)}L${f(seg[seg.length - 1][0])},${height}L${f(seg[0][0])},${height}Z`}
83
+ className={tone(t, "fill")}
84
+ fillOpacity={0.3}
85
+ mask={`url(#${uid}-fade)`}
86
+ />
87
+ ) : null,
88
+ )
89
+ : null}
90
+ {segs.map((seg, k) =>
91
+ seg.length > 1 ? (
92
+ <path key={k} d={path(seg)} fill="none" className={tone(t, "stroke")} strokeWidth={1.75} strokeLinejoin="round" strokeLinecap="round" />
93
+ ) : dot && seg === lastSeg ? null : (
94
+ // A value alone between two gaps: a dot the line's width.
95
+ <circle key={k} cx={seg[0][0]} cy={seg[0][1]} r={1.75} className={tone(t, "fill")} />
96
+ ),
97
+ )}
98
+ {dot ? <circle cx={last[0]} cy={last[1]} r={2.5} className={tone(t, "fill")} /> : null}
81
99
  </g>
82
100
  </>
83
101
  )}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useRef, useState } from "react";
4
- import { Avatar, Button, Collapsible, IconButton, Icon, Menu, Spinner, Textarea, Toggle, Tooltip, cx } from "../../ui";
4
+ import { Avatar, Button, Collapsible, IconButton, Icon, Menu, Spinner, Textarea, Toggle, Tooltip, cx, toLocalDate } from "../../ui";
5
5
  import { Attachment } from "./attachment";
6
6
  import { ChatText } from "./text";
7
7
 
@@ -25,10 +25,10 @@ export function textOf(message) {
25
25
  .join("");
26
26
  }
27
27
 
28
+ /** A Date, an ISO time or a day alone ("2026-09-28": that day's local
29
+ * midnight, never UTC's) → a Date; null when it is none. */
28
30
  export function toDate(value) {
29
- if (value == null || value === "") return null;
30
- const d = value instanceof Date ? value : new Date(value);
31
- return Number.isNaN(d.getTime()) ? null : d;
31
+ return toLocalDate(value);
32
32
  }
33
33
 
34
34
  export function formatTime(date) {
@@ -30,7 +30,7 @@ A comment in `comments`:
30
30
  id: "c-81",
31
31
  author: { id: "u-lena", name: "Lena Fischer", src: "https://…/lena.jpg" }, // or just "Lena Fischer"
32
32
  body: [ /* the doc onPost gave you */ ], // or a plain string
33
- createdAt: "2026-09-23T09:12:00Z", // ISO string, Date or ms
33
+ createdAt: "2026-09-23T09:12:00Z", // ISO string, Date, ms, or a day ("2026-09-23": that day, never UTC's)
34
34
  edited: false, // true adds "(edited)"
35
35
  }
36
36
  ```
@@ -8,7 +8,7 @@
8
8
  // tick (not a timer per comment) keeps every label current. The full date
9
9
  // is always in the title.
10
10
  import { useEffect, useState } from "react";
11
- import { cx } from "../../ui";
11
+ import { cx, toLocalDate } from "../../ui";
12
12
 
13
13
  const listeners = new Set();
14
14
  let timer = null;
@@ -34,11 +34,9 @@ export function useNow() {
34
34
  return now;
35
35
  }
36
36
 
37
- export const toDate = (v) => {
38
- if (v === undefined || v === null || v === "") return null;
39
- const d = v instanceof Date ? v : new Date(v);
40
- return Number.isNaN(d.getTime()) ? null : d;
41
- };
37
+ /** A Date, an ISO time or a day alone ("2026-09-28", a `date` column: that
38
+ * day's local midnight, never UTC's) → a Date; null when it is none. */
39
+ export const toDate = (v) => toLocalDate(v);
42
40
 
43
41
  const fmt = (opts) => new Intl.DateTimeFormat(undefined, opts);
44
42
  const TIME = { hour: "numeric", minute: "2-digit" };
@@ -70,6 +70,10 @@ one can carry its own (report-table's `kind`).
70
70
  - `label` — what the table is called to a screen reader, default `"Table"`.
71
71
  - `noun` — what a row is ("invoice"): used in "1–10 of 60 invoices", the
72
72
  facet counts and the announcements. Default `"row"`.
73
+ - `nounPlural` — the plural, when the regular ending is wrong for it:
74
+ `noun="person" nounPlural="people"`. Default: `noun` with the regular
75
+ English ending — "invoice" → "invoices", "company" → "companies",
76
+ "address" → "addresses", "batch" → "batches", "analysis" → "analyses".
73
77
 
74
78
  Search
75
79
 
@@ -101,7 +101,19 @@ function humanize(key) {
101
101
  return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
102
102
  }
103
103
 
104
- function plural(n, one, many = `${one}s`) {
104
+ /** A noun's plural by the regular English endings: "invoice" → "invoices",
105
+ * "company" → "companies", "address" → "addresses", "batch" → "batches",
106
+ * "analysis" → "analyses". Anything else is the table's `nounPlural`
107
+ * ("person" → "people"). Until 0.5.1 it only added "s": "11 companys". */
108
+ export function pluralOf(noun) {
109
+ const s = String(noun ?? "");
110
+ if (/[^aeiou]y$/.test(s)) return `${s.slice(0, -1)}ies`;
111
+ if (/sis$/.test(s)) return `${s.slice(0, -2)}es`;
112
+ if (/(?:s|x|z|ch|sh)$/.test(s)) return `${s}es`;
113
+ return `${s}s`;
114
+ }
115
+
116
+ function plural(n, one, many = pluralOf(one)) {
105
117
  return `${n.toLocaleString()} ${n === 1 ? one : many}`;
106
118
  }
107
119
 
@@ -186,12 +198,14 @@ export function DataTable({
186
198
  emptyAction,
187
199
  emptyIcon = "inbox",
188
200
  noun = "row",
201
+ nounPlural,
189
202
  mobile = "scroll",
190
203
  cardTitle,
191
204
  variant = "outline",
192
205
  className,
193
206
  }) {
194
207
  const uid = useId().replace(/[^\w-]/g, "");
208
+ const nouns = nounPlural || pluralOf(noun);
195
209
  const all = rows ?? NONE;
196
210
  const cols = useMemo(() => (columns ?? []).map((c) => ({ ...c, key: String(c.key), header: c.header ?? humanize(c.key) })), [columns]);
197
211
  const facetList = useMemo(
@@ -261,8 +275,8 @@ export function DataTable({
261
275
  }, [current, pageCount, loading]); // eslint-disable-line react-hooks/exhaustive-deps
262
276
 
263
277
  useEffect(() => {
264
- if (!loading && narrowed) setAnnounce(total === 0 ? "No matches" : `${plural(total, noun)} match`);
265
- }, [total, narrowed, loading, noun]);
278
+ if (!loading && narrowed) setAnnounce(total === 0 ? "No matches" : `${plural(total, noun, nouns)} match`);
279
+ }, [total, narrowed, loading, noun, nouns]);
266
280
 
267
281
  // ---- changes that move back to page 1 ---------------------------------
268
282
 
@@ -358,7 +372,7 @@ export function DataTable({
358
372
  return;
359
373
  }
360
374
  if (action.keepSelection !== true) setPicked([]);
361
- setAnnounce(`${action.label}: done for ${plural(real.length, noun)}`);
375
+ setAnnounce(`${action.label}: done for ${plural(real.length, noun, nouns)}`);
362
376
  }
363
377
 
364
378
  // ---- drawing -----------------------------------------------------------
@@ -588,7 +602,7 @@ export function DataTable({
588
602
  <Empty
589
603
  icon="search"
590
604
  title="No matches"
591
- body={narrowed ? "Nothing matches the search and filters." : `No ${noun}s on this page.`}
605
+ body={narrowed ? "Nothing matches the search and filters." : `No ${nouns} on this page.`}
592
606
  action={
593
607
  narrowed ? (
594
608
  <Button size="sm" icon="x" onClick={clearFilters}>
@@ -1137,7 +1151,7 @@ export function DataTable({
1137
1151
  {paginate && !body && (total > 0 || loading) ? (
1138
1152
  <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
1139
1153
  <p data-nums className="w-full text-sm text-muted-foreground sm:w-auto" id={`${uid}-range`}>
1140
- {loading ? "Loading…" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun)}`}
1154
+ {loading ? "Loading…" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun, nouns)}`}
1141
1155
  </p>
1142
1156
  <Pagination
1143
1157
  page={pageNow}
@@ -68,8 +68,10 @@ export function FlowEdge({ id, source, target, selected, data, label }) {
68
68
  const g = connectorGeometry(p1, sides.source, p2, sides.target, data?.line ?? "curved");
69
69
  const arrow = data?.arrow ?? "end";
70
70
  const editing = !readOnly && editingEdge === id;
71
- const ink = selected ? "stroke-primary" : "stroke-muted-foreground [.react-flow__edge:hover_&]:stroke-foreground";
72
- const fill = selected ? "fill-primary" : "fill-muted-foreground [.react-flow__edge:hover_&]:fill-foreground";
71
+ // `\_\_` and String.raw: an underscore in [...] is a space to Tailwind
72
+ // (theme.js says why).
73
+ const ink = selected ? "stroke-primary" : String.raw`stroke-muted-foreground [.react-flow\_\_edge:hover_&]:stroke-foreground`;
74
+ const fill = selected ? "fill-primary" : String.raw`fill-muted-foreground [.react-flow\_\_edge:hover_&]:fill-foreground`;
73
75
  return (
74
76
  <>
75
77
  <path d={g.d} fill="none" stroke="transparent" strokeWidth={18} className="react-flow__edge-interaction" />
@@ -10,71 +10,82 @@
10
10
  // one thing that outranks it. Our own nodes and connectors draw with
11
11
  // token classes directly (nodes.jsx, edges.jsx), so the library's node and
12
12
  // edge colours are never seen.
13
+ //
14
+ // React Flow's classes are BEM, `react-flow__node`, and Tailwind reads an
15
+ // underscore inside [...] as a space: written plainly, every rule below
16
+ // compiled to `.react-flow node`, which matches nothing, and none of this
17
+ // theme ever reached the page — the attribution was the library's grey box
18
+ // with its grey text, unreadable on a dark workspace (makersclaw-store
19
+ // BLOCKS.md, mind-maps 0.1.0). `\_` keeps an underscore, and String.raw
20
+ // keeps the backslash in the class name, where the rule Tailwind builds
21
+ // from this file's text expects it. Every React Flow class below is
22
+ // written rf`…`; test/cli.test.mjs refuses a plain one in any block.
23
+ const rf = String.raw;
13
24
  export const THEME = [
14
25
  // the canvas: the page's background with dots in the muted ink
15
26
  "[--xy-background-color:transparent]!",
16
27
  "[--xy-background-pattern-color:var(--color-muted-foreground)]!",
17
- "[&_.react-flow__background]:opacity-35!",
28
+ rf`[&_.react-flow\_\_background]:opacity-35!`,
18
29
  // the marquee, and the box around a multi-selection
19
- "[&_.react-flow__selection]:bg-primary/8!",
20
- "[&_.react-flow__selection]:border!",
21
- "[&_.react-flow__selection]:border-solid!",
22
- "[&_.react-flow__selection]:border-primary/70!",
23
- "[&_.react-flow__selection]:rounded-sm!",
24
- "[&_.react-flow__nodesselection-rect]:bg-transparent!",
25
- "[&_.react-flow__nodesselection-rect]:border!",
26
- "[&_.react-flow__nodesselection-rect]:border-dashed!",
27
- "[&_.react-flow__nodesselection-rect]:border-primary/60!",
28
- "[&_.react-flow__nodesselection-rect]:rounded-sm!",
30
+ rf`[&_.react-flow\_\_selection]:bg-primary/8!`,
31
+ rf`[&_.react-flow\_\_selection]:border!`,
32
+ rf`[&_.react-flow\_\_selection]:border-solid!`,
33
+ rf`[&_.react-flow\_\_selection]:border-primary/70!`,
34
+ rf`[&_.react-flow\_\_selection]:rounded-sm!`,
35
+ rf`[&_.react-flow\_\_nodesselection-rect]:bg-transparent!`,
36
+ rf`[&_.react-flow\_\_nodesselection-rect]:border!`,
37
+ rf`[&_.react-flow\_\_nodesselection-rect]:border-dashed!`,
38
+ rf`[&_.react-flow\_\_nodesselection-rect]:border-primary/60!`,
39
+ rf`[&_.react-flow\_\_nodesselection-rect]:rounded-sm!`,
29
40
  // the line drawn while a connector is being dragged out
30
- "[&_.react-flow__connection-path]:stroke-primary!",
31
- "[&_.react-flow__connection-path]:[stroke-width:2]!",
41
+ rf`[&_.react-flow\_\_connection-path]:stroke-primary!`,
42
+ rf`[&_.react-flow\_\_connection-path]:[stroke-width:2]!`,
32
43
  // cursors that match the tool (flow-canvas.jsx sets data-tool on the root)
33
- "[&_.react-flow__pane.selection]:cursor-default!",
34
- "[&_.react-flow__node.draggable]:cursor-default!",
35
- "[&_.react-flow__node.draggable.dragging]:cursor-grabbing!",
36
- "data-[tool=hand]:[&_.react-flow__pane]:cursor-grab!",
37
- "data-[tool=hand]:[&_.react-flow__pane.dragging]:cursor-grabbing!",
38
- "data-[tool=hand]:[&_.react-flow__node]:cursor-grab!",
39
- "data-[tool=place]:[&_.react-flow__pane]:cursor-crosshair!",
40
- "data-[tool=text]:[&_.react-flow__pane]:cursor-text!",
41
- "data-[tool=connector]:[&_.react-flow__node]:cursor-crosshair!",
44
+ rf`[&_.react-flow\_\_pane.selection]:cursor-default!`,
45
+ rf`[&_.react-flow\_\_node.draggable]:cursor-default!`,
46
+ rf`[&_.react-flow\_\_node.draggable.dragging]:cursor-grabbing!`,
47
+ rf`data-[tool=hand]:[&_.react-flow\_\_pane]:cursor-grab!`,
48
+ rf`data-[tool=hand]:[&_.react-flow\_\_pane.dragging]:cursor-grabbing!`,
49
+ rf`data-[tool=hand]:[&_.react-flow\_\_node]:cursor-grab!`,
50
+ rf`data-[tool=place]:[&_.react-flow\_\_pane]:cursor-crosshair!`,
51
+ rf`data-[tool=text]:[&_.react-flow\_\_pane]:cursor-text!`,
52
+ rf`data-[tool=connector]:[&_.react-flow\_\_node]:cursor-crosshair!`,
42
53
  // keyboard focus on a card: a visible ring (the library turns it off)
43
- "[&_.react-flow__node:focus-visible]:outline-2!",
44
- "[&_.react-flow__node:focus-visible]:outline-offset-4!",
45
- "[&_.react-flow__node:focus-visible]:outline-ring!",
46
- "[&_.react-flow__node:focus-visible]:[outline-style:solid]!",
54
+ rf`[&_.react-flow\_\_node:focus-visible]:outline-2!`,
55
+ rf`[&_.react-flow\_\_node:focus-visible]:outline-offset-4!`,
56
+ rf`[&_.react-flow\_\_node:focus-visible]:outline-ring!`,
57
+ rf`[&_.react-flow\_\_node:focus-visible]:[outline-style:solid]!`,
47
58
  // frames: only their title and edge take the pointer, so a double-click
48
59
  // or a marquee inside one lands on the canvas (nodes.jsx)
49
- "[&_.react-flow__node-frame]:pointer-events-none!",
60
+ rf`[&_.react-flow\_\_node-frame]:pointer-events-none!`,
50
61
  // the resize handles on a selected card
51
- "[&_.react-flow__resize-control.handle]:size-2.5!",
52
- "[&_.react-flow__resize-control.handle]:rounded-xs!",
53
- "[&_.react-flow__resize-control.handle]:border-primary!",
54
- "[&_.react-flow__resize-control.handle]:bg-background!",
55
- "[&_.react-flow__resize-control.handle]:pointer-events-auto!",
56
- "[&_.react-flow__resize-control.line]:border-primary/0!",
57
- "[&_.react-flow__resize-control.line]:pointer-events-auto!",
58
- "[&_.react-flow__resize-control.line.left]:w-2!",
59
- "[&_.react-flow__resize-control.line.right]:w-2!",
60
- "[&_.react-flow__resize-control.line.top]:h-2!",
61
- "[&_.react-flow__resize-control.line.bottom]:h-2!",
62
+ rf`[&_.react-flow\_\_resize-control.handle]:size-2.5!`,
63
+ rf`[&_.react-flow\_\_resize-control.handle]:rounded-xs!`,
64
+ rf`[&_.react-flow\_\_resize-control.handle]:border-primary!`,
65
+ rf`[&_.react-flow\_\_resize-control.handle]:bg-background!`,
66
+ rf`[&_.react-flow\_\_resize-control.handle]:pointer-events-auto!`,
67
+ rf`[&_.react-flow\_\_resize-control.line]:border-primary/0!`,
68
+ rf`[&_.react-flow\_\_resize-control.line]:pointer-events-auto!`,
69
+ rf`[&_.react-flow\_\_resize-control.line.left]:w-2!`,
70
+ rf`[&_.react-flow\_\_resize-control.line.right]:w-2!`,
71
+ rf`[&_.react-flow\_\_resize-control.line.top]:h-2!`,
72
+ rf`[&_.react-flow\_\_resize-control.line.bottom]:h-2!`,
62
73
  // the minimap: a card with the board in muted ink
63
74
  "[--xy-minimap-background-color:var(--color-card)]!",
64
75
  "[--xy-minimap-node-background-color:var(--color-muted-foreground)]!",
65
76
  "[--xy-minimap-mask-background-color:var(--color-muted)]!",
66
- "[&_.react-flow__minimap]:rounded-lg!",
67
- "[&_.react-flow__minimap]:border!",
68
- "[&_.react-flow__minimap]:border-border!",
69
- "[&_.react-flow__minimap]:shadow-md!",
70
- "[&_.react-flow__minimap]:overflow-hidden!",
71
- "[&_.react-flow__minimap-node]:opacity-50!",
72
- "[&_.react-flow__minimap-mask]:opacity-70!",
77
+ rf`[&_.react-flow\_\_minimap]:rounded-lg!`,
78
+ rf`[&_.react-flow\_\_minimap]:border!`,
79
+ rf`[&_.react-flow\_\_minimap]:border-border!`,
80
+ rf`[&_.react-flow\_\_minimap]:shadow-md!`,
81
+ rf`[&_.react-flow\_\_minimap]:overflow-hidden!`,
82
+ rf`[&_.react-flow\_\_minimap-node]:opacity-50!`,
83
+ rf`[&_.react-flow\_\_minimap-mask]:opacity-70!`,
73
84
  // React Flow's credit: kept (they ask for it), in the muted ink
74
- "[&_.react-flow__attribution]:bg-transparent!",
75
- "[&_.react-flow__attribution_a]:text-muted-foreground!",
85
+ rf`[&_.react-flow\_\_attribution]:bg-transparent!`,
86
+ rf`[&_.react-flow\_\_attribution_a]:text-muted-foreground!`,
76
87
  // panels: ours sit in the corners with our own spacing
77
- "[&_.react-flow__panel]:m-3!",
88
+ rf`[&_.react-flow\_\_panel]:m-3!`,
78
89
  ].join(" ");
79
90
 
80
91
  // Colour TOKENS → classes, written out whole so Tailwind generates them. A
@@ -14,6 +14,11 @@ reader, and a quick way to jump to a topic.
14
14
  It is built on `flow-canvas` (which draws it, pans and zooms): this block
15
15
  owns the tree, the layout and the keys.
16
16
 
17
+ A map opens fitted to its view, but never smaller than its words can be
18
+ read: 79%, where its smallest words (14px) draw at the kit's smallest
19
+ text size (11px). A map too big for that — most maps on a phone — opens
20
+ at 79% centred on its centre topic; ⇧1 still fits the whole of it.
21
+
17
22
  ```jsx
18
23
  import { MindMap } from "../components/blocks/mind-map"; // a Makersclaw app ("./blocks/mind-map" from components/)
19
24
  import { MindMap } from "@/components/makersclaw/blocks/mind-map"; // a shadcn project
@@ -64,7 +69,9 @@ string, or nothing (a fresh map). The component runs it on `value`.
64
69
 
65
70
  - `value` — the map, controlled. A new `value` that is not what the map
66
71
  last sent through `onChange` replaces it (loading another record); that
67
- replacement can be undone. Pass back exactly what `onChange` gave you.
72
+ replacement can be undone, and when it is another map (a new centre, or
73
+ other main branches) the view fits it, as it fits a first one. Pass
74
+ back exactly what `onChange` gave you.
68
75
  - `defaultValue` — the starting map, uncontrolled. With neither, a centre
69
76
  topic called "Central topic" and the first-run card.
70
77
  - `onChange(map)` — the whole tree after a change settles (debounced by
@@ -114,7 +121,7 @@ With a topic selected (click one, or press an arrow):
114
121
  | ⌘Z / ⇧⌘Z | undo / redo |
115
122
  | Home | the centre |
116
123
  | Esc | clear the selection |
117
- | ⇧1 | fit the map to the screen |
124
+ | ⇧1 | fit the whole map to the screen, however small |
118
125
  | ? | the shortcuts sheet |
119
126
 
120
127
  While typing a topic: **Enter** finishes it and starts the next one (on
@@ -33,6 +33,7 @@ import {
33
33
  mindMapToText,
34
34
  moveTopic,
35
35
  normalizeMap,
36
+ openingView,
36
37
  outdentTopic,
37
38
  outlineToTopics,
38
39
  parentOf,
@@ -49,14 +50,20 @@ const EXTRA_COLORS = ["warning", "success", "info", "destructive"];
49
50
  const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || "");
50
51
 
51
52
  // Measures words in the map's own font (a canvas's measureText), so a
52
- // topic's box fits its words exactly. Until the page is there (the
53
- // server), a guess. Measured again once mounted (the font of the element
54
- // the map sits in, not the body's) and when web fonts finish loading.
53
+ // topic's box fits its words exactly. Until the map is mounted, a guess —
54
+ // on the server AND in the browser's first render, which must lay the
55
+ // map out exactly as the server did: measured there, the topics landed
56
+ // elsewhere, the canvas's placeholder listed them in another order, and
57
+ // React threw a hydration error on every load of a saved map
58
+ // (makersclaw-store BLOCKS.md, mind-maps 0.1.0). Measured once mounted
59
+ // (the font of the element the map sits in, not the body's) and again
60
+ // when web fonts finish loading.
55
61
  function useMeasure(hostRef) {
56
62
  const [tick, setTick] = useState(0);
57
- const r = useRef({ ctx: null, family: null, cache: new Map() });
63
+ const r = useRef({ ctx: null, family: null, cache: new Map(), mounted: false });
58
64
  useEffect(() => {
59
65
  let live = true;
66
+ r.current.mounted = true;
60
67
  const again = () => {
61
68
  if (!live) return;
62
69
  r.current.cache.clear();
@@ -73,8 +80,8 @@ function useMeasure(hostRef) {
73
80
  }, []);
74
81
  return useCallback(
75
82
  (text, style) => {
76
- if (typeof document === "undefined") return estimateText(text, style);
77
83
  const c = r.current;
84
+ if (typeof document === "undefined" || !c.mounted) return estimateText(text, style);
78
85
  if (!c.ctx) c.ctx = document.createElement("canvas").getContext("2d");
79
86
  if (!c.ctx) return estimateText(text, style);
80
87
  if (!c.family) {
@@ -111,6 +118,15 @@ function useCoarsePointer() {
111
118
  return coarse;
112
119
  }
113
120
 
121
+ /** The same map, as far as the view goes: the same centre topic and the
122
+ * same main branches (in any order). */
123
+ export function sameBranches(a, b) {
124
+ if (!a || !b || a.id !== b.id) return false;
125
+ const ids = new Set((a.children ?? []).map((c) => c.id));
126
+ const next = b.children ?? [];
127
+ return next.length === ids.size && next.every((c) => ids.has(c.id));
128
+ }
129
+
114
130
  async function settle(fn, ...args) {
115
131
  try {
116
132
  const res = await fn(...args);
@@ -217,24 +233,47 @@ export function MindMap({
217
233
  };
218
234
 
219
235
  // Controlled: a `value` that is not our own echo replaces the map (and
220
- // can be undone).
236
+ // can be undone). One that is another map — a record loaded, the
237
+ // agent's first map over the starter — is fitted to the view like a
238
+ // first one: until 0.5.0 it stayed at the old map's zoom and place, and
239
+ // mind-maps remounted the block to get a fit. The same map back from a
240
+ // save (its branches unchanged) keeps the view where it is.
221
241
  const lastProp = useRef(value);
242
+ const [refit, setRefit] = useState(0);
222
243
  useEffect(() => {
223
244
  if (value === undefined || value === lastProp.current) return;
224
245
  lastProp.current = value;
225
246
  if (value === lastEmitted.current) return;
226
247
  const next = normalizeMap(value);
227
- setHist({ past: [...histRef.current.past, treeRef.current].slice(-HISTORY), future: [] });
248
+ const before = treeRef.current;
249
+ setHist({ past: [...histRef.current.past, before].slice(-HISTORY), future: [] });
228
250
  setTree(next);
229
251
  if (selectedRef.current && !findTopic(next, selectedRef.current)) select(null);
230
252
  if (editingRef.current) setEditing(null);
253
+ if (!sameBranches(before, next)) setRefit((n) => n + 1);
231
254
  }, [value, select]);
232
255
 
233
256
  // ── what the canvas draws ────────────────────────────────────────────
234
257
  const board = useMemo(() => mindMapToBoard(tree, { measure, colors }), [tree, measure, colors]);
235
258
  const sizes = useMemo(() => new Map(board.nodes.map((n) => [n.id, n])), [board]);
259
+ const boardRef = useRef(board);
260
+ boardRef.current = board;
261
+
262
+ // Open a map fitted to the view, but never smaller than its words can be
263
+ // read: a map too big for that opens at READABLE_ZOOM on its centre
264
+ // topic (openingView). ⇧1 and "Fit to screen" still fit the whole map.
265
+ const fitOpen = useCallback(
266
+ (opts) => {
267
+ const api = canvas.current;
268
+ if (!api) return;
269
+ const pane = boxRef.current?.querySelector(".react-flow") ?? boxRef.current;
270
+ api.fitView({ ...opts, ...openingView(boardRef.current.nodes, { width: pane?.clientWidth, height: pane?.clientHeight, rootId: treeRef.current.id }) });
271
+ },
272
+ [canvas],
273
+ );
236
274
 
237
- // Fit the map in view once its words are measured. The canvas fits only
275
+ // Fit the map in view once its words are measured, and again when a
276
+ // `value` puts another map in its place (above). The canvas fits only
238
277
  // on its own first mount, before the topics are sized in the map's font
239
278
  // (and again when web fonts land), so without this the map opened off to
240
279
  // the top-left of an empty-looking canvas (2026-09-24).
@@ -243,10 +282,10 @@ export function MindMap({
243
282
  // `value` in an effect of its own, and fits against what it holds.
244
283
  let raf = 0;
245
284
  const t = setTimeout(() => {
246
- raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => canvas.current?.fitView({ duration: 0 }))));
285
+ raf = requestAnimationFrame(() => (raf = requestAnimationFrame(() => fitOpen({ duration: 0 }))));
247
286
  }, 300);
248
287
  return () => (clearTimeout(t), cancelAnimationFrame(raf));
249
- }, [measure]);
288
+ }, [measure, refit, fitOpen]);
250
289
 
251
290
  // The canvas pans with a plain drag (its hand tool): a mind map has
252
291
  // nothing to lasso.
@@ -447,7 +486,7 @@ export function MindMap({
447
486
  finishEdit();
448
487
  commit(normalizeMap(map));
449
488
  select(null);
450
- setTimeout(() => canvas.current?.fitView(), 80);
489
+ setTimeout(() => fitOpen(), 80);
451
490
  };
452
491
 
453
492
  // ── announcements ────────────────────────────────────────────────────
@@ -901,7 +940,7 @@ export function MindMap({
901
940
  emptyState={false}
902
941
  templates={[]}
903
942
  describeNode={describeTopic}
904
- className="[&_.react-flow__node]:transition-transform [&_.react-flow__node]:duration-200 [&_.react-flow__node]:ease-out motion-reduce:[&_.react-flow__node]:transition-none"
943
+ className={String.raw`[&_.react-flow\_\_node]:transition-transform [&_.react-flow\_\_node]:duration-200 [&_.react-flow\_\_node]:ease-out motion-reduce:[&_.react-flow\_\_node]:transition-none`}
905
944
  />
906
945
  </MindContext.Provider>
907
946
  </div>
@@ -377,6 +377,39 @@ export const LEVELS = [
377
377
  { font: 14, weight: 400, line: 20, padX: 6, padY: 5, min: 44, max: 240, gap: 28 },
378
378
  ];
379
379
  export const levelOf = (depth) => LEVELS[Math.min(depth, LEVELS.length - 1)];
380
+
381
+ /** The smallest zoom a map opens at: its smallest words (14px, a main
382
+ * branch's and a subtopic's) drawn no smaller than the kit's smallest
383
+ * text, text-2xs (11px). 11 / 14 ≈ 0.79. */
384
+ export const READABLE_ZOOM = 11 / Math.min(...LEVELS.map((l) => l.font));
385
+
386
+ /**
387
+ * How a map opens in a view `width` × `height` px, as options for the
388
+ * canvas's fitView: the whole map when it fits at READABLE_ZOOM or more
389
+ * (→ {}), else READABLE_ZOOM centred on the centre topic `rootId`. Until
390
+ * 0.5.1 a big map opened fitted whole whatever the zoom: 31% on a phone,
391
+ * its words unreadable. `nodes` are the board's ({ position, width,
392
+ * height }); `padding` is the fit's share of the view (the canvas's 0.2),
393
+ * split as React Flow splits it.
394
+ */
395
+ export function openingView(nodes, { width, height, rootId, padding = 0.2 } = {}) {
396
+ if (!(width > 0 && height > 0) || !nodes?.length || rootId == null) return {};
397
+ let x1 = Infinity;
398
+ let y1 = Infinity;
399
+ let x2 = -Infinity;
400
+ let y2 = -Infinity;
401
+ for (const n of nodes) {
402
+ const x = n.position?.x ?? 0;
403
+ const y = n.position?.y ?? 0;
404
+ x1 = Math.min(x1, x);
405
+ y1 = Math.min(y1, y);
406
+ x2 = Math.max(x2, x + (n.width ?? 0));
407
+ y2 = Math.max(y2, y + (n.height ?? 0));
408
+ }
409
+ const room = (v) => v - 2 * Math.floor((v - v / (1 + padding)) / 2);
410
+ const fit = Math.min(room(width) / Math.max(1, x2 - x1), room(height) / Math.max(1, y2 - y1));
411
+ return fit >= READABLE_ZOOM ? {} : { nodes: [{ id: rootId }], minZoom: READABLE_ZOOM, maxZoom: READABLE_ZOOM };
412
+ }
380
413
  const SIBLING_GAP = [0, 20, 8];
381
414
  const siblingGap = (depth) => SIBLING_GAP[Math.min(depth, SIBLING_GAP.length - 1)];
382
415
  /** Room left under a subtopic for its coloured line. */
@@ -485,7 +485,7 @@ export function OrgChart({
485
485
  emptyState={false}
486
486
  templates={[]}
487
487
  describeNode={describePerson}
488
- className="[&_.react-flow__node]:transition-transform [&_.react-flow__node]:duration-200 [&_.react-flow__node]:ease-out motion-reduce:[&_.react-flow__node]:transition-none"
488
+ className={String.raw`[&_.react-flow\_\_node]:transition-transform [&_.react-flow\_\_node]:duration-200 [&_.react-flow\_\_node]:ease-out motion-reduce:[&_.react-flow\_\_node]:transition-none`}
489
489
  />
490
490
  </OrgContext.Provider>
491
491
 
@@ -5,7 +5,7 @@
5
5
  // viewer, a sheet on a narrow one (pdf-viewer.jsx decides). A click on an
6
6
  // entry takes the pages to it; its note is edited in place.
7
7
  import { useEffect, useRef, useState } from "react";
8
- import { Button, Icon, Menu, Textarea, cx } from "../../ui";
8
+ import { Button, Icon, Menu, Textarea, cx, toLocalDate } from "../../ui";
9
9
  import { sortHighlights } from "./helpers";
10
10
 
11
11
  /** The colours a highlight can take, as token names (never a colour). */
@@ -188,8 +188,9 @@ function NoteItem({ h, active, editing, readOnly, onJump, onEdit, onSaveNote, on
188
188
  );
189
189
  }
190
190
 
191
+ // A day alone ("2026-09-28") is that day in the reader's zone, not UTC's.
191
192
  function when(v) {
192
- const d = new Date(v);
193
- if (Number.isNaN(d.getTime())) return String(v);
193
+ const d = toLocalDate(v);
194
+ if (!d) return String(v);
194
195
  return new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short" }).format(d);
195
196
  }
@@ -31,7 +31,7 @@ A note in `notes`:
31
31
  id: "n-204",
32
32
  author: { id: "u-lena", name: "Lena Fischer", src: "https://…/lena.jpg" },
33
33
  body: [ /* the doc onAdd gave you (BlockNote JSON) */ ], // or a plain string
34
- createdAt: "2026-09-23T09:12:00Z", // ISO string, Date or ms
34
+ createdAt: "2026-09-23T09:12:00Z", // ISO string, Date, ms, or a day ("2026-09-23": that day, never UTC's)
35
35
  updatedAt: null, // set → "(edited)"
36
36
  }
37
37
  ```