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
@@ -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
 
@@ -10,7 +10,8 @@
10
10
  // example INSERTed into a shared table, a manifest entry whose file was
11
11
  // never written, an icon name that rendered nothing, a tweet card painted
12
12
  // in the workspace's tokens (unrecognisable as a tweet — the one thing
13
- // the app was for), a kit file "improved" per app.
13
+ // the app was for), a kit file "improved" per app, a button whose server
14
+ // action the app's own agent had no way to know about.
14
15
  //
15
16
  // npm run check static rules, then smoke
16
17
  // npm run check -- --static static rules only
@@ -73,12 +74,28 @@ const DEPICTION = /^components\/depictions\//;
73
74
  * preview can follow typing). Never the shell, the kit or lib/. */
74
75
  const FRAME_HOST = /^(?:surfaces\/[^/]+\.jsx|components\/[^/]+\.jsx)$/;
75
76
 
77
+ // Folders a build, an install or a tool leaves in the app, never its
78
+ // source: ONE list for every walk here — the rules, the smoke build's
79
+ // freshness, its copy in the sandbox. Until 0.5.0 each walk named its own
80
+ // two or three, and the rules read a temporary .next-stub a build had
81
+ // left (phase-planner, 2026-09-23; CLAW-FIXES #36). A name ending in *
82
+ // is a prefix (.next, .next-check, .next-stub…). node_modules and .git
83
+ // are skipped wherever they are; the rest at the app's root only, where
84
+ // the build puts them: a folder of the app's own may be called out/.
85
+ const BUILD_DIRS = ["node_modules", ".git", ".next*", "out", ".turbo", ".cache"];
86
+ const ANY_DEPTH = new Set(["node_modules", ".git"]);
87
+ const isBuildDir = (name) => BUILD_DIRS.some((d) => (d.endsWith("*") ? name.startsWith(d.slice(0, -1)) : name === d));
88
+ /** Is this path, relative to the app, inside a folder that is not source? */
89
+ function notSource(rel) {
90
+ const parts = rel.split("/");
91
+ return isBuildDir(parts[0]) || parts.some((part) => ANY_DEPTH.has(part));
92
+ }
93
+
76
94
  // 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.
