@bongos/core 1.19.396 → 1.19.398
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +149 -19
- package/.claude/skills/design/SKILL.md +9 -1
- package/docs/adr/0198-third-party-skill-vendoring-policy.md +43 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +1 -1
- package/docs/recipes/ui-look-before-you-ship.md +113 -0
- package/modules/hall-ui/public/ranks.states.json +15 -0
- package/modules/ui-design/kit/README.md +12 -2
- package/modules/ui-design/kit/check-mock.js +147 -0
- package/modules/ui-design/kit/contrast.js +156 -0
- package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
- package/modules/ui-design/kit/fixtures/featured.json +1 -0
- package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
- package/modules/ui-design/kit/fixtures/me.json +9 -0
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
- package/modules/ui-design/kit/fixtures/progress.json +1 -0
- package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
- package/modules/ui-design/kit/lib.js +389 -0
- package/modules/ui-design/kit/png.js +51 -0
- package/modules/ui-design/kit/probe.js +100 -0
- package/modules/ui-design/kit/render.js +137 -0
- package/modules/ui-design/kit/serve.js +178 -0
- package/modules/ui-design/skills/PREAMBLE.md +13 -0
- package/modules/ui-design/skills/README.md +36 -2
- package/modules/ui-design/skills/policy.json +12 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/index.probes.json +37 -0
- package/public-landing/index.states.json +16 -0
- package/public-landing/projects.probes.json +79 -0
- package/public-landing/projects.states.json +25 -0
- package/scripts/gds/claude-materialize.js +122 -13
- package/scripts/gds/ui-kit-transform.js +43 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +5 -1
- package/tests/claude_materialize.mjs +83 -0
- package/tests/ui_design_kit.mjs +217 -0
- package/tests/ui_design_skills.mjs +168 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// render.js — render a page's states and audit every render against the platform
|
|
4
|
+
// floors (task 1003320, ADR 0197). The states live in a JSON file BESIDE the
|
|
5
|
+
// surface (<dir>/<page>.states.json: name → url + actions + expectations); each
|
|
6
|
+
// is rendered in dark + light at 1440x900 / 390x844 / 320x700 through two stubs
|
|
7
|
+
// this file starts itself (signed out, signed in), then a reduced-motion pass per
|
|
8
|
+
// mode. Writes the shots + a JSON report; exits non-zero on any problem.
|
|
9
|
+
//
|
|
10
|
+
// node modules/ui-design/kit/render.js --page /projects (public-landing/projects.states.json)
|
|
11
|
+
// node modules/ui-design/kit/render.js --page /builders/ranks --surface hall-ui
|
|
12
|
+
// node modules/ui-design/kit/render.js --states <file> (explicit file)
|
|
13
|
+
// --modes dark,light --sizes 1440,390,320 --only a,b --out <dir>
|
|
14
|
+
// --base-out <url> --base-in <url> use running stubs instead of starting two
|
|
15
|
+
//
|
|
16
|
+
// Exit 0 ALL CLEAN · 1 problems · 2 usage/contract · 3 no browser.
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const os = require('node:os');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const lib = require('./lib.js');
|
|
21
|
+
|
|
22
|
+
function usage(msg) {
|
|
23
|
+
process.stderr.write(`render: ${msg}\n node modules/ui-design/kit/render.js --page /projects [--surface <dir|key>] [--states <file>] [--modes dark,light] [--sizes 1440,390,320] [--only a,b] [--out <dir>] [--base-out <url> --base-in <url>]\n`);
|
|
24
|
+
process.exit(lib.EXIT.USAGE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Resolve the states file + surface from the argv (pure over the filesystem; the
|
|
28
|
+
// test drives the failure paths through the CLI's exit code).
|
|
29
|
+
function locate(args) {
|
|
30
|
+
const explicit = lib.argOf(args, '--states', null);
|
|
31
|
+
let file = explicit && path.resolve(explicit);
|
|
32
|
+
let json;
|
|
33
|
+
if (file) {
|
|
34
|
+
if (!fs.existsSync(file)) usage(`states file not found: ${file}`);
|
|
35
|
+
try { json = lib.readJson(file); } catch (e) { usage(`states file is not JSON: ${e.message}`); }
|
|
36
|
+
} else {
|
|
37
|
+
const page = lib.normalizePageArg(lib.argOf(args, '--page', null));
|
|
38
|
+
if (!page) usage('--page </path> or --states <file> is required');
|
|
39
|
+
const surfaceKey = lib.argOf(args, '--surface', 'public-landing');
|
|
40
|
+
const dir = lib.resolveSurfaceDir(surfaceKey);
|
|
41
|
+
if (!dir) usage(`--surface "${surfaceKey}" is neither a checkout dir nor a module key with a webSurfaces dir`);
|
|
42
|
+
file = lib.sidecarPath(dir, page, 'states');
|
|
43
|
+
if (!fs.existsSync(file)) usage(`no states file beside the surface: ${file} — write one (see docs/recipes/ui-look-before-you-ship.md)`);
|
|
44
|
+
try { json = lib.readJson(file); } catch (e) { usage(`states file is not JSON: ${e.message}`); }
|
|
45
|
+
}
|
|
46
|
+
const v = lib.validateStatesFile(json);
|
|
47
|
+
if (!v.ok) usage(`${path.relative(lib.REPO_ROOT, file)} breaks the states contract:\n - ${v.errors.join('\n - ')}`);
|
|
48
|
+
const surface = json.surface || lib.argOf(args, '--surface', null) || path.relative(lib.REPO_ROOT, path.dirname(file)).split(path.sep).join('/');
|
|
49
|
+
return { file, json, surface };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Runs IN THE PAGE after the reduced-motion context loads + acts: what still moves?
|
|
53
|
+
async function reducedMotionFacts(page) {
|
|
54
|
+
return page.evaluate(async () => {
|
|
55
|
+
const running = document.getAnimations().filter((a) => a.playState === 'running').map((a) => a.animationName || a.constructor.name);
|
|
56
|
+
const orig = window.requestAnimationFrame; let frames = 0;
|
|
57
|
+
window.requestAnimationFrame = function (cb) { frames += 1; return orig.call(window, cb); };
|
|
58
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
59
|
+
window.requestAnimationFrame = orig;
|
|
60
|
+
return { running, frames };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function main() {
|
|
65
|
+
const args = process.argv.slice(2);
|
|
66
|
+
if (lib.hasFlag(args, '--help')) usage('');
|
|
67
|
+
const { file, json, surface } = locate(args);
|
|
68
|
+
const modes = lib.listArg(args, '--modes', [...lib.MODES]).filter((m) => lib.MODES.includes(m));
|
|
69
|
+
const sizes = lib.listArg(args, '--sizes', Object.keys(lib.SIZES)).map(Number).filter((w) => lib.SIZES[w]);
|
|
70
|
+
const only = lib.listArg(args, '--only', null);
|
|
71
|
+
const names = Object.keys(json.states).filter((n) => !only || only.includes(n));
|
|
72
|
+
if (!names.length) usage(`--only names no state of ${path.basename(file)}`);
|
|
73
|
+
const out = path.resolve(lib.argOf(args, '--out', path.join(os.tmpdir(), 'ui-design-kit', surface.replace(/[\\/]/g, '_'), lib.pageName(json.page))));
|
|
74
|
+
fs.mkdirSync(out, { recursive: true });
|
|
75
|
+
|
|
76
|
+
const stubs = [];
|
|
77
|
+
let baseOut = lib.argOf(args, '--base-out', null), baseIn = lib.argOf(args, '--base-in', null);
|
|
78
|
+
const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child };
|
|
79
|
+
if (!baseOut) { const s = await lib.startStub({ ...stubOpts, auth: false }); stubs.push(s); baseOut = s.base; }
|
|
80
|
+
if (!baseIn) { const s = await lib.startStub({ ...stubOpts, auth: true }); stubs.push(s); baseIn = s.base; }
|
|
81
|
+
const stop = () => stubs.forEach((s) => s.stop());
|
|
82
|
+
|
|
83
|
+
let browser;
|
|
84
|
+
try { browser = await lib.launchBrowser(); }
|
|
85
|
+
catch (e) { stop(); process.stderr.write(`render: ${e.message}\n`); process.exit(e instanceof lib.NoBrowserError ? lib.EXIT.NO_BROWSER : lib.EXIT.USAGE); }
|
|
86
|
+
|
|
87
|
+
const report = { page: json.page, surface, out, renders: {}, reducedMotion: {} };
|
|
88
|
+
const problems = [];
|
|
89
|
+
const collectorOpts = { ignoreRequests: json.ignoreRequests, ignoreConsole: json.ignoreConsole };
|
|
90
|
+
try {
|
|
91
|
+
for (const mode of modes) for (const w of sizes) for (const name of names) {
|
|
92
|
+
const st = json.states[name];
|
|
93
|
+
const [W, H] = lib.SIZES[w];
|
|
94
|
+
const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1, colorScheme: mode });
|
|
95
|
+
const page = await ctx.newPage();
|
|
96
|
+
const logs = lib.attachCollectors(page, collectorOpts);
|
|
97
|
+
const base = st.auth ? baseIn : baseOut;
|
|
98
|
+
await page.goto(base + lib.withMode(st.url || json.page, mode, json.modeQuery), { waitUntil: 'load' });
|
|
99
|
+
await page.evaluate(() => document.fonts.ready);
|
|
100
|
+
await page.waitForTimeout(lib.SETTLE_MS);
|
|
101
|
+
let actionFailed = null;
|
|
102
|
+
try { await lib.runActions(page, st.actions); } catch (e) { actionFailed = String(e.message).split('\n')[0]; logs.push('action failed: ' + actionFailed); }
|
|
103
|
+
const facts = await page.evaluate(lib.factsInPage, { expect: st.expect, ignoreControls: json.ignoreControls });
|
|
104
|
+
const found = [...logs, ...lib.auditFloors(facts, { width: W, mode }), ...lib.expectProblems(facts, st.expect)];
|
|
105
|
+
const key = `${mode}-${w}-${name}`;
|
|
106
|
+
await page.screenshot({ path: path.join(out, key + '.png') });
|
|
107
|
+
report.renders[key] = { facts, problems: found };
|
|
108
|
+
process.stdout.write(`${mode} ${w} ${name}: ${found.length ? 'PROBLEMS' : 'clean'} bar ${facts.bar ? facts.bar.h : '-'} expanded [${facts.expanded.join('|')}] anims ${facts.running} focus ${facts.activeId || facts.activeText || '-'}\n`);
|
|
109
|
+
for (const f of found) { process.stdout.write(` ${f}\n`); problems.push(`${key}: ${f}`); }
|
|
110
|
+
await ctx.close();
|
|
111
|
+
}
|
|
112
|
+
// reduced motion: the kill switch, exercised with the file's own interaction
|
|
113
|
+
const rm = json.reducedMotion || {};
|
|
114
|
+
for (const mode of modes) {
|
|
115
|
+
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: mode, reducedMotion: 'reduce' });
|
|
116
|
+
const page = await ctx.newPage();
|
|
117
|
+
await page.goto((rm.auth === false ? baseOut : baseIn) + lib.withMode(rm.url || json.page, mode, json.modeQuery), { waitUntil: 'load' });
|
|
118
|
+
await page.waitForTimeout(lib.SETTLE_MS);
|
|
119
|
+
try { await lib.runActions(page, rm.actions); } catch (e) { problems.push(`${mode} reduced-motion: action failed: ${String(e.message).split('\n')[0]}`); }
|
|
120
|
+
const facts = await reducedMotionFacts(page);
|
|
121
|
+
const found = lib.reducedMotionProblems(facts);
|
|
122
|
+
report.reducedMotion[mode] = { ...facts, problems: found };
|
|
123
|
+
process.stdout.write(`${mode} reduced-motion: ${found.length ? 'ALIVE' : 'dead'} running ${facts.running.length} rAF ${facts.frames}\n`);
|
|
124
|
+
for (const f of found) { process.stdout.write(` ${f}\n`); problems.push(`${mode}: ${f}`); }
|
|
125
|
+
await ctx.close();
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
await browser.close();
|
|
129
|
+
stop();
|
|
130
|
+
}
|
|
131
|
+
report.problems = problems;
|
|
132
|
+
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 1));
|
|
133
|
+
process.stdout.write(`\nshots + report.json → ${out}\n${problems.length ? `${problems.length} PROBLEM(S)` : 'ALL CLEAN'}\n`);
|
|
134
|
+
process.exit(problems.length ? lib.EXIT.PROBLEMS : lib.EXIT.CLEAN);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch((e) => { process.stderr.write(`render: ${e.stack || e.message}\n`); process.exit(lib.EXIT.USAGE); });
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// serve.js — the look-before-you-ship stub (task 1003320, ADR 0197): serves ANY
|
|
4
|
+
// surface directory of the checkout through the instance's REAL serving
|
|
5
|
+
// transforms — the apex-only strip, the brand-token stamp and the pack's theme
|
|
6
|
+
// injection, run by scripts/gds/ui-kit-transform.js (a core-side command: a
|
|
7
|
+
// module may not require core and the doorway may not import the serving domain,
|
|
8
|
+
// so the real code path is reached as a child process) — plus the content-hash
|
|
9
|
+
// ?v= stamp, with the instance's own branding pack. Answers the public feeds from
|
|
10
|
+
// fixtures/ so a page boots with real-shaped data and no database. Serves docs/
|
|
11
|
+
// raw so a mock under docs/design/mocks/ renders with its relative links.
|
|
12
|
+
//
|
|
13
|
+
// node modules/ui-design/kit/serve.js -> http://127.0.0.1:3412/ (public-landing)
|
|
14
|
+
// SURFACE=<dir or module key> a checkout dir ("public-landing", "modules/hall-ui/public") or a
|
|
15
|
+
// module key whose manifest declares a webSurfaces dir
|
|
16
|
+
// PREFIX=/builders/ the URL prefix the surface's pages + assets are served under
|
|
17
|
+
// STUB_REPO=<checkout> the checkout to serve (default: the one this file sits in)
|
|
18
|
+
// STUB_PORT=<n> default 3412 (0 = any free port; the port is printed)
|
|
19
|
+
// AUTH=1 /api/<ns>/me answers the fixture builder — { builder }, the real
|
|
20
|
+
// route's shape; my-projects + provisioning answer too
|
|
21
|
+
// CHILD=1 the child-instance shape: apex-only regions stripped
|
|
22
|
+
// LIVE=1 | LIVE=<origin> proxy the public feeds to the pack's public origin (or the named one)
|
|
23
|
+
// FAIL=<feed,feed> those feeds answer 500 (the names in FEEDS below)
|
|
24
|
+
//
|
|
25
|
+
// Exit 2 on a surface that does not resolve. Prints ONE line when listening
|
|
26
|
+
// ("stub on http://…") — render.js and probe.js wait for it.
|
|
27
|
+
const http = require('node:http');
|
|
28
|
+
const https = require('node:https');
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const crypto = require('node:crypto');
|
|
32
|
+
const { spawnSync } = require('node:child_process');
|
|
33
|
+
const lib = require('./lib.js');
|
|
34
|
+
|
|
35
|
+
const ROOT = process.env.STUB_REPO ? path.resolve(process.env.STUB_REPO) : lib.REPO_ROOT;
|
|
36
|
+
const SURFACE = process.env.SURFACE || 'public-landing';
|
|
37
|
+
const PREFIX = process.env.PREFIX || '/';
|
|
38
|
+
const PORT = process.env.STUB_PORT === undefined ? 3412 : Number(process.env.STUB_PORT);
|
|
39
|
+
const AUTH = process.env.AUTH === '1';
|
|
40
|
+
const CHILD = process.env.CHILD === '1';
|
|
41
|
+
const LIVE = process.env.LIVE || '';
|
|
42
|
+
const FAIL = new Set(String(process.env.FAIL || '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
43
|
+
const FIXTURES = path.join(__dirname, 'fixtures');
|
|
44
|
+
const DOCS = path.join(ROOT, 'docs');
|
|
45
|
+
const TRANSFORM = path.join(ROOT, 'scripts', 'gds', 'ui-kit-transform.js');
|
|
46
|
+
|
|
47
|
+
const DIR = lib.resolveSurfaceDir(SURFACE, ROOT);
|
|
48
|
+
if (!DIR) { process.stderr.write(`serve: SURFACE "${SURFACE}" is neither a directory of ${ROOT} nor a module key with a webSurfaces dir\n`); process.exit(lib.EXIT.USAGE); }
|
|
49
|
+
if (!PREFIX.startsWith('/') || !PREFIX.endsWith('/')) { process.stderr.write('serve: PREFIX must start and end with "/"\n'); process.exit(lib.EXIT.USAGE); }
|
|
50
|
+
if (!fs.existsSync(TRANSFORM)) { process.stderr.write(`serve: the transform command is missing: ${TRANSFORM}\n`); process.exit(lib.EXIT.USAGE); }
|
|
51
|
+
|
|
52
|
+
const MIME = {
|
|
53
|
+
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'application/javascript', '.mjs': 'application/javascript',
|
|
54
|
+
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
|
55
|
+
'.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
|
56
|
+
'.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8', '.mmd': 'text/plain; charset=utf-8',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// The real transforms, as the instance runs them (see ui-kit-transform.js).
|
|
60
|
+
function transform(file) {
|
|
61
|
+
const r = spawnSync(process.execPath, [TRANSFORM, path.join(DIR, file), ...(CHILD ? ['--child'] : []), '--json'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
62
|
+
if (r.status !== 0) throw new Error(`ui-kit-transform exited ${r.status}: ${(r.stderr || r.stdout || '').trim().slice(0, 600)}`);
|
|
63
|
+
return JSON.parse(r.stdout);
|
|
64
|
+
}
|
|
65
|
+
let liveOrigin = LIVE;
|
|
66
|
+
// The content-hash ?v= stamp, by RESOLUTION (a URL is an asset if it names a file
|
|
67
|
+
// under the surface dir) — the shape platform-server.js and asset-version.js stamp
|
|
68
|
+
// with; the stub knows the dir + prefix, so it stamps here, LAST (their order).
|
|
69
|
+
const LOCAL_ASSET = /(href|src)="(\/[A-Za-z0-9._~/-]+\.[A-Za-z0-9]+)"/g;
|
|
70
|
+
function stampAssets(html) {
|
|
71
|
+
return String(html).replace(LOCAL_ASSET, (m, attr, url) => {
|
|
72
|
+
const rel = url.startsWith(PREFIX) ? url.slice(PREFIX.length) : url.replace(/^\//, '');
|
|
73
|
+
const abs = path.resolve(DIR, rel);
|
|
74
|
+
if (!abs.startsWith(DIR + path.sep)) return m;
|
|
75
|
+
try { return `${attr}="${url}?v=${crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex').slice(0, 8)}"`; }
|
|
76
|
+
catch { return m; }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const pages = new Map();
|
|
80
|
+
function page(file) {
|
|
81
|
+
if (!pages.has(file)) {
|
|
82
|
+
const t = transform(file);
|
|
83
|
+
if (LIVE === '1' && !liveOrigin.startsWith('http')) liveOrigin = t.publicOrigin || '';
|
|
84
|
+
pages.set(file, { html: stampAssets(t.html), brand: t.brand });
|
|
85
|
+
}
|
|
86
|
+
return pages.get(file);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The feeds a surface may read on boot → the fixture that answers. Both API
|
|
90
|
+
// namespaces the instance mounts resolve to the same fixture.
|
|
91
|
+
const FEEDS = {
|
|
92
|
+
'/public/progress': 'progress',
|
|
93
|
+
'/public/cost-summary': 'cost-summary',
|
|
94
|
+
'/public/recent-shipped': 'recent-shipped',
|
|
95
|
+
'/public/leaderboard': 'leaderboard',
|
|
96
|
+
'/projects/featured': 'featured',
|
|
97
|
+
};
|
|
98
|
+
const AUTHED = { '/my-projects': 'my-projects' };
|
|
99
|
+
const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
|
|
100
|
+
|
|
101
|
+
function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); }
|
|
102
|
+
function fixture(name, res) {
|
|
103
|
+
if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
|
|
104
|
+
const file = path.join(FIXTURES, name + '.json');
|
|
105
|
+
if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
|
|
106
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
107
|
+
res.end(fs.readFileSync(file));
|
|
108
|
+
}
|
|
109
|
+
function proxy(req, res) {
|
|
110
|
+
const origin = (liveOrigin === '1' ? '' : liveOrigin).replace(/\/$/, '');
|
|
111
|
+
if (!origin) return json(res, 502, { error: { code: 'no_live_origin', message: 'LIVE=1 needs a pack with domains.publicOrigin; or LIVE=<origin>' } });
|
|
112
|
+
const target = origin + req.url;
|
|
113
|
+
(target.startsWith('https:') ? https : http).get(target, { headers: { accept: 'application/json' } }, (up) => {
|
|
114
|
+
res.writeHead(up.statusCode, { 'content-type': up.headers['content-type'] || 'application/json' });
|
|
115
|
+
up.pipe(res);
|
|
116
|
+
}).on('error', (e) => json(res, 502, { error: { code: 'proxy_failed', message: e.message } }));
|
|
117
|
+
}
|
|
118
|
+
function sendFile(abs, res) {
|
|
119
|
+
res.writeHead(200, { 'content-type': MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
|
|
120
|
+
fs.createReadStream(abs).pipe(res);
|
|
121
|
+
}
|
|
122
|
+
function fileUnder(base, rel) {
|
|
123
|
+
const abs = path.resolve(base, '.' + '/' + rel.replace(/^\/+/, ''));
|
|
124
|
+
return abs.startsWith(base + path.sep) && fs.existsSync(abs) && fs.statSync(abs).isFile() ? abs : null;
|
|
125
|
+
}
|
|
126
|
+
// The fixture builder's avatar is served by this stub (a page accepts only an
|
|
127
|
+
// http(s) avatar and sizes its chip by the image), so a root-relative avatar_url
|
|
128
|
+
// is absolutised to this origin per request.
|
|
129
|
+
const AVATAR_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="32" fill="#888"/><circle cx="32" cy="26" r="11" fill="#ddd"/><path d="M12 56c3-12 11-17 20-17s17 5 20 17" fill="#ddd"/></svg>';
|
|
130
|
+
function me(origin) {
|
|
131
|
+
const m = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'me.json'), 'utf8'));
|
|
132
|
+
if (m.builder && typeof m.builder.avatar_url === 'string' && m.builder.avatar_url.startsWith('/')) m.builder.avatar_url = origin + m.builder.avatar_url;
|
|
133
|
+
return m;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const server = http.createServer((req, res) => {
|
|
137
|
+
const u = new URL(req.url, 'http://x');
|
|
138
|
+
const p = u.pathname;
|
|
139
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
140
|
+
if (p === '/__kit/avatar.svg') { res.writeHead(200, { 'content-type': MIME['.svg'], 'cache-control': 'no-store' }); return res.end(AVATAR_SVG); }
|
|
141
|
+
// ── the API: /api/gds/* and /api/bongos/* are one surface on the instance ──
|
|
142
|
+
const apiMatch = p.match(/^\/api\/(gds|bongos)(\/.*)$/);
|
|
143
|
+
if (apiMatch) {
|
|
144
|
+
const rest = apiMatch[2];
|
|
145
|
+
if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res);
|
|
146
|
+
if (rest === '/me') return AUTH ? json(res, 200, me(origin)) : json(res, 401, UNAUTH);
|
|
147
|
+
if (AUTHED[rest]) return AUTH ? fixture(AUTHED[rest], res) : json(res, 401, UNAUTH);
|
|
148
|
+
if (rest === '/provisioning/instances') return AUTH ? json(res, 200, { instances: [] }) : json(res, 401, UNAUTH);
|
|
149
|
+
if (rest === '/auth/logout') return json(res, 200, { ok: true });
|
|
150
|
+
if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
|
|
151
|
+
if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
|
|
152
|
+
return json(res, 404, { error: { code: 'not_found', message: p } });
|
|
153
|
+
}
|
|
154
|
+
// the generated client every internal page loads (the real bundle when present)
|
|
155
|
+
if (/\/lib\/bongos-client\.js$/.test(p)) {
|
|
156
|
+
const bundle = fileUnder(ROOT, 'clients/bongos-client/bongos-client.global.js');
|
|
157
|
+
res.writeHead(200, { 'content-type': MIME['.js'], 'cache-control': 'no-store' });
|
|
158
|
+
return res.end(bundle ? fs.readFileSync(bundle, 'utf8') : '/* stub: no client bundle in this checkout */');
|
|
159
|
+
}
|
|
160
|
+
// docs/ raw (a mock's relative links resolve) and the surface dir by its checkout path
|
|
161
|
+
if (p.startsWith('/docs/')) { const abs = fileUnder(DOCS, p.slice('/docs/'.length)); if (abs) return sendFile(abs, res); }
|
|
162
|
+
const relSurface = path.relative(ROOT, DIR).split(path.sep).join('/');
|
|
163
|
+
if (p.startsWith('/' + relSurface + '/')) { const abs = fileUnder(DIR, p.slice(relSurface.length + 2)); if (abs) return sendFile(abs, res); }
|
|
164
|
+
// ── the surface: pages by name under PREFIX (and at the root), assets by path ──
|
|
165
|
+
const under = p.startsWith(PREFIX) ? p.slice(PREFIX.length) : (PREFIX !== '/' && (p === PREFIX.slice(0, -1)) ? '' : p.replace(/^\//, ''));
|
|
166
|
+
const asset = fileUnder(DIR, under);
|
|
167
|
+
if (asset && !asset.endsWith('.html')) return sendFile(asset, res);
|
|
168
|
+
const name = under === '' ? 'index' : under.replace(/\/$/, '').replace(/\.html$/, '');
|
|
169
|
+
if (!name.includes('/') && fs.existsSync(path.join(DIR, name + '.html'))) {
|
|
170
|
+
try { res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-store' }); return res.end(page(name + '.html').html); }
|
|
171
|
+
catch (e) { res.writeHead(500, { 'content-type': 'text/plain' }); return res.end('render failed: ' + e.stack); }
|
|
172
|
+
}
|
|
173
|
+
res.writeHead(404, { 'content-type': 'text/plain' }); res.end('404 ' + p);
|
|
174
|
+
});
|
|
175
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
176
|
+
const port = server.address().port;
|
|
177
|
+
process.stdout.write(`stub on http://127.0.0.1:${port}${PREFIX} surface=${path.relative(ROOT, DIR) || '.'} auth=${AUTH} child=${CHILD} live=${LIVE || '-'} fail=${[...FAIL].join(',') || '-'}\n`);
|
|
178
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!-- BEGIN PLATFORM PREAMBLE · ui-design module · one block, byte-identical in every vendored skill so an upstream refresh is a three-way merge; the source is modules/ui-design/skills/PREAMBLE.md and tests/ui_design_skills.mjs fails on drift -->
|
|
2
|
+
> **Platform preamble — read before any rule below.** This skill is vendored by the `ui-design` module and runs INSIDE an instance's world, which it reads first and never overrides.
|
|
3
|
+
>
|
|
4
|
+
> 1. **Load the world before any taste rule.** `config/branding.json` over `config/branding.neutral.json` (`theme.ui` is the fifteen tokens: <redacted> colours and two font stacks), `config/design-tokens.json` over `config/design-tokens.neutral.json`, and `DESIGN.md` at the repo root: the instance's palette and derived tiers, faces, materials, motion grammar, components, and its Do's and Don'ts. Where an instance has no `DESIGN.md`, the neutral pack is the world and the platform floors in item 6 are the whole rulebook; say so and build to them. Never invent a palette.
|
|
5
|
+
> 2. **The fifteen-token contract is the only colour source.** Every colour is a `var()` or a `color-mix()` of the fifteen; the only legal literal is pure black or white with alpha as a scrim, mask or halo. No page `:root` (page tokens live on `body`), and every dark rule is written twice: `:root[data-mode="dark"]` and its `prefers-color-scheme` twin.
|
|
6
|
+
> 3. **A style skill produces variants INSIDE that world, never a second world.** The look this skill carries (its palette, faces, materials, mood boards) is a reference for composition and craft; the instance's tokens and faces replace it. Changing the world is the owner's decision and an ADR, not a session's taste.
|
|
7
|
+
> 4. **Images are for hero plates and hero objects only.** UI is built from templates and the instance's world, not painted; any image-generation step below applies to text-free plates and hero objects, never to controls, cards, text, or a screenshot of a screen.
|
|
8
|
+
> 5. **The instance's taste bans apply and win.** Read them from its `DESIGN.md`. Cloud Bongos's own (no em dash or en dash in visible copy, no emoji, no gradient text, no three-equal-card row, no fake screenshot built from divs) are that instance's, not the module's; where this skill's rules conflict with an instance's bans, the instance wins.
|
|
9
|
+
> 6. **The platform floors hold on every instance** and a `DESIGN.md` may tighten them, never loosen them: every control clears 24px on both sides (primary pills 46–48px); AA in both modes (4.5:1 body text, 3:1 large text and non-text) measured from the real stylesheet; and the Kill Switch, `@media (prefers-reduced-motion: reduce)` at the foot of the token layer killing every animation and transition, no motion authored in JavaScript, and a rest frame that is a complete composition on its own.
|
|
10
|
+
> 7. **Look before you ship.** Render the real surface through the module's kit before and after (`/design` Step 2 and `docs/recipes/ui-look-before-you-ship.md`: every state at 1440 / 390 / 320 in dark and light, audited against the floors); a page with no states file gets one first, and new interactive code ships with a pin in the page's own test file.
|
|
11
|
+
>
|
|
12
|
+
> The rules below are the upstream skill's, unchanged. `PROVENANCE.md` beside this file names the origin, the pinned commit, the licence and the scan verdict it was vendored under.
|
|
13
|
+
<!-- END PLATFORM PREAMBLE -->
|
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
# `modules/ui-design/skills/` —
|
|
1
|
+
# `modules/ui-design/skills/` — the vetted UI skills: the policy, the roster, and why the roster is what it is
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The home for third-party design skills the `ui-design` module ships to every builder on every instance where the module is on ([task 1003321](https://cloudbongos.com/builders#/task/1003321), [ADR 0198](../../../docs/adr/<redacted>.md)). A skill that lands here is **materialised** into a builder's `.claude/skills/<name>/` by `scripts/gds/claude-materialize.js` (step 1b) on `bongos init` / `bongos upgrade`, exactly like a core skill, and withdrawn when the instance disables the module.
|
|
4
|
+
|
|
5
|
+
## The policy (enforced by `tests/ui_design_skills.mjs`, declared in [`policy.json`](policy.json))
|
|
6
|
+
|
|
7
|
+
A third-party skill lands here **only** as a copy of a pinned commit that:
|
|
8
|
+
|
|
9
|
+
1. was scanned with `/scan-before-install` at the `default` tier or higher and reduced to **`clean` or `caution`** — a `dangerous` verdict never lands a copy, not for a skill already installed on a builder's machine, not for one that "ran the last round"; the arithmetic is the scanner's, not a session's;
|
|
10
|
+
2. carries a licence on the allowlist — `Apache-2.0`, `MIT`, `BSD-2-Clause`, `BSD-3-Clause`, `CC-BY-4.0` — with the upstream `LICENSE` text beside the copy; anything else or unlicensed becomes a rebuild spec, not a copy;
|
|
11
|
+
3. carries a `PROVENANCE.md` sidecar (YAML frontmatter with the fields [`policy.json`](policy.json) names: `name`, `origin`, `sourcePath`, `commit`, `licence`, `scanVerdict`, `scanTier`, `scanReport`, `scannedOn`, `vendoredOn`, then prose) — and the materialiser reads it **at copy time** (`vendoredSkillGate` in `scripts/gds/claude-materialize.js`): a vendored dir with no sidecar, an off-list licence or a verdict that may not land is refused and logged, whatever the CI test says;
|
|
12
|
+
4. opens its `SKILL.md` body with the **PLATFORM PREAMBLE** — [`PREAMBLE.md`](PREAMBLE.md), one fenced block, byte-identical in every copy so an upstream refresh is a three-way merge — which reads the instance's world (its branding pack, its design tokens, its `DESIGN.md`) before any taste rule, keeps the fifteen-token contract the only colour source, makes a style skill produce variants *inside* that world, confines image generation to hero plates and hero objects, applies the instance's taste bans, restates the platform floors and the kill switch, and points at the look-before-you-ship loop;
|
|
13
|
+
5. is declared in `module.json` `contributes.skills` (the manifest stays the single declaration; an undeclared dir is not a skill) and lives under **`vendor/<name>/`** — the `vendor/` segment is the one `scripts/gds/fitness-ratchets.js` exempts from the first-party line and duplication budgets, so a 150-file upstream tree does not count as our debt. A skill the module *authors* lives at `<name>/` and is counted.
|
|
14
|
+
|
|
15
|
+
No auto-update: a refresh is a task that re-scans the new commit and re-applies the preamble. An instance may disable the module (`config/modules.json`), which withdraws every skill here; it may add its own skills under its own `.claude/skills/` — never by editing a vendored copy.
|
|
16
|
+
|
|
17
|
+
## The roster (2026-08-28)
|
|
18
|
+
|
|
19
|
+
Two origins were scanned for the set scoped with the owner (impeccable; the eleven taste skills). **Both reduced to `dangerous` at the deterministic floor, so neither landed as a copy** — the reports are in the scanning builder's `~/.config/otb/scan-reports/`, the rows in `security_scans`, the verdicts recorded in ADR 0198.
|
|
20
|
+
|
|
21
|
+
| Skill (in-house name) | Upstream | Licence | Verdict | Landing |
|
|
22
|
+
|---|---|---|---|---|
|
|
23
|
+
| `impeccable` | `pbakaus/impeccable` @ `<redacted>` (skill v4.1.x) | Apache-2.0 | **dangerous** — floor: 3 critical (a `bypassPermissions` marker in the skill's own live copy-edit agent; an instruction-override phrase in a test fixture; a same-file credential-store + http-client co-occurrence in a test), 19 high (shipped PostToolUse + Stop hooks, credential-store references), 107 medium, 147 low; panel: hooks written into five harness configs (globally on a global install), a child agent spawned with permissions bypassed from a loopback HTTP route, an autonomy directive that tells the agent to disregard its system prompt, a live-server token embedded in page source, an unverified remote bundle download | rebuild spec → GDS task |
|
|
24
|
+
| `design-taste-frontend`, `design-taste-frontend-v1`, `high-end-visual-design`, `minimalist-ui`, `industrial-brutalist-ui`, `redesign-existing-projects`, `gpt-taste`, `stitch-design-taste`, `image-to-code`, `imagegen-frontend-web`, `imagegen-frontend-mobile`, `brandkit` | `Leonxlnx/taste-skill` @ `<redacted>` | MIT | **dangerous** — floor: 1 critical (an injection-phrase hit at `skills/taste-skill/SKILL.md:892`, which the panel identifies as a filename placeholder; the floor is untouchable by design); panel: caution (1 medium: the only documented install path runs an unpinned third-party `npx` CLI; 21 low: mandated invocation of any available image-generation tool, unbounded image counts, remote assets and scripts in generated pages, an auto-loaded cross-agent instruction file) | rebuild spec → GDS task |
|
|
25
|
+
|
|
26
|
+
The eleven taste skills are single-file prose; the floor's one critical is a false positive in the human sense and a true positive in the scanner's — and the rule that makes the scanner worth having is that a session does not get to tell the difference. Vendoring any of them anyway is an **owner override recorded in an ADR**, not a judgement a task makes.
|
|
27
|
+
|
|
28
|
+
**The playwright plugin** (`claude-plugins-official`, an MCP plugin) is the LIVE browser-driving loop, where the kit is the headless one. It is a prerequisite, documented — `claude plugin install playwright@claude-plugins-official` — never vendored.
|
|
29
|
+
|
|
30
|
+
## How to land one (when a scan says it may)
|
|
31
|
+
|
|
32
|
+
1. `/scan-before-install <origin url>` at `default` or higher; keep the report; record the row.
|
|
33
|
+
2. `git init <tmp> && git -C <tmp> fetch --depth 1 <url> <sha> && git -C <tmp> checkout --detach FETCH_HEAD` — the pinned commit only, never a branch.
|
|
34
|
+
3. Copy the skill's directory to `vendor/<name>/`; add the upstream `LICENSE`; write `PROVENANCE.md` from `policy.json`'s `sidecarRequiredFields`.
|
|
35
|
+
4. Insert the contents of `PREAMBLE.md` as the first thing after the SKILL.md frontmatter.
|
|
36
|
+
5. Add `<name>` to `module.json` `contributes.skills`; add its line to `/design` (`.claude/skills/design/SKILL.md`) and to the roster above.
|
|
37
|
+
6. `node tests/ui_design_skills.mjs` — it refuses a missing sidecar, an off-list licence, a dangerous verdict, a drifted preamble, an undeclared dir.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_doc": "The vendoring policy for third-party UI skills in the ui-design module (task 1003321, ADR 0198). tests/ui_design_skills.mjs enforces it; modules/ui-design/skills/README.md explains it. A skill lands under skills/vendor/<name>/ only with a PROVENANCE.md sidecar, a licence on the allowlist, a /scan-before-install verdict that is not dangerous, and the PLATFORM PREAMBLE block (PREAMBLE.md, byte-identical) at the top of its SKILL.md body. A dangerous verdict or a licence off the allowlist lands a rebuild spec, never a copy.",
|
|
3
|
+
"licenceAllowlist": ["Apache-2.0", "MIT", "BSD-2-Clause", "BSD-3-Clause", "CC-BY-4.0"],
|
|
4
|
+
"verdictsThatMayLand": ["clean", "caution"],
|
|
5
|
+
"sidecar": "PROVENANCE.md",
|
|
6
|
+
"preamble": {
|
|
7
|
+
"source": "PREAMBLE.md",
|
|
8
|
+
"begin": "<!-- BEGIN PLATFORM PREAMBLE",
|
|
9
|
+
"end": "<!-- END PLATFORM PREAMBLE -->"
|
|
10
|
+
},
|
|
11
|
+
"sidecarRequiredFields": ["name", "origin", "sourcePath", "commit", "licence", "scanVerdict", "scanTier", "scanReport", "scannedOn", "vendoredOn"]
|
|
12
|
+
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.398",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.398",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.398",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The landing's interaction contract for modules/ui-design/kit/probe.js (task 1003320): the same disclosure component as the hub, on the front page.",
|
|
3
|
+
"page": "/",
|
|
4
|
+
"surface": "public-landing",
|
|
5
|
+
"probes": [
|
|
6
|
+
{ "name": "landing: Enter opens Projects on the first item", "auth": true, "width": 1440,
|
|
7
|
+
"steps": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 150]],
|
|
8
|
+
"expect": { "open": "#menuProjects", "activeText": "Sky" } },
|
|
9
|
+
{ "name": "landing: arrows walk the visible items (Your projects shows signed in)", "continue": true,
|
|
10
|
+
"steps": [["press", "ArrowDown"], ["press", "ArrowDown"]],
|
|
11
|
+
"expect": { "activeText": "Your projects" } },
|
|
12
|
+
{ "name": "landing: Escape closes and returns focus", "continue": true,
|
|
13
|
+
"steps": [["press", "Escape"], ["wait", 100]],
|
|
14
|
+
"expect": { "closed": "#menuProjects", "activeId": "projTrig" } },
|
|
15
|
+
{ "name": "landing: the profile band closes the Projects band", "continue": true,
|
|
16
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["click", "#who .trig"], ["wait", 120]],
|
|
17
|
+
"expect": { "open": "#menuProfile", "closed": "#menuProjects" } },
|
|
18
|
+
{ "name": "landing: the profile band names the person; sign out keeps the honest href", "continue": true,
|
|
19
|
+
"steps": [],
|
|
20
|
+
"expect": { "href": [["#menuProfile a.signout", "/api/bongos/auth/logout"]], "text": [["#bandWho", "builder"]], "count": [["#bandWho img", 1]] } },
|
|
21
|
+
{ "name": "landing: a click outside closes", "continue": true,
|
|
22
|
+
"steps": [["clickAt", 700, 700], ["wait", 120]],
|
|
23
|
+
"expect": { "closed": "#menuProfile", "expanded": false } },
|
|
24
|
+
{ "name": "landing: the band deep-links the hub views + About + Docs", "continue": true,
|
|
25
|
+
"steps": [["click", "#projTrig"], ["wait", 120]],
|
|
26
|
+
"expect": { "count": [["#menuProjects a", 6]], "href": [["#menuProjects .bandCell:nth-child(1) a", "/projects?view=map"], ["#menuProjects .bandCell:nth-child(2) a", "/projects?view=map&sub=table"], ["#menuProjects a[href=\"#about\"]", "#about"], ["#menuProjects a[href=\"/docs\"]", "/docs"]] } },
|
|
27
|
+
{ "name": "landing: #about exists", "continue": true,
|
|
28
|
+
"steps": [["press", "Escape"]],
|
|
29
|
+
"expect": { "count": [["#about", 1]] } },
|
|
30
|
+
{ "name": "landing signed out: the door, no chip, no Your-projects cell", "auth": false, "width": 1440,
|
|
31
|
+
"steps": [],
|
|
32
|
+
"expect": { "visible": ["#who a.signin"], "count": [["#who .trig", 0]], "hidden": ["#mineCell"] } },
|
|
33
|
+
{ "name": "landing signed out: nothing throws", "continue": true,
|
|
34
|
+
"steps": [["click", "#projTrig"], ["wait", 100], ["press", "Escape"], ["wait", 100]],
|
|
35
|
+
"expect": { "closed": "#menuProjects" } }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The landing's states for modules/ui-design/kit/render.js (task 1003320): the bar and its two bands, signed out and in.",
|
|
3
|
+
"page": "/",
|
|
4
|
+
"surface": "public-landing",
|
|
5
|
+
"modeQuery": "mode",
|
|
6
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/"],
|
|
7
|
+
"ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
|
|
8
|
+
"ignoreControls": [".skip", ".vh"],
|
|
9
|
+
"states": {
|
|
10
|
+
"out": { "auth": false, "expect": { "expanded": false, "visible": ["#who a.signin"], "hidden": ["#mineCell"] } },
|
|
11
|
+
"in": { "auth": true, "expect": { "expanded": false, "visible": ["#who .trig"] } },
|
|
12
|
+
"out-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProjects", "count": [["#menuProjects [aria-current]", 0]] } },
|
|
13
|
+
"in-profile": { "auth": true, "actions": [["click", "#who .trig"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProfile" } }
|
|
14
|
+
},
|
|
15
|
+
"reducedMotion": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 100]] }
|
|
16
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The hub's interaction contract for modules/ui-design/kit/probe.js (task 1003320): the disclosure bands, the views, the card, the fold under 480, signed out. A probe with `continue` keeps the previous page — the contract is a sequence.",
|
|
3
|
+
"page": "/projects",
|
|
4
|
+
"surface": "public-landing",
|
|
5
|
+
"probes": [
|
|
6
|
+
{ "name": "Enter opens Projects and lands on the first item", "auth": true, "width": 1440,
|
|
7
|
+
"steps": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 150]],
|
|
8
|
+
"expect": { "open": "#menuProjects", "activeText": "Sky" } },
|
|
9
|
+
{ "name": "ArrowDown walks to Table", "continue": true,
|
|
10
|
+
"steps": [["press", "ArrowDown"]],
|
|
11
|
+
"expect": { "activeText": "Table" } },
|
|
12
|
+
{ "name": "ArrowUp wraps to the last item (Docs, the About/Docs row)", "continue": true,
|
|
13
|
+
"steps": [["press", "ArrowUp"], ["press", "ArrowUp"]],
|
|
14
|
+
"expect": { "activeText": "Docs" } },
|
|
15
|
+
{ "name": "Escape closes and returns focus to the trigger", "continue": true,
|
|
16
|
+
"steps": [["press", "Escape"], ["wait", 100]],
|
|
17
|
+
"expect": { "closed": "#menuProjects", "activeId": "projTrig", "expanded": false } },
|
|
18
|
+
{ "name": "opening the profile band closes the Projects band", "continue": true,
|
|
19
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["click", "#who"], ["wait", 120]],
|
|
20
|
+
"expect": { "open": "#menuProfile", "closed": "#menuProjects", "expandedIds": ["who"] } },
|
|
21
|
+
{ "name": "a click outside closes", "continue": true,
|
|
22
|
+
"steps": [["clickAt", 700, 600], ["wait", 120]],
|
|
23
|
+
"expect": { "closed": "#menuProfile", "expanded": false } },
|
|
24
|
+
{ "name": "Tab out of the band closes it", "continue": true,
|
|
25
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["focus", "#menuProjects a[href=\"/docs\"]"], ["press", "Tab"], ["wait", 120]],
|
|
26
|
+
"expect": { "closed": "#menuProjects" } },
|
|
27
|
+
{ "name": "Table from the band: the table on stage, Table current, band closed", "continue": true,
|
|
28
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"table\"]"], ["wait", 300]],
|
|
29
|
+
"expect": { "visible": ["#sub-table-body"], "closed": "#menuProjects", "text": [["[aria-current=\"page\"]", "Table"]] } },
|
|
30
|
+
{ "name": "Sky from the band: the sky back on stage, Sky current", "continue": true,
|
|
31
|
+
"steps": [["click", "#projTrig"], ["wait", 120], ["click", "a[data-sub=\"sky\"]"], ["wait", 300]],
|
|
32
|
+
"expect": { "hidden": ["#sub-table-body"], "text": [["[aria-current=\"page\"]", "Sky"]] } },
|
|
33
|
+
{ "name": "Your projects: the search hides, the URL says mine", "continue": true,
|
|
34
|
+
"steps": [["fill", "#mapQ", "demo"], ["wait", 200], ["click", "#projTrig"], ["wait", 120], ["click", "a[data-nav=\"mine\"]"], ["wait", 300]],
|
|
35
|
+
"expect": { "hidden": ["#qWrap", "#qToggle"], "param": { "view": "mine" } } },
|
|
36
|
+
{ "name": "Back to the map restores the typed filter", "continue": true,
|
|
37
|
+
"steps": [["back"], ["wait", 400]],
|
|
38
|
+
"expect": { "visible": ["#qWrap"], "value": [["#mapQ", "demo"]] } },
|
|
39
|
+
{ "name": "the profile band names the person and keeps the honest sign-out href", "continue": true,
|
|
40
|
+
"steps": [["fill", "#mapQ", ""], ["wait", 200], ["click", "#who"], ["wait", 120]],
|
|
41
|
+
"expect": { "href": [["#menuProfile a.signout", "/api/bongos/auth/logout"]], "text": [["#bandWho", "builder"]], "count": [["#bandWho img", 1]] } },
|
|
42
|
+
{ "name": "My projects (profile band) switches the view and closes the band", "continue": true,
|
|
43
|
+
"steps": [["click", "#menuProfile a[data-nav-to=\"mine\"]"], ["wait", 300]],
|
|
44
|
+
"expect": { "param": { "view": "mine" }, "closed": "#menuProfile", "text": [["[aria-current=\"page\"]", "Your projects"]] } },
|
|
45
|
+
{ "name": "an orb opens the card", "continue": true,
|
|
46
|
+
"steps": [["back"], ["wait", 400], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 400]],
|
|
47
|
+
"expect": { "dialog": true } },
|
|
48
|
+
{ "name": "Escape closes the card when no band is open", "continue": true,
|
|
49
|
+
"steps": [["press", "Escape"], ["wait", 150]],
|
|
50
|
+
"expect": { "dialog": false } },
|
|
51
|
+
{ "name": "a solo Shift tap focuses the search on the map", "continue": true,
|
|
52
|
+
"steps": [["clickAt", 700, 600], ["press", "Shift"], ["wait", 100]],
|
|
53
|
+
"expect": { "activeId": "mapQ" } },
|
|
54
|
+
{ "name": "the mode toggle: the label says what the press does, and the press does it", "continue": true,
|
|
55
|
+
"steps": [["clickAt", 700, 600], ["click", "#modeToggle"], ["wait", 100]],
|
|
56
|
+
"expect": { "attr": [["#modeToggle", "aria-label", "Switch to light mode"], ["html", "data-mode", "dark"]] } },
|
|
57
|
+
{ "name": "signed out: no chip, the door shows, no switch, the search stays", "auth": false, "width": 1440,
|
|
58
|
+
"steps": [],
|
|
59
|
+
"expect": { "hidden": ["#who", ".switch"], "visible": ["#signinTop", "#qWrap"] } },
|
|
60
|
+
{ "name": "signed out the Projects band is Sky / Table / New project + About / Docs (no Your projects)", "continue": true,
|
|
61
|
+
"steps": [["click", "#projTrig"], ["wait", 120]],
|
|
62
|
+
"expect": { "count": [["#menuProjects .bandCell:not([hidden])", 5]], "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
|
|
63
|
+
{ "name": "signed out: the card still closes on Escape (nothing throws with no session)", "continue": true,
|
|
64
|
+
"steps": [["press", "Escape"], ["wait", 100], ["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 400], ["press", "Escape"], ["wait", 150]],
|
|
65
|
+
"expect": { "dialog": false, "closed": "#menuProjects" } },
|
|
66
|
+
{ "name": "390: the magnifier opens the row and focuses the field", "auth": true, "width": 390,
|
|
67
|
+
"steps": [["click", "#qToggle"], ["wait", 150]],
|
|
68
|
+
"expect": { "activeId": "mapQ", "hasClass": [["#qWrap", "is-open"]] } },
|
|
69
|
+
{ "name": "390: Escape folds the row and returns focus to the magnifier", "continue": true,
|
|
70
|
+
"steps": [["press", "Escape"], ["wait", 100]],
|
|
71
|
+
"expect": { "activeId": "qToggle", "hasClass": [["#qWrap", "is-open", false]] } },
|
|
72
|
+
{ "name": "390: an opening band folds the search", "continue": true,
|
|
73
|
+
"steps": [["click", "#qToggle"], ["wait", 100], ["click", "#projTrig"], ["wait", 150]],
|
|
74
|
+
"expect": { "hasClass": [["#qWrap", "is-open", false]], "open": "#menuProjects" } },
|
|
75
|
+
{ "name": "390: a solo Shift tap opens the fold and focuses the field, without overflow", "continue": true,
|
|
76
|
+
"steps": [["press", "Escape"], ["wait", 100], ["press", "Shift"], ["wait", 150]],
|
|
77
|
+
"expect": { "hasClass": [["#qWrap", "is-open", true]], "activeId": "mapQ", "noOverflow": true } }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "The hub's states for modules/ui-design/kit/render.js (task 1003320): name → url + actions + expectations. `auth` picks the signed-in stub. Rendered dark + light at 1440/390/320 and audited against the platform floors.",
|
|
3
|
+
"page": "/projects",
|
|
4
|
+
"surface": "public-landing",
|
|
5
|
+
"modeQuery": "mode",
|
|
6
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/my-projects", "/provisioning/"],
|
|
7
|
+
"ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
|
|
8
|
+
"ignoreControls": [".skip", ".vh"],
|
|
9
|
+
"states": {
|
|
10
|
+
"out-map": { "auth": false, "expect": { "expanded": false } },
|
|
11
|
+
"in-map": { "auth": true, "expect": { "expanded": false } },
|
|
12
|
+
"in-map-projects": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProjects" } },
|
|
13
|
+
"in-map-profile": { "auth": true, "actions": [["click", "#who"], ["wait", 350]], "expect": { "expanded": true, "open": "#menuProfile" } },
|
|
14
|
+
"in-map-kbd": { "auth": true, "actions": [["focus", "#projTrig"], ["press", "Enter"], ["wait", 350], ["press", "ArrowDown"], ["wait", 120]], "expect": { "expanded": true, "activeText": "Table" } },
|
|
15
|
+
"in-map-search": { "auth": true, "actions": [["clickIfVisible", "#qToggle"], ["wait", 200], ["fill", "#mapQ", "emer"], ["wait", 300]], "expect": { "value": [["#mapQ", "emer"]] } },
|
|
16
|
+
"in-map-mine-on": { "auth": true, "actions": [["check", "#onlyMine"], ["wait", 300]], "expect": { "checked": "#onlyMine" } },
|
|
17
|
+
"in-table": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 250], ["click", "a[data-sub=\"table\"]"], ["wait", 400]], "expect": { "visible": ["#sub-table-body"], "closed": "#menuProjects" } },
|
|
18
|
+
"in-mine": { "auth": true, "url": "/projects?view=mine", "expect": { "hidden": ["#qWrap"] } },
|
|
19
|
+
"in-new": { "auth": true, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
|
|
20
|
+
"out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
|
|
21
|
+
"out-map-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
|
|
22
|
+
"card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb:not(#selfOrb)"], ["wait", 600]], "expect": { "dialog": true } }
|
|
23
|
+
},
|
|
24
|
+
"reducedMotion": { "auth": true, "actions": [["click", "#projTrig"], ["wait", 100]] }
|
|
25
|
+
}
|