@bongos/core 1.24.55 → 1.24.57

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 (37) hide show
  1. package/.bongos-core.json +67 -37
  2. package/docs/copy-inventory.md +16 -16
  3. package/docs/copy-registry.json +17 -17
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-readings.json +18 -18
  6. package/modules/hall-ui/public/approval-queue.css +9 -9
  7. package/modules/hall-ui/public/atlas.css +5 -1
  8. package/modules/hall-ui/public/atlas.js +14 -11
  9. package/modules/hall-ui/public/city-draw.js +14 -14
  10. package/modules/hall-ui/public/sky-draw.js +24 -6
  11. package/modules/hall-ui/public/sky-interaction.js +1 -1
  12. package/modules/hall-ui/public/sky-panel.js +1 -1
  13. package/modules/hall-ui/public/studio.css +22 -22
  14. package/modules/hall-ui/public/thinking.css +46 -48
  15. package/modules/hall-ui/public/tweak-editor.css +18 -18
  16. package/modules/hall-ui/public/welcome-boot.css +5 -8
  17. package/modules/hall-ui/public/welcome-boot.js +11 -15
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/release-notes.json +12 -0
  21. package/scripts/bongos/css-literal-scan.js +144 -0
  22. package/scripts/bongos/design-generators-fresh.js +32 -0
  23. package/scripts/bongos/fitness-ratchets.js +70 -3
  24. package/scripts/bongos/fitness.js +3 -2
  25. package/scripts/gds/css-literal-scan.js +7 -0
  26. package/scripts/gds/design-generators-fresh.js +6 -0
  27. package/src/bongos/module-scope-map.js +1 -1
  28. package/src/module-api.js +1 -1
  29. package/tests/design_counts_ratchet.mjs +134 -0
  30. package/tests/hall_catalog_world.mjs +7 -2
  31. package/tests/hall_city.mjs +5 -4
  32. package/tests/hall_private_palettes.mjs +148 -0
  33. package/tests/hall_sky.mjs +29 -32
  34. package/tests/hall_street.mjs +3 -2
  35. package/tests/helpers.mjs +25 -1
  36. package/tests/welcome_boot.mjs +1 -1
  37. package/tests/welcome_boot_browser.mjs +1 -1
@@ -27,6 +27,13 @@
27
27
  // docs-entropy scanner's three counts, reused as a library
28
28
  // source_text_only_tests — test files that only read source text and match
29
29
  // its wording, never running the code (task 1004868)
30
+ // design counts — PER STYLESHEET, the typed-in colours, font sizes, radii,
31
+ // shadows and breakpoints (task 1005093, goal 1000138; detector:
32
+ // css-literal-scan.js). Kept in their own `design_counts` object rather than
33
+ // `metrics` — ~60 sheets × 5 kinds would drown the metric line — but with the
34
+ // same verdict: higher than baseline fails, lower nudges a ratchet-down. A
35
+ // sheet with no baseline row has a baseline of zero, so a NEW sheet starts on
36
+ // the --ds-* tokens.
30
37
  //
31
38
  // The knip dead-code ratchet needs node_modules + ~seconds, so it is NOT part
32
39
  // of the in-suite check: CI runs `node scripts/bongos/fitness-ratchets.js --knip`
@@ -535,6 +542,46 @@ function skillListingMetrics() {
535
542
  }
536
543
  }
537
544
 
