@bongos/core 1.19.544 → 1.19.545

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.
@@ -139,24 +139,160 @@ function buildSynthetic({ nodes, goals, density, seed = 1003282 }) {
139
139
  return tasks;
140
140
  }
141
141
 
142
+ // depIdsOf — the dependency ids on one row of a real dump, whatever the API called
143
+ // them. This is load-bearing and was WRONG until task 1003477: `GET /tasks?include=deps`
144
+ // answers with `blocked_by` (an array of {id,title,status,goal_id,satisfied} — see
145
+ // db.attachTaskDepSummaries), and this loader only ever looked for `dependencies`/`deps`.
146
+ // A real dump therefore parsed as a corpus with ZERO edges, and every --from number
147
+ // would have described an empty graph while looking perfectly plausible — the exact
148
+ // failure a spike must not have, because nothing about the output says "no edges here
149
+ // because the field was misnamed". All three spellings are accepted now, and each may
150
+ // hold bare ids or objects carrying one.
151
+ function depIdsOf(row) {
152
+ // First NON-EMPTY array wins, not the first field present: a dump carrying both an
153
+ // empty `blocked_by` and a populated `dependencies` would otherwise read as edgeless,
154
+ // which is the same silent zero this function was written to stop.
155
+ const raw = [row.blocked_by, row.dependencies, row.deps]
156
+ .find((v) => Array.isArray(v) && v.length > 0) || [];
157
+ return raw
158
+ .map((d) => Number(d && typeof d === 'object' ? d.id : d))
159
+ .filter(Number.isFinite);
160
+ }
161
+
142
162
  // loadReal — the same shape from a real `GET /tasks?include=deps` dump, so the next
143
163
  // session with DB access can replace every synthetic number in the verdict with a
144
164
  // measured one by re-running with --from.
165
+ //
166
+ // GOALS: a task with no goal does NOT share a bucket with the other ungoaled tasks —
167
+ // "no goal" is not a goal they have in common, and pooling them would silently turn
168
+ // every dependency between two ungoaled tasks into a WITHIN-goal edge, understating
169
+ // the one number this loader exists to produce. Each gets its own bucket instead;
170
+ // `goalId: null` marks them so census() can report them apart from real goals.
145
171
  function loadReal(file) {
146
172
  const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
147
173
  const rows = Array.isArray(raw) ? raw : (raw.tasks || raw.rows || []);
148
174
  const goalIndex = new Map();
175
+ let nextBucket = 0;
149
176
  return rows.map((r) => {
150
- const g = r.goal_id == null ? '__none__' : String(r.goal_id);
151
- if (!goalIndex.has(g)) goalIndex.set(g, goalIndex.size);
177
+ const gid = r.goal_id == null || r.goal_id === '' ? null : String(r.goal_id);
178
+ let bucket;
179
+ if (gid === null) {
180
+ bucket = nextBucket++; // ungoaled: a bucket of one
181
+ } else {
182
+ if (!goalIndex.has(gid)) goalIndex.set(gid, nextBucket++);
183
+ bucket = goalIndex.get(gid);
184
+ }
152
185
  return {
153
186
  id: Number(r.id),
154
- goal: goalIndex.get(g),
155
- deps: (r.dependencies || r.deps || []).map(Number).filter(Number.isFinite),
187
+ goal: bucket,
188
+ goalId: gid,
189
+ deps: depIdsOf(r),
156
190
  };
157
191
  });
158
192
  }
159
193
 
