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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +4 -4
- package/r/audio-player.json +2 -2
- package/r/auth-form.json +2 -2
- package/r/board.json +2 -2
- package/r/calendar-view.json +9 -9
- package/r/cart.json +2 -2
- package/r/chart.json +7 -7
- package/r/chat.json +3 -3
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +5 -5
- package/r/contact-section.json +2 -2
- package/r/core.json +17 -11
- package/r/csv-import.json +3 -3
- package/r/data-table.json +4 -4
- package/r/diagram.json +2 -2
- package/r/doc-editor.json +2 -2
- package/r/doc-page.json +3 -3
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +2 -2
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +2 -2
- package/r/gantt.json +3 -3
- package/r/job-panel.json +8 -5
- package/r/json-editor.json +3 -3
- package/r/map.json +2 -2
- package/r/menubar.json +2 -2
- package/r/mind-map.json +6 -6
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +2 -2
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +3 -3
- package/r/product-detail.json +2 -2
- package/r/product-grid.json +3 -3
- package/r/record-form.json +2 -2
- package/r/record-notes.json +6 -6
- package/r/registry.json +131 -123
- package/r/search-dialog.json +2 -2
- package/r/settings-list.json +2 -2
- package/r/setup-checklist.json +2 -2
- package/r/share-dialog.json +2 -2
- package/r/sidebar.json +4 -4
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +3 -3
- package/r/task-list.json +3 -3
- package/r/task-model.json +2 -2
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +2 -2
- package/r/whiteboard.json +3 -3
- package/registry/blocks/approval-request/README.md +2 -1
- package/registry/blocks/approval-request/approval-request.jsx +5 -3
- package/registry/blocks/calendar-view/README.md +8 -0
- package/registry/blocks/calendar-view/agenda.jsx +5 -4
- package/registry/blocks/calendar-view/calendar.jsx +23 -8
- package/registry/blocks/calendar-view/demo.jsx +2 -0
- package/registry/blocks/calendar-view/month.jsx +6 -5
- package/registry/blocks/calendar-view/time-grid.jsx +9 -7
- package/registry/blocks/calendar-view/tray.jsx +5 -4
- package/registry/blocks/chart/README.md +9 -4
- package/registry/blocks/chart/cartesian.jsx +31 -24
- package/registry/blocks/chart/core.jsx +21 -0
- package/registry/blocks/chart/pie.jsx +15 -10
- package/registry/blocks/chart/sparkline.jsx +32 -14
- package/registry/blocks/chat/message.jsx +4 -4
- package/registry/blocks/comment-box/README.md +1 -1
- package/registry/blocks/comment-box/comment-time.jsx +4 -6
- package/registry/blocks/data-table/README.md +4 -0
- package/registry/blocks/data-table/data-table.jsx +20 -6
- package/registry/blocks/flow-canvas/edges.jsx +4 -2
- package/registry/blocks/flow-canvas/theme.js +59 -48
- package/registry/blocks/job-panel/README.md +40 -3
- package/registry/blocks/job-panel/block.json +2 -2
- package/registry/blocks/job-panel/demo.jsx +75 -6
- package/registry/blocks/job-panel/job-panel.jsx +60 -11
- package/registry/blocks/mind-map/README.md +9 -2
- package/registry/blocks/mind-map/mind-map.jsx +51 -12
- package/registry/blocks/mind-map/tree.js +33 -0
- package/registry/blocks/org-chart/org-chart.jsx +1 -1
- package/registry/blocks/pdf-viewer/notes.jsx +4 -3
- package/registry/blocks/record-notes/README.md +1 -1
- package/registry/blocks/record-notes/record-notes.jsx +4 -5
- package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
- package/registry/blocks/sidebar/sidebar.jsx +13 -13
- package/registry/blocks/signature-pad/signature.js +8 -1
- package/registry/blocks/spreadsheet/cells.jsx +2 -2
- package/src/shadcn.mjs +1 -1
- package/templates/next/files/README.md +410 -86
- package/templates/next/files/app/auto-refresh.jsx +42 -15
- package/templates/next/files/app/claw-alive/route.js +17 -0
- package/templates/next/files/app/error-relay.jsx +9 -4
- package/templates/next/files/app/escape-relay.jsx +27 -0
- package/templates/next/files/app/frame.js +409 -0
- package/templates/next/files/app/layout.jsx +18 -3
- package/templates/next/files/check/run.mjs +51 -18
- package/templates/next/files/components/parts/fixture.jsx +29 -4
- package/templates/next/files/components/parts/index.js +2 -1
- package/templates/next/files/components/parts/live-work.jsx +235 -0
- package/templates/next/files/components/ui/button.jsx +6 -1
- package/templates/next/files/components/ui/calendar.jsx +24 -0
- package/templates/next/files/components/ui/date-picker.jsx +15 -2
- package/templates/next/files/components/ui/field.jsx +12 -3
- package/templates/next/files/components/ui/icon.jsx +37 -2
- package/templates/next/files/components/ui/index.js +1 -0
- package/templates/next/files/components/ui/sheet.jsx +38 -2
- package/templates/next/files/components/ui/toggle.jsx +20 -2
- package/templates/next/files/lib/actions.js +63 -5
- package/templates/next/files/lib/claw.js +127 -28
- package/templates/next/files/lib/data.js +4 -1
- package/templates/next/files/lib/db.js +18 -1
- package/templates/next/files/lib/hooks.js +493 -37
- package/templates/next/files/lib/platform.js +113 -15
- package/templates/next/files/surfaces/README.md +3 -0
- 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
14
|
-
//
|
|
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)
|
|
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
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
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
|
);
|