makersclaw 0.4.0 → 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 (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 +26 -10
  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
@@ -36,9 +36,37 @@ export const ESCAPE_MESSAGE = "claw:escape";
36
36
  // ---------------------------------------------------------------------------
37
37
  // Auto-refresh
38
38
 
39
- /** How often the page re-renders from the server while it is shown. */
39
+ /** How often the page re-renders from the server while it is shown: the
40
+ * wait between one refresh landing and the next asking. */
40
41
  export const REFRESH_MS = 5000;
41
42
 
43
+ /** The longest wait between two refreshes, however slow the last was. */
44
+ export const MAX_REFRESH_MS = 60000;
45
+
46
+ /** How long a refresh may stay pending before the loop stops waiting for
47
+ * it: a transition a navigation overtook never reports back, and one
48
+ * that never lands must not end the refreshing for good. */
49
+ export const REFRESH_SETTLE_MS = 60000;
50
+
51
+ /** How long a shown page may go without a sign of anyone — a key, the
52
+ * pointer, a touch, a wheel, focus — before the refresh stops. The
53
+ * platform stops an app nobody used for 30 minutes, and every refresh is
54
+ * a use: a tab left open on a screen nobody looks at kept its app, and
55
+ * the sandbox under it, running all night. The same 30 minutes here, so
56
+ * an app left open is an app left alone. */
57
+ export const UNATTENDED_MS = 30 * 60 * 1000;
58
+
59
+ /** What counts as someone there. */
60
+ export const PRESENCE_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "focusin"];
61
+
62
+ /** What a surface puts on its root to keep the page as it is: no refresh,
63
+ * and no probe, while any element on the page carries it. For a surface
64
+ * whose rows only its own writes change, or one too big to send every
65
+ * few seconds (a base of 4,812 records was about 2 MB a refresh). Its
66
+ * writes still re-render it (lib/actions.js revalidates), and so does
67
+ * every navigation. */
68
+ export const REFRESH_OFF = '[data-claw-refresh="off"]';
69
+
42
70
  /** How long after the last keystroke in a field a refresh still waits:
43
71
  * long enough to tab from one field to the next, or to the Save button,
44
72
  * without a refresh landing in between. */
@@ -89,11 +117,28 @@ export function isEditing(doc, typed = null) {
89
117
  return Boolean(doc.querySelector("dialog[open]"));
90
118
  }
91
119
 
