@bridge-ds/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { a as collectRefs, i as isAlias, b as aliasTarget, h as hexToRgba, p as parseColor, v as valuesEqual, f as formatValue, L as LayeredTokenSet, e as aliasOf, r as rgbaToDtcg, c as comboKey, q as rgbaToHex, g as globToRegExp, d as validateLayered } from "./layered-DGate4nv.js";
1
+ import { a as collectRefs, i as isAlias, b as aliasTarget, h as hexToRgba, p as parseColor, v as valuesEqual, f as formatValue, L as LayeredTokenSet, d as aliasOf, r as rgbaToDtcg, c as comboKey, e as rgbaToHex, g as globToRegExp, j as validateLayered } from "./layered-BnbmToGs.js";
2
2
  const EXT_ID = "com.dsbridge.id";
3
3
  const EXT_SCOPES = "com.figma.scopes";
4
4
  const PRIMITIVE_MODE = "value";
@@ -100,12 +100,12 @@ function previewComment(input) {
100
100
  }
101
101
  lines.push("");
102
102
  const ctx = { branch: input.branch, pr: input.pr, sha: input.sha };
103
- const links = previewLinks(input.apps, ctx);
103
+ const previews = input.previews ?? resolveBranchPreviews(input.apps, ctx);
104
104
  lines.push("### Previews", "");
