makersclaw 0.3.2 → 0.5.1

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 (121) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/r/annotate.json +3 -3
  4. package/r/approval-request.json +4 -4
  5. package/r/audio-player.json +2 -2
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +2 -2
  8. package/r/calendar-view.json +9 -9
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +7 -7
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +5 -5
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +17 -11
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +4 -4
  18. package/r/diagram.json +2 -2
  19. package/r/doc-editor.json +2 -2
  20. package/r/doc-page.json +3 -3
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +2 -2
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +2 -2
  26. package/r/gantt.json +3 -3
  27. package/r/job-panel.json +8 -5
  28. package/r/json-editor.json +3 -3
  29. package/r/map.json +2 -2
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +6 -6
  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 +2 -2
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +3 -3
  39. package/r/product-detail.json +2 -2
  40. package/r/product-grid.json +3 -3
  41. package/r/record-form.json +2 -2
  42. package/r/record-notes.json +6 -6
  43. package/r/registry.json +131 -123
  44. package/r/search-dialog.json +2 -2
  45. package/r/settings-list.json +2 -2
  46. package/r/setup-checklist.json +2 -2
  47. package/r/share-dialog.json +2 -2
  48. package/r/sidebar.json +4 -4
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +3 -3
  53. package/r/task-list.json +3 -3
  54. package/r/task-model.json +2 -2
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +2 -2
  57. package/r/whiteboard.json +3 -3
  58. package/registry/blocks/approval-request/README.md +2 -1
  59. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  60. package/registry/blocks/calendar-view/README.md +8 -0
  61. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  62. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  63. package/registry/blocks/calendar-view/demo.jsx +2 -0
  64. package/registry/blocks/calendar-view/month.jsx +6 -5
  65. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  66. package/registry/blocks/calendar-view/tray.jsx +5 -4
  67. package/registry/blocks/chart/README.md +9 -4
  68. package/registry/blocks/chart/cartesian.jsx +31 -24
  69. package/registry/blocks/chart/core.jsx +21 -0
  70. package/registry/blocks/chart/pie.jsx +15 -10
  71. package/registry/blocks/chart/sparkline.jsx +32 -14
  72. package/registry/blocks/chat/message.jsx +4 -4
  73. package/registry/blocks/comment-box/README.md +1 -1
  74. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  75. package/registry/blocks/data-table/README.md +4 -0
  76. package/registry/blocks/data-table/data-table.jsx +20 -6
  77. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  78. package/registry/blocks/flow-canvas/theme.js +59 -48
  79. package/registry/blocks/job-panel/README.md +40 -3
  80. package/registry/blocks/job-panel/block.json +2 -2
  81. package/registry/blocks/job-panel/demo.jsx +75 -6
  82. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  83. package/registry/blocks/mind-map/README.md +9 -2
  84. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  85. package/registry/blocks/mind-map/tree.js +33 -0
  86. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  87. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  88. package/registry/blocks/record-notes/README.md +1 -1
  89. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  90. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  91. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  92. package/registry/blocks/signature-pad/signature.js +8 -1
  93. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  94. package/src/shadcn.mjs +1 -1
  95. package/templates/next/files/README.md +410 -86
  96. package/templates/next/files/app/auto-refresh.jsx +42 -15
  97. package/templates/next/files/app/claw-alive/route.js +17 -0
  98. package/templates/next/files/app/error-relay.jsx +9 -4
  99. package/templates/next/files/app/escape-relay.jsx +27 -0
  100. package/templates/next/files/app/frame.js +409 -0
  101. package/templates/next/files/app/layout.jsx +18 -3
  102. package/templates/next/files/check/run.mjs +51 -18
  103. package/templates/next/files/components/parts/fixture.jsx +29 -4
  104. package/templates/next/files/components/parts/index.js +2 -1
  105. package/templates/next/files/components/parts/live-work.jsx +235 -0
  106. package/templates/next/files/components/ui/button.jsx +6 -1
  107. package/templates/next/files/components/ui/calendar.jsx +24 -0
  108. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  109. package/templates/next/files/components/ui/field.jsx +12 -3
  110. package/templates/next/files/components/ui/icon.jsx +37 -2
  111. package/templates/next/files/components/ui/index.js +1 -0
  112. package/templates/next/files/components/ui/sheet.jsx +38 -2
  113. package/templates/next/files/components/ui/toggle.jsx +20 -2
  114. package/templates/next/files/lib/actions.js +63 -5
  115. package/templates/next/files/lib/claw.js +127 -28
  116. package/templates/next/files/lib/data.js +4 -1
  117. package/templates/next/files/lib/db.js +18 -1
  118. package/templates/next/files/lib/hooks.js +493 -37
  119. package/templates/next/files/lib/platform.js +113 -15
  120. package/templates/next/files/surfaces/README.md +3 -0
  121. package/templates/next/template.json +19 -8
