@bongos/core 1.19.396 → 1.19.398

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 (38) hide show
  1. package/.bongos-core.json +149 -19
  2. package/.claude/skills/design/SKILL.md +9 -1
  3. package/docs/adr/0198-third-party-skill-vendoring-policy.md +43 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/file-map.md +1 -1
  6. package/docs/recipes/ui-look-before-you-ship.md +113 -0
  7. package/modules/hall-ui/public/ranks.states.json +15 -0
  8. package/modules/ui-design/kit/README.md +12 -2
  9. package/modules/ui-design/kit/check-mock.js +147 -0
  10. package/modules/ui-design/kit/contrast.js +156 -0
  11. package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
  12. package/modules/ui-design/kit/fixtures/featured.json +1 -0
  13. package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
  14. package/modules/ui-design/kit/fixtures/me.json +9 -0
  15. package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
  16. package/modules/ui-design/kit/fixtures/progress.json +1 -0
  17. package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
  18. package/modules/ui-design/kit/lib.js +389 -0
  19. package/modules/ui-design/kit/png.js +51 -0
  20. package/modules/ui-design/kit/probe.js +100 -0
  21. package/modules/ui-design/kit/render.js +137 -0
  22. package/modules/ui-design/kit/serve.js +178 -0
  23. package/modules/ui-design/skills/PREAMBLE.md +13 -0
  24. package/modules/ui-design/skills/README.md +36 -2
  25. package/modules/ui-design/skills/policy.json +12 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/public-landing/index.probes.json +37 -0
  29. package/public-landing/index.states.json +16 -0
  30. package/public-landing/projects.probes.json +79 -0
  31. package/public-landing/projects.states.json +25 -0
  32. package/scripts/gds/claude-materialize.js +122 -13
  33. package/scripts/gds/ui-kit-transform.js +43 -0
  34. package/src/bongos/module-scope-map.js +1 -0
  35. package/src/module-api.js +5 -1
  36. package/tests/claude_materialize.mjs +83 -0
  37. package/tests/ui_design_kit.mjs +217 -0
  38. package/tests/ui_design_skills.mjs +168 -0
