makersclaw 0.3.2 → 0.4.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 (81) 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 +2 -2
  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 +3 -3
  9. package/r/cart.json +2 -2
  10. package/r/chart.json +2 -2
  11. package/r/chat.json +2 -2
  12. package/r/code-editor.json +2 -2
  13. package/r/comment-box.json +3 -3
  14. package/r/contact-section.json +2 -2
  15. package/r/core.json +12 -6
  16. package/r/csv-import.json +3 -3
  17. package/r/data-table.json +2 -2
  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 +2 -2
  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 +3 -3
  32. package/r/navigation-menu.json +2 -2
  33. package/r/order-detail.json +2 -2
  34. package/r/org-chart.json +3 -3
  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 +2 -2
  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 +4 -4
  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 +2 -2
  49. package/r/signature-pad.json +2 -2
  50. package/r/sketch-canvas.json +2 -2
  51. package/r/spreadsheet.json +2 -2
  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/job-panel/README.md +40 -3
  59. package/registry/blocks/job-panel/block.json +2 -2
  60. package/registry/blocks/job-panel/demo.jsx +75 -6
  61. package/registry/blocks/job-panel/job-panel.jsx +60 -11
  62. package/src/shadcn.mjs +1 -1
  63. package/templates/next/files/README.md +339 -73
  64. package/templates/next/files/app/auto-refresh.jsx +72 -10
  65. package/templates/next/files/app/claw-alive/route.js +17 -0
  66. package/templates/next/files/app/error-relay.jsx +9 -4
  67. package/templates/next/files/app/escape-relay.jsx +27 -0
  68. package/templates/next/files/app/frame.js +218 -0
  69. package/templates/next/files/app/layout.jsx +18 -3
  70. package/templates/next/files/check/run.mjs +25 -8
  71. package/templates/next/files/components/parts/fixture.jsx +29 -4
  72. package/templates/next/files/components/parts/index.js +2 -1
  73. package/templates/next/files/components/parts/live-work.jsx +235 -0
  74. package/templates/next/files/components/ui/button.jsx +6 -1
  75. package/templates/next/files/components/ui/date-picker.jsx +15 -2
  76. package/templates/next/files/components/ui/icon.jsx +37 -2
  77. package/templates/next/files/lib/claw.js +127 -28
  78. package/templates/next/files/lib/hooks.js +454 -33
  79. package/templates/next/files/lib/platform.js +113 -15
  80. package/templates/next/files/surfaces/README.md +3 -0
  81. package/templates/next/template.json +17 -6
@@ -2,24 +2,86 @@
2
2
 
3
3
  import { useEffect } from "react";
4
4
  import { useRouter } from "next/navigation";
5
+ import { REFRESH_MS, answering, isEditing, probeUrl, shouldRefresh } from "./frame";
5
6
 
6
7
  // Rows change under this page from outside it — the agent writes them
7
- // while the founder watches. Re-render from the server every 5 s while the
8
- // tab is visible; router.refresh() keeps form state and scroll, a reload
9
- // would not. Nothing runs while the tab is hidden.
10
- const EVERY_MS = 5000;
11
-
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.
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:
18
+ //
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.
12
33
  export default function AutoRefresh() {
13
34
  const router = useRouter();
14
35
  useEffect(() => {
15
- const tick = () => {
16
- if (document.visibilityState === "visible") router.refresh();
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
+ };
57
+
58
+ const onInput = (event) => {
59
+ lastInput = Date.now();
60
+ typed = event.target;
61
+ };
62
+ const onVisible = () => {
63
+ if (document.visibilityState === "visible") tick();
17
64
  };
18
- const id = setInterval(tick, EVERY_MS);
19
- document.addEventListener("visibilitychange", tick);
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
+ }
75
+
76
+ const id = setInterval(tick, REFRESH_MS);
77
+ document.addEventListener("visibilitychange", onVisible);
78
+ document.addEventListener("input", onInput, true);
20
79
  return () => {
80
+ stopped = true;
21
81
  clearInterval(id);
22
- document.removeEventListener("visibilitychange", tick);
82
+ observer?.disconnect();
83
+ document.removeEventListener("visibilitychange", onVisible);
84
+ document.removeEventListener("input", onInput, true);
23
85
  };
24
86
  }, [router]);