194
+ // census — THE TWO NUMBERS task 1003477 exists to measure, plus the three that decide
195
+ // what they mean. ADR 0227 deferred items 3 (the goal graph) and 4 (the dependency
196
+ // forest) on a SYNTHETIC density sweep and said the live corpus must settle them.
197
+ //
198
+ // with_dependency_pct — the share of tasks carrying at least one dependency row.
199
+ // Item 4's question: is there a forest, or is the corpus
200
+ // confetti? Note this is OUT-degree (what a task waits on),
201
+ // not the degree-0 count the table's `isol%` reports — a task
202
+ // that only BLOCKS others declares no row of its own but is
203
+ // still drawn, so the two numbers differ and mean different
204
+ // things.
205
+ // cross_goal_pct — the share of dependency edges whose two ends sit in
206
+ // different goals. Item 3's question: does the goal graph
207
+ // have EDGES, or is it forty disconnected dots?
208
+ //
209
+ // And what turns those into a verdict: how many goals are left with no cross-goal edge
210
+ // at all (isolated dots a node-link layout has nothing to say about), how many separate
211
+ // components the forest breaks into, and how big the typical one is — a forest of 128
212
+ // four-node trees is a drill-down, not a map, however many edges it contains.
213
+ function census(tasks) {
214
+ const byId = new Map(tasks.map((t) => [t.id, t]));
215
+ const edges = [];
216
+ let withDep = 0;
217
+ for (const t of tasks) {
218
+ const live = t.deps.filter((d) => byId.has(d));
219
+ if (t.deps.length > 0) withDep += 1;
220
+ for (const d of live) edges.push([t.id, d]);
221
+ }
222
+
223
+ let crossGoal = 0;
224
+ let touchingUngoaled = 0;
225
+ const goalPairs = new Set();
226
+ const goalsWithEdge = new Set();
227
+ for (const [a, b] of edges) {
228
+ const ga = byId.get(a).goalId;
229
+ const gb = byId.get(b).goalId;
230
+ if (ga === null || gb === null) { touchingUngoaled += 1; continue; }
231
+ if (ga === gb) continue;
232
+ crossGoal += 1;
233
+ goalPairs.add(`${ga}>${gb}`);
234
+ goalsWithEdge.add(ga);
235
+ goalsWithEdge.add(gb);
236
+ }
237
+
238
+ const realGoals = new Set(tasks.map((t) => t.goalId).filter((g) => g !== null));
239
+ const ungoaled = tasks.filter((t) => t.goalId === null).length;
240
+
241
+ // The forest: only the tasks with an edge in either direction, and how it breaks up.
242
+ const degree = new Map(tasks.map((t) => [t.id, 0]));
243
+ for (const [a, b] of edges) {
244
+ degree.set(a, degree.get(a) + 1);
245
+ degree.set(b, degree.get(b) + 1);
246
+ }
247
+ const connected = tasks.filter((t) => degree.get(t.id) > 0).map((t) => t.id);
248
+ const comp = components(connected, edges);
249
+ const sizes = componentSizes(connected, edges);
250
+ const median = sizes.length ? sizes[Math.floor(sizes.length / 2)] : 0;
251
+
252
+ // THE DRILL-DOWN, which is the shape item 4 is actually scoped as ("queried when a
253
+ // goal is opened") — not the whole-corpus forest. Per goal: the tasks with an edge
254
+ // whose OTHER end sits in the same goal. Its WORST case is the number that decides
255
+ // the item, because a drill-down has to survive its biggest goal, not its median one.
256
+ const perGoal = new Map();
257
+ for (const [a, b] of edges) {
258
+ const ga = byId.get(a).goalId;
259
+ if (ga === null || ga !== byId.get(b).goalId) continue;
260
+ if (!perGoal.has(ga)) perGoal.set(ga, new Set());
261
+ perGoal.get(ga).add(a);
262
+ perGoal.get(ga).add(b);
263
+ }
264
+ const perGoalSizes = [...perGoal.values()].map((v) => v.size).sort((x, y) => y - x);
265
+ const perGoalMax = perGoalSizes[0] || 0;
266
+ const perGoalMedian = perGoalSizes.length ? perGoalSizes[Math.floor(perGoalSizes.length / 2)] : 0;
267
+
268
+ const pct = (n, d) => (d ? Number(((n / d) * 100).toFixed(2)) : 0);
269
+ return {
270
+ tasks: tasks.length,
271
+ edges: edges.length,
272
+ with_dependency: withDep,
273
+ with_dependency_pct: pct(withDep, tasks.length),
274
+ connected: connected.length,
275
+ connected_pct: pct(connected.length, tasks.length),
276
+ cross_goal: crossGoal,
277
+ cross_goal_pct: pct(crossGoal, edges.length),
278
+ edges_touching_ungoaled: touchingUngoaled,
279
+ goals: realGoals.size,
280
+ ungoaled_tasks: ungoaled,
281
+ goal_pairs: goalPairs.size,
282
+ goals_with_cross_goal_edge: goalsWithEdge.size,
283
+ goals_isolated: realGoals.size - goalsWithEdge.size,
284
+ goals_isolated_pct: pct(realGoals.size - goalsWithEdge.size, realGoals.size),
285
+ forest_components: comp.count,
286
+ forest_largest: comp.largest,
287
+ forest_median_component: median,
288
+ per_goal_forests: perGoalSizes.length,
289
+ per_goal_largest: perGoalMax,
290
+ per_goal_median: perGoalMedian,
291
+ // The drill-down clears the bar only if its BIGGEST goal still fits labels.
292
+ per_goal_largest_labels: inkBudget(perGoalMax || 1).fits_with_label,
293
+ };
294
+ }
295
+
160
296
  // ---------------------------------------------------------------------------
