@bongos/core 1.19.484 → 1.19.486

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.
Files changed (48) hide show
  1. package/.bongos-core.json +102 -52
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +147 -3
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +169 -178
  12. package/docs/copy-registry.json +295 -405
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +4 -0
  15. package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  17. package/modules/hall-ui/public/goal-inbox.js +119 -1
  18. package/modules/hall-ui/public/not-ready.html +14 -10
  19. package/modules/hall-ui/public/not-ready.states.json +14 -0
  20. package/modules/hall-ui/public/pair.css +17 -67
  21. package/modules/hall-ui/public/pair.html +8 -1
  22. package/modules/hall-ui/public/pair.states.json +14 -0
  23. package/modules/hall-ui/public/panel.css +333 -0
  24. package/modules/hall-ui/public/profile.css +85 -0
  25. package/modules/hall-ui/public/profile.html +12 -5
  26. package/modules/hall-ui/public/profile.states.json +15 -0
  27. package/modules/hall-ui/public/settings.css +119 -336
  28. package/modules/hall-ui/public/settings.html +28 -19
  29. package/modules/hall-ui/public/settings.js +44 -35
  30. package/modules/hall-ui/public/settings.states.json +16 -0
  31. package/modules/hall-ui/public/style.css +5 -2
  32. package/modules/lifecycle/module.json +2 -1
  33. package/modules/lifecycle/rot.js +210 -0
  34. package/modules/lifecycle/routes/rot.js +160 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness-checks-vocabulary.js +0 -1
  38. package/scripts/gds/fitness.js +9 -0
  39. package/src/bongos/project-settings.js +193 -0
  40. package/src/module-api.js +29 -1
  41. package/tests/hall_settings_world.mjs +274 -0
  42. package/tests/join_application_note.mjs +7 -15
  43. package/tests/module-scope-map.mjs +1 -1
  44. package/tests/module_api.mjs +1 -0
  45. package/tests/rot_inbox.mjs +532 -0
  46. package/modules/hall-ui/public/my-projects.css +0 -62
  47. package/modules/hall-ui/public/my-projects.html +0 -73
  48. package/modules/hall-ui/public/my-projects.js +0 -153
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/project-settings.js — the instance's live, DB-backed knobs, and the
4
+ // typed accessor every reader goes through (task 1003279, goal 1000072).
5
+ //
6
+ // WHY THIS FILE EXISTS. Goal 1000072's rotting half needs a number — "untouched
7
+ // for how long?" — and task 1003280 states where it must live: "STALE TIMER —
8
+ // live, DB-backed", followed by "do not fork a second store". A knob an owner can
9
+ // change from a page cannot be an env var that needs a redeploy, and it must not
10
+ // become a THIRD place staleness is configured. So there is one table
11
+ // (project_settings, migration core_231) and one accessor, here.
12
+ //
13
+ // HOW THIS RELATES TO src/stale-timer.js — READ THIS BEFORE ADDING A THIRD TIMER.
14
+ // ADR 0226 collapsed four disagreeing answers to "how long may a CLAIM go silent"
15
+ // into one resolver. This file does not reopen that. The two timers measure
16
+ // different things, are owned by different surfaces, and have different
17
+ // consequences:
18
+ //
19
+ // src/stale-timer.js a CLAIM goes silent hours env the sweep RELEASES it
20
+ // this file work goes UNTOUCHED days DB a human is ASKED
21
+ //
22
+ // The distinction that keeps them from collapsing into each other: the claim timer
23
+ // is about a builder who stopped responding, and the machine acts alone. The rot
24
+ // timer is about work nobody picked up, and the machine only ever raises a
25
+ // question. One enforces, one reports — which is exactly the asymmetry ADR 0226's
26
+ // header names, applied one level up.
27
+ //
28
+ // This is why the rot read deliberately EXCLUDES tasks at 'active': a claimed task
29
+ // that has gone quiet is the claim timer's question, and reporting it here too
30
+ // would put the same rot in two inboxes with two different numbers — the precise
31
+ // failure ADR 0226 exists to prevent.
32
+ //
33
+ // FAIL POSTURE: REPORTING, SO IT DEGRADES. Every caller of this file is a read
34
+ // surface (the needs-attention inbox, the settings page). A knob typed wrong shows
35
+ // up ON the screen of the person who can fix it, labelled, instead of 500-ing the
36
+ // inbox they were using to find the problem. Nothing here throws on a bad value;
37
+ // it reports one. (An enforcing caller would have to refuse instead — see the
38
+ // stale-timer header. There is no enforcing caller of the rot timer, by design.)
39
+
40
+ const { pool } = require('./pool');
41
+ // The structured logger rather than console.* — both are kernel files (this one is on
42
+ // fitness.js's KERNEL_FILES roster), a structured line is attributable and rides
43
+ // ADR 0022's redaction, and the quality ratchet counts bare console calls.
44
+ const { logger } = require('./logger');
45
+
46
+ // The knob names. Namespaced by the surface that owns them so a settings page can
47
+ // group rows without a second column, and so a future knob from another surface
48
+ // cannot collide with this one.
49
+ const SETTING_KEYS = {
50
+ ROT_DAYS: 'attention.rot_days',
51
+ };
52
+
53
+ // The last-resort rot timer, in days. 30 because that is the shortest window in
54
+ // which "nobody has touched this" is evidence of rot rather than evidence of a
55
+ // normal week — a fortnight would fire on every ordinary holiday, and a quarter is
56
+ // long enough that the backlog has already stopped being read.
57
+ const DEFAULT_ROT_DAYS = 30;
58
+
59
+ // The accepted range, in days. 1 because "yesterday" is the shortest question worth
60
+ // asking. 365 because past a year the timer is an off switch, and an off switch
61
+ // should be asked for out loud rather than typed as a big number — the same
62
+ // reasoning, and the same sentence, as the 720h bound in src/stale-timer.js.
63
+ const MIN_ROT_DAYS = 1;
64
+ const MAX_ROT_DAYS = 365;
65
+
66
+ // Parse one candidate into whole days, or say why it is unusable. Pure, never
67
+ // throws: both the DB tier and the override tier want to decide for themselves what
68
+ // an unusable value means. Mirrors parseHours in src/stale-timer.js deliberately —
69
+ // two timers with two different range checks would drift into two different ideas
70
+ // of what "valid" means.
71
+ function parseDays(raw) {
72
+ if (raw == null || raw === '') return { reason: 'empty' };
73
+ const n = Number(raw);
74
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
75
+ if (!Number.isInteger(n)) return { reason: 'must be a whole number of days' };
76
+ if (n <= 0) return { reason: 'must be positive' };
77
+ if (n < MIN_ROT_DAYS || n > MAX_ROT_DAYS) {
78
+ return { reason: `out of range (${MIN_ROT_DAYS}..${MAX_ROT_DAYS})` };
79
+ }
80
+ return { days: n };
81
+ }
82
+
83
+ // getSetting(key) — the raw row, or null when the key has never been set.
84
+ // NULL IS MEANINGFUL: absence means "never configured, use the code default", which
85
+ // is what lets a fresh instance boot with an empty table and what lets the settings
86
+ // page tell "never touched" apart from "deliberately set to the default value".
87
+ // Never seed a default row to avoid the null.
88
+ async function getSetting(key) {
89
+ const { rows } = await pool.query(
90
+ 'SELECT key, value, updated_at, updated_by FROM project_settings WHERE key = $1',
91
+ [key]
92
+ );
93
+ return rows[0] || null;
94
+ }
95
+
96
+ // setSetting(key, value, builderId) — upsert one knob, returning the stored row.
97
+ // The value is stored verbatim as text and validated by the typed resolver at READ
98
+ // time, not here, so a value that was valid when written and later fell outside a
99
+ // tightened range degrades on the screen rather than becoming unreadable.
100
+ // builderId may be null for a system path with no builder in the request.
101
+ async function setSetting(key, value, builderId = null) {
102
+ const { rows } = await pool.query(
103
+ `INSERT INTO project_settings (key, value, updated_by, updated_at)
104
+ VALUES ($1, $2, $3, now())
105
+ ON CONFLICT (key) DO UPDATE
106
+ SET value = EXCLUDED.value,
107
+ updated_by = EXCLUDED.updated_by,
108
+ updated_at = now()
109
+ RETURNING key, value, updated_at, updated_by`,
110
+ [key, String(value), builderId]
111
+ );
112
+ return rows[0];
113
+ }
114
+
115
+ // resolveRotDays({ override }) — the project's rot timer and where the number came
116
+ // from, for any surface that reports staleness.
117
+ //
118
+ // Precedence, widest-scope last (the same shape as resolveStaleClaimHours):
119
+ // 1. override — a per-request lens (?rot_days=). Someone asking "what would 7
120
+ // days show me?" is not reconfiguring the project, so this never
121
+ // becomes the project's answer and is never written back.
122
+ // 2. the DB — project_settings['attention.rot_days']. THIS is the project's
123
+ // configured timer, the row task 1003280's page writes.
124
+ // 3. default — DEFAULT_ROT_DAYS.
125
+ //
126
+ // Returns { days, source, invalid }:
127
+ // days — always a usable positive integer, whatever went wrong
128
+ // source — 'override' | 'project_settings' | 'default'
129
+ // invalid — null, or { where, raw, reason }: something was SET and unusable.
130
+ // `days` has already fallen through to the next tier; a reporting
131
+ // caller should say so on screen.
132
+ //
133
+ // A DB that is unreachable is NOT an invalid value — it is a failure to read, and
134
+ // this returns the default with source 'default' rather than taking the inbox down.
135
+ // The inbox is how someone would notice the database is unhappy.
136
+ async function resolveRotDays({ override } = {}) {
137
+ if (override != null && override !== '') {
138
+ const got = parseDays(override);
139
+ if (got.days != null) return { days: got.days, source: 'override', invalid: null };
140
+ return {
141
+ days: DEFAULT_ROT_DAYS,
142
+ source: 'default',
143
+ invalid: { where: 'override', raw: String(override), reason: got.reason },
144
+ };
145
+ }
146
+
147
+ let row = null;
148
+ try {
149
+ row = await getSetting(SETTING_KEYS.ROT_DAYS);
150
+ } catch (err) {
151
+ logger.error(`project_settings read failed; falling back to the default rot timer: ${err && err.message}`);
152
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
153
+ }
154
+
155
+ if (row && row.value != null && row.value !== '') {
156
+ const got = parseDays(row.value);
157
+ if (got.days != null) return { days: got.days, source: 'project_settings', invalid: null };
158
+ return {
159
+ days: DEFAULT_ROT_DAYS,
160
+ source: 'default',
161
+ invalid: { where: `project_settings['${SETTING_KEYS.ROT_DAYS}']`, raw: String(row.value), reason: got.reason },
162
+ };
163
+ }
164
+
165
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
166
+ }
167
+
168
+ // describeRotTimer(resolved) — one sentence naming the timer and its provenance, for
169
+ // an API receipt or a settings row. A number with no provenance is what let a 6h and
170
+ // a 24h coexist unnoticed for months (ADR 0226); every surface that prints the rot
171
+ // timer prints this instead of printing the bare number.
172
+ function describeRotTimer(resolved) {
173
+ const r = resolved || {};
174
+ const base = `${r.days}d (${r.source})`;
175
+ if (!r.invalid) return base;
176
+ return `${base} — ignoring ${r.invalid.where}="${r.invalid.raw}" (${r.invalid.reason})`;
177
+ }
178
+
179
+ // parseDays is exported for the tests only — the range check and the default are
180
+ // asserted against the SAME constants the resolver reads, because a test that
181
+ // re-typed 30 or 365 would be the second copy of the number this file exists to
182
+ // keep singular.
183
+ module.exports = {
184
+ SETTING_KEYS,
185
+ DEFAULT_ROT_DAYS,
186
+ MIN_ROT_DAYS,
187
+ MAX_ROT_DAYS,
188
+ parseDays,
189
+ getSetting,
190
+ setSetting,
191
+ resolveRotDays,
192
+ describeRotTimer,
193
+ };
package/src/module-api.js CHANGED
@@ -17,6 +17,17 @@ const { pool, instanceDbName } = require('./bongos/pool');
17
17
  const branding = require('./branding');
