@bridge-ds/visual 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.
package/dist/capture.d.ts CHANGED
@@ -38,3 +38,13 @@ export declare function shoot(page: Page, r: ShotRequest): Promise<string | null
38
38
  * click a story), much faster than a page load per story. Resolves once the story rendered or failed.
39
39
  */
40
40
  export declare function switchStory(page: Page, storyId: string): Promise<void>;
41
+ /**
42
+ * A page for `bridge specs --measure`: an installed Chrome, one page at 1200x800, light scheme, reduced motion. Unlike
43
+ * the capture context it reaches remote hosts (a team's deployed Storybook). Close it when done.
44
+ */
45
+ export declare function openMeasurePage(o?: LaunchOptions & {
46
+ scale?: number;
47
+ }): Promise<{
48
+ page: Page;
49
+ close: () => Promise<void>;
50
+ }>;
package/dist/diff.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface DiffResult {
12
12
  }
13
13
  export declare const decodePng: (buf: Buffer) => PNG;
14
14
  export declare const encodePng: (png: PNG) => Buffer;
15
+ /** The `x, y, width, height` region of `src` (px of the image), clipped to it. */
16
+ export declare function cropPng(src: PNG, x: number, y: number, width: number, height: number): PNG;
15
17
  /** Copies `src` onto a `width` x `height` canvas filled with an opaque neutral, so sizes can differ. */
16
18
  export declare function padTo(src: PNG, width: number, height: number): PNG;
17
19
  /**
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { runImpact, planImpact, changedTokens, type ImpactOptions, type ImpactResult, type Plan } from './run.js';
2
2
  export { selectImpact, storiesFromSource, storyId, storyComponent, type SelectInput } from './select.js';
3
- export { diffPngs, boxesFromDiff, boxesFromCells, padTo, decodePng, encodePng, type DiffResult } from './diff.js';
3
+ export { diffPngs, boxesFromDiff, boxesFromCells, padTo, cropPng, decodePng, encodePng, type DiffResult } from './diff.js';
4
4
  export { COMMENT_MARKER, hasVisualChange, rankShots, renderComment, summarize, summaryLine, type CommentOptions } from './manifest.js';
5
5
  export { DEFAULT_ROUTES, DEFAULT_TENANTS, routeUrl, storyUrl, shotKey } from './routes.js';
6
6
  export { captureFigmaReferences, type ReferencesOptions, type ReferencesResult } from './references.js';
7
7
  export { linkNodeModules } from './workspace.js';
8
+ export { openMeasurePage } from './capture.js';
8
9
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -121,6 +121,12 @@ var CONFIG_FILE = "bridge.config.json";
121
121
  var CHANGES_MODES = ["suggest", "direct"];
122
122
  var DEFAULT_CHANGES_MODE = "suggest";
123
123
  var DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
124
+ var TEAM_NAME_MAX = 60;
125
+ function teamName(value) {
126
+ if (typeof value !== "string") return void 0;
127
+ const v = value.trim().replace(/\s+/g, " ");
128
+ return v && v.length <= TEAM_NAME_MAX ? v : void 0;
129
+ }
124
130
  var DEFAULT_TOKEN_PATHS = {
125
131
  root: "tokens",
126
132
  primitives: "primitives",
@@ -226,6 +232,7 @@ function defineConfig(input) {
226
232
  const g = input.governance ?? {};
227
233
  const contrastFile = contrastPath ?? [root === "." ? "" : root, paths.contrastChecks].filter(Boolean).join("/");
228
234
  return {
235
+ ...teamName(input.name) ? { name: teamName(input.name) } : {},
229
236
  repo: parseRepoInput(input.repo, input.branch),
230
237
  tokens: { ...paths, include: input.tokens?.include ?? ["**/*.tokens.json"], exclude: input.tokens?.exclude ?? [], tiers, layers, ...t.usage ? { usage: trimSlashes(t.usage) } : {} },
231
238
  axes,
@@ -264,6 +271,7 @@ function defineConfig(input) {
264
271
  changes: input.changes === "direct" ? "direct" : DEFAULT_CHANGES_MODE,
265
272
  ...input.storybook?.url ? { storybook: { url: input.storybook.url.trim().replace(/\/+$/, "") } } : {},
266
273
  ...input.library?.public === true ? { library: { public: true } } : {},
274
+ ...input.demo?.viewer?.trim() ? { demo: { viewer: input.demo.viewer.trim(), ...input.demo.role?.trim() ? { role: input.demo.role.trim() } : {} } } : {},
267
275
  ...input.audit ? { audit: { ...input.audit } } : {},
268
276
  ...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
269
277
  ...input.integrations ? { integrations: { ...input.integrations } } : {},
@@ -388,6 +396,7 @@ function loadConfig(source) {
388
396
  }
389
397
  }
