@1agh/maude 0.36.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -0
  2. package/apps/studio/acp/bootstrap-brief.ts +93 -0
  3. package/apps/studio/acp/bridge.ts +114 -1
  4. package/apps/studio/acp/index.ts +184 -21
  5. package/apps/studio/acp/plugin-bootstrap.ts +115 -0
  6. package/apps/studio/acp/transcript.ts +36 -3
  7. package/apps/studio/activity.ts +45 -0
  8. package/apps/studio/api.ts +265 -47
  9. package/apps/studio/bin/_ensure-browser.mjs +305 -0
  10. package/apps/studio/bin/ensure-browser.sh +26 -0
  11. package/apps/studio/bin/screenshot.sh +33 -8
  12. package/apps/studio/bin/smoke.sh +46 -0
  13. package/apps/studio/canvas-edit.ts +422 -6
  14. package/apps/studio/canvas-lib.tsx +48 -0
  15. package/apps/studio/canvas-shell.tsx +684 -12
  16. package/apps/studio/client/app.jsx +686 -35
  17. package/apps/studio/client/github.js +6 -0
  18. package/apps/studio/client/panels/ChatPanel.jsx +593 -31
  19. package/apps/studio/client/panels/CreateProject.jsx +57 -23
  20. package/apps/studio/client/panels/GitPanel.jsx +1 -1
  21. package/apps/studio/client/panels/IdentityBar.jsx +43 -2
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
  24. package/apps/studio/client/panels/acp-runtime.js +227 -70
  25. package/apps/studio/client/panels/chat-context.js +124 -0
  26. package/apps/studio/client/panels/slash-commands.js +147 -0
  27. package/apps/studio/client/styles/3-shell-maude.css +15 -0
  28. package/apps/studio/client/styles/6-acp-chat.css +244 -0
  29. package/apps/studio/client/tour/collab-tour.js +3 -3
  30. package/apps/studio/commands/reorder-command.ts +77 -0
  31. package/apps/studio/config.schema.json +6 -0
  32. package/apps/studio/dist/client.bundle.js +17 -13
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/github/endpoints.ts +42 -0
  35. package/apps/studio/hmr-broadcast.ts +27 -19
  36. package/apps/studio/http.ts +183 -26
  37. package/apps/studio/inspect.ts +108 -1
  38. package/apps/studio/paths.ts +32 -0
  39. package/apps/studio/readiness.ts +79 -30
  40. package/apps/studio/scaffold-design.ts +95 -2
  41. package/apps/studio/server.ts +11 -2
  42. package/apps/studio/test/acp-activity.test.ts +154 -0
  43. package/apps/studio/test/acp-ai-activity.test.ts +182 -0
  44. package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
  45. package/apps/studio/test/acp-commands.test.ts +108 -0
  46. package/apps/studio/test/acp-origin-gate.test.ts +64 -1
  47. package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
  48. package/apps/studio/test/acp-session-plugins.test.ts +132 -0
  49. package/apps/studio/test/acp-transcript.test.ts +53 -0
  50. package/apps/studio/test/active-state.test.ts +41 -0
  51. package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
  52. package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
  53. package/apps/studio/test/canvas-reorder.test.ts +211 -0
  54. package/apps/studio/test/chat-context.test.ts +129 -0
  55. package/apps/studio/test/csrf-write-guard.test.ts +24 -0
  56. package/apps/studio/test/edit-suppress.test.ts +170 -0
  57. package/apps/studio/test/ensure-browser.test.ts +92 -0
  58. package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
  59. package/apps/studio/test/hmr-broadcast.test.ts +44 -0
  60. package/apps/studio/test/inspect-selections.test.ts +219 -0
  61. package/apps/studio/test/paths.test.ts +57 -0
  62. package/apps/studio/test/readiness.test.ts +83 -13
  63. package/apps/studio/test/reorder-api.test.ts +210 -0
  64. package/apps/studio/test/slash-commands.test.ts +117 -0
  65. package/apps/studio/test/sync-agent.test.ts +9 -2
  66. package/apps/studio/undo-stack.ts +6 -0
  67. package/apps/studio/whats-new.json +63 -0
  68. package/apps/studio/ws.ts +37 -0
  69. package/cli/commands/design.mjs +1 -0
  70. package/package.json +11 -9
  71. package/plugins/design/dependencies.json +3 -3