161
297
  // graph views — the literal ask, and the two shapes it could become
162
298
  // ---------------------------------------------------------------------------
@@ -223,7 +359,11 @@ function viewsOf(tasks) {
223
359
 
224
360
  // weakly-connected components, ignoring edge direction — a reader does not perceive
225
361
  // arrow direction when deciding "are these two blobs the same thing".
226
- function components(nodes, edges) {
362
+ // componentSizes — the same union-find, returning the raw sizes largest-first. Split
363
+ // out because "how many components" and "how big is the typical one" are different
364
+ // questions: 128 components says a view is fragmented, but only the MEDIAN size says
365
+ // whether each fragment is a readable little tree or a hairball of its own (census()).
366
+ function componentSizes(nodes, edges) {
227
367
  const idx = new Map(nodes.map((n, i) => [n, i]));
228
368
  const parent = nodes.map((_, i) => i);
229
369
  const find = (x) => { while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; } return x; };
@@ -238,7 +378,11 @@ function components(nodes, edges) {
238
378
  const r = find(i);
239
379
  sizes.set(r, (sizes.get(r) || 0) + 1);
240
380
  }
241
- const all = [...sizes.values()].sort((a, b) => b - a);
381
+ return [...sizes.values()].sort((a, b) => b - a);
382
+ }
383
+
384
+ function components(nodes, edges) {
385
+ const all = componentSizes(nodes, edges);
242
386
  return { count: all.length, largest: all[0] || 0, singletons: all.filter((s) => s === 1).length };
243
387
  }
244
388
 
@@ -658,11 +802,34 @@ function measure(label, view, { render: doRender, file, radius = 3 } = {}) {
658
802
  };
659
803
  }
660
804
 
