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
@@ -1,26 +1,53 @@
1
1
  "use client";
2
2
 
3
- import { useEffect } from "react";
3
+ import { useEffect, useRef, useTransition } from "react";
4
4
  import { useRouter } from "next/navigation";
5
+ import { refreshLoop } 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 while the page is
9
+ // shown; router.refresh() keeps form state and scroll, a reload would not.
10
+ //
11
+ // The rules are app/frame.js's (refreshLoop, and the reasons with each):
12
+ //
13
+ // - only while the tab is visible AND the App tab is on screen: no timer
14
+ // at all otherwise, and coming back refreshes at once;
15
+ // - only while someone is there: after 30 minutes with no key, pointer,
16
+ // touch, wheel or focus in the app it stops, so the platform's idle
17
+ // sweep can stop an app left open; the next sign refreshes at once;
18
+ // - only while the founder is not typing and no dialog is open
19
+ // (isEditing), and never while a surface has opted out with
20
+ // data-claw-refresh="off" on its root;
21
+ // - only after app/claw-alive/route.js answers 204 within 4 s: while
22
+ // the app is down, restarting or unreachable the page stays as it is,
23
+ // never reloaded;
24
+ // - one refresh at a time. It runs in a transition, and the next tick is
25
+ // timed only once `isPending` is false again: the interval plus what
26
+ // the last one took, so a slow render is never cut off by the next and
27
+ // a busy stack is asked less often. Until 0.5.0 a refresh fired every
28
+ // 5 s whatever was in flight.
12
29
  export default function AutoRefresh() {
13
30
  const router = useRouter();
31
+ const [pending, startTransition] = useTransition();
32
+ const landed = useRef([]); // the loop's waits for the refresh in flight
33
+
34
+ // isPending turns false when the refresh's new tree has rendered: that
35
+ // is the refresh landed.
36
+ useEffect(() => {
37
+ if (pending) return;
38
+ for (const done of landed.current.splice(0)) done();
39
+ }, [pending]);
40
+
14
41
  useEffect(() => {
15
- const tick = () => {
16
- if (document.visibilityState === "visible") router.refresh();
17
- };
18
- const id = setInterval(tick, EVERY_MS);
19
- document.addEventListener("visibilitychange", tick);
20
- return () => {
21
- clearInterval(id);
22
- document.removeEventListener("visibilitychange", tick);
23
- };
42
+ const loop = refreshLoop({
43
+ doc: document,
44
+ refresh: () =>
45
+ new Promise((done) => {
46
+ landed.current.push(done);
47
+ startTransition(() => router.refresh());
48
+ }),
49
+ });
50
+ return () => loop.stop();
24
51
  }, [router]);
25
52
  return null;
26
53
  }
