@reqvire-org/ads 0.3.12 → 0.3.13

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 (41) hide show
  1. package/README.md +20 -1
  2. package/dist/capture/worker.mjs +38 -0
  3. package/dist/capture-store.mjs +123 -0
  4. package/dist/capture.mjs +117 -0
  5. package/dist/config.mjs +6 -1
  6. package/dist/mcp/context.mjs +1 -1
  7. package/dist/mcp/mutations.mjs +25 -5
  8. package/dist/mcp/server.mjs +62 -15
  9. package/dist/scenarios.mjs +9 -3
  10. package/dist/showcase/client.bundle.js +18 -18
  11. package/dist/showcase/style.css +2 -0
  12. package/dist/skills/ads-break-ui/SKILL.md +6 -1
  13. package/dist/skills/ads-break-ui/references/captures.md +147 -0
  14. package/dist/skills/ads-break-ui/references/guarded-editing.md +58 -0
  15. package/dist/skills/ads-break-ui/references/scenarios.md +5 -0
  16. package/dist/skills/ads-designer/SKILL.md +6 -1
  17. package/dist/skills/ads-designer/references/captures.md +147 -0
  18. package/dist/skills/ads-designer/references/guarded-editing.md +58 -0
  19. package/dist/skills/ads-designer/references/scenarios.md +5 -0
  20. package/dist/skills/ads-import/SKILL.md +6 -1
  21. package/dist/skills/ads-import/references/captures.md +147 -0
  22. package/dist/skills/ads-import/references/guarded-editing.md +58 -0
  23. package/dist/skills/ads-import/references/scenarios.md +5 -0
  24. package/dist/skills/ads-migrate/SKILL.md +6 -1
  25. package/dist/skills/ads-migrate/references/captures.md +147 -0
  26. package/dist/skills/ads-migrate/references/guarded-editing.md +58 -0
  27. package/dist/skills/ads-migrate/references/scenarios.md +5 -0
  28. package/dist/skills/ads-motion/SKILL.md +6 -1
  29. package/dist/skills/ads-motion/references/captures.md +147 -0
  30. package/dist/skills/ads-motion/references/guarded-editing.md +58 -0
  31. package/dist/skills/ads-motion/references/scenarios.md +5 -0
  32. package/dist/verification/browser.mjs +8 -3
  33. package/dist/verification/playwright-reporter.cjs +23 -8
  34. package/dist/verification.mjs +137 -33
  35. package/docs/ADS.md +5 -0
  36. package/docs/ARCHITECTURE.md +39 -2
  37. package/docs/BEHAVIORS.md +57 -3
  38. package/docs/CHANGELOG.md +17 -0
  39. package/docs/GUIDE.md +48 -1
  40. package/docs/MCP.md +49 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -19,6 +19,25 @@ ADS supports **React and native Preact** with the same architecture guards and
19
19
  MCP preview flow. The empty starter uses React; this repository’s working example
