@momentiq/dark-factory-cli 3.0.1 → 3.1.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.
Files changed (53) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +3 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/mode.d.ts.map +1 -1
  5. package/dist/commands/mode.js +120 -22
  6. package/dist/commands/mode.js.map +1 -1
  7. package/dist/commands/skills.d.ts.map +1 -1
  8. package/dist/commands/skills.js +11 -1
  9. package/dist/commands/skills.js.map +1 -1
  10. package/dist/commands/verify.d.ts.map +1 -1
  11. package/dist/commands/verify.js +113 -8
  12. package/dist/commands/verify.js.map +1 -1
  13. package/dist/mcp/tools/skills-install.d.ts.map +1 -1
  14. package/dist/mcp/tools/skills-install.js +8 -3
  15. package/dist/mcp/tools/skills-install.js.map +1 -1
  16. package/dist/mode/critic-registration.d.ts +30 -10
  17. package/dist/mode/critic-registration.d.ts.map +1 -1
  18. package/dist/mode/critic-registration.js +31 -10
  19. package/dist/mode/critic-registration.js.map +1 -1
  20. package/dist/mode/guard.d.ts.map +1 -1
  21. package/dist/mode/guard.js +30 -2
  22. package/dist/mode/guard.js.map +1 -1
  23. package/dist/mode/init.d.ts +7 -4
  24. package/dist/mode/init.d.ts.map +1 -1
  25. package/dist/mode/init.js +146 -12
  26. package/dist/mode/init.js.map +1 -1
  27. package/dist/mode/preview-capture.d.ts +3 -0
  28. package/dist/mode/preview-capture.d.ts.map +1 -0
  29. package/dist/mode/preview-capture.js +129 -0
  30. package/dist/mode/preview-capture.js.map +1 -0
  31. package/dist/mode/preview.d.ts +160 -0
  32. package/dist/mode/preview.d.ts.map +1 -0
  33. package/dist/mode/preview.js +522 -0
  34. package/dist/mode/preview.js.map +1 -0
  35. package/dist/onboard/seeders/design-md.d.ts.map +1 -1
  36. package/dist/onboard/seeders/design-md.js +512 -17
  37. package/dist/onboard/seeders/design-md.js.map +1 -1
  38. package/dist/skills/install.d.ts +25 -5
  39. package/dist/skills/install.d.ts.map +1 -1
  40. package/dist/skills/install.js +34 -17
  41. package/dist/skills/install.js.map +1 -1
  42. package/dist/skills/known-skills.d.ts +2 -0
  43. package/dist/skills/known-skills.d.ts.map +1 -0
  44. package/dist/skills/known-skills.js +25 -0
  45. package/dist/skills/known-skills.js.map +1 -0
  46. package/package.json +2 -2
  47. package/skills/designer-build/SKILL.md.tmpl +14 -0
  48. package/skills/designer-handoff/SKILL.md.tmpl +43 -3
  49. package/skills/designer-ship/SKILL.md.tmpl +17 -14
  50. package/skills/verify/producer/README.md +29 -4
  51. package/skills/verify/producer/playwright-route.sh +10 -0
  52. package/skills/verify/producer/surface-declaration.ts +373 -0
  53. package/skills/verify/producer/ui-route.producer.spec.ts +44 -11
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Designer-safe UI-surface COVERAGE DECLARATION for the reusable playwright
3
+ * (UI) verification-route producer (momentiq-ai/dark-factory#359).
4
+ *
5
+ * ## The wall this removes
6
+ *
7
+ * The engineer `playwright` route fails closed when a changed product-UI path
8
+ * maps to NO `SURFACES[]` entry (`coverage.ts`). The ONLY way to clear that
9
+ * block was to EDIT the Playwright spec — hand-author a `UiSurface` (a route
10
+ * `path`, a `requiredHeading` **RegExp**, and `covers` globs) in
11
+ * `ui-route.producer.spec.ts`. That is pure engineer work: a designer touching
12
+ * an unmapped area (e.g. `web/components/chat/**`) mid-feature is walled by a
13
+ * TS/RegExp edit they cannot safely make.
14
+ *
15
+ * ## What this adds (WITHOUT weakening the gate)
16
+ *
17
+ * A plain DATA file — `.darkfactory/ui-surfaces.json` — in which a designer
18
+ * DECLARES "this changed area is shown on `<route>`". The producer reads the
19
+ * declaration, turns each entry into a real `UiSurface`, and CAPTURES that route
20
+ * (navigates, asserts its heading, screenshots) exactly like a hand-authored
21
+ * surface. So the coverage requirement is UNCHANGED — the declared route must
22
+ * really render — only the AUTHORING surface moves from "edit test/TS code" to
23
+ * "add a JSON entry". Anything that maps to no surface AND no declaration still
24
+ * FAILS CLOSED (the engineer contract is untouched).
25
+ *
26
+ * Two deliberate designer-safety choices:
27
+ * - `requiredHeading` is a plain string matched case-insensitively as a
28
+ * LITERAL (escaped), so no regex knowledge is required and a stray metachar
29
+ * cannot silently widen the assertion.
30
+ * - A malformed declaration fails LOUD (never silently ignored) — a designer
31
+ * who mis-declares sees a clear error, not a still-blocked route.
32
+ *
33
+ * ── CONSUMER REFERENCE FILE — ships in the npm tarball next to `coverage.ts`;
34
+ * copy it into your repo alongside the producer spec and own it. It is pure
35
+ * (Node builtins only, no Dark Factory / vendor dependency) and unit-tested
36
+ * without a browser (mirroring `coverage.ts`).
37
+ */
38
+ import { existsSync, readFileSync } from "node:fs";
39
+ import { dirname, isAbsolute, join, resolve } from "node:path";
40
+
41
+ import type { UiSurface } from "./coverage";
42
+
43
+ /** Repo-root-relative default location of the designer declaration. */
44
+ export const DECLARATION_REL_PATH = ".darkfactory/ui-surfaces.json";
45
+
46
+ /** Env var that overrides the declaration location (absolute or start-dir-
47
+ * relative). Exported by `playwright-route.sh` as the canonical repo-root path;
48
+ * falls back to an upward filesystem walk when unset. */
49
+ export const DECLARATION_ENV_VAR = "DF_UI_SURFACES";
50
+
51
+ const EXAMPLE_JSON =
52
+ ` {\n` +
53
+ ` "surfaces": [\n` +
54
+ ` { "path": "/chat", "covers": ["web/components/chat/**"], "requiredHeading": "Chat" }\n` +
55
+ ` ]\n` +
56
+ ` }`;
57
+
58
+ /**
59
+ * The DESIGNER-FACING shape of one declaration entry (the JSON a designer
60
+ * writes). Deliberately simpler than `UiSurface`: `requiredHeading` is a plain
61
+ * string (not a RegExp) and `slug` is optional (derived from `path` when
62
+ * omitted). This is documentation of the accepted shape; parsing accepts
63
+ * `unknown` and validates defensively.
64
+ */
65
+ export interface DeclaredSurfaceInput {
66
+ /** URL path the producer navigates + captures, e.g. "/chat". Must start "/". */
67
+ path: string;
68
+ /** Source-path globs this route is the evidence for — the changed paths it
69
+ * COVERS, e.g. ["web/components/chat/**"]. Non-empty. */
70
+ covers: string[];
71
+ /** A word/phrase from the page's main heading (role=heading, accessible
72
+ * name). Matched case-insensitively as a LITERAL — no regex. Non-empty. */
73
+ requiredHeading: string;
74
+ /** Optional evidence-subdir slug; derived from `path` when omitted. */
75
+ slug?: string;
76
+ }
77
+
78
+ /** Escape a plain string so it matches LITERALLY inside a RegExp. */
79
+ function escapeRegExp(literal: string): string {
80
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ }
82
+
83
+ function sanitizeSlug(raw: string): string {
84
+ const s = raw
85
+ .replace(/[^a-z0-9]+/gi, "-")
86
+ .replace(/-+/g, "-")
87
+ .replace(/^-|-$/g, "")
88
+ .toLowerCase();
89
+ return s.length > 0 ? s : "surface";
90
+ }
91
+
92
+ function slugFromPath(path: string): string {
93
+ const s = sanitizeSlug(path.replace(/^\/+/, ""));
94
+ return s === "surface" ? "home" : s;
95
+ }
96
+
97
+ /** Pull the entry array out of either a bare array or a `{ surfaces: [...] }`
98
+ * wrapper. Anything else is a loud parse error. `prefix` names the source (the
99
+ * resolved file, when known) so the operator sees WHICH declaration is wrong. */
100
+ function extractEntries(raw: unknown, prefix: string): unknown[] {
101
+ if (Array.isArray(raw)) return raw;
102
+ if (raw !== null && typeof raw === "object" && "surfaces" in raw) {
103
+ const surfaces = (raw as { surfaces: unknown }).surfaces;
104
+ if (Array.isArray(surfaces)) return surfaces;
105
+ throw new Error(
106
+ `${prefix}: the top-level "surfaces" key must be an array of surface ` +
107
+ `entries. Example:\n${EXAMPLE_JSON}`,
108
+ );
109
+ }
110
+ throw new Error(
111
+ `${prefix}: expected a JSON array of surface entries, or an object with a ` +
112
+ `"surfaces" array. Example:\n${EXAMPLE_JSON}`,
113
+ );
114
+ }
115
+
116
+ function toUiSurface(entry: unknown, index: number, prefix: string): UiSurface {
117
+ const at = `${prefix} entry [${index}]`;
118
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
119
+ throw new Error(
120
+ `${at} must be an object with "path", "covers", and "requiredHeading".`,
121
+ );
122
+ }
123
+ const rec = entry as Record<string, unknown>;
124
+
125
+ const rawPath = rec["path"];
126
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
127
+ throw new Error(
128
+ `${at} is missing a non-empty "path" string (the route to navigate, e.g. ` +
129
+ `"/chat").`,
130
+ );
131
+ }
132
+ const path = rawPath.trim();
133
+ if (!path.startsWith("/")) {
134
+ throw new Error(
135
+ `${at} "path" must start with "/" (a route relative to your site root), ` +
136
+ `got ${JSON.stringify(rawPath)}.`,
137
+ );
138
+ }
139
+
140
+ const rawCovers = rec["covers"];
141
+ if (!Array.isArray(rawCovers) || rawCovers.length === 0) {
142
+ throw new Error(
143
+ `${at} is missing a non-empty "covers" array (the source globs this route ` +
144
+ `is the evidence for, e.g. ["web/components/chat/**"]).`,
145
+ );
146
+ }
147
+ const covers = rawCovers.map((glob, gi) => {
148
+ if (typeof glob !== "string" || glob.trim().length === 0) {
149
+ throw new Error(`${at} "covers[${gi}]" must be a non-empty glob string.`);
150
+ }
151
+ return glob.trim();
152
+ });
153
+
154
+ const rawHeading = rec["requiredHeading"];
155
+ if (typeof rawHeading !== "string" || rawHeading.trim().length === 0) {
156
+ throw new Error(
157
+ `${at} is missing a non-empty "requiredHeading" string (a word from the ` +
158
+ `page's main heading — matched case-insensitively, NOT a regex).`,
159
+ );
160
+ }
161
+ const heading = rawHeading.trim();
162
+
163
+ const rawSlug = rec["slug"];
164
+ let slug: string;
165
+ if (rawSlug === undefined) {
166
+ slug = slugFromPath(path);
167
+ } else if (typeof rawSlug === "string" && rawSlug.trim().length > 0) {
168
+ slug = sanitizeSlug(rawSlug);
169
+ } else {
170
+ throw new Error(`${at} "slug", when present, must be a non-empty string.`);
171
+ }
172
+
173
+ return {
174
+ path,
175
+ slug,
176
+ requiredHeading: new RegExp(escapeRegExp(heading), "i"),
177
+ covers,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Parse an already-JSON-decoded declaration (a bare array or `{ surfaces: [] }`)
183
+ * into real `UiSurface[]`. PURE (no fs) — the load-bearing validation is
184
+ * unit-tested without touching disk. Throws a clear, actionable error on any
185
+ * malformed entry (loud, never silent). `source` (the resolved file path, when
186
+ * called from `loadDeclaredSurfaces`) is woven into every error so the operator
187
+ * can tell WHICH declaration is wrong — `DF_UI_SURFACES` can override the path.
188
+ */
189
+ export function parseSurfaceDeclaration(
190
+ raw: unknown,
191
+ source?: string,
192
+ ): UiSurface[] {
193
+ const prefix =
194
+ typeof source === "string" && source.trim().length > 0
195
+ ? `ui-surfaces declaration (${source})`
196
+ : "ui-surfaces declaration";
197
+ return extractEntries(raw, prefix).map((entry, i) =>
198
+ toUiSurface(entry, i, prefix),
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Merge designer-DECLARED surfaces onto the hand-authored `SURFACES[]`, FAILING
204
+ * CLOSED on a slug collision. Evidence is written per surface to
205
+ * `<out-dir>/<slug>/`, so two surfaces sharing a slug would SILENTLY OVERWRITE
206
+ * each other's screenshots. Rather than dedupe (which drops a real surface's
207
+ * evidence) or overwrite, this throws a loud, plain-language error naming the
208
+ * colliding slug — the designer renames the declaration's `slug`. Detects a
209
+ * declared slug colliding with an existing `SURFACES[]` entry OR with another
210
+ * declared surface. (A pre-existing collision WITHIN `base` is the engineer's
211
+ * own concern and is not re-checked here.)
212
+ */
213
+ export function mergeDeclaredSurfaces(
214
+ base: readonly UiSurface[],
215
+ declared: readonly UiSurface[],
216
+ ): UiSurface[] {
217
+ const origin = new Map<string, "surface" | "declaration">();
218
+ for (const s of base) origin.set(s.slug, "surface");
219
+ for (const d of declared) {
220
+ const clash = origin.get(d.slug);
221
+ if (clash !== undefined) {
222
+ const owner =
223
+ clash === "surface"
224
+ ? "an existing SURFACES[] entry"
225
+ : "another declared surface";
226
+ throw new Error(
227
+ `ui-surfaces declaration: the declared surface for path "${d.path}" uses ` +
228
+ `slug "${d.slug}", which is ALREADY used by ${owner}. Evidence is ` +
229
+ `written per surface to <out-dir>/${d.slug}/, so two surfaces sharing a ` +
230
+ `slug would SILENTLY OVERWRITE each other's screenshots — this route ` +
231
+ `FAILS CLOSED instead. Give this declaration a unique "slug" in ` +
232
+ `.darkfactory/ui-surfaces.json (e.g. "slug": "${d.slug}-2").`,
233
+ );
234
+ }
235
+ origin.set(d.slug, "declaration");
236
+ }
237
+ return [...base, ...declared];
238
+ }
239
+
240
+ /**
241
+ * Resolve the declaration file location: the `DF_UI_SURFACES` override first
242
+ * (absolute or `startDir`-relative), else an upward filesystem walk from
243
+ * `startDir` for `.darkfactory/ui-surfaces.json`. Returns `null` when no
244
+ * declaration exists.
245
+ *
246
+ * The upward walk exists because the producer runs with `cwd` = the web dir
247
+ * (e.g. `web/`) but the declaration lives at the REPO ROOT alongside the other
248
+ * `.darkfactory/` config — a `join(cwd, ...)` would miss it.
249
+ */
250
+ export function resolveDeclarationPath(
251
+ startDir: string,
252
+ env: NodeJS.ProcessEnv = process.env,
253
+ ): string | null {
254
+ const override = env[DECLARATION_ENV_VAR];
255
+ if (typeof override === "string" && override.trim().length > 0) {
256
+ const raw = override.trim();
257
+ const abs = isAbsolute(raw) ? raw : resolve(startDir, raw);
258
+ return existsSync(abs) ? abs : null;
259
+ }
260
+ let dir = resolve(startDir);
261
+ for (;;) {
262
+ const candidate = join(dir, DECLARATION_REL_PATH);
263
+ if (existsSync(candidate)) return candidate;
264
+ // Stop at the repo root (a dir/file named `.git` — a plain repo has a dir, a
265
+ // worktree/submodule has a file): the declaration is a repo-root artifact, so
266
+ // never search ABOVE it, which would risk picking up an unrelated
267
+ // `.darkfactory/ui-surfaces.json` in a parent checkout.
268
+ if (existsSync(join(dir, ".git"))) return null;
269
+ const parent = dirname(dir);
270
+ if (parent === dir) return null; // reached the filesystem root
271
+ dir = parent;
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Load + parse the designer declaration, returning the extra `UiSurface[]` to
277
+ * merge into the producer's `SURFACES[]`. A MISSING declaration is normal (no
278
+ * designer override) → `[]`. A PRESENT-but-malformed declaration (bad JSON or a
279
+ * failed field validation) THROWS — fail loud, never silently un-cover a path.
280
+ */
281
+ export function loadDeclaredSurfaces(
282
+ startDir: string,
283
+ env: NodeJS.ProcessEnv = process.env,
284
+ ): UiSurface[] {
285
+ const file = resolveDeclarationPath(startDir, env);
286
+ if (file === null) return [];
287
+
288
+ let text: string;
289
+ try {
290
+ text = readFileSync(file, "utf8");
291
+ } catch (err) {
292
+ throw new Error(
293
+ `ui-surfaces declaration: failed to read ${file}: ${
294
+ err instanceof Error ? err.message : String(err)
295
+ }`,
296
+ );
297
+ }
298
+
299
+ let json: unknown;
300
+ try {
301
+ json = JSON.parse(text);
302
+ } catch (err) {
303
+ throw new Error(
304
+ `ui-surfaces declaration: ${file} is not valid JSON: ${
305
+ err instanceof Error ? err.message : String(err)
306
+ }`,
307
+ );
308
+ }
309
+
310
+ // Pass the resolved file so a schema-validation error names WHICH file is
311
+ // wrong (DF_UI_SURFACES can override the location).
312
+ return parseSurfaceDeclaration(json, file);
313
+ }
314
+
315
+ /** Context for the fail-closed coverage message. */
316
+ export interface UncoveredErrorContext {
317
+ /** The changed UI path(s) with no mapped/declared surface. */
318
+ uncovered: readonly string[];
319
+ /** The active operator mode (e.g. `process.env.DF_MODE`). When set (a
320
+ * non-empty operator-driven session — Designer Mode is the first), the
321
+ * message routes to `/designer-handoff` in plain language instead of "edit
322
+ * the spec". */
323
+ mode?: string | undefined;
324
+ /** Where the designer declaration lives (for the message). */
325
+ declarationPath?: string;
326
+ }
327
+
328
+ /**
329
+ * Build the fail-closed coverage error. Pure + unit-tested so both personas'
330
+ * copy is asserted:
331
+ * - ENGINEER (no operator mode): points to `SURFACES[]` AND the designer
332
+ * declaration — both clear the block; the coverage requirement is unchanged.
333
+ * - DESIGNER / operator mode active: plain-language, framed as the gate WORKING
334
+ * (not a rejection), with two designer-safe options — the JSON declaration,
335
+ * or `/designer-handoff` as PROGRESS. It never routes a designer to a
336
+ * TS/spec edit. (References the `/designer-handoff` skill; does NOT edit it.)
337
+ */
338
+ export function formatUncoveredError(ctx: UncoveredErrorContext): string {
339
+ const declPath = ctx.declarationPath ?? DECLARATION_REL_PATH;
340
+ const list = ctx.uncovered.map((p) => ` - ${p}`).join("\n");
341
+ const example = `${declPath}:\n${EXAMPLE_JSON}`;
342
+ const operatorMode =
343
+ typeof ctx.mode === "string" && ctx.mode.trim().length > 0;
344
+
345
+ if (operatorMode) {
346
+ return (
347
+ `Design verification can't see this change yet.\n\n` +
348
+ `The UI you changed isn't mapped to a screen this check captures, so it ` +
349
+ `can't produce real before/after evidence for it — it stops here. This is ` +
350
+ `the safety gate doing its job, NOT a rejection of your work:\n${list}\n\n` +
351
+ `Two designer-safe ways forward — neither touches test or TypeScript code:\n\n` +
352
+ ` 1. Say which screen shows this change, in ${example}\n\n` +
353
+ ` (read: "the /chat screen is where web/components/chat/** appears"). ` +
354
+ `Commit it and re-run — this check then captures /chat as the evidence.\n\n` +
355
+ ` 2. If you can't map it to a screen, hand it to an engineer as PROGRESS: ` +
356
+ `run \`/designer-handoff\`. It escalates with the context filled in — never ` +
357
+ `a rejection.`
358
+ );
359
+ }
360
+
361
+ return (
362
+ `Evidence-Gated Validation playwright route: the following changed UI ` +
363
+ `path(s) have NO mapped capture surface, so this route cannot produce real ` +
364
+ `evidence for them and FAILS CLOSED:\n${list}\n\n` +
365
+ `Fix (either — the coverage requirement is unchanged; the added/declared ` +
366
+ `route must really render):\n` +
367
+ ` • Engineer: add a surface (path + requiredHeading + covers glob) to ` +
368
+ `SURFACES[] in ui-route.producer.spec.ts.\n` +
369
+ ` • No test-code edit: declare the covering route in ${example}\n\n` +
370
+ `Do NOT widen NON_SURFACE_GLOBS to suppress a real UI change — that ` +
371
+ `re-opens the false-positive hole this gate closes.`
372
+ );
373
+ }
@@ -30,6 +30,12 @@
30
30
  * cannot be satisfied by rendering only an unaffected surface. The mapping logic
31
31
  * lives in the unit-tested `coverage.ts`.
32
32
  *
33
+ * A changed area can be covered EITHER by editing `SURFACES[]` below (engineer)
34
+ * OR by declaring the covering route in `.darkfactory/ui-surfaces.json` — a
35
+ * designer-safe DATA file that needs no test/TS edit (#359, `surface-declaration.ts`).
36
+ * Declared surfaces are captured identically, so the coverage requirement is
37
+ * unchanged; only the authoring surface differs.
38
+ *
33
39
  * ## Auth (consumer-supplied storage-state — NOT vendor-coupled)
34
40
  *
35
41
  * Public surfaces need no auth. For PROTECTED surfaces, supply an authenticated
@@ -53,14 +59,21 @@
53
59
  * gate without a real, passing capture bound to the SHA.
54
60
  *
55
61
  * ── CONSUMER REFERENCE FILE — copy into your repo and OWN it. The one block you
56
- * MUST edit is `SURFACES[]` below (your routes + their required headings +
57
- * the source globs each one covers). `df skills install verify` does NOT
58
- * overwrite this file; it is yours to maintain.
62
+ * edit for engineer-authored surfaces is `SURFACES[]` below (your routes +
63
+ * their required headings + the source globs each one covers); a designer can
64
+ * instead add covering routes to `.darkfactory/ui-surfaces.json` with no code
65
+ * edit (#359). `df skills install verify` does NOT overwrite this file; it is
66
+ * yours to maintain.
59
67
  */
60
68
  import { test, expect, type Page } from "@playwright/test";
61
69
  import { mkdirSync, writeFileSync } from "node:fs";
62
70
  import { join } from "node:path";
63
71
  import { partitionChangedPaths, type UiSurface } from "./coverage";
72
+ import {
73
+ formatUncoveredError,
74
+ loadDeclaredSurfaces,
75
+ mergeDeclaredSurfaces,
76
+ } from "./surface-declaration";
64
77
 
65
78
  const SHA = process.env.DF_UI_ROUTE_SHA ?? "unknown";
66
79
  const OUT_DIR =
@@ -134,11 +147,29 @@ const CHANGED_UI_PATHS = (process.env.DF_UI_ROUTE_CHANGED_PATHS ?? "")
134
147
  .map((p) => p.trim())
135
148
  .filter(Boolean);
136
149
 
150
+ // Designer-safe coverage declaration (#359): a designer can DECLARE a covering
151
+ // route in `.darkfactory/ui-surfaces.json` (a data file) instead of editing the
152
+ // SURFACES[] above — pure engineer work they can't safely do mid-feature.
153
+ // Declared entries become real UiSurfaces, so the producer navigates + asserts +
154
+ // screenshots them exactly like a hand-authored surface: the fail-closed
155
+ // coverage REQUIREMENT is unchanged; only the AUTHORING surface (JSON data vs
156
+ // TS/RegExp) differs. Missing declaration → []; a malformed one throws (loud).
157
+ // Resolved from the repo root (the producer runs with cwd = the web dir) via
158
+ // DF_UI_SURFACES or an upward filesystem walk. `mergeDeclaredSurfaces` FAILS
159
+ // CLOSED on a slug collision (a declared slug equal to an existing SURFACES[]
160
+ // slug or another declaration): evidence is written to <out>/<slug>/, so a
161
+ // shared slug would silently overwrite another surface's screenshots.
162
+ const DECLARED_SURFACES = loadDeclaredSurfaces(process.cwd(), process.env);
163
+ const ALL_SURFACES: readonly UiSurface[] = mergeDeclaredSurfaces(
164
+ SURFACES,
165
+ DECLARED_SURFACES,
166
+ );
167
+
137
168
  const {
138
169
  armed: ARMED_SURFACES,
139
170
  uncovered: UNCOVERED_PATHS,
140
171
  smokeAllSurfaces: SMOKE_ALL_SURFACES,
141
- } = partitionChangedPaths(CHANGED_UI_PATHS, SURFACES, NON_SURFACE_GLOBS);
172
+ } = partitionChangedPaths(CHANGED_UI_PATHS, ALL_SURFACES, NON_SURFACE_GLOBS);
142
173
 
143
174
  function routeOutDir(slug: string): string {
144
175
  return join(OUT_DIR, slug);
@@ -197,14 +228,16 @@ async function captureBefore(
197
228
  // NON_SURFACE_GLOBS to hide it.
198
229
  test("ui-route-coverage — every changed UI path maps to a capture surface", async () => {
199
230
  if (UNCOVERED_PATHS.length > 0) {
231
+ // Mode-aware message (#359): an engineer sees the SURFACES[] + declaration
232
+ // fix; a Designer-Mode / operator session (DF_MODE set) is routed to the
233
+ // designer-safe `.darkfactory/ui-surfaces.json` declaration and
234
+ // `/designer-handoff` as PROGRESS — never to a TS/spec edit. The coverage
235
+ // REQUIREMENT is identical either way; only the remediation copy changes.
200
236
  throw new Error(
201
- "Evidence-Gated Validation playwright route: the following changed UI " +
202
- "path(s) have NO mapped capture surface, so this route cannot produce " +
203
- "real evidence for them and FAILS CLOSED:\n" +
204
- UNCOVERED_PATHS.map((p) => ` - ${p}`).join("\n") +
205
- "\n\nAdd a surface for the affected area to SURFACES[] in " +
206
- "ui-route.producer.spec.ts. Do NOT widen NON_SURFACE_GLOBS to suppress " +
207
- "a real UI change — that re-opens the false-positive hole this gate closes.",
237
+ formatUncoveredError({
238
+ uncovered: UNCOVERED_PATHS,
239
+ mode: process.env.DF_MODE,
240
+ }),
208
241
  );
209
242
  }
210
243
  expect(UNCOVERED_PATHS).toEqual([]);