@@ -0,0 +1,305 @@
1
+ // _ensure-browser.mjs — resolve (and if needed download) a headless Chromium for
2
+ // screenshots. Prints the resolved browser executable path to stdout.
3
+ //
4
+ // Screenshots (agent-browser preferred, playwright fallback) need a Chrome-family
5
+ // engine. On a fresh desktop machine there's no globally-installed browser, so we
6
+ // provision `chrome-headless-shell` — the ~94 MB headless-ONLY Chrome-for-Testing
7
+ // build (vs ~172 MB full Chrome) — on first need into a machine-global Maude
8
+ // cache, and screenshot.sh points `AGENT_BROWSER_EXECUTABLE_PATH` at it. Verified
9
+ // end-to-end: agent-browser + a headless-shell renders a Maude canvas faithfully.
10
+ //
11
+ // Resolution priority (first hit wins):
12
+ // 1. MAUDE_BROWSER_EXECUTABLE / AGENT_BROWSER_EXECUTABLE_PATH override (if valid)
13
+ // 2. a Maude-cached chrome-headless-shell (previous provision)
14
+ // 3. a Playwright chromium-headless-shell already on disk (~/.cache/ms-playwright)
15
+ // 4. system Google Chrome / Chromium
16
+ // 5. download chrome-headless-shell → cache (skipped under --no-download)
17
+ //
18
+ // Flags: --no-download (resolve only — for readiness probes, never triggers a
19
+ // 94 MB fetch) · --json · --quiet.
20
+
21
+ import { execFileSync } from 'node:child_process';
22
+ import {
23
+ chmodSync,
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ renameSync,
28
+ rmSync,
29
+ statSync,
30
+ } from 'node:fs';
31
+ import { homedir, tmpdir } from 'node:os';
32
+ import { basename, join } from 'node:path';
33
+ import { pathToFileURL } from 'node:url';
34
+
35
+ const args = process.argv.slice(2);
36
+ const NO_DOWNLOAD = args.includes('--no-download');
37
+ const JSON_OUT = args.includes('--json');
38
+ const QUIET = args.includes('--quiet') || JSON_OUT;
39
+
40
+ const log = (m) => {
41
+ if (!QUIET) process.stderr.write(`${m}\n`);
42
+ };
43
+
44
+ const CDN_VERSIONS =
45
+ 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json';
46
+
47
+ // The download URL comes from a remote manifest and its bytes are chmod +x'd and
48
+ // executed — pin the scheme + host so a tampered/redirecting manifest can't point
49
+ // us at http:// or a foreign host (defender F2). Chrome-for-Testing artifacts live
50
+ // on storage.googleapis.com; the manifest on googlechromelabs.github.io.
51
+ export const CDN_HOSTS = new Set(['storage.googleapis.com', 'googlechromelabs.github.io']);
52
+ // The CfT version string flows into cache paths and (on Windows) a PowerShell
53
+ // `-Command` string, so validate it before ANY use (defender F3).
54
+ export const CFT_VERSION_RE = /^[0-9]+(?:\.[0-9]+)*$/;
55
+
56
+ /** True when a resolved download URL is https + an allowlisted CDN host (defender F2). */
57
+ export function isTrustedDownloadUrl(url) {
58
+ try {
59
+ const u = new URL(url);
60
+ return u.protocol === 'https:' && CDN_HOSTS.has(u.hostname);
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ /** Machine-global cache (shared across projects) — NOT the per-project .ai/cache. */
67
+ function browsersDir() {
68
+ return process.env.MAUDE_BROWSERS_DIR || join(homedir(), '.maude', 'browsers');
69
+ }
70
+
71
+ /** node/bun platform → Chrome-for-Testing platform slug (null = no CfT build). */
72
+ function cftPlatform() {
73
+ const p = process.platform;
74
+ const a = process.arch;
75
+ if (p === 'darwin') return a === 'arm64' ? 'mac-arm64' : 'mac-x64';
76
+ if (p === 'win32') return a === 'arm64' ? null : 'win64';
77
+ if (p === 'linux') return a === 'arm64' ? null : 'linux64'; // CfT ships no linux-arm64
78
+ return null;
79
+ }
80
+
81
+ /** The headless-shell executable's basename for this platform. */
82
+ function shellExeName() {
83
+ return process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
84
+ }
85
+
86
+ function isRunnable(p) {
87
+ try {
88
+ return !!p && existsSync(p) && statSync(p).isFile();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /** Recursively find the first `chrome-headless-shell[.exe]` under dir. */
95
+ function findShell(dir) {
96
+ if (!existsSync(dir)) return null;
97
+ const want = shellExeName();
98
+ const stack = [dir];
99
+ while (stack.length) {
100
+ const cur = stack.pop();
101
+ let entries;
102
+ try {
103
+ entries = readdirSync(cur, { withFileTypes: true });
104
+ } catch {
105
+ continue;
106
+ }
107
+ for (const e of entries) {
108
+ const full = join(cur, e.name);
109
+ if (e.isDirectory()) stack.push(full);
110
+ else if (e.name === want && isRunnable(full)) return full;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /** (2) A previously-provisioned headless-shell in the Maude cache. */
117
+ function cachedShell() {
118
+ return findShell(browsersDir());
119
+ }
120
+
121
+ /** (3) A Playwright chromium-headless-shell already installed. */
122
+ function playwrightShell() {
123
+ const roots = [
124
+ process.env.PLAYWRIGHT_BROWSERS_PATH,
125
+ join(homedir(), 'Library', 'Caches', 'ms-playwright'), // macOS
126
+ join(homedir(), '.cache', 'ms-playwright'), // linux
127
+ join(process.env.LOCALAPPDATA || '', 'ms-playwright'), // windows
128
+ ].filter(Boolean);
129
+ for (const r of roots) {
130
+ const hit = findShell(r);
131
+ if (hit) return hit;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** (4) System Chrome / Chromium — a full browser is fine for headless capture. */
137
+ function systemChrome() {
138
+ const candidates =
139
+ process.platform === 'darwin'
140
+ ? [
141
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
142
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
143
+ '/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
144
+ ]
145
+ : process.platform === 'win32'
146
+ ? [
147
+ join(
148
+ process.env.PROGRAMFILES || 'C:/Program Files',
149
+ 'Google/Chrome/Application/chrome.exe'
150
+ ),
151
+ join(
152
+ process.env['PROGRAMFILES(X86)'] || 'C:/Program Files (x86)',
153
+ 'Google/Chrome/Application/chrome.exe'
154
+ ),
155
+ ]
156
+ : [];
157
+ for (const c of candidates) if (isRunnable(c)) return c;
158
+ // PATH lookup (linux `google-chrome` / `chromium`, or any platform).
159
+ for (const bin of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
160
+ try {
161
+ const p = execFileSync(process.platform === 'win32' ? 'where' : 'command', ['-v', bin], {
162
+ encoding: 'utf8',
163
+ stdio: ['ignore', 'pipe', 'ignore'],
164
+ })
165
+ .split('\n')[0]
166
+ .trim();
167
+ if (isRunnable(p)) return p;
168
+ } catch {
169
+ /* not on PATH */
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function unzip(zipPath, destDir) {
176
+ mkdirSync(destDir, { recursive: true });
177
+ // Prefer `unzip` (macOS/Linux); fall back to bsdtar (`tar -xf` handles zip on
178
+ // macOS + Windows 10+), then PowerShell on Windows.
179
+ const attempts =
180
+ process.platform === 'win32'
181
+ ? [
182
+ ['tar', ['-xf', zipPath, '-C', destDir]],
183
+ [
184
+ 'powershell',
185
+ [
186
+ '-NoProfile',
187
+ '-Command',
188
+ `Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'`,
189
+ ],
190
+ ],
191
+ ]
192
+ : [
193
+ ['unzip', ['-q', '-o', zipPath, '-d', destDir]],
194
+ ['tar', ['-xf', zipPath, '-C', destDir]],
195
+ ];
196
+ let lastErr;
197
+ for (const [cmd, a] of attempts) {
198
+ try {
199
+ execFileSync(cmd, a, { stdio: 'ignore' });
200
+ return;
201
+ } catch (e) {
202
+ lastErr = e;
203
+ }
204
+ }
205
+ throw new Error(`unzip failed (${lastErr?.message ?? 'no extractor'})`);
206
+ }
207
+
208
+ /** (5) Download chrome-headless-shell for this platform into the Maude cache. */
209
+ async function downloadShell() {
210
+ const plat = cftPlatform();
211
+ if (!plat) {
212
+ log(`ensure-browser: no Chrome-for-Testing build for ${process.platform}/${process.arch}`);
213
+ return null;
214
+ }
215
+ log('ensure-browser: no browser found — downloading chrome-headless-shell (~94 MB, one-time)…');
216
+ const versions = await fetch(CDN_VERSIONS).then((r) => r.json());
217
+ const stable = versions.channels?.Stable;
218
+ const version = stable?.version;
219
+ // F3 — reject a suspicious version before it reaches a path or a shell string.
220
+ if (typeof version !== 'string' || !CFT_VERSION_RE.test(version)) {
221
+ log(`ensure-browser: refusing malformed Chrome-for-Testing version ${JSON.stringify(version)}`);
222
+ return null;
223
+ }
224
+ const url = (stable?.downloads?.['chrome-headless-shell'] || []).find(
225
+ (d) => d.platform === plat
226
+ )?.url;
227
+ if (!url) {
228
+ log(`ensure-browser: CDN has no chrome-headless-shell for ${plat}`);
229
+ return null;
230
+ }
231
+ // F2 — the resolved URL is remote-controlled + its bytes get executed. Pin
232
+ // https + an allowlisted host so a tampered manifest/redirect can't land a
233
+ // foreign or plaintext-fetched binary. (CDN-of-record compromise is the
234
+ // accepted residual — same trust anchor Playwright/Puppeteer rely on.)
235
+ if (!isTrustedDownloadUrl(url)) {
236
+ log(`ensure-browser: refusing non-allowlisted download URL ${url}`);
237
+ return null;
238
+ }
239
+ // F3 — the cache is exec-forever, so keep it 0700 (only this user can plant a
240
+ // binary that later runs as the screenshot engine). Created before the download.
241
+ mkdirSync(browsersDir(), { recursive: true, mode: 0o700 });
242
+ try {
243
+ chmodSync(browsersDir(), 0o700);
244
+ } catch {
245
+ /* pre-existing dir owned by another mode — best-effort */
246
+ }
247
+ const dest = join(browsersDir(), `chrome-headless-shell-${version}-${plat}`);
248
+ if (findShell(dest)) return findShell(dest); // concurrent provision won the race
249
+ const tmp = join(tmpdir(), `maude-hshell-${version}-${plat}.zip`);
250
+ // F1 — do NOT follow redirects: the host allowlist above validated THIS url, but
251
+ // a 3xx would bounce us to an unvalidated host. The CfT CDN serves the artifact
252
+ // directly (200), so redirect:'error' fails safe instead of chasing a redirect.
253
+ const buf = Buffer.from(await fetch(url, { redirect: 'error' }).then((r) => r.arrayBuffer()));
254
+ const { writeFileSync } = await import('node:fs');
255
+ writeFileSync(tmp, buf);
256
+ // Extract to a sibling temp dir, then atomically rename into place.
257
+ const staging = `${dest}.staging-${process.pid}`;
258
+ rmSync(staging, { recursive: true, force: true });
259
+ unzip(tmp, staging);
260
+ rmSync(tmp, { force: true });
261
+ rmSync(dest, { recursive: true, force: true });
262
+ renameSync(staging, dest);
263
+ const exe = findShell(dest);
264
+ if (exe && process.platform !== 'win32') chmodSync(exe, 0o755);
265
+ log(`ensure-browser: installed → ${exe}`);
266
+ return exe;
267
+ }
268
+
269
+ /**
270
+ * Resolve a screenshot browser executable. `download:false` resolves an EXISTING
271
+ * browser only (for readiness probes — never fetches ~94 MB). Exported so the
272
+ * dev-server readiness probe can resolve in-process without spawning.
273
+ */
274
+ export async function resolveBrowser({ download = true } = {}) {
275
+ // (1) explicit override
276
+ const override =
277
+ process.env.MAUDE_BROWSER_EXECUTABLE || process.env.AGENT_BROWSER_EXECUTABLE_PATH;
278
+ if (isRunnable(override)) return { path: override, source: 'override' };
279
+ // (2) Maude cache
280
+ const cached = cachedShell();
281
+ if (cached) return { path: cached, source: 'cache' };
282
+ // (3) Playwright headless-shell
283
+ const pw = playwrightShell();
284
+ if (pw) return { path: pw, source: 'playwright' };
285
+ // (4) system Chrome
286
+ const sys = systemChrome();
287
+ if (sys) return { path: sys, source: 'system' };
288
+ // (5) download (unless probing)
289
+ if (!download) return { path: null, source: 'none' };
290
+ const dl = await downloadShell();
291
+ return { path: dl, source: dl ? 'downloaded' : 'none' };
292
+ }
293
+
294
+ // CLI entry — only when invoked directly (not when imported by readiness.ts).
295
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
296
+ const result = await resolveBrowser({ download: !NO_DOWNLOAD });
297
+ if (JSON_OUT) {
298
+ process.stdout.write(
299
+ `${JSON.stringify({ ...result, name: result.path ? basename(result.path) : null })}\n`
300
+ );
301
+ } else if (result.path) {
302
+ process.stdout.write(`${result.path}\n`);
303
+ }
304
+ process.exit(result.path ? 0 : 1);
305
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # ensure-browser.sh — resolve (and if needed download) a headless Chromium for
3
+ # screenshots; prints the browser executable path to stdout. Reached via
4
+ # `maude design ensure-browser` (DDR-062), never a raw bin path.
5
+ #
6
+ # Usage:
7
+ # ensure-browser.sh [--no-download] [--json] [--quiet]
8
+ #
9
+ # --no-download resolves an already-present browser only (never fetches ~94 MB) —
10
+ # use it from readiness probes. Default may download chrome-headless-shell into
11
+ # the Maude browsers cache (~/.maude/browsers, override MAUDE_BROWSERS_DIR).
12
+ # Exit 0 with the path on stdout when a browser is available; exit 1 otherwise.
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+
15
+ case "$1" in
16
+ --help|-h) sed -n '2,12p' "$0" | sed 's/^# \?//'; exit 0 ;;
17
+ esac
18
+
19
+ if command -v node >/dev/null 2>&1; then
20
+ exec node "$SCRIPT_DIR/_ensure-browser.mjs" "$@"
21
+ elif command -v bun >/dev/null 2>&1; then
22
+ exec bun run "$SCRIPT_DIR/_ensure-browser.mjs" "$@"
23
+ else
24
+ echo "ensure-browser.sh: node or bun is required to resolve/download the screenshot browser." >&2
25
+ exit 1
26
+ fi
@@ -126,13 +126,38 @@ case "$MODE" in
126
126
  esac
127
127
 
128
128
  # ---------- engine resolution ----------
129
+ # Prefer the bundled agent-browser the desktop sidecar pins via MAUDE_AGENT_BROWSER
130
+ # (DDR-144 attacker-F4: an explicit single-binary pointer, NOT a PATH prepend that
131
+ # would let a same-user attacker shadow node/chrome in the app dir), else the one
132
+ # on PATH.
133
+ AB="${MAUDE_AGENT_BROWSER:-agent-browser}"
129
134
  if [ "$ENGINE" = "auto" ]; then
130
- if command -v agent-browser >/dev/null 2>&1; then
135
+ if command -v "$AB" >/dev/null 2>&1; then
131
136
  ENGINE="agent-browser"
132
137
  else
133
138
  ENGINE="playwright"
134
139
  fi
135
140
  fi
141
+
142
+ # ---------- browser resolution for agent-browser (DDR — bundled screenshots) ----------
143
+ # agent-browser needs a Chrome-family engine. On a fresh desktop machine there's
144
+ # no system Chrome, so resolve (or, on the desktop path only, one-time download)
145
+ # `chrome-headless-shell` and point agent-browser at it via
146
+ # AGENT_BROWSER_EXECUTABLE_PATH. Honor a caller-set value; leave the web/CLI path
147
+ # untouched (--no-download → resolve an EXISTING browser only, never a surprise
148
+ # ~94 MB fetch; agent-browser keeps its own system-Chrome default if nothing's
149
+ # found). The desktop bundle sets MAUDE_DEV_SERVER_ROOT (sidecar.rs) — the signal
150
+ # that a one-time provisioning download is wanted for the zero-install experience.
151
+ if [ "$ENGINE" = "agent-browser" ] && [ -z "$AGENT_BROWSER_EXECUTABLE_PATH" ]; then
152
+ ES_DIR="$(cd "$(dirname "$0")" && pwd)"
153
+ ES_FLAGS="--quiet"
154
+ [ -z "$MAUDE_DEV_SERVER_ROOT" ] && ES_FLAGS="--quiet --no-download"
155
+ BROWSER_PATH="$(bash "$ES_DIR/ensure-browser.sh" $ES_FLAGS 2>/dev/null)"
156
+ if [ -n "$BROWSER_PATH" ] && [ -x "$BROWSER_PATH" ]; then
157
+ export AGENT_BROWSER_EXECUTABLE_PATH="$BROWSER_PATH"
158
+ echo "→ agent-browser browser: $BROWSER_PATH" >&2
159
+ fi
160
+ fi
136
161
  echo "→ screenshot engine: $ENGINE | url: $URL" >&2
137
162
 
138
163
  # ---------- engine: agent-browser ----------
@@ -140,9 +165,9 @@ ab_screenshot() {
140
165
  local css="$1"
141
166
  local out="$2"
142
167
  if [ -n "$css" ]; then
143
- agent-browser screenshot "$css" "$out" >&2 || return 1
168
+ "$AB" screenshot "$css" "$out" >&2 || return 1
144
169
  else
145
- agent-browser screenshot --full "$out" >&2 || return 1
170
+ "$AB" screenshot --full "$out" >&2 || return 1
146
171
  fi
147
172
  # agent-browser sometimes reports success without writing — verify size.
148
173
  if [ ! -s "$out" ]; then
@@ -175,7 +200,7 @@ pw_screenshot() {
175
200
 
176
201
  navigate_once() {
177
202
  if [ "$ENGINE" = "agent-browser" ]; then
178
- agent-browser open "$URL" >&2
203
+ "$AB" open "$URL" >&2
179
204
  # Wait for canvas to mount — Babel/React canvases take 2–4s to settle.
180
205
  # Poll for [data-dc-screen] or [data-dc-slot] up to $TIMEOUT seconds; fall
181
206
  # through to a fixed sleep when the page isn't a DC canvas.
@@ -185,7 +210,7 @@ navigate_once() {
185
210
  sleep 1
186
211
  poll=$((poll + 1))
187
212
  local raw
188
- raw=$(agent-browser eval "document.querySelectorAll('[data-dc-screen],[data-dc-slot]').length" 2>/dev/null)
213
+ raw=$("$AB" eval "document.querySelectorAll('[data-dc-screen],[data-dc-slot]').length" 2>/dev/null)
189
214
  # raw is a plain number on success — strip whitespace, validate.
190
215
  local has=$(printf '%s' "$raw" | tr -d '[:space:]')
191
216
  case "$has" in
@@ -270,7 +295,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
270
295
  if [ "$ENGINE" = "agent-browser" ]; then
271
296
  # agent-browser wraps string results in quotes and escapes newlines as
272
297
  # literal `\n`; comma-join + tr is simpler than parsing JSON for IDs.
273
- raw=$(agent-browser eval \
298
+ raw=$("$AB" eval \
274
299
  "Array.from(document.querySelectorAll('[data-dc-screen],[data-dc-slot]')).map(e => e.getAttribute('data-dc-screen') || e.getAttribute('data-dc-slot')).filter(Boolean).join(',')" \
275
300
  2>/dev/null)
276
301
  # Strip surrounding quotes, then split on comma.
@@ -281,7 +306,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
281
306
  if [ -z "$IDS" ] && relive_url; then
282
307
  navigate_once
283
308
  if [ "$ENGINE" = "agent-browser" ]; then
284
- raw=$(agent-browser eval \
309
+ raw=$("$AB" eval \
285
310
  "Array.from(document.querySelectorAll('[data-dc-screen],[data-dc-slot]')).map(e => e.getAttribute('data-dc-screen') || e.getAttribute('data-dc-slot')).filter(Boolean).join(',')" \
286
311
  2>/dev/null)
287
312
  IDS=$(printf '%s' "$raw" | sed 's/^"//; s/"$//' | tr ',' '\n')
@@ -298,7 +323,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
298
323
  NN=$(printf "%03d" "$N")
299
324
  OUT_FILE="$OUT_DIR/${NN}-screen-${ID}.png"
300
325
  if [ "$ENGINE" = "agent-browser" ]; then
301
- agent-browser eval "document.querySelector('[data-dc-screen=\"$ID\"], [data-dc-slot=\"$ID\"]').scrollIntoView({block:'center'})" >/dev/null 2>&1
326
+ "$AB" eval "document.querySelector('[data-dc-screen=\"$ID\"], [data-dc-slot=\"$ID\"]').scrollIntoView({block:'center'})" >/dev/null 2>&1
302
327
  sleep 0.6
303
328
  fi
304
329
  if capture_resilient "[data-dc-screen=\"$ID\"], [data-dc-slot=\"$ID\"]" "$OUT_FILE"; then
@@ -12,6 +12,11 @@
12
12
  # else the canvas's import graph lost the token CSS and every var()-driven rule
13
13
  # is dead — the specimen mounts with content but renders unstyled).
14
14
  #
15
+ # Also runs an ADVISORY artboard-isolation lint over ui/ canvases: `@media`
16
+ # width queries + viewport units (vw/vh/*-screen) resolve against the studio
17
+ # canvas stage, not the fixed artboard box, so they reflow the mock when the
18
+ # panel/sidebar/window resizes. Warns only — never fails the exit code.
19
+ #
15
20
  # Usage:
16
21
  # smoke.sh [--root <repo>]
17
22
  # [--out-dir <dir>]
@@ -237,6 +242,41 @@ lint_specimen_imports() {
237
242
  fi
238
243
  }
239
244
 
245
+ # Static artboard-isolation lint (advisory). An artboard is a fixed-size design
246
+ # surface, but `@media` width queries + viewport units (vh/vw/vmin/vmax + the
247
+ # dynamic svh/dvh/lvh family, incl. Tailwind's *-screen utilities) resolve
248
+ # against the IFRAME VIEWPORT — i.e. the studio's canvas stage — not the
249
+ # artboard box. So a ui/ mock that uses them reflows when the Assistant panel /
250
+ # sidebar / window resizes, even at a fixed zoom. `container-type` on
251
+ # .dc-artboard-body (canvas-lib ENGINE_CSS) gives an isolated responsive path
252
+ # via @container + cqw/cqh; viewport units still escape by spec. This warns —
253
+ # it never fails the exit code (existing DS canvases legitimately use vw/vh in
254
+ # full-bleed specimens, and the fix is an author rewrite, not a smoke gate).
255
+ # Scopes ui/ canvases + their sibling .css only (preview specimens render
256
+ # standalone, so viewport units there are fine). Sets ISO_WARN.
257
+ ISO_WARN=0
258
+ lint_artboard_isolation() {
259
+ local f n=0 hits total=0 pat
260
+ # Length units (vh/vw/…), Tailwind *-screen utilities, raw viewport @media.
261
+ pat='[0-9.]+(vh|vw|vmin|vmax|dvh|svh|lvh|dvw|svw|lvw)([^a-z]|$)|(min-|max-)?[hw]-screen|@media[[:space:]]*\((min|max)-width'
262
+ [ -d "$DESIGN_ROOT/ui" ] || { echo "→ artboard-isolation lint: no ui/ dir — skipped" >&2; return; }
263
+ while IFS= read -r f; do
264
+ [ -e "$f" ] || continue
265
+ case "$(basename "$f")" in _*) continue ;; esac
266
+ hits=$(grep -cnE "$pat" "$f" 2>/dev/null || true)
267
+ [ "${hits:-0}" -gt 0 ] || continue
268
+ n=$((n + 1)); total=$((total + hits))
269
+ printf 'ISO-WARN %s — %s viewport-escape(s) (@media/vw/vh/*-screen leak to the studio stage)\n' "${f#$DESIGN_ROOT/}" "$hits" >&2
270
+ printf '| ⚠ ISO | `%s` | — | %s viewport-escape(s) — use %%%%/px or @container+cqw |\n' "${f#$DESIGN_ROOT/}" "$hits" >> "$MD"
271
+ done < <(find "$DESIGN_ROOT/ui" -type f \( -name '*.tsx' -o -name '*.css' \) 2>/dev/null | sort)
272
+ ISO_WARN=$n
273
+ if [ "$n" -eq 0 ]; then
274
+ echo "→ artboard-isolation lint: clean" >&2
275
+ else
276
+ echo "⚠ artboard-isolation lint: $n file(s), $total viewport-escape(s) — advisory, see report" >&2
277
+ fi
278
+ }
279
+
240
280
  # Probe a canvas via agent-browser. Returns three lines on stdout:
241
281
  # <status> one of OK / BLANK / ERROR
242
282
  # <detail> short summary string
@@ -371,6 +411,8 @@ printf 'status\tfile\tscreenshot\tdetail\n' > "$TSV"
371
411
  if [ "$INCLUDE_SYSTEM" = "1" ] && [ -d "$DESIGN_ROOT/system" ]; then
372
412
  lint_specimen_imports
373
413
  fi
414
+ # Artboard-isolation lint always runs (ui/ canvases are always in scope). Advisory.
415
+ lint_artboard_isolation
374
416
 
375
417
  FAILED=0
376
418
  N=0
@@ -435,6 +477,10 @@ TOTAL_FAIL=$((FAILED + LINT_FAILED))
435
477
  else
436
478
  echo "**Result:** ✗ $FAILED / $COUNT canvases failed render/style; $LINT_FAILED import-graph lint violation(s)."
437
479
  fi
480
+ if [ "${ISO_WARN:-0}" -gt 0 ]; then
481
+ echo
482
+ echo "> ⚠ **Artboard isolation:** $ISO_WARN ui/ file(s) use viewport-escaping CSS (\`@media\`/\`vw\`/\`vh\`/\`*-screen\`) that reflows with the studio chrome. Advisory — not a failure. Replace with fixed px / \`%\` / \`@container\`+\`cqw\`."
483
+ fi
438
484
  } >> "$MD"
439
485
 
440
486
  echo "→ report: $MD" >&2