@@ -8,7 +8,8 @@ import { Button, Card, Empty, Field, Icon, Skeleton, Textarea, cx } from "../../
8
8
  // keeps the four rules a hand-drawn panel breaks: it is not there when
9
9
  // nothing runs, it shows each step's label (the founder's phrase, never
10
10
  // the tool's name), the agent's text is its account and not the
11
- // deliverable (the rows are), and Stop / Close are always one press away.
11
+ // deliverable (the rows are), and Stop / Close are always one press away
12
+ // (Answer in Stop's place while the agent waits on the founder).
12
13
  //
13
14
  // const job = useJob();
14
15
  // <Button onClick={() => job.start("Draft a launch plan for Rowhouse")}>Generate a launch plan</Button>
@@ -24,10 +25,21 @@ import { Button, Card, Empty, Field, Icon, Skeleton, Textarea, cx } from "../../
24
25
  // options={<Field …><Select …/></Field>}
25
26
  // onGenerate={(prompt) => job.start(`Make a product photo: ${prompt}. Size ${size}.`)}
26
27
  // results={photos.map((p) => <PhotoCard key={p.id} photo={p} />)} />
28
+ //
29
+ // Since 0.4.0 a job is a request, and its conversation lives in the
30
+ // desk's agent column: the agent's questions and the founder's yes are
31
+ // there, not here. So the panel reads two more states off the job, when
32
+ // the job has them: needsYou (the agent waits on the founder — the
33
+ // heading says so and Answer, in Stop's place, opens the question in the
34
+ // column) and queued (one job per list runs at a time — the heading names
35
+ // the job it waits behind). A 0.3.x job object has neither and draws
36
+ // exactly as it always did.
27
37
 
28
38
  function StepMark({ status }) {
29
39
  if (status === "done") return <Icon name="check" size={14} className="shrink-0 text-success" />;
30
40
  if (status === "failed") return <Icon name="warning" size={14} className="shrink-0 text-destructive" />;
41
+ if (status === "needs") return <Icon name="circle-help" size={14} className="shrink-0 text-warning" />;
42
+ if (status === "queued") return <Icon name="clock" size={14} className="shrink-0 text-muted-foreground" />;
31
43
  return <span aria-hidden="true" className="mx-[3px] size-2 shrink-0 animate-pulse rounded-full bg-primary" />;
32
44
  }
33
45
 
@@ -36,11 +48,17 @@ export function JobPanel({ variant = "panel", ...props }) {
36
48
  return <Panel {...props} />;
37
49
  }
38
50
 