95
+ // fallback colours), the fixed template files, the check itself. Build
96
+ // folders never reach this list (walk, below). Depiction files are NOT
97
+ // skipped — the `depictions` rule reads them.
79
98
  const SKIP = [
80
- /^node_modules\//,
81
- /^\.next(-check)?\//,
82
99
  /^check\//,
83
100
  KIT,
84
101
  /^app\/globals\.css$/,
@@ -94,12 +111,12 @@ const SKIP = [
94
111
  /\.(md|sql|json|lock)$/,
95
112
  ];
96
113
 
97
- // Only the build's own folders are skipped: a dot-named module
114
+ // Only build folders are skipped (BUILD_DIRS): a dot-named module
98
115
  // (components/.pictures.js) is a module Next bundles all the same.
99
116
  function walk(dir, out = []) {
100
117
  for (const name of readdirSync(dir)) {
101
- if (name === "node_modules" || name === ".git" || name === ".next" || name === ".next-check") continue;
102
118
  const full = join(dir, name);
119
+ if (notSource(relative(ROOT, full))) continue;
103
120
  if (statSync(full).isDirectory()) walk(full, out);
104
121
  else out.push(full);
105
122
  }
@@ -540,14 +557,82 @@ const surfaceKeys = [];
540
557
  else ok7(`${pictures} picture${pictures === 1 ? "" : "s"} of elsewhere, framed in ${hosts} file${hosts === 1 ? "" : "s"}: ${[...rendered.keys()].join(", ")}`);
541
558
  }
542
559
 
560
+ // 8. The agent runs the app. Every server action that writes is named, by
561
+ // its function name in backticks, in the agent's brief beside the app
562
+ // (../AGENTS.md) or its references (../references/*.md), with the sql
563
+ // that does the same: anything a person can do on the screens, the
564
+ // app's agent can do. Born of Whiteboard 0.3.0 (2026-09-28): asked in
565
+ // chat for a board, its agent asked the member to make one, since
566
+ // nothing told it how. An app with no brief beside it (a copy on its
567
+ // own) is not checked here.
568
+ {
569
+ const home = dirname(ROOT);
570
+ if (!existsSync(join(home, "AGENTS.md"))) ok("agent", "no AGENTS.md beside the app: its actions are not checked here");
571
+ else {
572
+ const refs = join(home, "references");
573
+ const brief = [join(home, "AGENTS.md"), ...(existsSync(refs) ? readdirSync(refs).filter((n) => n.endsWith(".md")).map((n) => join(refs, n)) : [])]
574
+ .map((p) => readFileSync(p, "utf8"))
575
+ .join("\n");
576
+ const WRITES =
577
+ /\b(insertRow|updateRow|deleteRow|moveRow|saveSetting)\s*\(|\binsert\s+into\b|\bupdate\s+(?:only\s+)?(?:public\.)?[a-z_][a-z0-9_]*(?:\s+(?:as\s+)?[a-z_][a-z0-9_]*)?\s+set\b|\bdelete\s+from\b/i;
578
+ // Every top-level function of a server file, bare or wrapped in a call
579
+ // (`export const save = safe(async (…) => …)`), exported by name or in
580
+ // an `export { … }` list. One writes when its body writes, or when it
581
+ // calls or hands on a function of its own file, or another action, that
582
+ // writes: a scan of direct SQL alone missed 67 of 669 in the store's
583
+ // apps (2026-09-28).
584
+ const HEAD =
585
+ /^(export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(|^(export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:[A-Za-z_$][\w$.]*\s*\(\s*)?(?:async\s*)?(?:function\b|\((?:[^()]|\([^()]*\))*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/gm;
586
+ const mentions = (body, name) => new RegExp(`(^|[^\\w$.])${name.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
587
+ const fns = [];
588
+ for (const f of appFiles) {
589
+ if (!/^\s*(["'])use server\1/m.test(f.text.split("\n").slice(0, 8).join("\n"))) continue;
590
+ const listed = new Map();
591
+ for (const m of f.code.matchAll(/^export\s*\{([^}]*)\}/gm))
592
+ for (const part of m[1].split(",")) {
593
+ const [local, as] = part.trim().split(/\s+as\s+/);
594
+ if (local) listed.set(local.trim(), (as ?? local).trim());
595
+ }
596
+ const heads = [...f.code.matchAll(HEAD)];
597
+ heads.forEach((m, i) => {
598
+ const name = m[2] ?? m[4];
599
+ const body = f.code.slice(m.index, heads[i + 1]?.index ?? f.code.length);
600
+ fns.push({ file: f.path, name, exported: m[1] || m[3] ? name : (listed.get(name) ?? null), body, writes: WRITES.test(body), where: `${f.path}:${lineOf(f.code, m.index)}` });
601
+ });
602
+ }
603
+ for (let changed = true; changed; ) {
604
+ changed = false;
605
+ const actions = fns.filter((g) => g.writes && g.exported).map((g) => g.exported);
606
+ for (const f of fns) {
607
+ if (f.writes) continue;
608
+ const local = fns.filter((g) => g.writes && g.file === f.file && g !== f).map((g) => g.name);
609
+ const others = actions.filter((n) => n !== f.exported && n !== f.name);
610
+ if ([...local, ...others].some((n) => mentions(f.body.slice(f.body.indexOf("(")), n))) f.writes = changed = true;
611
+ }
612
+ }
613
+ const writes = fns.filter((f) => f.writes && f.exported).map((f) => ({ where: f.where, name: f.exported }));
614
+ const missing = writes.filter((w) => !brief.includes(`\`${w.name}\``));
615
+ if (!writes.length) ok("agent", "no server action writes");
616
+ else if (missing.length)
617
+ fail(
618
+ "agent",
619
+ missing[0].where,
620
+ `${missing.length} of ${writes.length} server actions that write are not in the agent's brief: ` +
621
+ `${missing.slice(0, 6).map((w) => w.name).join(", ")}${missing.length > 6 ? ", …" : ""}. Name each in ` +
622
+ `../references/actions.md, in backticks, with the sql that does the same, so the app's agent can do what the screen does`,
623
+ );
624
+ else ok("agent", `all ${writes.length} server actions that write are in the agent's brief`);
625
+ }
626
+ }
627
+
543
628
  // 6. Smoke: the app, built and started, answers every surface.
544
629
 
545
- /** Newest mtime among the app's own sources (what a build reads). */
630
+ /** Newest mtime among the app's own sources (what a build reads): walk
631
+ * leaves the build folders out, and check/ is not built. */
546
632
  function newestSource() {
547
633
  let newest = 0;
548
634
  for (const f of walk(ROOT)) {
549
- const rel = relative(ROOT, f);
550
- if (/^(node_modules|\.next|\.next-check|check)\//.test(rel)) continue;
635
+ if (/^check\//.test(relative(ROOT, f))) continue;
551
636
  newest = Math.max(newest, statSync(f).mtimeMs);
552
637
  }
553
638
  return newest;
@@ -582,7 +667,7 @@ function copySources(dir) {
582
667
  dereference: false,
583
668
  filter: (src) => {
584
669
  const rel = relative(ROOT, src);
585
- return !/^(node_modules|\.next|\.next-check|check)(\/|$)/.test(rel);
670
+ return !notSource(rel) && !/^check(\/|$)/.test(rel);
586
671
  },
587
672
  });
588
673
  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) {