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.
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +5 -5
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +2 -2
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +121 -121
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/templates/next/files/README.md +77 -19
- package/templates/next/files/app/auto-refresh.jsx +39 -74
- package/templates/next/files/app/frame.js +197 -6
- package/templates/next/files/check/run.mjs +96 -11
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +52 -17
- 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
|
|
93
|
-
*
|
|
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,
|
|
78
|
-
//
|
|
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
|
|
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
|
-
|
|
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 !/^
|
|
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
|
|
12
|
-
"
|
|
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(
|
|
51
|
+
return <input className={cx(!ownWidth(className) && "w-full", BOX, className)} aria-invalid={invalid ? true : undefined} {...rest} />;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
/**
|
|
@@ -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)
|
|
45
|
-
|
|
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(
|
|
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 =
|
|
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) {
|