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
@@ -14,7 +14,7 @@
14
14
  // browser: the server draws the notes as one list, and the day headings
15
15
  // appear once mounted — never a mismatch between the two renders.
16
16
  import { useEffect, useRef, useState } from "react";
17
- import { Avatar, Badge, Card, Confirm, Menu, cx } from "../../ui";
17
+ import { Avatar, Badge, Card, Confirm, Menu, cx, toLocalDate } from "../../ui";
18
18
  import { CommentBody, CommentComposer, CommentTime } from "../comment-box";
19
19
  import { docToText } from "../doc-editor";
20
20
 
@@ -22,10 +22,9 @@ const nameOf = (author) => (typeof author === "string" ? author : author?.name)
22
22
  const srcOf = (author) => (typeof author === "object" && author ? author.src : undefined);
23
23
  const idOf = (author) => (typeof author === "object" && author ? author.id : undefined);
24
24
 
25
- const toMs = (v) => {
26
- if (v === undefined || v === null || v === "") return NaN;
27
- return (v instanceof Date ? v : new Date(v)).getTime();
28
- };
25
+ // A note dated with a day alone ("2026-09-28", a `date` column) files
26
+ // under that day wherever the reader is: local midnight, never UTC's.
27
+ const toMs = (v) => toLocalDate(v)?.getTime() ?? NaN;
29
28
 
30
29
  function useMounted() {
31
30
  const [on, setOn] = useState(false);
@@ -69,9 +69,9 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
69
69
  );