805
+ // printCensus — the two gating numbers first and in words, because a reader who
806
+ // stops after the first screen must still leave with the answer ADR 0227 asked for.
807
+ function printCensus(c) {
808
+ const cg = c.goals_with_cross_goal_edge;
809
+ console.log('live corpus census — the numbers ADR 0227 items 3 and 4 wait on');
810
+ console.log(` corpus ${c.tasks} tasks, ${c.goals} goals`
811
+ + (c.ungoaled_tasks ? `, ${c.ungoaled_tasks} task(s) with no goal` : ''));
812
+ console.log(` Q1 tasks with a dependency ${c.with_dependency} (${c.with_dependency_pct}%) → item 4, the dependency forest`);
813
+ console.log(` Q2 cross-goal edges ${c.cross_goal} of ${c.edges} (${c.cross_goal_pct}%) → item 3, the goal graph`);
814
+ if (c.edges_touching_ungoaled) {
815
+ console.log(` (${c.edges_touching_ungoaled} edge(s) touch a task with no goal and are counted as neither)`);
816
+ }
817
+ console.log(` goal graph shape ${c.goal_pairs} distinct goal pairs; ${cg} goal(s) carry an edge, `
818
+ + `${c.goals_isolated} isolated (${c.goals_isolated_pct}%)`);
819
+ console.log(` forest shape ${c.connected} tasks with any edge (${c.connected_pct}%) in `
820
+ + `${c.forest_components} components; largest ${c.forest_largest}, median ${c.forest_median_component}`);
821
+ console.log(` per-goal drill-down ${c.per_goal_forests} goal(s) have one; largest ${c.per_goal_largest}, `
822
+ + `median ${c.per_goal_median} — worst case ${c.per_goal_largest_labels ? 'still carries labels' : 'CANNOT carry labels'}`);
823
+ console.log('');
824
+ }
825
+
661
826
  function main() {
662
827
  const results = [];
828
+ let realCensus = null;
663
829
 
664
830
  if (FROM) {
665
831
  const tasks = loadReal(path.resolve(REPO_ROOT, FROM));
832
+ realCensus = census(tasks);
666
833
  const v = viewsOf(tasks);
667
834
  results.push(measure('real', v.A, { render: RENDER, file: path.join(OUT_DIR, 'real-a-every-task.png') }));
668
835
  results.push(measure('real', v.B, { render: RENDER, file: path.join(OUT_DIR, 'real-b-dependency-forest.png') }));
@@ -688,13 +855,14 @@ function main() {
688
855
  }
689
856
 
690
857
  if (AS_JSON) {
691
- process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, results }, null, 2)}\n`);
858
+ process.stdout.write(`${JSON.stringify({ canvas: [CANVAS_W, CANVAS_H], nodes: N_TASKS, goals: N_GOALS, census: realCensus, results }, null, 2)}\n`);
692
859
  return;
693
860
  }
694
861
 
695
862
  const pad = (s, n) => String(s).padEnd(n);
696
863
  const rpad = (s, n) => String(s).padStart(n);
697
- console.log(`task-network-map spike — ${N_TASKS} tasks over ${N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
864
+ if (realCensus) printCensus(realCensus);
865
+ console.log(`task-network-map spike — ${realCensus ? realCensus.tasks : N_TASKS} tasks over ${realCensus ? realCensus.goals : N_GOALS} goals, drawn at ${CANVAS_W}×${CANVAS_H}`);
698
866
  console.log(`ink floor: a legible dot needs ${MIN_DOT_PITCH}px of pitch; a label needs ${MIN_LABEL_W}×${MIN_LABEL_H}px `
699
867
  + `(${inkBudget(1).labels_that_fit} labels fit on this canvas, total)`);
700
868
  console.log('');
@@ -725,6 +893,7 @@ if (require.main === module) main();
725
893
  // alpha-blend bug would quietly corrupt the evidence rather than fail loudly.
726
894
  // Exported = coverable; see tests/spike_task_map.mjs.
727
895
  module.exports = {
728
- buildSynthetic, viewsOf, components, layout, crossingCount, inkBudget, mulberry32,
896
+ buildSynthetic, loadReal, depIdsOf, census, viewsOf, components, componentSizes,
897
+ layout, crossingCount, inkBudget, mulberry32,
729
898
  crc32, chunk, writePng, canvas, goalColor, render,
730
899
  };
@@ -1033,7 +1033,7 @@ function mountInternalSurfaces(app) {
1033
1033
  // any-builder and scoped to the caller by the SERVER (GET /help-requests/for-me
1034
1034
  // derives crafts from the viewer's own profile), so signed-in is the right floor.
1035
1035
  const SIGNEDIN_BUILDERS_PAGE_RE =
1036
- /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1036
+ /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1037
1037
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1038
1038
  // page that embeds them.
1039
1039
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.544'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.545'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -149,7 +149,12 @@ test('sidebar groups read by audience, in order', () => {
149
149
  // pages followed by the two CRAFT surfaces side by side: the ideator's queue and
150
150
  // the artist's. Same reasoning as copy-desk for why it is here rather than Docs
151
151
  // (a place you act, not reference material) or You (its subject is the project).
152
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk'],
152
+ // task 1003117 (R06 of goal 1000074): 'studio' — the Studio, artist mode —
153
+ // joins the same group AFTER copy-desk, so the two artist surfaces (the calm
154
+ // room and the flag table) sit side by side, both with the project's own work
155
+ // rather than in Docs (reference) or You (a person). Pinning the order keeps a
156
+ // new tab in the hall's most valuable nav real estate a reviewed decision.
157
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk', 'studio'],
153
158
  'the unlabeled group is the project itself (task 1002425 order)');
154
159
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
155
160
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -52,6 +52,11 @@ const ORACLE = {
52
52
  // every rank by design, and collab's reads (task 1003172) are scoped to the
53
53
  // caller by the server — so for all three, sign-in IS the whole gate.
54
54
  '/builders/copy-desk': 'requireBuilderPage',
55
+ // task 1003117 (R06 of goal 1000074): the Studio (artist mode) — open to every
56
+ // rank like copy-desk (an artist is a craft, not a rank), so sign-in is the
57
+ // whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
58
+ // themselves server-side.
59
+ '/builders/studio': 'requireBuilderPage',
55
60
  '/builders/ideas': 'requireBuilderPage',
56
61
  '/builders/collab': 'requireBuilderPage',
57
62
  // task 1002710: the orientation reading rooms moved DOWN to this tier from
@@ -0,0 +1,143 @@
1
+ // tests/hall_studio_world.mjs — the Studio (artist mode), R06 of goal 1000074
2
+ // (task 1003117). The surface's load-bearing invariants, pinned so a later edit
3
+ // cannot quietly turn the calm room back into a form or a write path.
4
+ //
5
+ // hall_audit.mjs already holds this page to the universal rules (style.css +
6
+ // shell.js present, no colour literal, no page-declared pack var, no bespoke
7
+ // pager/chart, no sideways-scroll style). hall_nav.mjs pins the nav entry and
8
+ // hall_page_gate_map.mjs the sign-in gate. What is left, and lives here, is what
9
+ // only the Studio must be true: it writes nothing, it gates nothing, it is a
10
+ // room and not a table, and it IS the artist mode it claims to be.
11
+ //
12
+ // Pure file-read (no DB/network) — runs in the DB-free unit gate.
13
+
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
23
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
24
+
25
+ const HTML = read('studio.html');
26
+ // Comments legitimately mention markup (e.g. "the page's one <h1>"), so strip
27
+ // them before counting tags — the invariant is about the rendered DOM, not prose.
28
+ const HTML_NC = HTML.replace(/<!--[\s\S]*?-->/g, '');
29
+ const JS = read('studio.js');
30
+ const CSS = read('studio.css');
31
+
32
+ test('the Studio writes NOTHING — the goal\'s no-live-CMS rule, at the surface', () => {
33
+ // Every craft act (coach the grader, remake copy, propose wording) happens on
34
+ // the surface that owns it (the task record, the copy desk) as a claimed,
35
+ // graded, shipped task. This room only READS and ROUTES. A POST/PATCH/DELETE
36
+ // here would be the live-CMS write path the goal forbids (criterion
37
+ // artist-edits-land-as-tasks) — so it must not exist.
38
+ const writes = JS.match(/\.request\(\s*['"](POST|PATCH|PUT|DELETE)['"]/g) || [];
39
+ assert.deepEqual(writes, [], `studio.js must issue no writes; found ${writes.join(', ')}`);
40
+ });
41
+
42
+ test('the reviews read is bounded and correct', () => {
43
+ // Review requests (cascade.js) are filed at backlog, discipline artist. They sit
44
+ // there until dismissed and cascade files one per qualifying ship, so the read
45
+ // MUST carry a limit cap — the unbounded-read shape modules/hall-ui/CLAUDE.md
46
+ // flags as a repeat bug. Assert the actual bound, not just the filter, so the
47
+ // test name does not overclaim.
48
+ assert.match(JS, /\/tasks\?discipline=artist&status=backlog&limit=\$\{REVIEW_LIMIT\}/, 'the backlog artist read carries a limit cap');
49
+ assert.match(JS, /const REVIEW_LIMIT = \d+;/, 'the cap is a named, finite constant');
50
+ assert.match(JS, /source === 'cascade'/, 'and rows are filtered to the cascade (review) source');
51
+ });
52
+
53
+ test('the copy-desk read is best-effort — the room degrades, never errors', () => {
54
+ // An instance without the copy-desk module 404s /copy-desk/queue; the Studio
55
+ // must fall back to reviews alone, so the flags loader returns [] on !ok rather
56
+ // than throwing or blanking the room.
57
+ assert.match(JS, /copy-desk\/queue\?status=open/, 'flags come from the open copy-desk queue');
58
+ assert.match(JS, /if \(!res\.ok\) return \[\];/, 'a failed queue read yields no flags, not an error');
59
+ });
60
+
61
+ test('it is artist mode — the room declares the mode marker', () => {
62
+ // The social-mode pattern (shell.js applySocial): the mode rides an attribute
63
+ // on the document root, set here statically so entering the room IS entering
64
+ // the mode with no paint flash. Losing it makes this just another page.
65
+ assert.match(HTML, /<html[^>]*\bdata-artist="on"/, 'the page declares data-artist="on" on <html>');
66
+ });
67
+
68
+ test('one <main>, one <h1> — the greeting is it', () => {
69
+ // one-header-main-h1, checked here in the static markup so every state (incl.
70
+ // the no-JS fallback) keeps the single heading.
71
+ const h1s = HTML_NC.match(/<h1\b/g) || [];
72
+ assert.equal(h1s.length, 1, 'exactly one <h1>');
73
+ assert.match(HTML_NC, /<h1[^>]*id="studio-greet"/, 'the greeting is the page h1');
74
+ });
75
+
76
+ test('a room, not a table — no tabular markup', () => {
77
+ // The criterion is explicit: "rather than another table of rows". The copy
78
+ // desk is the table; the Studio must not become a second one.
79
+ assert.ok(!/<table\b/.test(HTML_NC) && !/<table\b/.test(JS), 'the Studio renders no <table>');
80
+ });
81
+
82
+ test('the accent is spent only where the rule allows', () => {
83
+ // DESIGN.md's One Warm Thing Rule: the accent lights the pressable (the primary
84
+ // action, .btn-accent, which the shell owns) or the LIVE. The one warm thing
85
+ // this sheet paints is the ambient lamp (an accent wash), documented as the
86
+ // room's live light; nothing else in studio.css may fill from the accent.
87
+ const accentFills = CSS.match(/background:\s*[^;]*var\(--accent\)[^;]*;/g) || [];
88
+ assert.ok(accentFills.length <= 1, `studio.css spends the accent on at most the lamp; found ${accentFills.length}`);
89
+ });
90
+
91
+ // Boot the real shell.js under a DOM stub (the hall_nav.mjs pattern) whose
92
+ // #nav-studio records classList toggles, so applyCraftEmphasis is tested for
93
+ // what it DOES, not just that it exists.
94
+ function bootShell() {
95
+ const src = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
96
+ const makeEl = () => {
97
+ const classes = new Set();
98
+ return {
99
+ dataset: {}, style: {}, title: '', textContent: '', hidden: false, _classes: classes,
100
+ set innerHTML(_v) {}, get innerHTML() { return ''; },
101
+ classList: {
102
+ add: (c) => classes.add(c), remove: (c) => classes.delete(c),
103
+ toggle: (c, on) => { const want = on === undefined ? !classes.has(c) : !!on; if (want) classes.add(c); else classes.delete(c); return want; },
104
+ contains: (c) => classes.has(c),
105
+ },
106
+ setAttribute() {}, getAttribute() { return null; }, removeAttribute() {},
107
+ addEventListener() {}, querySelectorAll() { return []; }, appendChild() {}, focus() {},
108
+ };
109
+ };
110
+ const els = new Map();
111
+ const getEl = (id) => { if (!els.has(id)) els.set(id, makeEl()); return els.get(id); };
112
+ const document = {
113
+ body: { dataset: { page: 'studio' } },
114
+ documentElement: getEl('__html__'),
115
+ getElementById: getEl,
116
+ querySelectorAll() { return []; },
117
+ createElement() { return makeEl(); },
118
+ addEventListener() {}, dispatchEvent() {}, get cookie() { return ''; }, set cookie(_v) {},
119
+ };
120
+ const location = { hostname: 'builders.cloudbongos.com', origin: 'https://builders.cloudbongos.com', pathname: '/studio', hash: '', assign() {}, replace() {} };
121
+ const window = {
122
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
123
+ BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
124
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
125
+ localStorage: { getItem() { return null; }, setItem() {} },
126
+ requestAnimationFrame() {}, scrollTo() {}, location, addEventListener() {},
127
+ };
128
+ const sandbox = { window, document, location, console, setTimeout, clearTimeout, CustomEvent: class {} };
129
+ sandbox.globalThis = sandbox;
130
+ vm.createContext(sandbox);
131
+ vm.runInContext(src, sandbox, { filename: 'shell.js' });
132
+ return { applyAccess: window.OTBShell.applyAccess, navStudio: () => getEl('nav-studio') };
133
+ }
134
+
135
+ test('the Studio nav entry is warmed only for an artist-craft builder', () => {
136
+ const { applyAccess, navStudio } = bootShell();
137
+
138
+ applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist', 'ideator'] } });
139
+ assert.ok(navStudio()._classes.has('nav-item--craft'), 'an artist-craft builder gets the warmed entry');
140
+
141
+ applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['engineer'] } });
142
+ assert.ok(!navStudio()._classes.has('nav-item--craft'), 'and a non-artist builder does not (the class is toggled off)');
143
+ });
Binary file