390
398
  problems.push(...visualImpactProblems(d.visualImpact));
399
+ if (d.name !== void 0 && teamName(d.name) === void 0) problems.push(`"name" must be the team's name, 1 to ${TEAM_NAME_MAX} characters`);
391
400
  if (d.changes !== void 0 && !CHANGES_MODES.includes(d.changes)) problems.push(`"changes" must be "suggest" or "direct" (got ${JSON.stringify(d.changes)})`);
392
401
  const sb = d.storybook;
393
402
  if (sb !== void 0) {
@@ -403,6 +412,14 @@ function loadConfig(source) {
403
412
  for (const k of Object.keys(lib)) if (k !== "public") problems.push(`"library.${k}" is not a known key`);
404
413
  }
405
414
  }
415
+ const demo = d.demo;
416
+ if (demo !== void 0) {
417
+ if (!isObj(demo) || !isStr(demo.viewer) || !demo.viewer.trim()) problems.push('"demo" must be { "viewer" }, for example { "viewer": "Dana", "role": "DS lead" }');
418
+ else {
419
+ if (demo.role !== void 0 && !isStr(demo.role)) problems.push('"demo.role" must be a string, for example "DS lead"');
420
+ for (const k of Object.keys(demo)) if (k !== "viewer" && k !== "role") problems.push(`"demo.${k}" is not a known key`);
421
+ }
422
+ }
406
423
  problems.push(...productProblems(d.products));
407
424
  const audit = d.audit;