105
- if (links.length) {
105
+ if (previews.length) {
106
106
  lines.push("| App | Preview |", "| --- | --- |");
107
- for (const l of links) lines.push(`| ${l.label} | ${l.url} |`);
108
- const docs = previewUrl(input.apps.docs, ctx);
107
+ for (const p of previews) lines.push(`| ${p.label} | ${p.url ?? "Link in the Vercel comment on this pull request"} |`);
108
+ const docs = previews.find((p) => p.app === "docs")?.url;
109
109
  if (docs) {
110
110
  lines.push("");
111
111
  lines.push(`Tokens only, no reinstall: open your staging with \`?bridge-tokens=${new URL("tokens.css", docs).href}\` (the staging app must allow this origin, see docs/PREVIEWS.md).`);
@@ -115,6 +115,449 @@ function previewComment(input) {
115
115
  }
116
116
  return lines.join("\n") + "\n";
117
117
  }
118
+ function installCommandFromPreviewComment(body) {
119
+ if (!body || !body.trimStart().startsWith(PREVIEW_COMMENT_MARKER)) return null;
120
+ const m = /```(?:sh|bash|shell)[^\n]*\n([\s\S]*?)```/.exec(body);
121
+ if (!m) return null;
122
+ const command = m[1].split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#")).join(" ").replace(/\s*\\\s+/g, " ").replace(/\s+/g, " ").trim();
123
+ return command || null;
124
+ }
125
+ function readPreviewComment(body) {
126
+ if (!body || !body.trimStart().startsWith(PREVIEW_COMMENT_MARKER)) return null;
127
+ const command = installCommandFromPreviewComment(body);
128
+ const sha7 = /Snapshot of `([0-9a-f]{7,40})`/.exec(body)?.[1]?.slice(0, 7) ?? null;
129
+ const source = !command ? "none" : command.includes("https://pkg.pr.new/") ? "pkg.pr.new" : /\/releases\/download\/preview-pr-\d+\//.test(command) ? "release" : "other";
130
+ return { command, sha7, source };
131
+ }
132
+ function findPreviewComment(comments) {
133
+ return [...comments].reverse().find((c) => !!c.body && c.body.trimStart().startsWith(PREVIEW_COMMENT_MARKER));
134
+ }
135
+ const httpsUrl = (raw) => {
136
+ if (!raw || typeof raw !== "string") return null;
137
+ try {
138
+ const u = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
139
+ return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
140
+ } catch {
141
+ return null;
142
+ }
143
+ };
144
+ const decodeBase64 = (b64) => {
145
+ const bin = atob(b64.replace(/\s/g, ""));
146
+ const bytes = new Uint8Array(bin.length);
147
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
148
+ return new TextDecoder("utf-8").decode(bytes);
149
+ };
150
+ const isVercelComment = (body) => !!body && /^\[vc\]: #[^:\s]*:[A-Za-z0-9+/=]+/m.test(body);
151
+ function parseVercelComment(body) {
152
+ if (!body) return null;
153
+ const m = /^\[vc\]: #[^:\s]*:([A-Za-z0-9+/=]+)/m.exec(body);
154
+ if (!m) return null;
155
+ let data;
156
+ try {
157
+ data = JSON.parse(decodeBase64(m[1]));
158
+ } catch {
159
+ return null;
160
+ }
161
+ if (!data || !Array.isArray(data.projects)) return null;
162
+ return data.projects.filter((p) => p && typeof p.name === "string").map((p) => ({ name: p.name, previewUrl: httpsUrl(typeof p.previewUrl === "string" ? p.previewUrl : null), status: typeof p.nextCommitStatus === "string" ? p.nextCommitStatus : null }));
163
+ }
164
+ function findVercelComment(comments) {
165
+ return [...comments].reverse().find((c) => isVercelComment(c.body));
166
+ }
167
+ function templateHostPrefix(template) {
168
+ if (!template) return null;
169
+ const host = /^https?:\/\/([^/?#]+)/i.exec(template)?.[1];
170
+ if (!host) return null;
171
+ return host.split(".")[0].split("{")[0].toLowerCase();
172
+ }
173
+ function appForProject(project, apps) {
174
+ const name = project.trim().toLowerCase();
175
+ if (!name) return null;
176
+ const keys = Object.keys(PREVIEW_APP_LABELS);
177
+ const prefixes = keys.map((app) => [app, templateHostPrefix(apps[app])]);
178
+ const exact = prefixes.find(([, p]) => p === `${name}-git-` || p === name);
179
+ if (exact) return exact[0];
180
+ const loose = prefixes.filter(([, p]) => p !== null && p.startsWith(`${name}-`));
181
+ return loose.length === 1 ? loose[0][0] : null;
182
+ }
183
+ function projectOfLabel(label) {
184
+ const m = /^\s*(?:preview|vercel)\s*[\u2013\u2014-]\s*(.+?)\s*$/i.exec(label);
185
+ return m ? m[1] : null;
186
+ }
187
+ const STATE_OF = {
188
+ success: "ready",
189
+ inactive: "ready",
190
+ deployed: "ready",
191
+ ready: "ready",
192
+ ignored: "ready",
193
+ // Vercel skipped the build (nothing changed for this app): the branch alias serves the last one.
194
+ pending: "building",
195
+ queued: "building",
196
+ in_progress: "building",
197
+ building: "building",
198
+ initializing: "building",
199
+ failure: "failed",
200
+ error: "failed",
201
+ failed: "failed",
202
+ canceled: "failed"
203
+ };
204
+ const stateOf = (s) => STATE_OF[(s ?? "").toLowerCase()] ?? "unknown";
205
+ const isInspectorUrl = (u) => /^https:\/\/(?:www\.)?vercel\.com\//i.test(u);
206
+ function resolveBranchPreviews(apps, ctx, sources = {}) {
207
+ const seen = /* @__PURE__ */ new Map();
208
+ const at = (app) => seen.get(app) ?? (seen.set(app, {}), seen.get(app));
209
+ const offer = (app, url, urlSource, state, stateRank) => {
210
+ if (!app) return;
211
+ const s = at(app);
212
+ if (url && !s.url) Object.assign(s, { url, urlSource });
213
+ if (state !== "unknown" && (s.stateRank === void 0 || stateRank < s.stateRank)) Object.assign(s, { state, stateRank });
214
+ };
215
+ const contexts = /* @__PURE__ */ new Set();
216
+ for (const st of sources.statuses ?? []) {
217
+ if (contexts.has(st.context)) continue;
218
+ contexts.add(st.context);
219
+ const project = projectOfLabel(st.context);
220
+ if (!project) continue;
221
+ const url = httpsUrl(st.target_url);
222
+ offer(appForProject(project, apps), url && !isInspectorUrl(url) ? url : null, "status", stateOf(st.state), 0);
223
+ }
224
+ for (const d of sources.deployments ?? []) {
225
+ const project = projectOfLabel(d.environment);
226
+ const app = project ? appForProject(project, apps) : null;
227
+ if (!app) continue;
228
+ const url = httpsUrl(d.environmentUrl);
229
+ const s = at(app);
230
+ if (url && s.urlSource !== "deployment") Object.assign(s, { url, urlSource: "deployment" });
231
+ const state = stateOf(d.state);
232
+ if (state !== "unknown" && (s.stateRank === void 0 || 1 < s.stateRank)) Object.assign(s, { state, stateRank: 1 });
233
+ }
234
+ for (const p of parseVercelComment(findVercelComment(sources.comments ?? [])?.body) ?? []) offer(appForProject(p.name, apps), p.previewUrl, "vercel-comment", stateOf(p.status), 2);
235
+ const out = [];
236
+ for (const app of Object.keys(PREVIEW_APP_LABELS)) {
237
+ if (!apps[app]) continue;
238
+ const s = seen.get(app) ?? {};
239
+ const url = s.url ?? previewUrl(apps[app], ctx);
240
+ const source = s.url ? s.urlSource : url ? "template" : null;
241
+ out.push({ app, label: PREVIEW_APP_LABELS[app], url, state: s.state ?? "unknown", source });
242
+ }
243
+ return out;
244
+ }
245
+ const previewLinksOf = (previews) => previews.filter((p) => !!p.url).map((p) => ({ app: p.app, label: p.label, url: p.url }));
246
+ const previewsSettled = (previews) => previews.every((p) => !!p.url && (p.state === "ready" || p.state === "failed"));
247
+ const LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
248
+ const SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
249
+ function parseOwnerEntry(raw) {
250
+ if (typeof raw !== "string") return null;
251
+ const s = raw.trim().replace(/^@/, "");
252
+ const slash = s.indexOf("/");
253
+ if (slash < 0) return LOGIN.test(s) ? { kind: "person", login: s } : null;
254
+ const org = s.slice(0, slash);
255
+ const slug = s.slice(slash + 1);
256
+ return LOGIN.test(org) && SLUG.test(slug) ? { kind: "team", org, slug } : null;
257
+ }
258
+ function formatOwnerEntry(e) {
259
+ return e.kind === "team" ? `@${e.org}/${e.slug}` : e.login;
260
+ }
261
+ const isTeamEntry = (raw) => parseOwnerEntry(raw)?.kind === "team";
262
+ const teamKey = (org, slug) => `@${org.toLowerCase()}/${slug.toLowerCase()}`;
263
+ function teamKeyOf(raw) {
264
+ const e = parseOwnerEntry(raw);
265
+ return e?.kind === "team" ? teamKey(e.org, e.slug) : void 0;
266
+ }
267
+ function ownerEntryProblems(raw) {
268
+ if (raw === void 0) return [];
269
+ const isObj2 = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
270
+ if (!isObj2(raw)) return ['"governance.owners" must be { "design": [people or teams], "tech": [people or teams] }'];
271
+ const out = [];
272
+ for (const [k, v] of Object.entries(raw)) {
273
+ if (k !== "design" && k !== "tech") {
274
+ out.push(`"governance.owners.${k}" is not an owner group (design, tech)`);
275
+ continue;
276
+ }
277
+ if (!Array.isArray(v)) {
278
+ out.push(`"governance.owners.${k}" must be a list of people or teams`);
279
+ continue;
280
+ }
281
+ v.forEach((x, i) => {
282
+ if (typeof x !== "string" || !parseOwnerEntry(x))
283
+ out.push(`"governance.owners.${k}[${i}]" (${JSON.stringify(x)}) is not a GitHub login or team. Write a person as "peterbono" or "@peterbono", a team as "@org/team-slug".`);
284
+ });
285
+ }
286
+ return out;
287
+ }
288
+ function teamUnreadableMessage(team, reason) {
289
+ const name = `@${team.org}/${team.slug}`;
290
+ switch (reason) {
291
+ case "personal-account":
292
+ return `${name} cannot be used: ${team.org} is a personal GitHub account, and teams only exist in organizations. List people instead, or move the repository to an organization.`;
293
+ case "missing-permission":
294
+ return `The members of ${name} cannot be read: the GitHub token needs read:org (classic token) or Members: Read on the ${team.org} organization (fine-grained token).`;
295
+ case "no-token":
296
+ return `The members of ${name} cannot be read without a working GitHub token. Connect GitHub with a token that has read:org.`;
297
+ case "not-found":
298
+ return `${name} was not found. Check the team slug on GitHub, or give the token read:org so it can see the team.`;
299
+ case "unreachable":
300
+ return `The members of ${name} cannot be read right now: GitHub did not answer. Try again in a moment.`;
301
+ }
302
+ }
303
+ function teamsIn(owners) {
304
+ const all = Array.isArray(owners) ? owners : [...owners.design ?? [], ...owners.tech ?? []];
305
+ const out = /* @__PURE__ */ new Map();
306
+ for (const raw of all) {
307
+ const e = parseOwnerEntry(raw);
308
+ if (e?.kind === "team") out.set(teamKey(e.org, e.slug), { key: teamKey(e.org, e.slug), org: e.org, slug: e.slug });
309
+ }
310
+ return [...out.values()];
311
+ }
312
+ const norm$1 = (x) => x.trim().toLowerCase().replace(/^@/, "");
313
+ function expandEntries(entries, teams) {
314
+ const out = [];
315
+ const seen = /* @__PURE__ */ new Set();
316
+ const add = (x) => {
317
+ const k = norm$1(x);
318
+ if (k && !seen.has(k)) {
319
+ seen.add(k);
320
+ out.push(x.replace(/^@(?=[^/]+$)/, ""));
321
+ }
322
+ };
323
+ for (const raw of entries) {
324
+ const e = parseOwnerEntry(raw);
325
+ if (!e) continue;
326
+ if (e.kind === "person") add(e.login);
327
+ else {
328
+ const state = teams?.[teamKey(e.org, e.slug)];
329
+ if (state?.status === "ok") state.members.forEach(add);
330
+ else add(teamKey(e.org, e.slug));
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+ function expandOwners(owners, teams) {
336
+ return { design: expandEntries(owners.design, teams), tech: expandEntries(owners.tech, teams) };
337
+ }
338
+ const people = (n) => `${n} ${n === 1 ? "person" : "people"}`;
339
+ function entryLabel(raw, teams) {
340
+ const e = parseOwnerEntry(raw);
341
+ if (!e) return raw;
342
+ if (e.kind === "person") return `@${e.login}`;
343
+ const state = teams?.[teamKey(e.org, e.slug)];
344
+ const name = `@${e.org}/${e.slug}`;
345
+ return state?.status === "ok" ? `${name} (${people(state.members.length)})` : name;
346
+ }
347
+ function entriesFor(entries, logins, teams) {
348
+ const want = new Set(logins.map(norm$1));
349
+ const out = [];
350
+ for (const raw of entries) {
351
+ const e = parseOwnerEntry(raw);
352
+ if (!e) continue;
353
+ if (e.kind === "person") {
354
+ if (want.has(norm$1(e.login))) out.push(entryLabel(raw, teams));
355
+ continue;
356
+ }
357
+ const state = teams?.[teamKey(e.org, e.slug)];
358
+ if (state?.status !== "ok" || state.members.some((m) => want.has(norm$1(m)))) out.push(entryLabel(raw, teams));
359
+ }
360
+ return [...new Set(out)];
361
+ }
362
+ function unreadableTeams(teams) {
363
+ return Object.values(teams ?? {}).filter((t) => t.status === "unreadable");
364
+ }
365
+ class TeamDirectory {
366
+ constructor(reader, opts = {}) {
367
+ this.reader = reader;
368
+ this.ttl = opts.ttlMs ?? 5 * 6e4;
369
+ this.now = opts.now ?? Date.now;
370
+ }
371
+ reader;
372
+ teams = /* @__PURE__ */ new Map();
373
+ accounts = /* @__PURE__ */ new Map();
374
+ memberships = /* @__PURE__ */ new Map();
375
+ ttl;
376
+ now;
377
+ accountType(org) {
378
+ const k = org.toLowerCase();
379
+ let p = this.accounts.get(k);
380
+ if (!p) {
381
+ p = this.reader.getAccountType(org).catch(() => null);
382
+ this.accounts.set(k, p);
383
+ }
384
+ return p;
385
+ }
386
+ /** The reason a read of `org` failed, as a state. A 404 on a personal account says so; a 403 is a missing permission. */
387
+ async failure(ref, err) {
388
+ const e = err ?? {};
389
+ let reason;
390
+ if (!this.reader.hasToken) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "no-token";
391
+ else if (e.status === 401) reason = "no-token";
392
+ else if (e.status === 403 || e.auth === "missing-permission") reason = "missing-permission";
393
+ else if (e.status === 404) reason = await this.accountType(ref.org) === "User" ? "personal-account" : "not-found";
394
+ else reason = "unreachable";
395
+ return { ...ref, status: "unreadable", reason, message: teamUnreadableMessage(ref, reason) };
396
+ }
397
+ /** Members of one team, or why they cannot be read. Cached. */
398
+ team(org, slug) {
399
+ const ref = { key: teamKey(org, slug), org, slug };
400
+ const hit = this.teams.get(ref.key);
401
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
402
+ const value = (async () => {
403
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
404
+ try {
405
+ const members = await this.reader.listTeamMembers(org, slug);
406
+ return { ...ref, status: "ok", members };
407
+ } catch (err) {
408
+ return this.failure(ref, err);
409
+ }
410
+ })();
411
+ this.teams.set(ref.key, { at: this.now(), value });
412
+ void value.then((s) => {
413
+ if (s.status === "unreadable" && s.reason === "unreachable") this.teams.delete(ref.key);
414
+ });
415
+ return value;
416
+ }
417
+ /** Every team named in the owner lists, read in parallel. Empty object when no team is named (no request). */
418
+ async resolve(owners) {
419
+ const refs = teamsIn(owners);
420
+ const states = await Promise.all(refs.map((r) => this.team(r.org, r.slug)));
421
+ return Object.fromEntries(states.map((s) => [s.key, s]));
422
+ }
423
+ /**
424
+ * Whether `login` is an active member of one team: from the cached member list when there is one, else one
425
+ * membership read (GET /orgs/{org}/teams/{slug}/memberships/{user}). An unreadable team returns its state.
426
+ */
427
+ async isMember(org, slug, login) {
428
+ const ref = { key: teamKey(org, slug), org, slug };
429
+ const cached = this.teams.get(ref.key);
430
+ if (cached && this.now() - cached.at < this.ttl) {
431
+ const s = await cached.value;
432
+ if (s.status === "ok") return s.members.some((m) => norm$1(m) === norm$1(login));
433
+ }
434
+ const k = `${ref.key}#${norm$1(login)}`;
435
+ const hit = this.memberships.get(k);
436
+ if (hit && this.now() - hit.at < this.ttl) return hit.value;
437
+ const value = (async () => {
438
+ if (!this.reader.hasToken) return this.failure(ref, void 0);
439
+ try {
440
+ return await this.reader.getTeamMembership(org, slug, login) === "active";
441
+ } catch (err) {
442
+ return this.failure(ref, err);
443
+ }
444
+ })();
445
+ this.memberships.set(k, { at: this.now(), value });
446
+ return value;
447
+ }
448
+ /**
449
+ * Whether `login` holds a right given to `entries` (people and teams): a person entry matches directly, a team
450
+ * through its membership. Teams are only read when no person entry matches.
451
+ */
452
+ async memberOfAny(entries, login) {
453
+ const unreadable = [];
454
+ const parsed = entries.map(parseOwnerEntry).filter((e) => Boolean(e));
455
+ if (parsed.some((e) => e.kind === "person" && norm$1(e.login) === norm$1(login))) return { member: true, unreadable };
456
+ for (const e of parsed) {
457
+ if (e.kind !== "team") continue;
458
+ const r = await this.isMember(e.org, e.slug, login);
459
+ if (r === true) return { member: true, unreadable };
460
+ if (typeof r === "object" && r.status === "unreadable") unreadable.push(r);
461
+ }
462
+ return { member: false, unreadable };
463
+ }
464
+ /** The organization's teams for a picker, or why they cannot be listed (a personal account has none). */
465
+ async orgTeams(org) {
466
+ if (await this.accountType(org) === "User") return { status: "unreadable", reason: "personal-account" };
467
+ if (!this.reader.hasToken) return { status: "unreadable", reason: "no-token" };
468
+ try {
469
+ return { status: "ok", teams: await this.reader.listOrgTeams(org) };
470
+ } catch (err) {
471
+ const s = await this.failure({ key: teamKey(org, "*"), org, slug: "*" }, err);
472
+ return { status: "unreadable", reason: s.status === "unreadable" ? s.reason : "unreachable" };
473
+ }
474
+ }
475
+ /** Drops every cached read (after the token changes). */
476
+ clear() {
477
+ this.teams.clear();
478
+ this.accounts.clear();
479
+ this.memberships.clear();
480
+ }
481
+ }
482
+ const OWNER_GROUPS = ["design", "tech"];
483
+ const RIGHTS = ["approveDesign", "approveTech", "ship"];
484
+ const RIGHT_LABEL = {
485
+ approveDesign: "Approve design",
486
+ approveTech: "Approve tech",
487
+ ship: "Ship"
488
+ };
489
+ const DEFAULT_RIGHTS = { approveDesign: ["design"], approveTech: ["tech"], ship: ["tech"] };
490
+ const norm = (x) => x.trim().toLowerCase().replace(/^@/, "");
491
+ const uniq = (xs) => [...new Set(xs)];
492
+ function normalizeRights(input) {
493
+ const pick = (r) => {
494
+ const v = input?.[r];
495
+ if (!Array.isArray(v)) return [...DEFAULT_RIGHTS[r]];
496
+ return OWNER_GROUPS.filter((g) => v.includes(g));
497
+ };
498
+ return { approveDesign: pick("approveDesign"), approveTech: pick("approveTech"), ship: pick("ship") };
499
+ }
500
+ function rightsProblems(raw) {
501
+ if (raw === void 0) return [];
502
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"governance.rights" must be { "approveDesign": [groups], "approveTech": [groups], "ship": [groups] }'];
503
+ const out = [];
504
+ for (const [k, v] of Object.entries(raw)) {
505
+ if (!RIGHTS.includes(k)) out.push(`"governance.rights.${k}" is not a right (${RIGHTS.join(", ")})`);
506
+ else if (!Array.isArray(v) || v.length === 0 || !v.every((g) => OWNER_GROUPS.includes(g)))
507
+ out.push(`"governance.rights.${k}" must list one or more of "design", "tech"`);
508
+ }
509
+ return out;
510
+ }
511
+ function holdersOf(right, owners, rights) {
512
+ const groups = (rights ?? DEFAULT_RIGHTS)[right];
513
+ return uniq(groups.flatMap((g) => owners[g] ?? []));
514
+ }
515
+ function approverLists(owners, rights, teams) {
516
+ const entries = approverEntries(owners, rights);
517
+ return teams === void 0 ? entries : expandOwners(entries, teams);
518
+ }
519
+ function approverEntries(owners, rights) {
520
+ if (!rights) return { design: [...owners.design], tech: [...owners.tech] };
521
+ return { design: holdersOf("approveDesign", owners, rights), tech: holdersOf("approveTech", owners, rights) };
522
+ }
523
+ function shipPolicy(owners, rights, teams) {
524
+ if (!rights) return null;
525
+ const entries = holdersOf("ship", owners, rights);
526
+ if (!entries.length) return null;
527
+ const logins = expandEntries(entries, teams);
528
+ return { logins, groups: [...rights.ship], entries, ...teams ? { teams } : {} };
529
+ }
530
+ function unreadableShipTeams(policy) {
531
+ if (!policy?.teams) return [];
532
+ const keys = new Set((policy.entries ?? []).map((e) => e.trim().toLowerCase().replace(/^@?/, "@")));
533
+ return unreadableTeams(policy.teams).filter((t) => keys.has(t.key));
534
+ }
535
+ function canShip(login, policy) {
536
+ if (!policy) return true;
537
+ if (!login || login.includes("/")) return false;
538
+ return policy.logins.map(norm).includes(norm(login));
539
+ }
540
+ function groupsPhrase(groups) {
541
+ const g = OWNER_GROUPS.filter((x) => groups.includes(x));
542
+ if (g.length === 1) return `the ${g[0]} owner`;
543
+ return `a ${g.join(" or ")} owner`;
544
+ }
545
+ function whoCanShipSentence(policy) {
546
+ const names = (policy.entries ?? policy.logins).map((l) => entryLabel(l, policy.teams)).join(", ");
547
+ return `Only ${groupsPhrase(policy.groups)} can ship this${names ? ` (${names})` : ""}.`;
548
+ }
549
+ const DEFAULT_VISUAL_IMPACT = { mode: "auto" };
550
+ const VISUAL_CHECK_LABEL = "visual-check";
551
+ function visualImpactProblems(raw) {
552
+ if (raw === void 0) return [];
553
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"visualImpact" must be { "mode": "auto" | "on-demand" }'];
554
+ const out = [];
555
+ for (const [k, v] of Object.entries(raw)) {
556
+ if (k !== "mode") out.push(`"visualImpact.${k}" is not a setting (mode)`);
557
+ else if (v !== "auto" && v !== "on-demand") out.push('"visualImpact.mode" must be "auto" or "on-demand"');
558
+ }
559
+ return out;
560
+ }
118
561
  const CONFIG_FILE = "bridge.config.json";
119
562
  const DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
120
563
  const DEFAULT_TOKEN_PATHS = {
@@ -251,9 +694,11 @@ function defineConfig(input) {
251
694
  requireReason: g.override.requireReason ?? DEFAULT_OVERRIDE.requireReason,
252
695
  minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
253
696
  }
254
- } : {}
697
+ } : {},
698
+ ...g.rights ? { rights: normalizeRights(g.rights) } : {}
255
699
  },
256
700
  ...input.audit ? { audit: { ...input.audit } } : {},
701
+ ...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
257
702
  ...input.integrations ? { integrations: { ...input.integrations } } : {},
258
703
  ...input.previews ? {
259
704
  previews: {
@@ -360,9 +805,7 @@ function loadConfig(source) {
360
805
  else {
361
806
  for (const k of ["protectedBranches", "labels", "guardrails"]) if (gov[k] !== void 0 && !(Array.isArray(gov[k]) && gov[k].every((x) => typeof x === "string"))) problems.push(`"governance.${k}" must be a list of strings`);
362
807
  if (gov.branchPrefix !== void 0 && !isStr(gov.branchPrefix)) problems.push('"governance.branchPrefix" must be a non-empty string');
363
- const o = gov.owners;
364
- if (o !== void 0 && (!isObj(o) || ["design", "tech"].some((k) => o[k] !== void 0 && !(Array.isArray(o[k]) && o[k].every((x) => typeof x === "string")))))
365
- problems.push('"governance.owners" must be { "design": [logins], "tech": [logins] }');
808
+ problems.push(...ownerEntryProblems(gov.owners));
366
809
  const ov = gov.override;
367
810
  if (ov !== void 0) {
368
811
  if (!isObj(ov)) problems.push('"governance.override" must be an object');
@@ -372,8 +815,10 @@ function loadConfig(source) {
372
815
  if (ov.minReasonLength !== void 0 && !(Number.isInteger(ov.minReasonLength) && ov.minReasonLength >= 0)) problems.push('"governance.override.minReasonLength" must be a whole number, 0 or more');
373
816
  }
374
817
  }
818
+ problems.push(...rightsProblems(gov.rights));
375
819
  }
376
820
  }
821
+ problems.push(...visualImpactProblems(d.visualImpact));
377
822
  const audit = d.audit;
378
823
  if (audit !== void 0) {
379
824
  if (!isObj(audit)) problems.push('"audit" must be an object');
@@ -419,7 +864,8 @@ function toConfigJson(config, schema = "./packages/core/bridge.config.schema.jso
419
864
  cssVar: config.cssVar,
420
865
  governance: config.governance,
421
866
  ...config.audit ? { audit: config.audit } : {},
422
- ...config.previews ? { previews: config.previews } : {}
867
+ ...config.previews ? { previews: config.previews } : {},
868
+ ...config.visualImpact ? { visualImpact: config.visualImpact } : {}
423
869
  };
424
870
  }
425
871
  class JsonParseError extends Error {
@@ -2480,141 +2926,183 @@ function parseComponentSpecs(text) {
2480
2926
  }
2481
2927
  const tokenCssVar = (path, prefix = "--", separator = "-") => `${prefix}${path.split(".").join(separator)}`;
2482
2928
  export {
2483
- checkLayeredPair as $,
2484
- removeToken as A,
2485
- moveToken as B,
2929
+ tokensPath as $,
2930
+ flattenTokens as A,
2931
+ serializeTokenFile as B,
2486
2932
  COMPONENT_SPECS_VERSION as C,
2487
- effectiveScopes as D,
2488
- sameScopes as E,
2489
- defaultScopes as F,
2490
- readTokenAt as G,
2491
- upsertToken as H,
2492
- serializeTokenFile as I,
2493
- emptyTokenFile as J,
2494
- parseTokenFile as K,
2495
- modeFile as L,
2496
- defaultFile as M,
2497
- EXT_SCOPES as N,
2498
- EXT_ID as O,
2499
- DEFAULT_PROTECTED_BRANCHES as P,
2500
- ProtectedBranchError as Q,
2501
- ConfigError as R,
2933
+ removeToken as D,
2934
+ upsertToken as E,
2935
+ threeWayDiff as F,
2936
+ emptyTokenFile as G,
2937
+ parseTokenFile as H,
2938
+ EXT_ID as I,
2939
+ EXT_SCOPES as J,
2940
+ resolve as K,
2941
+ moveToken as L,
2942
+ effectiveScopes as M,
2943
+ sameScopes as N,
2944
+ defaultScopes as O,
2945
+ readTokenAt as P,
2946
+ modeFile as Q,
2947
+ defaultFile as R,
2502
2948
  SINGLE_MODE as S,
2503
2949
  TIER_NAMES as T,
2504
- DEFAULT_TOKEN_PATHS as U,
2505
- applyTokenEdits as V,
2506
- buildHistoryEntry as W,
2507
- tokensPath as X,
2508
- coerceValue as Y,
2509
- toConfigJson as Z,
2510
- parseContrastChecks as _,
2950
+ DEFAULT_PROTECTED_BRANCHES as U,
2951
+ ProtectedBranchError as V,
2952
+ ConfigError as W,
2953
+ DEFAULT_TOKEN_PATHS as X,
2954
+ applyTokenEdits as Y,
2955
+ buildHistoryEntry as Z,
2956
+ resolveBranchPreviews as _,
2511
2957
  assertSafeBranch as a,
2512
- findToken as a$,
2513
- classifyRisk as a0,
2514
- formatChangeset as a1,
2515
- RISK_LABELS as a2,
2516
- parseJson as a3,
2517
- serializeJson as a4,
2518
- plainEqual as a5,
2519
- toPlain as a6,
2520
- getEntry as a7,
2521
- fromPlain as a8,
2522
- snapshotUrl as a9,
2523
- branchSlug as aA,
2524
- changeKind as aB,
2525
- changeTable as aC,
2526
- changedModes as aD,
2527
- changesetBump as aE,
2528
- classifyLayered as aF,
2529
- clearRawDeep as aG,
2530
- commitMessage as aH,
2531
- composeChangeset as aI,
2532
- composePrBody as aJ,
2533
- composeProposalBody as aK,
2534
- composite as aL,
2535
- contextFor as aM,
2536
- contrastDelta as aN,
2537
- contrastDeltaTable as aO,
2538
- contrastLevel as aP,
2539
- contrastPair as aQ,
2540
- contrastRatio as aR,
2541
- countChanges as aS,
2542
- defaultContext as aT,
2543
- defaultTitle as aU,
2544
- defineConfig as aV,
2545
- detectLayout as aW,
2546
- diffLayered as aX,
2547
- fieldChanges as aY,
2548
- fileModes as aZ,
2549
- filterHistory as a_,
2550
- tarballUrl as aa,
2551
- previewLinks as ab,
2552
- slugify as ac,
2553
- installCommand as ad,
2554
- AAA_MIN as ae,
2555
- AA_MIN as af,
2556
- BULK_LIMIT as ag,
2557
- CONTRAST_CELL_MAX as ah,
2558
- CONTRAST_CHECKS_FILE as ai,
2559
- DEFAULT_COMPONENT_SPECS_FILE as aj,
2560
- DEFAULT_GOVERNANCE as ak,
2561
- DEFAULT_OVERRIDE as al,
2562
- JsonParseError as am,
2563
- PREVIEW_APP_LABELS as an,
2564
- PREVIEW_COMMENT_MARKER as ao,
2565
- PREVIEW_PLACEHOLDERS as ap,
2566
- PRIMITIVE_MODE as aq,
2567
- RISK_TIER as ar,
2568
- SPEC_FIELDS as as,
2569
- TIER_ORDER as at,
2570
- UNRELEASED as au,
2571
- affectedContrast as av,
2572
- appUrl as aw,
2573
- assignVersions as ax,
2574
- baseBranchOf as ay,
2575
- branchName as az,
2958
+ contextFor as a$,
2959
+ coerceValue as a0,
2960
+ toConfigJson as a1,
2961
+ parseContrastChecks as a2,
2962
+ checkLayeredPair as a3,
2963
+ classifyRisk as a4,
2964
+ formatChangeset as a5,
2965
+ RISK_LABELS as a6,
2966
+ parseJson as a7,
2967
+ serializeJson as a8,
2968
+ plainEqual as a9,
2969
+ RISK_TIER as aA,
2970
+ SPEC_FIELDS as aB,
2971
+ TIER_ORDER as aC,
2972
+ TeamDirectory as aD,
2973
+ UNRELEASED as aE,
2974
+ VISUAL_CHECK_LABEL as aF,
2975
+ affectedContrast as aG,
2976
+ appForProject as aH,
2977
+ appUrl as aI,
2978
+ approverEntries as aJ,
2979
+ approverLists as aK,
2980
+ assignVersions as aL,
2981
+ baseBranchOf as aM,
2982
+ branchName as aN,
2983
+ branchSlug as aO,
2984
+ canShip as aP,
2985
+ changeKind as aQ,
2986
+ changeTable as aR,
2987
+ changedModes as aS,
2988
+ changesetBump as aT,
2989
+ classifyLayered as aU,
2990
+ clearRawDeep as aV,
2991
+ commitMessage as aW,
2992
+ composeChangeset as aX,
2993
+ composePrBody as aY,
2994
+ composeProposalBody as aZ,
2995
+ composite as a_,
2996
+ toPlain as aa,
2997
+ getEntry as ab,
2998
+ fromPlain as ac,
2999
+ previewLinks as ad,
3000
+ slugify as ae,
3001
+ readPreviewComment as af,
3002
+ findPreviewComment as ag,
3003
+ previewsSettled as ah,
3004
+ AAA_MIN as ai,
3005
+ AA_MIN as aj,
3006
+ BULK_LIMIT as ak,
3007
+ CONTRAST_CELL_MAX as al,
3008
+ CONTRAST_CHECKS_FILE as am,
3009
+ DEFAULT_COMPONENT_SPECS_FILE as an,
3010
+ DEFAULT_GOVERNANCE as ao,
3011
+ DEFAULT_OVERRIDE as ap,
3012
+ DEFAULT_RIGHTS as aq,
3013
+ DEFAULT_VISUAL_IMPACT as ar,
3014
+ JsonParseError as as,
3015
+ OWNER_GROUPS as at,
3016
+ PREVIEW_APP_LABELS as au,
3017
+ PREVIEW_COMMENT_MARKER as av,
3018
+ PREVIEW_PLACEHOLDERS as aw,
3019
+ PRIMITIVE_MODE as ax,
3020
+ RIGHTS as ay,
3021
+ RIGHT_LABEL as az,
2576
3022
  parseChangelog as b,
2577
- firstParagraph as b0,
2578
- flattenTokens as b1,
2579
- formatNumber as b2,
2580
- getChild as b3,
2581
- indexByPath as b4,
2582
- inheritedTypeAt as b5,
2583
- isGuardrail as b6,
2584
- isTokenFile as b7,
2585
- kindForMin as b8,
2586
- layeredImpact as b9,
2587
- tokenNode as bA,
2588
- valueIn as bB,
2589
- variantName as bC,
2590
- versionTimeline as bD,
2591
- luminance as ba,
2592
- modeLabel as bb,
2593
- nodesEqual as bc,
2594
- overrideKey as bd,
2595
- pairKey as be,
2596
- parseChangesetText as bf,
2597
- parseComponentSpecs as bg,
2598
- planRestore as bh,
2599
- planRevert as bi,
2600
- planToJson as bj,
2601
- previewComment as bk,
2602
- previewUrl as bl,
2603
- protectedBranchesOf as bm,
2604
- renderedColor as bn,
2605
- resolve as bo,
2606
- runLayeredContrast as bp,
2607
- sameToken as bq,
2608
- specHash as br,
2609
- summarizeTokenChange as bs,
2610
- tarballAssetName as bt,
2611
- threeWayDiff as bu,
2612
- titleOf as bv,
2613
- toChangeSummary as bw,
2614
- toMap as bx,
2615
- tokenBump as by,
2616
- tokenHistory as bz,
3023
+ tarballAssetName as b$,
3024
+ contrastDelta as b0,
3025
+ contrastDeltaTable as b1,
3026
+ contrastLevel as b2,
3027
+ contrastPair as b3,
3028
+ contrastRatio as b4,
3029
+ countChanges as b5,
3030
+ defaultContext as b6,
3031
+ defaultTitle as b7,
3032
+ defineConfig as b8,
3033
+ detectLayout as b9,
3034
+ luminance as bA,
3035
+ modeLabel as bB,
3036
+ nodesEqual as bC,
3037
+ normalizeRights as bD,
3038
+ overrideKey as bE,
3039
+ ownerEntryProblems as bF,
3040
+ pairKey as bG,
3041
+ parseChangesetText as bH,
3042
+ parseComponentSpecs as bI,
3043
+ parseOwnerEntry as bJ,
3044
+ parseVercelComment as bK,
3045
+ planRestore as bL,
3046
+ planRevert as bM,
3047
+ planToJson as bN,
3048
+ previewComment as bO,
3049
+ previewLinksOf as bP,
3050
+ previewUrl as bQ,
3051
+ projectOfLabel as bR,
3052
+ protectedBranchesOf as bS,
3053
+ renderedColor as bT,
3054
+ rightsProblems as bU,
3055
+ runLayeredContrast as bV,
3056
+ sameToken as bW,
3057
+ shipPolicy as bX,
3058
+ snapshotUrl as bY,
3059
+ specHash as bZ,
3060
+ summarizeTokenChange as b_,
3061
+ diffLayered as ba,
3062
+ entriesFor as bb,
3063
+ entryLabel as bc,
3064
+ expandEntries as bd,
3065
+ expandOwners as be,
3066
+ fieldChanges as bf,
3067
+ fileModes as bg,
3068
+ filterHistory as bh,
3069
+ findToken as bi,
3070
+ findVercelComment as bj,
3071
+ firstParagraph as bk,
3072
+ formatNumber as bl,
3073
+ formatOwnerEntry as bm,
3074
+ getChild as bn,
3075
+ groupsPhrase as bo,
3076
+ holdersOf as bp,
3077
+ indexByPath as bq,
3078
+ inheritedTypeAt as br,
3079
+ installCommand as bs,
3080
+ installCommandFromPreviewComment as bt,
3081
+ isGuardrail as bu,
3082
+ isTeamEntry as bv,
3083
+ isTokenFile as bw,
3084
+ isVercelComment as bx,
3085
+ kindForMin as by,
3086
+ layeredImpact as bz,
2617
3087
  collectHistory as c,
3088
+ tarballUrl as c0,
3089
+ teamKey as c1,
3090
+ teamKeyOf as c2,
3091
+ teamUnreadableMessage as c3,
3092
+ teamsIn as c4,
3093
+ titleOf as c5,
3094
+ toChangeSummary as c6,
3095
+ toMap as c7,
3096
+ tokenBump as c8,
3097
+ tokenHistory as c9,
3098
+ tokenNode as ca,
3099
+ unreadableShipTeams as cb,
3100
+ unreadableTeams as cc,
3101
+ valueIn as cd,
3102
+ variantName as ce,
3103
+ versionTimeline as cf,
3104
+ visualImpactProblems as cg,
3105
+ whoCanShipSentence as ch,
2618
3106
  buildReport as d,
2619
3107
  prNumberFromSubject as e,
2620
3108
  CONFIG_FILE as f,
@@ -2639,4 +3127,4 @@ export {
2639
3127
  contextLabel as y,
2640
3128
  pathId as z
2641
3129
  };
2642
- //# sourceMappingURL=component-spec-B0V_D3gM.js.map
3130
+ //# sourceMappingURL=component-spec-cvrSvIZj.js.map