@storyshelf/app 0.8.0 → 0.9.0-next-588f070

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,71 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { credentialKeys } from "./credential-keys.mjs";
5
+ import { clearPaused, notificationOrigin, warnPaused } from "./notifications-pause.mjs";
6
+ import { notifyProjectWith } from "./notify.mjs";
7
+ import { designChange } from "@storyshelf/core/models";
8
+ import { emitWebhookEvent } from "@storyshelf/core/adapter/webhook-events";
9
+ import { createUrlBuilder } from "@storyshelf/core/urls";
10
+ // src/design-events.ts
11
+ /**
12
+ * `design:changed`: a design moved after its baseline was approved, or no longer exists. It rides
13
+ * the existing paths (signed webhooks and the notification fan-out) and is a normal subscribable
14
+ * topic: it is not default-on (see `notify.ts`), so only people who opted in hear about it.
15
+ *
16
+ * Sent once per (project, design node, design version) from the first health check that sees the
17
+ * change. The durable claim is `DesignNodeModel.claimChange`; with no confirmed public origin
18
+ * nothing is sent and nothing is claimed, so the change is still announced once an origin exists.
19
+ * There is no boot replay: an unannounced change waits for the next health check.
20
+ */
21
+ /** Event name of a design change. */
22
+ const DESIGN_CHANGED_EVENT = "design:changed";
23
+ /**
24
+ * Announce every observed design change not announced before. Never throws: a failure is logged
25
+ * and the next health check tries again.
26
+ */
27
+ async function announceDesignChangesWith(deps, nodes, project, observations) {
28
+ const changes = observations.flatMap((observation) => {
29
+ const change = observation.componentPath ? designChange(observation.node, observation.baselineVersion) : null;
30
+ return observation.node && observation.componentPath && change ? [{
31
+ ...observation,
32
+ node: observation.node,
33
+ componentPath: observation.componentPath,
34
+ change
35
+ }] : [];
36
+ });
37
+ if (changes.length === 0 || !await originConfirmed(deps)) return;
38
+ await Promise.all(changes.map(async (item) => {
39
+ try {
40
+ if (await nodes.claimChange(item.node, item.change)) await announce(deps, project, item);
41
+ } catch (error) {
42
+ deps.logger.warn({
43
+ err: error,
44
+ projectId: project.id
45
+ }, "design change not announced");
46
+ }
47
+ }));
48
+ }
49
+ async function originConfirmed(deps) {
50
+ if (await notificationOrigin(deps) === void 0) {
51
+ warnPaused(deps.logger, DESIGN_CHANGED_EVENT);
52
+ return false;
53
+ }
54
+ clearPaused(deps.logger);
55
+ return true;
56
+ }
57
+ async function announce(deps, project, item) {
58
+ const data = {
59
+ projectId: project.id,
60
+ componentPath: item.componentPath,
61
+ figmaUrl: item.url,
62
+ change: item.change.change,
63
+ designVersion: item.change.designVersion
64
+ };
65
+ await emitWebhookEvent(deps.db, { webhooks: deps.db.tables.webhooks }, project.id, DESIGN_CHANGED_EVENT, data, credentialKeys(deps.config), deps.logger);
66
+ const component = createUrlBuilder("").component(project.slug, item.componentPath);
67
+ await notifyProjectWith(deps, project, DESIGN_CHANGED_EVENT, data, component.slice(`/projects/${project.slug}`.length));
68
+ }
69
+ export { DESIGN_CHANGED_EVENT, announceDesignChangesWith };
70
+
71
+ //# sourceMappingURL=design-events.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"design-events.mjs","names":[],"sources":["../src/design-events.ts"],"sourcesContent":["/**\n * `design:changed`: a design moved after its baseline was approved, or no longer exists. It rides\n * the existing paths (signed webhooks and the notification fan-out) and is a normal subscribable\n * topic: it is not default-on (see `notify.ts`), so only people who opted in hear about it.\n *\n * Sent once per (project, design node, design version) from the first health check that sees the\n * change. The durable claim is `DesignNodeModel.claimChange`; with no confirmed public origin\n * nothing is sent and nothing is claimed, so the change is still announced once an origin exists.\n * There is no boot replay: an unannounced change waits for the next health check.\n */\nimport { emitWebhookEvent } from \"@storyshelf/core/adapter/webhook-events\";\nimport { designChange, type DesignChange, type DesignNodeModel } from \"@storyshelf/core/models\";\nimport type { DesignNode, Project } from \"@storyshelf/core/schema\";\nimport { createUrlBuilder } from \"@storyshelf/core/urls\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { notifyProjectWith, type NotifyDeps } from \"./notify.ts\";\n\n/** Event name of a design change. */\nexport const DESIGN_CHANGED_EVENT = \"design:changed\";\n\n/** What `design:changed` carries. */\nexport interface DesignChangedData extends Record<string, unknown> {\n projectId: string;\n /** Story file (the component) whose design changed. */\n componentPath: string;\n figmaUrl: string;\n change: DesignChange[\"change\"];\n designVersion: string | null;\n}\n\n/** One design a health check looked at. */\nexport interface DesignObservation {\n url: string;\n node: DesignNode | undefined;\n /** The component (story file) the design belongs to. */\n componentPath: string | null;\n /** Version of the baseline the component was approved at. */\n baselineVersion: string | null;\n}\n\n/**\n * Announce every observed design change not announced before. Never throws: a failure is logged\n * and the next health check tries again.\n */\nexport async function announceDesignChangesWith(\n deps: NotifyDeps,\n nodes: Pick<DesignNodeModel, \"claimChange\">,\n project: Pick<Project, \"id\" | \"slug\">,\n observations: readonly DesignObservation[],\n): Promise<void> {\n const changes = observations.flatMap((observation) => {\n const change = observation.componentPath\n ? designChange(observation.node, observation.baselineVersion)\n : null;\n return observation.node && observation.componentPath && change\n ? [\n {\n ...observation,\n node: observation.node,\n componentPath: observation.componentPath,\n change,\n },\n ]\n : [];\n });\n if (changes.length === 0 || !(await originConfirmed(deps))) return;\n await Promise.all(\n changes.map(async (item) => {\n try {\n if (await nodes.claimChange(item.node, item.change)) await announce(deps, project, item);\n } catch (error) {\n deps.logger.warn({ err: error, projectId: project.id }, \"design change not announced\");\n }\n }),\n );\n}\n\nasync function originConfirmed(deps: NotifyDeps): Promise<boolean> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, DESIGN_CHANGED_EVENT);\n return false;\n }\n clearPaused(deps.logger);\n return true;\n}\n\nasync function announce(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n item: { url: string; componentPath: string; change: DesignChange },\n): Promise<void> {\n const data: DesignChangedData = {\n projectId: project.id,\n componentPath: item.componentPath,\n figmaUrl: item.url,\n change: item.change.change,\n designVersion: item.change.designVersion,\n };\n await emitWebhookEvent(\n deps.db,\n { webhooks: deps.db.tables.webhooks },\n project.id,\n DESIGN_CHANGED_EVENT,\n data,\n credentialKeys(deps.config),\n deps.logger,\n );\n const component = createUrlBuilder(\"\").component(project.slug, item.componentPath);\n await notifyProjectWith(\n deps,\n project,\n DESIGN_CHANGED_EVENT,\n data,\n component.slice(`/projects/${project.slug}`.length),\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAmBA,MAAa,uBAAuB;;;;;AA0BpC,eAAsB,0BACpB,MACA,OACA,SACA,cACe;CACf,MAAM,UAAU,aAAa,SAAS,gBAAgB;EACpD,MAAM,SAAS,YAAY,gBACvB,aAAa,YAAY,MAAM,YAAY,eAAe,IAC1D;EACJ,OAAO,YAAY,QAAQ,YAAY,iBAAiB,SACpD,CACE;GACE,GAAG;GACH,MAAM,YAAY;GAClB,eAAe,YAAY;GAC3B;EACF,CACF,IACA,CAAC;CACP,CAAC;CACD,IAAI,QAAQ,WAAW,KAAK,CAAE,MAAM,gBAAgB,IAAI,GAAI;CAC5D,MAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,SAAS;EAC1B,IAAI;GACF,IAAI,MAAM,MAAM,YAAY,KAAK,MAAM,KAAK,MAAM,GAAG,MAAM,SAAS,MAAM,SAAS,IAAI;EACzF,SAAS,OAAO;GACd,KAAK,OAAO,KAAK;IAAE,KAAK;IAAO,WAAW,QAAQ;GAAG,GAAG,6BAA6B;EACvF;CACF,CAAC,CACH;AACF;AAEA,eAAe,gBAAgB,MAAoC;CAEjE,IAAI,MADiB,mBAAmB,IAAI,MAC7B,KAAA,GAAW;EACxB,WAAW,KAAK,QAAQ,oBAAoB;EAC5C,OAAO;CACT;CACA,YAAY,KAAK,MAAM;CACvB,OAAO;AACT;AAEA,eAAe,SACb,MACA,SACA,MACe;CACf,MAAM,OAA0B;EAC9B,WAAW,QAAQ;EACnB,eAAe,KAAK;EACpB,UAAU,KAAK;EACf,QAAQ,KAAK,OAAO;EACpB,eAAe,KAAK,OAAO;CAC7B;CACA,MAAM,iBACJ,KAAK,IACL,EAAE,UAAU,KAAK,GAAG,OAAO,SAAS,GACpC,QAAQ,IACR,sBACA,MACA,eAAe,KAAK,MAAM,GAC1B,KAAK,MACP;CACA,MAAM,YAAY,iBAAiB,EAAE,CAAC,CAAC,UAAU,QAAQ,MAAM,KAAK,aAAa;CACjF,MAAM,kBACJ,MACA,SACA,sBACA,MACA,UAAU,MAAM,aAAa,QAAQ,OAAO,MAAM,CACpD;AACF"}
package/dist/index.mjs CHANGED
@@ -380,7 +380,7 @@ function registerDocs(app) {
380
380
  * from source (dev server, tests).
381
381
  */
382
382
  function packageVersion() {
383
- return "0.8.0";
383
+ return "0.9.0-next-588f070";
384
384
  }
385
385
  export { createAuthSystemHook, createShelfApp, createShelfLogger };
386
386
 
package/dist/notify.mjs CHANGED
@@ -76,7 +76,10 @@ async function targetsForSub(db, sub, wired) {
76
76
  enabled: true
77
77
  }));
