@bridge-ds/mcp 0.0.0-stage → 0.3.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.
@@ -0,0 +1,1125 @@
1
+ import {
2
+ LayeredTokenSet,
3
+ comboKey,
4
+ diffLayered,
5
+ git,
6
+ listFilesAt,
7
+ loadConfig,
8
+ selectTokenFiles,
9
+ showFile
10
+ } from "./chunk-X455XY4E.js";
11
+
12
+ // ../visual/src/run.ts
13
+ import { existsSync as existsSync3 } from "node:fs";
14
+ import { mkdir as mkdir3, readFile as readFile3, rm as rm2, unlink, writeFile as writeFile3 } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join as join3, relative as relative2, resolve } from "node:path";
17
+
18
+ // ../core/src/usage.ts
19
+ var VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
20
+ var IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
21
+ function cssVarsIn(text) {
22
+ const out = /* @__PURE__ */ new Set();
23
+ for (const m of text.matchAll(VAR_RE)) out.add(m[1]);
24
+ return [...out];
25
+ }
26
+ function importsIn(text) {
27
+ const out = /* @__PURE__ */ new Set();
28
+ for (const m of text.matchAll(IMPORT_RE)) out.add(m[1] ?? m[2]);
29
+ return [...out];
30
+ }
31
+ var norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
32
+ var isStory = (p) => /\.stories\.[cm]?[jt]sx?$/.test(p);
33
+ var isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
34
+ var isCss = (p) => /\.(css|scss|less)$/.test(p);
35
+ var isScript = (p) => /\.[cm]?[jt]sx?$/.test(p);
36
+ function componentOf(from, spec, componentsDir) {
37
+ if (!spec.startsWith(".")) return null;
38
+ const parts = from.split("/").slice(0, -1);
39
+ for (const seg of spec.split("/")) {
40
+ if (seg === "." || seg === "") continue;
41
+ if (seg === "..") parts.pop();
42
+ else parts.push(seg);
43
+ }
44
+ const abs = parts.join("/");
45
+ if (!abs.startsWith(`${componentsDir}/`)) return null;
46
+ return abs.slice(componentsDir.length + 1).split("/")[0] || null;
47
+ }
48
+ function componentForPath(path, componentsDir) {
49
+ const dir = norm(componentsDir);
50
+ const p = norm(path);
51
+ if (!p.startsWith(`${dir}/`)) return null;
52
+ const rest = p.slice(dir.length + 1).split("/");
53
+ return rest.length >= 2 ? rest[0] : null;
54
+ }
55
+ function buildUsageIndex(files, componentsDir) {
56
+ const dir = norm(componentsDir);
57
+ const modules = {};
58
+ const vars = /* @__PURE__ */ new Map();
59
+ const imports = /* @__PURE__ */ new Map();
60
+ const storyImports = /* @__PURE__ */ new Map();
61
+ const globalVars = {};
62
+ const get = (m, k) => m.get(k) ?? m.set(k, /* @__PURE__ */ new Set()).get(k);
63
+ for (const f of files) {
64
+ const path = norm(f.path);
65
+ const id = componentForPath(path, dir);
66
+ if (!id) {
67
+ if (isCss(path)) {
68
+ const v = cssVarsIn(f.text);
69
+ if (v.length) globalVars[path] = v.sort();
70
+ }
71
+ continue;
72
+ }
73
+ const mod = modules[id] ??= { id, dir: `${dir}/${id}`, files: [], vars: [], imports: [], storyImports: [] };
74
+ mod.files.push(path);
75
+ if (isTest(path)) continue;
76
+ for (const v of cssVarsIn(f.text)) get(vars, id).add(v);
77
+ if (!isScript(path)) continue;
78
+ for (const spec of importsIn(f.text)) {
79
+ const dep = componentOf(path, spec, dir);
80
+ if (!dep || dep === id) continue;
81
+ get(isStory(path) ? storyImports : imports, id).add(dep);
82
+ }
83
+ }
84
+ const byVar = {};
85
+ for (const mod of Object.values(modules)) {
86
+ mod.files.sort();
87
+ mod.vars = [...vars.get(mod.id) ?? []].sort();
88
+ mod.imports = [...imports.get(mod.id) ?? []].sort();
89
+ mod.storyImports = [...storyImports.get(mod.id) ?? []].filter((d) => !mod.imports.includes(d)).sort();
90
+ for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
91
+ }
92
+ for (const k of Object.keys(byVar)) byVar[k].sort();
93
+ return { componentsDir: dir, modules, byVar, globalVars };
94
+ }
95
+ function impactedModules(index, input) {
96
+ const reasons = /* @__PURE__ */ new Map();
97
+ const add = (id, reason, indirect) => {
98
+ const r = reasons.get(id);
99
+ if (r) {
100
+ if (!r.reasons.includes(reason)) r.reasons.push(reason);
101
+ r.indirect &&= indirect;
102
+ } else reasons.set(id, { reasons: [reason], indirect });
103
+ };
104
+ const changedVars = new Set(input.vars ?? []);
105
+ for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
106
+ for (const f of input.files ?? []) {
107
+ const id = componentForPath(f, index.componentsDir);
108
+ if (id && index.modules[id]) add(id, `file changed: ${norm(f)}`, false);
109
+ }
110
+ const renderedBy = /* @__PURE__ */ new Map();
111
+ for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
112
+ const queue = [...reasons.keys()];
113
+ while (queue.length) {
114
+ const id = queue.shift();
115
+ for (const parent of renderedBy.get(id) ?? []) {
116
+ const known = reasons.has(parent);
117
+ add(parent, `renders ${id}`, true);
118
+ if (!known) queue.push(parent);
119
+ }
120
+ }
121
+ const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
122
+ const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
123
+ return { modules, globalHits };
124
+ }
125
+
126
+ // ../visual/src/capture.ts
127
+ import { mkdir, writeFile } from "node:fs/promises";
128
+ import { dirname } from "node:path";
129
+ import { chromium } from "playwright-core";
130
+ var FROZEN_TIME = "2026-01-15T10:00:00.000Z";
131
+ var FREEZE_CSS = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;animation-iteration-count:1!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}
132
+ ::-webkit-scrollbar{display:none}
133
+ [data-visual-mask]{visibility:hidden!important}`;
134
+ var INIT_SCRIPT = `(() => {
135
+ let a = 0x2f6b1d;
136
+ Math.random = function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
137
+ const add = () => { const s = document.createElement('style'); s.setAttribute('data-bridge-visual', ''); s.textContent = ${JSON.stringify(FREEZE_CSS)}; (document.head || document.documentElement).appendChild(s); };
138
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add();
139
+ })();`;
140
+ var PIXEL = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8+P9/PQAJNAPNdHq0qgAAAABJRU5ErkJggg==", "base64");
141
+ async function launch(o = {}) {
142
+ const executablePath = o.executablePath ?? process.env.BRIDGE_CHROME_PATH;
143
+ return chromium.launch({
144
+ headless: true,
145
+ ...executablePath ? { executablePath } : { channel: o.channel ?? process.env.BRIDGE_CHROME_CHANNEL ?? "chrome" },
146
+ args: ["--lang=en-US", "--disable-gpu", "--font-render-hinting=none", "--disable-lcd-text", "--force-color-profile=srgb", "--hide-scrollbars", "--disable-skia-runtime-opts", "--disable-renderer-backgrounding"]
147
+ });
148
+ }
149
+ async function newContext(browser, viewport) {
150
+ const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, reducedMotion: "reduce", colorScheme: "light", locale: "en-US", timezoneId: "UTC", serviceWorkers: "block" });
151
+ await ctx.addInitScript(INIT_SCRIPT);
152
+ await ctx.route(
153
+ /^https?:\/\/(?!127\.0\.0\.1[:/]|localhost[:/])/,
154
+ (route) => route.request().resourceType() === "image" ? route.fulfill({ status: 200, contentType: "image/png", body: PIXEL }) : route.abort()
155
+ );
156
+ return ctx;
157
+ }
158
+ async function newPage(ctx) {
159
+ const page = await ctx.newPage();
160
+ await page.clock.setFixedTime(new Date(FROZEN_TIME));
161
+ return page;
162
+ }
163
+ async function settle(page, o = {}) {
164
+ const timeout = o.timeout ?? 1e4;
165
+ if (o.network !== false) await page.waitForLoadState("networkidle", { timeout }).catch(() => void 0);
166
+ const work = page.evaluate(async ({ limit, quiet, interval, waitBusy }) => {
167
+ const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
168
+ await document.fonts.ready;
169
+ const sig = () => `${document.body.innerHTML.length}:${document.querySelectorAll("*").length}`;
170
+ let last = "";
171
+ let same = 0;
172
+ const t0 = performance.now();
173
+ while (performance.now() - t0 < limit) {
174
+ await frames();
175
+ await new Promise((r) => setTimeout(r, interval));
176
+ const s = sig();
177
+ const busy = waitBusy && document.querySelectorAll('[aria-busy="true"]').length > 0;
178
+ if (s === last && !busy) same++;
179
+ else same = 0;
180
+ last = s;
181
+ if (same >= quiet) break;
182
+ }
183
+ const pending = Array.from(document.images).filter((i) => !i.complete && i.loading !== "lazy");
184
+ await Promise.race([Promise.all(pending.map((i) => new Promise((r) => (i.onload = r, i.onerror = r)))), new Promise((r) => setTimeout(r, 3e3))]);
185
+ await document.fonts.ready;
186
+ await frames();
187
+ }, { limit: timeout, quiet: o.quiet ?? 3, interval: o.interval ?? 120, waitBusy: o.busy ?? true });
188
+ let timer;
189
+ await Promise.race([work, new Promise((r) => timer = setTimeout(r, timeout + 5e3))]).finally(() => clearTimeout(timer));
190
+ }
191
+ async function shoot(page, r) {
192
+ const debug = process.env.BRIDGE_VISUAL_DEBUG ? (step) => process.stderr.write(` ${step} ${Date.now() - t0}ms
193
+ `) : () => void 0;
194
+ const t0 = Date.now();
195
+ try {
196
+ if (r.navigate) await r.navigate(page);
197
+ else await page.goto(r.url, { waitUntil: "load", timeout: 3e4 });
198
+ if (r.ready) await page.waitForSelector(r.ready, { state: "attached", timeout: 15e3 });
199
+ if (r.readyFn) await page.waitForFunction(r.readyFn, void 0, { timeout: 2e4 });
200
+ if (r.waitFor) await page.waitForSelector(r.waitFor, { state: "visible", timeout: 15e3 });
201
+ debug("ready");
202
+ await settle(page, r.settle);
203
+ debug("settled");
204
+ const err = await page.evaluate(() => document.body.classList.contains("sb-show-errordisplay") ? document.querySelector("#error-message")?.textContent ?? "Story failed to render" : null);
205
+ const buf = await page.screenshot({
206
+ fullPage: r.fullPage,
207
+ animations: "disabled",
208
+ caret: "hide",
209
+ scale: "css",
210
+ mask: (r.mask ?? []).map((s) => page.locator(s)),
211
+ maskColor: "#ff00ff"
212
+ });
213
+ debug("shot");
214
+ await mkdir(dirname(r.file), { recursive: true });
215
+ await writeFile(r.file, buf);
216
+ return err;
217
+ } catch (e) {
218
+ return e.message.split("\n")[0] ?? "capture failed";
219
+ }
220
+ }
221
+ async function switchStory(page, storyId2) {
222
+ const ok = await page.evaluate(
223
+ (id) => new Promise((resolve2) => {
224
+ const ch = window.__STORYBOOK_ADDONS_CHANNEL__;
225
+ if (!ch) return resolve2(false);
226
+ const events = ["storyRendered", "storyErrored", "storyThrewException", "storyMissing", "playFunctionThrewException"];
227
+ const timer = setTimeout(() => done(), 15e3);
228
+ function done() {
229
+ clearTimeout(timer);
230
+ for (const e of events) ch.off(e, done);
231
+ resolve2(true);
232
+ }
233
+ for (const e of events) ch.on(e, done);
234
+ window.scrollTo(0, 0);
235
+ ch.emit("setCurrentStory", { storyId: id, viewMode: "story" });
236
+ }),
237
+ storyId2
238
+ );
239
+ if (!ok) throw new Error("Storybook channel not found");
240
+ }
241
+
242
+ // ../visual/src/composite.ts
243
+ import { readFile, writeFile as writeFile2 } from "node:fs/promises";
244
+ var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
245
+ async function composite(page, c) {
246
+ const col = Math.min(c.column ?? 520, c.width);
247
+ const k = col / c.width;
248
+ const h = Math.round(c.height * k);
249
+ const img = async (p) => p ? `data:image/png;base64,${(await readFile(p)).toString("base64")}` : null;
250
+ const [b, a, d] = await Promise.all([img(c.before), img(c.after), img(c.diff)]);
251
+ const boxes = c.boxes.map((x) => `<i style="left:${x.x * k - 2}px;top:${x.y * k - 2}px;width:${x.width * k + 4}px;height:${x.height * k + 4}px"></i>`).join("");
252
+ const big = [...c.boxes].sort((p, q) => q.width * q.height - p.width * p.height)[0];
253
+ let zoomRow = "";
254
+ if (big && c.boxes.length && big.width * big.height < c.width * c.height * 0.5) {
255
+ const pad = 24;
256
+ const z = Math.min(2, col / Math.min(c.width, big.width + pad * 2));
257
+ const w = Math.min(c.width, col / z);
258
+ const hh = Math.min(c.height, Math.max(big.height + pad * 2, 100), 360 / z);
259
+ const clamp = (v, max) => Math.max(0, Math.min(v, max));
260
+ const x = clamp(big.x + big.width / 2 - w / 2, c.width - w);
261
+ const y = clamp(big.y + big.height / 2 - hh / 2, c.height - hh);
262
+ const crop = (label, src) => `<figure><figcaption>${label}</figcaption><div class="frame" style="width:${col}px;height:${Math.round(hh * z)}px">${src ? `<div style="width:${Math.round(w * z)}px;height:${Math.round(hh * z)}px;background:url(${src}) ${-x * z}px ${-y * z}px / ${c.width * z}px ${c.height * z}px no-repeat;image-rendering:pixelated"></div>` : ""}</div></figure>`;
263
+ zoomRow = `<div class="row zoom">${crop(`Before, zoom ${z.toFixed(1)}x`, b)}${crop("After", a)}${crop("Diff", d)}</div>`;
264
+ }
265
+ const panel = (label, src, withBoxes) => `<figure><figcaption>${label}</figcaption><div class="frame" style="width:${col}px;height:${h}px">${src ? `<img src="${src}" width="${col}" height="${h}">` : '<span class="none">Not present</span>'}${withBoxes ? boxes : ""}</div></figure>`;
266
+ await page.setContent(
267
+ `<!doctype html><html><head><style>
268
+ *{box-sizing:border-box;margin:0}
269
+ body{background:#f4f4f5;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:#18181b;padding:16px;display:inline-block}
270
+ header{display:flex;justify-content:space-between;align-items:baseline;gap:24px;margin-bottom:12px}
271
+ h1{font-size:16px;font-weight:600}
272
+ p{color:#52525b;font-variant-numeric:tabular-nums}
273
+ .row{display:flex;gap:12px}
274
+ .zoom{margin-top:12px}
275
+ figcaption{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#52525b;margin-bottom:6px}
276
+ .frame{position:relative;background:#fff;outline:1px solid #d4d4d8;overflow:hidden}
277
+ .frame img{display:block}
278
+ .frame i{position:absolute;border:2px solid #e5143c;border-radius:3px}
279
+ .none{position:absolute;inset:0;display:grid;place-items:center;color:#71717a}
280
+ </style></head><body><div id="c"><header><h1>${esc(c.title)}</h1><p>${esc(c.subtitle)}</p></header><div class="row">${panel("Before", b, false)}${panel("After", a, true)}${panel("Diff", d, true)}</div>${zoomRow}</div></body></html>`,
281
+ { waitUntil: "load" }
282
+ );
283
+ const buf = await page.locator("body").screenshot({ animations: "disabled", scale: "css" });
284
+ await writeFile2(c.out, buf);
285
+ }
286
+
287
+ // ../visual/src/diff.ts
288
+ import pixelmatch from "pixelmatch";
289
+ import { PNG } from "pngjs";
290
+ var decodePng = (buf) => PNG.sync.read(buf);
291
+ var encodePng = (png) => PNG.sync.write(png, { colorType: 6 });
292
+ function padTo(src, width, height) {
293
+ if (src.width === width && src.height === height) return src;
294
+ const out = new PNG({ width, height });
295
+ for (let i = 0; i < out.data.length; i += 4) {
296
+ out.data[i] = 255;
297
+ out.data[i + 1] = 0;
298
+ out.data[i + 2] = 255;
299
+ out.data[i + 3] = 255;
300
+ }
301
+ PNG.bitblt(src, out, 0, 0, Math.min(src.width, width), Math.min(src.height, height), 0, 0);
302
+ return out;
303
+ }
304
+ function diffPngs(before, after, threshold = 0.03, cell2 = 16) {
305
+ const width = Math.max(before.width, after.width);
306
+ const height = Math.max(before.height, after.height);
307
+ const a = padTo(before, width, height);
308
+ const b = padTo(after, width, height);
309
+ const out = new PNG({ width, height });
310
+ const changedPixels = pixelmatch(a.data, b.data, out.data, width, height, { threshold, includeAA: false, alpha: 0.15, diffColor: [229, 20, 60], diffColorAlt: [229, 20, 60] });
311
+ const totalPixels = width * height;
312
+ const percent = totalPixels ? Math.round(changedPixels / totalPixels * 1e4) / 100 : 0;
313
+ const boxes = changedPixels ? boxesFromDiff(out, cell2) : [];
314
+ return { changedPixels, totalPixels, percent: changedPixels && percent === 0 ? 0.01 : percent, boxes, diff: changedPixels ? out : null, width, height };
315
+ }
316
+ var isDiffPixel = (d, i) => d[i] === 229 && d[i + 1] === 20 && d[i + 2] === 60;
317
+ function boxesFromDiff(diff, cell2 = 16, max = 12) {
318
+ const cols = Math.ceil(diff.width / cell2);
319
+ const rows = Math.ceil(diff.height / cell2);
320
+ const hot = new Uint8Array(cols * rows);
321
+ for (let y = 0; y < diff.height; y++) {
322
+ for (let x = 0; x < diff.width; x++) {
323
+ if (isDiffPixel(diff.data, (y * diff.width + x) * 4)) hot[Math.floor(y / cell2) * cols + Math.floor(x / cell2)] = 1;
324
+ }
325
+ }
326
+ return boxesFromCells(hot, cols, rows, cell2, diff.width, diff.height, max);
327
+ }
328
+ function boxesFromCells(hot, cols, rows, cell2, width, height, max = 12) {
329
+ const seen = new Uint8Array(cols * rows);
330
+ let boxes = [];
331
+ for (let i = 0; i < hot.length; i++) {
332
+ if (!hot[i] || seen[i]) continue;
333
+ let x0 = cols, y0 = rows, x1 = -1, y1 = -1;
334
+ const stack = [i];
335
+ seen[i] = 1;
336
+ while (stack.length) {
337
+ const j = stack.pop();
338
+ const cx = j % cols;
339
+ const cy = (j - cx) / cols;
340
+ x0 = Math.min(x0, cx);
341
+ y0 = Math.min(y0, cy);
342
+ x1 = Math.max(x1, cx);
343
+ y1 = Math.max(y1, cy);
344
+ for (let dy = -2; dy <= 2; dy++) {
345
+ for (let dx = -2; dx <= 2; dx++) {
346
+ const nx = cx + dx;
347
+ const ny = cy + dy;
348
+ if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
349
+ const k = ny * cols + nx;
350
+ if (hot[k] && !seen[k]) {
351
+ seen[k] = 1;
352
+ stack.push(k);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ boxes.push({ x: x0 * cell2, y: y0 * cell2, width: Math.min((x1 + 1) * cell2, width) - x0 * cell2, height: Math.min((y1 + 1) * cell2, height) - y0 * cell2 });
358
+ }
359
+ boxes.sort((p, q) => p.y - q.y || p.x - q.x);
360
+ while (boxes.length > max) {
361
+ boxes.sort((p, q) => p.width * p.height - q.width * q.height);
362
+ const s = boxes.shift();
363
+ let best = 0;
364
+ let bestArea = Infinity;
365
+ boxes.forEach((b, k) => {
366
+ const u = union(s, b);
367
+ if (u.width * u.height < bestArea) bestArea = u.width * u.height, best = k;
368
+ });
369
+ boxes[best] = union(s, boxes[best]);
370
+ boxes = boxes.sort((p, q) => p.y - q.y || p.x - q.x);
371
+ }
372
+ return boxes;
373
+ }
374
+ function union(a, b) {
375
+ const x = Math.min(a.x, b.x);
376
+ const y = Math.min(a.y, b.y);
377
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
378
+ }
379
+
380
+ // ../visual/src/manifest.ts
381
+ var COMMENT_MARKER = "<!-- bridge-visual-impact -->";
382
+ function summarize(shots) {
383
+ const count = (s) => shots.filter((x) => x.status === s).length;
384
+ return {
385
+ shots: shots.length,
386
+ changed: count("changed"),
387
+ unchanged: count("unchanged"),
388
+ added: count("added"),
389
+ removed: count("removed"),
390
+ errors: count("error"),
391
+ maxPercent: shots.reduce((m, s) => Math.max(m, s.percent), 0)
392
+ };
393
+ }
394
+ function rankShots(shots) {
395
+ const rank = { changed: 0, added: 1, removed: 2, error: 3, unchanged: 4 };
396
+ return shots.map((s, i) => ({ s, i })).sort((a, b) => rank[a.s.status] - rank[b.s.status] || b.s.percent - a.s.percent || a.i - b.i).map((x) => x.s);
397
+ }
398
+ var hasVisualChange = (m) => m.summary.changed + m.summary.added + m.summary.removed > 0;
399
+ var mode = (s) => `${s.tenant ? `${s.tenant}, ` : ""}${s.brand}, ${s.theme}`;
400
+ var modeShort = (s) => `${s.tenant ?? s.brand} ${s.theme}`;
401
+ var pct = (s) => s.status === "changed" ? `${s.percent.toFixed(2)}%` : s.status === "unchanged" ? "0%" : s.status;
402
+ var short = (sha) => sha.slice(0, 7);
403
+ var cell = (t) => t.replace(/\|/g, "\\|").replace(/\n/g, " ");
404
+ function summaryLine(m) {
405
+ const s = m.summary;
406
+ if (m.options.plan) {
407
+ const p = m.options.planned ?? { modes: 0, appScreens: 0 };
408
+ return `Plan: ${m.selection.stories.length} stories in ${p.modes} modes and ${p.appScreens} app screens would be captured between ${m.base.ref} (${short(m.base.sha)}) and ${m.head.ref} (${short(m.head.sha)}).`;
409
+ }
410
+ if (!s.shots) return `Nothing to capture: ${m.selection.app.reason.toLowerCase()} and no story is impacted.`;
411
+ const parts = [`${s.changed} of ${s.shots} screenshot${s.shots > 1 ? "s" : ""} changed`];
412
+ if (s.added) parts.push(`${s.added} new`);
413
+ if (s.removed) parts.push(`${s.removed} gone`);
414
+ if (s.errors) parts.push(`${s.errors} failed`);
415
+ return `${parts.join(", ")} between ${m.base.ref} (${short(m.base.sha)}) and ${m.head.ref} (${short(m.head.sha)}).${s.changed ? ` Largest change ${s.maxPercent.toFixed(2)}%.` : ""}`;
416
+ }
417
+ function groupByLabel(shots) {
418
+ const out = /* @__PURE__ */ new Map();
419
+ for (const s of shots) (out.get(s.label) ?? out.set(s.label, { label: s.label, shots: [] }).get(s.label)).shots.push(s);
420
+ return [...out.values()];
421
+ }
422
+ function pickImages(shots, max) {
423
+ const out = [];
424
+ const seen = /* @__PURE__ */ new Set();
425
+ const take = (s) => {
426
+ if (out.length < max && !out.includes(s)) out.push(s);
427
+ };
428
+ for (const s of shots.filter((x) => x.kind === "route")) {
429
+ if (out.length >= 2) break;
430
+ if (seen.has(s.label)) continue;
431
+ seen.add(s.label);
432
+ take(s);
433
+ }
434
+ for (const s of shots) {
435
+ if (seen.has(s.label)) continue;
436
+ seen.add(s.label);
437
+ take(s);
438
+ }
439
+ for (const s of shots) take(s);
440
+ return out;
441
+ }
442
+ function renderComment(m, o = {}) {
443
+ const maxImages = o.maxImages ?? 6;
444
+ const maxRows = o.maxRows ?? 40;
445
+ const ranked = rankShots(m.shots);
446
+ const changed = ranked.filter((s) => s.status !== "unchanged");
447
+ const unchanged = ranked.filter((s) => s.status === "unchanged");
448
+ const lines = [COMMENT_MARKER, "## Visual impact", ""];
449
+ lines.push(`${hasVisualChange(m) ? "**" : ""}${summaryLine(m)}${hasVisualChange(m) ? "**" : ""}`);
450
+ lines.push("");
451
+ const why = [];
452
+ if (m.selection.tokens.length) {
453
+ const direct = m.selection.tokens.filter((t) => t.kind === "direct").map((t) => `\`${t.path}\``);
454
+ why.push(`${m.selection.tokens.length} token value${m.selection.tokens.length > 1 ? "s" : ""} change${direct.length ? ` (edited: ${direct.slice(0, 5).join(", ")}${direct.length > 5 ? ", ..." : ""})` : ""}`);
455
+ }
456
+ if (m.selection.components.length) why.push(`components in scope: ${m.selection.components.map((c) => c.id).join(", ")}`);
457
+ if (m.selection.fallback) why.push(m.selection.fallback);
458
+ if (why.length) lines.push(`Why these screens: ${why.join("; ")}.`, "");
459
+ if (changed.length) {
460
+ const groups = groupByLabel(changed);
461
+ lines.push("| Story or screen | Changed in | Largest |", "| --- | --- | ---: |");
462
+ for (const g of groups.slice(0, maxRows)) {
463
+ const modes = g.shots.map((s) => `${modeShort(s)}${s.status === "changed" ? "" : ` (${s.status})`}`).join(", ");
464
+ const err = g.shots.find((s) => s.error)?.error;
465
+ lines.push(`| ${cell(g.label)} | ${cell(modes)} | ${pct(g.shots[0])}${err ? ` (${cell(err.slice(0, 80))})` : ""} |`);
466
+ }
467
+ if (groups.length > maxRows) lines.push(`| ${groups.length - maxRows} more | | |`);
468
+ lines.push("");
469
+ }
470
+ if (unchanged.length) {
471
+ const groups = groupByLabel(unchanged);
472
+ lines.push(`<details><summary>${unchanged.length} unchanged (${groups.length} ${groups.length === 1 ? "story or screen" : "stories and screens"})</summary>`, "", "| Story or screen | Modes |", "| --- | --- |");
473
+ for (const g of groups.slice(0, maxRows)) lines.push(`| ${cell(g.label)} | ${cell(g.shots.map(modeShort).join(", "))} |`);
474
+ if (groups.length > maxRows) lines.push(`| ${groups.length - maxRows} more | |`);
475
+ lines.push("", "</details>", "");
476
+ }
477
+ const picked = pickImages(changed.filter((s) => s.images.composite), maxImages);
478
+ if (picked.length && o.imageBase) {
479
+ lines.push("### Before, after, diff", "");
480
+ const base = o.imageBase.replace(/\/$/, "");
481
+ for (const [i, s] of picked.entries()) {
482
+ lines.push(`<details${i < 2 ? " open" : ""}><summary>${s.label}, ${mode(s)}: ${pct(s)}</summary>`, "", `![${s.label}, ${mode(s)}](${base}/${s.images.composite})`, "", "</details>", "");
483
+ }
484
+ const rest = changed.filter((s) => s.images.composite).length - picked.length;
485
+ if (rest > 0) lines.push(`${rest} more before/after/diff image${rest > 1 ? "s" : ""}: open this pull request in Bridge Reviews (slider, diff toggle, brand and theme filters) or the workflow artifact.`, "");
486
+ }
487
+ const links = [o.artifactUrl ? `[All images (workflow artifact)](${o.artifactUrl})` : "", o.imageBase ? `[manifest.json](${o.imageBase.replace(/\/$/, "")}/manifest.json)` : ""].filter(Boolean);
488
+ lines.push(`<sub>Bridge visual impact, threshold ${m.options.threshold}, ${Math.round(m.durationMs / 1e3)} s. Diffs never fail the check; reviewers decide.${links.length ? ` ${links.join(" \xB7 ")}` : ""}</sub>`);
489
+ return `${lines.join("\n")}
490
+ `;
491
+ }
492
+
493
+ // ../visual/src/routes.ts
494
+ var DEFAULT_ROUTES = [
495
+ { id: "dashboard", label: "Dashboard", path: "/" },
496
+ { id: "orders-list", label: "Orders list", path: "/orders" },
497
+ // Order 12 is cancelled in both seeded shops: the status badge uses the danger tone.
498
+ { id: "order-edit", label: "Order edit", path: "/orders/12", waitFor: "form" },
499
+ // A rejected review in each shop, on the Rejected tab, with the review drawer open.
500
+ {
501
+ id: "reviews-drawer",
502
+ label: "Reviews, drawer open",
503
+ path: "/reviews/4",
504
+ pathByTenant: { northwind: `/reviews/4?filter=${encodeURIComponent('{"status":"rejected"}')}`, harbor: `/reviews/9?filter=${encodeURIComponent('{"status":"rejected"}')}` },
505
+ waitFor: '[role="dialog"]'
506
+ },
507
+ { id: "customers", label: "Customers", path: "/customers" },
508
+ { id: "products", label: "Products", path: "/products" }
509
+ ];
510
+ var DEFAULT_TENANTS = [
511
+ { id: "northwind", name: "Northwind Prints", brand: "bridge" },
512
+ { id: "harbor", name: "Harbor Goods", brand: "ocean" }
513
+ ];
514
+ var THEMES = ["light", "dark"];
515
+ var BRANDS = ["bridge", "ocean"];
516
+ function routeUrl(base, route, tenant, theme) {
517
+ return `${base.replace(/\/$/, "")}/?tenant=${encodeURIComponent(tenant)}&theme=${encodeURIComponent(theme)}&density=comfortable&lang=en#${route.pathByTenant?.[tenant] ?? route.path}`;
518
+ }
519
+ function storyUrl(base, id, brand, theme) {
520
+ return `${base.replace(/\/$/, "")}/iframe.html?id=${encodeURIComponent(id)}&viewMode=story&globals=brand:${brand};theme:${theme};density:comfortable`;
521
+ }
522
+ var shotKey = (...parts) => parts.map((p) => p.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")).join("__");
523
+
524
+ // ../visual/src/select.ts
525
+ var NO_VISUAL = /(\.test\.[cm]?[jt]sx?|\.spec\.[cm]?[jt]sx?|\.docs\.json|\.mdx?|README[^/]*)$/i;
526
+ var norm2 = (p) => p.replace(/^\.\//, "").replace(/\\/g, "/");
527
+ var isAllStates = (s) => /--all-states$/.test(s.id) || /^all ?states$/i.test(s.name);
528
+ function storyComponent(s, componentsDir) {
529
+ return componentForPath(norm2(s.importPath), componentsDir);
530
+ }
531
+ function selectImpact(input) {
532
+ const { usage, stories } = input;
533
+ const dir = usage.componentsDir;
534
+ const lib = input.librarySrc.replace(/\/$/, "");
535
+ const cap = input.maxStoriesPerComponent ?? 4;
536
+ const visualFiles = input.files.map(norm2).filter((f) => !NO_VISUAL.test(f));
537
+ const vars = [...new Set(input.tokens.map((t) => input.cssVar(t.path)))].sort();
538
+ const impact = impactedModules(usage, { vars, files: visualFiles });
539
+ let fallback = null;
540
+ const shared = visualFiles.find((f) => f.startsWith(`${lib}/`) && !f.startsWith(`${dir}/`));
541
+ const global = visualFiles.find((f) => (input.globalPaths ?? []).some((g) => f === g || f.startsWith(g.endsWith("/") ? g : `${g}/`)));
542
+ if (shared) fallback = `Shared library code changed (${shared}): every AllStates story is captured.`;
543
+ else if (global) fallback = `${global} changed: every AllStates story is captured.`;
544
+ else if (impact.globalHits.length) {
545
+ const g = impact.globalHits[0];
546
+ fallback = `${g.file} reads changed tokens (${g.vars.slice(0, 3).join(", ")}${g.vars.length > 3 ? ", ..." : ""}): every AllStates story is captured.`;
547
+ }
548
+ const hit = new Set(impact.modules.map((m) => m.id));
549
+ const byComponent = /* @__PURE__ */ new Map();
550
+ const foundations = [];
551
+ for (const s of stories) {
552
+ const c = storyComponent(s, dir);
553
+ if (!c) foundations.push(s);
554
+ else (byComponent.get(c) ?? byComponent.set(c, []).get(c)).push(s);
555
+ }
556
+ const picked = [];
557
+ const components = [...impact.modules];
558
+ for (const [c, list] of byComponent) {
559
+ if (hit.has(c)) continue;
560
+ const via = (usage.modules[c]?.storyImports ?? []).filter((d) => hit.has(d));
561
+ if (via.length && list.length) components.push({ id: c, reasons: via.map((d) => `story renders ${d}`), indirect: true });
562
+ }
563
+ components.sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
564
+ const order = (list) => [...list.filter(isAllStates), ...list.filter((s) => !isAllStates(s))];
565
+ for (const c of components) picked.push(...order(byComponent.get(c.id) ?? []).slice(0, c.indirect ? 1 : cap).map((s) => s.id));
566
+ if (input.tokens.length) picked.push(...foundations.map((s) => s.id));
567
+ if (fallback) {
568
+ for (const s of stories) if (isAllStates(s)) picked.push(s.id);
569
+ }
570
+ const appDir = input.appDir?.replace(/\/$/, "");
571
+ const appFile = appDir ? visualFiles.find((f) => f.startsWith(`${appDir}/`)) : void 0;
572
+ const app = input.tokens.length ? { include: true, reason: `${input.tokens.length} token value${input.tokens.length > 1 ? "s" : ""} changed` } : components.length || fallback ? { include: true, reason: "Component code changed" } : appFile ? { include: true, reason: `App code changed (${appFile})` } : { include: false, reason: "No token, component or app change" };
573
+ return {
574
+ tokens: input.tokens,
575
+ vars,
576
+ files: input.files.map(norm2),
577
+ components: components.map((c) => ({ id: c.id, reasons: c.reasons, indirect: c.indirect })),
578
+ stories: [...new Set(picked)],
579
+ fallback,
580
+ app
581
+ };
582
+ }
583
+ function storyId(title, exportName) {
584
+ const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
585
+ const startCase = exportName.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
586
+ return `${sanitize(title)}--${sanitize(startCase)}`;
587
+ }
588
+ function storiesFromSource(files) {
589
+ const out = [];
590
+ for (const f of files) {
591
+ const title = /title:\s*['"]([^'"]+)['"]/.exec(f.text)?.[1];
592
+ if (!title) continue;
593
+ for (const m of f.text.matchAll(/export\s+const\s+([A-Z]\w*)\s*[:=]/g)) {
594
+ const name = m[1];
595
+ out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${norm2(f.path)}` });
596
+ }
597
+ }
598
+ return out;
599
+ }
600
+
601
+ // ../visual/src/serve.ts
602
+ import { createReadStream, existsSync, statSync } from "node:fs";
603
+ import { createServer } from "node:http";
604
+ import { extname, join, normalize, sep } from "node:path";
605
+ var TYPES = {
606
+ ".html": "text/html; charset=utf-8",
607
+ ".js": "text/javascript; charset=utf-8",
608
+ ".mjs": "text/javascript; charset=utf-8",
609
+ ".css": "text/css; charset=utf-8",
610
+ ".json": "application/json; charset=utf-8",
611
+ ".svg": "image/svg+xml",
612
+ ".png": "image/png",
613
+ ".jpg": "image/jpeg",
614
+ ".jpeg": "image/jpeg",
615
+ ".gif": "image/gif",
616
+ ".webp": "image/webp",
617
+ ".ico": "image/x-icon",
618
+ ".woff": "font/woff",
619
+ ".woff2": "font/woff2",
620
+ ".ttf": "font/ttf",
621
+ ".map": "application/json",
622
+ ".txt": "text/plain; charset=utf-8"
623
+ };
624
+ function serveDir(dir) {
625
+ const root = normalize(dir);
626
+ const server = createServer((req, res) => {
627
+ const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
628
+ let file = normalize(join(root, path));
629
+ if (!file.startsWith(root + sep) && file !== root) {
630
+ res.writeHead(403).end();
631
+ return;
632
+ }
633
+ if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
634
+ if (!existsSync(file)) {
635
+ if (extname(path)) {
636
+ res.writeHead(404).end();
637
+ return;
638
+ }
639
+ file = join(root, "index.html");
640
+ }
641
+ res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" });
642
+ createReadStream(file).pipe(res);
643
+ });
644
+ return new Promise((resolve2, reject) => {
645
+ server.once("error", reject);
646
+ server.listen(0, "127.0.0.1", () => {
647
+ const addr = server.address();
648
+ const port = typeof addr === "object" && addr ? addr.port : 0;
649
+ resolve2({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(() => r())) });
650
+ });
651
+ });
652
+ }
653
+
654
+ // ../visual/src/workspace.ts
655
+ import { spawn } from "node:child_process";
656
+ import { existsSync as existsSync2 } from "node:fs";
657
+ import { lstat, mkdir as mkdir2, readdir, readFile as readFile2, realpath, rm, symlink } from "node:fs/promises";
658
+ import { dirname as dirname2, join as join2, relative } from "node:path";
659
+ var PRIVATE = /* @__PURE__ */ new Set([".cache", ".vite", ".vite-temp", ".package-lock.json"]);
660
+ async function workspaceDirs(root) {
661
+ const pkg = JSON.parse(await readFile2(join2(root, "package.json"), "utf8"));
662
+ const pats = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages ?? [];
663
+ const out = [];
664
+ for (const p of pats) {
665
+ if (!p.endsWith("/*")) {
666
+ if (existsSync2(join2(root, p, "package.json"))) out.push(p);
667
+ continue;
668
+ }
669
+ const parent = p.slice(0, -2);
670
+ if (!existsSync2(join2(root, parent))) continue;
671
+ for (const d of await readdir(join2(root, parent))) if (existsSync2(join2(root, parent, d, "package.json"))) out.push(`${parent}/${d}`);
672
+ }
673
+ return out;
674
+ }
675
+ async function link(target, at) {
676
+ await mkdir2(dirname2(at), { recursive: true });
677
+ await symlink(target, at);
678
+ }
679
+ async function linkNodeModules(src, dst) {
680
+ const srcNm = await realpath(join2(src, "node_modules"));
681
+ const dstNm = join2(dst, "node_modules");
682
+ if (existsSync2(dstNm)) return;
683
+ const dirs = await workspaceDirs(dst);
684
+ const names = /* @__PURE__ */ new Map();
685
+ for (const d of dirs) {
686
+ const { name } = JSON.parse(await readFile2(join2(dst, d, "package.json"), "utf8"));
687
+ if (name) names.set(name, d);
688
+ }
689
+ await mkdir2(dstNm, { recursive: true });
690
+ for (const e of await readdir(srcNm)) {
691
+ if (PRIVATE.has(e)) continue;
692
+ if (e.startsWith("@")) {
693
+ for (const c of await readdir(join2(srcNm, e))) if (!names.has(`${e}/${c}`)) await link(join2(srcNm, e, c), join2(dstNm, e, c));
694
+ } else if (!names.has(e)) await link(join2(srcNm, e), join2(dstNm, e));
695
+ }
696
+ for (const [name, d] of names) {
697
+ const at = join2(dstNm, name);
698
+ await rm(at, { force: true, recursive: false }).catch(() => void 0);
699
+ await link(relative(dirname2(at), join2(dst, d)), at);
700
+ }
701
+ for (const d of dirs) {
702
+ const nested = join2(src, d, "node_modules");
703
+ if (existsSync2(nested) && !existsSync2(join2(dst, d, "node_modules"))) await link(await realpath(nested), join2(dst, d, "node_modules"));
704
+ }
705
+ }
706
+ async function addWorktree(repo, sha, dir) {
707
+ await rm(dir, { recursive: true, force: true });
708
+ await git(repo, ["worktree", "prune"]);
709
+ await git(repo, ["worktree", "add", "--detach", "--force", dir, sha]);
710
+ }
711
+ async function removeWorktree(repo, dir) {
712
+ try {
713
+ await rm(join2(dir, "node_modules"), { recursive: true, force: true });
714
+ await git(repo, ["worktree", "remove", "--force", dir]);
715
+ } catch {
716
+ await rm(dir, { recursive: true, force: true });
717
+ }
718
+ await git(repo, ["worktree", "prune"]).catch(() => void 0);
719
+ }
720
+ function run(cmd, args, cwd, log, root = cwd, env = {}) {
721
+ return new Promise((resolve2, reject) => {
722
+ const bin = [.../* @__PURE__ */ new Set([join2(cwd, "node_modules", ".bin"), join2(root, "node_modules", ".bin")])].join(":");
723
+ const child = spawn(cmd, args, {
724
+ cwd,
725
+ env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=3072", STORYBOOK_DISABLE_TELEMETRY: "1", ...env },
726
+ stdio: ["ignore", "pipe", "pipe"]
727
+ });
728
+ let tail = "";
729
+ const keep = (b) => tail = (tail + b.toString()).slice(-4e3);
730
+ child.stdout.on("data", keep);
731
+ child.stderr.on("data", keep);
732
+ child.on("error", reject);
733
+ child.on("close", (code) => {
734
+ if (code === 0) resolve2();
735
+ else {
736
+ log(tail);
737
+ reject(new Error(`${cmd} ${args.join(" ")} failed in ${cwd} (exit ${code})`));
738
+ }
739
+ });
740
+ });
741
+ }
742
+ var DEFAULT_BUILD = {
743
+ appDir: "apps/admin",
744
+ storybookConfigDir: ".storybook",
745
+ tokensBuild: ["npm", "run", "tokens:build", "--silent"]
746
+ };
747
+ var isDir = async (p) => (await lstat(p).catch(() => null))?.isDirectory() ?? false;
748
+ async function buildRef(o) {
749
+ const cfg = o.config ?? DEFAULT_BUILD;
750
+ const out = join2(o.cache, o.sha);
751
+ const sbOut = join2(out, "storybook");
752
+ const appOut = join2(out, "app");
753
+ const needSb = o.plan.storybook && !existsSync2(join2(sbOut, "index.json"));
754
+ const needApp = o.plan.app && !existsSync2(join2(appOut, "index.html"));
755
+ const built = { ...o.plan.storybook ? { storybook: sbOut } : {}, ...o.plan.app ? { app: appOut } : {} };
756
+ if (!needSb && !needApp) {
757
+ o.log(` ${o.sha.slice(0, 7)}: builds cached`);
758
+ return built;
759
+ }
760
+ const dir = join2(o.work, `wt-${o.sha.slice(0, 12)}`);
761
+ o.log(` ${o.sha.slice(0, 7)}: worktree ${dir}`);
762
+ await addWorktree(o.repo, o.sha, dir);
763
+ try {
764
+ await linkNodeModules(o.repo, dir);
765
+ o.log(` ${o.sha.slice(0, 7)}: tokens`);
766
+ await run(cfg.tokensBuild[0], cfg.tokensBuild.slice(1), dir, o.log);
767
+ if (needSb) {
768
+ o.log(` ${o.sha.slice(0, 7)}: storybook build`);
769
+ await rm(sbOut, { recursive: true, force: true });
770
+ await run("storybook", ["build", "-c", cfg.storybookConfigDir, "-o", sbOut, "--quiet", "--disable-telemetry"], dir, o.log);
771
+ }
772
+ if (needApp && await isDir(join2(dir, cfg.appDir))) {
773
+ o.log(` ${o.sha.slice(0, 7)}: app build`);
774
+ await rm(appOut, { recursive: true, force: true });
775
+ await run("vite", ["build", "--outDir", appOut, "--emptyOutDir", "--logLevel", "warn"], join2(dir, cfg.appDir), o.log, dir);
776
+ }
777
+ } finally {
778
+ if (!o.keep) await removeWorktree(o.repo, dir);
779
+ }
780
+ if (o.plan.app && !existsSync2(join2(appOut, "index.html"))) delete built.app;
781
+ return built;
782
+ }
783
+
784
+ // ../visual/src/run.ts
785
+ var STORY_READY = `document.body.classList.contains('sb-show-main') && (document.querySelector('#storybook-root')?.childElementCount > 0 || document.querySelector('[role="dialog"]')) || document.body.classList.contains('sb-show-errordisplay')`;
786
+ var APP_READY = `document.querySelector('#root')?.childElementCount > 0 && !document.querySelector('[aria-busy="true"]')`;
787
+ async function readConfig(root, sha) {
788
+ const text = await showFile(root, sha, "bridge.config.json") ?? (existsSync3(join3(root, "bridge.config.json")) ? await readFile3(join3(root, "bridge.config.json"), "utf8") : null);
789
+ if (!text) throw new Error("bridge.config.json not found: run `bridge init` first.");
790
+ return loadConfig(text);
791
+ }
792
+ async function tokenSetAt(root, sha, config, files) {
793
+ const tokensRoot = config.tokens.root.replace(/\/$/, "");
794
+ const picked = selectTokenFiles(config, files);
795
+ const texts = await Promise.all(picked.map(async (f) => [f, await showFile(root, sha, tokensRoot === "." ? f : `${tokensRoot}/${f}`)]));
796
+ return new LayeredTokenSet(config, new Map(texts.filter((t) => t[1] !== null)));
797
+ }
798
+ function changedTokens(before, after) {
799
+ const direct = new Set(diffLayered(before, after).map((c) => c.path));
800
+ const modes = /* @__PURE__ */ new Map();
801
+ for (const combo of after.combos()) {
802
+ const paths = /* @__PURE__ */ new Set([...before.view(combo).keys(), ...after.view(combo).keys()]);
803
+ for (const p of paths) {
804
+ if (before.resolve(p, combo).css === after.resolve(p, combo).css) continue;
805
+ (modes.get(p) ?? modes.set(p, []).get(p)).push(comboKey(combo));
806
+ }
807
+ }
808
+ return [...modes.entries()].map(([path, m]) => ({ path, kind: direct.has(path) ? "direct" : "alias", modes: m })).sort((a, b) => Number(a.kind === "alias") - Number(b.kind === "alias") || a.path.localeCompare(b.path));
809
+ }
810
+ async function readIndexJson(dir) {
811
+ const raw = JSON.parse(await readFile3(join3(dir, "index.json"), "utf8"));
812
+ return Object.values(raw.entries).filter((e) => e.type === "story").map(({ id, title, name, importPath }) => ({ id, title, name, importPath }));
813
+ }
814
+ async function planImpact(o) {
815
+ const root = resolve(o.root);
816
+ const rev = async (r) => (await git(root, ["rev-parse", "--verify", `${r}^{commit}`])).trim();
817
+ const headSha = await rev(o.head);
818
+ const tipSha = await rev(o.base);
819
+ const baseSha = (await git(root, ["merge-base", tipSha, headSha])).trim() || tipSha;
820
+ const config = await readConfig(root, headSha);
821
+ const [baseFiles, headFiles] = await Promise.all([listFilesAt(root, baseSha), listFilesAt(root, headSha)]);
822
+ const [before, after] = await Promise.all([tokenSetAt(root, baseSha, config, baseFiles), tokenSetAt(root, headSha, config, headFiles)]);
823
+ const tokens = changedTokens(before, after);
824
+ const changed = (await git(root, ["diff", "--name-only", baseSha, headSha])).split("\n").filter(Boolean);
825
+ const componentsDir = config.components?.dir ?? "packages/react/src/components";
826
+ const librarySrc = componentsDir.split("/").slice(0, -1).join("/") || componentsDir;
827
+ const libFiles = headFiles.filter((f) => f.startsWith(`${librarySrc}/`) && /\.(css|tsx?|jsx?)$/.test(f));
828
+ const texts = await Promise.all(libFiles.map(async (path) => ({ path, text: await showFile(root, headSha, path) ?? "" })));
829
+ const usage = buildUsageIndex(texts, componentsDir);
830
+ const stories = storiesFromSource(texts.filter((t) => /\.stories\.[jt]sx?$/.test(t.path)));
831
+ const build = o.build ?? DEFAULT_BUILD;
832
+ const only = o.only?.length ? o.only : ["stories", "app"];
833
+ const selection = selectImpact({
834
+ files: changed,
835
+ tokens,
836
+ cssVar: (p) => after.cssVarName(p),
837
+ usage,
838
+ stories,
839
+ librarySrc,
840
+ globalPaths: [build.storybookConfigDir, "packages/tokens/build.mjs", "packages/tokens/scripts", "packages/tokens/fonts.css"],
841
+ appDir: build.appDir,
842
+ ...o.maxStoriesPerComponent ? { maxStoriesPerComponent: o.maxStoriesPerComponent } : {}
843
+ });
844
+ if (!only.includes("stories")) selection.stories = [];
845
+ if (!only.includes("app")) selection.app = { include: false, reason: "Skipped (--only stories)" };
846
+ return { base: { ref: o.base, sha: baseSha }, head: { ref: o.head, sha: headSha }, config, selection, sources: { stories, librarySrc, usage } };
847
+ }
848
+ async function runImpact(o) {
849
+ const started = Date.now();
850
+ const log = o.log ?? ((m) => process.stderr.write(`${m}
851
+ `));
852
+ const root = resolve(o.root);
853
+ const outDir = resolve(o.out ?? join3(root, "visual-impact"));
854
+ const threshold = o.threshold ?? 0.03;
855
+ const only = o.only?.length ? o.only : ["stories", "app"];
856
+ const vp = { stories: o.viewport?.stories ?? { width: 1024, height: 768 }, app: o.viewport?.app ?? { width: 1280, height: 800 } };
857
+ const build = o.build ?? DEFAULT_BUILD;
858
+ const concurrency = o.concurrency ?? Number(process.env.BRIDGE_VISUAL_CONCURRENCY ?? 2);
859
+ if (outDir === root || !relative2(outDir, root).startsWith("..")) throw new Error(`--out must be a folder inside or outside the repo, not the repo or one of its parents (${outDir}).`);
860
+ log(`bridge impact: planning ${o.base}...${o.head}`);
861
+ const plan = await planImpact({ ...o, only, build });
862
+ const { selection } = plan;
863
+ const modes = o.modes ?? BRANDS.flatMap((brand) => THEMES.map((theme) => ({ brand, theme })));
864
+ const tenants = o.tenants ?? DEFAULT_TENANTS;
865
+ const routes = o.routes ?? DEFAULT_ROUTES;
866
+ const finish = async (shots2) => {
867
+ const manifest = {
868
+ version: 1,
869
+ tool: "@bridge-ds/visual",
870
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
871
+ durationMs: Date.now() - started,
872
+ base: plan.base,
873
+ head: plan.head,
874
+ options: { only, threshold, ...o.plan ? { plan: true, planned: { modes: modes.length, appScreens: selection.app.include ? tenants.length * THEMES.length * routes.length : 0 } } : {}, viewport: vp },
875
+ selection,
876
+ summary: summarize(shots2),
877
+ shots: shots2
878
+ };
879
+ await mkdir3(outDir, { recursive: true });
880
+ const manifestPath = join3(outDir, "manifest.json");
881
+ await writeFile3(manifestPath, `${JSON.stringify(manifest, null, 2)}
882
+ `);
883
+ let commentPath;
884
+ if (o.imageBase !== void 0 || o.artifactUrl) {
885
+ commentPath = join3(outDir, "comment.md");
886
+ await writeFile3(commentPath, renderComment(manifest, { ...o.imageBase ? { imageBase: o.imageBase } : {}, ...o.artifactUrl ? { artifactUrl: o.artifactUrl } : {} }));
887
+ }
888
+ return { manifest, outDir, manifestPath, ...commentPath ? { commentPath } : {}, summary: summaryLine(manifest) };
889
+ };
890
+ if (o.plan) return finish([]);
891
+ const needStories = only.includes("stories") && selection.stories.length > 0;
892
+ const needApp = only.includes("app") && selection.app.include;
893
+ if (!needStories && !needApp) {
894
+ log("Nothing visual to capture.");
895
+ return finish([]);
896
+ }
897
+ const cache = resolve(o.cacheDir ?? join3(root, "node_modules", ".cache", "bridge-visual"));
898
+ const work = resolve(o.workDir ?? join3(tmpdir(), `bridge-visual-${process.pid}`));
899
+ await mkdir3(work, { recursive: true });
900
+ const builds = { base: {}, head: {} };
901
+ try {
902
+ for (const side of ["head", "base"]) {
903
+ log(`Building ${side} (${plan[side].ref})`);
904
+ builds[side] = await buildRef({ repo: root, sha: plan[side].sha, work, cache, plan: { storybook: needStories, app: needApp }, config: build, log });
905
+ }
906
+ } finally {
907
+ await rm2(work, { recursive: true, force: true });
908
+ }
909
+ if (needStories && builds.head.storybook) {
910
+ const real = await readIndexJson(builds.head.storybook);
911
+ const refined = selectImpact({
912
+ files: selection.files,
913
+ tokens: selection.tokens,
914
+ cssVar: (p) => `${plan.config.cssVar.prefix}${p.split(".").join(plan.config.cssVar.separator)}`,
915
+ usage: plan.sources.usage,
916
+ stories: real,
917
+ librarySrc: plan.sources.librarySrc,
918
+ globalPaths: [build.storybookConfigDir, "packages/tokens/build.mjs", "packages/tokens/scripts", "packages/tokens/fonts.css"],
919
+ appDir: build.appDir,
920
+ ...o.maxStoriesPerComponent ? { maxStoriesPerComponent: o.maxStoriesPerComponent } : {}
921
+ });
922
+ selection.stories = refined.stories;
923
+ selection.components = refined.components;
924
+ }
925
+ const byId = /* @__PURE__ */ new Map();
926
+ if (builds.head.storybook) for (const s of await readIndexJson(builds.head.storybook)) byId.set(s.id, s);
927
+ if (builds.base.storybook && existsSync3(join3(builds.base.storybook, "index.json"))) {
928
+ for (const s of await readIndexJson(builds.base.storybook)) if (!byId.has(s.id)) byId.set(s.id, s);
929
+ }
930
+ const jobs = [];
931
+ if (needStories)
932
+ for (const m of modes)
933
+ for (const id of selection.stories) {
934
+ const s = byId.get(id);
935
+ jobs.push({ key: shotKey("story", id, `${m.brand}-${m.theme}`), kind: "story", label: s ? `${s.title}: ${s.name}` : id, storyId: id, brand: m.brand, theme: m.theme });
936
+ }
937
+ if (needApp && builds.head.app)
938
+ for (const t of tenants)
939
+ for (const theme of THEMES)
940
+ for (const r of routes) jobs.push({ key: shotKey("app", r.id, t.id, theme), kind: "route", label: r.label, route: r, tenant: t, brand: t.brand, theme });
941
+ await rm2(outDir, { recursive: true, force: true });
942
+ for (const d of ["before", "after", "diff", "composite"]) await mkdir3(join3(outDir, d), { recursive: true });
943
+ const errors = /* @__PURE__ */ new Map();
944
+ for (const side of ["base", "head"]) {
945
+ const b = builds[side];
946
+ const served = [];
947
+ const browser = await launch();
948
+ try {
949
+ const sb = b.storybook && existsSync3(join3(b.storybook, "index.json")) ? await serveDir(b.storybook) : null;
950
+ const app = b.app && existsSync3(join3(b.app, "index.html")) ? await serveDir(b.app) : null;
951
+ if (sb) served.push(sb);
952
+ if (app) served.push(app);
953
+ const dir = join3(outDir, side === "base" ? "before" : "after");
954
+ const storyJobs = jobs.filter((j) => j.kind === "story");
955
+ if (sb && storyJobs.length) {
956
+ log(`Capturing ${storyJobs.length} story shots at ${side}`);
957
+ const ctx = await newContext(browser, vp.stories);
958
+ const known = new Set((await readIndexJson(b.storybook)).map((s) => s.id));
959
+ const queue = [...modes];
960
+ const worker = async () => {
961
+ const page = await newPage(ctx);
962
+ for (let m = queue.shift(); m; m = queue.shift()) {
963
+ let loaded = false;
964
+ for (const j of storyJobs.filter((x) => x.brand === m.brand && x.theme === m.theme)) {
965
+ if (!known.has(j.storyId)) continue;
966
+ const err = await shoot(page, {
967
+ url: storyUrl(sb.url, j.storyId, j.brand, j.theme),
968
+ file: join3(dir, `${j.key}.png`),
969
+ fullPage: true,
970
+ readyFn: STORY_READY,
971
+ // Loading stories (Spinner, Skeleton) stay aria-busy on purpose: do not wait for them.
972
+ settle: loaded ? { network: false, quiet: 2, interval: 40, busy: false } : { busy: false },
973
+ ...loaded ? { navigate: (p) => switchStory(p, j.storyId) } : {}
974
+ });
975
+ if (err) {
976
+ errors.set(`${side}:${j.key}`, err);
977
+ loaded = false;
978
+ } else loaded = true;
979
+ }
980
+ }
981
+ await page.close();
982
+ };
983
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, modes.length)) }, worker));
984
+ await ctx.close();
985
+ }
986
+ const routeJobs = jobs.filter((j) => j.kind === "route");
987
+ if (app && routeJobs.length) {
988
+ log(`Capturing ${routeJobs.length} app shots at ${side}`);
989
+ for (const t of tenants)
990
+ for (const theme of THEMES) {
991
+ const ctx = await newContext(browser, vp.app);
992
+ const page = await newPage(ctx);
993
+ for (const j of routeJobs.filter((x) => x.tenant.id === t.id && x.theme === theme)) {
994
+ const err = await shoot(page, { url: routeUrl(app.url, j.route, t.id, theme), file: join3(dir, `${j.key}.png`), fullPage: false, readyFn: APP_READY, ...j.route.waitFor ? { waitFor: j.route.waitFor } : {}, mask: ["[data-visual-mask]", ...j.route.mask ?? []] });
995
+ if (err) errors.set(`${side}:${j.key}`, err);
996
+ }
997
+ await ctx.close();
998
+ }
999
+ }
1000
+ } finally {
1001
+ await browser.close();
1002
+ for (const s of served) await s.close();
1003
+ }
1004
+ }
1005
+ log("Diffing");
1006
+ const shots = [];
1007
+ for (const j of jobs) {
1008
+ const bf = join3(outDir, "before", `${j.key}.png`);
1009
+ const af = join3(outDir, "after", `${j.key}.png`);
1010
+ const hasB = existsSync3(bf);
1011
+ const hasA = existsSync3(af);
1012
+ const base = {
1013
+ key: j.key,
1014
+ kind: j.kind,
1015
+ label: j.label,
1016
+ ...j.storyId ? { storyId: j.storyId } : {},
1017
+ ...j.route ? { route: j.route.path } : {},
1018
+ ...j.tenant ? { tenant: j.tenant.id } : {},
1019
+ brand: j.brand,
1020
+ theme: j.theme
1021
+ };
1022
+ const err = errors.get(`head:${j.key}`) ?? errors.get(`base:${j.key}`);
1023
+ if (!hasB && !hasA) {
1024
+ shots.push({ ...base, status: "error", changedPixels: 0, totalPixels: 0, percent: 0, boxes: [], size: { before: null, after: null }, images: {}, error: err ?? "not captured" });
1025
+ continue;
1026
+ }
1027
+ if (!hasB || !hasA) {
1028
+ const png = decodePng(await readFile3(hasA ? af : bf));
1029
+ const size = { width: png.width, height: png.height };
1030
+ shots.push({
1031
+ ...base,
1032
+ status: hasA ? "added" : "removed",
1033
+ changedPixels: png.width * png.height,
1034
+ totalPixels: png.width * png.height,
1035
+ percent: 100,
1036
+ boxes: [{ x: 0, y: 0, ...size }],
1037
+ size: { before: hasB ? size : null, after: hasA ? size : null },
1038
+ images: hasA ? { after: `after/${j.key}.png` } : { before: `before/${j.key}.png` },
1039
+ ...err ? { error: err } : {}
1040
+ });
1041
+ continue;
1042
+ }
1043
+ const pb = decodePng(await readFile3(bf));
1044
+ const pa = decodePng(await readFile3(af));
1045
+ const d = diffPngs(pb, pa, threshold);
1046
+ const changed = d.changedPixels > 0;
1047
+ const images = {};
1048
+ if (changed || o.keepUnchanged) Object.assign(images, { before: `before/${j.key}.png`, after: `after/${j.key}.png` });
1049
+ else await Promise.all([unlink(bf), unlink(af)]);
1050
+ if (d.diff) {
1051
+ await writeFile3(join3(outDir, "diff", `${j.key}.png`), encodePng(d.diff));
1052
+ images.diff = `diff/${j.key}.png`;
1053
+ }
1054
+ shots.push({
1055
+ ...base,
1056
+ status: err ? "error" : changed ? "changed" : "unchanged",
1057
+ changedPixels: d.changedPixels,
1058
+ totalPixels: d.totalPixels,
1059
+ percent: d.percent,
1060
+ boxes: d.boxes,
1061
+ size: { before: { width: pb.width, height: pb.height }, after: { width: pa.width, height: pa.height } },
1062
+ images,
1063
+ ...err ? { error: err } : {}
1064
+ });
1065
+ }
1066
+ const toComposite = shots.filter((s) => s.status === "changed" || s.status === "added" || s.status === "removed");
1067
+ if (toComposite.length) {
1068
+ log(`Composing ${toComposite.length} before/after/diff images`);
1069
+ const browser = await launch();
1070
+ try {
1071
+ const ctx = await browser.newContext({ viewport: { width: 1700, height: 900 }, deviceScaleFactor: 1 });
1072
+ const page = await ctx.newPage();
1073
+ for (const s of toComposite) {
1074
+ const w = Math.max(s.size.before?.width ?? 0, s.size.after?.width ?? 0);
1075
+ const h = Math.max(s.size.before?.height ?? 0, s.size.after?.height ?? 0);
1076
+ const file = join3(outDir, "composite", `${s.key}.png`);
1077
+ await composite(page, {
1078
+ title: s.label,
1079
+ subtitle: `${s.tenant ? `${DEFAULT_TENANTS.find((t) => t.id === s.tenant)?.name ?? s.tenant}, ` : ""}${s.brand}, ${s.theme} \xB7 ${s.status === "changed" ? `${s.percent.toFixed(2)}% changed` : s.status}`,
1080
+ before: s.images.before ? join3(outDir, s.images.before) : null,
1081
+ after: s.images.after ? join3(outDir, s.images.after) : null,
1082
+ diff: s.images.diff ? join3(outDir, s.images.diff) : null,
1083
+ width: w,
1084
+ height: h,
1085
+ boxes: s.boxes,
1086
+ out: file
1087
+ });
1088
+ s.images.composite = `composite/${s.key}.png`;
1089
+ }
1090
+ await ctx.close();
1091
+ } finally {
1092
+ await browser.close();
1093
+ }
1094
+ }
1095
+ const result = await finish(shots);
1096
+ log(`${result.summary} Manifest: ${relative2(process.cwd(), result.manifestPath) || result.manifestPath}`);
1097
+ return result;
1098
+ }
1099
+ export {
1100
+ COMMENT_MARKER,
1101
+ DEFAULT_ROUTES,
1102
+ DEFAULT_TENANTS,
1103
+ boxesFromCells,
1104
+ boxesFromDiff,
1105
+ changedTokens,
1106
+ decodePng,
1107
+ diffPngs,
1108
+ encodePng,
1109
+ hasVisualChange,
1110
+ linkNodeModules,
1111
+ padTo,
1112
+ planImpact,
1113
+ rankShots,
1114
+ renderComment,
1115
+ routeUrl,
1116
+ runImpact,
1117
+ selectImpact,
1118
+ shotKey,
1119
+ storiesFromSource,
1120
+ storyComponent,
1121
+ storyId,
1122
+ storyUrl,
1123
+ summarize,
1124
+ summaryLine
1125
+ };