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.
- 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 +26 -10
- 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
|
|
|
@@ -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,
|
|
78
|
-
//
|
|
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
|
|
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
|
-
|
|
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 !/^
|
|
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
|
|
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) {
|
|
@@ -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.
|