78
78
  }
79
- /** Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4). */
79
+ /**
80
+ * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).
81
+ * Every other topic (`design:changed` included) reaches only people who subscribed to it.
82
+ */
80
83
  const DEFAULT_ON_EVENTS = /* @__PURE__ */ new Set(["review:requested"]);
81
84
  /** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */
82
85
  const REVIEWER_ROLES = /* @__PURE__ */ new Set(["approver", "admin"]);
@@ -1 +1 @@
1
- {"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** Subscriptions matching an event (enabled + allowlist). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n): NotificationSubscriptionRow[] {\n return rows.filter((row) => {\n if (!row.enabled) {\n return false;\n }\n const events = NotificationSubscriptionModel.eventsOf(row);\n return events.length === 0 || events.includes(event);\n });\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/** Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4). */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([\"review:requested\"]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts\n * out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!wired.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member): Promise<NotificationChannel[]> => {\n const user = await new UserModel(db).get(member.userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${member.userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n }),\n );\n return targets.flat();\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, wired)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = await loadChannels(deps, project.id);\n const targets = await subscriberTargets(deps.db, project.id, event, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** Subscriptions matching an event (enabled + allowlist). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n): NotificationSubscriptionRow[] {\n return rows.filter((row) => {\n if (!row.enabled) {\n return false;\n }\n const events = NotificationSubscriptionModel.eventsOf(row);\n return events.length === 0 || events.includes(event);\n });\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/**\n * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).\n * Every other topic (`design:changed` included) reaches only people who subscribed to it.\n */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([\"review:requested\"]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts\n * out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!wired.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member): Promise<NotificationChannel[]> => {\n const user = await new UserModel(db).get(member.userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${member.userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n }),\n );\n return targets.flat();\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, wired)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = await loadChannels(deps, project.id);\n const targets = await subscriberTargets(deps.db, project.id, event, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
@@ -3,7 +3,7 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { Alert } from "../ui/feedback.mjs";
5
5
  import { StaleBaselineNotice } from "./build-diff-stale.mjs";
6
- import { environmentChangeOf, labelOf, summariseEnvironmentChanges } from "./environment-change.mjs";
6
+ import { environmentChangeOf, labelOf, summariseEnvironmentChanges, summarisePartlyUnknown } from "./environment-change.mjs";
7
7
  import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
8
8
  // src/pages/build-diff-environment.tsx
9
9
  /**
@@ -11,6 +11,30 @@ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
11
11
  * The default branch migrates them without a diff; a feature branch still diffs them (ADR 0005).
12
12
  */
13
13
  function EnvironmentChangeNotice(props) {
14
+ const unknown = summarisePartlyUnknown(props.snapshots);
15
+ const changed = /* @__PURE__ */ jsx(EnvironmentMigrationNotice, { ...props });
16
+ return unknown ? /* @__PURE__ */ jsxs(Fragment, { children: [changed, /* @__PURE__ */ jsx(PartlyUnknownNotice, {
17
+ count: unknown.count,
18
+ fields: unknown.fields
19
+ })] }) : changed;
20
+ }
21
+ /** The measurement version changed: some components were compared as unknown, not as different. */
22
+ function PartlyUnknownNotice(props) {
23
+ const { count, fields } = props;
24
+ return /* @__PURE__ */ jsxs(Alert, {
25
+ tone: "info",
26
+ title: "Environment partly unknown (measurement updated)",
27
+ children: [
28
+ count,
29
+ " ",
30
+ count === 1 ? "snapshot has" : "snapshots have",
31
+ " a baseline whose environment was measured an older way, so the ",
32
+ fields.join(", "),
33
+ " could not be compared. They are not treated as a change, and nothing was migrated because of them."
34
+ ]
35
+ });
36
+ }
37
+ function EnvironmentMigrationNotice(props) {
14
38
  const summary = summariseEnvironmentChanges(props.snapshots);
15
39
  if (!summary) return null;
16
40
  const { count, fields, browserVersion } = summary;
@@ -43,8 +67,14 @@ function EnvironmentChangeNotice(props) {
43
67
  function EnvironmentChangeSnapshotNotice(props) {
44
68
  const change = environmentChangeOf(props.snapshot);
45
69
  if (!change) return null;
70
+ const unknown = (change.unknown ?? []).map((field) => labelOf(field));
71
+ const note = unknown.length > 0 ? /* @__PURE__ */ jsx(PartlyUnknownNotice, {
72
+ count: 1,
73
+ fields: unknown
74
+ }) : null;
75
+ if (change.changed.length === 0) return note;
46
76
  const what = change.changed.map((field) => labelOf(field)).join(", ");
47
- return props.migrated ? /* @__PURE__ */ jsxs(Alert, {
77
+ return /* @__PURE__ */ jsxs(Fragment, { children: [props.migrated ? /* @__PURE__ */ jsxs(Alert, {
48
78
  tone: "info",
49
79
  title: "No diff: capture environment changed",
50
80
  children: [
@@ -60,7 +90,7 @@ function EnvironmentChangeSnapshotNotice(props) {
60
90
  what,
61
91
  "), so a difference may come from the environment rather than the code."
62
92
  ]
63
- });
93
+ }), note] });
64
94
  }
65
95
  /** Build-level notices above the review grid: stale baselines and environment migrations. */
66
96
  function ReviewNotices(props) {
@@ -1 +1 @@
1
- {"version":3,"file":"build-diff-environment.mjs","names":[],"sources":["../../src/pages/build-diff-environment.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Alert } from \"../ui/components.tsx\";\nimport { StaleBaselineNotice } from \"./build-diff-stale.tsx\";\nimport { environmentChangeOf, labelOf, summariseEnvironmentChanges } from \"./environment-change.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/**\n * Build-level note: some snapshots were captured in a different environment than their baseline.\n * The default branch migrates them without a diff; a feature branch still diffs them (ADR 0005).\n */\nexport function EnvironmentChangeNotice(props: {\n snapshots: readonly Snapshot[];\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const summary = summariseEnvironmentChanges(props.snapshots);\n if (!summary) {\n return null;\n }\n const { count, fields, browserVersion } = summary;\n const what = `the ${fields.join(\", \")} changed${\n browserVersion ? ` (${browserVersion.from} → ${browserVersion.to})` : \"\"\n }`;\n return props.migrated ? (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} accepted without a pixel diff\n because {what}. The same code renders differently here, so a diff would only show the\n environment. The baselines were updated to match.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} compared against a baseline from a\n different capture environment: {what}. A difference may come from the environment rather than\n the code; once the default branch captures in the new environment, a rebuild compares like\n with like.\n </Alert>\n );\n}\n\n/** Per-snapshot note shown above the viewer for a snapshot whose capture environment changed. */\nexport function EnvironmentChangeSnapshotNotice(props: {\n snapshot: Pick<Snapshot, \"envChange\">;\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const change = environmentChangeOf(props.snapshot);\n if (!change) {\n return null;\n }\n const what = change.changed.map((field) => labelOf(field)).join(\", \");\n return props.migrated ? (\n <Alert tone=\"info\" title=\"No diff: capture environment changed\">\n This snapshot was not compared pixel by pixel. What changed: {what}.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n The baseline was captured in a different environment ({what}), so a difference may come from\n the environment rather than the code.\n </Alert>\n );\n}\n\n/** Build-level notices above the review grid: stale baselines and environment migrations. */\nexport function ReviewNotices(props: {\n project: Project;\n build: Build;\n snapshots: readonly Snapshot[];\n drifted: Record<string, unknown>;\n canReview: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, drifted, canReview } = props;\n const staleCount = Object.keys(drifted).length;\n return (\n <>\n {staleCount > 0 ? (\n <StaleBaselineNotice\n project={project}\n build={build}\n staleCount={staleCount}\n canReview={canReview}\n />\n ) : null}\n <EnvironmentChangeNotice snapshots={snapshots} migrated={build.isDefault} />\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAYA,SAAgB,wBAAwB,OAGkB;CACxD,MAAM,UAAU,4BAA4B,MAAM,SAAS;CAC3D,IAAI,CAAC,SACH,OAAO;CAET,MAAM,EAAE,OAAO,QAAQ,mBAAmB;CAC1C,MAAM,OAAO,OAAO,OAAO,KAAK,IAAI,EAAE,UACpC,iBAAiB,KAAK,eAAe,KAAK,KAAK,eAAe,GAAG,KAAK;CAExE,OAAO,MAAM,WACX,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GAChD;GAAK;EAET;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GACzB;GAAK;EAGhC;;AAEX;;AAGA,SAAgB,gCAAgC,OAGU;CACxD,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CACjD,IAAI,CAAC,QACH,OAAO;CAET,MAAM,OAAO,OAAO,QAAQ,KAAK,UAAU,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;CACpE,OAAO,MAAM,WACX,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAgE;GACA;GAAK;EAC9D;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAuD;GACE;GAAK;EAEvD;;AAEX;;AAGA,SAAgB,cAAc,OAMqB;CACjD,MAAM,EAAE,SAAS,OAAO,WAAW,SAAS,cAAc;CAC1D,MAAM,aAAa,OAAO,KAAK,OAAO,CAAC,CAAC;CACxC,OACE,qBAAA,UAAA,EAAA,UAAA,CACG,aAAa,IACZ,oBAAC,qBAAD;EACW;EACF;EACK;EACD;CACZ,CAAA,IACC,MACJ,oBAAC,yBAAD;EAAoC;EAAW,UAAU,MAAM;CAAY,CAAA,CAC3E,EAAA,CAAA;AAEN"}
1
+ {"version":3,"file":"build-diff-environment.mjs","names":[],"sources":["../../src/pages/build-diff-environment.tsx"],"sourcesContent":["import type { Build, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { HtmlEscapedString } from \"hono/utils/html\";\nimport { Alert } from \"../ui/components.tsx\";\nimport { StaleBaselineNotice } from \"./build-diff-stale.tsx\";\nimport {\n environmentChangeOf,\n labelOf,\n summariseEnvironmentChanges,\n summarisePartlyUnknown,\n} from \"./environment-change.ts\";\n\n/* eslint-disable promise-function-async -- Hono JSX components return HtmlEscapedString | Promise<HtmlEscapedString> */\n\n/**\n * Build-level note: some snapshots were captured in a different environment than their baseline.\n * The default branch migrates them without a diff; a feature branch still diffs them (ADR 0005).\n */\nexport function EnvironmentChangeNotice(props: {\n snapshots: readonly Snapshot[];\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const unknown = summarisePartlyUnknown(props.snapshots);\n const changed = <EnvironmentMigrationNotice {...props} />;\n return unknown ? (\n <>\n {changed}\n <PartlyUnknownNotice count={unknown.count} fields={unknown.fields} />\n </>\n ) : (\n changed\n );\n}\n\n/** The measurement version changed: some components were compared as unknown, not as different. */\nfunction PartlyUnknownNotice(props: {\n count: number;\n fields: string[];\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { count, fields } = props;\n return (\n <Alert tone=\"info\" title=\"Environment partly unknown (measurement updated)\">\n {count} {count === 1 ? \"snapshot has\" : \"snapshots have\"} a baseline whose environment was\n measured an older way, so the {fields.join(\", \")} could not be compared. They are not treated\n as a change, and nothing was migrated because of them.\n </Alert>\n );\n}\n\nfunction EnvironmentMigrationNotice(props: {\n snapshots: readonly Snapshot[];\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const summary = summariseEnvironmentChanges(props.snapshots);\n if (!summary) {\n return null;\n }\n const { count, fields, browserVersion } = summary;\n const what = `the ${fields.join(\", \")} changed${\n browserVersion ? ` (${browserVersion.from} → ${browserVersion.to})` : \"\"\n }`;\n return props.migrated ? (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} accepted without a pixel diff\n because {what}. The same code renders differently here, so a diff would only show the\n environment. The baselines were updated to match.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n {count} {count === 1 ? \"snapshot was\" : \"snapshots were\"} compared against a baseline from a\n different capture environment: {what}. A difference may come from the environment rather than\n the code; once the default branch captures in the new environment, a rebuild compares like\n with like.\n </Alert>\n );\n}\n\n/** Per-snapshot note shown above the viewer for a snapshot whose capture environment changed. */\nexport function EnvironmentChangeSnapshotNotice(props: {\n snapshot: Pick<Snapshot, \"envChange\">;\n migrated: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> | null {\n const change = environmentChangeOf(props.snapshot);\n if (!change) {\n return null;\n }\n const unknown = (change.unknown ?? []).map((field) => labelOf(field));\n const note = unknown.length > 0 ? <PartlyUnknownNotice count={1} fields={unknown} /> : null;\n if (change.changed.length === 0) {\n return note;\n }\n const what = change.changed.map((field) => labelOf(field)).join(\", \");\n return (\n <>\n {props.migrated ? (\n <Alert tone=\"info\" title=\"No diff: capture environment changed\">\n This snapshot was not compared pixel by pixel. What changed: {what}.\n </Alert>\n ) : (\n <Alert tone=\"info\" title=\"Capture environment changed\">\n The baseline was captured in a different environment ({what}), so a difference may come\n from the environment rather than the code.\n </Alert>\n )}\n {note}\n </>\n );\n}\n\n/** Build-level notices above the review grid: stale baselines and environment migrations. */\nexport function ReviewNotices(props: {\n project: Project;\n build: Build;\n snapshots: readonly Snapshot[];\n drifted: Record<string, unknown>;\n canReview: boolean;\n}): HtmlEscapedString | Promise<HtmlEscapedString> {\n const { project, build, snapshots, drifted, canReview } = props;\n const staleCount = Object.keys(drifted).length;\n return (\n <>\n {staleCount > 0 ? (\n <StaleBaselineNotice\n project={project}\n build={build}\n staleCount={staleCount}\n canReview={canReview}\n />\n ) : null}\n <EnvironmentChangeNotice snapshots={snapshots} migrated={build.isDefault} />\n </>\n );\n}\n"],"mappings":";;;;;;;;;;;;AAiBA,SAAgB,wBAAwB,OAGkB;CACxD,MAAM,UAAU,uBAAuB,MAAM,SAAS;CACtD,MAAM,UAAU,oBAAC,4BAAD,EAA4B,GAAI,MAAQ,CAAA;CACxD,OAAO,UACL,qBAAA,UAAA,EAAA,UAAA,CACG,SACD,oBAAC,qBAAD;EAAqB,OAAO,QAAQ;EAAO,QAAQ,QAAQ;CAAS,CAAA,CACpE,EAAA,CAAA,IAEF;AAEJ;;AAGA,SAAS,oBAAoB,OAGsB;CACjD,MAAM,EAAE,OAAO,WAAW;CAC1B,OACE,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GAC1B,OAAO,KAAK,IAAI;GAAE;EAE5C;;AAEX;AAEA,SAAS,2BAA2B,OAGsB;CACxD,MAAM,UAAU,4BAA4B,MAAM,SAAS;CAC3D,IAAI,CAAC,SACH,OAAO;CAET,MAAM,EAAE,OAAO,QAAQ,mBAAmB;CAC1C,MAAM,OAAO,OAAO,OAAO,KAAK,IAAI,EAAE,UACpC,iBAAiB,KAAK,eAAe,KAAK,KAAK,eAAe,GAAG,KAAK;CAExE,OAAO,MAAM,WACX,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GAChD;GAAK;EAET;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GACG;GAAM;GAAE,UAAU,IAAI,iBAAiB;GAAiB;GACzB;GAAK;EAGhC;;AAEX;;AAGA,SAAgB,gCAAgC,OAGU;CACxD,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CACjD,IAAI,CAAC,QACH,OAAO;CAET,MAAM,WAAW,OAAO,WAAW,CAAC,EAAA,CAAG,KAAK,UAAU,QAAQ,KAAK,CAAC;CACpE,MAAM,OAAO,QAAQ,SAAS,IAAI,oBAAC,qBAAD;EAAqB,OAAO;EAAG,QAAQ;CAAU,CAAA,IAAI;CACvF,IAAI,OAAO,QAAQ,WAAW,GAC5B,OAAO;CAET,MAAM,OAAO,OAAO,QAAQ,KAAK,UAAU,QAAQ,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI;CACpE,OACE,qBAAA,UAAA,EAAA,UAAA,CACG,MAAM,WACL,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAgE;GACA;GAAK;EAC9D;CAEP,CAAA,IAAA,qBAAC,OAAD;EAAO,MAAK;EAAO,OAAM;EAAzB,UAAA;GAAuD;GACE;GAAK;EAEvD;CAER,CAAA,GAAA,IACD,EAAA,CAAA;AAEN;;AAGA,SAAgB,cAAc,OAMqB;CACjD,MAAM,EAAE,SAAS,OAAO,WAAW,SAAS,cAAc;CAC1D,MAAM,aAAa,OAAO,KAAK,OAAO,CAAC,CAAC;CACxC,OACE,qBAAA,UAAA,EAAA,UAAA,CACG,aAAa,IACZ,oBAAC,qBAAD;EACW;EACF;EACK;EACD;CACZ,CAAA,IACC,MACJ,oBAAC,yBAAD;EAAoC;EAAW,UAAU,MAAM;CAAY,CAAA,CAC3E,EAAA,CAAA;AAEN"}
@@ -0,0 +1,333 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { css } from "../ui/css.mjs";
6
+ import { Button } from "../ui/buttons.mjs";
7
+ import { Alert, Badge, EmptyState, Meta, Stat } from "../ui/feedback.mjs";
8
+ import { Card, PageHeader, SectionTitle } from "../ui/layout.mjs";
9
+ import { HStack, VStack } from "../ui/stacks.mjs";
10
+ import { Table } from "../ui/table.mjs";
11
+ import { RelativeTime } from "../ui/time.mjs";
12
+ import { DocumentLayout } from "../ui/document.mjs";
13
+ import { Fragment, jsx, jsxs } from "hono/jsx/jsx-runtime";
14
+ import { createUrlBuilder } from "@storyshelf/core/urls";
15
+ // src/pages/design-coverage.tsx
16
+ const wideOnly = css`
17
+ /* coverage-wide-only */
18
+ @media (max-width: 640px) {
19
+ & {
20
+ display: none;
21
+ }
22
+ }
23
+ `;
24
+ const narrowOnly = css`
25
+ /* coverage-narrow-only */
26
+ display: none;
27
+ @media (max-width: 640px) {
28
+ & {
29
+ display: block;
30
+ }
31
+ }
32
+ `;
33
+ const statLink = css`
34
+ /* coverage-stat-link */
35
+ display: block;
36
+ color: inherit;
37
+ text-decoration: none;
38
+ &:hover,
39
+ &:focus-visible {
40
+ text-decoration: none;
41
+ }
42
+ `;
43
+ const widePath = css`
44
+ /* coverage-wide-path */
45
+ ${wideOnly}
46
+ overflow-wrap: anywhere;
47
+ `;
48
+ /** The design coverage page: what lacks a design, what drifted, and how much is in sync. */
49
+ function renderDesignCoveragePage(data) {
50
+ const { project, build } = data;
51
+ const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
52
+ return /* @__PURE__ */ jsxs(DocumentLayout, {
53
+ title: "Design coverage",
54
+ nav: {
55
+ active: "library",
56
+ projectSlug: project.slug,
57
+ projectName: project.name,
58
+ trail: [{
59
+ label: "Library",
60
+ href: urls.library(project.slug)
61
+ }, { label: "Design coverage" }]
62
+ },
63
+ children: [/* @__PURE__ */ jsx(PageHeader, {
64
+ title: "Design coverage",
65
+ description: "Which components have a design file, and which have drifted from it.",
66
+ meta: build ? `Latest accepted build: ${build.gitBranch} · ${build.gitSha.slice(0, 7)}` : void 0
67
+ }), build ? /* @__PURE__ */ jsx(CoverageBody, { data }) : /* @__PURE__ */ jsx(EmptyState, {
68
+ title: "No build to check yet",
69
+ description: "Design coverage is read from the latest accepted build. Upload a Storybook and accept a build first.",
70
+ action: /* @__PURE__ */ jsx(Button, {
71
+ variant: "secondary",
72
+ size: "sm",
73
+ href: urls.buildsList(project.slug),
74
+ children: "Go to builds"
75
+ })
76
+ })]
77
+ });
78
+ }
79
+ /** Drift cannot be told for the linked designs: some provider has no token and nothing is known to have drifted. */
80
+ function driftUnavailable(data) {
81
+ return data.withoutToken.length > 0 && data.drifted.length === 0;
82
+ }
83
+ function CoverageBody(props) {
84
+ const { data } = props;
85
+ const unavailable = driftUnavailable(data);
86
+ return /* @__PURE__ */ jsxs(VStack, { children: [
87
+ data.storyLinksRead ? null : /* @__PURE__ */ jsx(Alert, {
88
+ tone: "info",
89
+ title: "Only mapped design links are counted",
90
+ children: "Links declared in stories are read from captured builds, so they are not counted without testing. Components linked under Settings → Design are."
91
+ }),
92
+ /* @__PURE__ */ jsxs("div", {
93
+ class: "grid grid--3",
94
+ children: [
95
+ /* @__PURE__ */ jsx("a", {
96
+ href: "#no-link",
97
+ class: statLink,
98
+ children: /* @__PURE__ */ jsx(Stat, {
99
+ label: "No design link",
100
+ value: data.unlinked.length,
101
+ tone: data.unlinked.length > 0 ? "warning" : "success"
102
+ })
103
+ }),
104
+ /* @__PURE__ */ jsx("a", {
105
+ href: "#drift",
106
+ class: statLink,
107
+ children: /* @__PURE__ */ jsx(Stat, {
108
+ label: unavailable ? "Needs attention · Unavailable" : "Needs attention",
109
+ value: unavailable ? "–" : data.drifted.length,
110
+ tone: unavailable ? "neutral" : data.drifted.length > 0 ? "warning" : "success"
111
+ })
112
+ }),
113
+ /* @__PURE__ */ jsx("a", {
114
+ href: "#current",
115
+ class: statLink,
116
+ children: /* @__PURE__ */ jsx(Stat, {
117
+ label: "Up to date",
118
+ value: data.current,
119
+ tone: data.current > 0 ? "success" : "neutral"
120
+ })
121
+ })
122
+ ]
123
+ }),
124
+ /* @__PURE__ */ jsx(UnlinkedSection, { data }),
125
+ /* @__PURE__ */ jsx(DriftSection, { data }),
126
+ /* @__PURE__ */ jsx(CurrentSection, { data })
127
+ ] });
128
+ }
129
+ /** Where a component's design links are managed: its Design tab with testing, else the project's design settings. */
130
+ function linkTarget(data, component) {
131
+ const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
132
+ return data.storyLinksRead ? urls.component(data.project.slug, component.importPath, "design") : `${urls.settings(data.project.slug)}/design`;
133
+ }
134
+ function ComponentLink(props) {
135
+ const { data, component } = props;
136
+ const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
137
+ const href = data.storyLinksRead ? urls.component(data.project.slug, component.importPath, "design") : urls.component(data.project.slug, component.importPath);
138
+ return /* @__PURE__ */ jsx("a", {
139
+ href,
140
+ children: component.title
141
+ });
142
+ }
143
+ function UnlinkedRow(props) {
144
+ const { data, component } = props;
145
+ return /* @__PURE__ */ jsxs("tr", { children: [
146
+ /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx(ComponentLink, {
147
+ data,
148
+ component
149
+ }), /* @__PURE__ */ jsx("div", {
150
+ class: narrowOnly,
151
+ children: /* @__PURE__ */ jsx(Meta, {
152
+ as: "span",
153
+ mono: true,
154
+ children: component.importPath
155
+ })
156
+ })] }),
157
+ /* @__PURE__ */ jsx("td", {
158
+ class: widePath,
159
+ children: /* @__PURE__ */ jsx("span", {
160
+ class: "mono",
161
+ children: component.importPath
162
+ })
163
+ }),
164
+ /* @__PURE__ */ jsx("td", {
165
+ class: wideOnly,
166
+ children: component.stories
167
+ }),
168
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsx(Button, {
169
+ variant: "ghost",
170
+ size: "sm",
171
+ href: linkTarget(data, component),
172
+ "aria-label": `Link a design to ${component.title}`,
173
+ children: "Link a design"
174
+ }) })
175
+ ] });
176
+ }
177
+ function UnlinkedSection(props) {
178
+ const { data } = props;
179
+ const { unlinked } = data;
180
+ return /* @__PURE__ */ jsx(Card, {
181
+ id: "no-link",
182
+ children: /* @__PURE__ */ jsxs(VStack, { children: [/* @__PURE__ */ jsxs(SectionTitle, { children: [
183
+ "No design link (",
184
+ unlinked.length,
185
+ ")"
186
+ ] }), unlinked.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { title: "Every component has a design link" }) : /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
187
+ /* @__PURE__ */ jsx("th", { children: "Component" }),
188
+ /* @__PURE__ */ jsx("th", {
189
+ class: wideOnly,
190
+ children: "Path"
191
+ }),
192
+ /* @__PURE__ */ jsx("th", {
193
+ class: wideOnly,
194
+ children: "Stories"
195
+ }),
196
+ /* @__PURE__ */ jsx("th", { children: /* @__PURE__ */ jsx("span", {
197
+ class: "visually-hidden",
198
+ children: "Action"
199
+ }) })
200
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: unlinked.map((component) => /* @__PURE__ */ jsx(UnlinkedRow, {
201
+ data,
202
+ component
203
+ }, component.importPath)) })] }) })] })
204
+ });
205
+ }
206
+ const REASONS = {
207
+ stale: {
208
+ badge: "Design changed",
209
+ title: "Design changed after approval"
210
+ },
211
+ deleted: {
212
+ badge: "Deleted",
213
+ title: "Design file deleted"
214
+ }
215
+ };
216
+ function DriftBadge(props) {
217
+ const reason = props.drift === "deleted" ? REASONS.deleted : REASONS.stale;
218
+ return /* @__PURE__ */ jsx("span", {
219
+ title: reason.title,
220
+ children: /* @__PURE__ */ jsx(Badge, {
221
+ tone: props.drift === "deleted" ? "danger" : "warning",
222
+ icon: true,
223
+ children: reason.badge
224
+ })
225
+ });
226
+ }
227
+ function ChangedAt(props) {
228
+ return props.value ? /* @__PURE__ */ jsx(RelativeTime, { value: props.value }) : /* @__PURE__ */ jsx(Meta, {
229
+ as: "span",
230
+ children: "unknown"
231
+ });
232
+ }
233
+ /** One row per drifted design link of a component. */
234
+ function DriftRows(props) {
235
+ const { data, component } = props;
236
+ return /* @__PURE__ */ jsx(Fragment, { children: component.links.filter((link) => link.drift === "stale" || link.drift === "deleted").map((link) => /* @__PURE__ */ jsxs("tr", { children: [
237
+ /* @__PURE__ */ jsx("td", { children: /* @__PURE__ */ jsxs(VStack, { children: [
238
+ /* @__PURE__ */ jsx(ComponentLink, {
239
+ data,
240
+ component
241
+ }),
242
+ /* @__PURE__ */ jsx(HStack, {
243
+ wrap: false,
244
+ children: /* @__PURE__ */ jsxs(Button, {
245
+ variant: "ghost",
246
+ size: "sm",
247
+ icon: "external-link",
248
+ href: link.url,
249
+ target: "_blank",
250
+ rel: "noopener noreferrer",
251
+ children: ["Open in ", link.providerName]
252
+ })
253
+ }),
254
+ link.name ? /* @__PURE__ */ jsx(Meta, {
255
+ as: "span",
256
+ children: link.name
257
+ }) : null
258
+ ] }) }),
259
+ /* @__PURE__ */ jsxs("td", { children: [/* @__PURE__ */ jsx(DriftBadge, { drift: link.drift }), /* @__PURE__ */ jsx("div", {
260
+ class: narrowOnly,
261
+ children: /* @__PURE__ */ jsx(Meta, {
262
+ as: "span",
263
+ children: /* @__PURE__ */ jsx(ChangedAt, { value: link.changedAt })
264
+ })
265
+ })] }),
266
+ /* @__PURE__ */ jsx("td", {
267
+ class: wideOnly,
268
+ children: /* @__PURE__ */ jsx(ChangedAt, { value: link.changedAt })
269
+ })
270
+ ] }, `${component.importPath}|${link.url}`)) });
271
+ }
272
+ /** Says which providers cannot be checked and where to add a token. */
273
+ function DriftAlert(props) {
274
+ const { project, withoutToken } = props.data;
275
+ const urls = createUrlBuilder("/", getStore().config.publishedBaseDomain);
276
+ return /* @__PURE__ */ jsxs(Alert, {
277
+ tone: "info",
278
+ title: "Drift can't be checked",
279
+ children: [
280
+ withoutToken.join(", "),
281
+ " has no access token for this project, so StoryShelf cannot see which designs changed. Add one in Settings → Design.",
282
+ /* @__PURE__ */ jsx("div", {
283
+ class: "mt-1",
284
+ children: /* @__PURE__ */ jsx(Button, {
285
+ variant: "secondary",
286
+ size: "sm",
287
+ href: `${urls.settings(project.slug)}/design`,
288
+ children: "Open design settings"
289
+ })
290
+ })
291
+ ]
292
+ });
293
+ }
294
+ function DriftTable(props) {
295
+ const { data } = props;
296
+ return /* @__PURE__ */ jsx(Table, { children: /* @__PURE__ */ jsxs("table", { children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
297
+ /* @__PURE__ */ jsx("th", { children: "Component" }),
298
+ /* @__PURE__ */ jsx("th", { children: "Reason" }),
299
+ /* @__PURE__ */ jsx("th", {
300
+ class: wideOnly,
301
+ children: "Design changed"
302
+ })
303
+ ] }) }), /* @__PURE__ */ jsx("tbody", { children: data.drifted.map((component) => /* @__PURE__ */ jsx(DriftRows, {
304
+ data,
305
+ component
306
+ }, component.importPath)) })] }) });
307
+ }
308
+ function DriftSection(props) {
309
+ const { data } = props;
310
+ const { drifted, withoutToken, lastChecked } = data;
311
+ const unavailable = driftUnavailable(data);
312
+ return /* @__PURE__ */ jsx(Card, {
313
+ id: "drift",
314
+ children: /* @__PURE__ */ jsxs(VStack, { children: [
315
+ /* @__PURE__ */ jsx(SectionTitle, { children: unavailable ? "Needs attention" : `Needs attention (${drifted.length})` }),
316
+ unavailable ? null : /* @__PURE__ */ jsx(Meta, { children: lastChecked ? /* @__PURE__ */ jsxs(Fragment, { children: ["Last checked ", /* @__PURE__ */ jsx(RelativeTime, { value: lastChecked })] }) : "Not checked yet" }),
317
+ withoutToken.length > 0 ? /* @__PURE__ */ jsx(DriftAlert, { data }) : null,
318
+ drifted.length > 0 ? /* @__PURE__ */ jsx(DriftTable, { data }) : null,
319
+ drifted.length === 0 && !unavailable ? /* @__PURE__ */ jsx(EmptyState, { title: "No drift found" }) : null
320
+ ] })
321
+ });
322
+ }
323
+ function CurrentSection(props) {
324
+ const { current, unknown } = props.data;
325
+ const unknownText = `${unknown} more ${unknown === 1 ? "has" : "have"} a link but nothing stored says whether the design changed.`;
326
+ return /* @__PURE__ */ jsx(Card, {
327
+ id: "current",
328
+ children: /* @__PURE__ */ jsxs(VStack, { children: [/* @__PURE__ */ jsx(SectionTitle, { children: "Up to date" }), /* @__PURE__ */ jsxs(Meta, { children: [current > 0 ? `${current} ${current === 1 ? "component is" : "components are"} linked and ${current === 1 ? "matches" : "match"} their approved design.` : unknown === 0 ? "No linked components yet." : "", unknown > 0 ? ` ${unknownText}` : ""] })] })
329
+ });
330
+ }
331
+ export { renderDesignCoveragePage };
332
+
333
+ //# sourceMappingURL=design-coverage.mjs.map