makersclaw 0.3.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/r/annotate.json +3 -3
  4. package/r/approval-request.json +4 -4
  5. package/r/audio-player.json +2 -2
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +2 -2
  8. package/r/calendar-view.json +9 -9
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +7 -7
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +5 -5
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +17 -11
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +4 -4
  18. package/r/diagram.json +2 -2
  19. package/r/doc-editor.json +2 -2
  20. package/r/doc-page.json +3 -3
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +2 -2
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +2 -2
  26. package/r/gantt.json +3 -3
  27. package/r/job-panel.json +8 -5
  28. package/r/json-editor.json +3 -3
  29. package/r/map.json +2 -2
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +6 -6
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +4 -4
  35. package/r/page-builder.json +2 -2
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +3 -3
  39. package/r/product-detail.json +2 -2
  40. package/r/product-grid.json +3 -3
  41. package/r/record-form.json +2 -2
  42. package/r/record-notes.json +6 -6
  43. package/r/registry.json +131 -123
  44. package/r/search-dialog.json +2 -2
  45. package/r/settings-list.json +2 -2
  46. package/r/setup-checklist.json +2 -2
  47. package/r/share-dialog.json +2 -2
  48. package/r/sidebar.json +4 -4
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +3 -3
  53. package/r/task-list.json +3 -3
  54. package/r/task-model.json +2 -2
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +2 -2
  57. package/r/whiteboard.json +3 -3
  58. package/registry/blocks/approval-request/README.md +2 -1
  59. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  60. package/registry/blocks/calendar-view/README.md +8 -0
  61. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  62. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  63. package/registry/blocks/calendar-view/demo.jsx +2 -0
  64. package/registry/blocks/calendar-view/month.jsx +6 -5
  65. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  66. package/registry/blocks/calendar-view/tray.jsx +5 -4
  67. package/registry/blocks/chart/README.md +9 -4
  68. package/registry/blocks/chart/cartesian.jsx +31 -24
  69. package/registry/blocks/chart/core.jsx +21 -0
  70. package/registry/blocks/chart/pie.jsx +15 -10
  71. package/registry/blocks/chart/sparkline.jsx +32 -14
  72. package/registry/blocks/chat/message.jsx +4 -4
  73. package/registry/blocks/comment-box/README.md +1 -1
  74. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  75. package/registry/blocks/data-table/README.md +4 -0
  76. package/registry/blocks/data-table/data-table.jsx +20 -6
  77. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  78. package/registry/blocks/flow-canvas/theme.js +59 -48
  79. package/registry/blocks/job-panel/README.md +40 -3
  80. package/registry/blocks/job-panel/block.json +2 -2
  81. package/registry/blocks/job-panel/demo.jsx +75 -6
  82. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  83. package/registry/blocks/mind-map/README.md +9 -2
  84. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  85. package/registry/blocks/mind-map/tree.js +33 -0
  86. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  87. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  88. package/registry/blocks/record-notes/README.md +1 -1
  89. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  90. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  91. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  92. package/registry/blocks/signature-pad/signature.js +8 -1
  93. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  94. package/src/shadcn.mjs +1 -1
  95. package/templates/next/files/README.md +410 -86
  96. package/templates/next/files/app/auto-refresh.jsx +42 -15
  97. package/templates/next/files/app/claw-alive/route.js +17 -0
  98. package/templates/next/files/app/error-relay.jsx +9 -4
  99. package/templates/next/files/app/escape-relay.jsx +27 -0
  100. package/templates/next/files/app/frame.js +409 -0
  101. package/templates/next/files/app/layout.jsx +18 -3
  102. package/templates/next/files/check/run.mjs +51 -18
  103. package/templates/next/files/components/parts/fixture.jsx +29 -4
  104. package/templates/next/files/components/parts/index.js +2 -1
  105. package/templates/next/files/components/parts/live-work.jsx +235 -0
  106. package/templates/next/files/components/ui/button.jsx +6 -1
  107. package/templates/next/files/components/ui/calendar.jsx +24 -0
  108. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  109. package/templates/next/files/components/ui/field.jsx +12 -3
  110. package/templates/next/files/components/ui/icon.jsx +37 -2
  111. package/templates/next/files/components/ui/index.js +1 -0
  112. package/templates/next/files/components/ui/sheet.jsx +38 -2
  113. package/templates/next/files/components/ui/toggle.jsx +20 -2
  114. package/templates/next/files/lib/actions.js +63 -5
  115. package/templates/next/files/lib/claw.js +127 -28
  116. package/templates/next/files/lib/data.js +4 -1
  117. package/templates/next/files/lib/db.js +18 -1
  118. package/templates/next/files/lib/hooks.js +493 -37
  119. package/templates/next/files/lib/platform.js +113 -15
  120. package/templates/next/files/surfaces/README.md +3 -0
  121. package/templates/next/template.json +19 -8