70
70
  } else if (panel.loading) {
71
71
  body = (
72
- <ul aria-busy="true" aria-label={`Loading ${panel.title ?? "list"}`} className="grid">
72
+ <ul aria-busy="true" aria-label={`Loading ${panel.title ?? "list"}`} className="grid grid-cols-1">
73
73
  {[0, 1, 2, 3, 4].map((i) => (
74
- <li key={i} className="grid gap-2 border-b border-border px-4 py-3">
74
+ <li key={i} className="grid grid-cols-1 gap-2 border-b border-border px-4 py-3">
75
75
  <Skeleton className="w-1/2" />
76
76
  <Skeleton className="w-3/4" />
77
77
  <Skeleton className="w-full" />
@@ -90,7 +90,7 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
90
90
  );
91
91
  } else {
92
92
  body = (
93
- <ul aria-label={panel.title ?? "List"} className="grid">
93
+ <ul aria-label={panel.title ?? "List"} className="grid grid-cols-1">
94
94
  {shown.map((it, i) => (
95
95
  <li key={it.key ?? it.href ?? `${it.title}-${i}`} className="border-b border-border">
96
96
  <Item
@@ -120,7 +120,7 @@ export function PanelBody({ panel, onChoose, sheet = false }) {
120
120
  return (
121
121
  <div className="flex min-h-0 flex-1 flex-col">
122
122
  {panel.title || panel.actions || searchOn ? (
123
- <div className="grid shrink-0 gap-3 border-b border-border p-3">
123
+ <div className="grid grid-cols-1 shrink-0 gap-3 border-b border-border p-3">
124
124
  {panel.title || panel.actions ? (
125
125
  <div className="flex min-h-9 items-center gap-2">
126
126
  {panel.title ? <h2 className="min-w-0 flex-1 truncate text-base font-semibold">{panel.title}</h2> : null}
@@ -354,7 +354,7 @@ export function Sidebar({
354
354
  <Icon name={item.icon} className="shrink-0" />
355
355
  ) : null}
356
356
  {item.description ? (
357
- <span className="grid min-w-0 flex-1 text-left leading-tight">
357
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
358
358
  <span className="truncate">{item.label}</span>
359
359
  <span className="truncate text-xs text-muted-foreground">{item.description}</span>
360
360
  </span>
@@ -420,7 +420,7 @@ export function Sidebar({
420
420
  opts.sheet ? "[&>summary]:px-4" : "[&>summary]:min-h-9 [&>summary]:px-3 [&>summary]:py-1",
421
421
  )}
422
422
  >
423
- <ul className="ml-5 grid gap-0.5 border-l border-border pl-2">
423
+ <ul className="ml-5 grid grid-cols-1 gap-0.5 border-l border-border pl-2">
424
424
  {item.items.map((child) => (
425
425
  <li key={keyOf(child)}>{leaf(child, opts)}</li>
426
426
  ))}
@@ -469,7 +469,7 @@ export function Sidebar({
469
469
  function replaced(item, { sheet, where }) {
470
470
  const headingId = `${uid}-${where}-drill`;
471
471
  return (
472
- <div className="grid gap-0.5">
472
+ <div className="grid grid-cols-1 gap-0.5">
473
473
  <Button id={`${uid}-${where}-back`} variant="nav" size={sheet ? "md" : "sm"} onClick={() => closeDrill(where)}>
474
474
  <Icon name="arrow-left" className="shrink-0" />
475
475
  <span className="min-w-0 flex-1 truncate text-left">Back</span>
@@ -477,7 +477,7 @@ export function Sidebar({
477
477
  <h2 id={headingId} className="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">
478
478
  {item.label}
479
479
  </h2>
480
- <ul aria-labelledby={headingId} className="grid gap-0.5">
480
+ <ul aria-labelledby={headingId} className="grid grid-cols-1 gap-0.5">
481
481
  {item.items.map((child) => (
482
482
  <li key={keyOf(child)}>{leaf(child, { sheet })}</li>
483
483
  ))}
@@ -513,7 +513,7 @@ export function Sidebar({
513
513
  // children stand in for the list.
514
514
  const drilledHere = drillItem && !icons && (drillMode === "replace" || (sheet && drillMode === "panel"));
515
515
  return (
516
- <nav aria-label={label} className={cx("grid gap-3", icons ? "justify-items-center py-2" : "px-2 py-2")}>
516
+ <nav aria-label={label} className={cx("grid grid-cols-1 gap-3", icons ? "justify-items-center py-2" : "px-2 py-2")}>
517
517
  {primaryAction && !drilledHere ? primary({ icons, sheet }) : null}
518
518
  {drilledHere
519
519
  ? replaced(drillItem, { sheet, where })
@@ -524,7 +524,7 @@ export function Sidebar({
524
524
  <ul
525
525
  aria-labelledby={group.label && !icons && !group.collapsible ? headingId : undefined}
526
526
  aria-label={group.label && !icons && group.collapsible ? group.label : undefined}
527
- className={cx("grid gap-0.5", icons && "justify-items-center")}
527
+ className={cx("grid grid-cols-1 gap-0.5", icons && "justify-items-center")}
528
528
  >
529
529
  {group.items.map((item) => (
530
530
  <li key={keyOf(item)}>{icons ? iconItem(item) : wideItem(item, { sheet, where })}</li>
@@ -546,7 +546,7 @@ export function Sidebar({
546
546
  );
547
547
  }
548
548
  return (
549
- <div key={group.key ?? group.label ?? gi} className="grid gap-0.5">
549
+ <div key={group.key ?? group.label ?? gi} className="grid grid-cols-1 gap-0.5">
550
550
  {group.label && !icons ? (
551
551
  <h2 id={headingId} className="flex h-8 items-center px-3 text-xs font-medium text-muted-foreground">
552
552
  {group.label}
@@ -565,7 +565,7 @@ export function Sidebar({
565
565
 
566
566
  function brand({ icons, side = "bottom" }) {
567
567
  const words = (
568
- <span className="grid min-w-0 flex-1 text-left leading-tight">
568
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
569
569
  <span className="truncate text-sm font-semibold">{title}</span>
570
570
  {subtitle ? <span className="truncate text-xs font-normal text-muted-foreground">{subtitle}</span> : null}
571
571
  </span>
@@ -667,7 +667,7 @@ export function Sidebar({
667
667
  if (!user) return null;
668
668
  const face = <Avatar name={user.name} src={user.src} size="sm" />;
669
669
  const words = (
670
- <span className="grid min-w-0 flex-1 text-left leading-tight">
670
+ <span className="grid grid-cols-1 min-w-0 flex-1 text-left leading-tight">
671
671
  <span className="truncate text-sm font-medium">{user.name}</span>
672
672
  {user.email ? <span className="truncate text-xs font-normal text-muted-foreground">{user.email}</span> : null}
673
673
  </span>
@@ -711,7 +711,7 @@ export function Sidebar({
711
711
  ) : null;
712
712
  if (!user && !plus && !footSwitch) return null;
713
713
  return (
714
- <div className="grid gap-1">
714
+ <div className="grid grid-cols-1 gap-1">
715
715
  {footSwitch ? brand({ icons, side: "top" }) : null}
716
716
  {user || plus ? (
717
717
  <div className={cx("flex items-center gap-1", icons && "flex-col")}>
@@ -745,7 +745,7 @@ export function Sidebar({
745
745
  <IconButton icon="x" label={`Close ${drillItem.label}`} size="sm" onClick={() => closeDrill("r")} />
746
746
  </div>
747
747
  <ScrollArea label={drillItem.label} className="min-h-0 flex-1" fade>
748
- <ul aria-labelledby={headingId} className="grid gap-0.5 p-2">
748
+ <ul aria-labelledby={headingId} className="grid grid-cols-1 gap-0.5 p-2">
749
749
  {drillItem.items.map((child, i) => (
750
750
  <li key={keyOf(child)}>{leaf(child, { sheet: false, id: i === 0 ? `${uid}-drill-first` : undefined })}</li>
751
751
  ))}
@@ -813,7 +813,7 @@ export function Sidebar({
813
813
  >
814
814
  {ctx.mobileOpen ? (
815
815
  sheetPanel && panel ? (
816
- <div className="-mx-2 -my-2 grid gap-2">
816
+ <div className="-mx-2 -my-2 grid grid-cols-1 gap-2">
817
817
  <div className="px-2 pt-2">
818
818
  <Button variant="nav" size="md" onClick={() => setSheetPanel(false)}>
819
819
  <Icon name="arrow-left" className="shrink-0" />
@@ -823,7 +823,7 @@ export function Sidebar({
823
823
  {panelIsNode ? panel : <PanelBody panel={panel} sheet onChoose={() => ctx.setMobileOpen(false)} />}
824
824
  </div>
825
825
  ) : (
826
- <div className="-mx-2 -my-2 grid gap-2">
826
+ <div className="-mx-2 -my-2 grid grid-cols-1 gap-2">
827
827
  {headerActions ? <div className="flex flex-wrap items-center gap-1 px-2">{headerActions}</div> : null}
828
828
  {searchOpts ? <div className="px-2">{searchRow({ icons: false, sheet: true })}</div> : null}
829
829
  {switcher?.length ? (
@@ -60,9 +60,16 @@ export function signatureRecord({ png, name, mode, signedAt }) {
60
60
  return { png, name: String(name ?? "").trim(), mode, signedAt: signedAt ?? new Date().toISOString() };
61
61
  }
62
62
 
63
+ // A day alone ("2026-09-28", from a `date` column) is that day's local
64
+ // midnight: new Date() would read it as UTC's, the day before west of
65
+ // Greenwich. Written out here, not the kit's toLocalDate: these helpers
66
+ // import nothing, so they run anywhere.
67
+ const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
68
+
63
69
  /** "Sep 23, 2026, 2:04 PM" in the reader's own locale. */
64
70
  export function formatSignedAt(iso, locale) {
65
- const d = new Date(iso);
71
+ const day = typeof iso === "string" ? DAY.exec(iso) : null;
72
+ const d = day ? new Date(+day[1], +day[2] - 1, +day[3]) : new Date(iso);
66
73
  if (Number.isNaN(d.getTime())) return "";
67
74
  return d.toLocaleString(locale, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
68
75
  }
@@ -5,7 +5,7 @@
5
5
  // type's `editor` name. A custom type (the `types` prop) may bring its own
6
6
  // `render(value, column, ctx)` and `Editor` component.
7
7
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
8
- import { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, usePopoverPosition } from "../../ui";
8
+ import { Avatar, AvatarGroup, Badge, Calendar, Command, Icon, Rating, cx, panelClass, todayString, usePopoverPosition } from "../../ui";
9
9
  import { optionOf, optionValue } from "./types";
10
10
 
11
11
  const asList = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]);
@@ -303,7 +303,7 @@ function DateEditor({ value, column, type, ctx, initial, today, onCommit, onCanc
303
303
  <button
304
304
  type="button"
305
305
  className="rounded-sm px-1.5 py-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
306
- onClick={() => onCommit(today ?? new Date().toISOString().slice(0, 10), null)}
306
+ onClick={() => onCommit(today ?? todayString(), null)}
307
307
  >
308
308
  Today
309
309
  </button>
@@ -56,6 +56,7 @@ app/DESIGN.md the design, written before the code (§3)
56
56
  surfaces/<key>.jsx one file per domain surface
57
57
  surfaces.js the manifest entries (import + component when built)
58
58
  components/*.jsx anything shared between surfaces
59
+ components/*-actions.js your own server actions, for a write lib/actions.js cannot make (§5)
59
60
  components/depictions/ pictures of things that exist elsewhere, in their own colours (§3c)
60
61
  ```
61
62
 
@@ -67,7 +68,7 @@ app/globals.css the workspace theme's tokens (§1) — no palette of your
67
68
  app/page.jsx / → the home surface (or "No surfaces yet" while the manifest is empty)
68
69
  app/s/[key]/page.jsx renders the surface with that key; planned ones say so
69
70
  app/error.jsx, app/global-error.jsx, app/not-found.jsx, app/error-relay.jsx, app/auto-refresh.jsx,
70
- app/escape-relay.jsx, app/frame.js, app/claw-alive/route.js the app inside its window: refresh while shown, Escape from the desk
71
+ app/escape-relay.jsx, app/frame.js, app/claw-alive/route.js the app inside its window: refresh while shown (a surface opts out, §7), Escape from the desk
71
72
  components/ui/ the kit: Button, Field, Input, Select, Sheet, Tabs, Menu, Notice, Icon, Depiction… (§3b, §3c)
72
73
  components/parts/ the parts: RecordSwitcher, LiveEmpty + LiveWork, useFixture + FixtureNotice (§3a, §3b)
73
74
  check/pins.json the fixed files by hash; `npm run check` refuses one that differs
@@ -194,7 +195,7 @@ import { Button, IconButton, ButtonGroup, ButtonGroupSeparator, Toggle, ToggleGr
194
195
  Field, Input, TimeInput, Textarea, Select, Switch, Checkbox, RadioGroup, Radio, Rating,
195
196
  InputGroup, PasswordInput, InputOTP, Slider, Image, FileInput, FileDrop, fileMatches, Combobox, Command, CommandDialog, Calendar, DatePicker, DateInput, DATE_PRESETS,
196
197
  parseDate, isDateString, toDateString, todayString, addDays, addMonths,
197
- startOfMonth, endOfMonth, dayOfWeek, formatDate, firstDayOfWeek,
198
+ startOfMonth, endOfMonth, dayOfWeek, formatDate, toLocalDate, firstDayOfWeek,
198
199
  Card, Notice, Badge, Kicker, Empty, Item, ItemGroup, Avatar, AvatarGroup,
199
200
  Sheet, Confirm, Tabs, TabPanel, Menu, ContextMenu, Popover, Tooltip, HoverCard,
200
201
  Breadcrumb, Pagination, Collapsible, Accordion, Carousel, ScrollArea,
@@ -218,8 +219,10 @@ import { RecordSwitcher, LiveEmpty, LiveWork, liveWords, useFixture, FixtureNoti
218
219
  `IconButton` for an icon alone (`label` required). `loading` while its
219
220
  action runs: a spinner, the same width, `aria-busy`, disabled.
220
221
  - `Field` wraps `Input`, `Textarea`, `Select`, `Switch`, `Checkbox` with
221
- its label, hint and error. `Select` with empty `options` renders a
222
- muted line, never a disabled dropdown. `TimeInput` — a time of day
222
+ its label, hint and error. An `Input` is as wide as its column; a width
223
+ in its `className` (`w-24`, `w-auto`) sets its own. `Select` with
224
+ empty `options` renders a muted line, never a disabled dropdown.
225
+ `TimeInput` — a time of day
223
226
  ("HH:MM"; `min max step`), the kit's clock in place of the browser's.
224
227
  - `Rating` — stars: read-only `value` of `max` (halves drawn, "4.5 out
225
228
  of 5" to a reader), or with `onChange` a radio-group input; `size`, `label`.
@@ -246,8 +249,10 @@ import { RecordSwitcher, LiveEmpty, LiveWork, liveWords, useFixture, FixtureNoti
246
249
  - `Sheet` — the one overlay, a rail beside the work (`side="end"`), a
247
250
  navigation drawer (`start`), a dialog (`center`) or a bottom sheet; on a narrow window every side
248
251
  becomes a bottom sheet; `bare` + `label` for a panel that draws its own
249
- top (no header, flush body). `Confirm` is the "delete this?" case;
250
- `consequences`, `requireText` and `acknowledge` hold it until met.
252
+ top (no header, flush body). A field inside with `autoFocus` takes the
253
+ focus once it opens (since 0.5.0; before, Close had it). `Confirm` is
254
+ the "delete this?" case; `consequences`, `requireText` and
255
+ `acknowledge` hold it until met.
251
256
  - `Image src alt ratio fit rounded` — a picture of a thing (thumbnail,
252
257
  cover): Skeleton while it loads, a glyph when it fails, lazy, `alt`
253
258
  required. Never a raw `<img>`; a face is an `Avatar`.
@@ -280,7 +285,13 @@ import { RecordSwitcher, LiveEmpty, LiveWork, liveWords, useFixture, FixtureNoti
280
285
  `DateInput` (a typed date with the Calendar a button away;
281
286
  `clearable` for an ×). Dates are
282
287
  `"YYYY-MM-DD"` strings in and out — never a Date — and `addDays`,
283
- `addMonths`, `formatDate`… do the arithmetic.
288
+ `addMonths`, `formatDate`… do the arithmetic. A `date` column reads
289
+ as that same string (§4), so a row's day goes into a `DatePicker` and
290
+ back unchanged. Never `new Date("2026-09-28")`: that is UTC's
291
+ midnight, the 27th west of Greenwich, and a form that saves what it
292
+ shows moves the day back. Showing a day beside times (a due date by
293
+ a sent-at), `toLocalDate(value)` gives a Date at that day's LOCAL
294
+ midnight (a timestamp or a Date passes through).
284
295
  - `RadioGroup` (`options` or `<Radio>` children, each with a
285
296
  `description`; `variant` list · card; `orientation`; in a `Field` with
286
297
  the same `id`), `Toggle` (`pressed` + `onPressedChange`, aria-pressed),
@@ -506,7 +517,14 @@ surfaces.js server files only (entries import components)
506
517
  ```
507
518
 
508
519
  Pass plain data across the boundary — strings, numbers, arrays, objects.
509
- `plain(row)` already turns dates into ISO strings. A client component can
520
+ `plain(row)` already turns a timestamp into its ISO string. A `date`
521
+ column is its day, `"2026-09-28"`: text, the same day for every reader,
522
+ from `loadRows` and from `query()` alike (`lib/db.js` reads it so). Up to
523
+ 0.4.x `query()` gave a Date at the server's midnight and the reads an ISO
524
+ timestamp of it, a day off on either side of UTC. A server file that
525
+ called a Date method on a `date` value now holds the day's string:
526
+ compare days as strings (`"2026-09-28" < "2026-10-01"`), and build a
527
+ Date only where a sum needs one. A client component can
510
528
  receive a server action as a prop, and nothing else that is a function.
511
529
 
512
530
  A surface file that uses hooks or event handlers starts with `"use
@@ -636,7 +654,8 @@ const { counts } = await countBy({ source: "public.investors", column: "stage" }
636
654
  const { rows: log } = await loadActivity({ surface: "drafts", limit: 50 });
637
655
  ```
638
656
 
639
- Helpers: `plain(row)` (dates → ISO, buffers → base64), `orderClause(orderBy,
657
+ Helpers: `plain(row)` (timestamps → ISO, buffers → base64; a `date` is
658
+ already its `"YYYY-MM-DD"`), `orderClause(orderBy,
640
659
  fallback)` (validated `ORDER BY` text), `markSample(sample, idColumn)`
641
660
  (stable ids + `_sample: true`), `currentSurface(explicit)` (the surface
642
661
  key of the request, from `x-claw-surface`, unless one is passed).
@@ -661,6 +680,13 @@ saveSetting({ mode = "table" | "json", source, keyColumn = "key", valueColumn =
661
680
  when missing — a plain `key text, value text` table is enough. JSON mode
662
681
  sets `key` inside the jsonb `column` of row `id`.
663
682
 
683
+ Every update (`updateRow`, `moveRow`, `saveSetting`) sets `updated_at =
684
+ now()` when the table has an `updated_at` timestamp it can write (since
685
+ 0.5.0; before, only a trigger of the app's own kept it). A patch that
686
+ sets `updated_at` itself wins, and a column of that name that is text, a
687
+ number or a view's computed value is left alone. An insert leaves it to
688
+ the column's default: `updated_at timestamptz not null default now()`.
689
+
664
690
  ```js
665
691
  "use client";
666
692
  import { moveRow } from "../lib/actions";
@@ -668,8 +694,15 @@ const r = await moveRow({ source: "public.investors", id, stateColumn: "stage",
668
694
  if (!r.ok) setError(r.error);
669
695
  ```
670
696
 
671
- Never write raw SQL from a surface. A write that needs more than these
672
- is a new function in `lib/actions.js` that also calls `logActivity`.
697
+ Never write raw SQL from a surface. `lib/*.js` is fixed (`npm run check`
698
+ refuses an edit, and `makersclaw update` lays the file again), so a write
699
+ these cannot make goes in a server-action file of the app's own:
700
+ `"use server"` on its first line, beside the components that call it
701
+ (`components/deal-actions.js`). There it composes the actions above, or
702
+ runs its UPDATE or DELETE through `query()` from `lib/db.js`, records it
703
+ with `logActivity()` and ends with `revalidatePath("/", "layout")`, as
704
+ `lib/actions.js` does. A new row still goes through `insertRow`: the
705
+ check refuses a raw `INSERT INTO` anywhere outside `lib/`.
673
706
 
674
707
  ### Platform — `lib/platform.js` (browser)
675
708
 
@@ -824,7 +857,11 @@ error, code }` — `needs_answer` while the thread waits on an answer in
824
857
  the column, `queue_full`, `unknown_job`, `no_credits`, …; the panel
825
858
  shows the error). The hook then follows the request — every 2.5 s while
826
859
  the App tab is on screen — until it ends. On the 0.3.x path it resolves
827
- when the job ends, as it always did.
860
+ when the job ends, as it always did. React's development double mount
861
+ (and a fast refresh) cleans the hook up and mounts it again: it follows
862
+ a live request on from there, and an ask it cut off before an answer
863
+ came closes the panel — neither done nor failed. A job started from an
864
+ effect runs twice there; start it from a press.
828
865
 
829
866
  What it holds, for the panel:
830
867
 
@@ -993,7 +1030,8 @@ surface; `--static` skips the build and the smoke. Then, by eye:
993
1030
  - Error state shows the text from `{ error }`.
994
1031
  - Keyboard focus visible on every control.
995
1032
  - Numbers tabular (`data-nums`).
996
- - Every write goes through `lib/actions.js`; no raw SQL from a surface.
1033
+ - Every write is a server action — `lib/actions.js`, or the app's own
1034
+ `"use server"` file (§5) — never raw SQL in a surface.
997
1035
  - Every surface reachable from the nav.
998
1036
 
999
1037
  ## 7. Run
@@ -1018,9 +1056,29 @@ state; a 5xx carries the log tail. Fix it before moving on — the founder
1018
1056
  is watching the preview.
1019
1057
 
1020
1058
  Every page is `dynamic = "force-dynamic"`: nothing is prerendered, so a
1021
- build never needs the database. `app/auto-refresh.jsx` re-renders from
1022
- the server every 5 s while the App tab is on screen and nobody is typing
1023
- (never while a sheet is open, a field they typed in holds focus, or
1024
- within 10 s of a keystroke) and the app answers `/claw-alive` (a fixed
1025
- route) with a 204, so the agent's writes appear. The founder reads "artifact", "app", "agent", "automation" —
1026
- nothing else.
1059
+ build never needs the database. `app/auto-refresh.jsx` re-renders the
1060
+ page from the server while it is shown, so the agent's writes appear:
1061
+ one refresh at a time, 5 s after the last one landed plus what that one
1062
+ took (a minute at most), and only while the App tab is on screen in a
1063
+ visible browser tab (hidden, it asks nothing; back, it refreshes at
1064
+ once), nobody is typing (never while a sheet is open, a field they typed
1065
+ in holds focus, or within 10 s of a keystroke), and the app answers
1066
+ `/claw-alive` (a fixed route) with a 204.
1067
+
1068
+ **A surface that must not be refreshed opts out with
1069
+ `data-claw-refresh="off"` on its root element**: one whose rows only its
1070
+ own writes change, or a table too big to send every few seconds. While
1071
+ any element on the page carries it, nothing is refreshed and nothing is
1072
+ probed; the surface's own writes still re-render it (`lib/actions.js`
1073
+ revalidates), and so does every navigation. Set it from state to pause
1074
+ only while it matters: `data-claw-refresh={importing ? "off" : undefined}`.
1075
+
1076
+ **A page left open stops refreshing after 30 minutes with no sign of
1077
+ anyone** in the app — a key, the pointer, a touch, a wheel, focus. The
1078
+ platform stops an app nobody used for 30 minutes, and every refresh is a
1079
+ use, so an open tab nobody looked at kept its app running all night. The
1080
+ next sign of someone refreshes at once and the refresh goes on; so does
1081
+ coming back to the tab. Nothing on the page says so: the first touch
1082
+ brings it up to date.
1083
+
1084
+ The founder reads "artifact", "app", "agent", "automation" — nothing else.
@@ -1,88 +1,53 @@
1
1
  "use client";
2
2
 
3
- import { useEffect } from "react";
3
+ import { useEffect, useRef, useTransition } from "react";
4
4
  import { useRouter } from "next/navigation";
5
- import { REFRESH_MS, answering, isEditing, probeUrl, shouldRefresh } from "./frame";
5
+ import { refreshLoop } from "./frame";
6
6
 
7
7
  // Rows change under this page from outside it — the agent writes them
8
- // while the founder watches. Re-render from the server every 5 s;
9
- // router.refresh() keeps form state and scroll, a reload would not.
8
+ // while the founder watches. Re-render from the server while the page is
9
+ // shown; router.refresh() keeps form state and scroll, a reload would not.
10
10
  //
11
- // Until 0.3.3 the only gate was document.visibilityState, and in the
12
- // desk it is always "visible": the window keeps the App tab mounted,
13
- // hidden, behind its other tabs, so an app refreshed every 5 s behind
14
- // Runs and Settings, and refreshed under the founder's hands mid-word.
15
- // One 502 on such a refresh (the app restarting) made Next load the
16
- // whole frame again and the half-typed field was gone (landing-page-
17
- // reviewer, okr-tracker, 2026-09-24). Now a tick refreshes only when:
11
+ // The rules are app/frame.js's (refreshLoop, and the reasons with each):
18
12
  //
19
- // - the tab is visible AND the App tab is on screen (an
20
- // IntersectionObserver on the root: app/frame.js says why nothing
21
- // else sees a hidden tab);
22
- // - the founder is not typing: nothing typed in the last 10 s, no
23
- // half-typed field holding focus, no dialog open (app/frame.js,
24
- // isEditing, says what counts and what does not);
25
- // - the app answers app/claw-alive/route.js with a 204 within 4 s:
26
- // while it is down, restarting or unreachable the page stays as it
27
- // is, never reloaded.
28
- //
29
- // The first two are asked again when the answer comes back: typing may
30
- // have started, or the founder may have switched tabs, while it was on
31
- // its way. Coming back on screen refreshes at once, so the rows are
32
- // current.
13
+ // - only while the tab is visible AND the App tab is on screen: no timer
14
+ // at all otherwise, and coming back refreshes at once;
15
+ // - only while someone is there: after 30 minutes with no key, pointer,
16
+ // touch, wheel or focus in the app it stops, so the platform's idle
17
+ // sweep can stop an app left open; the next sign refreshes at once;
18
+ // - only while the founder is not typing and no dialog is open
19
+ // (isEditing), and never while a surface has opted out with
20
+ // data-claw-refresh="off" on its root;
21
+ // - only after app/claw-alive/route.js answers 204 within 4 s: while
22
+ // the app is down, restarting or unreachable the page stays as it is,
23
+ // never reloaded;
24
+ // - one refresh at a time. It runs in a transition, and the next tick is
25
+ // timed only once `isPending` is false again: the interval plus what
26
+ // the last one took, so a slow render is never cut off by the next and
27
+ // a busy stack is asked less often. Until 0.5.0 a refresh fired every
28
+ // 5 s whatever was in flight.
33
29
  export default function AutoRefresh() {
34
30
  const router = useRouter();
35
- useEffect(() => {
36
- let onScreen = true;
37
- let lastInput = -Infinity;
38
- let typed = null; // the field the last `input` event came from
39
- let asking = false;
40
- let stopped = false;
41
-
42
- const ready = () =>
43
- shouldRefresh({
44
- visible: document.visibilityState === "visible",
45
- onScreen,
46
- editing: isEditing(document, typed),
47
- quietFor: Date.now() - lastInput,
48
- });
49
-
50
- const tick = async () => {
51
- if (asking || !ready()) return;
52
- asking = true;
53
- const up = await answering(probeUrl(document));
54
- asking = false;
55
- if (up && !stopped && ready()) router.refresh();
56
- };
31
+ const [pending, startTransition] = useTransition();
32
+ const landed = useRef([]); // the loop's waits for the refresh in flight
57
33
 
58
- const onInput = (event) => {
59
- lastInput = Date.now();
60
- typed = event.target;
61
- };
62
- const onVisible = () => {
63
- if (document.visibilityState === "visible") tick();
64
- };
65
-
66
- let observer = null;
67
- if (typeof IntersectionObserver === "function") {
68
- observer = new IntersectionObserver((entries) => {
69
- const was = onScreen;
70
- onScreen = entries[entries.length - 1].isIntersecting;
71
- if (onScreen && !was) tick();
72
- });
73
- observer.observe(document.documentElement);
74
- }
34
+ // isPending turns false when the refresh's new tree has rendered: that
35
+ // is the refresh landed.
36
+ useEffect(() => {
37
+ if (pending) return;
38
+ for (const done of landed.current.splice(0)) done();
39
+ }, [pending]);
75
40
 
76
- const id = setInterval(tick, REFRESH_MS);
77
- document.addEventListener("visibilitychange", onVisible);
78
- document.addEventListener("input", onInput, true);
79
- return () => {
80
- stopped = true;
81
- clearInterval(id);
82
- observer?.disconnect();
83
- document.removeEventListener("visibilitychange", onVisible);
84
- document.removeEventListener("input", onInput, true);
85
- };
41
+ useEffect(() => {
42
+ const loop = refreshLoop({
43
+ doc: document,
44
+ refresh: () =>
45
+ new Promise((done) => {
46
+ landed.current.push(done);
47
+ startTransition(() => router.refresh());
48
+ }),
49
+ });
50
+ return () => loop.stop();
86
51
  }, [router]);
87
52
  return null;
88
53
  }