@bongos/core 1.19.88 → 1.19.90
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 +61 -26
- package/config/branding.neutral.json +2 -2
- package/docs/design/front-door-and-hub-direction.md +155 -0
- package/docs/design/gate-navigation-direction.md +17 -0
- 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 +3 -3
- package/modules/dev-box/app/scripts/gen-brand.js +3 -3
- package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +1 -1
- package/modules/dev-box/app/scripts/gen-icons.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +27 -16
- package/public-landing/create.html +5 -2
- package/public-landing/gate/index.html +43 -1
- package/src/module-api.js +5 -1
- package/tests/cloud_bongos_pack.mjs +5 -3
- package/tests/cosmos_token_contrast.mjs +268 -0
- package/tests/gate_launch_metadata.mjs +67 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// tests/gate_launch_metadata.mjs — the landing gate is discoverable and degrades
|
|
2
|
+
// without JavaScript (task 1002746).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS. Until the front door moves to the landing page
|
|
5
|
+
// (docs/design/front-door-and-hub-direction.md), public-landing/gate/index.html is
|
|
6
|
+
// what answers cloudbongos.com/ — so it is what search results and link previews
|
|
7
|
+
// describe, and what a JS-off visitor or a non-executing crawler receives. It
|
|
8
|
+
// shipped with an internal-sounding <title>, no description, no social tags, and
|
|
9
|
+
// no <noscript>, and the sky is entirely JS-built, so that visitor got a blank
|
|
10
|
+
// page. The whole host is noindex pre-launch, which is exactly why the gap went
|
|
11
|
+
// unnoticed — it costs nothing until the lock comes off, then it costs everything
|
|
12
|
+
// at once.
|
|
13
|
+
//
|
|
14
|
+
// Pure disk read: no server boot, no DB, no network — this asserts the shipped
|
|
15
|
+
// artifact, which is the thing that actually reaches a crawler.
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const GATE = path.join(REPO_ROOT, 'public-landing', 'gate', 'index.html');
|
|
25
|
+
const html = fs.readFileSync(GATE, 'utf8');
|
|
26
|
+
|
|
27
|
+
test('the gate carries a real product title, not an internal label', () => {
|
|
28
|
+
const title = (html.match(/<title>([^<]*)<\/title>/) || [])[1] || '';
|
|
29
|
+
assert.ok(title.trim().length > 0, 'the gate must have a <title>');
|
|
30
|
+
// "Cloud Bongos — landing gate" described the file to us, not the product to a
|
|
31
|
+
// stranger reading a search result.
|
|
32
|
+
assert.doesNotMatch(title, /landing gate/i, `<title> must not read as an internal label: ${title}`);
|
|
33
|
+
assert.match(title, /Cloud Bongos/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the gate is describable by a crawler and a link preview', () => {
|
|
37
|
+
assert.match(html, /<meta\s+name="description"\s+content="[^"]{40,}"/, 'needs a meta description');
|
|
38
|
+
assert.match(html, /<link\s+rel="canonical"\s+href="https:\/\/cloudbongos\.com\/"/);
|
|
39
|
+
for (const prop of ['og:type', 'og:site_name', 'og:url', 'og:title', 'og:description', 'og:image']) {
|
|
40
|
+
assert.match(html, new RegExp(`property="${prop}"`), `missing ${prop}`);
|
|
41
|
+
}
|
|
42
|
+
for (const name of ['twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) {
|
|
43
|
+
assert.match(html, new RegExp(`name="${name}"`), `missing ${name}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the gate degrades to real content without JavaScript', () => {
|
|
48
|
+
const block = (html.match(/<noscript>([\s\S]*?)<\/noscript>/) || [])[1];
|
|
49
|
+
assert.ok(block, 'the JS-built sky must ship a <noscript> fallback');
|
|
50
|
+
// It has to SAY something, not just exist — a stranger with JS off should learn
|
|
51
|
+
// what this is and be able to leave through a real door.
|
|
52
|
+
assert.match(block, /Cloud Bongos|Cloud Bongos/);
|
|
53
|
+
assert.match(block, /href="\/projects"/, 'the fallback must reach the projects surface');
|
|
54
|
+
assert.match(block, /href="\/create"/, 'the fallback must reach project creation');
|
|
55
|
+
// Every rule in space.css is scoped to a body view-class the fallback must
|
|
56
|
+
// ignore, so its visibility cannot depend on the stylesheet.
|
|
57
|
+
assert.match(block, /style="/, 'the fallback must be inline-styled to survive the view-class CSS');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('the gate still boots into a view class (the ritual removal has not landed yet)', () => {
|
|
61
|
+
// Guards the file's structural contract while task 1002743 is outstanding: the
|
|
62
|
+
// body carries a view class, and <head> closes before <body> opens. If the
|
|
63
|
+
// ritual removal lands, the expected class becomes view-space — update this
|
|
64
|
+
// assertion there rather than deleting it.
|
|
65
|
+
assert.match(html, /<body class="view-(entry|space)"/);
|
|
66
|
+
assert.ok(html.indexOf('</head>') < html.indexOf('<body class='), 'head must close before body opens');
|
|
67
|
+
});
|