25
87
  return null;
@@ -0,0 +1,17 @@
1
+ // FIXED — the platform agent does not edit this file.
2
+ //
3
+ // GET <base>/claw-alive → 204, no body. app/auto-refresh.jsx asks it
4
+ // before every refresh: only a 2xx from here means the refresh will get
5
+ // its payload and not reload the frame (app/frame.js, answering, has the
6
+ // story). It renders nothing and reads nothing, so the question costs the
7
+ // app almost nothing; the middleware's token check still runs first, as
8
+ // on every request.
9
+ //
10
+ // A 204 and not the 404 of a path nobody serves: the browser logs every
11
+ // 404 fetch as a console error, and an app that logs one every 5 s reads
12
+ // as broken to anyone checking its console (2026-09-25).
13
+ export const dynamic = "force-dynamic";
14
+
15
+ export function GET() {
16
+ return new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
17
+ }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect } from "react";
4
- import { claw, publishStatus, reportError } from "../lib/claw";
4
+ import { claw, publishDesk, publishStatus, reportError } from "../lib/claw";
5
5
 
6
6
  // Errors React does not catch — a handler throwing, a rejected promise —
7
7
  // still reach Makersclaw: window.onerror and unhandledrejection post them
@@ -10,14 +10,19 @@ import { claw, publishStatus, reportError } from "../lib/claw";
10
10
  //
11
11
  // It also keeps <html data-claw-status> current from _claw/me (on mount
12
12
  // and whenever the tab comes back), so reportError can tag an error