@@ -20,6 +20,9 @@ import { Checkbox, Input } from "./field";
20
20
  // press outside still close it. Give it `label`.
21
21
  // label the dialog's name for a screen reader when no `title` shows
22
22
  //
23
+ // A field with autoFocus inside it takes focus once the sheet is open
24
+ // (otherwise the browser gives it to the first control: Close).
25
+ //
23
26
  // Confirm below is the "delete this?" case — never window.confirm().
24
27
 
25
28
  const SIDES = {
@@ -33,6 +36,37 @@ const SIDES = {
33
36
  bottom: "inset-x-0 top-auto bottom-0 w-full max-w-none max-h-[85dvh] rounded-t-xl border-t",
34
37
  };
35
38
 
39
+ // autoFocus inside a Sheet. React focuses an autoFocus field as the field
40
+ // mounts, and the dialog opens after that, in the effect below: a field
41
+ // mounted with the sheet, or long before it (a sheet's body is rendered
42
+ // while it is closed), was focused inside a closed dialog, where focus
43
+ // goes nowhere. showModal() then focused the first thing it could, the
44
+ // header's Close button, and typing in "New piece" went nowhere
45
+ // (weekly-content-report, 2026-09-23; CLAW-FIXES #18). So once it is
46
+ // open, the Sheet puts focus where a field asked for it.
47
+ //
48
+ // Which field: React renders no autofocus attribute in the browser. It
49
+ // keeps a host element's props on the element, under "__reactProps$…"
50
+ // (react-dom's own key since React 17; test:app holds the installed
51
+ // react-dom to that name), so that is read, for the elements React
52
+ // autofocuses. The attribute counts too: a server-rendered field has it.
53
+ const AUTOFOCUSABLE = "input, textarea, select, button, [autofocus]";
54
+
55
+ function reactProps(node) {
56
+ for (const key of Object.keys(node)) if (key.startsWith("__reactProps$")) return node[key];
57
+ return null;
58
+ }
59
+
60
+ /** The field in `dialog` that asked for focus (autoFocus), or null — never
61
+ * one inside a sheet nested in this one, never a disabled one. */
62
+ export function autoFocusTarget(dialog) {
63
+ for (const node of dialog.querySelectorAll(AUTOFOCUSABLE)) {
64
+ if (node.disabled || node.closest("dialog") !== dialog) continue;
65
+ if (node.hasAttribute("autofocus") || reactProps(node)?.autoFocus) return node;
66
+ }
67
+ return null;
68
+ }
69
+
36
70
  export function Sheet({ open, onClose, title, description, side = "end", footer, bare = false, label, className, children }) {
37
71
  const ref = useRef(null);
38
72
  const titleId = useId();
@@ -41,8 +75,10 @@ export function Sheet({ open, onClose, title, description, side = "end", footer,
41
75
  useEffect(() => {
42
76
  const el = ref.current;
43
77
  if (!el) return;
44
- if (open && !el.open) el.showModal();
45
- else if (!open && el.open) el.close();
78
+ if (open && !el.open) {
79
+ el.showModal();
80
+ autoFocusTarget(el)?.focus();
81
+ } else if (!open && el.open) el.close();
46
82
  }, [open]);
47
83
 
48
84
  return (
@@ -88,13 +88,24 @@ const SEG = {
88
88
  sm: { track: "p-0.5", item: "min-h-8 px-2.5", square: "min-h-8 min-w-8 px-0", glyph: 16 },
89
89
  };
90
90
 
91
+ // The group is a scroll container, so its row never makes its parent
92
+ // wider (in a plain grid it pushed the page wider on a phone, 0.5.0).
93
+ // That also takes away the minimums a flex item gets from its content,
94
+ // so each comes back unless the className sizes that axis itself:
95
+ // shrink-0 keeps its width in a flex row (its neighbours give way, as
96
+ // before), min-h-min its height in a flex column.
97
+ const FLEXES = /(?:^|\s)!?(?:w-|flex-|grow|shrink|basis-)/;
98
+ const TALL = /(?:^|\s)!?(?:h-|min-h-|size-)/;
99
+
91
100
  /**
92
101
  * A row of toggles as one control, the segmented look: a muted track,
93
102
  * the pressed items raised. `type` single (exactly one pressed — pressing
94
103
  * it again keeps it; a filter that can be off is `multiple`) · multiple.
95
104
  * `value` is a string (single) or an array (multiple); `onChange` gets
96
105
  * the same shape. md is 44px tall, sm 36px. Stretches when given a width
97
- * (className="w-full"): the items share it.
106
+ * (className="w-full"): the items share it. Never wider than its
107
+ * container: past that it scrolls sideways inside itself (it does not
108
+ * wrap unless className="flex-wrap").
98
109
  *
99
110
  * items [{ value, label, icon?, iconOnly?, disabled? }] — `iconOnly`
100
111
  * shows the icon alone; `label` is then what a screen reader says
@@ -157,7 +168,14 @@ export function ToggleGroup({
157
168
  <div
158
169
  role="group"
159
170
  aria-label={ariaLabel}
160
- className={cx("inline-flex max-w-full gap-1 rounded-md bg-muted", s.track, disabled && "opacity-50", className)}
171
+ className={cx(
172
+ "inline-flex max-w-full gap-1 overflow-x-auto rounded-md bg-muted",
173
+ !FLEXES.test(className ?? "") && "shrink-0",
174
+ !TALL.test(className ?? "") && "min-h-min",
175
+ s.track,
176
+ disabled && "opacity-50",
177
+ className,
178
+ )}
161
179
  >
162
180
  {live.map((it, i) => {
163
181
  const on = isOn(it.value);
@@ -43,18 +43,73 @@ function done() {
43
43
  revalidatePath("/", "layout");
44
44
  }
45
45
 
46
+ // updated_at: a row a write changes says when. Up to 0.4.x updateRow left
47
+ // it alone, so unless a package shipped its own trigger, "changed 3 days
48
+ // ago", a list sorted by updated_at and the per-field merges the basics
49
+ // apps do all read the stamp of an older write (CLAW-FIXES #22). Set only
50
+ // where the table has the column and a write can put now() in it: a
51
+ // timestamp, with or without a zone, that is not a view's computed column.
52
+ // Anything else (a text or a number column of that name) stays the app's.
53
+ // A patch that names updated_at itself wins.
54
+ //
55
+ // The catalog is asked once a minute per table, not on every write: a
56
+ // drag on a board is a write, and the workspace database is across the
57
+ // network. A minute is how long a column added meanwhile (an install
58
+ // widening a shared table) goes unstamped.
59
+ const STAMP = "updated_at";
60
+ const STAMP_TYPES = new Set([1114, 1184]); // timestamp, timestamptz
61
+ const STAMP_TTL_MS = 60_000;
62
+ const stamps = new Map(); // quoted source → { at, has }
63
+
64
+ async function stampsUpdatedAt(source) {
65
+ const table = ident(source);
66
+ const known = stamps.get(table);
67
+ if (known && Date.now() - known.at < STAMP_TTL_MS) return known.has;
68
+ let has = false;
69
+ try {
70
+ // to_regclass reads the quoted name as the UPDATE does: the same
71
+ // search_path, the same case. A domain counts as its base type.
72
+ const { rows } = await query(
73
+ `SELECT coalesce(nullif(t.typbasetype, 0), t.oid) AS type,
74
+ pg_column_is_updatable(a.attrelid, a.attnum, false) AS updatable
75
+ FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
76
+ WHERE a.attrelid = to_regclass($1) AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped`,
77
+ [table, STAMP],
78
+ );
79
+ has = Boolean(rows[0]?.updatable) && STAMP_TYPES.has(Number(rows[0]?.type));
80
+ } catch (error) {
81
+ // Never fail the write for its stamp; ask again next time.
82
+ console.error(`[actions] could not read ${source}'s columns: ${error?.message ?? error}`);
83
+ return false;
84
+ }
85
+ stamps.set(table, { at: Date.now(), has });
86
+ return has;
87
+ }
88
+
89
+ /** ", updated_at = now()" for an UPDATE of `source` that sets `cols`,
90
+ * when the table stamps it and the write does not set it itself; else "". */
91
+ async function stampFor(source, cols) {
92
+ return !cols.includes(STAMP) && (await stampsUpdatedAt(source)) ? `, ${ident(STAMP)} = now()` : "";
93
+ }
94
+
95
+ /** The SET text for `cols` (from $2 on), stamped (above). */
96
+ async function setClause(source, cols) {
97
+ return cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ") + (await stampFor(source, cols));
98
+ }
99
+
46
100
  function fail(error) {
47
101
  const message = error?.message ?? String(error);
48
102
  console.error(`[actions] write failed: ${message}`);
49
103
  return { ok: false, error: message };
50
104
  }
51
105
 
52
- /** Change columns of one row. `patch` is { column: value }. */
106
+ /** Change columns of one row. `patch` is { column: value }; updated_at
107
+ * becomes now() when the table has one (above). */
53
108
  export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data }) {
54
109
  try {
55
110
  const cols = Object.keys(patch ?? {}).filter((c) => patch[c] !== undefined);
56
111
  if (!cols.length) return { ok: false, error: "nothing to change" };
57
- const sets = cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ");
112
+ const sets = await setClause(source, cols);
58
113
  const { rowCount } = await query(
59
114
  `UPDATE ${ident(source)} SET ${sets} WHERE ${ident(idColumn)} = $1`,
60
115
  [id, ...cols.map((c) => patch[c])],
@@ -139,7 +194,8 @@ export async function deleteRow({ source, idColumn = "id", id, surface, detail }
139
194
  * ON CONFLICT, because a settings table made with the `sql` verb usually
140
195
  * has a plain `key text` column and Postgres rejects ON CONFLICT without a
141
196
  * unique index on it. JSON mode sets the key inside the jsonb `column` of
142
- * the row `id` in `source`.
197
+ * the row `id` in `source`. An update stamps updated_at as updateRow's
198
+ * does; the insert leaves it to the column's default.
143
199
  */
144
200
  export async function saveSetting({
145
201
  mode = "table",
@@ -156,15 +212,17 @@ export async function saveSetting({
156
212
  try {
157
213
  if (!key) return { ok: false, error: "no key" };
158
214
  if (mode === "json") {
215
+ const stamp = await stampFor(source, [column]);
159
216
  const { rowCount } = await query(
160
- `UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true) WHERE ${ident(idColumn)} = $1`,
217
+ `UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true)${stamp} WHERE ${ident(idColumn)} = $1`,
161
218
  [id, [key], JSON.stringify(value ?? null)],
162
219
  );
163
220
  if (!rowCount) return { ok: false, error: "row not found" };
164
221
  } else {
165
222
  const stored = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
223
+ const stamp = await stampFor(source, [valueColumn]);
166
224
  const { rowCount } = await query(
167
- `UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2 WHERE ${ident(keyColumn)} = $1`,
225
+ `UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2${stamp} WHERE ${ident(keyColumn)} = $1`,
168
226
  [key, stored],
169
227
  );
170
228
  if (!rowCount) {
@@ -3,18 +3,24 @@
3
3
  // (docs/ARTIFACT-CONTRACTS.md §5). It is reachable from the BROWSER only —
4
4
  // the grant cookie is the identity — so everything here is client-side.
5
5
  //
6
- // Every call resolves; nothing throws. A 404 or a network error means the
7
- // gateway has no such route yet, and comes back as `unavailable: true` so
8
- // a surface renders a quiet "not connected yet" state instead of breaking.
6
+ // Every call resolves; nothing throws. A 404 that names no refusal, or a
7
+ // network error, means the gateway has no such route yet, and comes back
8
+ // as `unavailable: true` so a surface renders a quiet "not connected yet"
9
+ // state instead of breaking. A 404 with a `code` is a route that exists
10
+ // saying no (notFound, below).
9
11
  //
10
- // An app calls four of these. `me` (identity, status) and `events`
11
- // (errors) are plumbing. `chat` and `cancel` are COMMISSIONING — work the
12
- // founder asks for from inside the app, streamed back so they can watch
13
- // it (SOP §2.3); lib/platform.js wraps them as startJob / cancelJob and
14
- // lib/hooks.js as useJob. What an app still never calls is monitoring:
15
- // the OS draws Agent, Runs and Activity as tabs on its window. The rest
16
- // of the gateway's routes (§5) stay reachable through claw() /
17
- // clawStream() for a surface that has a real reason.
12
+ // An app calls a handful of these. `me` (identity, status, the desk's
13
+ // origin) and `events` (errors) are plumbing. `ask` and `requests` are
14
+ // ASKING — a screen asks its agent for work, with a row as context, and
15
+ // the conversation lives in the desk's agent column, not in the app;
16
+ // lib/platform.js wraps them as ask / requests / stopRequest and
17
+ // lib/hooks.js as useJob and useRequests. `chat` and `cancel` are the
18
+ // 0.3.x door to the same work, streamed into the app (startJob /
19
+ // cancelJob): kept for a claw that has no `ask` yet. What an app still
20
+ // never calls is monitoring: the OS draws Agent, Runs and Activity as
21
+ // tabs on its window. The rest of the gateway's routes (§5) stay
22
+ // reachable through claw() / clawStream() for a surface that has a real
23
+ // reason.
18
24
 
19
25
  /** The prefix the app is served under. The <html> attribute is set by the
20
26
  * running server, so it wins over the value inlined at build time. */
@@ -59,20 +65,121 @@ export function errorSource() {
59
65
  return PRELAUNCH.has(clawStatus()) ? "preview" : "runtime";
60
66
  }
61
67
 
62
- async function readError(res) {
68
+ // ---------------------------------------------------------------------------
69
+ // The desk around the frame
70
+ //
71
+ // The app runs in a frame on the founder's desk, and on its own host in
72
+ // production, so nothing but a message crosses to the desk. A screen that
73
+ // asks its agent (lib/platform.js, ask) tells the desk which request to
74
+ // open in its agent column; the desk checks the message came from this
75
+ // frame and this app's origin before it acts (claw's web/src/os/
76
+ // frame-grant.ts, openRequestMessage).
77
+ //
78
+ // The message goes to the desk's exact origin, never "*": "*" hands the
79
+ // request and session ids to whatever page happens to frame the app.
80
+ // The origin comes from the gateway (`desk_origin` on _claw/me and on
81
+ // every ask) and lives on <html data-claw-desk-origin> the way the status
82
+ // does, so any code that asks can post without a component's state.
83
+
84
+ /** The message the app posts to the desk: open this request's
85
+ * conversation in the agent column. */
86
+ export const OPEN_REQUEST_MESSAGE = "claw:open-request";
87
+
88
+ /** An http(s) origin, or null. A URL with a path is cut to its origin; a
89
+ * "*", a relative path or anything else unparseable is refused. */
90
+ function originOf(value) {
91
+ if (typeof value !== "string" || !value) return null;
92
+ try {
93
+ const url = new URL(value);
94
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
95
+ return url.origin;
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ /** Keep the desk's origin, as last heard from the gateway. Anything that
102
+ * is not an origin clears it, as a missing status clears the status. */
103
+ export function publishDesk(origin) {
104
+ if (typeof document === "undefined") return;
105
+ const clean = originOf(origin);
106
+ if (clean) document.documentElement.dataset.clawDeskOrigin = clean;
107
+ else delete document.documentElement.dataset.clawDeskOrigin;
108
+ }
109
+
110
+ export function deskOrigin() {
111
+ if (typeof document === "undefined") return null;
112
+ return originOf(document.documentElement.dataset.clawDeskOrigin);
113
+ }
114
+
115
+ function currentWindow() {
116
+ return typeof window === "undefined" ? undefined : window;
117
+ }
118
+
119
+ /** Is the page inside a frame? Only then is there a desk to talk to. */
120
+ function framed(win) {
121
+ return Boolean(win && win.parent && win.parent !== win);
122
+ }
123
+
124
+ /**
125
+ * Ask the desk to open a request's conversation in its agent column.
126
+ * Posts only when the app is framed and the desk's origin is known, and
127
+ * only to that origin. → true when the message went.
128
+ */
129
+ export function openInDesk({ requestId, sessionId } = {}, win = currentWindow()) {
130
+ const origin = deskOrigin();
131
+ if (!requestId || !origin || !framed(win)) return false;
132
+ try {
133
+ win.parent.postMessage(
134
+ { type: OPEN_REQUEST_MESSAGE, request_id: String(requestId), session_id: sessionId ? String(sessionId) : null },
135
+ origin,
136
+ );
137
+ return true;
138
+ } catch {
139
+ return false; // a parent that will not take messages
140
+ }
141
+ }
142
+
143
+ /** Is this message from the desk, of this type? Only the window around
144
+ * the frame may send one — app/frame.js isDeskEscape, for any type. */
145
+ export function isDeskMessage(event, type, win = currentWindow()) {
146
+ return Boolean(framed(win) && event?.source === win.parent && event?.data?.type === type);
147
+ }
148
+
149
+ /** A refusal's words, and its `code` when the gateway names one
150
+ * (`not_your_thread`, `needs_answer`, …): the code is what a caller
151
+ * branches on, the words are what it shows. */
152
+ async function readFailure(res) {
63
153
  const text = await res.text().catch(() => "");
154
+ let parsed = null;
64
155
  try {
65
- const parsed = JSON.parse(text);
66
- return parsed.error || parsed.message || text || res.statusText;
156
+ parsed = JSON.parse(text);
67
157
  } catch {
68
- return text || res.statusText || `HTTP ${res.status}`;
158
+ /* not JSON: the words are the text */
159
+ }
160
+ if (parsed && typeof parsed === "object") {
161
+ const error = parsed.error || parsed.message || text || res.statusText;
162
+ return typeof parsed.code === "string" && parsed.code ? { error, code: parsed.code } : { error };
69
163
  }
164
+ return { error: text || res.statusText || `HTTP ${res.status}` };
165
+ }
166
+
167
+ /** A 404 is two different answers. With a `code` it is a route that
168
+ * exists refusing (_claw/ask's `unknown_thread`): an error like any
169
+ * other. Without one it is a gateway that has no such route — a claw
170
+ * from before it, or no gateway at all — and comes back `unavailable`,
171
+ * as every 404 did before codes existed. useJob reads that difference
172
+ * to choose between _claw/ask and the 0.3.x _claw/chat. */
173
+ async function notFound(res) {
174
+ const { error, code } = await readFailure(res);
175
+ if (code) return { ok: false, status: 404, error, code, unavailable: false };
176
+ return { ok: false, status: 404, error: "not available yet", unavailable: true };
70
177
  }
71
178
 
72
179
  /**
73
180
  * claw("me") / claw("agent", { method: "PATCH", body: {...} })
74
181
  * → { ok: true, status, data }
75
- * → { ok: false, status, error, unavailable }
182
+ * → { ok: false, status, error, unavailable, code? }
76
183
  */
77
184
  export async function claw(path, { method = "GET", body, signal } = {}) {
78
185
  let res;
@@ -92,12 +199,8 @@ export async function claw(path, { method = "GET", body, signal } = {}) {
92
199
  if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
93
200
  return { ok: false, status: 0, error: "not reachable", unavailable: true };
94
201
  }
95
- if (res.status === 404) {
96
- return { ok: false, status: 404, error: "not available yet", unavailable: true };
97
- }
98
- if (!res.ok) {
99
- return { ok: false, status: res.status, error: await readError(res), unavailable: false };
100
- }
202
+ if (res.status === 404) return notFound(res);
203
+ if (!res.ok) return { ok: false, status: res.status, ...(await readFailure(res)), unavailable: false };
101
204
  const text = await res.text().catch(() => "");
102
205
  let data = null;
103
206
  if (text) {
@@ -155,12 +258,8 @@ export async function clawStream(path, body, onEvent, { signal } = {}) {
155
258
  if (error?.name === "AbortError") return { ok: false, status: 0, error: "aborted", aborted: true };
156
259
  return { ok: false, status: 0, error: "not reachable", unavailable: true };
157
260
  }
158
- if (res.status === 404) {
159
- return { ok: false, status: 404, error: "not available yet", unavailable: true };
160
- }
161
- if (!res.ok) {
162
- return { ok: false, status: res.status, error: await readError(res), unavailable: false };
163
- }
261
+ if (res.status === 404) return notFound(res);
262
+ if (!res.ok) return { ok: false, status: res.status, ...(await readFailure(res)), unavailable: false };
164
263
  if (!res.body) return { ok: false, status: res.status, error: "empty stream" };
165
264
 
166
265
  const reader = res.body.getReader();
@@ -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.