18
18
  const instanceConfig = require('./instance-config');
19
19
  const staleTimer = require('./stale-timer');
20
+ // Destructured, not namespaced, deliberately — the same form src/bongos/routes/
21
+ // builders.js uses for stale-timer.js. knip attributes a named export to a
22
+ // destructured binding but not to a property read off a required namespace, so the
23
+ // namespace form reports every export here as dead and regresses the ratchet.
24
+ const {
25
+ resolveRotDays,
26
+ SETTING_KEYS: PROJECT_SETTING_KEYS,
27
+ getSetting: getProjectSetting,
28
+ setSetting: setProjectSetting,
29
+ describeRotTimer,
30
+ } = require('./bongos/project-settings');
20
31
  const seams = require('./module-seams');
21
32
  const { buildInfo } = require('./build-info');
22
33
  const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePagination, pageMeta, PAGINATION } = require('./bongos/routes/_helpers');
@@ -34,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
46
  // the entry to that file. Look for a version's history there, not here.
36
47
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.484'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.486'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
49
 
39
50
  // A namespaced logger so a module's log lines are attributable + consistent.
40
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -250,6 +261,23 @@ module.exports = {
250
261
  // a direct require in scripts/ rather than joining the published surface.
251
262
  resolveStaleClaimHours: staleTimer.resolveStaleClaimHours,
252
263
  staleClaimMinutes: staleTimer.staleClaimMinutes,
264
+ // --- the project's ROT TIMER + the knob store behind it (task 1003279, goal
265
+ // 1000072) — how long a GOAL or TASK may go untouched before the hall's
266
+ // needs-attention inbox raises a line about it. A DIFFERENT question from the
267
+ // stale-claim timer directly above, and the two must not be confused: that one
268
+ // counts hours, is read from the env, is about a builder who went quiet, and
269
+ // the sweeper ACTS on it alone. This one counts days, is read from the DB
270
+ // (task 1003280 puts it on a settings page, and "do not fork a second store"),
271
+ // is about work nobody picked up, and it only ever ASKS a human. The rot read
272
+ // deliberately skips tasks at 'active' so the two never report the same rot
273
+ // with two different numbers. See src/bongos/project-settings.js.
274
+ resolveRotDays,
275
+ projectSettings: {
276
+ KEYS: PROJECT_SETTING_KEYS,
277
+ get: getProjectSetting,
278
+ set: setProjectSetting,
279
+ describeRotTimer,
280
+ },
253
281
  readConfigFileSync: instanceConfig.readConfigFileSync,
254
282
  configHome: instanceConfig.configHome,
255
283
  configPath: instanceConfig.configPath,
@@ -0,0 +1,274 @@
1
+ // tests/hall_settings_world.mjs — the PANEL family in the chrome world
2
+ // (task 1003308, goal 1000081): settings, profile, pair, not-ready.
3
+ //
4
+ // tests/hall_tokens.mjs pins the TIER these pages spend; this pins how they
5
+ // spend it. Everything here is a fact that is silent in a browser: the page
6
+ // still renders, it just renders wrong, or it renders right on one machine and
7
+ // fails a floor on the reviewer's. Each assertion is something the redesign
8
+ // measured and would otherwise have to re-measure by hand every time.
9
+ //
10
+ // What it locks:
11
+ // * ONE ARCHETYPE SHEET. panel.css holds the row, the switch, the field, the
12
+ // chip control and the family's voices; the four page sheets hold only what
13
+ // one page has. Every page links panel.css BEFORE its own sheet.
14
+ // * THE ONE h1. None of the five had one — the hall predates the floor — so
15
+ // every render failed one-header-main-h1. The h1 is the page's name in the
16
+ // room voice, and it lives in the static markup so the DOM has it in every
17
+ // state, not only the happy path.
18
+ // * THE SWITCH IS A SWITCH. A toggle is role=switch with aria-checked, drawn
19
+ // as a 44x24 track inside a 46x38 target; the page scripts keep reading
20
+ // data-on, which they wrote. The wandering pick is a radio row, not four
21
+ // "Choose" buttons.
22
+ // * THE 24px FLOOR ON BOTH AXES. .craft-pref was 13x13 — the smallest control
23
+ // in the hall; .profile__disc-edit (123x19) and .profile__needs-act (18px)
24
+ // on the Standing card belonged to this family and are bought up with the
25
+ // prose-link recipe.
26
+ // * WHITE ON THE ACCENT, AND THE ONE EXCEPTION. Eight literals across three
27
+ // sheets are gone; the Discord blurple pair stays, because it is Discord's
28
+ // mark and not the world's, and it is named as the exception in a comment.
29
+ // * THE COMPAT ALIASES. Five sheets, zero aliases spent.
30
+ //
31
+ // Reproduce the renders these came from (the family needs the fixture harness —
32
+ // every panel is filled from /me/* reads the kit stub does not carry):
33
+ // node scripts/hall-preview/server.js --port 4621 --fixture-me
34
+ // node modules/ui-design/kit/render.js --states modules/hall-ui/public/settings.states.json \
35
+ // --base-in http://builders.localhost:4621 --base-out http://builders.localhost:4621
36
+ // (and profile / pair / not-ready .states.json the same way)
37
+
38
+ import { test } from 'node:test';
39
+ import assert from 'node:assert/strict';
40
+ import fs from 'node:fs';
41
+ import path from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
45
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
46
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
47
+ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
48
+
49
+ const PAGES = ['settings', 'profile', 'pair', 'not-ready'];
50
+ const SHEETS = ['panel.css', 'settings.css', 'pair.css', 'profile.css'];
51
+
52
+ const panelCss = read('panel.css');
53
+ const settingsCss = read('settings.css');
54
+ const styleCss = read('style.css');
55
+ const settingsJs = read('settings.js');
56
+
57
+ // The body of the first rule whose selector LIST contains `selector` as one of
58
+ // its comma-separated members — the family sheet groups the page scripts' own
59
+ // names onto one rule, so a rule is found by membership, not by a bare prefix.
60
+ function ruleOf(css, selector) {
61
+ // an @media wrapper is unwrapped so the rules inside it scan as flat rules;
62
+ // the FIRST match wins, which is the unwrapped one for a selector that also
63
+ // has a breakpoint override
64
+ const bare = stripComments(css).replace(/@media[^{]*\{/g, '');
65
+ // a lookbehind, not a consuming group: a consumed `}` would hide every other rule
66
+ const re = /(?<=^|[}])\s*([^{}]+?)\s*\{([^}]*)\}/g;
67
+ let m;
68
+ while ((m = re.exec(bare))) {
69
+ const members = m[1].split(',').map((s) => s.trim());
70
+ if (members.includes(selector)) return m[2];
71
+ }
72
+ return null;
73
+ }
74
+
75
+ // ---- 1. one archetype sheet, linked before the page's own -------------------
76
+
77
+ test('every page in the family links panel.css before its own sheet', () => {
78
+ for (const p of PAGES) {
79
+ const html = read(`${p}.html`);
80
+ const links = [...html.matchAll(/<link rel="stylesheet" href="\/builders\/([a-z-]+\.css)[^"]*">/g)].map((x) => x[1]);
81
+ const at = links.indexOf('panel.css');
82
+ assert.ok(at > -1, `${p}.html links panel.css`);
83
+ assert.ok(links.indexOf('style.css') < at, `${p}.html: the shell first, then the archetype`);
84
+ const own = `${p}.css`;
85
+ if (links.includes(own)) assert.ok(links.indexOf(own) > at, `${p}.html: the page sheet after the archetype`);
86
+ }
87
+ assert.ok(read('profile.html').includes('/builders/profile.css'), 'the profile has a sheet of its own now');
88
+ });
89
+
90
+ test('the archetype holds the row, the switch, the field and the voices; the page sheets do not redefine them', () => {
91
+ for (const sel of ['.prow', '.sw', '.sw__track', '.sw__knob', '.field-label', '.pinput', '.chip-ctl', '.panel-note', '.panel-status', '.gate']) {
92
+ assert.ok(ruleOf(panelCss, sel), `panel.css defines ${sel}`);
93
+ }
94
+ for (const sheet of ['settings.css', 'pair.css', 'profile.css']) {
95
+ const css = read(sheet);
96
+ for (const sel of ['.prow', '.sw', '.sw__track', '.pinput', '.chip-ctl']) {
97
+ assert.equal(ruleOf(css, sel), null, `${sheet} must not redefine ${sel} — that is how the family would drift`);
98
+ }
99
+ }
100
+ });
101
+
102
+ // ---- 2. the one h1, in the static markup ----------------------------------
103
+
104
+ test('each of the five pages has exactly one h1, in the family\'s page head', () => {
105
+ for (const p of PAGES) {
106
+ const html = read(`${p}.html`).replace(/<!--[\s\S]*?-->/g, '');
107
+ const h1s = html.match(/<h1\b/g) || [];
108
+ assert.equal(h1s.length, 1, `${p}.html: one-header-main-h1 — the page had none until task 1003308`);
109
+ assert.match(html, /<div class="page-head page-head--room page-head--panel">\s*(<p class="page-head__eyebrow">[^<]*<\/p>\s*)?<h1 id="[a-z-]+-h1" class="page-head__h">/,
110
+ `${p}.html: the h1 is the page's name in the room voice, static so every state has it`);
111
+ }
112
+ assert.match(ruleOf(panelCss, '.page-head--room.page-head--panel') || '', /padding-top:\s*0/,
113
+ 'the family takes the room VOICE, not its top air — a form page starts at the content edge');
114
+ });
115
+
116
+ // ---- 3. the switch, the radio, the chip: the 24px floor on both axes -------
117
+
118
+ test('a toggle is a role=switch with aria-checked, and the page script keeps data-on', () => {
119
+ assert.match(settingsJs, /function switchHtml\(/, 'one switch builder');
120
+ const fn = settingsJs.match(/function switchHtml\([\s\S]*?\n \}\n/);
121
+ assert.ok(fn, 'switchHtml is a whole function');
122
+ assert.match(fn[0], /role="switch"/, 'role=switch');
123
+ assert.match(fn[0], /aria-checked="\$\{on \? 'true' : 'false'\}"/, 'state is aria-checked');
124
+ assert.match(fn[0], /data-on="\$\{on \? '1' : '0'\}"/, 'and data-on stays, because every delegated click handler reads it');
125
+ assert.match(fn[0], /sw__track[\s\S]*sw__knob/, 'drawn as a track and a knob');
126
+ assert.doesNotMatch(settingsJs, /aria-pressed/, 'no aria-pressed toggles are left on the page');
127
+ for (const fnName of ['renderSounds', 'renderRender', 'renderCrossProject', 'privacyRow']) {
128
+ const body = settingsJs.match(new RegExp(`function ${fnName}\\([\\s\\S]*?\\n \\}\\n`));
129
+ assert.ok(body && /switchHtml\(/.test(body[0]), `${fnName} renders the switch`);
130
+ }
131
+ });
132
+
133
+ test('the switch clears the floor: a 46x38 target drawing a 44x24 track', () => {
134
+ const sw = ruleOf(panelCss, '.sw');
135
+ assert.ok(sw, '.sw has a rule');
136
+ assert.match(sw, /width:\s*46px/, 'the target is 46 wide');
137
+ assert.match(sw, /height:\s*38px/, 'and 38 tall — this is what the floor measures');
138
+ const track = ruleOf(panelCss, '.sw__track');
139
+ assert.match(track, /width:\s*44px/, 'the drawn track stays the world\'s small shape');
140
+ assert.match(track, /height:\s*24px/);
141
+ const on = ruleOf(panelCss, '.sw[aria-checked="true"] .sw__track');
142
+ assert.match(on, /background:\s*var\(--accent-tint\)/, 'on is the tint, the accent arrives on the knob');
143
+ });
144
+
145
+ test('the wandering pick is a radio row, wired on change', () => {
146
+ const render = settingsJs.match(/function renderWandering\([\s\S]*?\n \}\n/);
147
+ assert.ok(render, 'renderWandering is a whole function');
148
+ assert.match(render[0], /<label class="prow prow--pick" for="\$\{id\}"/, 'the whole row is the radio\'s label');
149
+ assert.match(render[0], /<input class="pradio" type="radio" name="wandering"/, 'a real radio group');
150
+ assert.match(render[0], /locked \? ' disabled' : ''/, 'a level above the rank ceiling is a disabled radio');
151
+ assert.doesNotMatch(render[0], /Rank-locked<\/button>|Choose<\/button>/, 'the four "Choose" buttons are gone');
152
+ const load = settingsJs.match(/async function loadWandering\([\s\S]*?\n \}\n/);
153
+ assert.match(load[0], /addEventListener\('change'/, 'a radio fires change, not click');
154
+ assert.match(load[0], /closest\('\.pradio'\)/);
155
+ const radio = ruleOf(panelCss, '.pradio');
156
+ assert.match(radio, /width:\s*24px/);
157
+ assert.match(radio, /height:\s*24px/, 'a native radio is 13px; the floor is 24');
158
+ });
159
+
160
+ test('.craft-pref is a 24px target with a 14px drawn box — it was 13x13, the smallest control in the hall', () => {
161
+ const box = ruleOf(panelCss, '.craft-pref');
162
+ assert.ok(box, '.craft-pref has a rule');
163
+ assert.match(box, /width:\s*24px/);
164
+ assert.match(box, /height:\s*24px/);
165
+ assert.match(box, /appearance:\s*none/, 'the native box has to go, or sizing it up sizes the glyph up too');
166
+ const before = ruleOf(panelCss, '.craft-pref::before');
167
+ assert.match(before, /width:\s*14px/, 'the drawn box stays 14px inside the 24px target');
168
+ const check = ruleOf(panelCss, '.craft-pref:checked::after');
169
+ assert.match(check, /border:\s*solid var\(--accent-on\)/, 'the tick is the dark ink on the salmon, never white');
170
+ });
171
+
172
+ test('the Standing card\'s two under-floor links buy both axes and hand the room back', () => {
173
+ for (const sel of ['.profile__disc-edit', '.profile__needs-act']) {
174
+ const r = ruleOf(styleCss, sel);
175
+ assert.ok(r, `${sel} has a rule`);
176
+ assert.match(r, /display:\s*inline-block/, `${sel}: inline-block, so min-height applies to a run of text`);
177
+ assert.match(r, /min-height:\s*24px/, `${sel}: the height half of the floor (it measured 19px and 18px)`);
178
+ assert.match(r, /min-width:\s*24px/, `${sel}: the width half`);
179
+ assert.match(r, /padding:\s*3px 4px/, `${sel}: the row recipe`);
180
+ assert.match(r, /margin:\s*-3px -4px/, `${sel}: the negative margin gives the padding back to the line`);
181
+ }
182
+ });
183
+
184
+ // ---- 4. the world's own bans -------------------------------------------------
185
+
186
+ test('no sheet in the family spends a compat alias', () => {
187
+ const aliases = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
188
+ for (const sheet of SHEETS) {
189
+ const bare = stripComments(read(sheet));
190
+ const spent = aliases.filter((a) => new RegExp(`var\\(\\s*${a}\\s*[,)]`).test(bare));
191
+ assert.deepEqual(spent, [], `${sheet}: the aliases exist for un-reskinned pages, so spending one here opts the sheet out of the world`);
192
+ }
193
+ });
194
+
195
+ test('white on the accent is gone, and the Discord mark is the one named exception', () => {
196
+ for (const sheet of ['panel.css', 'pair.css', 'profile.css']) {
197
+ const bare = stripComments(read(sheet));
198
+ assert.doesNotMatch(bare, /#[0-9a-f]{3,8}\b/i, `${sheet} carries no colour literal`);
199
+ assert.doesNotMatch(bare, /:\s*white\b/i, `${sheet} carries no named white`);
200
+ }
201
+ const bare = stripComments(settingsCss);
202
+ const literals = [...bare.matchAll(/#[0-9a-f]{3,8}\b/gi)].map((m) => m[0].toLowerCase());
203
+ assert.deepEqual([...new Set(literals)].sort(), ['#4853c4', '#5865f2', '#ffffff'],
204
+ 'settings.css: the blurple pair and its white are the only literals, and they are Discord\'s');
205
+ for (const lit of literals) {
206
+ // every literal sits inside a .settings-btn--discord rule
207
+ const idx = bare.indexOf(lit);
208
+ const ruleStart = bare.lastIndexOf('{', idx);
209
+ const selector = bare.slice(bare.lastIndexOf('}', ruleStart) + 1, ruleStart);
210
+ assert.match(selector, /\.settings-btn--discord/, `${lit} is spent only on the Discord button`);
211
+ }
212
+ assert.match(settingsCss, /THIRD-PARTY MARK, KEPT ON PURPOSE/, 'the exception is named in a comment so a literal sweep leaves it alone');
213
+ // the save/accent buttons take --accent-on. `.pair-approve` and `.craft-save`
214
+ // are members of the base pill rule AND the save rule, so the save rule is
215
+ // found by its own name and the two are asserted as its members.
216
+ const save = ruleOf(panelCss, '.pbtn--save');
217
+ assert.ok(save, '.pbtn--save has a rule');
218
+ assert.match(save, /background:\s*var\(--accent\)/);
219
+ assert.match(save, /color:\s*var\(--accent-on\)/, 'the dark ink for the salmon — white on it fails AA');
220
+ const saveSelector = stripComments(panelCss).match(/([^{}]*\.pbtn--save[^{}]*)\{/);
221
+ assert.ok(saveSelector, 'the save rule\'s selector list is readable');
222
+ for (const member of ['.craft-save', '.pair-approve']) {
223
+ assert.ok(saveSelector[1].split(',').map((s) => s.trim()).includes(member), `${member} is the accent door on its page`);
224
+ }
225
+ });
226
+
227
+ // ---- 5. the token is quoted, the project is a row, the profile is a band ----
228
+
229
+ test('the CLI token is quoted — one tonal step down, wrapping anywhere', () => {
230
+ const fn = settingsJs.match(/async function issueCliToken\([\s\S]*?\n \}\n/);
231
+ assert.ok(fn, 'issueCliToken is a whole function');
232
+ assert.match(fn[0], /class="quote"/, 'the result renders the quote');
233
+ assert.match(fn[0], /<pre class="quote__pre">\$\{escapeHtml\(body\.paste_command \|\| ''\)\}<\/pre>/, 'the secret is escaped into the pre');
234
+ assert.doesNotMatch(fn[0], /style="/, 'no inline style on the foot');
235
+ const pre = ruleOf(settingsCss, '.quote__pre');
236
+ assert.match(pre, /overflow-wrap:\s*anywhere/, 'a 64-character token wraps inside its quote instead of widening the panel');
237
+ assert.match(ruleOf(settingsCss, '.quote') || '', /background:\s*var\(--glass\)/, 'one tonal step down from the panel');
238
+ });
239
+
240
+ // The hall's my-projects page is GONE (owner decision, task 1003308): "my
241
+ // projects" is a user-level page that already lives on the hub, so the hall
242
+ // carries neither the page nor a link nor a redirect to it.
243
+ test('the hall no longer ships a my-projects page', () => {
244
+ for (const f of ['my-projects.html', 'my-projects.js', 'my-projects.css', 'my-projects.states.json']) {
245
+ assert.ok(!fs.existsSync(path.join(HALL, f)), `${f} is gone — the page is the hub's`);
246
+ }
247
+ for (const f of ['shell.js', 'palette.js']) {
248
+ assert.doesNotMatch(read(f), /my-projects/, `${f} does not link the page`);
249
+ }
250
+ });
251
+
252
+ test('the profile identity band is a MODIFIER on the shell\'s .profile, never a second base rule', () => {
253
+ assert.match(read('profile.html'), /<div class="profile profile--ident" id="profile-head">/, 'the band opts in from its markup');
254
+ const css = read('profile.css');
255
+ assert.ok(ruleOf(css, '.profile--ident'), 'profile.css styles the modifier');
256
+ assert.equal(ruleOf(css, '.profile'), null, 'and never redeclares bare .profile — Home\'s Standing card shares it');
257
+ assert.match(ruleOf(css, '.profile--ident .profile__avatar') || '', /var\(--skeleton\)/, 'the avatar placeholder is the skeleton tone, not --bg-deep (pure black in dark)');
258
+ assert.match(ruleOf(css, '.ach') || '', /background:\s*var\(--bg-card\)/, 'a shelf tile on the bare section is the panel, not a card in a card');
259
+ });
260
+
261
+ // ---- 6. the floors are reproducible ------------------------------------------
262
+
263
+ test('all five pages have a states file, so the floors are reproducible', () => {
264
+ for (const p of PAGES) {
265
+ const json = JSON.parse(read(`${p}.states.json`));
266
+ assert.equal(json.surface, 'hall-ui', `${p}.states.json names its surface`);
267
+ assert.ok(Object.keys(json.states).length >= 2, `${p}.states.json renders more than the default state`);
268
+ }
269
+ assert.ok(Object.keys(JSON.parse(read('settings.states.json')).states).length >= 4, 'settings renders every rail group');
270
+ for (const fx of ['me__sessions', 'auth__app-pair__info']) {
271
+ assert.ok(fs.existsSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', `${fx}.json`)),
272
+ `the harness serves ${fx} — without it the page only ever rendered its empty state`);
273
+ }
274
+ });
@@ -94,19 +94,11 @@ test('the relay sends the composed note and the applicant’s name — not a con
94
94
  assert.match(join, /note: \{ type: 'string', maxLength: APPLICANT_NOTE_MAX \}/, 'the note is declared in the route’s schema');
95
95
  });
96
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');
97
+ // The hall's my-projects page (the join box + its click handler) was REMOVED in
98
+ // task 1003308 — "my projects" is a user-level page that lives on the hub, and
99
+ // the hall keeps no copy. The route contract above is the whole surface now;
100
+ // the hub's own join form is pinned by tests/public_landing_projects.mjs.
101
+ test('the hall no longer carries a join box of its own', () => {
102
+ assert.ok(!fs.existsSync(path.join(ROOT, 'modules/hall-ui/public/my-projects.html')), 'the hall page is gone');
103
+ assert.ok(!fs.existsSync(path.join(ROOT, 'modules/hall-ui/public/my-projects.js')), 'and its script with it');
112
104
  });
@@ -126,7 +126,7 @@ test('platform-identity joined the roster (task 1002322) — the catalog territo
126
126
  'modules/platform-identity/platform-identity.js', // visibility column + catalog domain
127
127
  'modules/platform-identity/routes/projects.js', // the feed + invite routes
128
128
  'modules/status-ui/public/explore.js', // orb render-type surface
129
- 'modules/hall-ui/public/my-projects.js', // owner settings / applications queue
129
+ 'modules/hall-ui/public/settings.js', // the builder's own settings (my-projects.js left the hall in task 1003308)
130
130
  'modules/public-landing/public/projects.html', // the creation step, ?view=new since task 1002763
131
131
  'modules/provisioning/', // module-bundle selection at provision time
132
132
  ]) {
@@ -34,6 +34,7 @@ const PUBLISHED_SURFACE = [
34
34
  'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
35
35
  'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
36
36
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
37
+ 'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
37
38
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
38
39
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
39
40
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',