@bongos/core 1.19.89 → 1.19.91

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.
@@ -57,17 +57,23 @@ const { branding, clientBranding } = require('../src/branding');
57
57
  // with src/bongos/serve-internal.js applyBrandTokens. `brand` is the client-safe pack
58
58
  // and `publicOrigin` the resolved origin — both injected, never read ambiently, so the
59
59
  // transform stays pure and testable.
60
- function brandTokens(html, brand = {}, publicOrigin = '') {
60
+ function brandTokens(html, brand = {}, publicOrigin = '', buildersOrigin = '') {
61
61
  const world = (brand.identity && brand.identity.worldName) || '';
62
62
  const product = (brand.identity && brand.identity.productName) || '';
63
+ const tagline = (brand.copy && brand.copy.tagline) || '';
63
64
  const cur = (brand.currency && brand.currency.label) || 'credits';
64
65
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
66
+ const esc = (s) => String(s == null ? '' : s)
67
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
68
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
65
69
  return html
66
- .split('{{worldName}}').join(world)
67
- .split('{{productName}}').join(product)
68
- .split('{{Currency}}').join(Cur)
69
- .split('{{currency}}').join(cur)
70
- .split('{{publicOrigin}}').join(publicOrigin || '');
70
+ .split('{{worldName}}').join(esc(world))
71
+ .split('{{productName}}').join(esc(product))
72
+ .split('{{Currency}}').join(esc(Cur))
73
+ .split('{{currency}}').join(esc(cur))
74
+ .split('{{tagline}}').join(esc(tagline))
75
+ .split('{{publicOrigin}}').join(esc(publicOrigin || ''))
76
+ .split('{{buildersOrigin}}').join(esc(buildersOrigin || publicOrigin || ''));
71
77
  }
72
78
 
73
79
  // ---- the three off-box index.html injections -------------------------------
@@ -153,21 +153,45 @@ function injectBrandingGlobal(html, brand = clientBranding(), mods = clientModul
153
153
  // stays brand-free (the fitness identity gate). Runs BEFORE the __BRANDING__
154
154
  // script is inserted; the injected JSON holds resolved values (never tokens), so
155
155
  // order is immaterial. Tokens: {{worldName}} {{productName}} {{currency}}
156
- // (lowercase label) {{Currency}} (Capitalized) {{publicOrigin}} (the apex origin,
157
- // for the one functional cross-instance link — sourced from the FULL pack's
158
- // domains, which clientBranding deliberately omits). Pure + exported for the test;
159
- // `origin` is injectable so the test drives it with zero I/O.
160
- function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin()) {
156
+ // (lowercase label) {{Currency}} (Capitalized) {{tagline}} (the pack's one-line
157
+ // pitch), plus the two ORIGINS — {{publicOrigin}} (this instance's apex) and
158
+ // {{buildersOrigin}} (its hall). Both origins are sourced from the FULL pack's
159
+ // `domains`, which clientBranding deliberately omits, so they are the only way a
160
+ // served page can carry a correct cross-host href or canonical/og: URL: a client
161
+ // fill cannot reach them, and a baked one would make every instance advertise
162
+ // whichever instance happened to author the file (task 1002748, direction C5/C6).
163
+ // Pure + exported for the test; both origins are injectable so it drives with zero I/O.
164
+ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOrigin(), hallOrigin = safeBuildersOrigin(), sourceRepo = safeSourceRepoUrl()) {
161
165
  const world = (brand.identity && brand.identity.worldName) || '';
162
166
  const product = (brand.identity && brand.identity.productName) || '';
167
+ const tagline = (brand.copy && brand.copy.tagline) || '';
163
168
  const cur = (brand.currency && brand.currency.label) || 'credits';
164
169
  const Cur = cur ? cur.charAt(0).toUpperCase() + cur.slice(1) : cur;
165
170
  return String(html == null ? '' : html)
166
- .split('{{worldName}}').join(world)
167
- .split('{{productName}}').join(product)
168
- .split('{{Currency}}').join(Cur)
169
- .split('{{currency}}').join(cur)
170
- .split('{{publicOrigin}}').join(origin || '');
171
+ .split('{{worldName}}').join(esc(world))
172
+ .split('{{productName}}').join(esc(product))
173
+ .split('{{Currency}}').join(esc(Cur))
174
+ .split('{{currency}}').join(esc(cur))
175
+ .split('{{tagline}}').join(esc(tagline))
176
+ .split('{{publicOrigin}}').join(esc(origin || ''))
177
+ // a pack that names no hall of its own resolves to its OWN apex — never to
178
+ // whichever instance authored the file
179
+ .split('{{buildersOrigin}}').join(esc(hallOrigin || origin || ''))
180
+ .split('{{sourceRepoUrl}}').join(esc(sourceRepo || ''));
181
+ }
182
+ // Every token lands in HTML — as element text, or inside a double-quoted
183
+ // attribute (href/content). Escaping is defence in depth, not a live fix: the
184
+ // only source is config/branding.json, which is committed, code-reviewed and
185
+ // Archon-only, never request-supplied. But a pack string carrying a quote or a
186
+ // '<' would otherwise break out of its attribute or close <title> early, and
187
+ // the values now reach four times as many places as when this was written.
188
+ // No token is ever emitted into a <script> or a CSS context (checked across
189
+ // public-landing/, hall-ui/, status-ui/) — that would need different escaping.
190
+ // Ampersand first, or the other replacements get double-escaped.
191
+ function esc(s) {
192
+ return String(s == null ? '' : s)
193
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
194
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
171
195
  }
172
196
  // The instance apex origin from the full branding pack (domains is server-only,
173
197
  // so it never rides clientBranding). Best-effort so a branding hiccup can't crash
@@ -175,6 +199,25 @@ function applyBrandTokens(html, brand = clientBranding(), origin = safePublicOri
175
199
  function safePublicOrigin() {
176
200
  try { return branding().domains.publicOrigin || ''; } catch { return ''; }
177
201
  }
202
+ // The instance's OWN builders' hall origin, same server-only source. Empty is fine:
203
+ // applyBrandTokens falls back to the apex, so a pack that names no hall still gets
204
+ // a same-instance link rather than a dangling href.
205
+ function safeBuildersOrigin() {
206
+ try { return branding().domains.buildersOrigin || ''; } catch { return ''; }
207
+ }
208
+ // The instance's own source repo, from the pack's `repo: {owner, name}` binding —
209
+ // another server-only field (the branding contract keeps repo off clientBranding
210
+ // alongside domains). A page that wants to link "read the source" must take it
211
+ // from here: the authorship audit fails any hardcoded owner/name, and rightly so,
212
+ // since a baked one would point every instance at whoever authored the file.
213
+ // '' when the pack names no repo — the page drops the link rather than emit a
214
+ // dangling href.
215
+ function safeSourceRepoUrl() {
216
+ try {
217
+ const repo = branding().repo;
218
+ return repo && repo.owner && repo.name ? `https://github.com/${repo.owner}/${repo.name}` : '';
219
+ } catch { return ''; }
220
+ }
178
221
  // Instance product/world name for the API-discovery index (task 1918). Best-effort:
179
222
  // a branding hiccup must not break the JSON response.
180
223
  function safeProductName() {
package/src/module-api.js CHANGED
@@ -605,8 +605,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
605
605
  // landed since 1.19.87 with no explicit bump. run 31656577595. (task 1002620)
606
606
  // 1.19.89 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
607
607
  // landed since 1.19.88 with no explicit bump. run 31657467485. (task 1002620)
608
+ // 1.19.90 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
609
+ // landed since 1.19.89 with no explicit bump. run 31717551308. (task 1002620)
610
+ // 1.19.91 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
611
+ // landed since 1.19.90 with no explicit bump. run 31722841411. (task 1002620)
608
612
  // ---------------------------------------------------------------------------
609
- const CORE_VERSION = '1.19.89'; // CI auto-patch carrier (ADR 0161); see changelog above
613
+ const CORE_VERSION = '1.19.91'; // CI auto-patch carrier (ADR 0161); see changelog above
610
614
 
611
615
  // A namespaced logger so a module's log lines are attributable + consistent.
612
616
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -65,12 +65,36 @@ function landingPage(file) {
65
65
  if (!_landingPages.has(file)) {
66
66
  let html = '';
67
67
  try {
68
- html = injectBrandingGlobal(fs.readFileSync(path.join(LANDING_DIR, file), 'utf8'));
68
+ html = injectBrandingGlobal(stripApexOnly(fs.readFileSync(path.join(LANDING_DIR, file), 'utf8')));
69
69
  } catch { /* absent → '' */ }
70
70
  _landingPages.set(file, html);
71
71
  }
72
72
  return _landingPages.get(file);
73
73
  }
74
+
75
+ // THE APEX CONDITIONAL (task 1002748). public-landing/ ships wholesale to every
76
+ // instance, so one file has to render two shapes: the platform's own front door,
77
+ // and a child project's. Sections that are true only of the platform — the
78
+ // catalog of OTHER instances, the "capture-resistant commons" claim, the door
79
+ // into the catalog — are wrapped in <!--apex-only:start--> … <!--apex-only:end-->
80
+ // and REMOVED here on any instance that is not the hub.
81
+ //
82
+ // Removed, not CSS-hidden: a child instance must not ship the platform's claims
83
+ // to a crawler, and hiding is not the same as not saying it.
84
+ //
85
+ // Comment sentinels rather than an attribute selector because this is a string
86
+ // transform, not a DOM one — matching a balanced close tag with a regex is the
87
+ // fragile shape, and a sentinel pair has no such failure mode.
88
+ //
89
+ // `platform-identity` IS the hub flag, not a proxy for it: its manifest reads
90
+ // "Enabled ONLY on the hub instance; ordinary hosted + self-hosted instances
91
+ // leave it off", and it is the module that owns the very /projects catalog these
92
+ // sections point at. So this asks the instance what it IS, and nothing anywhere
93
+ // has to know the platform's hostname (direction C5).
94
+ const APEX_ONLY = /<!--apex-only:start-->[\s\S]*?<!--apex-only:end-->/g;
95
+ function stripApexOnly(html, isHub = isModuleEnabled('platform-identity')) {
96
+ return isHub ? html : String(html).replace(APEX_ONLY, '');
97
+ }
74
98
  function landingHtml() { return landingPage('index.html'); }
75
99
  // The interactive landing gate (public-landing/gate/, ported from amazonprimea
76
100
  // in task 1002407). When an instance ships it, it is the apex front door
@@ -232,4 +256,4 @@ if (require.main === module) {
232
256
  });
233
257
  }
234
258
 
235
- module.exports = { buildPlatformApp, startBackgroundServices };
259
+ module.exports = { buildPlatformApp, startBackgroundServices, stripApexOnly };
@@ -32,14 +32,18 @@ test('R74: a vanilla boot resolves to the Cloud Bongos identity', () => {
32
32
 
33
33
  test('R74: the vanilla pack carries the Cloud Bongos LOOK (sunrise palette, Baloo 2/Quicksand, bongo favicon)', () => {
34
34
  const c = clientBranding(vanilla());
35
- assert.equal(c.theme.colors.accent, '#f97316', 'sunrise orange accent');
36
- assert.equal(c.theme.colors.bg, '#fff7ed', 'warm sunrise base');
35
+ // fired down from the original #f97316 so white-on-accent clears WCAG AA
36
+ // (2.80:1 -> 4.61:1, task 1002749) — same ember hue, readable
37
+ assert.equal(c.theme.colors.accent, '#ce4806', 'ember orange accent');
38
+ // ember marigold (task 1002748): the base is PIGMENTED, not near-white. The
39
+ // old #fff7ed sat within a few points of the cards and the page read as fog.
40
+ assert.equal(c.theme.colors.bg, '#f9d9a8', 'marigold base');
37
41
  assert.match(c.theme.fonts.display, /Baloo 2/);
38
42
  assert.match(c.theme.fonts.body, /Quicksand/);
39
43
  assert.ok(c.theme.fonts.links.some((l) => /Baloo\+2/.test(l) && /Quicksand/.test(l) && /Fira\+Code/.test(l)), 'loads Baloo 2 + Quicksand + Fira Code');
40
44
  assert.match(c.theme.favicon, /circle/, 'bongo-pair favicon (circles), not the Greek Α');
41
45
  assert.doesNotMatch(c.theme.favicon, /font-family='serif'>Α/);
42
- assert.match(c.theme.backdrop, /linear-gradient\(180deg/, 'sunrise backdrop gradient');
46
+ assert.equal(c.theme.backdrop, '#f9d9a8', 'the marigold ground holds all the way down');
43
47
  assert.equal(c.copy.tagline, 'Building software is laying down a beat.');
44
48
  assert.equal(c.rankLabels.archon, 'Owner', 'plain rank labels, not "Archon"');
45
49
  });
@@ -48,21 +52,23 @@ test('R74: serving the hall on a vanilla boot injects the Cloud Bongos sunrise t
48
52
  const c = clientBranding(vanilla());
49
53
  const html = '<head><link rel="stylesheet" href="/builders/style.css"><title>x</title></head><body></body>';
50
54
  const out = injectBrandingGlobal(html, c);
51
- // palette override + sunrise body backdrop + bongo favicon + the type links
52
- assert.match(out, /:root\{[^}]*--accent:#f97316;/);
53
- assert.match(out, /body\{background:linear-gradient\(180deg/);
55
+ // palette override + the marigold body backdrop + bongo favicon + the type links
56
+ assert.match(out, /:root\{[^}]*--accent:#ce4806;/);
57
+ assert.match(out, /body\{background:#f9d9a8;\}/);
54
58
  assert.match(out, /<link rel="icon" href="data:image\/svg\+xml[^"]*circle/);
55
59
  assert.match(out, /fonts\.googleapis\.com\/css2\?family=Baloo\+2/);
56
60
  // and it lands AFTER the stylesheet so the Cloud Bongos look wins
57
- assert.ok(out.indexOf('/builders/style.css') < out.indexOf('--accent:#f97316'));
61
+ assert.ok(out.indexOf('/builders/style.css') < out.indexOf('--accent:#ce4806'));
58
62
  });
59
63
 
60
64
  test('R74: themeMarkup paints the backdrop only when the pack supplies one', () => {
61
65
  // OTB-shaped pack (no backdrop) → no body{background} rule.
62
66
  const otb = themeMarkup({ colors: { bg: '#ece0c2' }, fonts: { links: [] }, favicon: '', backdrop: '' });
63
67
  assert.doesNotMatch(otb, /body\{background/);
64
- // Cloud Bongos pack → backdrop present.
65
- assert.match(themeMarkup(clientBranding(vanilla()).theme), /body\{background:linear-gradient/);
68
+ // Cloud Bongos pack → backdrop present. Asserted by PRESENCE, not by shape:
69
+ // this test's subject is whether a backdrop paints at all, so it must not
70
+ // re-break every time the palette legitimately changes.
71
+ assert.match(themeMarkup(clientBranding(vanilla()).theme), /body\{background:.+\}/);
66
72
  });
67
73
 
68
74
  test('customer-0: GDS_BRANDING_FILE selects the cloudbongos.com instance pack (task 1374)', () => {
@@ -0,0 +1,268 @@
1
+ // Guards the WCAG AA contrast of the cosmos.css token layer (task 1002749).
2
+ //
3
+ // The tokens are the ONLY input here: this test PARSES public-landing/assets/cosmos.css
4
+ // and resolves var()/color-mix() itself, so it measures what the surfaces actually paint.
5
+ // Duplicating the hex values would prove nothing.
6
+ //
7
+ // AA thresholds: 4.5:1 normal text, 3.0:1 large text (>=24px, or >=18.66px bold) and
8
+ // meaningful non-text boundaries. Every pair below is a real selector in cosmos.css.
9
+
10
+ import { test } from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const CSS = path.join(
17
+ path.dirname(fileURLToPath(import.meta.url)),
18
+ '..', 'public-landing', 'assets', 'cosmos.css'
19
+ );
20
+ const src = fs.readFileSync(CSS, 'utf8');
21
+
22
+ // ── parse ────────────────────────────────────────────────────────────────────
23
+ const declarations = (body) => {
24
+ const out = {};
25
+ // strip comments, then take --name:value up to the ; that ends the declaration
26
+ for (const m of body.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
27
+ // collapse the whitespace of multi-line values so the lockstep check compares
28
+ // meaning, not indentation
29
+ out[m[1]] = m[2].replace(/\s+/g, ' ').trim();
30
+ }
31
+ return out;
32
+ };
33
+
34
+ const bodyAt = (open) => {
35
+ let depth = 0;
36
+ for (let i = open; i < src.length; i++) {
37
+ if (src[i] === '{') depth++;
38
+ else if (src[i] === '}' && --depth === 0) return src.slice(open + 1, i);
39
+ }
40
+ throw new Error(`unbalanced braces at offset ${open}`);
41
+ };
42
+
43
+ const blockAfter = (marker) => {
44
+ const at = src.indexOf(marker);
45
+ assert.notEqual(at, -1, `cosmos.css no longer contains ${marker} — update this test`);
46
+ return bodyAt(src.indexOf('{', at));
47
+ };
48
+
49
+ // A bare :root appears twice: the contract/chrome tier first, the derived tier last.
50
+ // Both feed both modes, so the two merge into one raw map per mode.
51
+ const bareRoots = [...src.matchAll(/\n:root\{/g)].map((m) => bodyAt(src.indexOf('{', m.index)));
52
+ assert.equal(bareRoots.length, 2, 'expected exactly two bare :root blocks in cosmos.css');
53
+ const [contractTier, derivedTier] = bareRoots.map(declarations);
54
+ const firstRoot = contractTier;
55
+ const derived = derivedTier;
56
+
57
+ const darkExplicit = declarations(blockAfter(':root[data-mode="dark"]{'));
58
+ const darkMedia = declarations(blockAfter(':root:not([data-mode="light"]){'));
59
+
60
+ // THE CASCADE THAT ACTUALLY SHIPS. injectBrandingGlobal splices the branding pack's
61
+ // theme <style> at the END of <head> — after the cosmos.css <link> — as a plain
62
+ // :root. Equal specificity, later in document order, so for the 13 tokens the pack
63
+ // emits the PACK wins and cosmos.css's own :root value never renders. Measuring
64
+ // cosmos.css alone would measure a page nobody sees.
65
+ // The dark blocks are :root[data-mode="dark"] and :root:not([data-mode="light"]) —
66
+ // both (0,2,0) — so they outrank the pack's (0,1,0) and win back.
67
+ const packColors = JSON.parse(
68
+ fs.readFileSync(path.join(path.dirname(CSS), '..', '..', 'config', 'branding.neutral.json'), 'utf8')
69
+ ).theme.ui.colors;
70
+
71
+ // mirrors COLOR_VARS in src/bongos/serve-internal.js
72
+ const camelToVar = (k) => '--' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
73
+ const PACK = Object.fromEntries(
74
+ Object.entries(packColors).map(([k, v]) => [camelToVar(k), v])
75
+ );
76
+
77
+ const RAW = {
78
+ light: { ...firstRoot, ...derived, ...PACK },
79
+ dark: { ...firstRoot, ...derived, ...PACK, ...darkExplicit },
80
+ };
81
+
82
+ // ── color resolution ─────────────────────────────────────────────────────────
83
+ const clamp = (v) => Math.min(255, Math.max(0, v));
84
+
85
+ function resolve(expr, mode, seen = new Set()) {
86
+ const e = String(expr).trim();
87
+
88
+ if (e === 'transparent') return [0, 0, 0, 0];
89
+
90
+ let m = e.match(/^#([0-9a-f]{6})$/i);
91
+ if (m) { const n = parseInt(m[1], 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1]; }
92
+
93
+ m = e.match(/^#([0-9a-f]{3})$/i);
94
+ if (m) return [...m[1]].map((c) => parseInt(c + c, 16)).concat(1);
95
+
96
+ m = e.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/);
97
+ if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
98
+
99
+ m = e.match(/^var\(\s*(--[\w-]+)\s*(?:,([\s\S]+))?\)$/);
100
+ if (m) {
101
+ const name = m[1];
102
+ assert.ok(!seen.has(name), `cyclic var reference at ${name}`);
103
+ const next = RAW[mode][name] ?? m[2];
104
+ assert.ok(next !== undefined, `${name} is not defined for ${mode} mode`);
105
+ return resolve(next, mode, new Set([...seen, name]));
106
+ }
107
+
108
+ m = e.match(/^color-mix\(\s*in\s+srgb\s*,\s*([\s\S]+?)\s+([\d.]+)%\s*,\s*([\s\S]+?)\s*\)$/);
109
+ if (m) {
110
+ const p = +m[2] / 100;
111
+ const a = resolve(m[1], mode, seen);
112
+ const b = resolve(m[3], mode, seen);
113
+ // color-mix premultiplies by alpha; every pair we assert on resolves opaque,
114
+ // and `transparent` correctly drags the result toward the other operand.
115
+ const alpha = a[3] * p + b[3] * (1 - p);
116
+ if (alpha === 0) return [0, 0, 0, 0];
117
+ return [0, 1, 2]
118
+ .map((i) => (a[i] * a[3] * p + b[i] * b[3] * (1 - p)) / alpha)
119
+ .concat(alpha);
120
+ }
121
+
122
+ throw new Error(`cannot resolve color expression: ${e}`);
123
+ }
124
+
125
+ // flatten a possibly-translucent color onto an opaque backdrop
126
+ const over = (fg, bg) =>
127
+ fg[3] === 1 ? fg : [0, 1, 2].map((i) => clamp(fg[i] * fg[3] + bg[i] * (1 - fg[3]))).concat(1);
128
+
129
+ const lin = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
130
+ const lum = ([r, g, b]) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
131
+ const contrast = (fg, bg) => {
132
+ const [hi, lo] = [lum(fg), lum(bg)].sort((a, b) => b - a);
133
+ return (hi + 0.05) / (lo + 0.05);
134
+ };
135
+
136
+ const hex = (c) => '#' + c.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
137
+
138
+ // ── the pairs cosmos.css actually paints ─────────────────────────────────────
139
+ // need: 4.5 normal text · 3.0 large text and meaningful non-text
140
+ const CARD = 'var(--bg-card)';
141
+ const PAIRS = [
142
+ // primary + secondary action
143
+ ['.btn label on its fill', '#ffffff', 'var(--accent)', 4.5],
144
+ ['.btn.ghost:hover label', 'var(--accent-deep)', CARD, 4.5],
145
+ ['.btn.danger label', 'var(--err)', CARD, 4.5],
146
+ // Inline links: cosmos.css recolors its own to --sun-lit in dark (:179-262), but
147
+ // create.html has ELEVEN --accent-deep consumers and zero dark CSS rules (its only
148
+ // data-mode hits are the JS toggle), so there the token itself must carry dark.
149
+ ['inline link on a card', 'var(--accent-deep)', CARD, 4.5],
150
+ ['inline link on the page ground', 'var(--accent-deep)', 'var(--bg)', 4.5],
151
+ ['code chip on --bg-deep', 'var(--accent-deep)', 'var(--bg-deep)', 4.5],
152
+ ['cosmos.css dark link (--sun-lit)', 'var(--sun-lit)', CARD, 4.5, 'dark'],
153
+ // body copy
154
+ ['--ink body copy', 'var(--ink)', CARD, 4.5],
155
+ ['--ink-soft secondary copy', 'var(--ink-soft)', CARD, 4.5],
156
+ ['--ink-faint .projMeta 13px', 'var(--ink-faint)', CARD, 4.5],
157
+ ['--ink-faint .finePrint on ground', 'var(--ink-faint)', 'var(--bg)', 4.5],
158
+ // chips: 12px/700 label on its own tint
159
+ ['.chip base label', 'var(--ink-soft)', 'var(--neutral-soft)', 4.5],
160
+ ['.chip.calm label', 'var(--ink-faint)', 'var(--neutral-soft)', 4.5],
161
+ ['.chip.live label', 'var(--ok)', 'var(--ok-soft)', 4.5],
162
+ ['.chip.maybe label', 'var(--warn)', 'var(--warn-soft)', 4.5],
163
+ ['.chip.down/.bad label', 'var(--err)', 'var(--err-soft)', 4.5],
164
+ // .chip.busy recolors in dark (cosmos.css:194-195) — assert what each mode paints
165
+ ['.chip.busy label', 'var(--accent-deep)', 'var(--accent-soft)', 4.5, 'light'],
166
+ ['.chip.busy label', 'var(--sun-lit)', 'var(--accent-soft)', 4.5, 'dark'],
167
+ // lifecycle strip: 12px/700
168
+ ['.strip .slbl idle', 'var(--ink-faint)', CARD, 4.5],
169
+ ['.strip .slbl now/done', 'color-mix(in srgb,var(--sea) 40%,var(--ink))', CARD, 4.5],
170
+ ['.strip.isLive .slbl', 'var(--ok)', CARD, 4.5],
171
+ // status messages
172
+ ['.projMsg.ok', 'var(--ok)', CARD, 4.5],
173
+ ['.projMsg.err / .confirmRow', 'var(--err)', CARD, 4.5],
174
+ // chrome: wordmark + footer sit on the page ground, not on a card
175
+ // create.html page-local grounds — it carries its own <style> and its own
176
+ // darkest tints, so cosmos.css's pairs alone do not cover /create
177
+ ['create.html .ghrow .priv 11px', 'var(--ink-soft)', 'color-mix(in srgb,var(--ink) 8%,var(--bg-deep))', 4.5],
178
+ ['create.html ::placeholder', 'var(--ink-faint)', CARD, 4.5],
179
+ ['create.html .ghlist .hint on deep', 'var(--ink-faint)', 'var(--bg-deep)', 4.5],
180
+ ['--chrome-accent wordmark', 'var(--chrome-accent)', 'var(--page-bg)', 4.5],
181
+ ['--chrome-ink', 'var(--chrome-ink)', 'var(--page-bg)', 4.5],
182
+ ['--foot-ink footer copy', 'var(--foot-ink)', 'var(--page-bg)', 4.5],
183
+ ['--foot-code footer code', 'var(--foot-code)', 'var(--page-bg)', 4.5],
184
+ ['--hero-b hero emphasis', 'var(--hero-b)', 'var(--bg)', 4.5],
185
+ ['--hero-lede hero lede', 'var(--hero-lede)', 'var(--bg)', 4.5],
186
+ ];
187
+
188
+ for (const mode of ['light', 'dark']) {
189
+ test(`cosmos.css tokens clear WCAG AA in ${mode} mode`, () => {
190
+ const ground = resolve('var(--page-bg)', mode);
191
+ const failures = [];
192
+ for (const [what, fgExpr, bgExpr, need, onlyMode] of PAIRS) {
193
+ if (onlyMode && onlyMode !== mode) continue;
194
+ const bg = over(resolve(bgExpr, mode), ground);
195
+ const fg = over(resolve(fgExpr, mode), bg);
196
+ const got = contrast(fg, bg);
197
+ if (got < need) {
198
+ failures.push(`${what}: ${got.toFixed(2)}:1 (needs ${need}) — ${hex(fg)} on ${hex(bg)}`);
199
+ }
200
+ }
201
+ assert.deepEqual(failures, [], `\n ${failures.join('\n ')}\n`);
202
+ });
203
+ }
204
+
205
+ test('the two dark blocks stay in lockstep', () => {
206
+ // cosmos.css carries dark twice — an explicit data-mode hook and a
207
+ // prefers-color-scheme twin. A change to one and not the other is the
208
+ // classic regression here, and it only shows up for half of users.
209
+ assert.deepEqual(
210
+ darkMedia,
211
+ darkExplicit,
212
+ 'the @media (prefers-color-scheme: dark) twin drifted from :root[data-mode="dark"]'
213
+ );
214
+ });
215
+
216
+ test('cosmos.css defaults do not drift from the branding pack', () => {
217
+ // For the 13 pack-owned tokens, cosmos.css's :root is a dead default — the pack
218
+ // always overrides it. That makes silent drift easy and dangerous: it was already
219
+ // real (cosmos.css said --ink-faint:#9c8d80, the pack shipped #857567), and it
220
+ // means anyone auditing cosmos.css alone measures colors no user ever sees.
221
+ const drift = [];
222
+ for (const [name, packValue] of Object.entries(PACK)) {
223
+ const cssValue = firstRoot[name];
224
+ if (cssValue === undefined) continue;
225
+ if (cssValue.toLowerCase() !== packValue.toLowerCase()) {
226
+ drift.push(`${name}: cosmos.css ${cssValue} vs pack ${packValue}`);
227
+ }
228
+ }
229
+ assert.deepEqual(drift, [], `\n ${drift.join('\n ')}\n`);
230
+ });
231
+
232
+ test('the dark blocks never pin a token the branding pack owns', () => {
233
+ // The pack injects a plain :root — specificity (0,1,0). Both dark selectors are
234
+ // (0,2,0), so ANY literal declared there outranks the pack forever and that
235
+ // instance gets Cloud Bongos' color in dark mode. A dark override of a
236
+ // pack-settable token is only safe if it DERIVES from one (var(--…)).
237
+ const PACK_OWNED = ['--accent', '--accent-deep', '--sea', '--terracotta'];
238
+ for (const block of [darkExplicit, darkMedia]) {
239
+ for (const name of PACK_OWNED) {
240
+ const v = block[name];
241
+ if (v === undefined) continue; // absent = the pack reaches dark. Ideal.
242
+ assert.match(
243
+ v,
244
+ /var\(--/,
245
+ `${name} is pinned to a literal in a dark block — a re-themed instance can never override it`
246
+ );
247
+ }
248
+ }
249
+ });
250
+
251
+ test('every accent-derived chrome token follows its contract token', () => {
252
+ // A branding pack re-themes the contract tier. Any chrome token that hardcodes
253
+ // the platform orange instead of referencing --accent leaks Cloud Bongos orange
254
+ // into a re-themed instance (ADR 0062 §7).
255
+ for (const [name, expected] of [
256
+ ['--chrome-accent', /var\(--accent(-deep)?\)/],
257
+ ['--glass-border', /var\(--accent(-deep)?\)/],
258
+ ['--hero-b', /var\(--accent(-deep)?\)/],
259
+ ['--foot-code', /var\(--accent(-deep)?\)/],
260
+ ['--foot-ink', /var\(--ink[\w-]*\)/],
261
+ ]) {
262
+ assert.match(
263
+ firstRoot[name] ?? '',
264
+ expected,
265
+ `${name} hardcodes a color instead of following the contract tier`
266
+ );
267
+ }
268
+ });