408
425
  if (audit !== void 0) {
@@ -897,7 +914,7 @@ var STATE_PSEUDO = {
897
914
  var iconName = (component) => component.replace(/^Icon\//, "");
898
915
  var exportName = (name) => name.replace(/[^A-Za-z0-9]+/g, "");
899
916
  function planReference(spec, variant, file) {
900
- if (spec.view === "popup") return { ok: false, reason: "drawn open in Figma; the code image shows it closed" };
917
+ if (spec.view === "popup") return planPopup(spec, variant);
901
918
  const props = { ...spec.fixed ?? {} };
902
919
  let pseudo;
903
920
  for (const axis of spec.axes) {
@@ -926,6 +943,7 @@ function planReference(spec, variant, file) {
926
943
  if (part.swap && swapTo) {
927
944
  const slot = file?.slots?.find((s) => s.name === swapTo);
928
945
  if (!slot) return { reason: `${swapTo} is not in the specs` };
946
+ if (slot.kind === "media") return { reason: `Figma shows a media placeholder (${swapTo}); code shows the image it is given, nothing to compare` };
929
947
  if (slot.kind !== "text" || !slot.text) return { reason: `Figma shows a placeholder (${swapTo}) that code has no example of` };
930
948
  return slot.text.default;
931
949
  }
@@ -977,10 +995,13 @@ function planReference(spec, variant, file) {
977
995
  }
978
996
  const example = spec.example;
979
997
  const exampleChildren = example?.children?.length ? example.children : void 0;
998
+ const component = exportName(example?.c ?? (spec.name.includes("/") ? spec.component ?? spec.name : spec.name));
999
+ const parent = spec.component ? exportName(spec.component) : void 0;
980
1000
  return {
981
1001
  ok: true,
982
1002
  render: {
983
- component: exportName(spec.view || spec.fixed || example ? example?.c ?? spec.component ?? spec.name : spec.name),
1003
+ component,
1004
+ ...parent && parent !== component ? { within: parent } : {},
984
1005
  props: { ...example?.p ?? {}, ...props },
985
1006
  ...exampleChildren ? { children: exampleChildren } : {},
986
1007
  ...text !== void 0 && !exampleChildren ? { text } : {},
@@ -991,6 +1012,28 @@ function planReference(spec, variant, file) {
991
1012
  }
992
1013
  };
993
1014
  }
1015
+ function planPopup(spec, variant) {
1016
+ const root = spec.parts.find((p) => !p.parent);
1017
+ const own = {};
1018
+ for (const axis of spec.axes) {
1019
+ const value = variant.props[axis.name];
1020
+ if (!axis.prop || value === void 0) return { ok: false, reason: `${axis.name} has no matching option in code` };
1021
+ own[axis.prop] = value === "true" ? true : value === "false" ? false : value;
1022
+ }
1023
+ const className = /\.module\.css \.([\w-]+)/.exec(root?.source ?? "")?.[1];
1024
+ const component = spec.component ? exportName(spec.component) : void 0;
1025
+ const example = spec.example;
1026
+ if (!component || !example) return { ok: false, reason: "drawn open from no code example: nothing to open in code" };
1027
+ if (!className) return { ok: false, reason: "the popup has no class to find it by in code" };
1028
+ const popup = { className };
1029
+ if (exportName(example.c) === component) {
1030
+ return { ok: true, render: { component, props: { ...example.p ?? {}, ...own, defaultOpen: true }, ...example.children?.length ? { children: example.children } : {}, popup } };
1031
+ }
1032
+ return {
1033
+ ok: true,
1034
+ render: { component, props: { defaultOpen: true }, children: [{ c: `${component}Trigger`, children: ["Open"] }, { ...example, p: { ...example.p ?? {}, ...own } }], popup }
1035
+ };
1036
+ }
994
1037
  function referenceImagePath(component, key) {
995
1038
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
996
1039
  const file = key.split(",").map((kv) => slug(kv)).filter(Boolean).join("__");
@@ -1214,6 +1257,17 @@ async function switchStory(page, storyId2) {
1214
1257
  );
1215
1258
  if (!ok) throw new Error("Storybook channel not found");
1216
1259
  }
1260
+ async function openMeasurePage(o = {}) {
1261
+ const browser = await launch(o);
1262
+ try {
1263
+ const ctx = await browser.newContext({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: o.scale ?? 1, reducedMotion: "reduce", colorScheme: "light", locale: "en-US", serviceWorkers: "block" });
1264
+ const page = await ctx.newPage();
1265
+ return { page, close: () => browser.close().catch(() => void 0) };
1266
+ } catch (e) {
1267
+ await browser.close().catch(() => void 0);
1268
+ throw e;
1269
+ }
1270
+ }
1217
1271
 
1218
1272
  // src/composite.ts
1219
1273
  import { readFile, writeFile as writeFile2 } from "node:fs/promises";
@@ -1265,6 +1319,15 @@ import pixelmatch from "pixelmatch";
1265
1319
  import { PNG } from "pngjs";
1266
1320
  var decodePng = (buf) => PNG.sync.read(buf);
1267
1321
  var encodePng = (png) => PNG.sync.write(png, { colorType: 6 });
1322
+ function cropPng(src, x, y, width, height) {
1323
+ const left = Math.max(0, Math.round(x));
1324
+ const top = Math.max(0, Math.round(y));
1325
+ const w = Math.max(1, Math.min(src.width - left, Math.round(width)));
1326
+ const h = Math.max(1, Math.min(src.height - top, Math.round(height)));
1327
+ const out = new PNG({ width: w, height: h });
1328
+ PNG.bitblt(src, out, left, top, w, h, 0, 0);
1329
+ return out;
1330
+ }
1268
1331
  function padTo(src, width, height) {
1269
1332
  if (src.width === width && src.height === height) return src;
1270
1333
  const out = new PNG({ width, height });
@@ -2105,9 +2168,12 @@ async function captureFigmaReferences(o) {
2105
2168
  try {
2106
2169
  const ctx = await newContext(browser, { width: 1200, height: 800 }, scale);
2107
2170
  const page = await newPage(ctx);
2108
- await page.goto(storyUrl(served.url, FIGMA_REFERENCE_STORY, brand, theme), { waitUntil: "load", timeout: 3e4 });
2109
- await page.waitForSelector("[data-fidelity-root], [data-fidelity-error]", { state: "attached", timeout: 2e4 });
2110
- await settle(page, { busy: false });
2171
+ const openStory = async () => {
2172
+ await page.goto(storyUrl(served.url, FIGMA_REFERENCE_STORY, brand, theme), { waitUntil: "load", timeout: 3e4 });
2173
+ await page.waitForSelector("[data-fidelity-root], [data-fidelity-error]", { state: "attached", timeout: 2e4 });
2174
+ await settle(page, { busy: false });
2175
+ };
2176
+ await openStory();
2111
2177
  log(`Capturing ${jobs.length} variants at ${scale}x (${brand} ${theme})`);
2112
2178
  for (const j of jobs) {
2113
2179
  const variants = manifest.components[j.component].variants;
@@ -2116,25 +2182,17 @@ async function captureFigmaReferences(o) {
2116
2182
  skipped++;
2117
2183
  continue;
2118
2184
  }
2119
- const plan = JSON.stringify(j.plan.render);
2120
- await page.evaluate(
2121
- ({ id, plan: plan2 }) => new Promise((resolve2) => {
2122
- const ch = window.__STORYBOOK_ADDONS_CHANNEL__;
2123
- const done = () => {
2124
- clearTimeout(timer);
2125
- ch.off("storyRendered", done);
2126
- resolve2();
2127
- };
2128
- const timer = setTimeout(done, 1e4);
2129
- ch.on("storyRendered", done);
2130
- ch.emit("updateStoryArgs", { storyId: id, updatedArgs: { plan: plan2 } });
2131
- }),
2132
- { id: FIGMA_REFERENCE_STORY, plan }
2133
- );
2185
+ const shown = await renderPlan(page, JSON.stringify(j.plan.render));
2186
+ if (shown.broken) {
2187
+ variants[j.key] = { skipped: `the code example threw: ${shown.error ?? "the story did not finish rendering"}` };
2188
+ skipped++;
2189
+ await openStory();
2190
+ continue;
2191
+ }
2134
2192
  await settle(page, { network: false, quiet: 2, interval: 40, busy: false });
2135
2193
  const error = await page.locator("[data-fidelity-error]").first().textContent({ timeout: 200 }).catch(() => null);
2136
2194
  let target = page.locator("[data-fidelity-root] > *").first();
2137
- if (!error && !await target.count()) {
2195
+ if (!error && (j.plan.render.within || j.plan.render.popup || !await target.count())) {
2138
2196
  const marked = page.locator("[data-fidelity-target]").first();
2139
2197
  await marked.waitFor({ state: "visible", timeout: 3e3 }).catch(() => void 0);
2140
2198
  await settle(page, { network: false, quiet: 2, interval: 40, busy: false });
@@ -2145,6 +2203,15 @@ async function captureFigmaReferences(o) {
2145
2203
  skipped++;
2146
2204
  continue;
2147
2205
  }
2206
+ const transparent = await target.evaluate((el) => {
2207
+ for (let e = el; e; e = e.parentElement) if (getComputedStyle(e).opacity === "0") return true;
2208
+ return false;
2209
+ });
2210
+ if (transparent) {
2211
+ variants[j.key] = { skipped: "the code example rendered invisible (opacity 0)" };
2212
+ skipped++;
2213
+ continue;
2214
+ }
2148
2215
  const file = referenceImagePath(j.component, j.key);
2149
2216
  const path = join4(o.out, file);
2150
2217
  await mkdir4(dirname3(path), { recursive: true });
@@ -2165,6 +2232,35 @@ async function captureFigmaReferences(o) {
2165
2232
  log(`${captured} images, ${skipped} variants skipped. ${manifestPath}`);
2166
2233
  return { manifest, manifestPath, captured, skipped };
2167
2234
  }
2235
+ async function renderPlan(page, plan) {
2236
+ const r = await page.evaluate(
2237
+ ({ id, plan: plan2 }) => new Promise((resolve2) => {
2238
+ const ch = window.__STORYBOOK_ADDONS_CHANNEL__;
2239
+ let error;
2240
+ const failed = (p) => {
2241
+ const e = p;
2242
+ error ??= e?.message ?? e?.title ?? e?.description;
2243
+ };
2244
+ const finish = (rendered) => {
2245
+ clearTimeout(timer);
2246
+ ch.off("storyRendered", done);
2247
+ ch.off("storyThrewException", failed);
2248
+ ch.off("storyErrored", failed);
2249
+ resolve2({ rendered, ...error ? { error } : {} });
2250
+ };
2251
+ const done = () => finish(true);
2252
+ document.querySelectorAll("[data-fidelity-target]").forEach((el) => el.removeAttribute("data-fidelity-target"));
2253
+ const timer = setTimeout(() => finish(false), 1e4);
2254
+ ch.on("storyThrewException", failed);
2255
+ ch.on("storyErrored", failed);
2256
+ ch.on("storyRendered", done);
2257
+ ch.emit("updateStoryArgs", { storyId: id, updatedArgs: { plan: plan2 } });
2258
+ }),
2259
+ { id: FIGMA_REFERENCE_STORY, plan }
2260
+ );
2261
+ const errorScreen = await page.evaluate(() => document.body.classList.contains("sb-show-errordisplay"));
2262
+ return { broken: !r.rendered || !!r.error || errorScreen, ...r.error ? { error: r.error } : {} };
2263
+ }
2168
2264
  export {
2169
2265
  COMMENT_MARKER,
2170
2266
  DEFAULT_ROUTES,
@@ -2173,11 +2269,13 @@ export {
2173
2269
  boxesFromDiff,
2174
2270
  captureFigmaReferences,
2175
2271
  changedTokens,
2272
+ cropPng,
2176
2273
  decodePng,
2177
2274
  diffPngs,
2178
2275
  encodePng,
2179
2276
  hasVisualChange,
2180
2277
  linkNodeModules,
2278
+ openMeasurePage,
2181
2279
  padTo,
2182
2280
  planImpact,
2183
2281
  rankShots,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge-ds/visual",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "description": "Bridge visual impact analysis: screenshots the stories and app screens a design system change touches, at the base and head refs, and pixel-diffs them into before/after/diff images and a JSON manifest.",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "build:package": "node ../../scripts/build-packages.mjs visual"
48
48
  },
49
49
  "dependencies": {
50
- "@bridge-ds/core": "^0.18.0",
50
+ "@bridge-ds/core": "^0.19.0",
51
51
  "pixelmatch": "7.1.0",
52
52
  "playwright-core": "1.63.0",
53
53
  "pngjs": "7.0.0"