@@ -139,27 +139,109 @@ function rebrandText(text, rules) {
139
139
 
140
140
  function readJsonSafe(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
141
141
 
142
- // Skills owned by a module that is DISABLED in the TARGET instance (task 2103b).
143
- // Each module.json declares `contributes.skills`; a module is enabled when the
144
- // target's config/modules.json says so, else by its own `default`. A skill owned
145
- // by NO module is core and is never skipped. PURE-ish (reads the fs) + exported.
146
- function disabledModuleSkills({ coreRoot, instanceDir }) {
147
- const skip = new Set();
142
+ // Every module that declares `contributes.skills`, with whether the TARGET instance
143
+ // enables it: the target's config/modules.json wins, else the module's own `default`.
144
+ // The one place that rule lives — disabledModuleSkills and moduleSkillSources both
145
+ // read it. PURE-ish (reads the fs) + exported.
146
+ function moduleSkillRoster({ coreRoot, instanceDir }) {
147
+ const roster = [];
148
148
  const modulesDir = path.join(coreRoot, 'modules');
149
- if (!fs.existsSync(modulesDir)) return skip;
149
+ if (!fs.existsSync(modulesDir)) return roster;
150
150
  const targetCfg = readJsonSafe(path.join(instanceDir, 'config', 'modules.json')) || {};
151
151
  const enabledMap = (targetCfg.modules && typeof targetCfg.modules === 'object') ? targetCfg.modules : {};
152
152
  for (const dir of fs.readdirSync(modulesDir)) {
153
153
  const mj = readJsonSafe(path.join(modulesDir, dir, 'module.json'));
154
154
  const skills = mj && mj.contributes && mj.contributes.skills;
155
155
  if (!Array.isArray(skills)) continue;
156
- const explicit = enabledMap[mj.key || dir];
156
+ const key = mj.key || dir;
157
+ const explicit = enabledMap[key];
157
158
  const enabled = explicit === undefined ? mj.default === true : explicit === true;
158
- if (!enabled) for (const s of skills) skip.add(s);
159
+ roster.push({ key, dir: path.join(modulesDir, dir), enabled, skills });
159
160
  }
161
+ return roster;
162
+ }
163
+
164
+ // Skills owned by a module that is DISABLED in the TARGET instance (task 2103b).
165
+ // A skill owned by NO module is core and is never skipped.
166
+ function disabledModuleSkills(opts) {
167
+ const skip = new Set();
168
+ for (const m of moduleSkillRoster(opts)) if (!m.enabled) for (const s of m.skills) skip.add(s);
160
169
  return skip;
161
170
  }
162
171
 
172
+ // A declared skill name is a path segment the materialiser WRITES under
173
+ // .claude/skills/, so it is allowlisted before it ever reaches path.join: lowercase
174
+ // slug only, no dots, no separators. A manifest carrying "../x" names nothing.
175
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
176
+
177
+ // The vendoring gate a THIRD-PARTY skill must pass at copy time, not only in CI
178
+ // (ADR 0198 §1–3): a PROVENANCE.md sidecar whose frontmatter names a licence on the
179
+ // allowlist and a scan verdict that may land. The module's own skills/policy.json
180
+ // (the ui-design shape) is the source of both lists when present; these defaults
181
+ // are the same five licences and two verdicts otherwise. A gate that cannot read
182
+ // the sidecar refuses — an unvetted copy never lands because a file was missing.
183
+ const VENDOR_DEFAULT_POLICY = Object.freeze({
184
+ licenceAllowlist: ['Apache-2.0', 'MIT', 'BSD-2-Clause', 'BSD-3-Clause', 'CC-BY-4.0'],
185
+ verdictsThatMayLand: ['clean', 'caution'],
186
+ });
187
+
188
+ function sidecarFrontmatter(file) {
189
+ let text;
190
+ try { text = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); } catch { return null; }
191
+ const m = text.match(/^---\n([\s\S]*?)\n---\n/);
192
+ if (!m) return null;
193
+ const out = {};
194
+ for (const line of m[1].split('\n')) {
195
+ const kv = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
196
+ if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '').trim();
197
+ }
198
+ return out;
199
+ }
200
+
201
+ // → { ok: true } or { ok: false, reason } for one vendored skill dir. Exported for the test.
202
+ function vendoredSkillGate(dir, policy = VENDOR_DEFAULT_POLICY) {
203
+ const fm = sidecarFrontmatter(path.join(dir, 'PROVENANCE.md'));
204
+ if (!fm) return { ok: false, reason: 'no PROVENANCE.md sidecar (or no frontmatter)' };
205
+ const licences = Array.isArray(policy.licenceAllowlist) ? policy.licenceAllowlist : VENDOR_DEFAULT_POLICY.licenceAllowlist;
206
+ const verdicts = Array.isArray(policy.verdictsThatMayLand) ? policy.verdictsThatMayLand : VENDOR_DEFAULT_POLICY.verdictsThatMayLand;
207
+ if (!licences.includes(fm.licence)) return { ok: false, reason: `licence "${fm.licence || ''}" is not on the allowlist` };
208
+ if (!verdicts.includes(fm.scanVerdict)) return { ok: false, reason: `scan verdict "${fm.scanVerdict || ''}" may not land` };
209
+ return { ok: true };
210
+ }
211
+
212
+ // Skills a module ships from its OWN tree instead of .claude/skills/ (task 1003321,
213
+ // ADR 0198): modules/<key>/skills/<name>/ for a skill the module authors, or
214
+ // modules/<key>/skills/vendor/<name>/ for a third-party skill carried with its
215
+ // PROVENANCE.md sidecar (the `vendor/` segment is the one fitness-ratchets exempts
216
+ // from the first-party line budgets). Only a name the module DECLARES in
217
+ // contributes.skills resolves — the manifest stays the single declaration — a name
218
+ // that fails SKILL_NAME_RE is refused before any path is built, a dir with no
219
+ // SKILL.md is not a skill, and a vendored dir must pass vendoredSkillGate. Refusals
220
+ // come back in `refused[]` so the caller can log them. Accepts a precomputed
221
+ // `roster` so materializeClaude walks modules/ once. PURE-ish (reads the fs) + exported.
222
+ function moduleSkillSources(opts) {
223
+ const out = [];
224
+ const refused = [];
225
+ const roster = (opts && opts.roster) || moduleSkillRoster(opts);
226
+ for (const m of roster) {
227
+ const policy = readJsonSafe(path.join(m.dir, 'skills', 'policy.json')) || VENDOR_DEFAULT_POLICY;
228
+ for (const name of m.skills) {
229
+ if (typeof name !== 'string' || !SKILL_NAME_RE.test(name)) { refused.push({ name: String(name), module: m.key, reason: 'name is not a lowercase slug' }); continue; }
230
+ const own = path.join(m.dir, 'skills', name);
231
+ const vendored = path.join(m.dir, 'skills', 'vendor', name);
232
+ const src = [own, vendored].find((p) => fs.existsSync(path.join(p, 'SKILL.md')));
233
+ if (!src) continue;
234
+ if (src === vendored) {
235
+ const gate = vendoredSkillGate(src, policy);
236
+ if (!gate.ok) { refused.push({ name, module: m.key, reason: gate.reason }); continue; }
237
+ }
238
+ out.push({ name, module: m.key, dir: src, enabled: m.enabled, vendored: src === vendored });
239
+ }
240
+ }
241
+ out.refused = refused;
242
+ return out;
243
+ }
244
+
163
245
  // ---- I/O: copy a tree, transforming text files through bongosify ----------
164
246
 
165
247
  const TEXT_EXT = new Set(['.md', '.txt', '.sh']); // command-bearing text we rewrite (skills are SKILL.md)
@@ -231,7 +313,7 @@ function transformSettings(srcPath) {
231
313
  const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
232
314
 
233
315
  function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
234
- const summary = { skipped: false, skills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
316
+ const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
235
317
  if (path.resolve(instanceDir) === path.resolve(coreRoot)) {
236
318
  summary.skipped = true;
237
319
  summary.notes.push('instance dir is the core root — nothing to materialize (byte-identical no-op).');
@@ -253,8 +335,11 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
253
335
  summary.rebrandRules = rebrandRules.length;
254
336
  if (rebrandRules.length) log(` • rebranding ${rebrandRules.length} founder literal(s) in the copied .claude/ → this instance's identity.`);
255
337
 
256
- // Drop skills owned by a module this instance hasn't enabled (task 2103b).
257
- const skipSkills = disabledModuleSkills({ coreRoot, instanceDir });
338
+ // Drop skills owned by a module this instance hasn't enabled (task 2103b). One
339
+ // walk of modules/ serves both this skip and step 1b's module-owned sources.
340
+ const roster = moduleSkillRoster({ coreRoot, instanceDir });
341
+ const skipSkills = new Set();
342
+ for (const m of roster) if (!m.enabled) for (const s of m.skills) skipSkills.add(s);
258
343
  const skillSkip = (rel) => skipSkills.has(String(rel).split(/[\\/]/)[0]);
259
344
 
260
345
  // 1. skills — ship in the core package; copy, rewrite invocations→bongos, rebrand
@@ -270,6 +355,30 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
270
355
  if (summary.excludedSkills.length) log(` • excluded ${summary.excludedSkills.length} skill(s) owned by disabled module(s): ${summary.excludedSkills.join(', ')}`);
271
356
  log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${skillsRes.files} skill file(s) → .claude/skills/ (invocations→bongos, identity rebranded)${left((skillsRes.skipped || []).length)}`);
272
357
 
358
+ // 1b. module-owned skills — shipped from modules/<key>/skills/ (own) or
359
+ // modules/<key>/skills/vendor/ (third-party + PROVENANCE.md) and landed in the
360
+ // SAME .claude/skills/<name>/ the builder's harness reads (task 1003321, ADR
361
+ // 0198). Same transforms as step 1; a disabled module's are excluded by the same
362
+ // rule; a name the core's own .claude/skills/ already carries keeps the core copy;
363
+ // a name that is not a slug, or a vendored dir that fails the sidecar gate, is
364
+ // REFUSED here at copy time — the CI test is the second line, not the only one.
365
+ const sources = moduleSkillSources({ roster });
366
+ for (const r of sources.refused) {
367
+ summary.notes.push(`module skill "${r.name}" (${r.module}) refused: ${r.reason}.`);
368
+ log(` ✗ refused module skill "${r.name}" (${r.module}): ${r.reason}`);
369
+ }
370
+ for (const s of sources) {
371
+ if (!s.enabled) { if (!summary.excludedSkills.includes(s.name)) summary.excludedSkills.push(s.name); continue; }
372
+ if (fs.existsSync(path.join(skillsSrc, s.name))) {
373
+ summary.notes.push(`module skill "${s.name}" (${s.module}) also exists under the core's .claude/skills/ — the core copy was materialized, the module copy ignored.`);
374
+ continue;
375
+ }
376
+ const r = copyTreeTransformed(s.dir, path.join(destClaude, 'skills', s.name), { dryRun, overwrite, transform: (t) => rebrand(bongosify(t)) });
377
+ summary.moduleSkills += r.files;
378
+ for (const rel of r.skipped || []) summary.skippedFiles.push(`skills/${s.name}/${rel}`);
379
+ }
380
+ if (summary.moduleSkills) log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${summary.moduleSkills} module-owned skill file(s) → .claude/skills/ (from modules/<key>/skills/)`);
381
+
273
382
  // 2. settings.json — topology; present in the checkout/core repo, absent in a package-only install.
274
383
  const settingsSrc = path.join(srcClaude, 'settings.json');
275
384
  if (fs.existsSync(settingsSrc)) {
@@ -343,4 +452,4 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
343
452
  return summary;
344
453
  }
345
454
 
346
- module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills };
455
+ module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // ui-kit-transform.js — the instance's serving transforms as a command (task
4
+ // 1003320, ADR 0197). Reads one surface page and prints it transformed exactly as
5
+ // serve-internal.js / platform-server.js serve it: the apex-only strip, the
6
+ // brand-token stamp, the pack's theme injection — with the instance's own
7
+ // branding pack.
8
+ //
9
+ // WHY a script: the ui-design kit's stub (modules/ui-design/kit/serve.js) must
10
+ // serve a page through the REAL code path, and a module may not require core
11
+ // (ADR 0083), while the doorway may not import the serving domain (fitness Check
12
+ // 12, ADR 0091 §1). A command under scripts/gds/ is outside both walls — the
13
+ // memory / ideas / sessions precedent: a command travels with its domain but
14
+ // stays under scripts/. The stub spawns this per page and memoises the answer.
15
+ //
16
+ // node scripts/gds/ui-kit-transform.js <page.html> [--child] [--json]
17
+ // --child the child-instance shape (apex-only regions removed)
18
+ // --json { html, brand, publicOrigin, hallOrigin } instead of the bare HTML
19
+ //
20
+ // Exit 2 on usage; the transformed page on stdout, nothing else.
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const args = process.argv.slice(2);
25
+ const file = args.find((a) => !a.startsWith('--'));
26
+ if (!file) { process.stderr.write('usage: node scripts/gds/ui-kit-transform.js <page.html> [--child] [--json]\n'); process.exit(2); }
27
+ const child = args.includes('--child');
28
+
29
+ const { injectBrandingGlobal, applyBrandTokens } = require('../../src/bongos/serve-internal');
30
+ const { stripApexOnly } = require('../../src/platform-server');
31
+ const branding = require('../../src/branding');
32
+
33
+ const brand = branding.clientBranding();
34
+ let domains = {};
35
+ try { domains = (typeof branding.branding === 'function' ? branding.branding() : {}).domains || {}; } catch { domains = {}; }
36
+ const publicOrigin = domains.publicOrigin || '';
37
+ const hallOrigin = child ? '' : (domains.buildersOrigin || '');
38
+
39
+ const raw = fs.readFileSync(path.resolve(file), 'utf8');
40
+ // platform-server's order for the landing: strip, then inject; the token stamp is
41
+ // what the hall/status surfaces get on the way through serve-internal.
42
+ const html = injectBrandingGlobal(applyBrandTokens(stripApexOnly(raw, !child), brand, publicOrigin, hallOrigin, '', ''), brand);
43
+ process.stdout.write(args.includes('--json') ? JSON.stringify({ html, brand, publicOrigin, hallOrigin }) : html);
@@ -223,6 +223,7 @@ const MODULE_GLOBS = {
223
223
  'ui-design': [
224
224
  'modules/ui-design/',
225
225
  'scripts/gds/design-sync.js', 'scripts/gds/figma-design-sync.js', 'scripts/gds/validate-design.js',
226
+ 'scripts/gds/ui-kit-transform.js', // the kit's stub reaches the real serving transforms through this command (task 1003320)
226
227
  '.claude/skills/design/', '.claude/skills/design-sync/', '.claude/skills/figma-design-sync/',
227
228
  'docs/recipes/claude-design-loop.md', 'docs/recipes/figma-design-loop.md',
228
229
  ],
package/src/module-api.js CHANGED
@@ -1232,8 +1232,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1232
1232
  // landed since 1.19.394 with no explicit bump. run 33188021384. (task 1002620)
1233
1233
  // 1.19.396 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1234
1234
  // landed since 1.19.395 with no explicit bump. run 33188525568. (task 1002620)
1235
+ // 1.19.397 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1236
+ // landed since 1.19.396 with no explicit bump. run 33193365480. (task 1002620)
1237
+ // 1.19.398 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1238
+ // landed since 1.19.397 with no explicit bump. run 33199474439. (task 1002620)
1235
1239
  // ---------------------------------------------------------------------------
1236
- const CORE_VERSION = '1.19.396'; // CI auto-patch carrier (ADR 0161); see changelog above
1240
+ const CORE_VERSION = '1.19.398'; // CI auto-patch carrier (ADR 0161); see changelog above
1237
1241
 
1238
1242
  // A namespaced logger so a module's log lines are attributable + consistent.
1239
1243
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -241,6 +241,89 @@ t('disabledModuleSkills: only skills of DISABLED modules are collected (default
241
241
  rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
242
242
  });
243
243
 
244
+ // ---- module-owned skills (task 1003321 / ADR 0198) ---------------------------
245
+ // A module ships a skill from modules/<key>/skills/<name>/ (its own) or
246
+ // modules/<key>/skills/vendor/<name>/ (third-party + PROVENANCE.md); only a DECLARED
247
+ // name resolves, and it lands in .claude/skills/<name>/ like any core skill — or is
248
+ // excluded by the same disabled-module rule.
249
+ t('moduleSkillSources: declared names resolve from skills/<name> or skills/vendor/<name>; undeclared and SKILL.md-less dirs do not', () => {
250
+ const core = mkdtempSync(join(tmpdir(), 'rb-core-'));
251
+ const inst = mkdtempSync(join(tmpdir(), 'rb-inst-'));
252
+ const modDir = join(core, 'modules', 'foo');
253
+ mkdirSync(join(modDir, 'skills', 'own'), { recursive: true });
254
+ mkdirSync(join(modDir, 'skills', 'vendor', 'third'), { recursive: true });
255
+ mkdirSync(join(modDir, 'skills', 'vendor', 'undeclared'), { recursive: true });
256
+ mkdirSync(join(modDir, 'skills', 'noskill'), { recursive: true });
257
+ writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['own', 'third', 'noskill', 'missing'] } }));
258
+ writeFileSync(join(modDir, 'skills', 'own', 'SKILL.md'), '---\nname: own\n---\nrun node scripts/gds/ship.js 1\n');
259
+ writeFileSync(join(modDir, 'skills', 'vendor', 'third', 'SKILL.md'), '---\nname: third\n---\nbody\n');
260
+ writeFileSync(join(modDir, 'skills', 'vendor', 'third', 'PROVENANCE.md'), '---\nlicence: MIT\nscanVerdict: caution\n---\n');
261
+ writeFileSync(join(modDir, 'skills', 'vendor', 'undeclared', 'SKILL.md'), '---\nname: undeclared\n---\n');
262
+ const srcs = m.moduleSkillSources({ coreRoot: core, instanceDir: inst });
263
+ assert.deepEqual(srcs.map((s) => [s.name, s.vendored, s.enabled]), [['own', false, true], ['third', true, true]]);
264
+ assert.deepEqual(srcs.refused, []);
265
+
266
+ // materialize: both land under .claude/skills/<name>/, sidecar included, SKILL.md bongosified
267
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
268
+ const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
269
+ assert.equal(res.moduleSkills, 3);
270
+ assert.ok(existsSync(join(inst, '.claude', 'skills', 'own', 'SKILL.md')));
271
+ assert.ok(existsSync(join(inst, '.claude', 'skills', 'third', 'SKILL.md')));
272
+ assert.ok(existsSync(join(inst, '.claude', 'skills', 'third', 'PROVENANCE.md')), 'the provenance sidecar travels with the skill');
273
+ assert.ok(!existsSync(join(inst, '.claude', 'skills', 'undeclared')), 'an undeclared dir never materializes');
274
+ assert.match(readFileSync(join(inst, '.claude', 'skills', 'own', 'SKILL.md'), 'utf8'), /bongos ship 1/);
275
+
276
+ // disabled in the target → neither lands, both are reported excluded
277
+ const inst2 = mkdtempSync(join(tmpdir(), 'rb-inst-'));
278
+ mkdirSync(join(inst2, 'config'), { recursive: true });
279
+ writeFileSync(join(inst2, 'config', 'modules.json'), JSON.stringify({ modules: { foo: false } }));
280
+ const res2 = m.materializeClaude({ coreRoot: core, instanceDir: inst2, dryRun: false });
281
+ assert.equal(res2.moduleSkills, 0);
282
+ assert.ok(!existsSync(join(inst2, '.claude', 'skills', 'third')));
283
+ assert.deepEqual(res2.excludedSkills.sort(), ['own', 'third']);
284
+ for (const d of [core, inst, inst2]) rmSync(d, { recursive: true, force: true });
285
+ });
286
+
287
+ // The copy-time gate (ADR 0198 §1–3): a declared name is a path segment, so it is a
288
+ // slug or nothing; a vendored dir lands only with a sidecar whose licence is on the
289
+ // allowlist and whose verdict may land. Refusals are reported, never silently skipped.
290
+ t('moduleSkillSources: a non-slug name never reaches path.join; a vendored dir with no sidecar, an off-list licence or a dangerous verdict is refused at copy time', () => {
291
+ const core = mkdtempSync(join(tmpdir(), 'rb-core-'));
292
+ const inst = mkdtempSync(join(tmpdir(), 'rb-inst-'));
293
+ const modDir = join(core, 'modules', 'foo');
294
+ const vendor = (name, sidecar) => {
295
+ mkdirSync(join(modDir, 'skills', 'vendor', name), { recursive: true });
296
+ writeFileSync(join(modDir, 'skills', 'vendor', name, 'SKILL.md'), `---\nname: ${name}\n---\n`);
297
+ if (sidecar != null) writeFileSync(join(modDir, 'skills', 'vendor', name, 'PROVENANCE.md'), sidecar);
298
+ };
299
+ vendor('nosidecar', null);
300
+ vendor('badlicence', '---\nlicence: GPL-3.0\nscanVerdict: clean\n---\n');
301
+ vendor('dangerous', '---\nlicence: MIT\nscanVerdict: dangerous\n---\n');
302
+ vendor('good', '---\nlicence: Apache-2.0\nscanVerdict: clean\n---\n');
303
+ mkdirSync(join(modDir, 'skills', 'evil'), { recursive: true });
304
+ writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['../evil', 'Bad.Name', 'nosidecar', 'badlicence', 'dangerous', 'good'] } }));
305
+ const srcs = m.moduleSkillSources({ coreRoot: core, instanceDir: inst });
306
+ assert.deepEqual(srcs.map((s) => s.name), ['good']);
307
+ assert.deepEqual(srcs.refused.map((r) => r.name).sort(), ['../evil', 'Bad.Name', 'badlicence', 'dangerous', 'nosidecar']);
308
+ assert.ok(srcs.refused.find((r) => r.name === 'dangerous').reason.includes('may not land'));
309
+ assert.ok(srcs.refused.find((r) => r.name === 'badlicence').reason.includes('allowlist'));
310
+ // the gate is the exported function the ui-design test pins too
311
+ assert.equal(m.vendoredSkillGate(join(modDir, 'skills', 'vendor', 'good')).ok, true);
312
+ assert.equal(m.vendoredSkillGate(join(modDir, 'skills', 'vendor', 'dangerous')).ok, false);
313
+ // a module's own skills/policy.json narrows the lists
314
+ writeFileSync(join(modDir, 'skills', 'policy.json'), JSON.stringify({ licenceAllowlist: ['MIT'], verdictsThatMayLand: ['clean'] }));
315
+ assert.deepEqual(m.moduleSkillSources({ coreRoot: core, instanceDir: inst }).map((s) => s.name), [], 'Apache-2.0 is off this module\'s narrower list');
316
+ // and materializeClaude reports every refusal, lands only the good one
317
+ rmSync(join(modDir, 'skills', 'policy.json'));
318
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
319
+ const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
320
+ assert.equal(res.moduleSkills, 2);
321
+ assert.ok(existsSync(join(inst, '.claude', 'skills', 'good', 'PROVENANCE.md')));
322
+ for (const bad of ['nosidecar', 'badlicence', 'dangerous', 'evil']) assert.ok(!existsSync(join(inst, '.claude', 'skills', bad)), `${bad} never lands`);
323
+ assert.equal(res.notes.filter((n) => n.includes('refused')).length, 5);
324
+ for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
325
+ });
326
+
244
327
  // ---- materializeClaude end-to-end: rebrand + skill-exclude (from the REAL repo) --
245
328
  t('materializeClaude: copied hooks carry NO founder origin/config-dir; a disabled-module skill is excluded', () => {
246
329
  if (!existsSync(join(ROOT, '.claude', 'hooks'))) return;
@@ -0,0 +1,217 @@
1
+ // tests/ui_design_kit.mjs — the ui-design kit (task 1003320, ADR 0197): the platform
2
+ // floors are a pinned contract; a states/probes file has a shape the kit refuses to
3
+ // guess at; the pure audit names what a bad render did; the mock checker keeps the
4
+ // platform's rules apart from an instance's taste bans; the instance's own pack
5
+ // clears the floor; every CLI exits with its documented code on a bad input; and the
6
+ // stub serves a surface through the doorway's real transforms. DB-free and
7
+ // browser-free — the renders themselves are the recipe's job, not CI's.
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const KIT = path.join(ROOT, 'modules', 'ui-design', 'kit');
20
+ const lib = require(path.join(KIT, 'lib.js'));
21
+ const mock = require(path.join(KIT, 'check-mock.js'));
22
+ const contrast = require(path.join(KIT, 'contrast.js'));
23
+
24
+ const run = (file, args, env = {}) => spawnSync(process.execPath, [path.join(KIT, file), ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 60000 });
25
+ const tmp = (name, text) => { const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ui-kit-test-')), name); fs.writeFileSync(p, text); return p; };
26
+ const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
27
+
28
+ // ── the contract ──────────────────────────────────────────────────────────────
29
+ test('the platform floors are a pinned list — a floor cannot vanish quietly', () => {
30
+ assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
31
+ assert.deepEqual(lib.EXIT, { CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
32
+ assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1440']);
33
+ });
34
+
35
+ test('the shipped states files validate and sit beside the page they name', () => {
36
+ for (const rel of ['public-landing/projects.states.json', 'public-landing/index.states.json', 'modules/hall-ui/public/ranks.states.json']) {
37
+ const json = readJson(rel);
38
+ const v = lib.validateStatesFile(json);
39
+ assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
40
+ assert.equal(path.basename(rel), `${lib.pageName(json.page)}.states.json`, `${rel} is named for its page`);
41
+ }
42
+ });
43
+
44
+ test('the shipped probes files validate', () => {
45
+ for (const rel of ['public-landing/projects.probes.json', 'public-landing/index.probes.json']) {
46
+ const v = lib.validateProbesFile(readJson(rel));
47
+ assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
48
+ }
49
+ });
50
+
51
+ test('a states file that breaks the contract is refused with the reason named', () => {
52
+ assert.equal(lib.validateStatesFile(null).ok, false);
53
+ const v = lib.validateStatesFile({});
54
+ assert.ok(v.errors.some((e) => e.startsWith('page: required')));
55
+ assert.ok(v.errors.some((e) => e.startsWith('states: required')));
56
+ const bad = lib.validateStatesFile({ page: '/x', states: { 'Bad Name': { actions: [['teleport', '#a']], expect: { glow: true } }, ok: { auth: 'yes' } } });
57
+ assert.ok(bad.errors.some((e) => /kebab-case/.test(e)));
58
+ assert.ok(bad.errors.some((e) => /unknown action "teleport"/.test(e)));
59
+ assert.ok(bad.errors.some((e) => /unknown expectation/.test(e) && /glow/.test(e)));
60
+ assert.ok(bad.errors.some((e) => /states\.ok\.auth/.test(e)));
61
+ const stub = lib.validateStatesFile({ page: '/x', stub: { prefix: 'builders' }, states: { a: {} } });
62
+ assert.ok(stub.errors.some((e) => /stub\.prefix/.test(e)));
63
+ });
64
+
65
+ test('a probes file needs a name and an expectation per probe, and cannot continue from nothing', () => {
66
+ const v = lib.validateProbesFile({ page: '/x', probes: [{ continue: true, steps: [] }, { name: 'n', steps: [['wait', 1]] }] });
67
+ assert.ok(v.errors.some((e) => /probes\[0\]\.name: required/.test(e)));
68
+ assert.ok(v.errors.some((e) => /probes\[0\]\.continue: the first probe/.test(e)));
69
+ assert.ok(v.errors.some((e) => /probes\[1\]\.expect: required/.test(e)));
70
+ assert.ok(lib.validateProbesFile({ page: '/x', probes: [{ name: 'n', steps: [], expect: { dialog: false } }] }).ok);
71
+ });
72
+
73
+ // ── the pure audit ────────────────────────────────────────────────────────────
74
+ const clean = () => ({ scrollWidth: 1440, clientWidth: 1440, mode: 'dark', headers: 1, mains: 1, h1s: 1, smallControls: [], bar: { h: 64, top: 0 }, expanded: [], dialog: false, running: 0, activeId: '', activeText: '', sel: {} });
75
+
76
+ test('auditFloors names each floor a render breaks, and says nothing about a clean one', () => {
77
+ assert.deepEqual(lib.auditFloors(clean(), { width: 1440, mode: 'dark' }), []);
78
+ const p = lib.auditFloors({ ...clean(), scrollWidth: 1500, h1s: 3, smallControls: ['a.x "Docs" 40x17'], bar: { h: 96 }, mode: 'light' }, { width: 1440, mode: 'dark' });
79
+ assert.ok(p.some((x) => x.startsWith('horizontal-overflow')));
80
+ assert.ok(p.some((x) => x.startsWith('one-header-main-h1') && /h1 3/.test(x)));
81
+ assert.ok(p.some((x) => x.startsWith('controls-24px') && /40x17/.test(x)));
82
+ assert.ok(p.some((x) => x.startsWith('top-bar-80px') && /96px/.test(x)));
83
+ assert.ok(p.some((x) => /data-mode "light" under a dark scheme/.test(x)));
84
+ assert.deepEqual(lib.auditFloors({ ...clean(), mode: null }, { mode: 'dark' }), [], 'a page that sets no data-mode is not held to one');
85
+ });
86
+
87
+ test('expectProblems compares every expectation the vocabulary allows', () => {
88
+ const facts = { ...clean(), expanded: ['projTrig'], dialog: true, sel: { '#a': true, '#b': false }, activeId: 'x', activeText: 'Sky', param: { view: 'mine' }, attr: ['dark'], text: ['Table'], href: ['/docs'], count: [3], checked: false, value: ['demo'], hasClass: [true] };
89
+ assert.deepEqual(lib.expectProblems(facts, { expanded: true, dialog: true, visible: ['#a'], hidden: ['#b'], open: '#a', closed: '#b', activeId: 'x', activeText: 'Sky', expandedIds: ['projTrig'], param: { view: 'mine' }, attr: [['html', 'data-mode', 'dark']], text: [['h1', 'Table']], href: [['a', '/docs']], count: [['li', 3]], value: [['#q', 'demo']], hasClass: [['#w', 'is-open']] }), []);
90
+ const p = lib.expectProblems(facts, { expanded: false, dialog: false, visible: ['#b'], hidden: ['#a'], activeText: 'Table', param: { view: null }, count: [['li', 5]], checked: '#c', hasClass: [['#w', 'is-open', false]], noOverflow: true });
91
+ for (const needle of ['expected no expanded trigger', 'expected no visible dialog', 'expected visible: #b', 'expected hidden: #a', 'active text "Sky", expected "Table"', '?view=mine, expected null', '3 × li, expected 5', '#c is not checked', 'still has class "is-open"']) {
92
+ assert.ok(p.some((x) => x.includes(needle)), `missing: ${needle} in ${p.join(' | ')}`);
93
+ }
94
+ });
95
+
96
+ test('reducedMotionProblems: a running animation or a rAF loop is a floor break', () => {
97
+ assert.deepEqual(lib.reducedMotionProblems({ running: [], frames: 1 }), []);
98
+ const p = lib.reducedMotionProblems({ running: ['breath'], frames: 40 });
99
+ assert.equal(p.length, 2);
100
+ assert.match(p[0], /breath/); assert.match(p[1], /requestAnimationFrame loop/);
101
+ });
102
+
103
+ test('the page argument survives Git Bash path conversion of a bare slash', () => {
104
+ assert.equal(lib.normalizePageArg('C:/Program Files/Git/'), '/');
105
+ assert.equal(lib.normalizePageArg('/projects'), '/projects');
106
+ assert.equal(lib.pageName('/'), 'index'); assert.equal(lib.pageName('/builders/ranks'), 'ranks'); assert.equal(lib.pageName('/projects?view=mine'), 'projects');
107
+ });
108
+
109
+ // ── the mock checker: platform rules vs the instance's taste ──────────────────
110
+ const GOOD_MOCK = `<!doctype html><html><head><style>
111
+ body{--pad:8px;color:var(--ink);background:var(--bg)}
112
+ :root[data-mode="dark"] body{--pad:9px}
113
+ @media (prefers-color-scheme: dark){:root:not([data-mode="light"]) body{--pad:9px}}
114
+ .a{transition:opacity 200ms}
115
+ </style></head><body><!-- THESIS: a mock that says what it claims, so the reviewer can check the render against the words -->
116
+ <a class="skip" href="#main">Skip</a><header><button aria-expanded="false" aria-controls="m">Menu</button></header>
117
+ <main id="main"><h1>Title</h1><p>Plain copy.</p><div id="m" hidden></div></main>
118
+ <script>const state = new URLSearchParams(location.search).get('state');</script></body></html>`;
119
+ const fails = (results) => results.filter((r) => r[0] === 'FAIL').map((r) => r[1]);
120
+
121
+ test('checkMock: a mock inside the contract passes every platform rule', () => {
122
+ assert.deepEqual(fails(mock.checkMock(GOOD_MOCK)), []);
123
+ });
124
+
125
+ test('checkMock: the platform rules catch a page :root, a literal colour, a pinned pack colour, JS motion', () => {
126
+ const f1 = fails(mock.checkMock(GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')));
127
+ assert.ok(f1.some((r) => r.startsWith('no page-local :root')), f1.join(' | '));
128
+ const f2 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#ff0000')));
129
+ assert.ok(f2.some((r) => r.startsWith('no literal hex colour')), f2.join(' | '));
130
+ const f3 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#fb9569'), { packColors: { accent: '#fb9569' } }));
131
+ assert.ok(f3.some((r) => r.startsWith('no pack colour pinned')), f3.join(' | '));
132
+ const f4 = fails(mock.checkMock(GOOD_MOCK.replace('<script>', '<script>requestAnimationFrame(() => {});')));
133
+ assert.ok(f4.some((r) => r.startsWith('no motion in JavaScript')), f4.join(' | '));
134
+ const f5 = fails(mock.checkMock(GOOD_MOCK.replace(':root[data-mode="dark"] body{--pad:9px}', ':root[data-mode="dark"] body{--pad:10px}')));
135
+ assert.ok(f5.some((r) => r.startsWith('every dark rule written twice')), f5.join(' | '));
136
+ });
137
+
138
+ test('checkMock: a taste ban applies only when the instance declared it', () => {
139
+ const dashed = GOOD_MOCK.replace('Plain copy.', 'Plain — copy.');
140
+ const withBan = mock.checkMock(dashed, { bans: new Set(['em-dash']) });
141
+ assert.ok(fails(withBan).some((r) => r.startsWith('taste: no em/en dash')));
142
+ const withoutBan = mock.checkMock(dashed, { bans: new Set() });
143
+ assert.deepEqual(fails(withoutBan), []);
144
+ assert.ok(!withoutBan.some((r) => r[1].startsWith('taste:')), 'no taste rule is even evaluated without a ban');
145
+ });
146
+
147
+ test('readDesignBans reads the Don\'t section — the live DESIGN.md declares the five, an empty file none', () => {
148
+ const live = mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'));
149
+ for (const k of ['em-dash', 'emoji', 'gradient-text', 'text-glow', 'box-shadow']) assert.ok(live.has(k), `live DESIGN.md bans ${k}`);
150
+ assert.equal(mock.readDesignBans('').size, 0);
151
+ assert.deepEqual([...mock.readDesignBans("## Do's and Don'ts\n\n### Do\n- Do use an em dash.\n\n### Don't\n- Don't use emoji.\n\n## Next\n")], ['emoji'], 'only the Don\'t section counts');
152
+ });
153
+
154
+ // ── the pack ──────────────────────────────────────────────────────────────────
155
+ test('the neutral pack clears the text floor on its own grounds (both pack shapes)', () => {
156
+ const filePack = readJson('config/branding.neutral.json');
157
+ const rows = contrast.packContrast(filePack);
158
+ const onGrounds = rows.filter((r) => r.bg === 'bg' || r.bg === 'bgCard');
159
+ assert.ok(onGrounds.length >= 6);
160
+ for (const r of onGrounds) assert.equal(r.ok, true, `${r.fg} on ${r.bg}: ${r.ratio} (need ${r.need})`);
161
+ const clientShape = { theme: filePack.theme.ui };
162
+ assert.deepEqual(contrast.packContrast(clientShape), rows, 'clientBranding() flattens theme.ui onto theme; same answer');
163
+ });
164
+
165
+ test('the WCAG maths: black on white is 21:1, alpha composites before it is measured', () => {
166
+ assert.equal(+lib.contrastRatio(lib.parseColor('#000'), lib.parseColor('#ffffff')).toFixed(1), 21);
167
+ const half = lib.composite(lib.parseColor('rgba(0,0,0,0.5)'), lib.parseColor('#fff'));
168
+ assert.ok(Math.abs(half[0] - 127.5) < 0.01);
169
+ assert.equal(lib.parseColor('nope'), null);
170
+ });
171
+
172
+ // ── the CLIs: exit codes are the contract a caller can script against ───────────
173
+ test('render.js: usage and a broken states file exit 2 with the reason, before any browser', () => {
174
+ const noArgs = run('render.js', []);
175
+ assert.equal(noArgs.status, lib.EXIT.USAGE); assert.match(noArgs.stderr, /--page/);
176
+ const missing = run('render.js', ['--states', path.join(os.tmpdir(), 'no-such.states.json')]);
177
+ assert.equal(missing.status, lib.EXIT.USAGE); assert.match(missing.stderr, /not found/);
178
+ const bad = run('render.js', ['--states', tmp('x.states.json', JSON.stringify({ page: 'projects', states: { a: { actions: [['fly']] } } }))]);
179
+ assert.equal(bad.status, lib.EXIT.USAGE);
180
+ assert.match(bad.stderr, /page: required/); assert.match(bad.stderr, /unknown action "fly"/);
181
+ const noSidecar = run('render.js', ['--page', '/nowhere']);
182
+ assert.equal(noSidecar.status, lib.EXIT.USAGE); assert.match(noSidecar.stderr, /no states file beside the surface/);
183
+ });
184
+
185
+ test('probe.js and check-mock.js: the same discipline', () => {
186
+ const bad = run('probe.js', ['--probes', tmp('x.probes.json', JSON.stringify({ page: '/', probes: [{ name: 'n', steps: [] }] }))]);
187
+ assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /expect: required/);
188
+ assert.equal(run('check-mock.js', []).status, lib.EXIT.USAGE);
189
+ const failing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')), '--no-taste']);
190
+ assert.equal(failing.status, lib.EXIT.PROBLEMS); assert.match(failing.stdout, /FAIL no page-local :root/);
191
+ const passing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK), '--no-taste']);
192
+ assert.equal(passing.status, lib.EXIT.CLEAN, passing.stdout); assert.match(passing.stdout, /ALL RULES PASS/);
193
+ });
194
+
195
+ test('serve.js refuses a surface that does not resolve, and serves one through the doorway transforms', async () => {
196
+ const bad = run('serve.js', [], { SURFACE: 'no-such-surface', STUB_PORT: '0' });
197
+ assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /neither a directory/);
198
+ for (const auth of [false, true]) {
199
+ const s = await lib.startStub({ surface: 'public-landing', auth });
200
+ try {
201
+ const html = await (await fetch(s.base + '/')).text();
202
+ assert.ok(html.includes('window.__BRANDING__'), 'the pack is injected');
203
+ assert.ok(/:root\s*\{/.test(html), 'the theme :root is spliced');
204
+ assert.ok(/\?v=[0-9a-f]{8}/.test(html), 'assets are content-stamped');
205
+ const me = await fetch(s.base + '/api/bongos/me');
206
+ assert.equal(me.status, auth ? 200 : 401);
207
+ if (auth) { const body = await me.json(); assert.ok(body.builder && body.builder.github_login, 'the real { builder } shape'); assert.match(body.builder.avatar_url, /^http:\/\/127\.0\.0\.1:\d+\/__kit\/avatar\.svg$/); }
208
+ assert.equal((await fetch(s.base + '/api/gds/public/progress')).status, 200, 'the public feed answers from a fixture');
209
+ assert.equal((await fetch(s.base + '/api/gds/nope')).status, 404);
210
+ } finally { s.stop(); }
211
+ }
212
+ const child = await lib.startStub({ surface: 'public-landing', child: true });
213
+ try {
214
+ const html = await (await fetch(child.base + '/')).text();
215
+ assert.equal((html.match(/<!--apex-only:start-->/g) || []).length, 0, 'the child shape strips the apex-only regions');
216
+ } finally { child.stop(); }
217
+ });