51
+ // Waiting on the founder or on another job is still a live job: not over,
52
+ // only paused. useJob counts both as running; an object of its own shape
53
+ // that sets only the flag gets the same panel.
54
+ const isLive = (job) => Boolean(job?.running || job?.needsYou || job?.queued);
55
+
39
56
  function Panel({
40
57
  job,
41
58
  title = "Working on it",
42
59
  doneTitle = "Done",
43
60
  failedTitle = "It stopped",
61
+ needsTitle = "Needs your answer",
44
62
  onRetry,
45
63
  autoClose = 6000,
46
64
  maxSteps = 6,
@@ -48,7 +66,9 @@ function Panel({
48
66
  className,
49
67
  }) {
50
68
  const textRef = useRef(null);
51
- const running = Boolean(job?.running);
69
+ const needsYou = Boolean(job?.needsYou);
70
+ const queued = !needsYou && Boolean(job?.queued);
71
+ const running = isLive(job);
52
72
  const failed = !running && Boolean(job?.error);
53
73
  const done = !running && !failed && Boolean(job?.finishedAt);
54
74
 
@@ -67,23 +87,43 @@ function Panel({
67
87
 
68
88
  if (!job || !(running || failed || done)) return null;
69
89
 
70
- const steps = job.steps ?? [];
90
+ // What the job waits on, if anything: its mark replaces the pulse.
91
+ const waiting = needsYou ? "needs" : queued ? "queued" : null;
92
+ const heading = needsYou
93
+ ? needsTitle
94
+ : queued
95
+ ? `Queued · after ${job.queuedAfter?.after_title ?? "the job before it"}`
96
+ : running
97
+ ? title
98
+ : failed
99
+ ? failedTitle
100
+ : doneTitle;
101
+ // A queued request's one step is its line, and its line is this
102
+ // heading again: say it once.
103
+ const steps = (job.steps ?? []).filter((step) => !(queued && step.label === heading));
71
104
  const hidden = Math.max(0, steps.length - maxSteps);
72
105
  const visible = steps.slice(hidden);
73
- const heading = running ? title : failed ? failedTitle : doneTitle;
106
+ // The question is in the desk's agent column; open() takes the founder
107
+ // there. Without it there is nowhere to point, and Stop stays.
108
+ const answer = needsYou && typeof job.open === "function";
74
109
 
75
110
  return (
76
111
  <section
77
112
  aria-label={heading}
78
- className={cx("rounded-lg border bg-card p-4 text-card-foreground", failed ? "border-destructive/50" : "border-border", className)}
113
+ className={cx(
114
+ "rounded-lg border bg-card p-4 text-card-foreground",
115
+ failed ? "border-destructive/50" : needsYou ? "border-warning/50" : "border-border",
116
+ className,
117
+ )}
79
118
  >
80
119
  <div className="flex items-start justify-between gap-3">
81
120
  <div className="min-w-0">
82
121
  <p aria-live="polite" className="flex items-center gap-2 text-sm font-semibold">
83
- {running ? <StepMark status="running" /> : <StepMark status={failed ? "failed" : "done"} />}
122
+ <StepMark status={waiting ?? (running ? "running" : failed ? "failed" : "done")} />
84
123
  <span className="min-w-0">{heading}</span>
85
124
  </p>
86
125
  {failed ? <p className="mt-1 text-sm text-muted-foreground">{job.error}</p> : null}
126
+ {needsYou ? <p className="mt-1 text-sm text-muted-foreground">The question is in the agent column.</p> : null}
87
127
  </div>
88
128
  <div className="flex shrink-0 gap-2">
89
129
  {failed && onRetry ? (
@@ -91,7 +131,11 @@ function Panel({
91
131
  Try again
92
132
  </Button>
93
133
  ) : null}
94
- {running ? (
134
+ {answer ? (
135
+ <Button size="sm" variant="secondary" onClick={() => job.open()}>
136
+ Answer
137
+ </Button>
138
+ ) : running ? (
95
139
  <Button size="sm" variant="quiet" onClick={() => job.cancel?.()}>
96
140
  Stop
97
141
  </Button>
@@ -107,12 +151,14 @@ function Panel({
107
151
  {hidden > 0 ? <li className="text-xs text-muted-foreground">{hidden} earlier {hidden === 1 ? "step" : "steps"}</li> : null}
108
152
  {visible.map((step, i) => (
109
153
  <li key={`${hidden + i}-${step.name}`} className="flex items-center gap-2">
110
- <StepMark status={step.status} />
154
+ <StepMark status={waiting && step.status === "running" ? waiting : step.status} />
111
155
  <span className={step.status === "running" ? "text-foreground" : "text-muted-foreground"}>{step.label ?? "Working"}</span>
112
156
  </li>
113
157
  ))}
114
158
  </ol>
115
159
  ) : null}
160
+ {/* The agent's account, on the 0.3.x path. A request's text is empty
161
+ (its account is in the column), so the area is not drawn. */}
116
162
  {showText && job.text ? (
117
163
  <div
118
164
  ref={textRef}
@@ -159,6 +205,7 @@ function Generator({
159
205
  title = "Working on it",
160
206
  doneTitle = "Done",
161
207
  failedTitle = "It stopped",
208
+ needsTitle = "Needs your answer",
162
209
  onRetry,
163
210
  autoClose = 6000,
164
211
  maxSteps = 6,
@@ -175,7 +222,7 @@ function Generator({
175
222
  if (controlled === undefined) setOwn(v);
176
223
  onPromptChange?.(v);
177
224
  };
178
- const running = Boolean(job?.running);
225
+ const running = isLive(job);
179
226
  const canGenerate = !disabled && !running && !starting && (canGenerateEmpty || value.trim().length > 0);
180
227
 
181
228
  async function generate(text = value) {
@@ -192,8 +239,9 @@ function Generator({
192
239
  setStarting(false);
193
240
  if (r && r.ok === false && !r.aborted) setError(r.error ?? "It did not start");
194
241
  }
195
- // useJob's start resolves when the job ends; a failure it reports is
196
- // already the panel's error, so the field says only what the panel does not.
242
+ // useJob's start resolves when the ask is taken (on a 0.3.x claw, when
243
+ // the job ends); a failure it reports is already the panel's error, so
244
+ // the field says only what the panel does not.
197
245
  const fieldError = error && !job?.error ? error : null;
198
246
 
199
247
  const cols = { 1: "grid-cols-1", 2: "grid-cols-1 sm:grid-cols-2", 3: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", 4: "grid-cols-2 lg:grid-cols-4" };
@@ -236,6 +284,7 @@ function Generator({
236
284
  title={title}
237
285
  doneTitle={doneTitle}
238
286
  failedTitle={failedTitle}
287
+ needsTitle={needsTitle}
239
288
  onRetry={onRetry ?? (lastPrompt.current || canGenerateEmpty ? () => generate(lastPrompt.current) : undefined)}
240
289
  autoClose={autoClose}
241
290
  maxSteps={maxSteps}
@@ -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
  ```
@@ -14,7 +14,7 @@
14
14
  // browser: the server draws the notes as one list, and the day headings
15
15
  // appear once mounted — never a mismatch between the two renders.
16
16
  import { useEffect, useRef, useState } from "react";
17
- import { Avatar, Badge, Card, Confirm, Menu, cx } from "../../ui";
17
+ import { Avatar, Badge, Card, Confirm, Menu, cx, toLocalDate } from "../../ui";
18
18
  import { CommentBody, CommentComposer, CommentTime } from "../comment-box";
19
19
  import { docToText } from "../doc-editor";
20
20
 
@@ -22,10 +22,9 @@ const nameOf = (author) => (typeof author === "string" ? author : author?.name)
22
22
  const srcOf = (author) => (typeof author === "object" && author ? author.src : undefined);
23
23
  const idOf = (author) => (typeof author === "object" && author ? author.id : undefined);
24
24
 
25
- const toMs = (v) => {
26
- if (v === undefined || v === null || v === "") return NaN;
27
- return (v instanceof Date ? v : new Date(v)).getTime();
28
- };
25
+ // A note dated with a day alone ("2026-09-28", a `date` column) files
26
+ // under that day wherever the reader is: local midnight, never UTC's.
27
+ const toMs = (v) => toLocalDate(v)?.getTime() ?? NaN;
29
28
 
30
29
  function useMounted() {
31
30
  const [on, setOn] = useState(false);
@@ -69,9 +69,9 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
69
69
  );
70
70
  } else if (panel.loading) {
71
71
  body = (
72
- <ul aria-busy="true" aria-label={`Loading ${panel.title ?? "list"}`} className="grid">
72
+ <ul aria-busy="true" aria-label={`Loading ${panel.title ?? "list"}`} className="grid grid-cols-1">
73
73
  {[0, 1, 2, 3, 4].map((i) => (
74
- <li key={i} className="grid gap-2 border-b border-border px-4 py-3">
74
+ <li key={i} className="grid grid-cols-1 gap-2 border-b border-border px-4 py-3">
75
75
  <Skeleton className="w-1/2" />
76
76
  <Skeleton className="w-3/4" />
77
77
  <Skeleton className="w-full" />
@@ -90,7 +90,7 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
90
90
  );
91
91
  } else {
92
92
  body = (
93
- <ul aria-label={panel.title ?? "List"} className="grid">
93
+ <ul aria-label={panel.title ?? "List"} className="grid grid-cols-1">
94
94
  {shown.map((it, i) => (
95
95
  <li key={it.key ?? it.href ?? `${it.title}-${i}`} className="border-b border-border">
96
96
  <Item
@@ -120,7 +120,7 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
120
120
  return (
121
121
  <div className="flex min-h-0 flex-1 flex-col">
122
122
  {panel.title || panel.actions || searchOn ? (
123
- <div className="grid shrink-0 gap-3 border-b border-border p-3">
123
+ <div className="grid grid-cols-1 shrink-0 gap-3 border-b border-border p-3">
124
124
  {panel.title || panel.actions ? (
125
125
  <div className="flex min-h-9 items-center gap-2">
126
126
  {panel.title ? <h2 className="min-w-0 flex-1 truncate text-base font-semibold">{panel.title}</h2> : null}
@@ -354,7 +354,7 @@ export function Sidebar({
354
354
  <Icon name={item.icon} className="shrink-0" />
355
355
  ) : null}
356
356
  {item.description ? (
357
- <span className="grid min-w-0 flex-1 text-left leading-tight">
357
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
358
358
  <span className="truncate">{item.label}</span>
359
359
  <span className="truncate text-xs text-muted-foreground">{item.description}</span>
360
360
  </span>
@@ -420,7 +420,7 @@ export function Sidebar({
420
420
  opts.sheet ? "[&>summary]:px-4" : "[&>summary]:min-h-9 [&>summary]:px-3 [&>summary]:py-1",
421
421
  )}
422
422
  >
423
- <ul className="ml-5 grid gap-0.5 border-l border-border pl-2">
423
+ <ul className="ml-5 grid grid-cols-1 gap-0.5 border-l border-border pl-2">
424
424
  {item.items.map((child) => (
425
425
  <li key={keyOf(child)}>{leaf(child, opts)}</li>
426
426
  ))}
@@ -469,7 +469,7 @@ export function Sidebar({
469
469
  function replaced(item, { sheet, where }) {
470
470
  const headingId = `${uid}-${where}-drill`;
471
471
  return (
472
- <div className="grid gap-0.5">
472
+ <div className="grid grid-cols-1 gap-0.5">
473
473
  <Button id={`${uid}-${where}-back`} variant="nav" size={sheet ? "md" : "sm"} onClick={() => closeDrill(where)}>
474
474
  <Icon name="arrow-left" className="shrink-0" />
475
475
  <span className="min-w-0 flex-1 truncate text-left">Back</span>
@@ -477,7 +477,7 @@ export function Sidebar({
477
477
  <h2 id={headingId} className="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">
478
478
  {item.label}
479
479
  </h2>
480
- <ul aria-labelledby={headingId} className="grid gap-0.5">
480
+ <ul aria-labelledby={headingId} className="grid grid-cols-1 gap-0.5">
481
481
  {item.items.map((child) => (
482
482
  <li key={keyOf(child)}>{leaf(child, { sheet })}</li>
483
483
  ))}
@@ -513,7 +513,7 @@ export function Sidebar({
513
513
  // children stand in for the list.
514
514
  const drilledHere = drillItem && !icons && (drillMode === "replace" || (sheet && drillMode === "panel"));
515
515
  return (
516
- <nav aria-label={label} className={cx("grid gap-3", icons ? "justify-items-center py-2" : "px-2 py-2")}>
516
+ <nav aria-label={label} className={cx("grid grid-cols-1 gap-3", icons ? "justify-items-center py-2" : "px-2 py-2")}>
517
517
  {primaryAction && !drilledHere ? primary({ icons, sheet }) : null}
518
518
  {drilledHere
519
519
  ? replaced(drillItem, { sheet, where })
@@ -524,7 +524,7 @@ export function Sidebar({
524
524
  <ul
525
525
  aria-labelledby={group.label && !icons && !group.collapsible ? headingId : undefined}
526
526
  aria-label={group.label && !icons && group.collapsible ? group.label : undefined}
527
- className={cx("grid gap-0.5", icons && "justify-items-center")}
527
+ className={cx("grid grid-cols-1 gap-0.5", icons && "justify-items-center")}
528
528
  >
529
529
  {group.items.map((item) => (
530
530
  <li key={keyOf(item)}>{icons ? iconItem(item) : wideItem(item, { sheet, where })}</li>
@@ -546,7 +546,7 @@ export function Sidebar({
546
546
  );
547
547
  }
548
548
  return (
549
- <div key={group.key ?? group.label ?? gi} className="grid gap-0.5">
549
+ <div key={group.key ?? group.label ?? gi} className="grid grid-cols-1 gap-0.5">
550
550
  {group.label && !icons ? (
551
551
  <h2 id={headingId} className="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">
552
552
  {group.label}
@@ -565,7 +565,7 @@ export function Sidebar({
565
565
 
566
566
  function brand({ icons, side = "bottom" }) {
567
567
  const words = (
568
- <span className="grid min-w-0 flex-1 text-left leading-tight">
568
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
569
569
  <span className="truncate text-sm font-semibold">{title}</span>
570
570
  {subtitle ? <span className="truncate text-xs font-normal text-muted-foreground">{subtitle}</span> : null}
571
571
  </span>
@@ -667,7 +667,7 @@ export function Sidebar({
667
667
  if (!user) return null;
668
668
  const face = <Avatar name={user.name} src={user.src} size="sm" />;
669
669
  const words = (
670
- <span className="grid min-w-0 flex-1 text-left leading-tight">
670
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
671
671
  <span className="truncate text-sm font-medium">{user.name}</span>
672
672
  {user.email ? <span className="truncate text-xs font-normal text-muted-foreground">{user.email}</span> : null}
673
673
  </span>
@@ -711,7 +711,7 @@ export function Sidebar({
711
711
  ) : null;
712
712
  if (!user && !plus && !footSwitch) return null;
713
713
  return (
714
- <div className="grid gap-1">
714
+ <div className="grid grid-cols-1 gap-1">
715
715
  {footSwitch ? brand({ icons, side: "top" }) : null}
716
716
  {user || plus ? (
717
717
  <div className={cx("flex items-center gap-1", icons && "flex-col")}>
@@ -745,7 +745,7 @@ export function Sidebar({
745
745
  <IconButton icon="x" label={`Close ${drillItem.label}`} size="sm" onClick={() => closeDrill("r")} />
746
746
  </div>
747
747
  <ScrollArea label={drillItem.label} className="min-h-0 flex-1" fade>
748
- <ul aria-labelledby={headingId} className="grid gap-0.5 p-2">
748
+ <ul aria-labelledby={headingId} className="grid grid-cols-1 gap-0.5 p-2">
749
749
  {drillItem.items.map((child, i) => (
750
750
  <li key={keyOf(child)}>{leaf(child, { sheet: false, id: i === 0 ? `${uid}-drill-first` : undefined })}</li>
751
751
  ))}
@@ -813,7 +813,7 @@ export function Sidebar({
813
813
  >
814
814
  {ctx.mobileOpen ? (
815
815
  sheetPanel && panel ? (
816
- <div className="-mx-2 -my-2 grid gap-2">
816
+ <div className="-mx-2 -my-2 grid grid-cols-1 gap-2">
817
817
  <div className="px-2 pt-2">
818
818
  <Button variant="nav" size="md" onClick={() => setSheetPanel(false)}>
819
819
  <Icon name="arrow-left" className="shrink-0" />
@@ -823,7 +823,7 @@ export function Sidebar({
823
823
  {panelIsNode ? panel : <PanelBody panel={panel} sheet onChoose={() => ctx.setMobileOpen(false)} />}
824
824
  </div>
825
825
  ) : (
826
- <div className="-mx-2 -my-2 grid gap-2">
826
+ <div className="-mx-2 -my-2 grid grid-cols-1 gap-2">
827
827
  {headerActions ? <div className="flex flex-wrap items-center gap-1 px-2">{headerActions}</div> : null}
828
828
  {searchOpts ? <div className="px-2">{searchRow({ icons: false, sheet: true })}</div> : null}
829
829
  {switcher?.length ? (
@@ -60,9 +60,16 @@ export function signatureRecord({ png, name, mode, signedAt }) {
60
60
  return { png, name: String(name ?? "").trim(), mode, signedAt: signedAt ?? new Date().toISOString() };
61
61
  }
62
62
 
63
+ // A day alone ("2026-09-28", from a `date` column) is that day's local
64
+ // midnight: new Date() would read it as UTC's, the day before west of
65
+ // Greenwich. Written out here, not the kit's toLocalDate: these helpers
66
+ // import nothing, so they run anywhere.
67
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
68
+
63
69
  /** "Sep 23, 2026, 2:04 PM" in the reader's own locale. */
64
70
  export function formatSignedAt(iso, locale) {
65
- const d = new Date(iso);
71
+ const day = typeof iso === "string" ? DAY.exec(iso) : null;
72
+ const d = day ? new Date(+day[1], +day[2] - 1, +day[3]) : new Date(iso);
66
73
  if (Number.isNaN(d.getTime())) return "";
67
74
  return d.toLocaleString(locale, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
68
75
  }
@@ -5,7 +5,7 @@
5
5
  // type's `editor` name. A custom type (the `types` prop) may bring its own
6
6
  // `render(value, column, ctx)` and `Editor` component.
7
7
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
8
- import { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, usePopoverPosition } from "../../ui";
8
+ import { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, todayString, usePopoverPosition } from "../../ui";
9
9
  import { optionOf, optionValue } from "./types";
10
10
 
11
11
  const asList = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
@@ -303,7 +303,7 @@ function DateEditor({ value, column, type, ctx, initial, today, onCommit, onCanc
303
303
  <button
304
304
  type="button"
305
305
  className="rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
306
- onClick={() => onCommit(today ?? new Date().toISOString().slice(0, 10), null)}
306
+ onClick={() => onCommit(today ?? todayString(), null)}
307
307
  >
308
308
  Today
309
309
  </button>
package/src/shadcn.mjs CHANGED
@@ -104,7 +104,7 @@ function coreItem() {
104
104
  title: "Makersclaw kit",
105
105
  description:
106
106
  "The kit every Makersclaw block is built from — Button, Field, Select, Sheet, Tabs, Menu, Notice, Icon, Depiction — " +
107
- "and the parts bound to data (RecordSwitcher, useFixture). Native elements under the project's own theme tokens.",
107
+ "and the parts bound to data (RecordSwitcher, useFixture, LiveEmpty). Native elements under the project's own theme tokens.",
108
108
  files,
109
109
  cssVars: CSS_VARS,
110
110
  css: CSS,