13
- // "preview" while the artifact is a draft. This is the fixed layer's job:
14
- // the shell is the agent's file and may never call useArtifact().
13
+ // "preview" while the artifact is a draft, and <html
14
+ // data-claw-desk-origin>, so a screen's ask can tell the desk which
15
+ // request to open (lib/claw.js, openInDesk) before any ask has answered.
16
+ // This is the fixed layer's job: the shell is the agent's file and may
17
+ // never call useArtifact().
15
18
  export default function ErrorRelay() {
16
19
  useEffect(() => {
17
20
  const controller = new AbortController();
18
21
  const publish = async () => {
19
22
  const r = await claw("me", { signal: controller.signal });
20
- if (r.ok) publishStatus(r.data?.artifact?.status);
23
+ if (!r.ok) return;
24
+ publishStatus(r.data?.artifact?.status);
25
+ publishDesk(r.data?.desk_origin);
21
26
  };
22
27
  const onVisible = () => {
23
28
  if (document.visibilityState === "visible") publish();
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { closeTopLayer, isDeskEscape } from "./frame";
5
+
6
+ // Escape pressed on the desk, for this app. A sheet open in the App tab
7
+ // covers it; the founder clicks the window's bar above it and presses
8
+ // Escape — the key lands in the desk's document, never in this frame,
9
+ // and the sheet stayed open (2026-09-25; app/frame.js has the whole
10
+ // story, and the contract with the desk, whose half is claw's). The desk
11
+ // posts an Escape it did not use itself into the focused app window;
12
+ // this closes the one layer the key would have closed here: the open
13
+ // popover, else the sheet or dialog on top.
14
+ //
15
+ // This is the fixed layer's job, not the kit's: the kit also installs
16
+ // into apps that are not in a Makersclaw window, and the message is the
17
+ // platform's.
18
+ export default function EscapeRelay() {
19
+ useEffect(() => {
20
+ const onMessage = (event) => {
21
+ if (isDeskEscape(event, window)) closeTopLayer(document);
22
+ };
23
+ window.addEventListener("message", onMessage);
24
+ return () => window.removeEventListener("message", onMessage);
25
+ }, []);
26
+ return null;
27
+ }
@@ -0,0 +1,218 @@
1
+ // The app lives in a frame: the App tab of its window on the founder's
2
+ // desk. Two things about that frame are not what a page assumes, and the
3
+ // fixed layer (app/auto-refresh.jsx, app/escape-relay.jsx) answers both
4
+ // with the rules below. They are plain functions over a document so
5
+ // `npm test` in the template can hold them to what was seen.
6
+ //
7
+ // 1. A hidden tab is still "visible". The window keeps the App tab
8
+ // MOUNTED behind Agent, Runs, Activity and Settings (display: none, so
9
+ // switching back never reloads the app), and the Page Visibility API
10
+ // speaks for the whole browser tab, not the frame: document.hidden
11
+ // stays false and innerWidth keeps its last value while the App tab is
12
+ // off. An IntersectionObserver on the root element does see it — it
13
+ // reports "not intersecting" the moment the tab is switched away and
14
+ // "intersecting" when it comes back (measured in the desk's own
15
+ // window, 2026-09-25). `onScreen` below is that answer.
16
+ //
17
+ // 2. Escape pressed on the desk never reaches the app. A sheet opened in
18
+ // the app covers the App tab, the founder clicks the window's bar above
19
+ // it (Running, Reload), and focus is now the desk's: the key goes to
20
+ // the desk's document, the <dialog> in the frame never hears it, and
21
+ // the sheet stays open until the app is loaded again (content-calendar
22
+ // and a podcast guest board, 2026-09-25). The frame boundary is the
23
+ // only thing in the way — and in production the app is on its own
24
+ // host, so nothing but a message crosses it. The contract: the desk
25
+ // hands an Escape it did not use itself to the focused app window as
26
+ // postMessage({ type: "claw:escape" }), and the app closes ONE layer,
27
+ // the one Escape would have closed had it landed here. The desk's
28
+ // half is claw's (its OS shell's Escape, web/src/os/os-keys.ts) and
29
+ // ships apart from this; until it does, no such message arrives and
30
+ // nothing here changes what an app does.
31
+
32
+ /** The message the desk posts into the app's frame for an Escape it did
33
+ * not use itself. */
34
+ export const ESCAPE_MESSAGE = "claw:escape";
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Auto-refresh
38
+
39
+ /** How often the page re-renders from the server while it is shown. */
40
+ export const REFRESH_MS = 5000;
41
+
42
+ /** How long after the last keystroke in a field a refresh still waits:
43
+ * long enough to tab from one field to the next, or to the Save button,
44
+ * without a refresh landing in between. */
45
+ export const QUIET_MS = 10000;
46
+
47
+ /** How long the probe may take before the tick counts the app as down. A
48
+ * proxy that holds the request — a sandbox waking, a stalled connection
49
+ * — would otherwise keep that tick waiting, and skip every tick after
50
+ * it, until the browser gave up on the fetch, minutes later. */
51
+ export const PROBE_TIMEOUT_MS = 4000;
52
+
53
+ // What takes typing. A button, a checkbox or a slider is a click, not a
54
+ // half-typed word, so focus on one does not hold a refresh.
55
+ const NOT_TYPED = new Set(["button", "submit", "reset", "checkbox", "radio", "range", "color", "file", "hidden", "image"]);
56
+
57
+ function takesTyping(el) {
58
+ const tag = String(el.tagName || "").toLowerCase();
59
+ if (tag === "textarea" || tag === "select") return true;
60
+ if (tag === "input") return !NOT_TYPED.has(String(el.type || "text").toLowerCase());
61
+ return Boolean(el.isContentEditable);
62
+ }
63
+
64
+ /** Is the founder in the middle of something a reload would lose?
65
+ *
66
+ * - Focus in the field they typed in last, while it still holds text:
67
+ * the half-typed word. `typed` is that field, the target of the last
68
+ * `input` event (app/auto-refresh.jsx keeps it). Focus alone does not
69
+ * count: the first draft of this rule counted it, and an autofocused
70
+ * search box, the note editor record-notes opens focused, or a caret
71
+ * parked in a doc held every refresh until the founder clicked
72
+ * elsewhere, so the agent's writes never appeared (review before
73
+ * 0.3.3 was published, 2026-09-25). Nothing typed is nothing lost, and
74
+ * an emptied field (a quick-add box its own Enter cleared) holds
75
+ * nothing either.
76
+ * - A dialog open over the page, whatever was done in it: a sheet's form
77
+ * is filled by clicks as much as by keys (a date picked, a menu item
78
+ * chosen), and a click fires no `input`. The rows behind a sheet catch
79
+ * up within a tick of its closing.
80
+ *
81
+ * The typed field is judged by the `input` event, not by value against
82
+ * defaultValue: React keeps a controlled input's value attribute in step
83
+ * with what was typed, so the two always agree there, typed or not. */
84
+ export function isEditing(doc, typed = null) {
85
+ const el = doc.activeElement;
86
+ if (el && el !== doc.body && typed && (el === typed || el.contains?.(typed)) && takesTyping(el)) {
87
+ if (el.isContentEditable || String(el.value ?? "") !== "") return true;
88
+ }
89
+ return Boolean(doc.querySelector("dialog[open]"));
90
+ }
91
+
92
+ /** One tick's decision: refresh only when someone can see the page and is
93
+ * not typing into it. `quietFor` is the time since the last input event
94
+ * (Infinity when there was none). */
95
+ export function shouldRefresh({ visible, onScreen, editing, quietFor }) {
96
+ return Boolean(visible && onScreen && !editing && quietFor >= QUIET_MS);
97
+ }
98
+
99
+ /** What to ask before a refresh: app/claw-alive/route.js, a fixed route
100
+ * that answers 204 with no body — no render, no database. The middleware
101
+ * checks the proxy's token first, as on every request, so a 204 says the
102
+ * page's own request would be answered too.
103
+ *
104
+ * Not a path the app merely 404s. The first draft of 0.3.3 asked for a
105
+ * file under _next/static/ that no build writes and read the 404 as
106
+ * "up"; it worked, but the browser logs every 404 fetch as a console
107
+ * error — twelve red lines a minute in every open app, where a QA pass
108
+ * reads an error seen once as a bug (review before publish,
109
+ * 2026-09-25). A GET, not a HEAD: the platform's product
110
+ * proxy routes GET, POST, PUT, PATCH and DELETE and answers a HEAD with
111
+ * its own 404 without asking the app (measured through the local stack,
112
+ * 2026-09-25). */
113
+ export function probeUrl(doc) {
114
+ const base = doc.documentElement?.getAttribute?.("data-claw-base-path") ?? "";
115
+ return `${base}/claw-alive`;
116
+ }
117
+
118
+ /** Will a refresh be answered by the app? Only a 2xx says so, from the
119
+ * probe route. Everything else is no: a 5xx (the proxy's 502 while the
120
+ * app restarts, its 503 while it wakes or a ship swaps the build), a 401
121
+ * or 404 page from the proxy (a grant gone stale, the app deleted), no
122
+ * answer, or no answer within PROBE_TIMEOUT_MS. A 401 is no for good
123
+ * reason: the reload it would cause follows the proxy's way back for a
124
+ * stale grant, a bounce through the dashboard, and the dashboard refuses
125
+ * to be framed (claw's web/vercel.json). A refresh must not take the
126
+ * frame there on its own.
127
+ *
128
+ * Why ask first: router.refresh() treats any answer that is not its own
129
+ * payload as "this is not my app any more" and loads the whole page
130
+ * again (Next 15, fetch-server-response.js: a non-OK or non-RSC response
131
+ * becomes a browser navigation). In the frame that reload wipes a
132
+ * half-typed field, and one 502 was enough (landing-page-reviewer,
133
+ * okr-tracker, 2026-09-24). So the probe says yes exactly where the
134
+ * refresh would not turn into a reload. Asking first leaves only the
135
+ * moment between the answer and the refresh; while the app is down, the
136
+ * page stays as it is and the next tick asks again. A new build answers
137
+ * too, and the refresh after it loads that build (Next reloads for a
138
+ * changed build id), which is why a refresh waits for the founder to
139
+ * stop typing. */
140
+ export async function answering(url, fetchImpl = globalThis.fetch, timeoutMs = PROBE_TIMEOUT_MS) {
141
+ if (!url || typeof fetchImpl !== "function") return false;
142
+ const init = { cache: "no-store", credentials: "same-origin" };
143
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") init.signal = AbortSignal.timeout(timeoutMs);
144
+ try {
145
+ const res = await fetchImpl(url, init);
146
+ return res.status >= 200 && res.status < 300;
147
+ } catch {
148
+ return false; // no answer, or none in time (the signal's TimeoutError)
149
+ }
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Escape from the desk
154
+
155
+ const matches = (el, selector) => {
156
+ try {
157
+ return typeof el.matches === "function" && el.matches(selector);
158
+ } catch {
159
+ return false; // a selector this browser does not know
160
+ }
161
+ };
162
+
163
+ /** A popover Escape closes: an open auto or hint one (a manual popover —
164
+ * a toast — is closed by its own code, never by Escape). */
165
+ function isOpenPopover(el) {
166
+ const kind = el.getAttribute?.("popover");
167
+ if (kind == null || kind === "manual") return false;
168
+ return matches(el, ":popover-open");
169
+ }
170
+
171
+ /** A dialog Escape closes: an open modal one (the kit's Sheet). */
172
+ function isOpenModal(el) {
173
+ if (!el.open) return false;
174
+ try {
175
+ return el.matches(":modal");
176
+ } catch {
177
+ return true; // a browser without :modal: any open dialog
178
+ }
179
+ }
180
+
181
+ /** The layer Escape would close, or null: popovers before dialogs (a
182
+ * popover is always shown over the dialog it was opened from), and among
183
+ * them the one holding focus, else the last in the document — the most
184
+ * recently opened, as the browser's top layer orders them. */
185
+ export function topLayer(doc) {
186
+ const popovers = [...doc.querySelectorAll("[popover]")].filter(isOpenPopover);
187
+ const dialogs = [...doc.querySelectorAll("dialog[open]")].filter(isOpenModal);
188
+ for (const layers of [popovers, dialogs]) {
189
+ if (!layers.length) continue;
190
+ const focused = doc.activeElement;
191
+ const holding = focused ? layers.filter((l) => l.contains(focused)) : [];
192
+ // The innermost of those that hold focus: the one no other contains.
193
+ const inner = holding.find((l) => !holding.some((o) => o !== l && l.contains(o)));
194
+ return inner ?? layers[layers.length - 1];
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** Close the top layer as Escape would; true when there was one. A dialog
200
+ * gets requestClose() where the browser has it — `cancel` fires first,
201
+ * so a sheet that guards its form is asked, exactly as by the key. */
202
+ export function closeTopLayer(doc) {
203
+ const layer = topLayer(doc);
204
+ if (!layer) return false;
205
+ if (layer.tagName?.toLowerCase() === "dialog") {
206
+ if (typeof layer.requestClose === "function") layer.requestClose();
207
+ else layer.close();
208
+ } else {
209
+ layer.hidePopover();
210
+ }
211
+ return true;
212
+ }
213
+
214
+ /** Is this message the desk's Escape? Only the window around the frame
215
+ * may send it. */
216
+ export function isDeskEscape(event, win) {
217
+ return Boolean(win && win.parent && win.parent !== win && event?.source === win.parent && event?.data?.type === ESCAPE_MESSAGE);
218
+ }
@@ -1,8 +1,10 @@
1
1
  import "./globals.css";
2
2
  import { cookies, headers } from "next/headers";
3
+ import { Examples } from "../components/parts/fixture";
3
4
  import { surfaceNav } from "../surfaces";
4
5
  import AutoRefresh from "./auto-refresh";
5
6
  import ErrorRelay from "./error-relay";
7
+ import EscapeRelay from "./escape-relay";
6
8
  import Shell from "./shell";
7
9
 
8
10
  // FIXED — the platform agent does not edit this file. Design lives in
@@ -29,11 +31,21 @@ export const metadata = { title: process.env.CLAW_PRODUCT || "Artifact" };
29
31
  // with no proxy (local `next dev`, where the link 404s harmlessly). The
30
32
  // href is prefixed by hand: Next's basePath applies to next/link and
31
33
  // assets, not to a literal <link>.
34
+ //
35
+ // Examples (components/parts/fixture.jsx) are off inside a workspace: an
36
+ // installed app never shows example rows. product_run sets
37
+ // CLAW_ARTIFACT_ID for every workspace app; the store's standalone loop
38
+ // has only DATABASE_URL, so its screens still open on their example.
39
+ // CLAW_EXAMPLES=1 forces them on. Read per request (the layout is
40
+ // dynamic), so where the app runs decides, not where it was built. The
41
+ // provider comes straight from its file, not parts/index.js: the layout
42
+ // needs nothing else from the parts.
32
43
  export default async function RootLayout({ children }) {
33
44
  const basePath = process.env.CLAW_BASE_PATH || "";
34
45
  const name = process.env.CLAW_PRODUCT || "Artifact";
35
46
  const wanted = (await headers()).get("x-claw-theme") || (await cookies()).get("claw_theme")?.value;
36
47
  const theme = wanted === "light" ? "light" : "dark";
48
+ const examples = process.env.CLAW_EXAMPLES === "1" || !process.env.CLAW_ARTIFACT_ID;
37
49
  return (
38
50
  <html lang="en" data-claw-base-path={basePath} data-claw-product={name} data-theme={theme}>
39
51
  <head>
@@ -42,9 +54,12 @@ export default async function RootLayout({ children }) {
42
54
  <body>
43
55
  <AutoRefresh />
44
56
  <ErrorRelay />
45
- <Shell name={name} surfaces={surfaceNav()}>
46
- {children}
47
- </Shell>
57
+ <EscapeRelay />
58
+ <Examples allowed={examples}>
59
+ <Shell name={name} surfaces={surfaceNav()}>
60
+ {children}
61
+ </Shell>
62
+ </Examples>
48
63
  </body>
49
64
  </html>
50
65
  );
@@ -48,7 +48,7 @@ const STATIC_ONLY = process.argv.includes("--static");
48
48
  // update` lays both folders again.
49
49
  // Exact names AND extensions: Next resolves .js before .jsx, so a
50
50
  // components/ui/depiction.js beside depiction.jsx would win unseen.
51
- const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:radio|toggle|button-group|avatar|breadcrumb|pagination|button|collapsible|item|table|depiction|feedback|stat|distribution|swatch|steps|separator|scroll-area|field|icon|menu|context-menu|popover|calendar|date-picker|tooltip|notice|sheet|tabs|slider|input-group|image|file-input|input-otp|command|combobox|rating|toast|carousel|resizable)\.jsx|parts\/(?:index|sample)\.js|parts\/(?:fixture|record-switcher)\.jsx)$/;
51
+ const KIT = /^components\/(?:ui\/(?:cx|index)\.js|ui\/(?:radio|toggle|button-group|avatar|breadcrumb|pagination|button|collapsible|item|table|depiction|feedback|stat|distribution|swatch|steps|separator|scroll-area|field|icon|menu|context-menu|popover|calendar|date-picker|tooltip|notice|sheet|tabs|slider|input-group|image|file-input|input-otp|command|combobox|rating|toast|carousel|resizable)\.jsx|parts\/(?:index|sample)\.js|parts\/(?:fixture|record-switcher|live-work)\.jsx)$/;
52
52
 
53
53
  // The pins: sha256 of every fixed file, written by `makersclaw init` and
54
54
  // `makersclaw add` (check/pins.json). Read first, because a BLOCK is
@@ -83,7 +83,9 @@ const SKIP = [
83
83
  KIT,
84
84
  /^app\/globals\.css$/,
85
85
  /^lib\/(db|data|actions|claw|platform|hooks|sample)\.js$/,
86
- /^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh)\.jsx$/,
86
+ /^app\/(layout|page|error|global-error|not-found|error-relay|auto-refresh|escape-relay)\.jsx$/,
87
+ /^app\/frame\.js$/,
88
+ /^app\/claw-alive\/route\.js$/,
87
89
  /^app\/s\/\[key\]\/page\.jsx$/,
88
90
  /^middleware\.js$/,
89
91
  /^next\.config\.mjs$/,
@@ -223,7 +225,10 @@ const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`
223
225
  if (!L.count) ok("controls", "every control is a kit part");
224
226
  }
225
227
 
226
- // 3. The example is a fixture in code — never a row in the shared database.
228
+ // 3. The example is a fixture in code — never a row in the shared database
229
+ // — and since 0.4.0 it shows only outside a workspace: useFixture reads
230
+ // the layout's <Examples allowed>, off in every workspace app, where an
231
+ // empty table shows its live work (LiveEmpty) instead.
227
232
  {
228
233
  const L = limiter("examples");
229
234
  scan(/\bINSERT\s+INTO\b/i, (f, m, line) => L.hit(`${f.path}:${line}`, "insert", "SQL INSERT outside lib/actions.js; insertRow() is the write door, and a fixture is never inserted"), files);
@@ -234,11 +239,14 @@ const ATTRS = String.raw`(?:"[^"]*"|'[^']*'|\{(?:[^{}]|\{[^{}]*\})*\}|[^>"'{])*`
234
239
  // them is the one that must run them through useFixture(). A depiction
235
240
  // never holds one: its rows arrive as props from the surface.
236
241
  const renders = /<\/|\/>/.test(f.code);
237
- const uses = /\buseFixture\(|\bmarkSample\(/.test(f.code);
242
+ // Only useFixture reads the layout's <Examples allowed>: markSample
243
+ // gives rows their ids and nothing else, so a surface that marks its
244
+ // own fixture would show it inside a workspace too.
245
+ const uses = /\buseFixture\(/.test(f.code);
238
246
  const server = /from\s*["']\.\.?\/lib\/data["']/.test(f.code);
239
- if (renders && !uses) L.hit(`${f.path}:${lineOf(f.code, decl.index)}`, "fixture", `${decl[1]} is rendered without useFixture() — the example shows only while the table is empty, and is never saved${DEPICTION.test(f.path) ? " (a depiction takes its rows as props; the surface holds the fixture)" : server ? " (a server surface cannot call it: pass the rows down and let the client component run useFixture)" : ""}`);
247
+ if (renders && !uses) L.hit(`${f.path}:${lineOf(f.code, decl.index)}`, "fixture", `${decl[1]} is rendered without useFixture() — examples show only outside a workspace (useFixture); a workspace app opens empty${DEPICTION.test(f.path) ? " (a depiction takes its rows as props; the surface holds the fixture)" : server ? " (a server surface cannot call it: pass the rows down and let the client component run useFixture)" : ""}`);
240
248
  }
241
- if (!L.count) ok("examples", "fixtures stay in code (useFixture), nothing INSERTs one");
249
+ if (!L.count) ok("examples", "examples show only outside a workspace (useFixture); a workspace app opens empty");
242
250
  }
243
251
 
244
252
  // 4. The manifest names surfaces that exist, once each, under keys that are free.
@@ -270,7 +278,10 @@ const surfaceKeys = [];
270
278
  }
271
279
 
272
280
  // 5. Every icon named exists in the sprite (a depiction's own glyphs are
273
- // not sprite names).
281
+ // not sprite names). The sprite is a kit file, fixed like the rest (the
282
+ // next rule refuses an edit to it), so the way out is another name —
283
+ // until 0.3.3 this line said "Add the glyph there", a door the next
284
+ // rule shuts.
274
285
  {
275
286
  const sprite = readFileSync(join(ROOT, "components/ui/icon.jsx"), "utf8");
276
287
  const names = new Set([...sprite.matchAll(/^\s+"?([a-z][a-z0-9-]*)"?:\s*"/gm)].map((m) => m[1]));
@@ -282,7 +293,7 @@ const surfaceKeys = [];
282
293
  scan(/\bicon(?:=|:\s*)["']([a-z][a-z0-9-]*)["']/, (f, m, line) => check(f, m[1], line));
283
294
  if (missing.size) {
284
295
  const where = [...missing].map(([n, at]) => `"${n}" (${at})`).join(", ");
285
- fail("icons", "sprite", `not in components/ui/icon.jsx: ${where}. Add the glyph there, or pick one of: ${[...names].join(" ")}`);
296
+ fail("icons", "sprite", `not in components/ui/icon.jsx: ${where}. The kit is fixed, so pick one of: ${[...names].join(" ")}`);
286
297
  } else ok("icons", "every icon name exists");
287
298
  }
288
299
 
@@ -625,6 +636,12 @@ async function smoke() {
625
636
  const home = await fetch(`${origin}/`, { redirect: "manual" });
626
637
  results.push(`/ → ${home.status}`);
627
638
  if (!(home.status === 200 || (home.status >= 300 && home.status < 400))) fail("smoke", "/", `answered ${home.status}`);
639
+ // The route app/auto-refresh.jsx asks before every refresh. Anything
640
+ // but its 204 and the app never refreshes again: its rows go stale
641
+ // while the agent writes them, and nothing on the page says so.
642
+ const alive = await fetch(`${origin}/claw-alive`, { redirect: "manual" });
643
+ results.push(`/claw-alive → ${alive.status}`);
644
+ if (alive.status !== 204) fail("smoke", "/claw-alive", `answered ${alive.status}, not 204: app/auto-refresh.jsx will never refresh this app. \`makersclaw update\` restores app/claw-alive/route.js`);
628
645
  for (const key of surfaceKeys) {
629
646
  const res = await fetch(`${origin}/s/${encodeURIComponent(key)}`);
630
647
  const body = await res.text();
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState } from "react";
3
+ import { createContext, useContext, useMemo, useState } from "react";
4
4
  import { markSample } from "./sample";
5
5
  import { Button, Notice } from "../ui";
6
6
 
@@ -20,18 +20,43 @@ import { Button, Notice } from "../ui";
20
20
  //
21
21
  // `rows` keeps its identity between renders (an effect may depend on it);
22
22
  // declare SAMPLE at module level, not inside the component, so it does too.
23
+ //
24
+ // Examples show only OUTSIDE a Makersclaw workspace: the store's authoring
25
+ // loop, a shadcn project. Inside one, an app never shows example data (up
26
+ // to 0.3.x it did, until its table had a row): an empty table stays empty
27
+ // and the screen shows what its agent is doing instead. app/layout.jsx
28
+ // decides it per request and says so through <Examples allowed>; with no
29
+ // provider at all — a shadcn project — the answer is yes. The provider
30
+ // lives here, not in lib/, so the parts still travel as one folder.
23
31
 
24
32
  const NONE = [];
33
+ const nothing = () => {};
34
+
35
+ const ExamplesContext = createContext(true);
36
+
37
+ /** Whether the parts below may show an example. The layout wraps the app
38
+ * in it; `allowed` is false inside a workspace. */
39
+ export function Examples({ allowed, children }) {
40
+ return <ExamplesContext.Provider value={Boolean(allowed)}>{children}</ExamplesContext.Provider>;
41
+ }
42
+
43
+ /** true unless an <Examples allowed={false}> is above. */
44
+ export function useExamples() {
45
+ return useContext(ExamplesContext);
46
+ }
25
47
 
26
48
  export function useFixture(rows, sample, { idColumn = "id" } = {}) {
49
+ const allowed = useExamples();
27
50
  const [cleared, setCleared] = useState(false);
28
- const isFixture = !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;
51
+ const isFixture = allowed && !cleared && (!rows || rows.length === 0) && Array.isArray(sample) && sample.length > 0;
29
52
  const fixtureRows = useMemo(() => markSample(sample, idColumn), [sample, idColumn]);
53
+ // With examples off there is no example to clear or bring back: the
54
+ // real rows, however few, are all a surface gets.
30
55
  return {
31
56
  rows: isFixture ? fixtureRows : rows ?? NONE,
32
57
  isFixture,
33
- clear: () => setCleared(true),
34
- restore: () => setCleared(false),
58
+ clear: allowed ? () => setCleared(true) : nothing,
59
+ restore: allowed ? () => setCleared(false) : nothing,
35
60
  };
36
61
  }
37
62
 
@@ -1,5 +1,6 @@
1
1
  // Parts: the kit's pieces bound to this template's data layer. Each
2
2
  // exists because an app got it wrong once; each carries the behaviour
3
3
  // that makes it right. README §3b.
4
- export { useFixture, isSample, FixtureNotice } from "./fixture";
4
+ export { Examples, useExamples, useFixture, isSample, FixtureNotice } from "./fixture";
5
+ export { LiveWork, LiveEmpty, liveWords } from "./live-work";
5
6
  export { RecordSwitcher } from "./record-switcher";