makersclaw 0.6.0 → 0.8.0

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 (152) hide show
  1. package/README.md +56 -9
  2. package/package.json +1 -1
  3. package/r/annotate.json +4 -4
  4. package/r/approval-request.json +3 -3
  5. package/r/audio-player.json +3 -3
  6. package/r/auth-form.json +2 -2
  7. package/r/board.json +4 -4
  8. package/r/calendar-view.json +4 -4
  9. package/r/cart.json +3 -3
  10. package/r/chart.json +6 -6
  11. package/r/chat.json +3 -3
  12. package/r/code-editor.json +4 -4
  13. package/r/comment-box.json +4 -4
  14. package/r/contact-section.json +3 -3
  15. package/r/core.json +10 -4
  16. package/r/csv-import.json +4 -4
  17. package/r/data-table.json +3 -3
  18. package/r/diagram.json +3 -3
  19. package/r/doc-editor.json +4 -4
  20. package/r/doc-page.json +4 -4
  21. package/r/email-render.json +2 -2
  22. package/r/faq.json +2 -2
  23. package/r/file-upload.json +3 -3
  24. package/r/flow-canvas.json +4 -4
  25. package/r/footer.json +3 -3
  26. package/r/gantt.json +4 -4
  27. package/r/job-panel.json +3 -3
  28. package/r/json-editor.json +4 -4
  29. package/r/map.json +5 -5
  30. package/r/menubar.json +2 -2
  31. package/r/mind-map.json +4 -4
  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 +6 -6
  36. package/r/payment-methods.json +2 -2
  37. package/r/pdf-document.json +2 -2
  38. package/r/pdf-viewer.json +6 -6
  39. package/r/product-detail.json +3 -3
  40. package/r/product-grid.json +4 -4
  41. package/r/record-form.json +3 -3
  42. package/r/record-notes.json +5 -5
  43. package/r/registry.json +126 -121
  44. package/r/search-dialog.json +3 -3
  45. package/r/settings-list.json +3 -3
  46. package/r/setup-checklist.json +3 -3
  47. package/r/share-dialog.json +3 -3
  48. package/r/sidebar.json +3 -3
  49. package/r/signature-pad.json +3 -3
  50. package/r/sketch-canvas.json +5 -5
  51. package/r/spreadsheet.json +3 -3
  52. package/r/stat-cards.json +4 -4
  53. package/r/task-list.json +4 -4
  54. package/r/task-model.json +3 -3
  55. package/r/testimonials.json +2 -2
  56. package/r/transaction-list.json +3 -3
  57. package/r/whiteboard.json +4 -4
  58. package/registry/blocks/annotate/README.md +24 -12
  59. package/registry/blocks/approval-request/README.md +15 -8
  60. package/registry/blocks/audio-player/README.md +17 -10
  61. package/registry/blocks/board/README.md +13 -9
  62. package/registry/blocks/board/board.jsx +1 -1
  63. package/registry/blocks/calendar-view/README.md +28 -4
  64. package/registry/blocks/cart/README.md +14 -10
  65. package/registry/blocks/chart/README.md +14 -7
  66. package/registry/blocks/chart/cartesian.jsx +1 -1
  67. package/registry/blocks/chart/core.jsx +33 -23
  68. package/registry/blocks/chart/pie.jsx +1 -1
  69. package/registry/blocks/chat/README.md +47 -14
  70. package/registry/blocks/chat/demo.jsx +2 -2
  71. package/registry/blocks/code-editor/README.md +4 -0
  72. package/registry/blocks/code-editor/code-editor.jsx +1 -1
  73. package/registry/blocks/comment-box/README.md +20 -13
  74. package/registry/blocks/contact-section/README.md +7 -6
  75. package/registry/blocks/csv-import/README.md +34 -12
  76. package/registry/blocks/data-table/README.md +41 -8
  77. package/registry/blocks/diagram/diagram.jsx +1 -1
  78. package/registry/blocks/doc-editor/README.md +18 -6
  79. package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
  80. package/registry/blocks/doc-page/README.md +16 -6
  81. package/registry/blocks/file-upload/README.md +7 -1
  82. package/registry/blocks/flow-canvas/README.md +30 -12
  83. package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
  84. package/registry/blocks/footer/README.md +7 -4
  85. package/registry/blocks/gantt/README.md +18 -12
  86. package/registry/blocks/job-panel/README.md +1 -1
  87. package/registry/blocks/json-editor/README.md +16 -4
  88. package/registry/blocks/map/README.md +19 -3
  89. package/registry/blocks/map/map-view.jsx +1 -1
  90. package/registry/blocks/map/maplibre.js +1 -1
  91. package/registry/blocks/mind-map/README.md +20 -7
  92. package/registry/blocks/org-chart/README.md +14 -9
  93. package/registry/blocks/page-builder/README.md +14 -7
  94. package/registry/blocks/page-builder/demo.jsx +3 -3
  95. package/registry/blocks/page-builder/page-builder.jsx +1 -1
  96. package/registry/blocks/page-builder/page-view.jsx +1 -1
  97. package/registry/blocks/page-builder/page.js +1 -1
  98. package/registry/blocks/pdf-viewer/README.md +26 -21
  99. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
  100. package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
  101. package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
  102. package/registry/blocks/product-detail/README.md +13 -7
  103. package/registry/blocks/product-detail/demo.jsx +1 -1
  104. package/registry/blocks/product-grid/README.md +12 -10
  105. package/registry/blocks/record-form/README.md +26 -9
  106. package/registry/blocks/record-notes/README.md +42 -30
  107. package/registry/blocks/search-dialog/README.md +38 -7
  108. package/registry/blocks/settings-list/README.md +29 -17
  109. package/registry/blocks/setup-checklist/README.md +12 -14
  110. package/registry/blocks/share-dialog/README.md +1 -1
  111. package/registry/blocks/sidebar/README.md +23 -13
  112. package/registry/blocks/signature-pad/README.md +15 -4
  113. package/registry/blocks/sketch-canvas/README.md +25 -10
  114. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
  115. package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
  116. package/registry/blocks/spreadsheet/README.md +17 -8
  117. package/registry/blocks/stat-cards/README.md +17 -8
  118. package/registry/blocks/task-list/README.md +14 -11
  119. package/registry/blocks/task-model/README.md +3 -3
  120. package/registry/blocks/transaction-list/README.md +15 -7
  121. package/registry/blocks/whiteboard/README.md +34 -22
  122. package/src/cli.mjs +33 -6
  123. package/src/install.mjs +285 -26
  124. package/src/registry.mjs +109 -1
  125. package/templates/next/browser/app/auto-refresh.jsx +67 -0
  126. package/templates/next/browser/app/layout.jsx +94 -0
  127. package/templates/next/browser/app/page.jsx +60 -0
  128. package/templates/next/browser/app/s/[key]/page.jsx +95 -0
  129. package/templates/next/browser/lib/actions.js +16 -0
  130. package/templates/next/browser/lib/data.js +43 -0
  131. package/templates/next/files/README.md +1100 -89
  132. package/templates/next/files/app/frame.js +128 -4
  133. package/templates/next/files/app/shell.jsx +54 -37
  134. package/templates/next/files/check/run.mjs +1514 -45
  135. package/templates/next/files/components/ui/index.js +1 -0
  136. package/templates/next/files/components/ui/item.jsx +2 -2
  137. package/templates/next/files/components/ui/layout.jsx +307 -0
  138. package/templates/next/files/components/ui/notice.jsx +1 -1
  139. package/templates/next/files/components/ui/separator.jsx +1 -1
  140. package/templates/next/files/lib/actions.js +170 -51
  141. package/templates/next/files/lib/brain-server.js +96 -0
  142. package/templates/next/files/lib/brain.js +413 -0
  143. package/templates/next/files/lib/claw.js +15 -1
  144. package/templates/next/files/lib/data.js +119 -29
  145. package/templates/next/files/lib/db.js +97 -17
  146. package/templates/next/files/lib/door.js +680 -0
  147. package/templates/next/files/lib/hooks.js +71 -2
  148. package/templates/next/files/lib/platform.js +93 -4
  149. package/templates/next/files/next.config.mjs +36 -2
  150. package/templates/next/files/surfaces.js +3 -2
  151. package/templates/next/template.json +18 -3
  152. package/templates/next/files/schema.sql +0 -22