@@ -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,409 @@
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: the
40
+ * wait between one refresh landing and the next asking. */
41
+ export const REFRESH_MS = 5000;
42
+
43
+ /** The longest wait between two refreshes, however slow the last was. */
44
+ export const MAX_REFRESH_MS = 60000;
45
+
46
+ /** How long a refresh may stay pending before the loop stops waiting for
47
+ * it: a transition a navigation overtook never reports back, and one
48
+ * that never lands must not end the refreshing for good. */
49
+ export const REFRESH_SETTLE_MS = 60000;
50
+
51
+ /** How long a shown page may go without a sign of anyone — a key, the
52
+ * pointer, a touch, a wheel, focus — before the refresh stops. The
53
+ * platform stops an app nobody used for 30 minutes, and every refresh is
54
+ * a use: a tab left open on a screen nobody looks at kept its app, and
55
+ * the sandbox under it, running all night. The same 30 minutes here, so
56
+ * an app left open is an app left alone. */
57
+ export const UNATTENDED_MS = 30 * 60 * 1000;
58
+
59
+ /** What counts as someone there. */
60
+ export const PRESENCE_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "focusin"];
61
+
62
+ /** What a surface puts on its root to keep the page as it is: no refresh,
63
+ * and no probe, while any element on the page carries it. For a surface
64
+ * whose rows only its own writes change, or one too big to send every
65
+ * few seconds (a base of 4,812 records was about 2 MB a refresh). Its
66
+ * writes still re-render it (lib/actions.js revalidates), and so does
67
+ * every navigation. */
68
+ export const REFRESH_OFF = '[data-claw-refresh="off"]';
69
+
70
+ /** How long after the last keystroke in a field a refresh still waits:
71
+ * long enough to tab from one field to the next, or to the Save button,
72
+ * without a refresh landing in between. */
73
+ export const QUIET_MS = 10000;
74
+
75
+ /** How long the probe may take before the tick counts the app as down. A
76
+ * proxy that holds the request — a sandbox waking, a stalled connection
77
+ * — would otherwise keep that tick waiting, and skip every tick after
78
+ * it, until the browser gave up on the fetch, minutes later. */
79
+ export const PROBE_TIMEOUT_MS = 4000;
80
+
81
+ // What takes typing. A button, a checkbox or a slider is a click, not a
82
+ // half-typed word, so focus on one does not hold a refresh.
83
+ const NOT_TYPED = new Set(["button", "submit", "reset", "checkbox", "radio", "range", "color", "file", "hidden", "image"]);
84
+
85
+ function takesTyping(el) {
86
+ const tag = String(el.tagName || "").toLowerCase();
87
+ if (tag === "textarea" || tag === "select") return true;
88
+ if (tag === "input") return !NOT_TYPED.has(String(el.type || "text").toLowerCase());
89
+ return Boolean(el.isContentEditable);
90
+ }
91
+
92
+ /** Is the founder in the middle of something a reload would lose?
93
+ *
94
+ * - Focus in the field they typed in last, while it still holds text:
95
+ * the half-typed word. `typed` is that field, the target of the last
96
+ * `input` event (app/auto-refresh.jsx keeps it). Focus alone does not
97
+ * count: the first draft of this rule counted it, and an autofocused
98
+ * search box, the note editor record-notes opens focused, or a caret
99
+ * parked in a doc held every refresh until the founder clicked
100
+ * elsewhere, so the agent's writes never appeared (review before
101
+ * 0.3.3 was published, 2026-09-25). Nothing typed is nothing lost, and
102
+ * an emptied field (a quick-add box its own Enter cleared) holds
103
+ * nothing either.
104
+ * - A dialog open over the page, whatever was done in it: a sheet's form
105
+ * is filled by clicks as much as by keys (a date picked, a menu item
106
+ * chosen), and a click fires no `input`. The rows behind a sheet catch
107
+ * up within a tick of its closing.
108
+ *
109
+ * The typed field is judged by the `input` event, not by value against
110
+ * defaultValue: React keeps a controlled input's value attribute in step
111
+ * with what was typed, so the two always agree there, typed or not. */
112
+ export function isEditing(doc, typed = null) {
113
+ const el = doc.activeElement;
114
+ if (el && el !== doc.body && typed && (el === typed || el.contains?.(typed)) && takesTyping(el)) {
115
+ if (el.isContentEditable || String(el.value ?? "") !== "") return true;
116
+ }
117
+ return Boolean(doc.querySelector("dialog[open]"));
118
+ }
119
+
120
+ /** One tick's decision: refresh only when someone can see the page, is not
121
+ * typing into it, and no surface on it has opted out. `quietFor` is the
122
+ * time since the last input event (Infinity when there was none). */
123
+ export function shouldRefresh({ visible, onScreen, editing, quietFor, optedOut = false }) {
124
+ return Boolean(visible && onScreen && !editing && !optedOut && quietFor >= QUIET_MS);
125
+ }
126
+
127
+ /** Has a surface on the page opted out (REFRESH_OFF on its root)? */
128
+ export function refreshOptedOut(doc) {
129
+ try {
130
+ return Boolean(doc.querySelector(REFRESH_OFF));
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /** The wait before the next tick: the interval, plus what the last tick
137
+ * cost — its probe and its refresh — so a busy stack is asked less often
138
+ * the busier it is, up to MAX_REFRESH_MS. */
139
+ export function nextRefreshIn(lastCostMs = 0) {
140
+ const cost = Number.isFinite(lastCostMs) && lastCostMs > 0 ? lastCostMs : 0;
141
+ return Math.min(MAX_REFRESH_MS, REFRESH_MS + cost);
142
+ }
143
+
144
+ /** What to ask before a refresh: app/claw-alive/route.js, a fixed route
145
+ * that answers 204 with no body — no render, no database. The middleware
146
+ * checks the proxy's token first, as on every request, so a 204 says the
147
+ * page's own request would be answered too.
148
+ *
149
+ * Not a path the app merely 404s. The first draft of 0.3.3 asked for a
150
+ * file under _next/static/ that no build writes and read the 404 as
151
+ * "up"; it worked, but the browser logs every 404 fetch as a console
152
+ * error — twelve red lines a minute in every open app, where a QA pass
153
+ * reads an error seen once as a bug (review before publish,
154
+ * 2026-09-25). A GET, not a HEAD: the platform's product
155
+ * proxy routes GET, POST, PUT, PATCH and DELETE and answers a HEAD with
156
+ * its own 404 without asking the app (measured through the local stack,
157
+ * 2026-09-25). */
158
+ export function probeUrl(doc) {
159
+ const base = doc.documentElement?.getAttribute?.("data-claw-base-path") ?? "";
160
+ return `${base}/claw-alive`;
161
+ }
162
+
163
+ /** Will a refresh be answered by the app? Only a 2xx says so, from the
164
+ * probe route. Everything else is no: a 5xx (the proxy's 502 while the
165
+ * app restarts, its 503 while it wakes or a ship swaps the build), a 401
166
+ * or 404 page from the proxy (a grant gone stale, the app deleted), no
167
+ * answer, or no answer within PROBE_TIMEOUT_MS. A 401 is no for good
168
+ * reason: the reload it would cause follows the proxy's way back for a
169
+ * stale grant, a bounce through the dashboard, and the dashboard refuses
170
+ * to be framed (claw's web/vercel.json). A refresh must not take the
171
+ * frame there on its own.
172
+ *
173
+ * Why ask first: router.refresh() treats any answer that is not its own
174
+ * payload as "this is not my app any more" and loads the whole page
175
+ * again (Next 15, fetch-server-response.js: a non-OK or non-RSC response
176
+ * becomes a browser navigation). In the frame that reload wipes a
177
+ * half-typed field, and one 502 was enough (landing-page-reviewer,
178
+ * okr-tracker, 2026-09-24). So the probe says yes exactly where the
179
+ * refresh would not turn into a reload. Asking first leaves only the
180
+ * moment between the answer and the refresh; while the app is down, the
181
+ * page stays as it is and the next tick asks again. A new build answers
182
+ * too, and the refresh after it loads that build (Next reloads for a
183
+ * changed build id), which is why a refresh waits for the founder to
184
+ * stop typing. */
185
+ export async function answering(url, fetchImpl = globalThis.fetch, timeoutMs = PROBE_TIMEOUT_MS) {
186
+ if (!url || typeof fetchImpl !== "function") return false;
187
+ const init = { cache: "no-store", credentials: "same-origin" };
188
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") init.signal = AbortSignal.timeout(timeoutMs);
189
+ try {
190
+ const res = await fetchImpl(url, init);
191
+ return res.status >= 200 && res.status < 300;
192
+ } catch {
193
+ return false; // no answer, or none in time (the signal's TimeoutError)
194
+ }
195
+ }
196
+
197
+ /** `promise`, or undefined once `ms` have passed, whichever comes first. */
198
+ function within(promise, ms, timers) {
199
+ let timer = null;
200
+ const late = new Promise((resolve) => (timer = timers.setTimeout(resolve, ms)));
201
+ return Promise.race([Promise.resolve(promise).catch(() => undefined), late]).finally(() => timers.clearTimeout(timer));
202
+ }
203
+
204
+ /**
205
+ * The refresh loop app/auto-refresh.jsx runs, as plain code over a
206
+ * document so `npm test` can drive it. `refresh()` starts one refresh and
207
+ * resolves when it has landed (the component's transition settling);
208
+ * `probe()` is the question above, asked first. → { stop() }
209
+ *
210
+ * Until 0.5.0 a timer fired every REFRESH_MS whatever was in flight: the
211
+ * probe was guarded, the refresh was not, so a render slower than the
212
+ * interval was cut off by the next one — 100+ refreshes in two minutes on
213
+ * an idle page, a 502 or 503 whenever the stack was busy — and a page left
214
+ * open in a hidden tab kept asking, so the platform's idle sweep never
215
+ * stopped its app (install test and basics wave, 2026-09-27). Now:
216
+ *
217
+ * - one tick at a time: the next is timed only once the last refresh
218
+ * has landed, REFRESH_MS plus what the last tick cost (nextRefreshIn);
219
+ * - no timer at all while the page is hidden or behind the window's
220
+ * other tabs; coming back ticks at once, so the rows are current;
221
+ * - none either after UNATTENDED_MS shown with no sign of anyone
222
+ * (PRESENCE_EVENTS, in the frame): the next sign refreshes at once
223
+ * and the loop goes on. Coming back from hidden is such a sign;
224
+ * - a tick refreshes only when shouldRefresh says so, with the probe's
225
+ * 2xx, and never while a surface has opted out (REFRESH_OFF).
226
+ *
227
+ * The rules are asked again after the probe: typing may have started, or
228
+ * the tab changed, while it was on its way.
229
+ */
230
+ export function refreshLoop({
231
+ doc,
232
+ refresh,
233
+ probe = () => answering(probeUrl(doc)),
234
+ now = () => Date.now(),
235
+ timers = globalThis,
236
+ Observer = globalThis.IntersectionObserver,
237
+ }) {
238
+ let onScreen = true;
239
+ let lastInput = -Infinity;
240
+ let typed = null; // the field the last `input` event came from
241
+ let timer = null;
242
+ let busy = false; // a probe or a refresh is out
243
+ let stopped = false;
244
+ let cost = 0; // what the last tick took, probe and refresh
245
+ let lastSeen = now(); // the last sign of someone; opening the page is one
246
+ let unattended = false; // stopped for want of anyone: the next sign ticks
247
+
248
+ const visible = () => doc.visibilityState === "visible";
249
+ const ready = () =>
250
+ shouldRefresh({
251
+ visible: visible(),
252
+ onScreen,
253
+ editing: isEditing(doc, typed),
254
+ quietFor: now() - lastInput,
255
+ optedOut: refreshOptedOut(doc),
256
+ });
257
+
258
+ const cancel = () => {
259
+ timers.clearTimeout(timer);
260
+ timer = null;
261
+ };
262
+ // Timed only while shown and attended: hidden, the loop waits for the
263
+ // page to come back (onVisible, the observer), and unattended for the
264
+ // next sign of someone (seen), rather than for a clock.
265
+ const schedule = () => {
266
+ cancel();
267
+ if (!stopped && !busy && !unattended && visible() && onScreen) timer = timers.setTimeout(tick, nextRefreshIn(cost));
268
+ };
269
+
270
+ async function tick() {
271
+ cancel();
272
+ if (stopped || busy) return;
273
+ if (visible() && onScreen && now() - lastSeen >= UNATTENDED_MS) {
274
+ unattended = true;
275
+ return;
276
+ }
277
+ if (!ready()) return schedule();
278
+ busy = true;
279
+ const started = now();
280
+ try {
281
+ if ((await probe()) && !stopped && ready()) await within(refresh(), REFRESH_SETTLE_MS, timers);
282
+ } catch {
283
+ // A probe or a refresh that throws is one that did not land; the
284
+ // next tick asks again. Never an unhandled rejection: the error
285
+ // relay would report every one to the platform.
286
+ } finally {
287
+ busy = false;
288
+ cost = now() - started;
289
+ }
290
+ schedule();
291
+ }
292
+
293
+ const onInput = (event) => {
294
+ lastInput = now();
295
+ typed = event.target;
296
+ };
297
+ // Someone is there. After a stop for want of anyone, refresh at once.
298
+ const seen = () => {
299
+ lastSeen = now();
300
+ if (!unattended) return;
301
+ unattended = false;
302
+ tick();
303
+ };
304
+ // Back on screen: someone looked, and the rows may be old.
305
+ const back = () => {
306
+ lastSeen = now();
307
+ unattended = false;
308
+ tick();
309
+ };
310
+ const onVisible = () => (visible() ? back() : cancel());
311
+
312
+ let observer = null;
313
+ if (typeof Observer === "function") {
314
+ observer = new Observer((entries) => {
315
+ const was = onScreen;
316
+ onScreen = entries[entries.length - 1].isIntersecting;
317
+ if (onScreen && !was) back();
318
+ else if (!onScreen) cancel();
319
+ });
320
+ observer.observe(doc.documentElement);
321
+ }
322
+ const PASSIVE = { capture: true, passive: true };
323
+ const win = doc.defaultView ?? null;
324
+ doc.addEventListener("visibilitychange", onVisible);
325
+ doc.addEventListener("input", onInput, true);
326
+ for (const type of PRESENCE_EVENTS) doc.addEventListener(type, seen, PASSIVE);
327
+ win?.addEventListener?.("focus", seen);
328
+ schedule();
329
+
330
+ return {
331
+ stop() {
332
+ stopped = true;
333
+ cancel();
334
+ observer?.disconnect();
335
+ doc.removeEventListener("visibilitychange", onVisible);
336
+ doc.removeEventListener("input", onInput, true);
337
+ for (const type of PRESENCE_EVENTS) doc.removeEventListener(type, seen, PASSIVE);
338
+ win?.removeEventListener?.("focus", seen);
339
+ },
340
+ };
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Escape from the desk
345
+
346
+ const matches = (el, selector) => {
347
+ try {
348
+ return typeof el.matches === "function" && el.matches(selector);
349
+ } catch {
350
+ return false; // a selector this browser does not know
351
+ }
352
+ };
353
+
354
+ /** A popover Escape closes: an open auto or hint one (a manual popover —
355
+ * a toast — is closed by its own code, never by Escape). */
356
+ function isOpenPopover(el) {
357
+ const kind = el.getAttribute?.("popover");
358
+ if (kind == null || kind === "manual") return false;
359
+ return matches(el, ":popover-open");
360
+ }
361
+
362
+ /** A dialog Escape closes: an open modal one (the kit's Sheet). */
363
+ function isOpenModal(el) {
364
+ if (!el.open) return false;
365
+ try {
366
+ return el.matches(":modal");
367
+ } catch {
368
+ return true; // a browser without :modal: any open dialog
369
+ }
370
+ }
371
+
372
+ /** The layer Escape would close, or null: popovers before dialogs (a
373
+ * popover is always shown over the dialog it was opened from), and among
374
+ * them the one holding focus, else the last in the document — the most
375
+ * recently opened, as the browser's top layer orders them. */
376
+ export function topLayer(doc) {
377
+ const popovers = [...doc.querySelectorAll("[popover]")].filter(isOpenPopover);
378
+ const dialogs = [...doc.querySelectorAll("dialog[open]")].filter(isOpenModal);
379
+ for (const layers of [popovers, dialogs]) {
380
+ if (!layers.length) continue;
381
+ const focused = doc.activeElement;
382
+ const holding = focused ? layers.filter((l) => l.contains(focused)) : [];
383
+ // The innermost of those that hold focus: the one no other contains.
384
+ const inner = holding.find((l) => !holding.some((o) => o !== l && l.contains(o)));
385
+ return inner ?? layers[layers.length - 1];
386
+ }
387
+ return null;
388
+ }
389
+
390
+ /** Close the top layer as Escape would; true when there was one. A dialog
391
+ * gets requestClose() where the browser has it — `cancel` fires first,
392
+ * so a sheet that guards its form is asked, exactly as by the key. */
393
+ export function closeTopLayer(doc) {
394
+ const layer = topLayer(doc);
395
+ if (!layer) return false;
396
+ if (layer.tagName?.toLowerCase() === "dialog") {
397
+ if (typeof layer.requestClose === "function") layer.requestClose();
398
+ else layer.close();
399
+ } else {
400
+ layer.hidePopover();
401
+ }
402
+ return true;
403
+ }
404
+
405
+ /** Is this message the desk's Escape? Only the window around the frame
406
+ * may send it. */
407
+ export function isDeskEscape(event, win) {
408
+ return Boolean(win && win.parent && win.parent !== win && event?.source === win.parent && event?.data?.type === ESCAPE_MESSAGE);
409
+ }
@@ -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
  );