92
- /** One tick's decision: refresh only when someone can see the page and is
93
- * not typing into it. `quietFor` is the time since the last input event
94
- * (Infinity when there was none). */
95
- export function shouldRefresh({ visible, onScreen, editing, quietFor }) {
96
- return Boolean(visible && onScreen && !editing && quietFor >= QUIET_MS);
120
+ /** One tick's decision: refresh only when someone can see the page, is not
121
+ * typing into it, and no surface on it has opted out. `quietFor` is the
122
+ * time since the last input event (Infinity when there was none). */
123
+ export function shouldRefresh({ visible, onScreen, editing, quietFor, optedOut = false }) {
124
+ return Boolean(visible && onScreen && !editing && !optedOut && quietFor >= QUIET_MS);
125
+ }
126
+
127
+ /** Has a surface on the page opted out (REFRESH_OFF on its root)? */
128
+ export function refreshOptedOut(doc) {
129
+ try {
130
+ return Boolean(doc.querySelector(REFRESH_OFF));
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** The wait before the next tick: the interval, plus what the last tick
137
+ * cost — its probe and its refresh — so a busy stack is asked less often
138
+ * the busier it is, up to MAX_REFRESH_MS. */
139
+ export function nextRefreshIn(lastCostMs = 0) {
140
+ const cost = Number.isFinite(lastCostMs) && lastCostMs > 0 ? lastCostMs : 0;
141
+ return Math.min(MAX_REFRESH_MS, REFRESH_MS + cost);
97
142
  }
98
143
 
99
144
  /** What to ask before a refresh: app/claw-alive/route.js, a fixed route
@@ -149,6 +194,152 @@ export async function answering(url, fetchImpl = globalThis.fetch, timeoutMs = P
149
194
  }
150
195
  }
151
196
 
197
+ /** `promise`, or undefined once `ms` have passed, whichever comes first. */
198
+ function within(promise, ms, timers) {
199
+ let timer = null;
200
+ const late = new Promise((resolve) => (timer = timers.setTimeout(resolve, ms)));
201
+ return Promise.race([Promise.resolve(promise).catch(() => undefined), late]).finally(() => timers.clearTimeout(timer));
202
+ }
203
+
204
+ /**
205
+ * The refresh loop app/auto-refresh.jsx runs, as plain code over a
206
+ * document so `npm test` can drive it. `refresh()` starts one refresh and
207
+ * resolves when it has landed (the component's transition settling);
208
+ * `probe()` is the question above, asked first. → { stop() }
209
+ *
210
+ * Until 0.5.0 a timer fired every REFRESH_MS whatever was in flight: the
211
+ * probe was guarded, the refresh was not, so a render slower than the
212
+ * interval was cut off by the next one — 100+ refreshes in two minutes on
213
+ * an idle page, a 502 or 503 whenever the stack was busy — and a page left
214
+ * open in a hidden tab kept asking, so the platform's idle sweep never
215
+ * stopped its app (install test and basics wave, 2026-09-27). Now:
216
+ *
217
+ * - one tick at a time: the next is timed only once the last refresh
218
+ * has landed, REFRESH_MS plus what the last tick cost (nextRefreshIn);
219
+ * - no timer at all while the page is hidden or behind the window's
220
+ * other tabs; coming back ticks at once, so the rows are current;
221
+ * - none either after UNATTENDED_MS shown with no sign of anyone
222
+ * (PRESENCE_EVENTS, in the frame): the next sign refreshes at once
223
+ * and the loop goes on. Coming back from hidden is such a sign;
224
+ * - a tick refreshes only when shouldRefresh says so, with the probe's
225
+ * 2xx, and never while a surface has opted out (REFRESH_OFF).
226
+ *
227
+ * The rules are asked again after the probe: typing may have started, or
228
+ * the tab changed, while it was on its way.
229
+ */
230
+ export function refreshLoop({
231
+ doc,
232
+ refresh,
233
+ probe = () => answering(probeUrl(doc)),
234
+ now = () => Date.now(),
235
+ timers = globalThis,
236
+ Observer = globalThis.IntersectionObserver,
237
+ }) {
238
+ let onScreen = true;
239
+ let lastInput = -Infinity;
240
+ let typed = null; // the field the last `input` event came from
241
+ let timer = null;
242
+ let busy = false; // a probe or a refresh is out
243
+ let stopped = false;
244
+ let cost = 0; // what the last tick took, probe and refresh
245
+ let lastSeen = now(); // the last sign of someone; opening the page is one
246
+ let unattended = false; // stopped for want of anyone: the next sign ticks
247
+
248
+ const visible = () => doc.visibilityState === "visible";
249
+ const ready = () =>
250
+ shouldRefresh({
251
+ visible: visible(),
252
+ onScreen,
253
+ editing: isEditing(doc, typed),
254
+ quietFor: now() - lastInput,
255
+ optedOut: refreshOptedOut(doc),
256
+ });
257
+
258
+ const cancel = () => {
259
+ timers.clearTimeout(timer);
260
+ timer = null;
261
+ };
262
+ // Timed only while shown and attended: hidden, the loop waits for the
263
+ // page to come back (onVisible, the observer), and unattended for the
264
+ // next sign of someone (seen), rather than for a clock.
265
+ const schedule = () => {
266
+ cancel();
267
+ if (!stopped && !busy && !unattended && visible() && onScreen) timer = timers.setTimeout(tick, nextRefreshIn(cost));
268
+ };
269
+
270
+ async function tick() {
271
+ cancel();
272
+ if (stopped || busy) return;
273
+ if (visible() && onScreen && now() - lastSeen >= UNATTENDED_MS) {
274
+ unattended = true;
275
+ return;
276
+ }
277
+ if (!ready()) return schedule();
278
+ busy = true;
279
+ const started = now();
280
+ try {
281
+ if ((await probe()) && !stopped && ready()) await within(refresh(), REFRESH_SETTLE_MS, timers);
282
+ } catch {
283
+ // A probe or a refresh that throws is one that did not land; the
284
+ // next tick asks again. Never an unhandled rejection: the error
285
+ // relay would report every one to the platform.
286
+ } finally {
287
+ busy = false;
288
+ cost = now() - started;
289
+ }
290
+ schedule();
291
+ }
292
+
293
+ const onInput = (event) => {
294
+ lastInput = now();
295
+ typed = event.target;
296
+ };
297
+ // Someone is there. After a stop for want of anyone, refresh at once.
298
+ const seen = () => {
299
+ lastSeen = now();
300
+ if (!unattended) return;
301
+ unattended = false;
302
+ tick();
303
+ };
304
+ // Back on screen: someone looked, and the rows may be old.
305
+ const back = () => {
306
+ lastSeen = now();
307
+ unattended = false;
308
+ tick();
309
+ };
310
+ const onVisible = () => (visible() ? back() : cancel());
311
+
312
+ let observer = null;
313
+ if (typeof Observer === "function") {
314
+ observer = new Observer((entries) => {
315
+ const was = onScreen;
316
+ onScreen = entries[entries.length - 1].isIntersecting;
317
+ if (onScreen && !was) back();
318
+ else if (!onScreen) cancel();
319
+ });
320
+ observer.observe(doc.documentElement);
321
+ }
322
+ const PASSIVE = { capture: true, passive: true };
323
+ const win = doc.defaultView ?? null;
324
+ doc.addEventListener("visibilitychange", onVisible);
325
+ doc.addEventListener("input", onInput, true);
326
+ for (const type of PRESENCE_EVENTS) doc.addEventListener(type, seen, PASSIVE);
327
+ win?.addEventListener?.("focus", seen);
328
+ schedule();
329
+
330
+ return {
331
+ stop() {
332
+ stopped = true;
333
+ cancel();
334
+ observer?.disconnect();
335
+ doc.removeEventListener("visibilitychange", onVisible);
336
+ doc.removeEventListener("input", onInput, true);
337
+ for (const type of PRESENCE_EVENTS) doc.removeEventListener(type, seen, PASSIVE);
338
+ win?.removeEventListener?.("focus", seen);
339
+ },
340
+ };
341
+ }
342
+
152
343
  // ---------------------------------------------------------------------------
153
344
  // Escape from the desk
154
345
 
@@ -73,12 +73,28 @@ const DEPICTION = /^components\/depictions\//;
73
73
  * preview can follow typing). Never the shell, the kit or lib/. */
74
74
  const FRAME_HOST = /^(?:surfaces\/[^/]+\.jsx|components\/[^/]+\.jsx)$/;
75
75
 
76
+ // Folders a build, an install or a tool leaves in the app, never its
77
+ // source: ONE list for every walk here — the rules, the smoke build's
78
+ // freshness, its copy in the sandbox. Until 0.5.0 each walk named its own
79
+ // two or three, and the rules read a temporary .next-stub a build had
80
+ // left (phase-planner, 2026-09-23; CLAW-FIXES #36). A name ending in *
81
+ // is a prefix (.next, .next-check, .next-stub…). node_modules and .git
82
+ // are skipped wherever they are; the rest at the app's root only, where
83
+ // the build puts them: a folder of the app's own may be called out/.
84
+ const BUILD_DIRS = ["node_modules", ".git", ".next*", "out", ".turbo", ".cache"];
85
+ const ANY_DEPTH = new Set(["node_modules", ".git"]);
86
+ const isBuildDir = (name) => BUILD_DIRS.some((d) => (d.endsWith("*") ? name.startsWith(d.slice(0, -1)) : name === d));
87
+ /** Is this path, relative to the app, inside a folder that is not source? */
88
+ function notSource(rel) {
89
+ const parts = rel.split("/");
90
+ return isBuildDir(parts[0]) || parts.some((part) => ANY_DEPTH.has(part));
91
+ }
92
+
76
93
  // Files the rules skip: the kit (it owns the native elements and the
77
- // fallback colours), the fixed template files, machine folders. Depiction
78
- // files are NOT skipped — the `depictions` rule reads them.
94
+ // fallback colours), the fixed template files, the check itself. Build
95
+ // folders never reach this list (walk, below). Depiction files are NOT
96
+ // skipped — the `depictions` rule reads them.
79
97
  const SKIP = [
80
- /^node_modules\//,
81
- /^\.next(-check)?\//,
82
98
  /^check\//,
83
99
  KIT,
84
100
  /^app\/globals\.css$/,
@@ -94,12 +110,12 @@ const SKIP = [
94
110
  /\.(md|sql|json|lock)$/,
95
111
  ];
96
112
 
97
- // Only the build's own folders are skipped: a dot-named module
113
+ // Only build folders are skipped (BUILD_DIRS): a dot-named module
98
114
  // (components/.pictures.js) is a module Next bundles all the same.
99
115
  function walk(dir, out = []) {
100
116
  for (const name of readdirSync(dir)) {
101
- if (name === "node_modules" || name === ".git" || name === ".next" || name === ".next-check") continue;
102
117
  const full = join(dir, name);
118
+ if (notSource(relative(ROOT, full))) continue;
103
119
  if (statSync(full).isDirectory()) walk(full, out);
104
120
  else out.push(full);
105
121
  }
@@ -542,12 +558,12 @@ const surfaceKeys = [];
542
558
 
543
559
  // 6. Smoke: the app, built and started, answers every surface.
544
560
 
545
- /** Newest mtime among the app's own sources (what a build reads). */
561
+ /** Newest mtime among the app's own sources (what a build reads): walk
562
+ * leaves the build folders out, and check/ is not built. */
546
563
  function newestSource() {
547
564
  let newest = 0;
548
565
  for (const f of walk(ROOT)) {
549
- const rel = relative(ROOT, f);
550
- if (/^(node_modules|\.next|\.next-check|check)\//.test(rel)) continue;
566
+ if (/^check\//.test(relative(ROOT, f))) continue;
551
567
  newest = Math.max(newest, statSync(f).mtimeMs);
552
568
  }
553
569
  return newest;
@@ -582,7 +598,7 @@ function copySources(dir) {
582
598
  dereference: false,
583
599
  filter: (src) => {
584
600
  const rel = relative(ROOT, src);
585
- return !/^(node_modules|\.next|\.next-check|check)(\/|$)/.test(rel);
601
+ return !notSource(rel) && !/^check(\/|$)/.test(rel);
586
602
  },
587
603
  });
588
604
  const link = join(dir, "node_modules");
@@ -139,6 +139,30 @@ export function formatDate(s, options = { dateStyle: "medium" }, locale) {
139
139
  return d ? plainSpaces(new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(d)) : "";
140
140
  }
141
141
 
142
+ /** Any date a row holds → a Date to show in the reader's own time; null
143
+ * when it is none. A day ("2026-09-28", what a `date` column reads as)
144
+ * is that day's LOCAL midnight: new Date("2026-09-28") is UTC's
145
+ * midnight, the 27th everywhere west of Greenwich, and a screen that
146
+ * shows it, then saves what it shows, moves the day back by one. A Date
147
+ * stays as it is; an ISO timestamp or epoch milliseconds go through
148
+ * new Date(). For a day alone, formatDate and the helpers above need no
149
+ * Date at all; this is for code that mixes days with instants (a
150
+ * comment's time, "due" beside "sent at"). */
151
+ export function toLocalDate(value) {
152
+ if (value === null || value === undefined || value === "") return null;
153
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
154
+ const day = typeof value === "string" ? parseDate(value) : null;
155
+ if (day) {
156
+ // setFullYear, not new Date(y, m, d): that one reads years 0–99 as 19xx.
157
+ const local = new Date(0);
158
+ local.setFullYear(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
159
+ local.setHours(0, 0, 0, 0);
160
+ return local;
161
+ }
162
+ const d = new Date(value);
163
+ return Number.isNaN(d.getTime()) ? null : d;
164
+ }
165
+
142
166
  // The week's first day for a locale: Intl's own answer where the browser
143
167
  // has it (getWeekInfo), else CLDR's regions that start on Sunday or
144
168
  // Saturday; everyone else starts on Monday.
@@ -8,10 +8,19 @@ import { cx } from "./cx";
8
8
  // validation, autofill). Wrap one in <Field> for its label, hint and
9
9
  // error — never a bare <input> with a <div> label beside it.
10
10
 
11
- const CONTROL =
12
- "w-full min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground " +
11
+ const BOX =
12
+ "min-h-11 rounded-md border border-input bg-background px-3 text-sm text-foreground " +
13
13
  "placeholder:text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring " +
14
14
  "disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive";
15
+ const CONTROL = `w-full ${BOX}`;
16
+
17
+ // An Input is as wide as its column unless the caller's className gives
18
+ // it a width (w-24, w-auto, w-[12rem], size-10), which then stands in
19
+ // for w-full. cx does not merge classes: with both, the rule later in the
20
+ // stylesheet wins, and Tailwind writes w-full after w-24, w-auto, w-fit
21
+ // and w-[…] — until 0.5.1 those did nothing on an Input. A width under a
22
+ // breakpoint (sm:w-40) keeps w-full below it.
23
+ const ownWidth = (className) => /(?:^|\s)!?(?:w|size)-/.test(className ?? "");
15
24
 
16
25
  /** Label, control, hint and error in one stack. Pass `id`; the label
17
26
  * points at it. `error` (a string) marks the control invalid. */
@@ -39,7 +48,7 @@ export function Field({ id, label, hint, error, required, className, children })
39
48
  }
40
49
 
41
50
  export function Input({ className, invalid, ...rest }) {
42
- return <input className={cx(CONTROL, className)} aria-invalid={invalid ? true : undefined} {...rest} />;
51
+ return <input className={cx(!ownWidth(className) && "w-full", BOX, className)} aria-invalid={invalid ? true : undefined} {...rest} />;
43
52
  }
44
53
 
45
54
  /**
@@ -23,6 +23,7 @@ export {
23
23
  endOfMonth,
24
24
  dayOfWeek,
25
25
  formatDate,
26
+ toLocalDate,
26
27
  firstDayOfWeek,
27
28
  } from "./calendar";
28
29
  export { DatePicker, DateInput, DATE_PRESETS } from "./date-picker";
@@ -20,6 +20,9 @@ import { Checkbox, Input } from "./field";
20
20
  // press outside still close it. Give it `label`.
21
21
  // label the dialog's name for a screen reader when no `title` shows
22
22
  //
23
+ // A field with autoFocus inside it takes focus once the sheet is open
24
+ // (otherwise the browser gives it to the first control: Close).
25
+ //
23
26
  // Confirm below is the "delete this?" case — never window.confirm().
24
27
 
25
28
  const SIDES = {
@@ -33,6 +36,37 @@ const SIDES = {
33
36
  bottom: "inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t",
34
37
  };
35
38
 
39
+ // autoFocus inside a Sheet. React focuses an autoFocus field as the field
40
+ // mounts, and the dialog opens after that, in the effect below: a field
41
+ // mounted with the sheet, or long before it (a sheet's body is rendered
42
+ // while it is closed), was focused inside a closed dialog, where focus
43
+ // goes nowhere. showModal() then focused the first thing it could, the
44
+ // header's Close button, and typing in "New piece" went nowhere
45
+ // (weekly-content-report, 2026-09-23; CLAW-FIXES #18). So once it is
46
+ // open, the Sheet puts focus where a field asked for it.
47
+ //
48
+ // Which field: React renders no autofocus attribute in the browser. It
49
+ // keeps a host element's props on the element, under "__reactProps$…"
50
+ // (react-dom's own key since React 17; test:app holds the installed
51
+ // react-dom to that name), so that is read, for the elements React
52
+ // autofocuses. The attribute counts too: a server-rendered field has it.
53
+ const AUTOFOCUSABLE = "input, textarea, select, button, [autofocus]";
54
+
55
+ function reactProps(node) {
56
+ for (const key of Object.keys(node)) if (key.startsWith("__reactProps$")) return node[key];
57
+ return null;
58
+ }
59
+
60
+ /** The field in `dialog` that asked for focus (autoFocus), or null — never
61
+ * one inside a sheet nested in this one, never a disabled one. */
62
+ export function autoFocusTarget(dialog) {
63
+ for (const node of dialog.querySelectorAll(AUTOFOCUSABLE)) {
64
+ if (node.disabled || node.closest("dialog") !== dialog) continue;
65
+ if (node.hasAttribute("autofocus") || reactProps(node)?.autoFocus) return node;
66
+ }
67
+ return null;
68
+ }
69
+
36
70
  export function Sheet({ open, onClose, title, description, side = "end", footer, bare = false, label, className, children }) {
37
71
  const ref = useRef(null);
38
72
  const titleId = useId();
@@ -41,8 +75,10 @@ export function Sheet({ open, onClose, title, description, side = "end", footer,
41
75
  useEffect(() => {
42
76
  const el = ref.current;
43
77
  if (!el) return;
44
- if (open && !el.open) el.showModal();
45
- else if (!open && el.open) el.close();
78
+ if (open && !el.open) {
79
+ el.showModal();
80
+ autoFocusTarget(el)?.focus();
81
+ } else if (!open && el.open) el.close();
46
82
  }, [open]);
47
83
 
48
84
  return (
@@ -88,13 +88,24 @@ const SEG = {
88
88
  sm: { track: "p-0.5", item: "min-h-8 px-2.5", square: "min-h-8 min-w-8 px-0", glyph: 16 },
89
89
  };
90
90
 
91
+ // The group is a scroll container, so its row never makes its parent
92
+ // wider (in a plain grid it pushed the page wider on a phone, 0.5.0).
93
+ // That also takes away the minimums a flex item gets from its content,
94
+ // so each comes back unless the className sizes that axis itself:
95
+ // shrink-0 keeps its width in a flex row (its neighbours give way, as
96
+ // before), min-h-min its height in a flex column.
97
+ const FLEXES = /(?:^|\s)!?(?:w-|flex-|grow|shrink|basis-)/;
98
+ const TALL = /(?:^|\s)!?(?:h-|min-h-|size-)/;
99
+
91
100
  /**
92
101
  * A row of toggles as one control, the segmented look: a muted track,
93
102
  * the pressed items raised. `type` single (exactly one pressed — pressing
94
103
  * it again keeps it; a filter that can be off is `multiple`) · multiple.
95
104
  * `value` is a string (single) or an array (multiple); `onChange` gets
96
105
  * the same shape. md is 44px tall, sm 36px. Stretches when given a width
97
- * (className="w-full"): the items share it.
106
+ * (className="w-full"): the items share it. Never wider than its
107
+ * container: past that it scrolls sideways inside itself (it does not
108
+ * wrap unless className="flex-wrap").
98
109
  *
99
110
  * items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`
100
111
  * shows the icon alone; `label` is then what a screen reader says
@@ -157,7 +168,14 @@ export function ToggleGroup({
157
168
  <div
158
169
  role="group"
159
170
  aria-label={ariaLabel}
160
- className={cx("inline-flex max-w-full gap-1 rounded-md bg-muted", s.track, disabled && "opacity-50", className)}
171
+ className={cx(
172
+ "inline-flex max-w-full gap-1 overflow-x-auto rounded-md bg-muted",
173
+ !FLEXES.test(className ?? "") && "shrink-0",
174
+ !TALL.test(className ?? "") && "min-h-min",
175
+ s.track,
176
+ disabled && "opacity-50",
177
+ className,
178
+ )}
161
179
  >
162
180
  {live.map((it, i) => {
163
181
  const on = isOn(it.value);
@@ -43,18 +43,73 @@ function done() {
43
43
  revalidatePath("/", "layout");
44
44
  }
45
45
 
46
+ // updated_at: a row a write changes says when. Up to 0.4.x updateRow left
47
+ // it alone, so unless a package shipped its own trigger, "changed 3 days
48
+ // ago", a list sorted by updated_at and the per-field merges the basics
49
+ // apps do all read the stamp of an older write (CLAW-FIXES #22). Set only
50
+ // where the table has the column and a write can put now() in it: a
51
+ // timestamp, with or without a zone, that is not a view's computed column.
52
+ // Anything else (a text or a number column of that name) stays the app's.
53
+ // A patch that names updated_at itself wins.
54
+ //
55
+ // The catalog is asked once a minute per table, not on every write: a
56
+ // drag on a board is a write, and the workspace database is across the
57
+ // network. A minute is how long a column added meanwhile (an install
58
+ // widening a shared table) goes unstamped.
59
+ const STAMP = "updated_at";
60
+ const STAMP_TYPES = new Set([1114, 1184]); // timestamp, timestamptz
61
+ const STAMP_TTL_MS = 60_000;
62
+ const stamps = new Map(); // quoted source → { at, has }
63
+
64
+ async function stampsUpdatedAt(source) {
65
+ const table = ident(source);
66
+ const known = stamps.get(table);
67
+ if (known && Date.now() - known.at < STAMP_TTL_MS) return known.has;
68
+ let has = false;
69
+ try {
70
+ // to_regclass reads the quoted name as the UPDATE does: the same
71
+ // search_path, the same case. A domain counts as its base type.
72
+ const { rows } = await query(
73
+ `SELECT coalesce(nullif(t.typbasetype, 0), t.oid) AS type,
74
+ pg_column_is_updatable(a.attrelid, a.attnum, false) AS updatable
75
+ FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
76
+ WHERE a.attrelid = to_regclass($1) AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped`,
77
+ [table, STAMP],
78
+ );
79
+ has = Boolean(rows[0]?.updatable) && STAMP_TYPES.has(Number(rows[0]?.type));
80
+ } catch (error) {
81
+ // Never fail the write for its stamp; ask again next time.
82
+ console.error(`[actions] could not read ${source}'s columns: ${error?.message ?? error}`);
83
+ return false;
84
+ }
85
+ stamps.set(table, { at: Date.now(), has });
86
+ return has;
87
+ }
88
+
89
+ /** ", updated_at = now()" for an UPDATE of `source` that sets `cols`,
90
+ * when the table stamps it and the write does not set it itself; else "". */
91
+ async function stampFor(source, cols) {
92
+ return !cols.includes(STAMP) && (await stampsUpdatedAt(source)) ? `, ${ident(STAMP)} = now()` : "";
93
+ }
94
+
95
+ /** The SET text for `cols` (from $2 on), stamped (above). */
96
+ async function setClause(source, cols) {
97
+ return cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ") + (await stampFor(source, cols));
98
+ }
99
+
46
100
  function fail(error) {
47
101
  const message = error?.message ?? String(error);
48
102
  console.error(`[actions] write failed: ${message}`);
49
103
  return { ok: false, error: message };
50
104
  }
51
105
 
52
- /** Change columns of one row. `patch` is { column: value }. */
106
+ /** Change columns of one row. `patch` is { column: value }; updated_at
107
+ * becomes now() when the table has one (above). */
53
108
  export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data }) {
54
109
  try {
55
110
  const cols = Object.keys(patch ?? {}).filter((c) => patch[c] !== undefined);
56
111
  if (!cols.length) return { ok: false, error: "nothing to change" };
57
- const sets = cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ");
112
+ const sets = await setClause(source, cols);
58
113
  const { rowCount } = await query(
59
114
  `UPDATE ${ident(source)} SET ${sets} WHERE ${ident(idColumn)} = $1`,
60
115
  [id, ...cols.map((c) => patch[c])],
@@ -139,7 +194,8 @@ export async function deleteRow({ source, idColumn = "id", id, surface, detail }
139
194
  * ON CONFLICT, because a settings table made with the `sql` verb usually
140
195
  * has a plain `key text` column and Postgres rejects ON CONFLICT without a
141
196
  * unique index on it. JSON mode sets the key inside the jsonb `column` of
142
- * the row `id` in `source`.
197
+ * the row `id` in `source`. An update stamps updated_at as updateRow's
198
+ * does; the insert leaves it to the column's default.
143
199
  */
144
200
  export async function saveSetting({
145
201
  mode = "table",
@@ -156,15 +212,17 @@ export async function saveSetting({
156
212
  try {
157
213
  if (!key) return { ok: false, error: "no key" };
158
214
  if (mode === "json") {
215
+ const stamp = await stampFor(source, [column]);
159
216
  const { rowCount } = await query(
160
- `UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true) WHERE ${ident(idColumn)} = $1`,
217
+ `UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true)${stamp} WHERE ${ident(idColumn)} = $1`,
161
218
  [id, [key], JSON.stringify(value ?? null)],
162
219
  );
163
220
  if (!rowCount) return { ok: false, error: "row not found" };
164
221
  } else {
165
222
  const stored = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
223
+ const stamp = await stampFor(source, [valueColumn]);
166
224
  const { rowCount } = await query(
167
- `UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2 WHERE ${ident(keyColumn)} = $1`,
225
+ `UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2${stamp} WHERE ${ident(keyColumn)} = $1`,
168
226
  [key, stored],
169
227
  );
170
228
  if (!rowCount) {
@@ -24,7 +24,10 @@ export function orderClause(orderBy, fallback) {
24
24
  }
25
25
 
26
26
  /** Dates and other non-JSON values become plain so rows can cross into
27
- * client components. pg already returns bigint/numeric as strings. */
27
+ * client components. pg already returns bigint/numeric as strings, and a
28
+ * `date` column as its day ("2026-09-28", lib/db.js), which passes
29
+ * through untouched: only a timestamp is still a Date here, and it
30
+ * becomes its ISO instant. */
28
31
  export function plain(row) {
29
32
  const out = {};
30
33
  for (const [k, v] of Object.entries(row)) {
@@ -1,4 +1,21 @@
1
- import { Pool } from "pg";
1
+ import { Pool, types } from "pg";
2
+
3
+ // A `date` column is a day on a wall calendar, not an instant, so it is
4
+ // read as its text: "2026-09-28", the same day for every reader, the
5
+ // kit's date format (components/ui/calendar.jsx), and what a save writes
6
+ // back unchanged. pg's own parser made it a Date at the SERVER's
7
+ // midnight: lib/data.js sent that on as an ISO timestamp, and a server
8
+ // east of UTC wrote "2026-09-27T22:00:00.000Z" for the 28th, a browser
9
+ // west of UTC showed the 27th, and a form that saved what it showed
10
+ // stored the day before (CLAW-FIXES #21, three store builds). Until 0.5.0
11
+ // it was a Date; a date[] column is text the same way. Registered on
12
+ // pg's shared types, so a pool made before this module loaded again (a
13
+ // dev server's hot reload keeps it on globalThis) reads days too.
14
+ const DATE = 1082;
15
+ const DATE_ARRAY = 1182;
16
+ const TEXT_ARRAY = 1009;
17
+ types.setTypeParser(DATE, (text) => text);
18
+ types.setTypeParser(DATE_ARRAY, types.getTypeParser(TEXT_ARRAY));
2
19
 
3
20
  // One pool per process; DATABASE_URL comes from this directory's .env,
4
21
  // written by app_database and sourced by product_run at start.