@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.
- package/.bongos-core.json +102 -52
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0232-rot-is-derived-not-swept-and-water-is-the-only-new-verb.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +147 -3
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +169 -178
- package/docs/copy-registry.json +295 -405
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_231_project_settings_and_attention_water.sql +163 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/hall-ui/public/goal-inbox.js +119 -1
- package/modules/hall-ui/public/not-ready.html +14 -10
- package/modules/hall-ui/public/not-ready.states.json +14 -0
- package/modules/hall-ui/public/pair.css +17 -67
- package/modules/hall-ui/public/pair.html +8 -1
- package/modules/hall-ui/public/pair.states.json +14 -0
- package/modules/hall-ui/public/panel.css +333 -0
- package/modules/hall-ui/public/profile.css +85 -0
- package/modules/hall-ui/public/profile.html +12 -5
- package/modules/hall-ui/public/profile.states.json +15 -0
- package/modules/hall-ui/public/settings.css +119 -336
- package/modules/hall-ui/public/settings.html +28 -19
- package/modules/hall-ui/public/settings.js +44 -35
- package/modules/hall-ui/public/settings.states.json +16 -0
- package/modules/hall-ui/public/style.css +5 -2
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/rot.js +210 -0
- package/modules/lifecycle/routes/rot.js +160 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-vocabulary.js +0 -1
- package/scripts/gds/fitness.js +9 -0
- package/src/bongos/project-settings.js +193 -0
- package/src/module-api.js +29 -1
- package/tests/hall_settings_world.mjs +274 -0
- package/tests/join_application_note.mjs +7 -15
- package/tests/module-scope-map.mjs +1 -1
- package/tests/module_api.mjs +1 -0
- package/tests/rot_inbox.mjs +532 -0
- package/modules/hall-ui/public/my-projects.css +0 -62
- package/modules/hall-ui/public/my-projects.html +0 -73
- 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.
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
assert.
|
|
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/
|
|
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
|
]) {
|
package/tests/module_api.mjs
CHANGED
|
@@ -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',
|