@bongos/core 1.19.463 → 1.19.465

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 (31) hide show
  1. package/.bongos-core.json +82 -27
  2. package/.claude/skills/design/SKILL.md +5 -1
  3. package/docs/adr/0222-the-image-family-under-the-hero-only-rule.md +36 -0
  4. package/docs/adr/0223-the-detector-is-two-tiers-in-the-kit.md +33 -0
  5. package/docs/adr/README.md +2 -0
  6. package/docs/file-map.md +1 -1
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/ui-look-before-you-ship.md +2 -0
  9. package/modules/ui-design/kit/README.md +12 -0
  10. package/modules/ui-design/kit/check-mock.js +18 -5
  11. package/modules/ui-design/kit/lib.js +22 -1
  12. package/modules/ui-design/kit/render.js +4 -2
  13. package/modules/ui-design/kit/tells.js +204 -0
  14. package/modules/ui-design/module.json +1 -1
  15. package/modules/ui-design/skills/README.md +6 -2
  16. package/modules/ui-design/skills/brandkit/SKILL.md +62 -0
  17. package/modules/ui-design/skills/image-to-code/SKILL.md +66 -0
  18. package/modules/ui-design/skills/imagegen-frontend-mobile/SKILL.md +62 -0
  19. package/modules/ui-design/skills/imagegen-frontend-web/SKILL.md +83 -0
  20. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  21. package/modules/ui-design/styles/grove/DESIGN.md +1 -1
  22. package/modules/ui-design/styles/grove/assets/grove-pod-cut.webp +0 -0
  23. package/modules/ui-design/styles/grove/assets/grove-pod-cut.webp.json +12 -0
  24. package/modules/ui-design/styles/grove/assets/grove-pod.jpg +0 -0
  25. package/modules/ui-design/styles/grove/assets/grove-pod.jpg.json +12 -0
  26. package/modules/ui-design/styles/grove/mock.html +15 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/src/module-api.js +1 -1
  30. package/tests/ui_design_kit.mjs +86 -0
  31. package/tests/ui_design_skills.mjs +52 -0
@@ -136,7 +136,7 @@ Verification is **bounded passes, never an open loop**, and the budget covers th
136
136
 
137
137
  ## Not here, and where it went
138
138
 