@@ -48,6 +48,7 @@ export { Stat, StatGroup, Delta, StatusDot, Legend, Meter } from "./stat";
48
48
  export { Steps } from "./steps";
49
49
  export { Separator, Kbd, KbdGroup, AspectRatio } from "./separator";
50
50
  export { ScrollArea } from "./scroll-area";
51
+ export { AppLayout, DocumentLayout } from "./layout";
51
52
  export { Slider } from "./slider";
52
53
  export { InputGroup, PasswordInput } from "./input-group";
53
54
  export { Image } from "./image";
@@ -17,7 +17,7 @@ import { Icon } from "./icon";
17
17
  // meta a count, a date, a Badge — muted, on the right
18
18
  // actions buttons on the far right (IconButton, Menu); they sit above
19
19
  // the row's target, so they click on their own
20
- // href a next/link row · `onClick` a button row · neither: static
20
+ // href a next/link row · `onClick` a button row · neither: a plain row
21
21
  // size default (56px) · sm (44px, for dense lists and rails) ·
22
22
  // lg (64px, roomier: a pipeline's runs, a list read at leisure)
23
23
  // variant plain (no fill) · outline (a bordered card) · muted (a filled panel)
@@ -58,7 +58,7 @@ const STACKED = {
58
58
 
59
59
  const TARGET = "outline-none after:absolute after:inset-0";
60
60
 
61
- // The row filled while its target (or a static row itself) is current.
61
+ // The row filled while its target (or a plain row itself) is current.
62
62
  const CURRENT =
63
63
  "has-[[data-item-target][aria-current]:not([aria-current=false])]:bg-accent " +
64
64
  "[&[aria-current]:not([aria-current=false])]:bg-accent";
@@ -0,0 +1,307 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
4
+ import { cx } from "./cx";
5
+ import { Button, IconButton } from "./button";
6
+ import { Sheet } from "./sheet";
7
+
8
+ // Layouts — who owns the window's height, and who scrolls.
9
+ //
10
+ // Every design-first build of 2026-10-07 shipped a shell of `min-h-dvh`
11
+ // that grew with its content: the page itself scrolled, no pane scrolled
12
+ // on its own, and a footer, an inspector or a button below a long
13
+ // article sat past the end of the iframe, where nothing could reach it.
14
+ // So the kit owns the window's height, once, and the app's code never
15
+ // sizes anything by the window (`npm run check` refuses min-h-dvh,
16
+ // h-screen, 100vh, h-[80vh]… outside the kit, rule 12).
17
+ //
18
+ // AppLayout — the app frame. Exactly one owns the viewport: the
19
+ // outermost. A layout inside another fills the slot it is in instead,
20
+ // whatever `height` says, so there is never a second viewport owner. The
21
+ // shell (app/shell.jsx) is the outermost one in every app the template
22
+ // lays; a surface that needs an inspector or a fill of its own returns
23
+ // an AppLayout as its outermost element, and it fills the shell's
24
+ // content edge to edge (the content's padding drops for it).
25
+ //
26
+ // header the top bar, always in view (title, the surface's
27
+ // commands). The bar draws its own bottom border
28
+ // navigation the surfaces, a page tree: a rail beside the content
29
+ // from md (768px); below md a Sheet from the leading
30
+ // edge, opened by the menu button in the top bar. A
31
+ // press on a link or a button inside closes it
32
+ // navigationLabel the Sheet's title and the menu button's words
33
+ // ("Navigation")
34
+ // navigationWidth the rail, in px (176; 160 to 320)
35
+ // children the content. It scrolls inside its own region
36
+ // contentMode scroll (the default): the content region scrolls ·
37
+ // fill: the region is a bounded flex column and the
38
+ // child owns its own scrolling (a canvas, a grid, a
39
+ // spreadsheet, an editor that scrolls itself)
40
+ // contentLabel the content region's name for a screen reader ("Content")
41
+ // contentClassName spacing and token backgrounds for the content
42
+ // region (p-4 sm:p-6, bg-card). Never a height or an
43
+ // overflow: the layout owns those
44
+ // scrollKey when it changes the content scrolls back to its top
45
+ // and the navigation Sheet closes: pass the route
46
+ // (usePathname()), since the frame stays mounted while
47
+ // the surfaces change inside it
48
+ // inspector the details, review or feedback panel beside the
49
+ // content, scrolling on its own
50
+ // inspectorLabel the inspector region's name ("Details")
51
+ // inspectorWidth in px (360; 240 to 560). Builds have needed 300 and 440
52
+ // inspectorAt md · lg (the default) · xl: the width from which the
53
+ // inspector sits beside the content
54
+ // footer under the content, always in view (the save state,
55
+ // the composer, the commands for what is above)
56
+ // height viewport (the default): the frame is the window's
57
+ // height · parent: it fills a bounded parent. Nested,
58
+ // it is parent whatever this says
59
+ // className the frame's own tokens; never a height or an overflow
60
+ //
61
+ // Below `inspectorAt`, where there is no room for two columns:
62
+ // - contentMode scroll: the content and the inspector share ONE scroll
63
+ // region, the inspector after the content. Everything is reachable
64
+ // by scrolling, and nothing is hidden behind a button.
65
+ // - contentMode fill: the content keeps the room and the inspector is
66
+ // a bounded pane under it (at most 45% of the height), scrolling on
67
+ // its own. A fill child has no flow to stack into, and a pane with
68
+ // no bound under it is cut off (workspace-layout's fault, 2026-10-07).
69
+ // The footer stays under both, in view.
70
+ //
71
+ // In print the frame lets go: no bound, no overflow, no navigation, no
72
+ // header, inspector or footer; the content flows onto pages. A page meant
73
+ // for print or export is a DocumentLayout inside the content.
74
+
75
+ const Nested = createContext(false);
76
+
77
+ const px = (value, fallback, min, max) => {
78
+ const n = Number(value);
79
+ return `${Number.isFinite(n) && n > 0 ? Math.min(max, Math.max(min, Math.round(n))) : fallback}px`;
80
+ };
81
+
82
+ // Tailwind reads class names whole from the source, so every breakpoint
83
+ // is written out.
84
+ const BESIDE = {
85
+ md: {
86
+ main: "md:grid md:grid-cols-[minmax(0,1fr)_var(--app-inspector)] md:grid-rows-[minmax(0,1fr)_auto]",
87
+ flow: "md:contents",
88
+ content: "md:col-start-1 md:row-start-1",
89
+ scrolls: "md:min-h-0 md:overflow-y-auto md:overscroll-contain",
90
+ inspector: "md:col-start-2 md:row-span-2 md:row-start-1 md:max-h-none md:min-h-0 md:overflow-y-auto md:border-l md:border-t-0",
91
+ footer: "md:col-start-1 md:row-start-2",
92
+ },
93
+ lg: {
94
+ main: "lg:grid lg:grid-cols-[minmax(0,1fr)_var(--app-inspector)] lg:grid-rows-[minmax(0,1fr)_auto]",
95
+ flow: "lg:contents",
96
+ content: "lg:col-start-1 lg:row-start-1",
97
+ scrolls: "lg:min-h-0 lg:overflow-y-auto lg:overscroll-contain",
98
+ inspector: "lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:max-h-none lg:min-h-0 lg:overflow-y-auto lg:border-l lg:border-t-0",
99
+ footer: "lg:col-start-1 lg:row-start-2",
100
+ },
101
+ xl: {
102
+ main: "xl:grid xl:grid-cols-[minmax(0,1fr)_var(--app-inspector)] xl:grid-rows-[minmax(0,1fr)_auto]",
103
+ flow: "xl:contents",
104
+ content: "xl:col-start-1 xl:row-start-1",
105
+ scrolls: "xl:min-h-0 xl:overflow-y-auto xl:overscroll-contain",
106
+ inspector: "xl:col-start-2 xl:row-span-2 xl:row-start-1 xl:max-h-none xl:min-h-0 xl:overflow-y-auto xl:border-l xl:border-t-0",
107
+ footer: "xl:col-start-1 xl:row-start-2",
108
+ },
109
+ };
110
+
111
+ const REGION = "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";
112
+ const LETS_GO = "print:block print:h-auto print:min-h-0 print:overflow-visible";
113
+
114
+ export function AppLayout({
115
+ header,
116
+ navigation,
117
+ navigationLabel = "Navigation",
118
+ navigationWidth,
119
+ children,
120
+ contentMode = "scroll",
121
+ contentLabel = "Content",
122
+ contentClassName,
123
+ scrollKey,
124
+ inspector,
125
+ inspectorLabel = "Details",
126
+ inspectorWidth,
127
+ inspectorAt = "lg",
128
+ footer,
129
+ height = "viewport",
130
+ className,
131
+ }) {
132
+ const nested = useContext(Nested);
133
+ const bounded = nested || height === "parent";
134
+ const fill = contentMode === "fill";
135
+ const at = BESIDE[inspectorAt] ?? BESIDE.lg;
136
+ const hasInspector = inspector != null && inspector !== false;
137
+ const hasNavigation = navigation != null && navigation !== false;
138
+ const shared = hasInspector && !fill; // below `at`: content and inspector in one scroll region
139
+ const [open, setOpen] = useState(false);
140
+ const flowRef = useRef(null);
141
+ const contentRef = useRef(null);
142
+
143
+ useEffect(() => {
144
+ if (flowRef.current) flowRef.current.scrollTop = 0;
145
+ if (contentRef.current) contentRef.current.scrollTop = 0;
146
+ setOpen(false);
147
+ }, [scrollKey]);
148
+
149
+ return (
150
+ <Nested.Provider value={true}>
151
+ <div
152
+ data-app-layout={bounded ? "parent" : "viewport"}
153
+ style={{ "--app-nav": px(navigationWidth, 176, 160, 320), "--app-inspector": px(inspectorWidth, 360, 240, 560) }}
154
+ className={cx(
155
+ "flex min-w-0 flex-col overflow-hidden bg-background text-foreground",
156
+ bounded ? "h-full min-h-0 flex-1" : "h-dvh",
157
+ LETS_GO,
158
+ className,
159
+ )}
160
+ >
161
+ {header != null && header !== false ? (
162
+ <div data-layout-header className="flex shrink-0 items-center gap-1 border-b border-border print:hidden">
163
+ {hasNavigation ? (
164
+ <IconButton icon="menu" label={`Open ${navigationLabel.toLowerCase()}`} onClick={() => setOpen(true)} className="ml-1 md:hidden" />
165
+ ) : null}
166
+ <div className="min-w-0 flex-1">{header}</div>
167
+ </div>
168
+ ) : hasNavigation ? (
169
+ <div data-layout-header className="flex shrink-0 items-center border-b border-border px-1 py-1 md:hidden print:hidden">
170
+ <Button variant="quiet" size="sm" icon="menu" onClick={() => setOpen(true)}>
171
+ {navigationLabel}
172
+ </Button>
173
+ </div>
174
+ ) : null}
175
+
176
+ {hasNavigation ? (
177
+ <Sheet open={open} onClose={() => setOpen(false)} title={navigationLabel} side="start">
178
+ {open ? (
179
+ <div
180
+ onClick={(e) => {
181
+ if (e.target instanceof Element && e.target.closest("a,button")) setOpen(false);
182
+ }}
183
+ >
184
+ {navigation}
185
+ </div>
186
+ ) : null}
187
+ </Sheet>
188
+ ) : null}
189
+
190
+ <div data-layout-body className={cx("flex min-h-0 min-w-0 flex-1", LETS_GO)}>
191
+ {hasNavigation ? (
192
+ <div
193
+ data-layout-navigation
194
+ className="hidden shrink-0 flex-col overflow-y-auto overscroll-contain border-r border-border md:flex md:w-(--app-nav) print:hidden"
195
+ >
196
+ {navigation}
197
+ </div>
198
+ ) : null}
199
+
200
+ <div data-layout-main className={cx("flex min-h-0 min-w-0 flex-1 flex-col", hasInspector && at.main, LETS_GO)}>
201
+ <div
202
+ ref={flowRef}
203
+ data-layout-flow
204
+ className={cx(
205
+ "flex min-h-0 min-w-0 flex-1 flex-col",
206
+ shared && "overflow-y-auto overscroll-contain",
207
+ hasInspector && at.flow,
208
+ LETS_GO,
209
+ )}
210
+ >
211
+ <div
212
+ ref={contentRef}
213
+ data-layout-content
214
+ role="region"
215
+ aria-label={contentLabel}
216
+ tabIndex={0}
217
+ className={cx(
218
+ "flex min-w-0 flex-col",
219
+ REGION,
220
+ fill
221
+ ? "min-h-0 flex-1 overflow-hidden"
222
+ : shared
223
+ ? cx("shrink-0", at.scrolls)
224
+ : "min-h-0 flex-1 overflow-y-auto overscroll-contain",
225
+ hasInspector && at.content,
226
+ LETS_GO,
227
+ contentClassName,
228
+ // A surface that is a frame of its own runs to the edges.
229
+ "has-[>[data-app-layout]]:p-0",
230
+ )}
231
+ >
232
+ {children}
233
+ </div>
234
+ {hasInspector ? (
235
+ <div
236
+ data-layout-inspector
237
+ role="region"
238
+ aria-label={inspectorLabel}
239
+ tabIndex={0}
240
+ className={cx(
241
+ "min-w-0 shrink-0 border-t border-border print:hidden",
242
+ REGION,
243
+ fill && "max-h-[45%] overflow-y-auto overscroll-contain",
244
+ at.inspector,
245
+ )}
246
+ >
247
+ {inspector}
248
+ </div>
249
+ ) : null}
250
+ </div>
251
+ {footer != null && footer !== false ? (
252
+ <div data-layout-footer className={cx("shrink-0 border-t border-border print:hidden", hasInspector && at.footer)}>
253
+ {footer}
254
+ </div>
255
+ ) : null}
256
+ </div>
257
+ </div>
258
+ </div>
259
+ </Nested.Provider>
260
+ );
261
+ }
262
+
263
+ // DocumentLayout — a page that flows: a report, an invoice, an article
264
+ // meant for print or export. It owns no height and scrolls nothing; it
265
+ // sits in the AppLayout's content (contentMode scroll), which scrolls it,
266
+ // and in print the frame lets go and it flows onto pages.
267
+ //
268
+ // children the document
269
+ // toolbar a bar held at the top while the document scrolls under it
270
+ // (Print, Export, the zoom); never printed
271
+ // width prose (the default: a measure for reading) · page (A4,
272
+ // 210mm) · letter (8.5in) · full. In print it is the paper's
273
+ // paper true: drawn as a sheet on the desk on screen (bg-card, a
274
+ // hairline, padding); plain in print
275
+ // align start (the default) · center
276
+ // label the document's name for a screen reader ("Document")
277
+ // className the document's own spacing and tokens; never a height
278
+ const WIDTHS = {
279
+ prose: "max-w-prose",
280
+ page: "max-w-[210mm]",
281
+ letter: "max-w-[8.5in]",
282
+ full: "max-w-none",
283
+ };
284
+
285
+ export function DocumentLayout({ children, toolbar, width = "prose", paper = false, align = "start", label = "Document", className }) {
286
+ return (
287
+ <div data-document-layout className="flex min-w-0 flex-col">
288
+ {toolbar != null && toolbar !== false ? (
289
+ <div data-document-toolbar className="sticky top-0 z-10 border-b border-border bg-background print:hidden">
290
+ {toolbar}
291
+ </div>
292
+ ) : null}
293
+ <article
294
+ aria-label={label}
295
+ className={cx(
296
+ "w-full min-w-0 print:max-w-none",
297
+ WIDTHS[width] ?? WIDTHS.prose,
298
+ align === "center" && "mx-auto",
299
+ paper && "my-6 rounded-lg border border-border bg-card p-8 text-card-foreground shadow-sm print:my-0 print:rounded-none print:border-0 print:p-0 print:shadow-none",
300
+ className,
301
+ )}
302
+ >
303
+ {children}
304
+ </article>
305
+ </div>
306
+ );
307
+ }
@@ -2,7 +2,7 @@ import { cx } from "./cx";
2
2
  import { IconButton } from "./button";
3
3
  import { Icon } from "./icon";
4
4
 
5
- // Static parts: a card, a notice, a badge, a kicker, the empty state.
5
+ // Plain parts: a card, a notice, a badge, a kicker, the empty state.
6
6
  // Server-safe (no hooks), so a server surface can use them directly.
7
7
 
8
8
  const TONES = {
@@ -1,6 +1,6 @@
1
1
  import { cx } from "./cx";
2
2
 
3
- // Small static parts that shape a layout: a separator, a key cap, a box
3
+ // Small plain parts that shape a layout: a separator, a key cap, a box
4
4
  // that keeps its ratio. Server-safe (no hooks).
5
5
 
6
6
  /**
@@ -2,13 +2,22 @@
2
2
 
3
3
  import { headers } from "next/headers";
4
4
  import { revalidatePath } from "next/cache";
5
- import { ident, logActivity, query } from "./db";
5
+ import { ident, logActivity, query, requestMember, transaction } from "./db";
6
6
 
7
7
  // Every write the app makes. Callable from server and client components
8
8
  // alike (they are server actions). Each one goes through lib/db.js,
9
9
  // records an activity row, and re-renders the app. Table and column names
10
10
  // are quoted through ident(), which refuses anything that is not a plain
11
- // identifier. Returns { ok } or { ok: false, error }; never throws.
11
+ // identifier. Returns { ok } or { ok: false, error, code }; never throws.
12
+ // `code` is the one the platform's data door answers a browser app with
13
+ // (lib/door.js), so a surface branches on it the same way in both modes.
14
+ //
15
+ // Each write runs in its own transaction that first tells the database
16
+ // who is asking (transaction() in lib/db.js: claw.member_id and
17
+ // claw.member_email, local to it), so a trigger or an action function
18
+ // reads the member here as it does in a browser app, behind the platform's
19
+ // door. The activity line is written after the COMMIT, never inside it:
20
+ // a line that fails must not undo the write it describes.
12
21
  //
13
22
  // `surface` is optional: proxy.js stamps the current surface key on
14
23
  // the request, so a surface rarely needs to pass it.
@@ -20,13 +29,12 @@ import { ident, logActivity, query } from "./db";
20
29
  // words. Pass a detail when the surface can say what changed in plain
21
30
  // words; leave it out and the tab shows the action word alone.
22
31
 
32
+ /** The line's `by`, as the door writes it: the member's email, else
33
+ * their id, else unknown (a local run). */
23
34
  async function actor() {
24
- try {
25
- const email = (await headers()).get("x-claw-user-email");
26
- return email ? `user:${email}` : "user:unknown";
27
- } catch {
28
- return "user:unknown";
29
- }
35
+ const { id, email } = await requestMember();
36
+ if (email) return `user:${email}`;
37
+ return id ? `user:${id}` : "user:unknown";
30
38
  }
31
39
 
32
40
  async function surfaceOf(explicit) {
@@ -97,36 +105,88 @@ async function setClause(source, cols) {
97
105
  return cols.map((c, i) => `${ident(c)} = $${i + 2}`).join(", ") + (await stampFor(source, cols));
98
106
  }
99
107
 
100
- function fail(error) {
108
+ // Postgres's raise_exception: what a plpgsql `raise exception` says.
109
+ const REFUSED = "P0001";
110
+
111
+ // The data door's codes for Postgres's errors (claw's STATIC-SPEC §1,
112
+ // "Errors reach the Builder"). Up to 0.6.x a failure here had no code, so
113
+ // a surface that branched on the door's `code` in a browser app fell
114
+ // through to its generic path after `makersclaw mode server`.
115
+ const CODES = new Map([
116
+ [REFUSED, "action_refused"],
117
+ ["23505", "conflict"], // unique
118
+ ["23503", "conflict"], // foreign key
119
+ ["23502", "bad_request"], // not null
120
+ ["22P02", "bad_request"], // not a value of the column's type
121
+ ["22007", "bad_request"], // not a date or time
122
+ ["22008", "bad_request"], // a date or time out of range
123
+ ["57014", "too_slow"],
124
+ ["40001", "try_again"], // serialization
125
+ ["40P01", "try_again"], // deadlock
126
+ ["55P03", "try_again"], // lock not available
127
+ ]);
128
+ const SQLSTATE = /^[0-9A-Z]{5}$/;
129
+
130
+ /** A failure's code, as the door gives it. `missing` names the one error
131
+ * that means the statement's own table or function is not there: 42P01
132
+ * no_such_table for a table's write, 42883 no_such_action for an
133
+ * action. Postgres raises it with no `where` (the context an error from
134
+ * inside a function or a trigger carries); from inside, it is the
135
+ * code's fault, bad_request, as the rest of class 42. ident()'s refusal
136
+ * is bad_request; a connection that failed is failed. */
137
+ function codeOf(error, missing) {
138
+ const state = typeof error?.code === "string" && SQLSTATE.test(error.code) ? error.code : "";
139
+ if (!state) return /^invalid identifier: /.test(error?.message ?? "") ? "bad_request" : "failed";
140
+ if (missing?.[state] && !error.where) return missing[state];
141
+ if (CODES.has(state)) return CODES.get(state);
142
+ return state.startsWith("42") ? "bad_request" : "failed";
143
+ }
144
+
145
+ function fail(error, missing) {
101
146
  const message = error?.message ?? String(error);
102
- console.error(`[actions] write failed: ${message}`);
103
- return { ok: false, error: message };
147
+ const code = codeOf(error, missing);
148
+ // A trigger's own raise exception is a deliberate no, as an action's is
149
+ // (runAction): a warning, or the log tail sends the platform agent after it.
150
+ if (code === "action_refused") console.warn(`[actions] refused: ${message}`);
151
+ else console.error(`[actions] write failed: ${message}`);
152
+ return { ok: false, error: message, code };
104
153
  }
105
154
 
155
+ /** A write refused here, with the door's code for it. */
156
+ const refused = (error, code) => ({ ok: false, error, code });
157
+
158
+ /** What the door reads a table's write failing with, when the table is not there. */
159
+ const NO_TABLE = { "42P01": "no_such_table" };
160
+
106
161
  /** Change columns of one row. `patch` is { column: value }; updated_at
107
- * becomes now() when the table has one (above). */
108
- export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data }) {
162
+ * becomes now() when the table has one (above). `log: false` writes no
163
+ * activity line: for a save that is typing, not a decision (an autosave
164
+ * every few keystrokes would flood the Activity tab). */
165
+ export async function updateRow({ source, idColumn = "id", id, patch, surface, action = "edited", detail, data, log }) {
109
166
  try {
167
+ const table = ident(source);
168
+ const key = ident(idColumn);
110
169
  const cols = Object.keys(patch ?? {}).filter((c) => patch[c] !== undefined);
111
- if (!cols.length) return { ok: false, error: "nothing to change" };
170
+ if (!cols.length) return refused("nothing to change", "bad_request");
112
171
  const sets = await setClause(source, cols);
113
- const { rowCount } = await query(
114
- `UPDATE ${ident(source)} SET ${sets} WHERE ${ident(idColumn)} = $1`,
115
- [id, ...cols.map((c) => patch[c])],
172
+ const { rowCount } = await transaction((q) =>
173
+ q(`UPDATE ${table} SET ${sets} WHERE ${key} = $1`, [id, ...cols.map((c) => patch[c])]),
116
174
  );
117
- if (!rowCount) return { ok: false, error: "row not found" };
118
- await logActivity({
119
- action,
120
- surface: await surfaceOf(surface),
121
- row_ref: id,
122
- detail: detail ?? `${cols.join(", ")} changed`,
123
- data: data ?? patch,
124
- by: await actor(),
125
- });
175
+ if (!rowCount) return refused("row not found", "not_found");
176
+ if (log !== false) {
177
+ await logActivity({
178
+ action,
179
+ surface: await surfaceOf(surface),
180
+ row_ref: id,
181
+ detail: detail ?? `${cols.join(", ")} changed`,
182
+ data: data ?? patch,
183
+ by: await actor(),
184
+ });
185
+ }
126
186
  done();
127
187
  return { ok: true };
128
188
  } catch (error) {
129
- return fail(error);
189
+ return fail(error, NO_TABLE);
130
190
  }
131
191
  }
132
192
 
@@ -148,12 +208,10 @@ export async function moveRow({ source, idColumn = "id", id, stateColumn, to, su
148
208
  export async function insertRow({ source, idColumn = "id", values, surface, action = "added", detail }) {
149
209
  try {
150
210
  const cols = Object.keys(values ?? {}).filter((c) => values[c] !== undefined);
151
- if (!cols.length) return { ok: false, error: "nothing to add" };
211
+ if (!cols.length) return refused("nothing to add", "bad_request");
152
212
  const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
153
- const { rows } = await query(
154
- `INSERT INTO ${ident(source)} (${cols.map(ident).join(", ")}) VALUES (${placeholders}) RETURNING ${ident(idColumn)}`,
155
- cols.map((c) => values[c]),
156
- );
213
+ const text = `INSERT INTO ${ident(source)} (${cols.map(ident).join(", ")}) VALUES (${placeholders}) RETURNING ${ident(idColumn)}`;
214
+ const { rows } = await transaction((q) => q(text, cols.map((c) => values[c])));
157
215
  const id = rows[0]?.[idColumn];
158
216
  await logActivity({
159
217
  action,
@@ -166,14 +224,15 @@ export async function insertRow({ source, idColumn = "id", values, surface, acti
166
224
  done();
167
225
  return { ok: true, id };
168
226
  } catch (error) {
169
- return fail(error);
227
+ return fail(error, NO_TABLE);
170
228
  }
171
229
  }
172
230
 
173
231
  export async function deleteRow({ source, idColumn = "id", id, surface, detail }) {
174
232
  try {
175
- const { rowCount } = await query(`DELETE FROM ${ident(source)} WHERE ${ident(idColumn)} = $1`, [id]);
176
- if (!rowCount) return { ok: false, error: "row not found" };
233
+ const text = `DELETE FROM ${ident(source)} WHERE ${ident(idColumn)} = $1`;
234
+ const { rowCount } = await transaction((q) => q(text, [id]));
235
+ if (!rowCount) return refused("row not found", "not_found");
177
236
  await logActivity({
178
237
  action: "removed",
179
238
  surface: await surfaceOf(surface),
@@ -184,7 +243,7 @@ export async function deleteRow({ source, idColumn = "id", id, surface, detail }
184
243
  done();
185
244
  return { ok: true };
186
245
  } catch (error) {
187
- return fail(error);
246
+ return fail(error, NO_TABLE);
188
247
  }
189
248
  }
190
249
 
@@ -195,7 +254,8 @@ export async function deleteRow({ source, idColumn = "id", id, surface, detail }
195
254
  * has a plain `key text` column and Postgres rejects ON CONFLICT without a
196
255
  * unique index on it. JSON mode sets the key inside the jsonb `column` of
197
256
  * the row `id` in `source`. An update stamps updated_at as updateRow's
198
- * does; the insert leaves it to the column's default.
257
+ * does; the insert leaves it to the column's default. The UPDATE and the
258
+ * INSERT run in one transaction (up to 0.6.x they were two).
199
259
  */
200
260
  export async function saveSetting({
201
261
  mode = "table",
@@ -210,24 +270,21 @@ export async function saveSetting({
210
270
  surface,
211
271
  }) {
212
272
  try {
213
- if (!key) return { ok: false, error: "no key" };
273
+ if (!key) return refused("no key", "bad_request");
214
274
  if (mode === "json") {
275
+ const col = ident(column);
215
276
  const stamp = await stampFor(source, [column]);
216
- const { rowCount } = await query(
217
- `UPDATE ${ident(source)} SET ${ident(column)} = jsonb_set(COALESCE(${ident(column)}, '{}'::jsonb), $2::text[], $3::jsonb, true)${stamp} WHERE ${ident(idColumn)} = $1`,
218
- [id, [key], JSON.stringify(value ?? null)],
219
- );
220
- if (!rowCount) return { ok: false, error: "row not found" };
277
+ const text = `UPDATE ${ident(source)} SET ${col} = jsonb_set(COALESCE(${col}, '{}'::jsonb), $2::text[], $3::jsonb, true)${stamp} WHERE ${ident(idColumn)} = $1`;
278
+ const { rowCount } = await transaction((q) => q(text, [id, [key], JSON.stringify(value ?? null)]));
279
+ if (!rowCount) return refused("row not found", "not_found");
221
280
  } else {
222
281
  const stored = typeof value === "object" && value !== null ? JSON.stringify(value) : value;
282
+ const [table, keyCol, valueCol] = [ident(source), ident(keyColumn), ident(valueColumn)];
223
283
  const stamp = await stampFor(source, [valueColumn]);
224
- const { rowCount } = await query(
225
- `UPDATE ${ident(source)} SET ${ident(valueColumn)} = $2${stamp} WHERE ${ident(keyColumn)} = $1`,
226
- [key, stored],
227
- );
228
- if (!rowCount) {
229
- await query(`INSERT INTO ${ident(source)} (${ident(keyColumn)}, ${ident(valueColumn)}) VALUES ($1, $2)`, [key, stored]);
230
- }
284
+ await transaction(async (q) => {
285
+ const { rowCount } = await q(`UPDATE ${table} SET ${valueCol} = $2${stamp} WHERE ${keyCol} = $1`, [key, stored]);
286
+ if (!rowCount) await q(`INSERT INTO ${table} (${keyCol}, ${valueCol}) VALUES ($1, $2)`, [key, stored]);
287
+ });
231
288
  }
232
289
  await logActivity({
233
290
  action: "edited",
@@ -240,6 +297,68 @@ export async function saveSetting({
240
297
  done();
241
298
  return { ok: true };
242
299
  } catch (error) {
243
- return fail(error);
300
+ return fail(error, NO_TABLE);
301
+ }
302
+ }
303
+
304
+ // An action's name as the door takes it: one bare, lower-case name. The
305
+ // door calls only a function of the app's own schema, so a name with a
306
+ // schema (`app_x.fn`) or capitals (`CloseCycle`), which ident() quotes
307
+ // and Postgres would find, worked here and is no_such_action there.
308
+ // Refused here too, so a surface that works in one mode works in the
309
+ // other.
310
+ const ACTION_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
311
+
312
+ /**
313
+ * Run an action: a function in the app's own schema, `(args jsonb)
314
+ * returns jsonb`, for any write of more than one row, or one that decides
315
+ * something. The screen calls it here; the app's agent runs the same
316
+ * function with `sql` (`select close_cycle($1::jsonb)`), so the two never
317
+ * drift. It runs inside transaction(), after the member is set, so it
318
+ * reads who is asking from `current_setting('claw.member_email', true)`.
319
+ * It says no with `raise exception '<sentence>'`: the sentence comes back
320
+ * as `error`, code action_refused, and the log says it was refused, not
321
+ * that the app failed. A name that is not one bare lower-case name, or no
322
+ * function of it that takes jsonb, is no_such_action, as at the door.
323
+ *
324
+ * The activity line is this call's: `action` defaults to the function's
325
+ * name, `row_ref` is `rowRef` or the result's `id`. The function never
326
+ * writes claw_app.activity itself. `log: false` writes no line (an
327
+ * autosave). `args` defaults to {}.
328
+ * → { ok: true, result } | { ok: false, error, code }
329
+ */
330
+ export async function runAction({ name, args, surface, action, detail, rowRef, log } = {}) {
331
+ if (typeof name !== "string" || !ACTION_NAME.test(name)) {
332
+ const error = `no action ${JSON.stringify(name ?? null)}: an action is named in lower case, a-z, 0-9 and _, with no schema (close_cycle)`;
333
+ console.error(`[actions] ${error}`);
334
+ return refused(error, "no_such_action");
335
+ }
336
+ try {
337
+ const text = `SELECT ${ident(name)}($1::jsonb) AS result`;
338
+ const json = JSON.stringify(args === undefined ? {} : args);
339
+ const { rows } = await transaction((q) => q(text, [json]));
340
+ const result = rows[0]?.result ?? null;
341
+ if (log !== false) {
342
+ const returned = result && typeof result === "object" && !Array.isArray(result) ? result.id : undefined;
343
+ await logActivity({
344
+ action: action || name,
345
+ surface: await surfaceOf(surface),
346
+ row_ref: rowRef ?? returned ?? null,
347
+ detail: detail ?? null,
348
+ by: await actor(),
349
+ });
350
+ }
351
+ done();
352
+ return { ok: true, result };
353
+ } catch (error) {
354
+ // P0001 is the function's own raise exception: a deliberate no ("that
355
+ // cycle is already closed"), which the member acts on. Logged as an
356
+ // error, the log tail would send the platform agent chasing it as a
357
+ // bug, which the door avoids by not recording it either.
358
+ if (error?.code === REFUSED) {
359
+ console.warn(`[actions] ${name} refused: ${error.message}`);
360
+ return refused(error.message, "action_refused");
361
+ }
362
+ return fail(error, { "42883": "no_such_action" });
244
363
  }
245
364
  }