545
+ // ---------- design counts (task 1005093) ----------
546
+
547
+ const DESIGN_HINT = ' — use a --ds-* scale token (modules/ui-design/public/tokens.css) or one of the fifteen colour '
548
+ + 'tokens instead of a typed-in value (DESIGN.md, the Fifteen Rule). Run '
549
+ + '`node scripts/bongos/css-literal-scan.js` to see every sheet\'s counts and the rule.';
550
+
551
+ // Current per-sheet counts, or null when the scan found no stylesheet at all —
552
+ // a collapsed enumeration is a scan defect, never a flattering all-zero tree.
553
+ function designCounts() {
554
+ const { scanCssLiterals } = require('./css-literal-scan.js');
555
+ const { sheets, scanned } = scanCssLiterals();
556
+ if (!scanned) {
557
+ process.stderr.write('fitness-ratchets: design counts NOT MEASURED — the stylesheet scan enumerated ZERO files, which is a scan defect, not a clean tree\n');
558
+ return null;
559
+ }
560
+ return sheets;
561
+ }
562
+
563
+ // Pure verdict over { sheet: { kind: n } } maps. Keys starting with `_` are notes.
564
+ function compareDesignCounts(current, baseline) {
565
+ const { KINDS } = require('./css-literal-scan.js');
566
+ const violations = [];
567
+ const warnings = [];
568
+ const sheets = new Set([...Object.keys(current || {}), ...Object.keys(baseline || {})].filter((k) => !k.startsWith('_')));
569
+ for (const sheet of [...sheets].sort()) {
570
+ const cur = (current || {})[sheet] || {};
571
+ const base = (baseline || {})[sheet] || {};
572
+ for (const kind of KINDS) {
573
+ const value = cur[kind] || 0;
574
+ const floor = base[kind] || 0;
575
+ if (value > floor) {
576
+ violations.push(`design count ${kind} in ${sheet} regressed: ${value} > baseline ${floor} — fix the regression; never raise the baseline to pass${DESIGN_HINT}`);
577
+ } else if (value < floor) {
578
+ warnings.push(`ratchet-down available: design count ${kind} in ${sheet} improved to ${value} (baseline ${floor}) — lower it in config/fitness-baselines.json design_counts`);
579
+ }
580
+ }
581
+ }
582
+ return { violations, warnings };
583
+ }
584
+
538
585
  function computeMetrics({ withDocs = true } = {}) {
539
586
  const files = trackedJsFiles();
540
587
  const contents = new Map(files.map((f) => [f, readRepoFile(f)]));
@@ -560,15 +607,28 @@ function computeMetrics({ withDocs = true } = {}) {
560
607
  // computeMetrics() sweep — git ls-files plus every tracked .js plus the
561
608
  // docs-entropy scan, measured at 23.7s + 2.5s + 2.8s, i.e. ~29s of this file's
562
609
  // 29s runtime, which is what pushed it into the 90s timeout in the first place.
563
- function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null } = {}) {
610
+ // `designCounts` (task 1005093) is injectable the same way; when a test injects
611
+ // `metrics` alone, the stylesheet scan is skipped rather than run for real.
612
+ function checkQualityRatchets({ baselinePath = BASELINE_PATH, metrics: injectedMetrics = null, designCounts: injectedDesign = null } = {}) {
564
613
  const metrics = injectedMetrics === null ? computeMetrics() : injectedMetrics;
565
- const { metrics: baselines, error: baselineError } = readBaselines(baselinePath);
614
+ const { metrics: baselines, error: baselineError, parsed } = readBaselines(baselinePath);
566
615
  const { violations, warnings } = compareToBaselines(metrics, baselines);
616
+ const design = injectedDesign !== null ? injectedDesign : (injectedMetrics === null ? designCounts() : null);
617
+ if (design) {
618
+ const designBaseline = parsed && parsed.design_counts;
619
+ if (!baselineError && (!designBaseline || typeof designBaseline !== 'object')) {
620
+ violations.push('config/fitness-baselines.json has no "design_counts" object — the per-stylesheet design ratchet cannot verify anything, so it fails CLOSED. Bootstrap deliberately: node scripts/bongos/fitness-ratchets.js --update-baselines');
621
+ } else if (designBaseline) {
622
+ const d = compareDesignCounts(design, designBaseline);
623
+ violations.push(...d.violations);
624
+ warnings.push(...d.warnings);
625
+ }
626
+ }
567
627
  // task 1003227: an unreadable baseline file is a hard failure, not silence. Put it
568
628
  // FIRST so it is the headline rather than buried under per-metric noise.
569
629
  if (baselineError) violations.unshift(baselineError);
570
630
  return {
571
- name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests only tighten (task 1003129)',
631
+ name: 'quality ratchets — size, duplication, cycles, console, doc entropy, legacy GDS literals, source-text-only tests, per-stylesheet design counts only tighten (task 1003129)',
572
632
  hardFail: violations.length > 0,
573
633
  violations,
574
634
  warnings,
@@ -598,6 +658,12 @@ function main() {
598
658
  // this file cannot be eaten by a refresh either.
599
659
  const preserved = existingFile && typeof existingFile === 'object' ? { ...existingFile } : {};
600
660
  delete preserved.metrics;
661
+ // design_counts is recomputed too (task 1005093), keeping its `_` notes.
662
+ const design = designCounts();
663
+ if (design) {
664
+ const notes = Object.fromEntries(Object.entries(preserved.design_counts || {}).filter(([k]) => k.startsWith('_')));
665
+ preserved.design_counts = { ...notes, ...design };
666
+ }
601
667
  if (!preserved._comment) {
602
668
  preserved._comment = 'fitness-ratchets baselines (task 1003129): quality debt recorded at gate-adoption. Shrink-only — lower a value when the metric improves; raising one to pass CI is out of policy.';
603
669
  }
@@ -647,6 +713,7 @@ module.exports = {
647
713
  readBaselines,
648
714
  computeMetrics,
649
715
  compareToBaselines,
716
+ compareDesignCounts,
650
717
  sourceTextTestMetrics,
651
718
  duplicateWindowCount,
652
719
  normalizeLines,
@@ -200,7 +200,7 @@ function checkNestedDocsPresent() {
200
200
  // here would false-fail every PR (the gitignored whole file is absent from a clean checkout, so it
201
201
  // always reads as "would change"); `--check-block` is the exact, non-noisy gate. (docs/file-map.md +
202
202
  // the OpenAPI docs are still fully committed and unchanged by ADR 0110 — they keep the plain --check.)
203
- function checkGeneratedArtifactsFresh() {
203
+ function checkGeneratedArtifactsFresh({ designRoot = REPO_ROOT } = {}) { // designRoot: tests only (task 1005093)
204
204
  const violations = [];
205
205
  try {
206
206
  execFileSync(process.execPath, [path.join(__dirname, 'gen-repo-map.js'), '--check-block'], { cwd: REPO_ROOT, stdio: 'pipe' });
@@ -254,9 +254,10 @@ function checkGeneratedArtifactsFresh() {
254
254
  } catch {
255
255
  violations.push('docs/page-inventory.json + docs/page-inventory.md are STALE (or two pages claim one id) — a page was added, renamed, retitled or changed its scripts/states. Run `node scripts/bongos/page-inventory.js` and commit (task 1004314).');
256
256
  }
257
+ violations.push(...require('./design-generators-fresh.js').checkDesignGeneratorsFresh(designRoot)); // task 1005093
257
258
  // NB the role packs' responsibility blocks are generated too, but their freshness
258
259
  // is asserted by role-pack-guard.js — one check owns "the packs are correct".
259
- return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
260
+ return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, the API spec + typed client GENERATE from the routes (both gitignored, task 1004866), docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556) and docs/page-inventory.json + page-inventory.md (every page and its stable id, task 1004314), and the design system config/design-tokens.neutral.json + public /brand reference (task 1005093). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
260
261
  }
261
262
 
262
263
  // ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/css-literal-scan.js — @bongos-forwarder (task 1004848). Generated by
3
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
4
+ // The real script is scripts/bongos/css-literal-scan.js. This forwarder keeps callers that still name
5
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
6
+ 'use strict';
7
+ require('../bongos/forwarder.js').forward(module, 'css-literal-scan.js');
@@ -0,0 +1,6 @@
1
+ // scripts/gds/design-generators-fresh.js — @bongos-forwarder (task 1004848). Generated by
2
+ // scripts/bongos/codemod-rename-scripts-gds.js; do not edit.
3
+ // The real script is scripts/bongos/design-generators-fresh.js. This forwarder keeps callers that still name
4
+ // the old path working, and warns once so they get repointed. Task 1003706 removes it.
5
+ 'use strict';
6
+ require('../bongos/forwarder.js').forward(module, 'design-generators-fresh.js');
@@ -473,7 +473,7 @@ const MODULE_GLOBS = {
473
473
  'scripts/bongos/adr-namespace.js', 'scripts/bongos/baseline-staleness.js',
474
474
  'scripts/bongos/category-advisory-guard.js', 'scripts/bongos/client-baseurl-guard.js',
475
475
  'scripts/bongos/doc-cli-guard.js', 'scripts/bongos/doc-control-claims.js', 'scripts/bongos/doc-comment-coverage.js',
476
- 'scripts/bongos/exec-path-guard.js', 'scripts/bongos/fitness-lib.js',
476
+ 'scripts/bongos/exec-path-guard.js', 'scripts/bongos/fitness-lib.js', 'scripts/bongos/design-generators-fresh.js',
477
477
  'scripts/bongos/fitness-checks-error-envelope.js', 'scripts/bongos/fitness-checks-identity.js',
478
478
  'scripts/bongos/fitness-checks-packaging.js', 'scripts/bongos/fitness-checks-route-pins.js',
479
479
  'scripts/bongos/fitness-checks-vocabulary.js', 'scripts/bongos/fitness-checks-write-validation.js',
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.55'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.57'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,134 @@
1
+ // tests/design_counts_ratchet.mjs — design counts that can only go down, and the
2
+ // design generators wired into the fitness freshness gate (task 1005093, goal 1000138).
3
+ //
4
+ // Done when: adding a new hard-coded font-size to a hall sheet fails fitness, and
5
+ // editing the shared token sheet without regenerating fails the freshness gate.
6
+ // Both are proved here on the real tree: the first by scanning the real sheets with
7
+ // one extra declaration, the second by running the gate on a copied tree.
8
+ //
9
+ // Run: node --test tests/design_counts_ratchet.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { createRequire } from 'node:module';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const scan = require(path.join(ROOT, 'scripts', 'bongos', 'css-literal-scan.js'));
22
+ const ratchets = require(path.join(ROOT, 'scripts', 'bongos', 'fitness-ratchets.js'));
23
+ const fitness = require(path.join(ROOT, 'scripts', 'bongos', 'fitness.js'));
24
+ const { checkDesignGeneratorsFresh } = require(path.join(ROOT, 'scripts', 'bongos', 'design-generators-fresh.js'));
25
+
26
+ const HALL_SHEET = 'modules/hall-ui/public/style.css';
27
+ const readReal = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
28
+
29
+ test('the detector counts typed-in values and leaves tokens alone', () => {
30
+ const c = scan.countSheet(`
31
+ :root { --ds-x: 14px; --tone: #123456; } /* token definitions: not counted */
32
+ .a { font-size: 13px; border-radius: 6px; color: #abc; }
33
+ .b { font-size: var(--ds-text-sm); border-radius: var(--ds-radius-tile); color: var(--ink); }
34
+ .c { box-shadow: 0 2px 4px rgba(0, 0, 0, .2); } /* a shadow; the black scrim is allowed */
35
+ .d { box-shadow: none; background: url("data:image/svg+xml;fill='#fff'"); }
36
+ .e { background: color-mix(in srgb, var(--ink) 40%, rgb(12 34 56)); padding: 0; }
37
+ @media (max-width: 720px) { .f { border-radius: 0; } }
38
+ @media (prefers-reduced-motion: reduce) { .g { transition: none; } }
39
+ `);
40
+ assert.deepEqual(c, { colours: 2, font_sizes: 1, radii: 1, shadows: 1, breakpoints: 1 });
41
+ });
42
+
43
+ test('a selector with a colon is not read as a declaration', () => {
44
+ assert.equal(scan.countSheet('a:hover, b:focus-visible { color: var(--ink); }').colours, 0);
45
+ });
46
+
47
+ test('the real tree matches its committed design-count baselines', () => {
48
+ const { sheets, scanned } = scan.scanCssLiterals();
49
+ assert.ok(scanned > 40, `the scan should see the live stylesheets (saw ${scanned})`);
50
+ const r = ratchets.checkQualityRatchets({ metrics: {}, designCounts: sheets });
51
+ assert.deepEqual(r.violations, [], 'design counts regressed against config/fitness-baselines.json');
52
+ });
53
+
54
+ test('a NEW hard-coded font-size in a hall sheet fails fitness', () => {
55
+ const { sheets } = scan.scanCssLiterals({
56
+ read: (f) => readReal(f) + (f === HALL_SHEET ? '\n.planted { font-size: 13px; }\n' : ''),
57
+ });
58
+ const r = ratchets.checkQualityRatchets({ metrics: {}, designCounts: sheets });
59
+ assert.equal(r.hardFail, true);
60
+ assert.ok(r.violations.some((v) => v.includes(`font_sizes in ${HALL_SHEET} regressed`)), r.violations.join('\n'));
61
+ });
62
+
63
+ test('a new sheet starts at zero: any typed-in colour in it fails', () => {
64
+ const d = ratchets.compareDesignCounts({ 'modules/x/public/new.css': { colours: 1 } }, { _comment: 'note' });
65
+ assert.equal(d.violations.length, 1);
66
+ assert.match(d.violations[0], /colours in modules\/x\/public\/new\.css regressed: 1 > baseline 0/);
67
+ });
68
+
69
+ test('a lower count nudges a ratchet-down and does not fail', () => {
70
+ const d = ratchets.compareDesignCounts({ 'a.css': { radii: 2 } }, { 'a.css': { radii: 3 } });
71
+ assert.deepEqual(d.violations, []);
72
+ assert.match(d.warnings[0], /ratchet-down available: design count radii in a\.css improved to 2/);
73
+ });
74
+
75
+ test('a baseline file with no design_counts fails closed', () => {
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-counts-'));
77
+ const file = path.join(dir, 'baselines.json');
78
+ fs.writeFileSync(file, JSON.stringify({ metrics: {} }));
79
+ try {
80
+ const r = ratchets.checkQualityRatchets({ baselinePath: file, metrics: {}, designCounts: {} });
81
+ assert.equal(r.hardFail, true);
82
+ assert.match(r.violations[0], /no "design_counts" object/);
83
+ } finally {
84
+ fs.rmSync(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ // The generators resolve their sources from their own location, so a copy of the
89
+ // files they read is a complete, isolated tree the gate can run against.
90
+ const GENERATOR_SOURCES = [
91
+ 'config/branding.neutral.json',
92
+ 'config/design-tokens.neutral.json',
93
+ 'modules/ui-design/public/tokens.css',
94
+ 'modules/ui-design/public/brand.html',
95
+ 'modules/ui-design/public/brand',
96
+ 'modules/ui-design/scripts/gen-design-tokens.js',
97
+ 'modules/ui-design/scripts/gen-design-reference.js',
98
+ 'modules/hall-ui/public/style.css',
99
+ 'modules/hall-ui/public/controls.css',
100
+ 'modules/hall-ui/public/dom-utils.js',
101
+ 'scripts/bongos/gen-brand.js',
102
+ 'DESIGN.md',
103
+ ];
104
+
105
+ function copiedTree() {
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-fresh-'));
107
+ for (const rel of GENERATOR_SOURCES) {
108
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
109
+ fs.cpSync(path.join(ROOT, rel), path.join(dir, rel), { recursive: true });
110
+ }
111
+ return dir;
112
+ }
113
+
114
+ test('the freshness gate passes on a fresh tree and fails when the token sheet changes without a regeneration', () => {
115
+ const dir = copiedTree();
116
+ try {
117
+ assert.deepEqual(checkDesignGeneratorsFresh(dir), [], 'the copied tree should start fresh');
118
+ const sheet = path.join(dir, 'modules/ui-design/public/tokens.css');
119
+ const before = fs.readFileSync(sheet, 'utf8');
120
+ const after = before.replace(/--ds-space-xs:\s*[^;]+;/, '--ds-space-xs: 11px;');
121
+ assert.notEqual(after, before, 'the token sheet should declare --ds-space-xs');
122
+ fs.writeFileSync(sheet, after);
123
+ const v = checkDesignGeneratorsFresh(dir);
124
+ assert.ok(v.some((x) => x.startsWith('config/design-tokens.neutral.json is STALE')), v.join('\n'));
125
+ assert.ok(v.some((x) => x.startsWith('modules/ui-design/public/brand.html')), v.join('\n'));
126
+ // …and the registered fitness check carries that verdict, so the build goes red.
127
+ assert.ok(fitness.CHECKS.includes(fitness.checkGeneratedArtifactsFresh));
128
+ const gate = fitness.checkGeneratedArtifactsFresh({ designRoot: dir });
129
+ assert.equal(gate.hardFail, true);
130
+ assert.ok(gate.violations.some((x) => x.startsWith('config/design-tokens.neutral.json is STALE')), gate.violations.join('\n'));
131
+ } finally {
132
+ fs.rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ });
@@ -168,10 +168,15 @@ test('the shell no longer aliases .mod-chip onto the pill', () => {
168
168
 
169
169
  test('atlas: the nine semantic colours stay fixed literals, and nothing else in the sheet is one', () => {
170
170
  const css = read('atlas.css');
171
- for (const t of ['--ab-sun', '--ab-sky', '--ab-coral', '--ab-paper', '--ab-good', '--ab-core', '--ab-db', '--ab-ghost', '--ab-danger']) {
171
+ for (const t of ['--ab-sun', '--ab-sky', '--ab-coral', '--ab-paper', '--ab-ghost']) {
172
172
  assert.match(css, new RegExp(`${t}:#[0-9a-f]{6}`), `${t} is a fixed literal (ADR 0094)`);
173
173
  }
174
- const rest = decomment(css).replace(/--ab-(sun|sky|coral|paper|good|core|db|ghost|danger):#[0-9a-f]{6}/g, '');
174
+ // task 1005087: the four STATE colours are the hall's status vocabulary — fixed per instance
175
+ // (never the pack's), so ADR 0094's no-scramble invariant still holds, and no longer Tailwind's.
176
+ for (const [t, tok] of [['--ab-good', '--ok'], ['--ab-core', '--warn'], ['--ab-db', '--danger'], ['--ab-danger', '--danger']]) {
177
+ assert.match(css, new RegExp(`${t}:var\\(${tok}\\)`), `${t} is the hall's ${tok}`);
178
+ }
179
+ const rest = decomment(css).replace(/--ab-(sun|sky|coral|paper|ghost):#[0-9a-f]{6}/g, '');
175
180
  assert.deepEqual(rest.match(/#[0-9a-f]{3,8}\b/gi) || [], [], 'no other literal colour');
176
181
  noAlias(css, 'atlas.css');
177
182
  tokensDefined(css, 'atlas.css');
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import vm from 'node:vm';
26
26
  import { createRequire } from 'node:module';
27
27
  import { fileURLToPath } from 'node:url';
28
+ import { cityTokens } from './helpers.mjs';
28
29
 
29
30
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
30
31
  const PUB = path.join(ROOT, 'modules/hall-ui/public');
@@ -66,13 +67,13 @@ test('the nine civic recipes draw exactly the prototype paths, in both modes', (
66
67
  const a = []; const b = [];
67
68
  p.isoInstitution(a, pk, kind, x, y, { fixes: false });
68
69
  City.isoInstitution(b, ck, kind, x, y, { fixes: false });
69
- assert.deepEqual(b, a, `${mode} ${kind}`);
70
+ assert.deepEqual(b, cityTokens(a), `${mode} ${kind}`);
70
71
  }
71
72
  }
72
73
  });
73
74
 
74
75
  test('the iso sub-palette is the prototype theme().iso, both modes', () => {
75
- for (const mode of ['dark', 'light']) assert.deepEqual(City.isoTheme(mode), new Proto({ mode }).theme(mode).iso, mode);
76
+ for (const mode of ['dark', 'light']) assert.deepEqual(City.isoTheme(mode), cityTokens(new Proto({ mode }).theme(mode).iso), mode);
76
77
  });
77
78
 
78
79
  test('every work kind the prototype draws is drawn the same way', () => {
@@ -83,7 +84,7 @@ test('every work kind the prototype draws is drawn the same way', () => {
83
84
  const a = []; const b = [];
84
85
  p.isoWork(a, pk, 6, 2, kind, 58);
85
86
  City.isoWork(b, ck, 6, 2, kind, 58);
86
- assert.deepEqual(b, a, kind);
87
+ assert.deepEqual(b, cityTokens(a), kind);
87
88
  }
88
89
  });
89
90
 
@@ -95,7 +96,7 @@ test('the ground, ring road, lanes and plaza are the prototype home view, path f
95
96
  const home = p.homeView(T, p.matters(), false);
96
97
  const model = City.cityModel({ docket: docket([matter()]), goals: [], rollups: [], mode: 'light' });
97
98
  // The prototype's flat list is ground, 4 roads, lanes, plaza, then its 4 plots and their grid.
98
- assert.deepEqual(model.flat.slice(0, 7), home.flat.slice(0, 7));
99
+ assert.deepEqual(model.flat.slice(0, 7), cityTokens(home.flat.slice(0, 7)));
99
100
  });
100
101
 
101
102
  test('with four goals the districts sit on the prototype\'s four plots, in its order', () => {
@@ -0,0 +1,148 @@
1
+ // tests/hall_private_palettes.mjs — the hall's private-palette rooms mix the shared tokens only
2
+ // (task 1005087, goal 1000138 "One Cloud Bongos design system").
3
+ //
4
+ // The UI audit found Studio, Thinking (the sky), the Tweak editor, City, Approvals, Atlas and the
5
+ // welcome screen each re-typing the salmon and the ink, their own font stacks, and off-the-shelf
6
+ // green / amber / red — so a re-branded instance saw those rooms in Cloud Bongos's colours. This
7
+ // scans every sheet and script of those rooms (comments stripped) and fails on:
8
+ // * a hex, an rgb()/rgba() or a bare [r, g, b] triple equal to one of the pack's colours, the
9
+ // hall's dark-tier inks, the accent's light tint, or an off-the-shelf status green/amber/red;
10
+ // * a font family typed in by name instead of the pack's stacks;
11
+ // * an overshooting (bounce) easing;
12
+ // * the welcome screen's beacon or star twinkle coming back.
13
+ // Black and white (with or without alpha) stay legal: they are scrims, halos and the glass rooms'
14
+ // inks. Each guard was watched go red on the pre-task sheets.
15
+ import { test, after } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const kit = require('../modules/ui-design/kit/lib.js');
24
+
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const PUB = path.join(ROOT, 'modules', 'hall-ui', 'public');
27
+ const ROOMS = /^(studio|thinking|sky-|tweak-editor|city|approval-queue|atlas|welcome-boot)/;
28
+ const FILES = fs.readdirSync(PUB).filter((f) => ROOMS.test(f) && /\.(css|js|html)$/.test(f));
29
+
30
+ const strip = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/<!--[\s\S]*?-->/g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1');
31
+
32
+ const PACK = Object.values(JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'branding.neutral.json'), 'utf8')).theme.ui.colors);
33
+ const BRAND = new Set([...PACK,
34
+ // the hall's dark-tier inks and grounds, and the accent's light tint the glass rooms used for links
35
+ '#101012', '#08080a', '#c0c0c0', '#a8a8a8', '#fcbfa4', '#96410f',
36
+ // off-the-shelf status green / amber / red (Tailwind's, and the rooms' own copies of them)
37
+ '#10b981', '#22c55e', '#4ade80', '#16a34a', '#15803d', '#166534', '#1f7a44',
38
+ '#f59e0b', '#fbbf24', '#facc15', '#d97706', '#b45309', '#92400e', '#8f6a0c',
39
+ '#ef4444', '#dc2626', '#b91c1c', '#991b1b', '#f87171', '#bb1f1f',
40
+ ].map((h) => h.toLowerCase()));
41
+ const tripleOf = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(',');
42
+ const TRIPLES = new Set([...BRAND].map(tripleOf));
43
+
44
+ function brandLiterals(src) {
45
+ const s = strip(src);
46
+ const hits = [];
47
+ for (const m of s.matchAll(/#[0-9a-f]{6}\b/gi)) if (BRAND.has(m[0].toLowerCase())) hits.push(m[0]);
48
+ for (const m of s.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/gi)) if (TRIPLES.has(`${+m[1]},${+m[2]},${+m[3]}`)) hits.push(m[0]);
49
+ for (const m of s.matchAll(/\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]/g)) if (TRIPLES.has(`${+m[1]},${+m[2]},${+m[3]}`)) hits.push(m[0]);
50
+ for (const m of s.matchAll(/--[a-z-]+-rgb\s*:\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/gi)) if (TRIPLES.has(`${+m[1]},${+m[2]},${+m[3]}`)) hits.push(m[0]);
51
+ return hits;
52
+ }
53
+
54
+ test('the rooms are all here (the scan is not vacuous)', () => {
55
+ for (const f of ['studio.css', 'thinking.css', 'sky-draw.js', 'tweak-editor.css', 'city.css', 'city-draw.js', 'approval-queue.css', 'atlas.css', 'atlas.js', 'welcome-boot.css', 'welcome-boot.js']) {
56
+ assert.ok(FILES.includes(f), `${f} is scanned`);
57
+ }
58
+ });
59
+
60
+ test('no hex, rgb or [r, g, b] literal for a brand colour or an off-the-shelf status colour remains', () => {
61
+ const found = {};
62
+ for (const f of FILES) { const h = brandLiterals(fs.readFileSync(path.join(PUB, f), 'utf8')); if (h.length) found[f] = [...new Set(h)]; }
63
+ assert.deepEqual(found, {}, 'mix the shared tokens (the pack\'s, the status vocabulary) instead');
64
+ });
65
+
66
+ test('the scan catches each shape of brand literal, and spares black, white and comments', () => {
67
+ assert.deepEqual(brandLiterals('a { color: #FB9569; }'), ['#FB9569']);
68
+ assert.deepEqual(brandLiterals('a { color: rgba(23, 24, 26, .4); }'), ['rgba(23, 24, 26']);
69
+ assert.deepEqual(brandLiterals('const ACC = [251, 149, 105];'), ['[251, 149, 105]']);
70
+ assert.deepEqual(brandLiterals('--sky-accent-rgb: 251,149,105;'), ['--sky-accent-rgb: 251,149,105']);
71
+ assert.deepEqual(brandLiterals("dot: '#f59e0b'"), ['#f59e0b']);
72
+ assert.deepEqual(brandLiterals('/* was #fb9569 */ a { color: rgba(0, 0, 0, .5); background: #ffffff; }'), []);
73
+ });
74
+
75
+ test('no room types a font family by name: the pack\'s stacks only', () => {
76
+ const hits = [];
77
+ for (const f of FILES.filter((n) => /\.(css|js)$/.test(n))) {
78
+ const s = strip(fs.readFileSync(path.join(PUB, f), 'utf8'));
79
+ if (/Manrope|JetBrains Mono|Segoe UI|Helvetica|Arial\b/.test(s)) hits.push(f);
80
+ }
81
+ assert.deepEqual(hits, [], 'use var(--font-body) / var(--font-display) / var(--font-mono)');
82
+ });
83
+
84
+ test('no bounce: no easing in the rooms overshoots (use --ds-ease / --ds-ease-arrive)', () => {
85
+ const hits = [];
86
+ for (const f of FILES) {
87
+ const s = strip(fs.readFileSync(path.join(PUB, f), 'utf8'));
88
+ for (const m of s.matchAll(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/g)) {
89
+ if (+m[2] > 1 || +m[4] > 1 || +m[2] < 0 || +m[4] < 0) hits.push(`${f}: ${m[0]}`);
90
+ }
91
+ }
92
+ assert.deepEqual(hits, []);
93
+ });
94
+
95
+ test('the welcome screen keeps no beacon and no twinkle', () => {
96
+ const css = strip(fs.readFileSync(path.join(PUB, 'welcome-boot.css'), 'utf8'));
97
+ const js = strip(fs.readFileSync(path.join(PUB, 'welcome-boot.js'), 'utf8'));
98
+ assert.doesNotMatch(css, /wb-beacon|wb-twinkle/, 'no beacon blink, no star twinkle in the sheet');
99
+ assert.doesNotMatch(js, /wb-beacon/, 'the script draws no beacon');
100
+ assert.doesNotMatch(js, /\.wb-star[^]*animation-duration/, 'no star carries a twinkle timing');
101
+ });
102
+
103
+ // ── the done-when, in a browser: a re-branded look renders the rooms in its own colours ──
104
+ // The ui-design kit's stub serves the hall under a look from the style library (grove: a
105
+ // sand accent), and every computed colour on each room is checked for the neutral pack's
106
+ // salmon. Skips when the machine has no system Edge or Chrome, as ui_design_kit_fit does.
107
+ let browser = null;
108
+ try { browser = await kit.launchBrowser(); } catch (e) { if (!(e instanceof kit.NoBrowserError)) throw e; }
109
+ after(() => browser && browser.close());
110
+
111
+ test('under the grove look, Thinking, the Tweak editor, Atlas, City and Studio show no salmon in either mode', { skip: !browser && 'no system Edge or Chrome', timeout: 180000 }, async () => {
112
+ const stub = await kit.startStub({ surface: 'hall-ui', prefix: '/builders/', style: 'grove', auth: true });
113
+ try {
114
+ for (const mode of ['dark', 'light']) {
115
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 860 }, colorScheme: mode });
116
+ await ctx.addInitScript(() => { try { sessionStorage.setItem('hall-craft-asked', '1'); } catch (e) { /* private mode */ } });
117
+ for (const room of ['thinking', 'tweak-editor', 'atlas', 'city', 'studio']) {
118
+ const page = await ctx.newPage();
119
+ await page.goto(`${stub.base}/builders/${room}`, { waitUntil: 'load' });
120
+ await page.waitForTimeout(800);
121
+ const r = await page.evaluate(() => {
122
+ const SALMON = /\b251,\s*149,\s*105\b/;
123
+ const hits = [];
124
+ for (const el of document.querySelectorAll('*')) {
125
+ const cs = getComputedStyle(el);
126
+ for (const k of ['color', 'backgroundColor', 'borderTopColor', 'fill', 'stroke', 'outlineColor', 'boxShadow', 'backgroundImage']) {
127
+ if (SALMON.test(cs[k] || '')) hits.push(`${el.tagName.toLowerCase()} ${k}`);
128
+ }
129
+ }
130
+ return { accent: getComputedStyle(document.documentElement).getPropertyValue('--accent').trim().toLowerCase(), hits };
131
+ });
132
+ await page.close();
133
+ assert.notEqual(r.accent, '#fb9569', `${mode} ${room}: the look's accent is applied`);
134
+ assert.deepEqual(r.hits, [], `${mode} ${room}: no salmon left`);
135
+ }
136
+ await ctx.close();
137
+ }
138
+ } finally { stub.stop(); }
139
+ });
140
+
141
+ test('the sky reads its colours off the stage as tokens, not channels', () => {
142
+ const css = strip(fs.readFileSync(path.join(PUB, 'thinking.css'), 'utf8'));
143
+ assert.doesNotMatch(css, /--sky-[a-z]+-rgb/, 'no rgb channel tokens');
144
+ assert.match(css, /--void:\s*var\(--bg\)/, 'the void is the hall\'s ground');
145
+ assert.match(css, /--accent:\s*var\(--accent-ink\)/, 'the accent is the pack\'s (darkened on paper by the hall\'s own rule)');
146
+ const draw = fs.readFileSync(path.join(PUB, 'sky-draw.js'), 'utf8');
147
+ for (const tok of ['--ink', '--void', '--sky-cold', '--accent', '--chart']) assert.match(draw, new RegExp(`fill\\('${tok}'`), `sky-draw reads ${tok}`);
148
+ });
@@ -559,65 +559,62 @@ const skyBlocks = () => {
559
559
  return { dark: dark && dark[1], light: light && light[1], twin: twin && twin[1] };
560
560
  };
561
561
 
562
+ // What the stage's computed style answers in each theme: the two blocks above,
563
+ // read the way the browser would.
564
+ const stageStyles = (body) => Object.fromEntries([...body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
565
+
562
566
  await test('LIGHT IS WRITTEN TWICE AND THE TWO ARE ONE — the data-theme block and its first-paint twin declare the same lines', () => {
563
567
  const { dark, light, twin } = skyBlocks();
564
- assert.ok(dark, 'the dark (template) token block is found');
568
+ assert.ok(dark, 'the stage token block is found');
565
569
  assert.ok(light, 'the html[data-theme="light"] block is found');
566
570
  assert.ok(twin, 'the prefers-color-scheme: light twin is found');
567
571
  same(declLines(twin), declLines(light), 'the twin is the data-theme block, line for line');
568
- assert.ok(declLines(light).length >= 6, 'and the block is not vacuously empty');
569
- });
570
-
571
- await test('light re-declares every colour the dark set declares, and every channel is three bytes', () => {
572
- const { dark, light } = skyBlocks();
573
- const type = new Set(['--label', '--mono', '--face']); // the faces are not a theme
574
- const want = [...declNames(dark)].filter((n) => !type.has(n));
575
- assert.ok(want.length >= 15, `the dark set names its colours (${want.length})`);
576
- const have = declNames(light);
577
- same(want.filter((n) => !have.has(n)), [], 'no dark colour is left for light to inherit');
578
- for (const [name, body] of [['dark', dark], ['light', light]]) {
579
- const chans = [...body.matchAll(/(--sky-[\w-]+-rgb)\s*:\s*([^;]+);/g)];
580
- assert.ok(chans.length >= 5, `${name} declares the canvas's channels`);
581
- for (const [, n, v] of chans) {
582
- const parts = v.split(',').map((x) => Number(x.trim()));
583
- assert.ok(parts.length === 3 && parts.every((x) => Number.isInteger(x) && x >= 0 && x <= 255), `${name} ${n} is r,g,b (got "${v}")`);
584
- }
585
- }
572
+ same([...declNames(light)].sort(), ['--sky-drop', '--sky-temp', 'color-scheme'], 'and it carries only what the hall\'s own tier cannot: the card shadow and the canvas warmth');
586
573
  });
587
574
 
588
- // What the stage's computed style answers in each theme: the two blocks above,
589
- // read the way the browser would.
590
- const stageStyles = (body) => Object.fromEntries([...body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));
575
+ // task 1005087: the sky's colours are the HALL's tokens, which already flip with the theme and
576
+ // follow the pack, so a re-branded instance's sky wears its colours. No literal, no rgb channel.
577
+ await test('the stage mixes the hall\'s tokens only: every colour is a var() or a color-mix of them', () => {
578
+ const { dark } = skyBlocks();
579
+ const type = new Set(['--label', '--mono', '--face', '--sky-temp', '--sky-drop']);
580
+ const colours = Object.entries(stageStyles(dark)).filter(([n]) => !type.has(n));
581
+ assert.ok(colours.length >= 10, `the stage names its colours (${colours.length})`);
582
+ for (const [n, v] of colours) assert.match(v, /^(var\(--[\w-]+\)|color-mix\(in srgb, var\(--[\w-]+\) [\d.]+%, (transparent|var\(--[\w-]+\))\))$/, `${n}: ${v}`);
583
+ assert.doesNotMatch(SKY_CSS.replace(/\/\*[\s\S]*?\*\//g, ''), /--sky-[\w-]+-rgb/, 'no rgb channel is left');
584
+ });
585
+
586
+ // The stage's computed colours in each theme, as the browser answers the probe sky-draw.js reads.
587
+ const DARK = { '--ink': 'rgb(255, 255, 255)', '--void': 'rgb(0, 0, 0)', '--sky-cold': 'color(srgb 0.08 0.08 0.08)', '--accent': 'rgb(251, 149, 105)', '--chart': 'rgb(251, 191, 36)', '--sky-temp': '1' };
588
+ const LIGHT = { '--ink': 'rgb(23, 24, 26)', '--void': 'rgb(237, 236, 238)', '--sky-cold': 'color(srgb 0.88 0.88 0.89)', '--accent': 'rgb(163, 72, 26)', '--chart': 'rgb(180, 83, 9)', '--sky-temp': '.42' };
591
589
 
592
590
  await test('THE CANVAS FOLLOWS THE TOGGLE — a theme flip re-reads the stage and repaints, with no reload', async () => {
593
- const { dark, light } = skyBlocks();
594
- const styles = stageStyles(dark);
591
+ const styles = { ...DARK };
595
592
  const p = await boot({ mine: mine(FIVE()), sky: sky([well()]), versions: versions(), styles });
596
593
  const SKY = p.SKY(); const acc = SKY.ACC;
597
594
  assert.ok(p.themeObservers() >= 1, 'the page watches html[data-theme]');
598
595
  p.frame();
599
- same(SKY.INK, [255, 255, 255], 'dark: the ink is the template white');
596
+ same(SKY.INK, [255, 255, 255], 'dark: the ink is the hall\'s white');
600
597
  same(SKY.VOID, [0, 0, 0], 'dark: the void is black');
601
598
  same(SKY.tone([255, 238, 222]), [255, 238, 222], 'dark: a thought keeps its temperature');
602
599
 
603
- Object.assign(styles, stageStyles(light));
600
+ Object.assign(styles, LIGHT);
604
601
  p.S().dirty = false;
605
602
  p.setTheme('light');
606
603
  assert.equal(!!p.S().dirty, true, 'the flip marks the sky for a redraw');
607
604
  p.frame();
608
- same(SKY.INK, stageStyles(light)['--sky-ink-rgb'].split(',').map(Number), 'light: the ink is the stage\'s');
609
- same(SKY.VOID, stageStyles(light)['--sky-void-rgb'].split(',').map(Number), 'light: the void is paper');
610
- same(SKY.ACC, stageStyles(light)['--sky-accent-rgb'].split(',').map(Number), 'light: the accent darkens');
605
+ same(SKY.INK, [23, 24, 26], 'light: the ink is the stage\'s');
606
+ same(SKY.VOID, [237, 236, 238], 'light: the void is paper');
607
+ same(SKY.ACC, [163, 72, 26], 'light: the accent darkens');
611
608
  assert.equal(SKY.ACC === acc, true, 'refilled in place, so SKY.ACC is still the object every file holds');
612
- const k = Number(stageStyles(light)['--sky-temp']);
613
- same(SKY.tone([255, 238, 222]), [255, 238, 222].map((v) => Math.round(v * k)), 'light: a near-white thought darkens to read on paper');
609
+ same(SKY.tone([255, 238, 222]), [255, 238, 222].map((v) => Math.round(v * 0.42)), 'light: a near-white thought darkens to read on paper');
614
610
  assert.equal(SKY.ink(0.5), `rgba(${SKY.INK.join(',')},0.5)`, 'ink() paints in the new ink');
615
611
 
616
- Object.assign(styles, stageStyles(dark));
612
+ Object.assign(styles, DARK);
617
613
  p.setTheme('dark');
618
614
  p.frame();
619
615
  same(SKY.INK, [255, 255, 255], 'and back: the white returns');
620
616
  assert.equal(SKY.ink(0.5), 'rgba(255,255,255,0.5)', 'ink() forgot the light strings');
621
617
  });
622
618
 
619
+
623
620
  summary();