139
- - **The anti-pattern detector**: task 1003329 lands it as rules in `kit/check-mock.js` and `kit/lib.js`; today `check-mock.js` (the platform rules and the instance's `DESIGN.md` bans over a mock), `render.js`'s floors and `contrast.js` are the deterministic floor. Run it once per review round, treat a clean scan as a floor and never as proof, verify every finding in context and name false positives out loud.
139
+ - **The anti-pattern detector**: landed as rules in the kit (task 1003329, ADR 0223): `kit/tells.js` runs inside `check-mock.js` (two floors that FAIL, eighteen tells that WARN, a kicker above the h1 a FAIL where the world bans the eyebrow), and the rendered twins ride `render.js` as `~ tell:` lines per state; `contrast.js` is the pair floor. Run it once per review round, treat a clean scan as a floor and never as proof, verify every finding in context, and name a false positive where it fires with `kit-ignore: <rule> (reason)` rather than silencing the rule.
140
140
  - **The live loop**: `claude plugin install playwright@claude-plugins-official`, a prerequisite the module never installs, driving `kit/serve.js`'s stub (`SURFACE=<dir or module key>`, `STYLE=<look>`). Variants are states in the states file, rendered by the kit; accepting one is a commit and discarding one is a checkout; there is no in-page overlay, no injected script, no journal, and no leftover to sweep because nothing was injected.
141
141
  - **Hooks, the bypass agent, telemetry, the remote bundle, the installer, `doctor`, `pin`, `unpin`**: gone. The ship gate is the sweep; the materialiser is the install; a reviewer runs with ordinary permissions.
142
142
  - **The asset producer**: images are hero plates and hero objects by token (task 1003328 for the image family); nothing here generates, crops or cleans an image.
@@ -115,7 +115,7 @@ Everything in this look is cut from soft paper and set down in a garden. Contain
115
115
 
116
116
  - **Tinted shadows.** `0 10px 26px -14px shade, 0 2px 6px shade-soft`, where `shade` is the ink at 16% and `shade-soft` the ink at 7%; in dark, black at 60% and 35%. Grey shadows are banned: a shadow is the ground's own colour, deepened.
117
117
  - **The slab.** A deep-green panel (the sea mixed 62% into black, the same in both modes) with cream type and a 20%-white hairline inside it.
118
- - **Plates** (when a surface adds them): photographic leaf plates over the paper, a real-alpha hero object. A surface's plates are its own; this look brings none.
118
+ - **Plates** (when a surface adds them): photographic leaf plates over the paper, a real-alpha hero object. A surface's plates are its own. This look's specimen carries one, as the worked example of the pair convention (task 1003328, `imagegen-frontend-web`): `assets/grove-pod.jpg` (the black-ground plate, dark) and `assets/grove-pod-cut.webp` (the alpha cutout, light), a sculpted paper seed pod in sage and tan, each with a `<file>.json` provenance sidecar; the tokens are `--plate-pod` / `--plate-pod-cut` on the mock's `body`.
119
119
 
120
120
  ## Motion
121
121
 
@@ -0,0 +1,12 @@
1
+ {
2
+ "kind": "cutout",
3
+ "model": "nano_banana_pro",
4
+ "aspect_ratio": "1:1",
5
+ "resolution": "2k",
6
+ "generated": "2026-09-02T00:57Z",
7
+ "round": 1,
8
+ "prompt": "Background removed (the art pipeline's image background remover) from the grove-pod generation: a single sculpted paper seed pod, hand-folded from thick pale sage and warm tan paper, soft rounded forms, matte fibrous paper texture, two curled paper leaves at its base, lit softly from the upper left with soft tinted shadows, studio still life, centred, no text, no letters, no logo, no watermark.",
9
+ "source": "the remover's PNG (2048px, real alpha verified with ffmpeg alphaextract, YAVG 118)",
10
+ "encoded": "ffmpeg libwebp quality 88, alpha kept (yuva420p), 1600px (task 1003328)",
11
+ "use": "light mode: the specimen's hero object as an alpha cutout on the pale sage paper"
12
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "kind": "plate",
3
+ "model": "nano_banana_pro",
4
+ "aspect_ratio": "1:1",
5
+ "resolution": "2k",
6
+ "generated": "2026-09-02T00:55Z",
7
+ "round": 1,
8
+ "prompt": "A single sculpted paper seed pod, hand-folded from thick pale sage and warm tan paper, soft rounded forms, matte fibrous paper texture, two curled paper leaves at its base, lit softly from the upper left with soft tinted shadows, studio still life, centred, pure black everywhere the object is not, no text, no letters, no logo, no watermark.",
9
+ "source": "the generation returned a white ground (the one-in-six case); this plate is the real-alpha cutout (grove-pod-cut.webp's source) composited on pure black with ffmpeg, then scaled to 1600px",
10
+ "encoded": "ffmpeg overlay on color=black, scale=1600, JPEG -q:v 3 (task 1003328)",
11
+ "use": "dark mode: the specimen's hero object as a masthead on the black ground"
12
+ }
@@ -39,6 +39,10 @@ body {
39
39
  --radius: 22px;
40
40
  --radius-sm: 12px;
41
41
  --radius-pill: 999px;
42
+ --plate-pod: url(/assets/grove-pod.jpg);
43
+ --plate-pod-cut: url(/assets/grove-pod-cut.webp);
44
+ --plate-pod-hero: var(--plate-pod-cut);
45
+ --pod-shade: drop-shadow(0 18px 22px var(--shade));
42
46
  --top-h: 64px;
43
47
  --gutter: clamp(16px, 4vw, 26px);
44
48
  --band: clamp(40px, 7vh, 72px);
@@ -73,6 +77,8 @@ body {
73
77
  --label-face: var(--font-body);
74
78
  --label-size: 11.5px;
75
79
  --label-space: .06em;
80
+ --plate-pod-hero: var(--plate-pod);
81
+ --pod-shade: none;
76
82
  }
77
83
  @media (prefers-color-scheme: dark) {
78
84
  :root:not([data-mode="light"]) body {
@@ -98,6 +104,8 @@ body {
98
104
  --label-face: var(--font-body);
99
105
  --label-size: 11.5px;
100
106
  --label-space: .06em;
107
+ --plate-pod-hero: var(--plate-pod);
108
+ --pod-shade: none;
101
109
  }
102
110
  }
103
111
 
@@ -125,6 +133,11 @@ body[data-state="signed-in"] .who { display: inline-flex; }
125
133
 
126
134
  /* the bands */
127
135
  .band { padding: var(--band) 0; }
136
+ /* the hero object: the specimen's one plate, a sculpted paper seed pod, as a masthead above the
137
+ words, offset right and bleeding past the wrap by a gutter (the masthead anchor of
138
+ imagegen-frontend-web; never text-left image-right). Light takes the alpha cutout on the paper
139
+ under a tinted lift; dark takes the black-ground plate on the black. Decorative: aria-hidden. */
140
+ .pod { width: min(360px, 58vw); aspect-ratio: 1; margin: calc(-1 * var(--band) / 2) calc(-1 * var(--gutter)) 6px auto; background: var(--plate-pod-hero) center / contain no-repeat; filter: var(--pod-shade); }
128
141
  h1 { font-family: var(--font-display); font-weight: 800; font-size: clamp(34px, 6.4vw, 84px); line-height: .96; letter-spacing: -.03em; margin: 0 0 18px; max-width: 12ch; }
129
142
  h2 { font-family: var(--font-display); font-weight: 800; font-size: clamp(24px, 3vw, 38px); line-height: 1.08; letter-spacing: -.02em; margin: 0 0 14px; }
130
143
  .lede { color: var(--ink-soft); max-width: 56ch; margin: 0; }
@@ -189,7 +202,7 @@ footer a { color: var(--accent-ink); display: inline-block; padding: 3px 0; font
189
202
  </style>
190
203
  </head>
191
204
  <body>
192
- <!-- THESIS: the grove as a specimen sheet. The thirteen colours and the two faces the pack carries, laid through this look's own derived recipes (its DESIGN.md frontmatter): a bar that floats as a soft card, a rounded heavy display voice, tiles under tinted shadows mixed from the ink, the sea green as a slab a whole band sits on, door pills with the primary in tan, a rail of soft tiles with one lit, ledger rows inside a sculpted panel. This page declares no :root and no colour of its own: the pack is spliced by the stub (STYLE=grove) and every value is a var() or a color-mix() of it. Dark restates the platform's black tier on body, since a mock links no sheet; the candidate's green dark ground cannot travel in a pack, so dark is the black under the tan and the sea. STATES: rest (signed out), signed-in. MODES: ?mode=dark|light. -->
205
+ <!-- THESIS: the grove as a specimen sheet. The thirteen colours and the two faces the pack carries, laid through this look's own derived recipes (its DESIGN.md frontmatter): a bar that floats as a soft card, a rounded heavy display voice, tiles under tinted shadows mixed from the ink, the sea green as a slab a whole band sits on, door pills with the primary in tan, a rail of soft tiles with one lit, ledger rows inside a sculpted panel, and one hero object (a sculpted paper seed pod, the pair convention: the alpha cutout on the paper in light, the black-ground plate in dark, each with a provenance sidecar under assets/). This page declares no :root and no colour of its own: the pack is spliced by the stub (STYLE=grove) and every value is a var() or a color-mix() of it. Dark restates the platform's black tier on body, since a mock links no sheet; the candidate's green dark ground cannot travel in a pack, so dark is the black under the tan and the sea. STATES: rest (signed out), signed-in. MODES: ?mode=dark|light. -->
193
206
  <a class="skip" href="#main">Skip to content</a>
194
207
  <header class="bar">
195
208
  <div class="wrap">
@@ -207,6 +220,7 @@ footer a { color: var(--accent-ink); display: inline-block; padding: 3px 0; font
207
220
  <main id="main" tabindex="-1">
208
221
  <section class="band">
209
222
  <div class="wrap">
223
+ <div class="pod" aria-hidden="true"></div>
210
224
  <p class="label badge">A look from the style library</p>
211
225
  <h1>The grove</h1>
212
226
  <p class="lede">A sculpted paper garden. Pale sage paper, soft corners on everything that holds content, shadows tinted from the ink and never grey, the sea green as a second voice, and one lit tan on everything you can press.</p>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.463",
3
+ "version": "1.19.465",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.463",
9
+ "version": "1.19.465",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.463",
3
+ "version": "1.19.465",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.463'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.465'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -185,6 +185,92 @@ test('checkMock: a taste ban applies only when the instance declared it', () =>
185
185
  assert.ok(!withoutBan.some((r) => r[1].startsWith('taste:')), 'no taste rule is even evaluated without a ban');
186
186
  });
187
187
 
188
+ // ── the anti-pattern detector (task 1003329, ADR 0223): every rule trips on a fixture and passes on one ──
189
+ const tellsMod = require(path.join(KIT, 'tells.js'));
190
+ const CLEAN = `<!doctype html><html><head><style>
191
+ ::selection{background:var(--accent)} body{color:var(--ink);--font:var(--font-body)} p{max-width:64ch}
192
+ .x{transition:opacity 200ms cubic-bezier(.2,.7,.3,1)} .lift{box-shadow:0 10px 26px -14px var(--shade)}
193
+ </style></head><body><main><h1>Short title</h1><p>Plain copy.</p></main></body></html>`;
194
+ const withSnippet = (snippet) => (typeof snippet === 'function' ? snippet(CLEAN) : CLEAN.replace('</main>', snippet + '</main>'));
195
+ const tripped = (html, prefix, opts) => tellsMod.scanTells(html, opts).find((r) => r[1].includes(prefix) && r[0] !== 'PASS');
196
+ const DETECTOR_CASES = [
197
+ ['bounce easing', 'FAIL', '<style>.b{transition:transform 200ms cubic-bezier(.34,1.56,.64,1)}</style>'],
198
+ ['a card nested in a card', 'FAIL', '<div class="card"><div class="card">x</div></div>'],
199
+ ['identical icon-heading-text', 'WARN', '<ul><li><svg></svg><h3>a</h3><p>b</p></li><li><svg></svg><h3>a</h3><p>b</p></li><li><svg></svg><h3>a</h3><p>b</p></li></ul>'],
200
+ ['the hero-metric', 'WARN', '<section><b>99%</b> <b>10k</b> <b>24+</b> uptime</section>'],
201
+ ['decorative section numbering', 'WARN', '<p>01 Features</p><p>02 Pricing</p>'],
202
+ ['a kicker', 'WARN', (c) => c.replace('<h1>Short title</h1>', '<p class="label">Eyebrow</p><h1>Short title</h1>')],
203
+ ['a coloured side stripe', 'WARN', '<style>.q{border-left:4px solid var(--accent)}</style>'],
204
+ ['a hard zero-blur', 'WARN', '<style>.h{box-shadow:6px 6px 0 var(--ink)}</style>'],
205
+ ['a zero-offset coloured halo', 'WARN', '<style>.g{box-shadow:0 0 24px var(--accent)}</style>'],
206
+ ['decorative glass', 'WARN', '<style>.a{backdrop-filter:blur(8px)}.b{backdrop-filter:blur(8px)}.c{backdrop-filter:blur(8px)}</style>'],
207
+ ['a default typeface', 'WARN', '<style>h2{font-family:Inter,sans-serif}</style>'],
208
+ ['a prose measure', 'WARN', '<style>.wide{max-width:96ch}</style>'],
209
+ ['a headline longer', 'WARN', (c) => c.replace('<h1>Short title</h1>', `<h1>${'word '.repeat(22)}</h1>`)],
210
+ ['the same entrance', 'WARN', '<style>.a{animation:rise 1s}.b{animation:rise 1s}.c{animation:rise 1s}</style>'],
211
+ ['a geometric mask', 'WARN', '<style>img.plate{clip-path:circle(50%)}</style>'],
212
+ ['unicode glyphs', 'WARN', '<p>→ one ★ two ✓ three</p>'],
213
+ ['a rainbow', 'WARN', '<style>.m{background:conic-gradient(var(--a),var(--b))}</style>'],
214
+ ['the browser surfaces', 'WARN', '<style>a{text-decoration:underline}</style><a href="#">x</a>'],
215
+ ['proportional numerals', 'WARN', '<table><tr><td>1,200</td><td>34,000</td><td>12345</td></tr></table>'],
216
+ ['tiny type', 'WARN', '<style>.s{font-size:9px}</style>'],
217
+ ];
218
+
219
+ test('the detector: a clean page trips nothing, and every rule class trips on its own fixture at its own tier', () => {
220
+ const clean = tellsMod.scanTells(CLEAN);
221
+ assert.equal(clean.length, tellsMod.FLOOR_RULES.length + tellsMod.TELL_RULES.length, 'every rule reports a row');
222
+ assert.deepEqual(clean.filter((r) => r[0] !== 'PASS'), [], 'the clean fixture passes every floor and tell');
223
+ for (const [prefix, tier, snippet] of DETECTOR_CASES) {
224
+ const row = tripped(withSnippet(snippet), prefix);
225
+ assert.ok(row, `${prefix}: trips on its fixture`);
226
+ assert.equal(row[0], tier, `${prefix}: reported as ${tier}`);
227
+ assert.ok(row[2], `${prefix}: names what it saw`);
228
+ }
229
+ assert.equal(DETECTOR_CASES.length, tellsMod.FLOOR_RULES.length + tellsMod.TELL_RULES.length, 'one tripping fixture per rule, no rule unpinned');
230
+ });
231
+
232
+ test('the detector: a kicker above the h1 is a tell by default and a FAIL only where the instance bans the eyebrow', () => {
233
+ const page = withSnippet((c) => c.replace('<h1>Short title</h1>', '<p class="eyebrow">Eyebrow</p><h1>Short title</h1>'));
234
+ assert.equal(tripped(page, 'a kicker')[0], 'WARN');
235
+ assert.equal(tripped(page, 'a kicker', { bans: new Set(['eyebrow']) })[0], 'FAIL');
236
+ assert.ok(mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8')).has('eyebrow'), 'the chrome world bans it by name');
237
+ });
238
+
239
+ test('the detector: a per-file kit-ignore waiver turns a hit into WAIVE with its reason, and --raw ignores waivers', () => {
240
+ const page = withSnippet('<style>.s{font-size:9px}</style><!-- kit-ignore: tell: tiny type (the mono label is 9px by the world\'s own rule) -->');
241
+ const waivers = tellsMod.parseWaivers(page);
242
+ assert.equal(waivers.get('tell: tiny type'), 'the mono label is 9px by the world\'s own rule');
243
+ const row = tellsMod.scanTells(page, { waivers }).find((r) => r[1].includes('tiny type'));
244
+ assert.equal(row[0], 'WAIVE'); assert.match(row[2], /9px; the mono label/);
245
+ assert.equal(tellsMod.scanTells(page).find((r) => r[1].includes('tiny type'))[0], 'WARN', 'with no waiver map (--raw) the tell stands');
246
+ const file = tmp('m.html', page);
247
+ const raw = run('check-mock.js', ['--file', file, '--no-taste', '--raw']);
248
+ assert.match(raw.stdout, /WARN\s+tell: tiny type/);
249
+ const json = run('check-mock.js', ['--file', file, '--no-taste', '--json']);
250
+ const parsed = JSON.parse(json.stdout);
251
+ assert.ok(parsed.results.some((r) => r.status === 'WAIVE' && /tiny type/.test(r.rule)), '--json carries the WAIVE row');
252
+ assert.ok(!parsed.results.some((r) => r.status === 'FAIL' && /^(floor|tell):/.test(r.rule)), 'a waived tell is not a failure (the fixture fails only the structural platform rules a bare fragment cannot meet)');
253
+ assert.equal(parsed.fails, parsed.results.filter((r) => r.status === 'FAIL').length, '--json counts its own FAIL rows');
254
+ });
255
+
256
+ test('the detector runs inside checkMock (floors fail the mock; a tell never does) and --no-tells switches it off', () => {
257
+ const f = fails(mock.checkMock(GOOD_MOCK.replace('<style>', '<style>.c{transition:transform 200ms cubic-bezier(.34,1.56,.64,1)}')));
258
+ assert.ok(f.some((r) => r.startsWith('floor: bounce easing')), f.join(' | '));
259
+ const rows = mock.checkMock(GOOD_MOCK.replace('<style>', '<style>.s{font-size:9px}'));
260
+ assert.ok(rows.some((r) => r[0] === 'WARN' && /tiny type/.test(r[1])));
261
+ assert.deepEqual(fails(rows), []);
262
+ assert.ok(!mock.checkMock(GOOD_MOCK, { noTells: true }).some((r) => /^(floor|tell):/.test(r[1])), '--no-tells evaluates no detector rule');
263
+ assert.ok(mock.TASTE_BANS.eyebrow.test('an eyebrow above the h1'));
264
+ });
265
+
266
+ test('the rendered tells: craftTells reads the in-page facts as warning lines and never as problems', () => {
267
+ assert.deepEqual(lib.craftTells(clean()), []);
268
+ const t = lib.craftTells({ ...clean(), tells: ['prose-measure: p.lede ~110ch', 'tiny-type: small 9px'] });
269
+ assert.deepEqual(t, ['tell: prose-measure: p.lede ~110ch', 'tell: tiny-type: small 9px']);
270
+ assert.deepEqual(lib.auditFloors({ ...clean(), tells: ['tiny-type: small 9px'] }, { width: 1440, mode: 'dark' }), [], 'a tell is not a floor');
271
+ assert.match(String(lib.factsInPage), /out\.tells = tells/, 'factsInPage collects the tells in the page');
272
+ });
273
+
188
274
  test('readDesignBans reads the Don\'t section — the live DESIGN.md declares the five, an empty file none', () => {
189
275
  const live = mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'));
190
276
  for (const k of ['em-dash', 'emoji', 'gradient-text', 'text-glow', 'box-shadow']) assert.ok(live.has(k), `live DESIGN.md bans ${k}`);
@@ -148,6 +148,58 @@ test('impeccable carries none of the behaviour that made its origin dangerous: n
148
148
  assert.match(skill, /1003329/, 'the detector is named as its own task, not shipped here');
149
149
  });
150
150
 
151
+ // ── the image family (task 1003328, ADR 0222): plates and hero objects only, never screens ──
152
+ const IMAGE_FAMILY = ['image-to-code', 'imagegen-frontend-web', 'imagegen-frontend-mobile', 'brandkit'];
153
+
154
+ test('the image family of task 1003328 exists as four in-house skills, declared, each naming the sidecar, "text-free" and the art pipeline key in its own rules', () => {
155
+ const pre = read(path.join(SKILLS, 'PREAMBLE.md')).trimEnd();
156
+ for (const name of IMAGE_FAMILY) {
157
+ assert.ok(ownDirs.includes(name), `${name}: skills/${name}/SKILL.md exists`);
158
+ assert.ok(manifest.contributes.skills.includes(name), `${name}: declared in contributes.skills`);
159
+ const text = read(path.join(SKILLS, name, 'SKILL.md'));
160
+ const own = text.slice(text.indexOf(pre) + pre.length);
161
+ assert.match(own, /sidecar/i, `${name}: names the provenance sidecar beside every plate (DESIGN.md's materials contract)`);
162
+ assert.match(own, /text-free/i, `${name}: restates that a plate carries no text`);
163
+ assert.match(own, /art[- ]key|art pipeline/i, `${name}: generation goes through the instance's art pipeline key, never a credential of its own`);
164
+ assert.match(own, /1003328/, `${name}: names the port task`);
165
+ }
166
+ });
167
+
168
+ test('no image-family skill carries the directives the panel flagged: no eagerness, no image-count mandate, no one-image-per-section or per-screen rule, no must-invoke-any-image-tool', () => {
169
+ const pre = read(path.join(SKILLS, 'PREAMBLE.md')).trimEnd();
170
+ for (const name of IMAGE_FAMILY) {
171
+ const text = read(path.join(SKILLS, name, 'SKILL.md'));
172
+ const own = text.slice(text.indexOf(pre) + pre.length);
173
+ assert.doesNotMatch(own, /IMAGE_GENERATION_EAGERNESS|eagerness\s*[:=]/i, `${name}: no eagerness setting`);
174
+ assert.doesNotMatch(own, /do not be lazy|don't be lazy/i, `${name}: no image-count goad`);
175
+ assert.doesNotMatch(own, /\b(exactly|generate|produce|render)\s+one\s+(image|frame|render)\s+per\s+(section|screen)\b/i, `${name}: no one-image-per-section or per-screen rule as a directive (naming the upstream's rule in the Rebuilt-from is fine)`);
176
+ assert.doesNotMatch(own, /\b(must|always)\s+(invoke|call|use)\s+(any|every|an?|the)\s+(available\s+)?image[- ]generat/i, `${name}: no must-invoke-any-image-tool directive`);
177
+ assert.doesNotMatch(own, /scale[s]?\s+(the\s+)?image\s+count\s+with/i, `${name}: no count scaled with sections (naming it as the upstream's is fine; directing it is not)`);
178
+ }
179
+ for (const name of ['imagegen-frontend-mobile', 'brandkit']) {
180
+ const own = read(path.join(SKILLS, name, 'SKILL.md'));
181
+ assert.match(own, /never (a|an) (screen|phone mockup|generated logo)|no phone mockups?/i, `${name}: says what it will not generate`);
182
+ }
183
+ });
184
+
185
+ test('the worked example of the image family is the grove specimen: the pair with a sidecar beside every raster, referenced as tokens on the mock body', () => {
186
+ const grove = path.join(MODULE, 'styles', 'grove');
187
+ for (const f of ['grove-pod.jpg', 'grove-pod-cut.webp']) {
188
+ assert.ok(fs.existsSync(path.join(grove, 'assets', f)), `styles/grove/assets/${f} exists`);
189
+ const side = path.join(grove, 'assets', `${f}.json`);
190
+ assert.ok(fs.existsSync(side), `${f}.json sidecar beside it (named WITH the extension)`);
191
+ const meta = readJson(side);
192
+ for (const k of ['kind', 'model', 'prompt', 'encoded', 'use']) assert.ok(meta[k], `${f}.json carries ${k}`);
193
+ assert.match(meta.prompt, /no text/i, 'the prompt carries the text-free refusal');
194
+ }
195
+ const mock = read(path.join(grove, 'mock.html'));
196
+ assert.match(mock, /--plate-pod:\s*url\(\/assets\/grove-pod\.jpg\)/, 'the dark plate is a token on body, root-absolute');
197
+ assert.match(mock, /--plate-pod-cut:\s*url\(\/assets\/grove-pod-cut\.webp\)/, 'the light cutout is a token on body, root-absolute');
198
+ assert.ok(!/<img\b/i.test(mock), 'the plate is a token, not an <img>');
199
+ const design = read(path.join(grove, 'DESIGN.md'));
200
+ assert.match(design, /grove-pod\.jpg/, 'the look\'s DESIGN.md names its specimen\'s plate instead of claiming it brings none');
201
+ });
202
+
151
203
  test('every in-house SKILL.md opens with the PLATFORM PREAMBLE, byte-identical to PREAMBLE.md, exactly once, right after its frontmatter', () => {
152
204
  const pre = read(path.join(SKILLS, 'PREAMBLE.md')).trimEnd();
153
205
  for (const name of ownDirs) {