20
20
  uses Preact. See [implementation support](docs/GUIDE.md#implementation-support).
21
21
 
22
+ ## What ADS does
23
+
24
+ - **Inspect** components, patterns, controllers, tokens and their owning source
25
+ through focused MCP tools.
26
+ - **Preview** real UI through MCP Apps and the local showcase, using the same
27
+ project-owned scenarios and fixtures.
28
+ - **Edit with guards** using owner patches, coupled transactions and isolated
29
+ dry runs that validate architecture, lint and registered project checks.
30
+ - **Compare visual changes** with on-demand screenshots of current source and
31
+ proposed candidates, exposed as MCP image resources.
32
+ - **Verify behavior** through project-owned guarantees and registered tests,
33
+ with progress, timings and evidence tied to current source.
34
+ - **Guide agents** with prompts and portable skills for building, importing,
35
+ migrating, motion and UI stress testing.
36
+
37
+ See [using ADS through MCP](docs/GUIDE.md#use-ads-through-mcp) for the workflow and
38
+ [visual captures](docs/MCP.md#screenshots-and-candidate-comparison) for screenshot
39
+ setup and comparison.
40
+
22
41
  ## Set up your project
23
42
 
24
43
  Run **inside your project**, where `package.json` lives. Requires Node 22.12 or newer.
@@ -99,7 +118,7 @@ they keep source writes behind the same guards. See
99
118
 
100
119
  The server exposes public components/patterns, token values,
101
120
  source guides, controllers/hooks, architecture checks, behavioral verification,
102
- workflow prompts, and packaged ADS skills.
121
+ on-demand screenshots, workflow prompts, and packaged ADS skills.
103
122
  It supports older MCP clients and the newer Skills protocol.
104
123
 
105
124
  Private controllers stay beside their component; shared controllers have their own
@@ -0,0 +1,38 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { openPreviewScenario } from "../verification/browser.mjs";
5
+
6
+ // Runs only as an owned child, using the receiving project's browser dependency.
7
+ const request = JSON.parse(readFileSync(process.argv[2], "utf8"));
8
+ const playwright = createRequire(import.meta.url)(request.playwright);
9
+ let browser;
10
+ try {
11
+ browser = await playwright[request.settings.browser].launch({ headless: true, ...(request.settings.channel ? { channel: request.settings.channel } : {}) });
12
+ const images = [];
13
+ for (const [index, app] of request.apps.entries()) {
14
+ const context = await browser.newContext({ viewport: app.viewport, deviceScaleFactor: 1, colorScheme: app.theme });
15
+ try {
16
+ const page = await context.newPage(), timeout = request.settings.timeoutMs;
17
+ const frame = await openPreviewScenario(page, app.url, app.scenario, { timeout });
18
+ await frame.locator("html").waitFor({ state: "visible", timeout });
19
+ await frame.waitForFunction(theme => document.documentElement.dataset.theme === theme, app.theme, { timeout });
20
+ await frame.evaluate(async () => {
21
+ await document.fonts.ready;
22
+ await Promise.all([...document.images].map(image => image.decode().catch(() => {})));
23
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
24
+ });
25
+ const actualViewport = await frame.evaluate(() => ({ width: innerWidth, height: innerHeight }));
26
+ if (actualViewport.width !== app.viewport.width || actualViewport.height !== app.viewport.height) throw new Error(`App viewport differs from requested capture settings: ${JSON.stringify(actualViewport)}`);
27
+ const target = frame.locator(app.selector);
28
+ if (await target.count() !== 1) throw new Error(`Capture target ${app.target ?? "widget"} must resolve to exactly one element.`);
29
+ await target.waitFor({ state: "visible", timeout });
30
+ const bounds = await target.boundingBox();
31
+ if (!bounds || bounds.width * bounds.height > 8 * 1024 * 1024) throw new Error("Capture target is empty or exceeds 8 megapixels.");
32
+ await target.screenshot({ path: path.join(request.output, `${index}.png`), type: "png", animations: app.animations, caret: "hide", scale: "css", timeout });
33
+ if (await frame.locator("html").getAttribute("data-ads-preview-state") !== "ready") throw new Error("The App stopped being ready during capture.");
34
+ images.push({ index, scenario: app.scenario, actualViewport, capturedAt: new Date().toISOString() });
35
+ } finally { await context.close(); }
36
+ }
37
+ writeFileSync(path.join(request.output, "report.json"), JSON.stringify({ schemaVersion: 1, runId: request.runId, images }));
38
+ } finally { await browser?.close(); }
@@ -0,0 +1,123 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+
6
+ export const CAPTURES_URI = "ads://project/captures";
7
+ export const captureLimits = { selections: 8, imageBytes: 4 * 1024 * 1024, pixels: 8 * 1024 * 1024, storeBytes: 256 * 1024 * 1024, pairBytes: 12 * 1024 * 1024 };
8
+ export const captureSpec = z.object({
9
+ scenario: z.string().min(1).max(256), target: z.string().regex(/^[a-zA-Z0-9][\w.-]{0,79}$/).optional(),
10
+ theme: z.enum(["light", "dark"]).default("light"),
11
+ viewport: z.object({ width: z.number().int().min(100).max(4096), height: z.number().int().min(100).max(4096) }).strict().default({ width: 1280, height: 800 }),
12
+ animations: z.enum(["disabled", "allow"]).default("disabled"),
13
+ }).strict().refine(value => value.viewport.width * value.viewport.height <= captureLimits.pixels, "Capture viewport exceeds 8 megapixels.");
14
+ export const screenshotFields = { screenshot: z.boolean().optional(), captures: z.array(captureSpec).min(1).max(captureLimits.selections).optional() };
15
+ export const captureKey = ({ scenario, target }) => createHash("sha256").update(JSON.stringify([scenario, target ?? null])).digest("hex");
16
+ export const captureUri = (key, slot) => `ads://captures/${key}/${slot}`;
17
+ const missing = message => Object.assign(new Error(message), { code: "ADS_CAPTURE_NOT_FOUND" });
18
+ const validTarget = (record, scenarios, revision) => scenarios.some(scenario => scenario.id === record.scenario && (!record.target || Object.hasOwn(scenario.captureTargets ?? {}, record.target))) || record.proposedTarget === true && record.candidate?.baseRevision === revision;
19
+ const summary = image => { if (!image) return null; const { blob: _blob, ...metadata } = image; return metadata; };
20
+
21
+ /** Two slots in one atomically replaced file: no revision-addressed image history. */
22
+ export function createCaptureStore(root) {
23
+ root = path.resolve(root);
24
+ const directory = path.join(root, ".ds/captures"), cache = new Map();
25
+ function safeDirectory(create = false) {
26
+ for (const file of [path.join(root, ".ds"), directory]) {
27
+ if (!existsSync(file)) { if (!create) return false; mkdirSync(file); }
28
+ if (!lstatSync(file).isDirectory() || realpathSync(file) !== file) throw new Error("Capture storage must use ordinary project .ds/captures directories.");
29
+ }
30
+ return true;
31
+ }
32
+ function files() { return safeDirectory() ? readdirSync(directory).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort() : []; }
33
+ function read(key) {
34
+ if (!safeDirectory()) return null;
35
+ const file = path.join(directory, `${key}.json`);
36
+ const stat = lstatSync(file, { throwIfNoEntry: false });
37
+ if (!stat) return null;
38
+ if (!stat.isFile() || stat.nlink !== 1 || realpathSync(file) !== file || stat.size > captureLimits.pairBytes) throw new Error("Invalid or oversized capture pair file.");
39
+ const signature = `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
40
+ if (cache.get(key)?.signature === signature) return cache.get(key).value;
41
+ const value = JSON.parse(readFileSync(file, "utf8"));
42
+ if (value.schemaVersion !== 1 || value.key !== key || captureKey(value) !== key || !value.current && !value.candidate || [value.current, value.candidate].filter(Boolean).some(image => typeof image.blob !== "string" || image.blob.length > Math.ceil(captureLimits.imageBytes / 3) * 4 || image.mimeType !== "image/png")) throw new Error("Invalid capture pair schema.");
43
+ cache.set(key, { signature, value });
44
+ return value;
45
+ }
46
+ function remove(key) { rmSync(path.join(directory, `${key}.json`), { force: true }); cache.delete(key); }
47
+ function commit(records) {
48
+ safeDirectory(true);
49
+ const lock = path.join(directory, ".publish-lock");
50
+ function acquire() { writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: "wx" }); }
51
+ try { acquire(); }
52
+ catch (error) {
53
+ if (error.code !== "EEXIST") throw error;
54
+ const stat = lstatSync(lock);
55
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024) throw new Error("Invalid capture publication lock.");
56
+ const owner = JSON.parse(readFileSync(lock, "utf8"));
57
+ if (!Number.isSafeInteger(owner.pid) || owner.pid <= 1) throw new Error("Invalid capture publication owner.");
58
+ let alive = true;
59
+ try { process.kill(owner.pid, 0); } catch (error) { if (error.code === "ESRCH") alive = false; }
60
+ if (alive) throw new Error("Another capture publication is in progress; retry after it finishes.");
61
+ rmSync(lock); acquire();
62
+ }
63
+ const staged = [], written = [];
64
+ try {
65
+ let total = files().reduce((sum, file) => sum + lstatSync(path.join(directory, file)).size, 0);
66
+ for (const record of records) {
67
+ if (record.key !== captureKey(record)) throw new Error("Capture key does not match its scenario/target.");
68
+ const file = path.join(directory, `${record.key}.json`), content = JSON.stringify(record), bytes = Buffer.byteLength(content);
69
+ if (bytes > captureLimits.pairBytes) throw new Error("Capture pair exceeds 12 MiB.");
70
+ const old = read(record.key), previous = old ? readFileSync(file) : null;
71
+ total += bytes - (previous?.length ?? 0);
72
+ if (total > captureLimits.storeBytes) throw new Error("Capture storage exceeds 256 MiB. Clear unneeded targets with ads_capture_clear before capturing again.");
73
+ const temporary = path.join(directory, `.capture-${randomUUID()}`);
74
+ writeFileSync(temporary, content);
75
+ staged.push({ file, temporary, previous, key: record.key });
76
+ }
77
+ for (const entry of staged) { renameSync(entry.temporary, entry.file); cache.delete(entry.key); written.push(entry); }
78
+ } catch (error) {
79
+ for (const entry of written.reverse()) { if (entry.previous) writeFileSync(entry.file, entry.previous); else rmSync(entry.file, { force: true }); cache.delete(entry.key); }
80
+ throw error;
81
+ } finally { for (const entry of staged) rmSync(entry.temporary, { force: true }); rmSync(lock, { force: true }); }
82
+ }
83
+ return {
84
+ commit,
85
+ resourceUris: () => files().flatMap(file => { try { const record = read(file.slice(0, -5)); return ["current", "candidate"].filter(slot => record[slot]).map(slot => captureUri(record.key, slot)); } catch { return []; } }),
86
+ fingerprint: () => files().map(file => { const stat = lstatSync(path.join(directory, file)); return `${file}:${stat.mtimeMs}:${stat.size}`; }).join("\n"),
87
+ list(scenarios, revision, { prune = true } = {}) {
88
+ const captures = [], diagnostics = [];
89
+ for (const file of files()) {
90
+ const key = file.slice(0, -5);
91
+ try {
92
+ const record = read(key);
93
+ if (!validTarget(record, scenarios, revision) && prune) { remove(key); continue; }
94
+ captures.push({ key, scenario: record.scenario, ...(record.target ? { target: record.target } : {}), current: record.current ? { ...summary(record.current), resourceUri: captureUri(key, "current"), stale: record.current.sourceRevision !== revision } : null, ...(!record.current ? { currentAbsent: "This scenario/target is new in the candidate; no baseline image exists." } : {}), candidate: record.candidate ? { ...summary(record.candidate), resourceUri: captureUri(key, "candidate"), stale: record.candidate.baseRevision !== revision } : null });
95
+ } catch (error) { diagnostics.push({ key, message: error.message }); }
96
+ }
97
+ return { captures, diagnostics, limits: captureLimits };
98
+ },
99
+ readResource(uri, scenarios, revision, { allowUnknownTargets = false } = {}) {
100
+ const match = /^ads:\/\/captures\/([a-f0-9]{64})\/(current|candidate)$/.exec(uri);
101
+ if (!match) throw missing("Unknown screenshot URI. Discover captures through ads://project/captures.");
102
+ const record = read(match[1]), slot = match[2];
103
+ if (!record || !allowUnknownTargets && !validTarget(record, scenarios, revision) || !record[slot]) throw missing("This capture slot is absent or was replaced/cleared. Request a new capture with ads_capture or a screenshot-enabled dry run.");
104
+ return { uri, mimeType: "image/png", blob: record[slot].blob, _meta: { ...summary(record[slot]), stale: (slot === "candidate" ? record[slot].baseRevision : record[slot].sourceRevision) !== revision } };
105
+ },
106
+ clear({ scenario, target, candidateOnly = false }) {
107
+ const key = captureKey({ scenario, target }), record = read(key);
108
+ if (!record) return { cleared: false };
109
+ if (candidateOnly && record.current) { commit([{ ...record, candidate: null }]); return { cleared: true, key, slots: ["candidate"] }; }
110
+ remove(key); return { cleared: true, key, slots: ["current", "candidate"] };
111
+ },
112
+ promote(revision, scenarios) {
113
+ const records = [];
114
+ for (const file of files()) {
115
+ const record = read(file.slice(0, -5));
116
+ if (!validTarget(record, scenarios, revision)) { remove(record.key); continue; }
117
+ if (record.candidate?.sourceRevision === revision) records.push({ ...record, proposedTarget: undefined, current: { ...record.candidate, baseRevision: undefined, proposal: undefined }, candidate: null });
118
+ }
119
+ if (records.length) commit(records);
120
+ return records.map(record => record.key);
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,117 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import sharp from "sharp";
7
+ import { loadConfig } from "./config.mjs";
8
+ import { inspectScenarios } from "./scenarios.mjs";
9
+ import { previewSourceRevision } from "./preview-inputs.mjs";
10
+ import { executeDevelopmentCommand } from "./verification.mjs";
11
+ import { captureKey, captureSpec, captureLimits, createCaptureStore } from "./capture-store.mjs";
12
+
13
+ export function captureSelections(values) {
14
+ if (!Array.isArray(values) || !values.length || values.length > captureLimits.selections) throw new Error("Select 1–8 exact scenarios/targets with captures:[{scenario,target?,theme?,viewport?}]. Discover IDs with ads_scenarios.");
15
+ const selections = values.map(value => captureSpec.parse(value));
16
+ if (new Set(selections.map(captureKey)).size !== selections.length) throw new Error("Select each scenario/target once; settings replace that target's slots rather than creating extra history.");
17
+ return selections;
18
+ }
19
+
20
+ export function createCaptureController(root) {
21
+ root = path.resolve(root);
22
+ const store = createCaptureStore(root);
23
+ let active, progress;
24
+ return {
25
+ store,
26
+ status: () => ({ running: !!active, ...(progress ? { progress: { ...progress, elapsedMs: Date.now() - progress.startedAt } } : {}) }),
27
+ async close() { active?.abort(); await active?.completion; },
28
+ async run({ captures, candidateRoot, proposal, publish = true } = {}, signal, notifyProgress) {
29
+ if (active) throw new Error("A screenshot capture is already running. Wait for ads_status.captures.running to clear.");
30
+ const selections = captureSelections(captures);
31
+ const controller = new AbortController(); active = controller;
32
+ signal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
33
+ let finish; controller.completion = new Promise(resolve => { finish = resolve; });
34
+ const hosts = [];
35
+ let temporary;
36
+ const startedAt = Date.now();
37
+ const update = (stage = progress?.stage ?? "preparing", completed = progress?.completed ?? 0, total = progress?.total ?? selections.length * (candidateRoot ? 2 : 1)) => {
38
+ progress = { profile: "screenshots", stage, startedAt, elapsedMs: Date.now() - startedAt, completed, total, active: [], message: `Screenshots: ${stage}; ${completed}/${total} images complete; ${Date.now() - startedAt} ms elapsed.` };
39
+ notifyProgress?.(progress);
40
+ };
41
+ const heartbeat = setInterval(update, 5000); heartbeat.unref();
42
+ try {
43
+ update();
44
+ const options = loadConfig(root), require = createRequire(path.join(root, "package.json"));
45
+ let playwright;
46
+ for (const name of ["@playwright/test", "playwright"]) { try { playwright = require.resolve(name); break; } catch { /* Try the other supported project-installed entry. */ } }
47
+ if (!playwright) throw new Error("Screenshots require project-local @playwright/test or playwright and its installed browser. ADS installs neither. Install/configure these in the connected project, then retry; mcp.capture selects browser/channel/timeoutMs.");
48
+ const projects = [root, ...(candidateRoot ? [path.resolve(candidateRoot)] : [])].map(directory => {
49
+ const options = loadConfig(directory), registry = inspectScenarios(options);
50
+ if (registry.diagnostics.length) throw new Error(`Scenario registration failed: ${registry.diagnostics[0].message}`);
51
+ const apps = selections.map((selection, selectionIndex) => {
52
+ const scenario = registry.scenarios.find(scenario => scenario.id === selection.scenario);
53
+ if ((!scenario || selection.target && !Object.hasOwn(scenario.captureTargets ?? {}, selection.target)) && directory === root && candidateRoot) return null;
54
+ if (!scenario) throw new Error(`Unknown scenario ${selection.scenario}. Discover exact IDs with ads_scenarios.`);
55
+ if (selection.target && !Object.hasOwn(scenario.captureTargets ?? {}, selection.target)) throw new Error(`Unknown capture target ${selection.target} in ${selection.scenario}. Discover captureTargets with ads_scenarios; omit target for the full widget viewport.`);
56
+ return { ...selection, selectionIndex, selector: selection.target ? scenario.captureTargets[selection.target] : "html", scenarioFile: scenario.file, fixtureFile: scenario.fixture };
57
+ }).filter(Boolean);
58
+ return { options, revision: previewSourceRevision(options), apps };
59
+ });
60
+ const outputRoot = path.join(root, ".ds");
61
+ mkdirSync(outputRoot, { recursive: true });
62
+ if (realpathSync(outputRoot) !== outputRoot) throw new Error("Capture work output cannot resolve through a symlink.");
63
+ for (const entry of readdirSync(outputRoot)) if (/^capture-work-[a-zA-Z0-9]+$/.test(entry)) {
64
+ const work = path.join(outputRoot, entry), ownerFile = path.join(work, "owner.json");
65
+ try {
66
+ if (!lstatSync(work).isDirectory() || realpathSync(work) !== work || realpathSync(ownerFile) !== ownerFile || lstatSync(ownerFile).size > 1024) continue;
67
+ const { pid } = JSON.parse(readFileSync(ownerFile, "utf8"));
68
+ if (!Number.isSafeInteger(pid) || pid <= 1) continue;
69
+ try { process.kill(pid, 0); } catch (error) { if (error.code === "ESRCH") rmSync(work, { recursive: true, force: true }); }
70
+ } catch { /* Unknown or still-active work directories are not ours to remove. */ }
71
+ }
72
+ temporary = mkdtempSync(path.join(outputRoot, "capture-work-"));
73
+ writeFileSync(path.join(temporary, "owner.json"), JSON.stringify({ pid: process.pid }));
74
+ const apps = [];
75
+ const { startAdsMcp } = await import("./mcp/server.mjs");
76
+ for (const [projectIndex, project] of projects.entries()) {
77
+ if (signal.aborted) throw new Error("Screenshot capture cancelled.");
78
+ if (!project.apps.length) { hosts.push(null); continue; }
79
+ const host = await startAdsMcp(project.options.root, { stdio: false, showcase: true });
80
+ hosts.push(host);
81
+ for (const selection of project.apps) {
82
+ const url = new URL(host.url); url.searchParams.set("scenario", selection.scenario); url.searchParams.set("capture", "1"); url.searchParams.set("theme", selection.theme);
83
+ apps.push({ ...selection, projectIndex, url: url.href });
84
+ }
85
+ }
86
+ const request = path.join(temporary, "request.json"), report = path.join(temporary, "report.json"), runId = randomUUID();
87
+ const settings = { browser: "chromium", timeoutMs: 60000, ...options.config.mcp.capture };
88
+ writeFileSync(request, JSON.stringify({ schemaVersion: 1, runId, playwright, settings, apps, output: temporary }));
89
+ update("rendering", 0, apps.length);
90
+ const run = await executeDevelopmentCommand({ command: "node", timeoutMs: settings.timeoutMs, label: "Screenshot capture" }, [fileURLToPath(new URL("./capture/worker.mjs", import.meta.url)), request], root, {}, signal);
91
+ if (run.code !== 0) throw new Error(`Screenshot browser failed (${run.code}): ${run.output.slice(-3000)}`);
92
+ if (!existsSync(report) || realpathSync(report) !== report || lstatSync(report).size > 128 * 1024) throw new Error("Screenshot runner produced no bounded report.");
93
+ const value = JSON.parse(readFileSync(report, "utf8"));
94
+ if (value.schemaVersion !== 1 || value.runId !== runId || !Array.isArray(value.images) || value.images.length !== apps.length) throw new Error("Screenshot runner returned the wrong run identity or image count.");
95
+ const images = new Map();
96
+ for (const [index, app] of apps.entries()) {
97
+ const file = path.join(temporary, `${index}.png`), stat = lstatSync(file);
98
+ if (!stat.isFile() || stat.nlink !== 1 || realpathSync(file) !== file || stat.size > captureLimits.imageBytes) throw new Error("Screenshot output must be an ordinary PNG no larger than 4 MiB.");
99
+ const bytes = readFileSync(file), metadata = await sharp(bytes).metadata();
100
+ if (metadata.format !== "png" || !metadata.width || !metadata.height || metadata.width * metadata.height > captureLimits.pixels) throw new Error("Screenshot output exceeds 8 megapixels or is not PNG.");
101
+ const project = projects[app.projectIndex];
102
+ images.set(`${app.projectIndex}:${app.selectionIndex}`, { mimeType: "image/png", blob: bytes.toString("base64"), digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, width: metadata.width, height: metadata.height, bytes: bytes.length, capturedAt: value.images[index].capturedAt, sourceRevision: project.revision, buildRevision: hosts[app.projectIndex].previewRevision(), settings: { theme: app.theme, viewport: app.viewport, animations: app.animations, browser: settings.browser, ...(settings.channel ? { channel: settings.channel } : {}), scale: "css" }, scenarioFile: app.scenarioFile, fixtureFile: app.fixtureFile });
103
+ }
104
+ for (const project of projects) if (previewSourceRevision(loadConfig(project.options.root)) !== project.revision) throw new Error("Project inputs changed while capturing. No capture slots were replaced; retry against current source.");
105
+ if (signal.aborted) throw new Error("Screenshot capture cancelled before publication.");
106
+ update("publishing", apps.length, apps.length);
107
+ const records = selections.map((selection, index) => ({ schemaVersion: 1, key: captureKey(selection), scenario: selection.scenario, ...(selection.target ? { target: selection.target } : {}), current: images.get(`0:${index}`) ?? null, ...(!images.has(`0:${index}`) ? { proposedTarget: true } : {}), candidate: candidateRoot ? { ...images.get(`1:${index}`), baseRevision: projects[0].revision, proposal } : null }));
108
+ if (!publish) return { records, baseRevision: projects[0].revision };
109
+ store.commit(records);
110
+ return { ok: true, captures: store.list(inspectScenarios(loadConfig(root)).scenarios, projects[0].revision).captures.filter(record => selections.some(selection => captureKey(selection) === record.key)) };
111
+ } finally {
112
+ try { await Promise.all(hosts.map(host => host?.close())); }
113
+ finally { clearInterval(heartbeat); try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { active = undefined; progress = undefined; finish(); } }
114
+ }
115
+ },
116
+ };
117
+ }
package/dist/config.mjs CHANGED
@@ -17,7 +17,7 @@ export const defaultConfig = {
17
17
  boundaries: { styleWrites: [], componentVariables: [] },
18
18
  assets: { publicDirs: ["public/assets"], generatedPaths: ["dist", "dist-showcase", ".vite", ".ds"], generatedFiles: [], iconSource: null },
19
19
  oxlint: { plugins: [], rules: {}, overrides: [] },
20
- mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null, behaviorProfiles: {} },
20
+ mcp: { showcaseUrl: null, showcaseTitle: null, apps: [], previewWatch: null, previewManifest: null, mutationChecks: null, behaviorProfiles: {}, capture: {} },
21
21
  };
22
22
 
23
23
  export function isInside(parent, file) {
@@ -100,6 +100,11 @@ export function loadConfig(directory = process.cwd()) {
100
100
  }
101
101
  if (config.mcp.showcaseTitle !== null && (typeof config.mcp.showcaseTitle !== "string" || !config.mcp.showcaseTitle.trim() || config.mcp.showcaseTitle.length > 120)) throw new Error("mcp.showcaseTitle must be a non-empty title of at most 120 characters or null.");
102
102
  if (!Array.isArray(config.mcp.apps)) throw new Error("mcp.apps must be an array of built project previews.");
103
+ const capture = config.mcp.capture;
104
+ if (!capture || typeof capture !== "object" || Array.isArray(capture) || Object.keys(capture).some(key => !["browser", "channel", "timeoutMs"].includes(key))
105
+ || !["chromium", "firefox", "webkit"].includes(capture.browser ?? "chromium")
106
+ || capture.channel !== undefined && (typeof capture.channel !== "string" || !/^[\w-]{1,60}$/.test(capture.channel) || ![undefined, "chromium"].includes(capture.browser))
107
+ || capture.timeoutMs !== undefined && (!Number.isInteger(capture.timeoutMs) || capture.timeoutMs < 100 || capture.timeoutMs > 300000)) throw new Error("mcp.capture accepts browser (chromium/firefox/webkit), an optional Chromium channel, and timeoutMs (100–300000). Install Playwright and its browser in the project; ADS installs nothing.");
103
108
  if (!config.mcp.behaviorProfiles || typeof config.mcp.behaviorProfiles !== "object" || Array.isArray(config.mcp.behaviorProfiles) || Object.keys(config.mcp.behaviorProfiles).length > 20) throw new Error("mcp.behaviorProfiles must be an object of up to 20 registered execution profiles.");
104
109
  for (const [name, profile] of Object.entries(config.mcp.behaviorProfiles)) {
105
110
  if (!/^[a-zA-Z0-9][\w.-]{0,79}$/.test(name) || !profile || typeof profile !== "object" || Array.isArray(profile)
@@ -5,7 +5,7 @@ import { projectGuideFiles } from "../project-documents.mjs";
5
5
 
6
6
  export const GUIDE_URI = "ads://project/guide";
7
7
  // Put the task triggers and entry path first: some hosts shorten server guidance.
8
- export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
8
+ export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For requested visual evidence, use ads_capture or screenshot-enabled patch dry runs; read ads://guides/captures for named targets, image resources and the current/candidate lifecycle. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
9
9
 
10
10
  function manager(root, pkg) {
11
11
  if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
@@ -11,6 +11,9 @@ import { generateLintConfig } from "../lint-config.mjs";
11
11
  import { generateBrandAssets } from "../brand-assets.mjs";
12
12
  import { projectContext } from "./context.mjs";
13
13
  import { applyFilePatch, parseUnifiedPatch } from "./patch.mjs";
14
+ import { createCaptureController, captureSelections } from "../capture.mjs";
15
+ import { previewSourceRevision } from "../preview-inputs.mjs";
16
+ import { inspectScenarios } from "../scenarios.mjs";
14
17
  import { checkUnitCandidate, checkUnitFiles, inspectUnits, validateUnitId } from "../units.mjs";
15
18
 
16
19
  export const mutationLimits = { units: 8, files: 32, patchBytes: 256 * 1024, patchFileBytes: 16 * 1024 * 1024, fileBytes: 16 * 1024 * 1024, readChars: 12000, snapshotFiles: 20000, snapshotBytes: 128 * 1024 * 1024 };
@@ -143,10 +146,11 @@ function checks(options) {
143
146
  return scripts.map(script => ({ command: context.packageManager, args: ["run", script] }));
144
147
  }
145
148
 
146
- export function createMutationController(directory) {
149
+ export function createMutationController(directory, { captures = createCaptureController(directory) } = {}) {
147
150
  const root = path.resolve(directory);
148
151
  let busy = false;
149
152
  let lastResult = null;
153
+ let progress;
150
154
  let active;
151
155
  return {
152
156
  status() {
@@ -154,7 +158,7 @@ export function createMutationController(directory) {
154
158
  let commands = [], diagnostics = [];
155
159
  try { commands = checks(options).map(check => [check.command, ...check.args].join(" ")); }
156
160
  catch (error) { diagnostics = [error.diagnostic ?? { rule: "mutation-check", message: error.message }]; }
157
- return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, lastResult };
161
+ return { enabled: true, scope: "one-ds-unit", format: "git-unified-diff", transactions:{tool:"ads_apply_transaction",maxUnits:mutationLimits.units,finalCandidateChecks:true}, preparation:{owner:"ads_prepare_patch",tokenSamples:"ads_prepare_token_samples"}, dsDir: options.config.dsDir, limits: mutationLimits, checks: ["ads check", "registered Oxlint rules", ...commands], diagnostics, busy, ...(progress ? { progress } : {}), lastResult };
158
162
  },
159
163
  cancel() { active?.abort(); },
160
164
  units() { return inspectUnits(loadConfig(root)).units; },
@@ -170,7 +174,7 @@ export function createMutationController(directory) {
170
174
  return { ...source, unit, content, offset, nextOffset: end < source.content.length ? end : null, totalChars: source.content.length,
171
175
  startLine: source.content.slice(0, offset).split("\n").length };
172
176
  },
173
- async apply({ unit, patch, patchFile, patches, dryRun = false }, signal) {
177
+ async apply({ unit, patch, patchFile, patches, dryRun = false, screenshot = false, captures: selections }, signal, notifyProgress) {
174
178
  if (busy) return { ok: false, applied: false, diagnostics: [{ rule: "mutation-busy", message: "Another mutation is validating; retry after it finishes." }] };
175
179
  busy = true;
176
180
  active = new AbortController();
@@ -179,6 +183,8 @@ export function createMutationController(directory) {
179
183
  const verified = [];
180
184
  try {
181
185
  const options = loadConfig(root);
186
+ if (screenshot && !dryRun || selections !== undefined && !screenshot) throw fail("mutation-capture", "Use screenshot:true only with dryRun:true and an explicit captures:[{scenario,target?,theme?,viewport?}] selection. Ordinary patches create no screenshots.");
187
+ if (screenshot) selections = captureSelections(selections);
182
188
  if (patches !== undefined && (unit !== undefined || patch !== undefined || patchFile !== undefined || !Array.isArray(patches) || patches.length < 2 || patches.length > mutationLimits.units)) throw fail("mutation-batch", "A transaction requires 2–8 distinct owner patches, without a top-level unit/patch.");
183
189
  const groups = patches ?? [{unit,patch,patchFile}];
184
190
  if (new Set(groups.map(group => group.unit)).size !== groups.length) throw fail("mutation-batch", "Use one patch per distinct unit in a transaction.");
@@ -279,17 +285,29 @@ export function createMutationController(directory) {
279
285
  for (const check of extraChecks) verified.push(await checkCommand(check.command, check.args, candidate, signal));
280
286
  const behaviorUnits = affectedBehaviorUnits(candidateOptions, changes.map(change => change.path));
281
287
  if (behaviorUnits.length) {
282
- const verification = createVerificationController(candidate);
288
+ const verification = createVerificationController(candidate, { reportRoot: root, onProgress: value => { progress = { stage: "behavior", ...value }; notifyProgress?.(progress); } });
283
289
  let evidence;
284
290
  try { evidence = await verification.run({ units: behaviorUnits, persist: false }, signal); }
285
291
  finally { await verification.close(); }
286
292
  if (!evidence.ok) return lastResult = { ok: false, applied: false, diagnostics: [...evidence.diagnostics, ...evidence.results.filter(item => item.status !== "passed").map(item => ({ rule: "behavior-check", message: `${item.unit}#${item.check}: ${item.status}. ${item.message}` }))].slice(0, 50), checks: verified, behavior: evidence };
287
293
  verified.push({ name: "registered behavior checks", passed: true, results: evidence.results });
288
294
  }
295
+ let screenshots;
296
+ if (screenshot) {
297
+ progress = { stage: "capture", total: selections.length, completed: 0, active: [], profile: "screenshots", elapsedMs: 0 };
298
+ notifyProgress?.(progress);
299
+ const proposal = digest(Buffer.from(JSON.stringify(changes.map(change => [change.path, change.content === null ? null : digest(Buffer.from(change.content))]))));
300
+ screenshots = await captures.run({ captures: selections, candidateRoot: candidate, proposal, publish: false }, signal, value => { progress = value; notifyProgress?.(value); });
301
+ }
289
302
  if (signal?.aborted) throw fail("mutation-cancelled", "Mutation cancelled before application.");
290
303
  if (snapshot(candidateOptions) !== expected) throw fail("mutation-check", "A validation command changed source files. Use checks without source-writing side effects.");
291
304
  if (snapshot(loadConfig(root)) !== before) throw fail("mutation-conflict", "Project inputs changed during validation; no changes were applied. Read current source and retry.");
292
305
  for (const change of changes) address(options, change.path);
306
+ if (screenshots) {
307
+ captures.store.commit(screenshots.records);
308
+ const selected = new Set(screenshots.records.map(record => record.key));
309
+ screenshots = { ok: true, captures: captures.store.list(inspectScenarios(loadConfig(root)).scenarios, screenshots.baseRevision).captures.filter(record => selected.has(record.key)) };
310
+ }
293
311
  if (!dryRun) {
294
312
  const written = [];
295
313
  const directories = [];
@@ -317,11 +335,13 @@ export function createMutationController(directory) {
317
335
  }
318
336
  const warnings = [];
319
337
  if (!dryRun && (assets || configuration)) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
338
+ if (!dryRun && captures.store.fingerprint()) { try { captures.store.promote(previewSourceRevision(loadConfig(root)), inspectScenarios(loadConfig(root)).scenarios); } catch (error) { warnings.push({ rule: "mutation-capture", message: `Source was applied; capture promotion needs recovery: ${error.message}` }); } }
320
339
  return lastResult = { ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
340
+ ...(screenshots ? { screenshots } : {}),
321
341
  preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." };
322
342
  } catch (error) {
323
343
  return lastResult = { ok: false, applied: false, diagnostics: [error.diagnostic ?? { rule: "mutation-error", message: error.message }], checks: verified };
324
- } finally { try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { busy = false; active = undefined; } }
344
+ } finally { try { if (temporary) rmSync(temporary, { recursive: true, force: true }); } finally { busy = false; active = undefined; progress = undefined; } }
325
345
  },
326
346
  };
327
347
  }