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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/r/annotate.json +3 -3
- package/r/approval-request.json +2 -2
- 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 +3 -3
- package/r/cart.json +2 -2
- package/r/chart.json +2 -2
- package/r/chat.json +2 -2
- package/r/code-editor.json +2 -2
- package/r/comment-box.json +3 -3
- package/r/contact-section.json +2 -2
- package/r/core.json +12 -6
- package/r/csv-import.json +3 -3
- package/r/data-table.json +2 -2
- 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 +2 -2
- 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 +3 -3
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +3 -3
- 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 +2 -2
- 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 +4 -4
- 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 +2 -2
- package/r/signature-pad.json +2 -2
- package/r/sketch-canvas.json +2 -2
- package/r/spreadsheet.json +2 -2
- 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/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/src/shadcn.mjs +1 -1
- package/templates/next/files/README.md +339 -73
- package/templates/next/files/app/auto-refresh.jsx +72 -10
- 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 +218 -0
- package/templates/next/files/app/layout.jsx +18 -3
- package/templates/next/files/check/run.mjs +25 -8
- 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/date-picker.jsx +15 -2
- package/templates/next/files/components/ui/icon.jsx +37 -2
- package/templates/next/files/lib/claw.js +127 -28
- package/templates/next/files/lib/hooks.js +454 -33
- package/templates/next/files/lib/platform.js +113 -15
- package/templates/next/files/surfaces/README.md +3 -0
- 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
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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,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
|
-
<
|
|
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
|
);
|
|
@@ -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
|
-
|
|
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() —
|
|
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", "
|
|
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}.
|
|
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";
|