@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.
- package/.bongos-core.json +59 -29
- package/config/branding.neutral.json +6 -6
- package/docs/design/landing-page-direction.md +176 -0
- package/docs/design/mocks/_schemes.css +604 -0
- package/docs/design/mocks/landing-page-mock.html +687 -0
- package/docs/design/mocks/landing-palettes.html +103 -0
- package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
- package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
- package/modules/dev-box/app/assets/tray/win-off.png +0 -0
- package/modules/dev-box/app/assets/tray/win-on.png +0 -0
- package/modules/dev-box/app/build-resources/icon.png +0 -0
- package/modules/dev-box/app/renderer/theme.generated.css +11 -11
- package/modules/dev-box/app/scripts/gen-brand.js +12 -12
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +3 -3
- package/modules/dev-box/app/scripts/gen-icons.js +3 -3
- package/modules/dev-box/app/src/brand.generated.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +129 -70
- package/public-landing/create.html +5 -2
- package/public-landing/index.html +543 -88
- package/scripts/status-mirror-sync.js +12 -6
- package/src/bongos/serve-internal.js +53 -10
- package/src/module-api.js +5 -1
- package/src/platform-server.js +26 -2
- package/tests/cloud_bongos_pack.mjs +15 -9
- package/tests/cosmos_token_contrast.mjs +268 -0
- package/tests/landing_page.mjs +289 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
68
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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('{{
|
|
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) {{
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// `
|
|
160
|
-
|
|
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('{{
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
194
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
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.
|
|
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');
|
package/src/platform-server.js
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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.
|
|
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 +
|
|
52
|
-
assert.match(out, /:root\{[^}]*--accent:#
|
|
53
|
-
assert.match(out, /body\{background
|
|
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:#
|
|
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
|
-
|
|
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
|
+
});
|