@bongos/core 1.19.394 → 1.19.396
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 +114 -59
- package/.claude/skills/design/SKILL.md +68 -0
- package/.claude/skills/design-sync/SKILL.md +2 -2
- package/.claude/skills/figma-design-sync/SKILL.md +3 -3
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/design-tokens.neutral.json +1 -1
- package/config/design-tokens.schema.json +1 -1
- package/docs/adr/0197-the-ui-design-module.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/copy-inventory.md +11 -9
- package/docs/copy-registry.json +30 -12
- package/docs/file-map.md +2 -0
- package/docs/modules-contract.md +1 -0
- package/docs/onboarding/slash-commands.md +1 -1
- package/docs/recipes/claude-design-loop.md +3 -3
- package/docs/recipes/figma-design-loop.md +4 -4
- package/modules/hall-ui/public/my-projects.css +22 -0
- package/modules/hall-ui/public/my-projects.html +7 -1
- package/modules/hall-ui/public/my-projects.js +17 -11
- package/modules/platform-identity/routes/my-projects.js +50 -3
- package/modules/ui-design/CLAUDE.md +15 -0
- package/{src/ui → modules/ui-design}/adapters/claude-design/index.js +1 -1
- package/{src/ui → modules/ui-design}/adapters/figma/index.js +3 -3
- package/{docs → modules/ui-design/docs}/design-contract.md +12 -12
- package/modules/ui-design/kit/README.md +3 -0
- package/modules/ui-design/module.json +11 -0
- package/modules/ui-design/scripts/design-sync.js +65 -0
- package/modules/ui-design/scripts/figma-design-sync.js +64 -0
- package/modules/ui-design/scripts/validate-design.js +258 -0
- package/modules/ui-design/skills/README.md +3 -0
- package/modules/ui-design/styles/README.md +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/copy-inventory.js +7 -5
- package/scripts/gds/design-sync.js +9 -59
- package/scripts/gds/doc-cli-guard.js +11 -5
- package/scripts/gds/figma-design-sync.js +9 -59
- package/scripts/gds/fitness-checks-identity.js +5 -3
- package/scripts/gds/publish-manifest.js +1 -1
- package/scripts/gds/validate-design.js +7 -249
- package/src/bongos/module-scope-map.js +18 -0
- package/src/module-api.js +5 -1
- package/tests/design_tokens_sync.mjs +1 -1
- package/tests/doc_cli_guard.mjs +19 -0
- package/tests/figma_adapter.mjs +1 -1
- package/tests/join_application_note.mjs +112 -0
- package/tests/module_loader.mjs +4 -1
|
@@ -196,20 +196,26 @@ const NAME = 'a doc that names a CLI matches the real CLI (G01)';
|
|
|
196
196
|
// a false alarm that pushes the next agent toward silencing the rule via
|
|
197
197
|
// DOC_CLI_ALLOW, which is exactly the outcome the rule exists to prevent.
|
|
198
198
|
//
|
|
199
|
-
// One level of
|
|
200
|
-
//
|
|
201
|
-
//
|
|
199
|
+
// One level of relative requires is enough and keeps the read bounded — either a
|
|
200
|
+
// same-directory sibling (where a carve puts things), or the module-hosted CLI a
|
|
201
|
+
// scripts/gds/ SHIM forwards to: task 1003324 moved the design CLIs into
|
|
202
|
+
// modules/ui-design/scripts/ and left `require('../../modules/ui-design/…')`
|
|
203
|
+
// shims behind, so the flag a doc names is parsed in the module file. Nothing
|
|
204
|
+
// else is followed — a require into src/ is not a CLI phase. A missing target is
|
|
205
|
+
// skipped: the point is to find the flag, not to prove the require graph.
|
|
206
|
+
const MODULE_CLI_RE = /^modules[\\/][^\\/]+[\\/]/;
|
|
202
207
|
function readCliSource(rel) {
|
|
203
208
|
const src = readFileOrNull(rel);
|
|
204
209
|
if (src === null) return null;
|
|
205
210
|
const dir = path.dirname(rel);
|
|
206
211
|
const seen = new Set([rel]);
|
|
207
212
|
const parts = [src];
|
|
208
|
-
const RE = /require\(\s*['"](
|
|
213
|
+
const RE = /require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g;
|
|
209
214
|
let m;
|
|
210
215
|
while ((m = RE.exec(src))) {
|
|
211
216
|
let sib = path.join(dir, m[1]);
|
|
212
217
|
if (!sib.endsWith('.js')) sib += '.js';
|
|
218
|
+
if (path.dirname(sib) !== path.normalize(dir) && !MODULE_CLI_RE.test(sib)) continue; // normalize: join() flips separators on Windows
|
|
213
219
|
if (seen.has(sib)) continue;
|
|
214
220
|
seen.add(sib);
|
|
215
221
|
const sibSrc = readFileOrNull(sib);
|
|
@@ -255,6 +261,6 @@ function main() {
|
|
|
255
261
|
if (require.main === module) main();
|
|
256
262
|
|
|
257
263
|
module.exports = {
|
|
258
|
-
checkDocNamesRealCli, scanDocs, trackedMarkdown, scriptMentionsFlag, stripLineComments,
|
|
264
|
+
checkDocNamesRealCli, scanDocs, trackedMarkdown, scriptMentionsFlag, stripLineComments, readCliSource,
|
|
259
265
|
DOC_CLI_ALLOW, DOC_CLI_RE, DOC_FLAG_RE, UNIVERSAL_FLAGS,
|
|
260
266
|
};
|
|
@@ -1,61 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
'use strict';
|
|
3
|
+
// figma-design-sync.js — thin shim: the Figma push-plan CLI lives in the ui-design
|
|
4
|
+
// module at modules/ui-design/scripts/figma-design-sync.js (task 1003324, ADR 0197).
|
|
3
5
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
// The figma-design-sync skill calls this, then executes the plan's frames
|
|
12
|
-
// against the live Figma MCP tool.
|
|
13
|
-
|
|
14
|
-
const path = require('node:path');
|
|
15
|
-
const fs = require('node:fs');
|
|
16
|
-
const { resolveInstanceRoot } = require(path.resolve(__dirname, '..', '..', 'src', 'instance-config'));
|
|
17
|
-
|
|
18
|
-
// ADR 0108 §1: the design system (src/ui/, config/design-tokens.json) is host
|
|
19
|
-
// content. resolveInstanceRoot() returns the repo root today, so this is
|
|
20
|
-
// byte-identical for the live instance.
|
|
21
|
-
const REPO_ROOT = resolveInstanceRoot();
|
|
22
|
-
|
|
23
|
-
function arg(flag, args) {
|
|
24
|
-
const i = args.indexOf(flag);
|
|
25
|
-
return i !== -1 ? args[i + 1] : null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
(async () => {
|
|
29
|
-
const args = process.argv.slice(2);
|
|
30
|
-
const targetSurface = arg('--surface', args) || undefined;
|
|
31
|
-
|
|
32
|
-
const adapter = require(path.join(REPO_ROOT, 'src', 'ui', 'adapters', 'figma', 'index.js'));
|
|
33
|
-
|
|
34
|
-
const result = await adapter.import({
|
|
35
|
-
repoRoot: REPO_ROOT,
|
|
36
|
-
...(targetSurface ? { surfaces: [{ id: targetSurface, path: path.join(REPO_ROOT, 'src', 'ui', targetSurface) }] } : {}),
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
const { planPath, surfaceIds, summary } = result;
|
|
40
|
-
const files = collectFiles(planPath, planPath);
|
|
41
|
-
|
|
42
|
-
const manifest = { planPath, files, surfaceIds, summary };
|
|
43
|
-
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
|
|
44
|
-
})().catch(err => {
|
|
45
|
-
process.stderr.write(`figma-design-sync: ${err.message}\n`);
|
|
46
|
-
process.exit(1);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
function collectFiles(dir, root) {
|
|
50
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
51
|
-
const out = [];
|
|
52
|
-
for (const e of entries) {
|
|
53
|
-
const full = path.join(dir, e.name);
|
|
54
|
-
if (e.isDirectory()) {
|
|
55
|
-
out.push(...collectFiles(full, root));
|
|
56
|
-
} else {
|
|
57
|
-
out.push(path.relative(root, full));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return out;
|
|
61
|
-
}
|
|
6
|
+
// Kept because `node scripts/gds/<x>.js` is the form the skills and recipes use and
|
|
7
|
+
// the form claude-materialize rewrites to `bongos exec` for a packaged core. Flags
|
|
8
|
+
// pass straight through:
|
|
9
|
+
// node scripts/gds/figma-design-sync.js — build the Figma push plan, print the manifest
|
|
10
|
+
// node scripts/gds/figma-design-sync.js --surface <id> — limit to one surface
|
|
11
|
+
require('../../modules/ui-design/scripts/figma-design-sync.js');
|
|
@@ -274,8 +274,10 @@ function checkNeutralConfigsNoHostIdentity() {
|
|
|
274
274
|
|
|
275
275
|
function checkDesignContract() {
|
|
276
276
|
const { spawnSync } = require('node:child_process');
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
// The validator + adapters live in the ui-design module (task 1003324, ADR 0197);
|
|
278
|
+
// scripts/gds/validate-design.js is a shim over the module file spawned here.
|
|
279
|
+
const validatorPath = path.join(REPO_ROOT, 'modules', 'ui-design', 'scripts', 'validate-design.js');
|
|
280
|
+
const adaptersDir = path.join(REPO_ROOT, 'modules', 'ui-design', 'adapters');
|
|
279
281
|
const hasAdapters = (() => {
|
|
280
282
|
try { return fs.readdirSync(adaptersDir).some((e) => fs.statSync(path.join(adaptersDir, e)).isDirectory()); }
|
|
281
283
|
catch { return false; }
|
|
@@ -290,7 +292,7 @@ function checkDesignContract() {
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
return {
|
|
293
|
-
name: 'design contract valid (docs/design-contract.md / ADR 0081)',
|
|
295
|
+
name: 'design contract valid (modules/ui-design/docs/design-contract.md / ADR 0081)',
|
|
294
296
|
ok: report.ok,
|
|
295
297
|
hardFail: !report.ok && hasAdapters,
|
|
296
298
|
violations: report.errors || [],
|
|
@@ -63,6 +63,7 @@ const PUBLISH_ALLOWLIST = [
|
|
|
63
63
|
'modules/security/',
|
|
64
64
|
'modules/government/', // the same machinery under its post-ADR-0174 name (goal 1000068, task 1003003). Added in lockstep with the protected-surfaces.json entry: publish_manifest asserts every PROTECTED_GLOB is either topology-excluded or publishable, so registering the glob as protected WITHOUT this line default-denies the module from the published core — the open-source methodology would silently lose its permission machinery at the next release.
|
|
65
65
|
'modules/copy-desk/', // the artist's loop over user-facing TEXT (goal 1000074, ADR 0178). default:true, so it MUST be here — and note the ASYMMETRY that makes omitting it worse than useless: its hall surface ships regardless, because modules/hall-ui/ is allowlisted as one subtree. A released core carrying the page and the nav entry but not the routes behind them puts a tab in every instance's hall whose every call 404s. The module and its page are only ever added or removed together; tests/publish_manifest.mjs holds that pair.
|
|
66
|
+
'modules/ui-design/', // the design contract + its adapters/CLIs + the /design playbook (task 1003324, ADR 0197). default:true, so it MUST be here: a released core without it would materialize skills (design, design-sync, figma-design-sync) whose CLIs and adapters do not exist.
|
|
66
67
|
'modules/hall-ui/',
|
|
67
68
|
'modules/status-ui/',
|
|
68
69
|
'modules/dev-box/',
|
|
@@ -105,7 +106,6 @@ const PUBLISH_ALLOWLIST = [
|
|
|
105
106
|
'docs/modules-contract.md',
|
|
106
107
|
'docs/branding-contract.md',
|
|
107
108
|
'docs/branding-fill-prompt.md',
|
|
108
|
-
'docs/design-contract.md',
|
|
109
109
|
// RUNTIME DATA that happens to live under docs/, not documentation (ADR 0178).
|
|
110
110
|
// modules/copy-desk reads the registry on EVERY queue load, and nothing on a
|
|
111
111
|
// deployed box regenerates it — copy-inventory.js is a commit-it generator run
|
|
@@ -1,254 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
// validate-design.js —
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// validate-design.js — thin shim: the design-contract validator lives in the
|
|
4
|
+
// ui-design module at modules/ui-design/scripts/validate-design.js (task 1003324,
|
|
5
|
+
// ADR 0197). fitness.js (checkDesignContract in fitness-checks-identity.js) spawns
|
|
6
|
+
// the module file directly; this path is the documented CLI entry point, kept
|
|
7
|
+
// because it is the form claude-materialize rewrites to `bongos exec` for a
|
|
8
|
+
// packaged core. Flags pass straight through:
|
|
7
9
|
// node scripts/gds/validate-design.js # exits 0 on pass, 1 on failure
|
|
8
10
|
// node scripts/gds/validate-design.js --json # machine-readable { ok, errors, warnings }
|
|
9
11
|
// node scripts/gds/validate-design.js --strict # treat warnings as errors
|
|
10
|
-
|
|
11
|
-
const fs = require('node:fs');
|
|
12
|
-
const path = require('node:path');
|
|
13
|
-
|
|
14
|
-
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
15
|
-
const ARGS = new Set(process.argv.slice(2));
|
|
16
|
-
const JSON_MODE = ARGS.has('--json');
|
|
17
|
-
const STRICT = ARGS.has('--strict');
|
|
18
|
-
|
|
19
|
-
const NEUTRAL_TOKENS = path.join(REPO_ROOT, 'config', 'design-tokens.neutral.json');
|
|
20
|
-
const INSTANCE_TOKENS = path.join(REPO_ROOT, 'config', 'design-tokens.json');
|
|
21
|
-
const TOKENS_SCHEMA = path.join(REPO_ROOT, 'config', 'design-tokens.schema.json');
|
|
22
|
-
const DESIGN_SOURCES = path.join(REPO_ROOT, 'config', 'design-sources.json');
|
|
23
|
-
const UI_SRC = path.join(REPO_ROOT, 'src', 'ui');
|
|
24
|
-
const UI_ASSETS = path.join(REPO_ROOT, 'assets', 'ui');
|
|
25
|
-
const ADAPTERS_DIR = path.join(REPO_ROOT, 'src', 'ui', 'adapters');
|
|
26
|
-
|
|
27
|
-
const TOOL_ENUM = new Set(['claude-design', 'figma', 'hand', 'asset']);
|
|
28
|
-
const REQUIRED_COLORS = ['bg', 'bgCard', 'ink', 'accent'];
|
|
29
|
-
const REQUIRED_FONTS = ['display', 'body'];
|
|
30
|
-
|
|
31
|
-
const errors = [];
|
|
32
|
-
const warnings = [];
|
|
33
|
-
|
|
34
|
-
function err(msg) { errors.push(msg); }
|
|
35
|
-
function warn(msg) { warnings.push(msg); }
|
|
36
|
-
|
|
37
|
-
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
38
|
-
|
|
39
|
-
function readJson(filePath, label) {
|
|
40
|
-
try {
|
|
41
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
42
|
-
} catch (e) {
|
|
43
|
-
err(`${label}: cannot read/parse — ${e.message}`);
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function deepMerge(base, override) {
|
|
49
|
-
if (!override) return base;
|
|
50
|
-
const out = { ...base };
|
|
51
|
-
for (const [k, v] of Object.entries(override)) {
|
|
52
|
-
if (v && typeof v === 'object' && !Array.isArray(v) && typeof out[k] === 'object') {
|
|
53
|
-
out[k] = deepMerge(out[k], v);
|
|
54
|
-
} else {
|
|
55
|
-
out[k] = v;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function isUri(s) {
|
|
62
|
-
try { new URL(s); return true; } catch { return false; }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function dirExists(p) {
|
|
66
|
-
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function fileExists(p) {
|
|
70
|
-
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function listDirs(p) {
|
|
74
|
-
if (!dirExists(p)) return [];
|
|
75
|
-
return fs.readdirSync(p, { withFileTypes: true })
|
|
76
|
-
.filter(e => e.isDirectory())
|
|
77
|
-
.map(e => e.name);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ── Check 1: neutral tokens file exists + parses ─────────────────────────────
|
|
81
|
-
|
|
82
|
-
const schema = readJson(TOKENS_SCHEMA, 'config/design-tokens.schema.json');
|
|
83
|
-
const neutral = readJson(NEUTRAL_TOKENS, 'config/design-tokens.neutral.json');
|
|
84
|
-
|
|
85
|
-
if (!neutral) {
|
|
86
|
-
err('config/design-tokens.neutral.json is missing — this is the required baseline');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── Check 2: instance tokens file, if present, parses ────────────────────────
|
|
90
|
-
|
|
91
|
-
let instance = null;
|
|
92
|
-
if (fileExists(INSTANCE_TOKENS)) {
|
|
93
|
-
instance = readJson(INSTANCE_TOKENS, 'config/design-tokens.json');
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── Check 3: merged tokens satisfy required fields ───────────────────────────
|
|
97
|
-
|
|
98
|
-
const merged = neutral ? deepMerge(neutral, instance) : null;
|
|
99
|
-
|
|
100
|
-
if (merged) {
|
|
101
|
-
if (!merged.colors || typeof merged.colors !== 'object') {
|
|
102
|
-
err('merged tokens: "colors" group is missing');
|
|
103
|
-
} else {
|
|
104
|
-
for (const key of REQUIRED_COLORS) {
|
|
105
|
-
if (!merged.colors[key]) err(`merged tokens: colors.${key} is required but missing`);
|
|
106
|
-
else if (typeof merged.colors[key] !== 'string') err(`merged tokens: colors.${key} must be a string`);
|
|
107
|
-
}
|
|
108
|
-
for (const [key, val] of Object.entries(merged.colors)) {
|
|
109
|
-
if (typeof val !== 'string') err(`merged tokens: colors.${key} must be a string`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (!merged.fonts || typeof merged.fonts !== 'object') {
|
|
114
|
-
err('merged tokens: "fonts" group is missing');
|
|
115
|
-
} else {
|
|
116
|
-
for (const key of REQUIRED_FONTS) {
|
|
117
|
-
if (!merged.fonts[key]) err(`merged tokens: fonts.${key} is required but missing`);
|
|
118
|
-
else if (typeof merged.fonts[key] !== 'string') err(`merged tokens: fonts.${key} must be a string`);
|
|
119
|
-
}
|
|
120
|
-
if (merged.fonts.links !== undefined) {
|
|
121
|
-
if (!Array.isArray(merged.fonts.links)) {
|
|
122
|
-
err('merged tokens: fonts.links must be an array');
|
|
123
|
-
} else {
|
|
124
|
-
for (const link of merged.fonts.links) {
|
|
125
|
-
if (typeof link !== 'string' || !isUri(link)) {
|
|
126
|
-
err(`merged tokens: fonts.links entry is not a valid URI: ${link}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Optional groups: check value types if present
|
|
134
|
-
if (merged.scale) {
|
|
135
|
-
for (const [k, v] of Object.entries(merged.scale)) {
|
|
136
|
-
if (typeof v !== 'string') err(`merged tokens: scale.${k} must be a string`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
if (merged.radius) {
|
|
140
|
-
for (const [k, v] of Object.entries(merged.radius)) {
|
|
141
|
-
if (typeof v !== 'string') err(`merged tokens: radius.${k} must be a string`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
if (merged.motion) {
|
|
145
|
-
for (const [k, v] of Object.entries(merged.motion)) {
|
|
146
|
-
if (typeof v !== 'string') err(`merged tokens: motion.${k} must be a string`);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ── Check 4: design-sources.json is valid + entries have required fields ─────
|
|
152
|
-
|
|
153
|
-
let sources = null;
|
|
154
|
-
if (fileExists(DESIGN_SOURCES)) {
|
|
155
|
-
sources = readJson(DESIGN_SOURCES, 'config/design-sources.json');
|
|
156
|
-
if (sources) {
|
|
157
|
-
if (!sources.surfaces || typeof sources.surfaces !== 'object') {
|
|
158
|
-
err('config/design-sources.json: "surfaces" key must be an object');
|
|
159
|
-
} else {
|
|
160
|
-
for (const [surfaceId, entry] of Object.entries(sources.surfaces)) {
|
|
161
|
-
if (!entry || typeof entry !== 'object') {
|
|
162
|
-
err(`design-sources: surface "${surfaceId}" entry must be an object`);
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
if (!entry.tool) {
|
|
166
|
-
err(`design-sources: surface "${surfaceId}" is missing "tool" field`);
|
|
167
|
-
} else if (!TOOL_ENUM.has(entry.tool)) {
|
|
168
|
-
err(`design-sources: surface "${surfaceId}" tool "${entry.tool}" is not in allowed enum (${[...TOOL_ENUM].join(', ')})`);
|
|
169
|
-
}
|
|
170
|
-
if (entry.sourceUrl !== undefined) {
|
|
171
|
-
if (typeof entry.sourceUrl !== 'string' || !isUri(entry.sourceUrl)) {
|
|
172
|
-
err(`design-sources: surface "${surfaceId}" sourceUrl is not a valid URI`);
|
|
173
|
-
}
|
|
174
|
-
} else if (entry.tool && !['hand', 'asset'].includes(entry.tool)) {
|
|
175
|
-
warn(`design-sources: surface "${surfaceId}" tool "${entry.tool}" has no sourceUrl`);
|
|
176
|
-
}
|
|
177
|
-
if (entry.lastSyncedAt !== undefined) {
|
|
178
|
-
if (typeof entry.lastSyncedAt !== 'string' || isNaN(Date.parse(entry.lastSyncedAt))) {
|
|
179
|
-
err(`design-sources: surface "${surfaceId}" lastSyncedAt is not a valid ISO 8601 date`);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
} else {
|
|
186
|
-
warn('config/design-sources.json not found — this is optional until adapters are in use');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// ── Check 5: design-sources surface ids match directories (if dirs exist) ─────
|
|
190
|
-
|
|
191
|
-
const hasUiSrc = dirExists(UI_SRC);
|
|
192
|
-
const hasUiAssets = dirExists(UI_ASSETS);
|
|
193
|
-
|
|
194
|
-
if (sources && sources.surfaces) {
|
|
195
|
-
const uiSrcDirs = hasUiSrc ? new Set(listDirs(UI_SRC).filter(n => n !== 'adapters')) : new Set();
|
|
196
|
-
const uiAssetsDirs = hasUiAssets ? new Set(listDirs(UI_ASSETS)) : new Set();
|
|
197
|
-
|
|
198
|
-
for (const surfaceId of Object.keys(sources.surfaces)) {
|
|
199
|
-
const inSrc = uiSrcDirs.has(surfaceId);
|
|
200
|
-
const inAssets = uiAssetsDirs.has(surfaceId);
|
|
201
|
-
if (!inSrc && !inAssets && (hasUiSrc || hasUiAssets)) {
|
|
202
|
-
warn(`design-sources: surface "${surfaceId}" has no matching directory in src/ui/ or assets/ui/`);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ── Check 6: adapter directories have valid index.js ─────────────────────────
|
|
208
|
-
|
|
209
|
-
if (dirExists(ADAPTERS_DIR)) {
|
|
210
|
-
for (const adapterName of listDirs(ADAPTERS_DIR)) {
|
|
211
|
-
const indexPath = path.join(ADAPTERS_DIR, adapterName, 'index.js');
|
|
212
|
-
if (!fileExists(indexPath)) {
|
|
213
|
-
err(`adapter "${adapterName}": missing index.js at src/ui/adapters/${adapterName}/index.js`);
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
let mod;
|
|
217
|
-
try {
|
|
218
|
-
mod = require(indexPath);
|
|
219
|
-
} catch (e) {
|
|
220
|
-
err(`adapter "${adapterName}": index.js threw on require — ${e.message}`);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
if (typeof mod.id !== 'string' || !mod.id) {
|
|
224
|
-
err(`adapter "${adapterName}": index.js must export a non-empty string "id"`);
|
|
225
|
-
} else if (mod.id !== adapterName) {
|
|
226
|
-
warn(`adapter "${adapterName}": exported id "${mod.id}" does not match directory name`);
|
|
227
|
-
}
|
|
228
|
-
if (typeof mod.import !== 'function') {
|
|
229
|
-
err(`adapter "${adapterName}": index.js must export an "import" function`);
|
|
230
|
-
}
|
|
231
|
-
if (typeof mod.export !== 'function') {
|
|
232
|
-
err(`adapter "${adapterName}": index.js must export an "export" function`);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Report ────────────────────────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
const effectiveErrors = STRICT ? [...errors, ...warnings] : errors;
|
|
240
|
-
const ok = effectiveErrors.length === 0;
|
|
241
|
-
|
|
242
|
-
if (JSON_MODE) {
|
|
243
|
-
process.stdout.write(JSON.stringify({ ok, errors, warnings }, null, 2) + '\n');
|
|
244
|
-
} else {
|
|
245
|
-
const label = ok ? '✓ design contract valid' : '✗ design contract invalid';
|
|
246
|
-
process.stdout.write(`${label}\n`);
|
|
247
|
-
for (const e of errors) process.stdout.write(` ERROR ${e}\n`);
|
|
248
|
-
for (const w of warnings) process.stdout.write(` WARNING ${w}\n`);
|
|
249
|
-
if (ok && !STRICT && warnings.length) {
|
|
250
|
-
process.stdout.write(` (${warnings.length} warning(s); use --strict to fail on warnings)\n`);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
process.exit(ok ? 0 : 1);
|
|
12
|
+
require('../../modules/ui-design/scripts/validate-design.js');
|
|
@@ -208,6 +208,24 @@ const MODULE_GLOBS = {
|
|
|
208
208
|
// than instance hosting, and the web-surface precedent (hall-ui/status-ui) is
|
|
209
209
|
// one key per surface (task 1002690, goal 1000059).
|
|
210
210
|
'public-landing': ['public-landing/'],
|
|
211
|
+
// --- the design capability (task 1003324, ADR 0197): the design contract, its
|
|
212
|
+
// adapters + sync CLIs + validator, the /design playbook, and the reserved homes
|
|
213
|
+
// for the kit / the vendored UI skills / the style library. default-on,
|
|
214
|
+
// declaration-only (three skills, no routes/port/migrations). The three
|
|
215
|
+
// scripts/gds/ shims are core-side entry points over the module's CLIs —
|
|
216
|
+
// listed for task affinity (the memory/ideas precedent: a command travels with
|
|
217
|
+
// its domain). The contributed skill dirs follow the devsecops note above
|
|
218
|
+
// ('.claude/skills/' stays with whichever module ships the skill); the two
|
|
219
|
+
// loop recipes are the module's how-tos. NOT here, deliberately: config/
|
|
220
|
+
// design-tokens.* + design-sources.json (the host boundary, kernel 'config/')
|
|
221
|
+
// and DESIGN.md (the instance's world); public-landing keeps its own key —
|
|
222
|
+
// the front door is this module's first customer, not its owner. ---
|
|
223
|
+
'ui-design': [
|
|
224
|
+
'modules/ui-design/',
|
|
225
|
+
'scripts/gds/design-sync.js', 'scripts/gds/figma-design-sync.js', 'scripts/gds/validate-design.js',
|
|
226
|
+
'.claude/skills/design/', '.claude/skills/design-sync/', '.claude/skills/figma-design-sync/',
|
|
227
|
+
'docs/recipes/claude-design-loop.md', 'docs/recipes/figma-design-loop.md',
|
|
228
|
+
],
|
|
211
229
|
// --- feature modules ---
|
|
212
230
|
// BV1.R49: game graduated to modules/game/ — src/rooms/, src/world/, src/seats.js,
|
|
213
231
|
// src/db.js, migrations/game/, and public/ all moved there. src/preview-server.js
|
package/src/module-api.js
CHANGED
|
@@ -1228,8 +1228,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1228
1228
|
// landed since 1.19.392 with no explicit bump. run 33177136260. (task 1002620)
|
|
1229
1229
|
// 1.19.394 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1230
1230
|
// landed since 1.19.393 with no explicit bump. run 33182535783. (task 1002620)
|
|
1231
|
+
// 1.19.395 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1232
|
+
// landed since 1.19.394 with no explicit bump. run 33188021384. (task 1002620)
|
|
1233
|
+
// 1.19.396 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1234
|
+
// landed since 1.19.395 with no explicit bump. run 33188525568. (task 1002620)
|
|
1231
1235
|
// ---------------------------------------------------------------------------
|
|
1232
|
-
const CORE_VERSION = '1.19.
|
|
1236
|
+
const CORE_VERSION = '1.19.396'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1233
1237
|
|
|
1234
1238
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1235
1239
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -58,7 +58,7 @@ test('the fonts the two files share are the same fonts', () => {
|
|
|
58
58
|
|
|
59
59
|
test('the claude-design adapter falls back to the pack accent, never a brand literal of its own', () => {
|
|
60
60
|
const source = fs.readFileSync(
|
|
61
|
-
path.join(REPO_ROOT, '
|
|
61
|
+
path.join(REPO_ROOT, 'modules', 'ui-design', 'adapters', 'claude-design', 'index.js'), 'utf8');
|
|
62
62
|
const block = source.match(/const FALLBACK_ACCENT =[\s\S]*?\}\)\(\);/);
|
|
63
63
|
assert.ok(block, 'the adapter declares one resolved fallback accent instead of inline literals');
|
|
64
64
|
assert.match(block[0], /design-tokens\.neutral\.json/,
|
package/tests/doc_cli_guard.mjs
CHANGED
|
@@ -181,6 +181,25 @@ test('a flag present in real code produces NO warning', () => {
|
|
|
181
181
|
assert.deepEqual(warnings, []);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
+
test('a scripts/gds/ shim is read together with the module CLI it forwards to (task 1003324)', () => {
|
|
185
|
+
// scripts/gds/validate-design.js is `require('../../modules/ui-design/scripts/validate-design.js')`
|
|
186
|
+
// plus a usage banner; `--strict` is parsed in the module file. Read alone, the shim
|
|
187
|
+
// would only ever say "named in comments" for a flag that is genuinely honoured.
|
|
188
|
+
const src = g.readCliSource('scripts/gds/validate-design.js');
|
|
189
|
+
assert.ok(src, 'the shim exists');
|
|
190
|
+
assert.ok(g.scriptMentionsFlag(g.stripLineComments(src), 'strict'),
|
|
191
|
+
'the flag is found in CODE — the module CLI was followed, not just the shim banner');
|
|
192
|
+
const { violations, warnings } = g.scanDocs(['d.md'], {
|
|
193
|
+
readDoc: () => 'run `node scripts/gds/validate-design.js --strict`',
|
|
194
|
+
readScript: g.readCliSource, allow: new Map(),
|
|
195
|
+
});
|
|
196
|
+
assert.deepEqual(violations, []);
|
|
197
|
+
assert.deepEqual(warnings, [], 'no "only in comments" warning for a shim-forwarded flag');
|
|
198
|
+
// Bounded: a require into src/ is NOT followed (it is not a CLI phase).
|
|
199
|
+
assert.ok(!g.readCliSource('scripts/gds/fitness.js').includes('function requireBuilder'),
|
|
200
|
+
'a core internal the CLI requires is not concatenated into its source');
|
|
201
|
+
});
|
|
202
|
+
|
|
184
203
|
test('stripLineComments keeps code and drops both comment forms', () => {
|
|
185
204
|
const out = g.stripLineComments("const a = '--keep'; // --dropped\n/* --alsodropped */\n");
|
|
186
205
|
assert.ok(out.includes('--keep'));
|
package/tests/figma_adapter.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
|
|
18
18
|
const require = createRequire(import.meta.url);
|
|
19
|
-
const adapter = require('../
|
|
19
|
+
const adapter = require('../modules/ui-design/adapters/figma/index.js');
|
|
20
20
|
|
|
21
21
|
const REPO_ROOT = path.resolve(process.cwd());
|
|
22
22
|
const SURFACE_ID = `__test_figma_${process.pid}`;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/join_application_note.mjs — a project-level application is one click plus
|
|
2
|
+
// an optional note (task 1002330, ADR 0182 D6 / the admin-console spec's D2).
|
|
3
|
+
//
|
|
4
|
+
// The hub does not admit anyone; it relays the application to the project's own
|
|
5
|
+
// public POST /access-requests, which is what the owner's queue reads. So what
|
|
6
|
+
// this file pins is the composition of that relayed row:
|
|
7
|
+
//
|
|
8
|
+
// - the applicant's own words reach the owner, with the hub's provenance line
|
|
9
|
+
// saying where the request came from;
|
|
10
|
+
// - writing nothing is still an application — the pre-1002330 behaviour;
|
|
11
|
+
// - nothing the applicant types can push the relayed fields past the project's
|
|
12
|
+
// own column caps (500 note / 100 display_name), because a 400 from the
|
|
13
|
+
// project is a status joinRelayOutcome cannot read, and the applicant would
|
|
14
|
+
// then be told the project "could not be reached" — a lie about someone
|
|
15
|
+
// else's door caused by our own field.
|
|
16
|
+
//
|
|
17
|
+
// Pure, DB-free.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/join_application_note.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import {
|
|
24
|
+
relayApplicationNote,
|
|
25
|
+
relayApplicantName,
|
|
26
|
+
APPLICANT_NOTE_MAX,
|
|
27
|
+
} from '../modules/platform-identity/routes/my-projects.js';
|
|
28
|
+
|
|
29
|
+
// The caps the PROJECT's public POST /access-requests enforces on the row it
|
|
30
|
+
// stores (modules/onboarding/routes/access-requests.js). Restated here so this
|
|
31
|
+
// test fails if the hub ever starts asking for more than a project will take.
|
|
32
|
+
const PROJECT_NOTE_MAX = 500;
|
|
33
|
+
const PROJECT_NAME_MAX = 100;
|
|
34
|
+
|
|
35
|
+
test('the applicant’s own words reach the owner, with where it came from', () => {
|
|
36
|
+
const note = relayApplicationNote("I'd like to help with the terrain work");
|
|
37
|
+
assert.match(note, /I'd like to help with the terrain work/);
|
|
38
|
+
assert.match(note, /via platform My Projects/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('an application with nothing written is still an application', () => {
|
|
42
|
+
for (const empty of [undefined, null, '', ' ', '\n\t']) {
|
|
43
|
+
assert.equal(relayApplicationNote(empty), 'via platform My Projects',
|
|
44
|
+
`${JSON.stringify(empty)} relays the provenance line alone`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the composed note always fits the project’s column', () => {
|
|
49
|
+
const composed = relayApplicationNote('x'.repeat(5000));
|
|
50
|
+
assert.ok(composed.length <= PROJECT_NOTE_MAX,
|
|
51
|
+
`composed note is ${composed.length} chars, project stores ${PROJECT_NOTE_MAX}`);
|
|
52
|
+
assert.match(composed, /via platform My Projects/, 'provenance survives the clamp');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the hub asks for less note than the project will take', () => {
|
|
56
|
+
const suffix = relayApplicationNote('').length + ' ()'.length;
|
|
57
|
+
assert.ok(APPLICANT_NOTE_MAX + suffix <= PROJECT_NOTE_MAX,
|
|
58
|
+
'the validator cap plus the provenance suffix must fit the project’s 500');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a display name travels so the queue reads as a person, not a bare login', () => {
|
|
62
|
+
assert.equal(relayApplicantName(' Jane Doe '), 'Jane Doe');
|
|
63
|
+
const clamped = relayApplicantName('n'.repeat(400));
|
|
64
|
+
assert.ok(clamped.length <= PROJECT_NAME_MAX);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('no display name relays null — the queue keeps the bare @login it always had', () => {
|
|
68
|
+
for (const empty of [undefined, null, '', ' ']) {
|
|
69
|
+
assert.equal(relayApplicantName(empty), null, `${JSON.stringify(empty)}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The wiring — that the composed note actually travels, and that the applicant's
|
|
75
|
+
// words survive a door refusal. Source assertions (the house pattern in
|
|
76
|
+
// tests/joinability_invite_only.mjs): the relay needs a live hub session and a
|
|
77
|
+
// second running project to exercise, which no unit test can boot.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
import fs from 'node:fs';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
import { fileURLToPath } from 'node:url';
|
|
83
|
+
|
|
84
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
85
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
86
|
+
|
|
87
|
+
test('the relay sends the composed note and the applicant’s name — not a constant', () => {
|
|
88
|
+
const route = src('modules/platform-identity/routes/my-projects.js');
|
|
89
|
+
const join = (route.match(/router\.post\('\/my-projects\/join'[\s\S]*?\n \}\);\n/) || [''])[0];
|
|
90
|
+
assert.ok(join, 'the join route must exist');
|
|
91
|
+
assert.match(join, /note: relayApplicationNote\(req\.body\.note\)/, 'the applicant’s words are composed, then relayed');
|
|
92
|
+
assert.match(join, /display_name: relayApplicantName\(req\.builder\.display_name\)/);
|
|
93
|
+
assert.doesNotMatch(join, /note: 'via platform My Projects'/, 'the hardcoded note is gone');
|
|
94
|
+
assert.match(join, /note: \{ type: 'string', maxLength: APPLICANT_NOTE_MAX \}/, 'the note is declared in the route’s schema');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the join box offers the note as optional, and never demands it', () => {
|
|
98
|
+
const html = src('modules/hall-ui/public/my-projects.html');
|
|
99
|
+
assert.match(html, /<textarea id="join-note"/, 'the note field exists');
|
|
100
|
+
assert.match(html, /maxlength="400"/, 'the field cannot outrun the route’s cap');
|
|
101
|
+
assert.match(html, /Add a note \(optional\)/, 'and it is labelled optional');
|
|
102
|
+
assert.match(html, /id="join-status"/, 'the answer line is its own element, not the note field');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a door refusal keeps what the applicant typed; a filed request clears it', () => {
|
|
106
|
+
const js = src('modules/hall-ui/public/my-projects.js');
|
|
107
|
+
const click = (js.match(/joinBtn\.addEventListener\('click'[\s\S]*?\n \}\);\n/) || [''])[0];
|
|
108
|
+
assert.ok(click, 'the join handler must exist');
|
|
109
|
+
assert.match(click, /note \? \{ origin: origin, note: note \} : \{ origin: origin \}/, 'a blank note is omitted, not sent as ""');
|
|
110
|
+
assert.match(click, /if \(!refused\) \{ joinInput\.value = ''; joinNoteInput\.value = ''; load\(\); \}/,
|
|
111
|
+
'cleared only when the request was actually filed — a refusal keeps the words for the next try');
|
|
112
|
+
});
|
package/tests/module_loader.mjs
CHANGED
|
@@ -176,6 +176,9 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
176
176
|
// off in enabledModules(). And copy-desk (task 1003113 / R02 of goal 1000074) —
|
|
177
177
|
// the copy half of the design contract: the flag + the artist's queue over
|
|
178
178
|
// user-facing text, default-on because every instance shows a person words.
|
|
179
|
+
// And ui-design (task 1003324 / ADR 0197) — the design contract, its adapters
|
|
180
|
+
// and the /design playbook as a platform capability: declaration-only (three
|
|
181
|
+
// skills, no routes/port), default-on because every instance has a face.
|
|
179
182
|
// All are discovered; none has validation
|
|
180
183
|
// errors or coreVersion mismatches. Order is the readdir sort.
|
|
181
184
|
//
|
|
@@ -200,7 +203,7 @@ test('the live tree discovers the extracted modules cleanly (BV1.R43/R48/R47/R49
|
|
|
200
203
|
// That is precisely how the agents regression reached an open PR (task 1002485).
|
|
201
204
|
loader._reset();
|
|
202
205
|
const snap = loader.loaderSnapshot();
|
|
203
|
-
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui']);
|
|
206
|
+
assert.deepEqual(snap.loaded, ['agents', 'autonomy', 'builder-settings', 'copy-desk', 'dev-box', 'discord', 'economy', 'government', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'platform-identity', 'provisioning', 'security', 'sessions', 'status-ui', 'ui-design']);
|
|
204
207
|
assert.deepEqual(snap.errors, []);
|
|
205
208
|
assert.deepEqual(snap.skipped, []);
|
|
206
209
|
loader._reset();
|